mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
5199 lines
206 KiB
TypeScript
5199 lines
206 KiB
TypeScript
import {
|
||
AllocationLoadType,
|
||
LoadingStatus,
|
||
SchedulingStatus,
|
||
TrainCheckpointKind,
|
||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||
WagonMovementKind,
|
||
WagonStatus,
|
||
} from '@edr/types';
|
||
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
Optional,
|
||
} from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { InjectDataSource } from '@nestjs/typeorm';
|
||
import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm';
|
||
|
||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||
import { Booking } from '../bookings/entities/booking.entity';
|
||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||
import { Container } from '../container-management/entities/container.entity';
|
||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
|
||
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
|
||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||
import {
|
||
ImportDjiboutiOperation,
|
||
type ImportDjiboutiDocumentType,
|
||
} from './entities/import-djibouti-operation.entity';
|
||
import {
|
||
ImportDjiboutiActionDto,
|
||
UploadImportDjiboutiDocumentDto,
|
||
} from './dto/import-djibouti-operation.dto';
|
||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||
import { type BookingWindowConfig } from './booking-window.config';
|
||
import { BookingWindowGateway } from './booking-window.gateway';
|
||
import { BookingNotifierService } from './booking-notifier.service';
|
||
import {
|
||
buildCappedWagonPlan,
|
||
computeFleetAvailability,
|
||
selectBookingsWithinFleetCap,
|
||
summarizeFleetWarnings,
|
||
totalAssignedWeight,
|
||
wagonsRequiredForBooking,
|
||
type DeferredBookingRow,
|
||
type FleetAvailabilityRow,
|
||
} from './fleet-plan.util';
|
||
import {
|
||
buildBulkWagonPlan,
|
||
buildContainerWagonPlan,
|
||
buildMixedWagonPlan,
|
||
expandBookingContainerUnits,
|
||
getContainerSlotSequenceNos,
|
||
roundTons,
|
||
sumWagonsRequired,
|
||
type TrainLimitConfig,
|
||
validateContainerPlacements,
|
||
validateMixedTrainLimits,
|
||
validateTrainLimits,
|
||
type ContainerPlacementInput,
|
||
type WagonPlanSlot,
|
||
} from './wagon-plan.util';
|
||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||
import {
|
||
deriveTrainCapacityFromLocomotive,
|
||
minLocomotiveLimits,
|
||
wagonTypeDimensionsFromEntity,
|
||
WagonTypeDimensions,
|
||
} from './train-capacity.util';
|
||
import {
|
||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||
DEFAULT_BULK_WAGON_TARE_TONS,
|
||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||
} from './booking-batch.constants';
|
||
import {
|
||
computeExportWindowTimes,
|
||
computeImportWindowTimes,
|
||
earliestSchedulableDeparture,
|
||
eatDay,
|
||
} from './batch-window.util';
|
||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||
import { BookingJourneyService } from './booking-journey.service';
|
||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
|
||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||
import {
|
||
autoFillPlacements,
|
||
findMissingContainerNumberIssues,
|
||
isPlaceholderContainerNumber,
|
||
placementsForBookings,
|
||
type ContainerUnitForPlacement,
|
||
} from './container-placement.util';
|
||
|
||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||
|
||
/**
|
||
* The booking-window rule fields frozen onto a train schedule at creation (and
|
||
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
|
||
* its display cycles from this snapshot, so a later global-rules edit never redraws
|
||
* an already-open schedule's windows. The reopen gap is derived here — doc review +
|
||
* payment — because that is the real delay between a cycle closing and reopening.
|
||
*/
|
||
function windowRuleSnapshot(cfg: BookingWindowConfig) {
|
||
return {
|
||
ruleWindowOpenHour: cfg.windowOpenHour,
|
||
ruleWindowCloseHour: cfg.windowCloseHour,
|
||
ruleWindowDurationHours: cfg.windowDurationHours,
|
||
ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes,
|
||
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
|
||
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The booking-window config a specific schedule runs under: its frozen rule
|
||
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
|
||
* config, with the live config filling any snapshot field a legacy row lacks.
|
||
*
|
||
* The window SHAPE (hours, duration, lead, reopen gap) comes from the snapshot so
|
||
* the runtime cycle engine matches exactly what the board drew and the customer
|
||
* saw — a later global-rule edit must not retro-change an existing train. The
|
||
* doc-review / payment split is an internal process timing (not part of the
|
||
* window the customer sees) and is not stored split in the snapshot, so it always
|
||
* takes the live values; their sum is only used as a fallback reopen gap when the
|
||
* row predates `ruleReopenDelayMinutes`.
|
||
*/
|
||
export function effectiveWindowConfig(
|
||
schedule: {
|
||
ruleWindowOpenHour?: number | null;
|
||
ruleWindowCloseHour?: number | null;
|
||
ruleWindowDurationHours?: number | null;
|
||
ruleReopenDelayMinutes?: number | null;
|
||
ruleImportWindowLeadDays?: number | null;
|
||
ruleExportBookingLeadHours?: number | null;
|
||
},
|
||
liveCfg: BookingWindowConfig,
|
||
): BookingWindowConfig {
|
||
return {
|
||
importWindowLeadDays:
|
||
schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays,
|
||
exportBookingLeadHours:
|
||
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
|
||
windowOpenHour: schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
|
||
windowCloseHour: schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
|
||
windowDurationHours:
|
||
schedule.ruleWindowDurationHours != null
|
||
? Number(schedule.ruleWindowDurationHours)
|
||
: liveCfg.windowDurationHours,
|
||
docReviewMinutes: liveCfg.docReviewMinutes,
|
||
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
|
||
reopenDelayMinutes:
|
||
schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
|
||
};
|
||
}
|
||
|
||
export type BookingWagonAllocationStatus =
|
||
| 'NOT_ATTEMPTED'
|
||
| 'ASSIGNED'
|
||
| 'DEFERRED'
|
||
| 'FAILED';
|
||
|
||
export interface BookingWagonAllocationIssue {
|
||
bookingId: string;
|
||
status: BookingWagonAllocationStatus;
|
||
issue: string | null;
|
||
}
|
||
|
||
export interface WagonAllocationAttemptResult {
|
||
assignedBookingIds: string[];
|
||
deferred: DeferredBookingRow[];
|
||
issues: BookingWagonAllocationIssue[];
|
||
violations: string[];
|
||
}
|
||
|
||
export interface CompositionUnassignedBookingRow {
|
||
id: string;
|
||
reference: string | null;
|
||
freightType: string | null;
|
||
priorityScore: number;
|
||
cargoTotalWeightVgm: number;
|
||
status: string | null;
|
||
schedulingStatus: string | null;
|
||
wagonsRequired: number;
|
||
requiredWagonTypeCode: string;
|
||
yardWagonsAvailable: number;
|
||
canAssign: boolean;
|
||
blockReason: string | null;
|
||
}
|
||
|
||
export interface UnassignedBookingsResponse {
|
||
fleetAtOrigin: FleetAvailabilityRow[];
|
||
bookings: CompositionUnassignedBookingRow[];
|
||
}
|
||
|
||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||
maxWeightTons: 3500,
|
||
maxLengthMeters: 760,
|
||
maxWagonsPerTrain: Math.floor(760 / 14),
|
||
max20ftContainerWeightTons: 30,
|
||
max20ftPairWeightDiffTons: 10,
|
||
};
|
||
|
||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||
interface BookingWindowRow {
|
||
schedule_id: string;
|
||
reference: string | null;
|
||
contract_id: string | null;
|
||
contract_kind: string | null;
|
||
direction: string | null;
|
||
window_phase: string | null;
|
||
window_opens_at: Date | null;
|
||
window_closes_at: Date | null;
|
||
doc_review_ends_at: Date | null;
|
||
payment_phase_ends_at: Date | null;
|
||
booking_window_status: string;
|
||
booking_cycle_no: number;
|
||
scheduled_departure_date: Date;
|
||
origin_label: string | null;
|
||
origin_code: string | null;
|
||
destination_label: string | null;
|
||
destination_code: string | null;
|
||
}
|
||
|
||
@Injectable()
|
||
export class TrainSchedulingService {
|
||
private readonly logger = new Logger(TrainSchedulingService.name);
|
||
|
||
constructor(
|
||
@InjectDataSource()
|
||
private readonly dataSource: DataSource,
|
||
private readonly bookingsRepository: BookingsRepository,
|
||
private readonly locomotivesRepository: LocomotivesRepository,
|
||
private readonly wagonTypesRepository: WagonTypesRepository,
|
||
private readonly trainSchedulesRepository: TrainSchedulesRepository,
|
||
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
|
||
private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository,
|
||
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
|
||
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
|
||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||
private readonly bookingJourneyService: BookingJourneyService,
|
||
private readonly bookingNotifier: BookingNotifierService,
|
||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||
private readonly configService?: ConfigService,
|
||
) {}
|
||
|
||
/**
|
||
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||
*/
|
||
private async notifyScheduleBookings(
|
||
schedule: TrainSchedule,
|
||
event: 'dispatched' | 'arrived',
|
||
): Promise<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}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||
// resolving the schedule's route + day and filtering on the day instead.
|
||
let day: string | undefined;
|
||
let originStationId = query.originStationId;
|
||
let destinationStationId = query.destinationStationId;
|
||
if (query.trainScheduleId) {
|
||
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
|
||
if (schedule?.scheduledDepartureDate) {
|
||
day = eatDay(schedule.scheduledDepartureDate);
|
||
originStationId = originStationId ?? schedule.originStationId;
|
||
destinationStationId = destinationStationId ?? schedule.destinationStationId;
|
||
}
|
||
}
|
||
|
||
const bookings = await this.bookingsRepository.findEligibleForScheduling({
|
||
freightType: query.freightType,
|
||
originStationId,
|
||
destinationStationId,
|
||
schedulingStatus: query.schedulingStatus,
|
||
trainScheduleId: query.trainScheduleId,
|
||
day,
|
||
});
|
||
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
||
}
|
||
|
||
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
|
||
return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' });
|
||
}
|
||
|
||
async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) {
|
||
return this.getEligibleBookings({ ...query, freightType: 'BULK' });
|
||
}
|
||
|
||
async getTrainSchedulingGlobalRules() {
|
||
return this.loadGlobalRulesRow();
|
||
}
|
||
|
||
async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||
const row = await this.loadGlobalRulesRow();
|
||
if (!row) {
|
||
throw new NotFoundException('Train scheduling global rules not configured');
|
||
}
|
||
if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters;
|
||
if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons;
|
||
if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain;
|
||
if (dto.max20ftContainerWeightTons != null) {
|
||
row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons;
|
||
}
|
||
if (dto.max20ftPairWeightDiffTons != null) {
|
||
row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons;
|
||
}
|
||
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
|
||
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
||
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
|
||
if (dto.windowCloseHour != null) row.windowCloseHour = dto.windowCloseHour;
|
||
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
||
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
|
||
|
||
// The booking desk supports three shapes: a same-day range
|
||
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
|
||
// overnight range that wraps past midnight (openHour > closeHour, e.g.
|
||
// 08:00 → 07:00). officeHoursOpen handles all three, so no ordering guard.
|
||
|
||
// Fields that change the STAMPED open/close times of a schedule. docReview/
|
||
// payment/reopen are read live by the cron each tick, so they need no
|
||
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
|
||
const windowTimingChanged =
|
||
dto.importWindowLeadDays != null ||
|
||
dto.windowOpenHour != null ||
|
||
dto.windowCloseHour != null ||
|
||
dto.windowDurationHours != null ||
|
||
dto.docReviewMinutes != null ||
|
||
dto.paymentWindowMinutes != null ||
|
||
dto.exportBookingLeadHours != null;
|
||
|
||
const saved = await this.dataSource
|
||
.getRepository(TrainSchedulingGlobalRules)
|
||
.save(row);
|
||
|
||
// The cron reads config fresh every tick, so derived timings (doc review,
|
||
// payment, reopen) take effect on the next tick with no restart. But each
|
||
// schedule's initial open/close times were FROZEN at creation — re-stamp the
|
||
// ones whose window has not opened yet so a config edit applies to them too.
|
||
if (windowTimingChanged) {
|
||
await this.restampPendingWindows();
|
||
}
|
||
|
||
return saved;
|
||
}
|
||
|
||
/**
|
||
* Override the booking-window rule for ONE schedule (staff action on the ops
|
||
* board). Only the fields provided are changed; the rest keep the schedule's
|
||
* existing snapshot (falling back to the live global config for legacy rows).
|
||
* The window must not have opened yet — an OPEN/past schedule stays frozen so
|
||
* customers keep the times they were shown. windowOpensAt/ClosesAt are
|
||
* re-derived from the merged rule, and the snapshot is updated so the board
|
||
* draws the new cycles.
|
||
*/
|
||
async updateScheduleWindowRule(
|
||
id: string,
|
||
dto: UpdateScheduleWindowRuleDto,
|
||
): Promise<TrainSchedule> {
|
||
const schedule = await this.trainSchedulesRepository.findById(id);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||
}
|
||
if (schedule.windowPhase !== 'PRE_WINDOW') {
|
||
throw new BadRequestException(
|
||
'Booking window settings can only be changed before the window opens ' +
|
||
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
|
||
);
|
||
}
|
||
const now = new Date();
|
||
if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) {
|
||
throw new BadRequestException(
|
||
'This schedule has already departed or has no departure date.',
|
||
);
|
||
}
|
||
|
||
// Merge the override onto the schedule's current effective rule (its snapshot,
|
||
// or the live config where a legacy row has no snapshot).
|
||
const liveCfg = await this.getWindowConfig();
|
||
const merged: BookingWindowConfig = {
|
||
importWindowLeadDays:
|
||
dto.importWindowLeadDays ??
|
||
schedule.ruleImportWindowLeadDays ??
|
||
liveCfg.importWindowLeadDays,
|
||
exportBookingLeadHours:
|
||
dto.exportBookingLeadHours ??
|
||
schedule.ruleExportBookingLeadHours ??
|
||
liveCfg.exportBookingLeadHours,
|
||
windowOpenHour:
|
||
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
|
||
windowCloseHour:
|
||
dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
|
||
windowDurationHours:
|
||
dto.windowDurationHours ??
|
||
(schedule.ruleWindowDurationHours != null
|
||
? Number(schedule.ruleWindowDurationHours)
|
||
: liveCfg.windowDurationHours),
|
||
// The reopen gap is doc review + payment; keep the config values unless the
|
||
// override changes them, so the derived snapshot delay stays consistent.
|
||
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
|
||
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
|
||
reopenDelayMinutes: liveCfg.reopenDelayMinutes,
|
||
};
|
||
|
||
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
|
||
// — officeHoursOpen resolves each, so no close-vs-open ordering guard here.
|
||
|
||
const times =
|
||
schedule.direction === 'EXPORT'
|
||
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
|
||
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
|
||
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
|
||
throw new BadRequestException(
|
||
'These settings leave no booking window before departure — with the ' +
|
||
'desk hours applied, the window would only open once the train has left.',
|
||
);
|
||
}
|
||
|
||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||
windowOpensAt: times.windowOpensAt,
|
||
windowClosesAt: times.windowClosesAt,
|
||
...windowRuleSnapshot(merged),
|
||
});
|
||
this.logger.log(
|
||
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
|
||
);
|
||
void this.emitWindowState(id);
|
||
|
||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||
return fresh ?? schedule;
|
||
}
|
||
|
||
/**
|
||
* Reschedule ONE train's departure date (staff action on the ops board). Only
|
||
* allowed while the booking window has not opened yet — an OPEN/past schedule
|
||
* stays frozen so customers keep the times they were shown. The new date must
|
||
* still leave room for the booking lead window before departure (same floor as
|
||
* schedule creation); INTERCITY/DOMESTIC uses the import lead. The window
|
||
* open/close times are re-derived from the schedule's existing rule snapshot.
|
||
*/
|
||
async updateScheduleDate(
|
||
id: string,
|
||
dto: UpdateScheduleDateDto,
|
||
): Promise<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()}).`,
|
||
);
|
||
}
|
||
|
||
// Re-derive the window from the schedule's own rule snapshot (falling back to
|
||
// the live config where a legacy row has no snapshot) against the new date.
|
||
const merged = effectiveWindowConfig(schedule, windowCfg);
|
||
|
||
const times =
|
||
schedule.direction === 'EXPORT'
|
||
? computeExportWindowTimes(departure, merged)
|
||
: computeImportWindowTimes(departure, merged, now);
|
||
|
||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||
scheduledDepartureDate: departure,
|
||
windowOpensAt: times.windowOpensAt,
|
||
windowClosesAt: times.windowClosesAt,
|
||
});
|
||
this.logger.log(
|
||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||
`(window reopens ${times.windowOpensAt.toISOString()})`,
|
||
);
|
||
void this.emitWindowState(id);
|
||
|
||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||
return fresh ?? schedule;
|
||
}
|
||
|
||
/**
|
||
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
||
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
||
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
|
||
* past their window are left untouched — customers may have booked against the
|
||
* times they were shown, so those stay frozen. Returns the count re-stamped.
|
||
*/
|
||
async restampPendingWindows(): Promise<number> {
|
||
const cfg = await this.getWindowConfig();
|
||
const now = new Date();
|
||
const schedules = await this.trainSchedulesRepository.findAll({
|
||
where: [
|
||
{ status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' },
|
||
{ status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' },
|
||
],
|
||
});
|
||
|
||
const repo = this.dataSource.getRepository(TrainSchedule);
|
||
let restamped = 0;
|
||
for (const s of schedules) {
|
||
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
|
||
const times =
|
||
s.direction === 'EXPORT'
|
||
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
||
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
|
||
// A not-yet-open schedule legitimately adopts the new rule, so refresh its
|
||
// snapshot alongside the re-stamped times — the board then draws the new
|
||
// window from this same rule.
|
||
await repo.update(s.id, {
|
||
windowOpensAt: times.windowOpensAt,
|
||
windowClosesAt: times.windowClosesAt,
|
||
...windowRuleSnapshot(cfg),
|
||
});
|
||
restamped += 1;
|
||
// New times take effect immediately on every card (the tick then opens
|
||
// the window within seconds if the re-derived open is already due).
|
||
void this.emitWindowState(s.id);
|
||
}
|
||
if (restamped > 0) {
|
||
this.logger.log(
|
||
`Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`,
|
||
);
|
||
}
|
||
return restamped;
|
||
}
|
||
|
||
/**
|
||
* Booking-window timings with hardcoded fallbacks for a missing/legacy config row.
|
||
* Numeric columns come back from pg as strings — normalize every field.
|
||
*/
|
||
async getWindowConfig(): Promise<BookingWindowConfig> {
|
||
const row = await this.loadGlobalRulesRow();
|
||
const num = (v: unknown, fallback: number) => {
|
||
const n = v == null ? NaN : Number(v);
|
||
return Number.isFinite(n) ? n : fallback;
|
||
};
|
||
return {
|
||
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
|
||
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
|
||
windowOpenHour: num(row?.windowOpenHour, 8),
|
||
windowCloseHour: num(row?.windowCloseHour, 17),
|
||
windowDurationHours: num(row?.windowDurationHours, 3),
|
||
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
||
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
||
reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
|
||
};
|
||
}
|
||
|
||
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
|
||
const limits = await this.resolveTrainLimitConfig(dto);
|
||
return this.buildPreviewResponse(
|
||
await this.validateBookingsForScheduling(
|
||
dto,
|
||
null,
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
dto.targetScheduleId,
|
||
),
|
||
);
|
||
}
|
||
|
||
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
|
||
const limits = await this.resolveTrainLimitConfig(dto);
|
||
return this.buildPreviewResponse(
|
||
await this.validateBookingsForScheduling(
|
||
dto,
|
||
'CONTAINER',
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
dto.targetScheduleId,
|
||
),
|
||
);
|
||
}
|
||
|
||
async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) {
|
||
const limits = await this.resolveTrainLimitConfig(dto);
|
||
return this.buildPreviewResponse(
|
||
await this.validateBookingsForScheduling(
|
||
dto,
|
||
'BULK',
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
dto.targetScheduleId,
|
||
),
|
||
);
|
||
}
|
||
|
||
private buildPreviewResponse(validation: Awaited<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 locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||
if (locomotiveIds.length < 2) {
|
||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
||
}
|
||
|
||
const scheduleWarnings: string[] = [];
|
||
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
|
||
// Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on
|
||
// multiple future schedules and does not need to be at the origin yard yet — staff
|
||
// plan around its arrival. Only decommissioned locomotives are hard-blocked;
|
||
// everything else surfaces as a warning.
|
||
const lockedLocomotives: Locomotive[] = [];
|
||
for (const locomotiveId of locomotiveIds) {
|
||
const locked = await manager.getRepository(Locomotive).findOne({
|
||
where: { id: locomotiveId },
|
||
lock: { mode: 'pessimistic_write' },
|
||
});
|
||
if (!locked) {
|
||
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||
}
|
||
if (locked.status === 'OUT_OF_SERVICE') {
|
||
throw new ConflictException(`Locomotive ${locked.code} is out of service`);
|
||
}
|
||
if (locked.status !== 'AVAILABLE') {
|
||
scheduleWarnings.push(
|
||
`Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`,
|
||
);
|
||
}
|
||
if (locked.currentYardId !== route.originYardId) {
|
||
scheduleWarnings.push(
|
||
`Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`,
|
||
);
|
||
}
|
||
lockedLocomotives.push(locked);
|
||
}
|
||
|
||
// Frozen on the route at create/update from the yard-country enum;
|
||
// getSchedulableRoute already rejected DOMESTIC (intercity).
|
||
const direction = this.resolveRouteDirection(route);
|
||
|
||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
||
// Effective capacity is capped by the weakest locomotive in the set.
|
||
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
|
||
const departure = new Date(dto.scheduleDate);
|
||
// Every schedule starts with a CLOSED customer window; the window engine opens
|
||
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
||
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
|
||
// 24h before departure (FCFS). No schedule is ever always-open now.
|
||
const windowCfg = await this.getWindowConfig();
|
||
|
||
// Staff cannot schedule inside the lead window — there must be room for a
|
||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
||
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
||
// lead is in hours (24h = 1 day ahead).
|
||
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
||
if (departure.getTime() < earliest.getTime()) {
|
||
const detail =
|
||
direction === 'EXPORT'
|
||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||
throw new BadRequestException(
|
||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||
`(earliest ${earliest.toISOString()})`,
|
||
);
|
||
}
|
||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||
// windows from it rather than the live config.
|
||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||
const windowFields =
|
||
direction === 'EXPORT'
|
||
? {
|
||
bookingWindowStatus: 'CLOSED',
|
||
windowPhase: 'PRE_WINDOW',
|
||
...ruleSnapshot,
|
||
...computeExportWindowTimes(departure, windowCfg),
|
||
}
|
||
: {
|
||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||
bookingWindowStatus: 'CLOSED',
|
||
windowPhase: 'PRE_WINDOW',
|
||
...ruleSnapshot,
|
||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||
};
|
||
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
|
||
.maxWagonsPerTrain;
|
||
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
||
// (the unique index rejects the loser; it re-reads the max and tries again).
|
||
const saved = await this.insertScheduleWithReference(manager, (reference) =>
|
||
manager.getRepository(TrainSchedule).create({
|
||
reference,
|
||
trainSetId: trainSet.id,
|
||
routeId: route.id,
|
||
originStationId: route.originYardId,
|
||
destinationStationId: route.destinationYardId,
|
||
scheduledDepartureDate: departure,
|
||
status: TrainScheduleStatusEnum.Draft,
|
||
direction,
|
||
maxWagons,
|
||
...windowFields,
|
||
}),
|
||
);
|
||
// Locomotives stay in their current status until dispatch — advance scheduling
|
||
// must not block the locomotive from serving earlier trains.
|
||
return saved.id;
|
||
});
|
||
|
||
const created = await this.getTrainScheduleById(createdScheduleId);
|
||
// New window announced — portal home / GL cards pick it up immediately.
|
||
void this.emitWindowState(createdScheduleId);
|
||
return { ...created, warnings: scheduleWarnings };
|
||
}
|
||
|
||
async assignBookingsToSchedule(
|
||
scheduleId: string,
|
||
dto: AssignBookingsDto,
|
||
freightType?: 'CONTAINER' | 'BULK',
|
||
) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Cannot assign bookings to schedule in status ${schedule.status}`,
|
||
);
|
||
}
|
||
if (!schedule.trainSet) {
|
||
throw new BadRequestException('Schedule has no train set');
|
||
}
|
||
|
||
// Batch parity: a schedule may only allocate bookings that targeted it. This mirrors
|
||
// the automatic fill, which only pulls bookings whose train_schedule_id is this schedule.
|
||
if (dto.bookingIds.length) {
|
||
const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
|
||
const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId);
|
||
if (stray.length) {
|
||
throw new BadRequestException(
|
||
`These bookings are not assigned to this schedule: ${stray
|
||
.map((b) => b.reference ?? b.id)
|
||
.join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const previewDto = {
|
||
bookingIds: dto.bookingIds,
|
||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
maxTrainWeightTons: dto.maxTrainWeightTons,
|
||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||
maxWagonsPerTrain: dto.maxWagonsPerTrain,
|
||
};
|
||
|
||
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
||
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
|
||
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
|
||
|
||
// Callers that add bookings without hand-picking container slots (the
|
||
// workspace "Add from pool" button, re-adding a removed booking) send no
|
||
// containerPlacements. Auto-fill them the same way the batch engine does:
|
||
// preview the wagon plan first, then lay containers into the plan's slots.
|
||
// Without this the placement validator rejects container bookings outright
|
||
// ("Container placements are required for container bookings").
|
||
let containerPlacements = dto.containerPlacements;
|
||
if (!containerPlacements?.length) {
|
||
const preview = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
freightType ?? null,
|
||
dto.forceAssign,
|
||
[],
|
||
false,
|
||
limits,
|
||
scheduleId,
|
||
);
|
||
const containerBookings = preview.bookings.filter(
|
||
(b) => b.freightType === 'CONTAINER',
|
||
);
|
||
if (containerBookings.length) {
|
||
const units = expandBookingContainerUnits(containerBookings);
|
||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
||
const generated = autoFillPlacements(units, slots);
|
||
const missing = findMissingContainerNumberIssues(units, generated);
|
||
if (missing.length) {
|
||
throw new BadRequestException({
|
||
message: `Booking validation failed: ${missing
|
||
.map((m) => m.issue)
|
||
.join('; ')}`,
|
||
violations: missing.map((m) => m.issue),
|
||
});
|
||
}
|
||
containerPlacements = generated;
|
||
}
|
||
}
|
||
|
||
const validation = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
freightType ?? null,
|
||
dto.forceAssign,
|
||
containerPlacements,
|
||
true,
|
||
limits,
|
||
scheduleId,
|
||
);
|
||
|
||
if (!validation.valid) {
|
||
// Put the violation detail in the message itself — global exception
|
||
// filters flatten the body, and "Booking validation failed" alone tells
|
||
// staff nothing (e.g. which wagon type is missing at the yard).
|
||
throw new BadRequestException({
|
||
message: `Booking validation failed: ${validation.violations.join('; ')}`,
|
||
violations: validation.violations,
|
||
warnings: validation.warnings,
|
||
});
|
||
}
|
||
|
||
if (!validation.bookings.length) {
|
||
const shortfall = validation.deferredBookings
|
||
.map((d) => `${d.reference}: ${d.reason}`)
|
||
.join('; ');
|
||
throw new BadRequestException({
|
||
message: `No wagons available for the selected bookings${shortfall ? ` — ${shortfall}` : ''}`,
|
||
violations: ['Insufficient fleet wagons for the selected bookings'],
|
||
warnings: validation.warnings,
|
||
deferredBookings: validation.deferredBookings,
|
||
});
|
||
}
|
||
|
||
const { bookings, wagonType, wagonPlan, warnings, deferredBookings } = validation;
|
||
const totalWeightTons = validation.summary.totalWeightTons;
|
||
const totalLengthMeters = validation.summary.totalLengthMeters;
|
||
|
||
if (!limitLoco) {
|
||
throw new BadRequestException('Schedule train set has no locomotives');
|
||
}
|
||
// forceAssign lets staff overload the locomotive set knowingly — the
|
||
// validator has already surfaced it as a warning in that case. Each
|
||
// locomotive's overageToleranceTons/Meters extends the hard cap before that
|
||
// override is even needed (e.g. the fertilizer example's +90T deviation).
|
||
const weightCapWithOverage =
|
||
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
|
||
const lengthCapWithOverage =
|
||
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
|
||
if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) {
|
||
throw new BadRequestException(
|
||
`Train set locomotives cannot pull ${totalWeightTons}T`,
|
||
);
|
||
}
|
||
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
|
||
throw new BadRequestException(
|
||
`Train set locomotives cannot support ${totalLengthMeters}m`,
|
||
);
|
||
}
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const trainSetId = schedule.trainSetId;
|
||
|
||
await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
|
||
|
||
const deletedAllocationIds =
|
||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
|
||
|
||
if (deletedAllocationIds.length) {
|
||
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
|
||
deletedAllocationIds,
|
||
manager,
|
||
);
|
||
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(
|
||
deletedAllocationIds,
|
||
manager,
|
||
);
|
||
}
|
||
|
||
await manager.getRepository(TrainSetWagon).delete({ trainSetId });
|
||
await manager.getRepository(TrainScheduleBooking).delete({ trainScheduleId: scheduleId });
|
||
|
||
await manager.getRepository(TrainSet).update(trainSetId, {
|
||
totalWeightTons,
|
||
totalLengthMeters,
|
||
wagonCount: wagonPlan.length,
|
||
status: 'ASSIGNED',
|
||
});
|
||
|
||
const savedWagons = await this.persistTrainSetWagons(
|
||
manager,
|
||
trainSetId,
|
||
wagonType,
|
||
wagonPlan,
|
||
);
|
||
|
||
const scheduleBookingRecords = bookings.map((booking) => ({
|
||
trainScheduleId: scheduleId,
|
||
bookingId: booking.id,
|
||
}));
|
||
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
|
||
|
||
await this.persistAllocationsAndLoads(
|
||
manager,
|
||
savedWagons,
|
||
wagonPlan,
|
||
bookings,
|
||
containerPlacements ?? [],
|
||
);
|
||
|
||
// 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),
|
||
},
|
||
manager,
|
||
);
|
||
}
|
||
|
||
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
scheduleId,
|
||
TrainScheduleStatusEnum.Draft,
|
||
{},
|
||
manager,
|
||
);
|
||
}
|
||
|
||
await this.autoPinWagonsForSchedule(
|
||
manager,
|
||
scheduleId,
|
||
schedule.originStationId,
|
||
savedWagons,
|
||
);
|
||
});
|
||
|
||
const detail = await this.getTrainScheduleById(scheduleId);
|
||
return { ...detail, warnings, deferredBookings };
|
||
}
|
||
|
||
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
|
||
}
|
||
|
||
const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
|
||
if (!link) {
|
||
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
|
||
}
|
||
|
||
const booking = await this.bookingsRepository.findById(bookingId);
|
||
const bookingReference = booking?.reference ?? null;
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const allocationIds = (schedule.trainSet?.wagons ?? [])
|
||
.flatMap((w) => w.allocations ?? [])
|
||
.filter((a) => a.bookingId === bookingId)
|
||
.map((a) => a.id);
|
||
|
||
if (allocationIds.length) {
|
||
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
|
||
allocationIds,
|
||
manager,
|
||
);
|
||
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
|
||
await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
|
||
}
|
||
|
||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||
scheduleId,
|
||
bookingId,
|
||
manager,
|
||
);
|
||
|
||
const booking = await this.bookingsRepository.findById(bookingId);
|
||
const schedulingStatus = this.resolvePostUnassignStatus(booking);
|
||
await this.bookingsRepository.updateSchedulingFields(
|
||
bookingId,
|
||
{ schedulingStatus, wagonsRequired: null },
|
||
manager,
|
||
);
|
||
|
||
const remainingBookings = (schedule.scheduleBookings ?? []).filter(
|
||
(sb) => sb.bookingId !== bookingId,
|
||
);
|
||
if (remainingBookings.length === 0) {
|
||
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
|
||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
|
||
schedule.trainSetId,
|
||
manager,
|
||
);
|
||
await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId });
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||
totalWeightTons: 0,
|
||
totalLengthMeters: 0,
|
||
wagonCount: 0,
|
||
status: 'DRAFT',
|
||
});
|
||
}
|
||
});
|
||
|
||
await this.trainCompositionRemovalLogRepository.create({
|
||
scheduleId,
|
||
bookingId,
|
||
bookingReference,
|
||
removedByUserId: userId ?? null,
|
||
removedAt: new Date(),
|
||
});
|
||
|
||
console.log(
|
||
`[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`,
|
||
);
|
||
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
private async runWarehouseArrivalAutomation(scheduleId: string) {
|
||
// Runs after the arrival transaction has committed — must never throw, or a
|
||
// successfully arrived train reports a 500 and looks stuck to the operator.
|
||
let direction: string | undefined;
|
||
try {
|
||
const [schedule]: Array<{
|
||
originCountry: string | null;
|
||
destinationCountry: string | null;
|
||
destinationCode: string | null;
|
||
destinationName: string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT oy.country AS "originCountry",
|
||
dy.country AS "destinationCountry",
|
||
dy.code AS "destinationCode",
|
||
dy.label AS "destinationName"
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.id = $1 AND ts.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[scheduleId],
|
||
);
|
||
|
||
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
|
||
|
||
direction = deriveTradeDirection(
|
||
{ country: schedule.originCountry },
|
||
{ country: schedule.destinationCountry },
|
||
);
|
||
if (direction === 'IMPORT') {
|
||
const result = await this.warehouseInventoryService.autoUnloadArrivedBookings(
|
||
scheduleId,
|
||
'SYSTEM_TRAIN_ARRIVAL',
|
||
);
|
||
// Customer tracking: cargo is off the train at the destination yard.
|
||
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
|
||
return {
|
||
direction,
|
||
action: 'IMPORT_AUTO_UNLOAD',
|
||
status: 'COMPLETED',
|
||
result,
|
||
};
|
||
}
|
||
|
||
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
|
||
const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
|
||
scheduleId,
|
||
'SYSTEM_TRAIN_ARRIVAL',
|
||
);
|
||
// Customer tracking: cargo is off the train at the Djibouti port.
|
||
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
|
||
return {
|
||
direction,
|
||
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
|
||
status: 'COMPLETED',
|
||
result,
|
||
};
|
||
}
|
||
|
||
return { direction, status: 'SKIPPED', reason: 'No warehouse arrival automation for this route' };
|
||
} catch (error) {
|
||
return {
|
||
direction,
|
||
status: 'FAILED',
|
||
reason: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
}
|
||
|
||
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
|
||
const normalized = (value ?? '').toUpperCase();
|
||
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
|
||
normalized.includes(token),
|
||
);
|
||
}
|
||
|
||
async getImportLoadingBookings(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
|
||
const [scheduleBookings, allocations] = await Promise.all([
|
||
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
|
||
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
|
||
]);
|
||
if (!scheduleBookings.length) {
|
||
return { count: 0, items: [] };
|
||
}
|
||
|
||
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
|
||
const statusByBookingId = new Map(
|
||
scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]),
|
||
);
|
||
const candidateIds = scheduleBookings
|
||
.map((sb) => sb.bookingId)
|
||
.filter((id) => allocatedBookingIds.has(id));
|
||
if (!candidateIds.length) {
|
||
return { count: 0, items: [] };
|
||
}
|
||
|
||
const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds);
|
||
const items = bookings
|
||
.filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
|
||
.map((b) => ({
|
||
id: b.id,
|
||
reference: b.reference ?? null,
|
||
customer: b.company?.name ?? null,
|
||
weightTons: b.cargoTotalWeightVgm,
|
||
loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
|
||
}));
|
||
return { count: items.length, items };
|
||
}
|
||
|
||
async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
|
||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
|
||
const [scheduleBookings, allocations, bookings] = await Promise.all([
|
||
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
|
||
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
|
||
this.bookingsRepository.findByIdsForScheduling(dto.bookingIds),
|
||
]);
|
||
|
||
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
|
||
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
|
||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||
|
||
const invalid: string[] = [];
|
||
for (const id of dto.bookingIds) {
|
||
const booking = bookingById.get(id);
|
||
if (
|
||
!scheduledIds.has(id) ||
|
||
!allocatedIds.has(id) ||
|
||
!booking ||
|
||
booking.tradeDirection !== 'IMPORT' ||
|
||
booking.paymentStatus !== 'PAID'
|
||
) {
|
||
invalid.push(id);
|
||
}
|
||
}
|
||
if (invalid.length) {
|
||
throw new BadRequestException(
|
||
`Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||
scheduleId,
|
||
dto.bookingIds,
|
||
dto.loadingStatus,
|
||
);
|
||
return this.getImportLoadingBookings(scheduleId);
|
||
}
|
||
|
||
/**
|
||
* Flip loaded/unloaded on the schedule↔booking link from the workspace, for any
|
||
* direction (import/export/domestic). Distinct from unassign: the booking stays
|
||
* on its wagon; this only records whether cargo is physically loaded. Allowed
|
||
* only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival
|
||
* warehouse automation owns unload, so staff can no longer hand-edit the flag.
|
||
*/
|
||
async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
|
||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Loading status can only be changed before dispatch (schedule is ${schedule.status})`,
|
||
);
|
||
}
|
||
|
||
const [scheduleBookings, allocations] = await Promise.all([
|
||
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
|
||
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
|
||
]);
|
||
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
|
||
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
|
||
|
||
// Only bookings that are on this train AND pinned to a wagon can be loaded —
|
||
// no direction/payment filter, staff load whatever is physically on the set.
|
||
const invalid = dto.bookingIds.filter(
|
||
(id) => !scheduledIds.has(id) || !allocatedIds.has(id),
|
||
);
|
||
if (invalid.length) {
|
||
throw new BadRequestException(
|
||
`Not allocated to a wagon on this schedule: ${invalid.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||
scheduleId,
|
||
dto.bookingIds,
|
||
dto.loadingStatus,
|
||
);
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
|
||
}
|
||
|
||
const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id));
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
for (const assignment of dto.assignments) {
|
||
if (!slotIds.has(assignment.trainSetWagonId)) {
|
||
throw new BadRequestException(
|
||
`Train set wagon ${assignment.trainSetWagonId} does not belong to this schedule`,
|
||
);
|
||
}
|
||
|
||
const physicalWagon = await manager.getRepository(Wagon).findOne({
|
||
where: { id: assignment.physicalWagonId },
|
||
});
|
||
if (!physicalWagon) {
|
||
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
|
||
}
|
||
if (
|
||
physicalWagon.status !== WagonStatus.Available &&
|
||
physicalWagon.currentTrainScheduleId !== scheduleId
|
||
) {
|
||
throw new ConflictException(
|
||
`Wagon ${physicalWagon.wagonNumber} is not available`,
|
||
);
|
||
}
|
||
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
||
throw new ConflictException(
|
||
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
||
);
|
||
}
|
||
|
||
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
|
||
physicalWagonId: assignment.physicalWagonId,
|
||
status: 'RESERVED',
|
||
});
|
||
await manager.getRepository(Wagon).update(assignment.physicalWagonId, {
|
||
trainSetWagonId: assignment.trainSetWagonId,
|
||
currentTrainScheduleId: scheduleId,
|
||
status: WagonStatus.Assigned,
|
||
});
|
||
}
|
||
});
|
||
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async finalizeSchedule(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status !== TrainScheduleStatusEnum.Draft) {
|
||
throw new BadRequestException('Only DRAFT schedules can be finalized');
|
||
}
|
||
if (!schedule.scheduleBookings?.length) {
|
||
throw new BadRequestException('Cannot finalize a schedule with no bookings');
|
||
}
|
||
|
||
const now = new Date();
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
scheduleId,
|
||
TrainScheduleStatusEnum.Scheduled,
|
||
{},
|
||
manager,
|
||
);
|
||
for (const sb of schedule.scheduleBookings ?? []) {
|
||
await this.bookingsRepository.updateSchedulingFields(
|
||
sb.bookingId,
|
||
{ schedulingStatus: SchedulingStatus.Scheduled, scheduledAt: now },
|
||
manager,
|
||
);
|
||
}
|
||
});
|
||
|
||
// Finalized — push so portal/GL cards reflect the new state instantly.
|
||
void this.emitWindowState(scheduleId);
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async dispatchSchedule(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||
}
|
||
await this.assertImportDjiboutiMayDepart(schedule);
|
||
// A locomotive may sit on many future schedules, but it can only pull one train
|
||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
|
||
|
||
const now = new Date();
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||
if (setLocomotiveIds.length) {
|
||
await manager
|
||
.getRepository(Locomotive)
|
||
.update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' });
|
||
}
|
||
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
scheduleId,
|
||
TrainScheduleStatusEnum.Dispatched,
|
||
{ actualDepartureAt: now, trainNumber },
|
||
manager,
|
||
);
|
||
if (schedule.trainSetId) {
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
|
||
}
|
||
for (const sb of schedule.scheduleBookings ?? []) {
|
||
await this.bookingsRepository.updateSchedulingFields(
|
||
sb.bookingId,
|
||
{ schedulingStatus: SchedulingStatus.Dispatched },
|
||
manager,
|
||
);
|
||
}
|
||
// Per-booking journey fallback: bookings boarding at the TRAIN's origin
|
||
// that the operator didn't load individually are auto-loaded now — the
|
||
// train is leaving with them. Mid-corridor boarders stay PAID until the
|
||
// operator loads them at their own yard.
|
||
await manager.query(
|
||
`UPDATE freight.bookings b
|
||
SET status = 'IN_TRANSIT',
|
||
loaded_at = COALESCE(b.loaded_at, $3)
|
||
FROM freight.train_schedule_bookings tsb
|
||
WHERE tsb.booking_id = b.id
|
||
AND tsb.train_schedule_id = $1
|
||
AND tsb.deleted_at IS NULL
|
||
AND b.deleted_at IS NULL
|
||
AND b.origin_yard_id = $2
|
||
AND b.loaded_at IS NULL
|
||
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
|
||
[scheduleId, schedule.originStationId, now],
|
||
);
|
||
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
||
await manager
|
||
.getRepository(TrainSchedule)
|
||
.update(scheduleId, { bookingWindowStatus: 'CLOSED' });
|
||
await manager
|
||
.getRepository(Booking)
|
||
.createQueryBuilder()
|
||
.update()
|
||
.set({
|
||
status: 'EXPIRED',
|
||
schedulingStatus: SchedulingStatus.Eligible,
|
||
paymentDeadline: null,
|
||
})
|
||
.where('train_schedule_id = :scheduleId', { scheduleId })
|
||
.andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||
.execute();
|
||
});
|
||
|
||
if (this.isImportDjiboutiSchedule(schedule)) {
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id);
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now,
|
||
});
|
||
console.log(
|
||
`[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`,
|
||
);
|
||
}
|
||
|
||
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||
void this.emitWindowState(scheduleId);
|
||
// Customer tracking: cargo is on the departing train — loading milestones
|
||
// plus the direction's "departed" handoff milestone. Restricted to bookings
|
||
// that BOARD at the train's origin; mid-corridor boarders get their loading
|
||
// milestones from their own operator load at their own yard.
|
||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||
void this.completeMilestonesForScheduleBookings(
|
||
scheduleId,
|
||
[
|
||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||
// doc-trigger path no-ops it for import bookings.
|
||
'CARGO_ARRIVED',
|
||
'READY_FOR_LOADING',
|
||
'LOADED',
|
||
schedule.direction === 'IMPORT'
|
||
? 'DEPARTED_FROM_DJIBOUTI'
|
||
: 'DEPARTED_TO_DJIBOUTI',
|
||
],
|
||
{ originYardId: schedule.originStationId },
|
||
);
|
||
}
|
||
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async getImportDjiboutiOperation(scheduleId: string) {
|
||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
return this.mapImportDjiboutiOperation(schedule, operation);
|
||
}
|
||
|
||
async uploadImportDjiboutiDocument(
|
||
scheduleId: string,
|
||
dto: UploadImportDjiboutiDocumentDto,
|
||
) {
|
||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
const documents = {
|
||
...(operation.documents ?? {}),
|
||
[dto.documentType]: {
|
||
fileId: dto.fileId ?? null,
|
||
fileUrl: dto.fileUrl ?? null,
|
||
reference: dto.reference ?? null,
|
||
uploadedAt: new Date().toISOString(),
|
||
uploadedBy: dto.performedBy ?? null,
|
||
notes: dto.notes ?? null,
|
||
},
|
||
};
|
||
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
documents,
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
|
||
return this.getImportDjiboutiOperation(schedule.id);
|
||
}
|
||
|
||
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
|
||
const documents = { ...(operation.documents ?? {}) };
|
||
if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) {
|
||
documents.GATE_PASS = {
|
||
fileId: dto.fileId ?? null,
|
||
fileUrl: dto.fileUrl ?? null,
|
||
reference: dto.reference ?? null,
|
||
uploadedAt: new Date().toISOString(),
|
||
uploadedBy: dto.performedBy ?? null,
|
||
notes: dto.notes ?? null,
|
||
};
|
||
}
|
||
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
documents,
|
||
gatepassGrantedAt: securedAt,
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
|
||
await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt);
|
||
|
||
console.log(
|
||
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
|
||
);
|
||
return this.getImportDjiboutiOperation(schedule.id);
|
||
}
|
||
|
||
/**
|
||
* Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED
|
||
* milestone for every customs booking on this schedule, so contract/booking
|
||
* clearance views still reading that milestone (older deployed builds) see
|
||
* the gate pass as done. Drop once every clearance-api deployment reads
|
||
* ImportDjiboutiOperation.gatepassGrantedAt directly.
|
||
*
|
||
* A booking only earns its gate pass once the customer has settled the freight
|
||
* charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train
|
||
* schedule, so an unpaid booking must not ride a paid neighbour's grant: it
|
||
* keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the
|
||
* train and its paid bookings proceed. Re-securing the gate pass after payment
|
||
* settles picks the booking up; so does any later call to this bridge.
|
||
*/
|
||
private async completeGatepassMilestoneForSchedule(
|
||
scheduleId: string,
|
||
securedAt: Date,
|
||
): Promise<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) {
|
||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||
scheduleId,
|
||
[...wagonAssignedIds],
|
||
LoadingStatus.Loaded,
|
||
);
|
||
}
|
||
// Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
|
||
// is the export-side "cargo reached origin yard" step that precedes it).
|
||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||
'CARGO_ARRIVED',
|
||
'READY_FOR_LOADING',
|
||
'LOADED',
|
||
]);
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
this.assertImportDjiboutiGatepassGranted(operation);
|
||
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
|
||
|
||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||
await this.dispatchSchedule(schedule.id);
|
||
} else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||
throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti');
|
||
}
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(),
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
return this.getImportDjiboutiOperation(schedule.id);
|
||
}
|
||
|
||
async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
const generatedAt = operation.loadListGeneratedAt ?? new Date();
|
||
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
loadListGeneratedAt: generatedAt,
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
|
||
return {
|
||
generatedAt: generatedAt.toISOString(),
|
||
trainScheduleId: schedule.id,
|
||
trainNumber: schedule.trainNumber ?? null,
|
||
route: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
|
||
sequenceNo: wagon.sequenceNo,
|
||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||
bookingId: allocation.bookingId,
|
||
bookingReference: allocation.booking?.reference ?? null,
|
||
loadType: allocation.loadType ?? null,
|
||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||
containerNumbers: (allocation.containerItems ?? [])
|
||
.map((item) => item.containerNumber)
|
||
.filter(Boolean),
|
||
})),
|
||
})),
|
||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||
};
|
||
}
|
||
|
||
async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
const loadList = await this.generateImportLoadList(scheduleId, {
|
||
performedBy: 'DOCUMENT_GENERATION',
|
||
});
|
||
const html = this.buildImportLoadListHtml(loadList);
|
||
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
|
||
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
|
||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
|
||
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
||
return {
|
||
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||
buffer,
|
||
};
|
||
}
|
||
|
||
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!this.isExportSchedule(schedule)) {
|
||
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
|
||
}
|
||
|
||
const html = this.buildExportLoadListHtml(schedule);
|
||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
|
||
const reference = schedule.trainNumber ?? schedule.id;
|
||
return {
|
||
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||
buffer,
|
||
};
|
||
}
|
||
|
||
private buildExportLoadListHtml(schedule: TrainSchedule): string {
|
||
const esc = (value: unknown) =>
|
||
String(value ?? '-')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||
const rows = (schedule.trainSet?.wagons ?? [])
|
||
.flatMap((wagon) =>
|
||
(wagon.allocations ?? []).map((allocation) => {
|
||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||
const containerItems = allocation.containerItems ?? [];
|
||
const firstContainer = containerItems[0];
|
||
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
|
||
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||
return `<tr>
|
||
<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>
|
||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||
<td>${esc(booking?.companyId)}</td>
|
||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||
<td>${esc(chassisNumbers)}</td>
|
||
<td>${esc(sealNumbers)}</td>
|
||
</tr>`;
|
||
}),
|
||
)
|
||
.join('');
|
||
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
|
||
(sum, wagon) =>
|
||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||
0,
|
||
);
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Export Marshalling Document</title>
|
||
<style>
|
||
@page { size: A4 landscape; margin: 10mm; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||
.tile strong { font-size: 11px; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||
.num { text-align: right; }
|
||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="top">
|
||
<div>
|
||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||
<h1>Export Marshalling Document / Load List</h1>
|
||
</div>
|
||
<div class="meta">
|
||
Train / Schedule
|
||
<strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
|
||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="summary">
|
||
<div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
|
||
<div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
|
||
<div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
|
||
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
|
||
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
|
||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
|
||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Seq</th>
|
||
<th>Wagon No</th>
|
||
<th>Wagon Type</th>
|
||
<th class="num">Equated Length</th>
|
||
<th class="num">Tare Weight</th>
|
||
<th class="num">Load Capacity</th>
|
||
<th>Customer Name</th>
|
||
<th>Customer ID</th>
|
||
<th>Cargo Type</th>
|
||
<th>Container No</th>
|
||
<th>Chassis No</th>
|
||
<th>Seal No</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="notice">
|
||
Loading and dispatch staff must verify wagon identity, seal number, container number,
|
||
cargo type, and customer booking against the physical consist before departure.
|
||
</div>
|
||
|
||
<div class="signatures">
|
||
<div class="line">Prepared person / date</div>
|
||
<div class="line">Check person / date</div>
|
||
<div class="line">Operations authorization / date</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
private isExportSchedule(schedule: TrainSchedule): boolean {
|
||
const direction =
|
||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||
(schedule.originStation && schedule.destinationStation
|
||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||
: null);
|
||
return direction === 'EXPORT';
|
||
}
|
||
|
||
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 allocationRows = loadList.wagons
|
||
.flatMap((wagon) =>
|
||
wagon.allocations.map(
|
||
(allocation) => `<tr>
|
||
<td>${esc(wagon.sequenceNo)}</td>
|
||
<td>${esc(wagon.wagonNumber)}</td>
|
||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||
<td>${esc(allocation.loadType)}</td>
|
||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||
</tr>`,
|
||
),
|
||
)
|
||
.join('');
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Import Load List / Marshalling Document</title>
|
||
<style>
|
||
@page { size: A4; margin: 14mm; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||
.doc { min-height: 100vh; position: relative; }
|
||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
|
||
.brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||
h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
|
||
.subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
|
||
.meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
|
||
.meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
|
||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
|
||
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
|
||
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
|
||
.tile strong { font-size: 13px; }
|
||
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
|
||
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
|
||
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
|
||
.pending { background: #f8fafc; color: #64748b; }
|
||
h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||
.num { text-align: right; }
|
||
.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)}</strong></div>
|
||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||
</div>
|
||
|
||
<div class="status">
|
||
<div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
|
||
<div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
|
||
<div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
|
||
<div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
|
||
<div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
|
||
<div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
|
||
</div>
|
||
|
||
<h2>Wagon Marshalling Allocation</h2>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Seq</th>
|
||
<th>Wagon</th>
|
||
<th>Booking</th>
|
||
<th>Load</th>
|
||
<th>Container numbers</th>
|
||
<th class="num">Weight T</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</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) return schedule.trainNumber;
|
||
|
||
// Count container vs bulk wagons from the planned allocations.
|
||
let containerWagons = 0;
|
||
let bulkWagons = 0;
|
||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
|
||
if (isBulk) bulkWagons += 1;
|
||
else containerWagons += 1;
|
||
}
|
||
|
||
const direction =
|
||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||
(schedule.originStation && schedule.destinationStation
|
||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||
: null);
|
||
|
||
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
|
||
|
||
// Lock the set of currently-active numbered schedules so two concurrent
|
||
// dispatches serialize and can't both claim the same lowest-free number.
|
||
const activeNumbered = await manager
|
||
.getRepository(TrainSchedule)
|
||
.createQueryBuilder('schedule')
|
||
.setLock('pessimistic_write')
|
||
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
|
||
.andWhere('schedule.train_number IS NOT NULL')
|
||
.getMany();
|
||
|
||
const usedNumbers = activeNumbered
|
||
.map((s) => s.trainNumber)
|
||
.filter((n): n is string => Boolean(n));
|
||
|
||
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
|
||
if (!number) {
|
||
throw new ConflictException(
|
||
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
|
||
);
|
||
}
|
||
return number;
|
||
}
|
||
|
||
/** Open or close a schedule's booking window (staff override). */
|
||
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<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);
|
||
}
|
||
|
||
return this.getScheduleCheckpoints(scheduleId);
|
||
}
|
||
|
||
/**
|
||
* Mark a dispatched train arrived: close out the schedule, move the locomotive
|
||
* and wagons to the destination yard, and free the assets for re-use.
|
||
*/
|
||
async arriveSchedule(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||
throw new BadRequestException('Only DISPATCHED trains can arrive');
|
||
}
|
||
|
||
const now = new Date();
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
scheduleId,
|
||
TrainScheduleStatusEnum.Arrived,
|
||
{ actualArrivalAt: now },
|
||
manager,
|
||
);
|
||
|
||
if (schedule.trainSetId) {
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||
status: 'COMPLETED',
|
||
});
|
||
}
|
||
|
||
// Per-booking journey: bookings destined for the FINAL yard that the
|
||
// operator didn't unload individually get their arrival stamped now as a
|
||
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
|
||
// their own unload (possibly already done while the train kept rolling).
|
||
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
|
||
|
||
// Release every locomotive of the set (not just the legacy primary) and move it
|
||
// to the destination yard where it physically arrived.
|
||
const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||
if (arrivedLocoIds.length) {
|
||
await manager.getRepository(Locomotive).update(
|
||
{ id: In(arrivedLocoIds) },
|
||
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
|
||
);
|
||
}
|
||
|
||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||
if (!slot.physicalWagonId) continue;
|
||
const wagon = await manager
|
||
.getRepository(Wagon)
|
||
.findOne({ where: { id: slot.physicalWagonId } });
|
||
if (!wagon) continue;
|
||
// A wagon that already alighted mid-route (unload released it, possibly
|
||
// re-pinned elsewhere since) is no longer this schedule's to move.
|
||
if (wagon.currentTrainScheduleId !== scheduleId) continue;
|
||
// Dynamic consist: the wagon settles at its slot's alight yard, not
|
||
// blanket at the train's destination.
|
||
const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
|
||
await manager.getRepository(Wagon).update(wagon.id, {
|
||
currentTrainScheduleId: null,
|
||
trainSetWagonId: null,
|
||
status: WagonStatus.Available,
|
||
currentYardId: settleYardId,
|
||
});
|
||
// Ledger: the wagon rode this schedule to its settle yard.
|
||
const slotAllocations = slot.allocations ?? [];
|
||
await manager.getRepository(WagonMovement).save(
|
||
manager.getRepository(WagonMovement).create({
|
||
wagonId: wagon.id,
|
||
fromYardId: slot.boardYardId ?? schedule.originStationId,
|
||
toYardId: settleYardId,
|
||
trainScheduleId: scheduleId,
|
||
bookingId: slotAllocations[0]?.bookingId ?? null,
|
||
kind: slotAllocations.length
|
||
? WagonMovementKind.Loaded
|
||
: WagonMovementKind.EmptyReposition,
|
||
occurredAt: now,
|
||
}),
|
||
);
|
||
}
|
||
|
||
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
||
const stations = await this.buildScheduleStations(schedule);
|
||
const finalStation = stations[stations.length - 1];
|
||
const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({
|
||
where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo },
|
||
});
|
||
if (!existingFinal) {
|
||
await manager.getRepository(TrainCheckpointEvent).save(
|
||
manager.getRepository(TrainCheckpointEvent).create({
|
||
trainScheduleId: scheduleId,
|
||
yardId: finalStation.yardId,
|
||
sequenceNo: finalStation.sequenceNo,
|
||
kind: TrainCheckpointKind.Arrived,
|
||
occurredAt: now,
|
||
}),
|
||
);
|
||
}
|
||
});
|
||
|
||
// Customer tracking: the train reached the corridor's far end. Restricted
|
||
// to bookings destined for the FINAL yard — mid-corridor bookings get their
|
||
// arrival milestone from their own operator unload at their own yard.
|
||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||
void this.completeMilestonesForScheduleBookings(
|
||
scheduleId,
|
||
[schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'],
|
||
{ destinationYardId: schedule.destinationStationId },
|
||
);
|
||
}
|
||
void this.notifyScheduleBookings(schedule, 'arrived');
|
||
|
||
const detail = await this.getTrainScheduleById(scheduleId);
|
||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||
return Object.assign(detail, { warehouseAutomation });
|
||
}
|
||
|
||
async getContainerTrainSchedules() {
|
||
const schedules = await this.trainSchedulesRepository.findAll({
|
||
relations: {
|
||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||
// Yards carry the route's display name used by mapScheduleListItem.
|
||
route: { originYard: true, destinationYard: true },
|
||
originStation: true,
|
||
destinationStation: true,
|
||
scheduleBookings: { booking: true },
|
||
},
|
||
// Newest-created first (the client can re-sort; this is the default order).
|
||
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' },
|
||
});
|
||
return schedules.map((s) => this.mapScheduleListItem(s));
|
||
}
|
||
|
||
async getContainerTrainScheduleById(id: string) {
|
||
return this.getTrainScheduleById(id);
|
||
}
|
||
|
||
async cancelTrainSchedule(id: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||
}
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
id,
|
||
TrainScheduleStatusEnum.Cancelled,
|
||
// Retire the booking window so a canceled schedule never lingers as an
|
||
// "open window" in booking-window lists or the legacy batch fill.
|
||
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
|
||
manager,
|
||
);
|
||
if (schedule.trainSetId) {
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
|
||
}
|
||
// Locomotives are only ASSIGNED while out on a dispatched train. Release ours,
|
||
// but never stomp a locomotive that is currently pulling another dispatched train.
|
||
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||
if (cancelledLocoIds.length) {
|
||
const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere(
|
||
cancelledLocoIds,
|
||
id,
|
||
manager,
|
||
);
|
||
const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId));
|
||
if (releasable.length) {
|
||
await manager
|
||
.getRepository(Locomotive)
|
||
.update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' });
|
||
}
|
||
}
|
||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||
if (wagon.physicalWagonId) {
|
||
await manager.getRepository(Wagon).update(wagon.physicalWagonId, {
|
||
currentTrainScheduleId: null,
|
||
trainSetWagonId: null,
|
||
status: WagonStatus.Available,
|
||
});
|
||
}
|
||
}
|
||
for (const sb of schedule.scheduleBookings ?? []) {
|
||
const booking = await this.bookingsRepository.findById(sb.bookingId);
|
||
await this.bookingsRepository.updateSchedulingFields(
|
||
sb.bookingId,
|
||
{ schedulingStatus: this.resolvePostUnassignStatus(booking) },
|
||
manager,
|
||
);
|
||
}
|
||
});
|
||
|
||
// Window retired (DONE) — remove the card from portal/GL lists right away.
|
||
void this.emitWindowState(id);
|
||
return this.getTrainScheduleById(id);
|
||
}
|
||
|
||
private async getTrainScheduleById(id: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||
}
|
||
return this.mapScheduleDetail(schedule);
|
||
}
|
||
|
||
private async validateBookingsForScheduling(
|
||
dto: PreviewContainerTrainScheduleDto | PreviewBulkTrainScheduleDto | PreviewTrainScheduleDto,
|
||
freightType: 'CONTAINER' | 'BULK' | null,
|
||
forceAssign = false,
|
||
containerPlacements: ContainerPlacementInput[] = [],
|
||
requireContainerPlacements = false,
|
||
trainLimits: Required<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(', ')}`,
|
||
);
|
||
}
|
||
|
||
if (
|
||
await (async () => {
|
||
// Corridor-aware: a booking belongs on this train when its origin and
|
||
// destination lie on the schedule's stop list in order — sub-corridor
|
||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
|
||
let stops = [dto.originStationId, dto.destinationStationId];
|
||
if (targetScheduleId) {
|
||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||
if (target) stops = await this.stopYardsForSchedule(target);
|
||
}
|
||
return bookings.some((b) => {
|
||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||
return false;
|
||
}
|
||
const fromIdx = stops.indexOf(b.originYardId);
|
||
const toIdx = stops.indexOf(b.destinationYardId);
|
||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||
});
|
||
})()
|
||
) {
|
||
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||
}
|
||
|
||
if (!forceAssign) {
|
||
for (const booking of bookings) {
|
||
if (this.isHoldActive(booking)) {
|
||
warnings.push(
|
||
`Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`,
|
||
);
|
||
}
|
||
// 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`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let wagonType: WagonType;
|
||
let containerWagonType: WagonType;
|
||
let bulkWagonType: WagonType;
|
||
let demandPlan: WagonPlanSlot[];
|
||
let fittingBookings = bookings;
|
||
let deferredBookings: DeferredBookingRow[] = [];
|
||
let fleetAvailability: FleetAvailabilityRow[] = [];
|
||
|
||
if (resolvedMode === 'MIXED') {
|
||
const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER');
|
||
const bulkBookings = bookings.filter((b) => b.freightType === 'BULK');
|
||
containerWagonType = await this.resolveWagonType('CONTAINER', bookingIds);
|
||
bulkWagonType = await this.resolveWagonType('BULK', bookingIds);
|
||
wagonType = containerWagonType;
|
||
demandPlan = buildMixedWagonPlan(
|
||
containerBookings,
|
||
bulkBookings,
|
||
containerWagonType,
|
||
bulkWagonType,
|
||
);
|
||
} else {
|
||
wagonType = await this.resolveWagonType(resolvedMode, bookingIds);
|
||
containerWagonType = wagonType;
|
||
bulkWagonType = wagonType;
|
||
demandPlan =
|
||
resolvedMode === 'CONTAINER'
|
||
? buildContainerWagonPlan(bookings, wagonType)
|
||
: buildBulkWagonPlan(bookings, wagonType);
|
||
}
|
||
|
||
const originYardId = dto.originStationId;
|
||
// Dynamic consist: a slot's physical wagon may ride from the train's origin
|
||
// OR already sit at the booking's own boarding yard and attach there — so
|
||
// the usable fleet is the union across the origin and every boarding yard.
|
||
const boardYardIds = [
|
||
...new Set(
|
||
[originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
|
||
),
|
||
];
|
||
const fleetCountsByYard = await Promise.all(
|
||
boardYardIds.map((yardId) =>
|
||
this.countFleetAvailability(yardId, targetScheduleId),
|
||
),
|
||
);
|
||
const mergedFleet = new Map<string, { code: string; available: number }>();
|
||
for (const rows of fleetCountsByYard) {
|
||
for (const row of rows) {
|
||
const existing = mergedFleet.get(row.wagonTypeId) ?? {
|
||
code: row.wagonTypeCode,
|
||
available: 0,
|
||
};
|
||
existing.available += row.available;
|
||
mergedFleet.set(row.wagonTypeId, existing);
|
||
}
|
||
}
|
||
const fleetCounts = [...mergedFleet.entries()].map(
|
||
([wagonTypeId, value]) => ({
|
||
wagonTypeId,
|
||
wagonTypeCode: value.code,
|
||
available: value.available,
|
||
}),
|
||
);
|
||
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
|
||
fleetAvailability = computeFleetAvailability(
|
||
demandPlan,
|
||
fleetByTypeId,
|
||
new Map(fleetCounts.map((row) => [row.wagonTypeId, row.wagonTypeCode])),
|
||
);
|
||
|
||
const selection = selectBookingsWithinFleetCap(
|
||
bookings,
|
||
fleetByTypeId,
|
||
(booking) =>
|
||
booking.freightType === 'BULK' ? bulkWagonType.id : containerWagonType.id,
|
||
Number(bulkWagonType.capacityTons),
|
||
);
|
||
fittingBookings = selection.fitting;
|
||
deferredBookings = selection.deferred;
|
||
warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings));
|
||
|
||
const wagonPlan = buildCappedWagonPlan({
|
||
bookings: fittingBookings,
|
||
resolvedMode,
|
||
containerWagonType,
|
||
bulkWagonType,
|
||
});
|
||
this.stampSlotLegs(
|
||
wagonPlan,
|
||
fittingBookings,
|
||
dto.originStationId,
|
||
dto.destinationStationId,
|
||
);
|
||
|
||
violations.push(
|
||
...(await this.validatePhysicalFleetForPlan(
|
||
wagonPlan,
|
||
originYardId,
|
||
targetScheduleId,
|
||
)),
|
||
);
|
||
|
||
const placementRules = {
|
||
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
|
||
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
||
};
|
||
|
||
// With forceAssign, capacity-shaped rules (train limits, total weight,
|
||
// locomotive capability) become warnings — staff owns the override. Physical
|
||
// impossibilities (no wagon of the required type at the yard, wrong route,
|
||
// wrong status) can never be forced and stay violations.
|
||
const pushLimit = (issues: string[]) =>
|
||
forceAssign ? warnings.push(...issues) : violations.push(...issues);
|
||
|
||
if (resolvedMode === 'MIXED') {
|
||
pushLimit(
|
||
validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
|
||
);
|
||
if (requireContainerPlacements) {
|
||
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
|
||
violations.push(
|
||
...validateContainerPlacements(
|
||
containerBookings,
|
||
wagonPlan,
|
||
containerPlacements,
|
||
placementRules,
|
||
),
|
||
);
|
||
violations.push(
|
||
...(await this.validateFleetContainers(containerPlacements, containerBookings)),
|
||
);
|
||
}
|
||
} else {
|
||
pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits));
|
||
|
||
if (requireContainerPlacements && resolvedMode === 'CONTAINER') {
|
||
violations.push(
|
||
...validateContainerPlacements(
|
||
fittingBookings,
|
||
wagonPlan,
|
||
containerPlacements,
|
||
placementRules,
|
||
),
|
||
);
|
||
violations.push(
|
||
...(await this.validateFleetContainers(containerPlacements, fittingBookings)),
|
||
);
|
||
}
|
||
}
|
||
|
||
const totalWeightTons = totalAssignedWeight(fittingBookings);
|
||
const totalLengthMeters = roundTons(
|
||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||
);
|
||
if (totalWeightTons > trainLimits.maxWeightTons) {
|
||
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||
if (!violations.includes(message) && !warnings.includes(message)) {
|
||
pushLimit([message]);
|
||
}
|
||
}
|
||
|
||
let assignedLocomotives: Locomotive[] = [];
|
||
if (targetScheduleId) {
|
||
const targetSchedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
|
||
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
|
||
}
|
||
|
||
if (assignedLocomotives.length) {
|
||
// Advance scheduling: a locomotive that hasn't reached the origin yard yet is a
|
||
// warning (it must arrive before dispatch), but a set too weak to pull the train
|
||
// is a hard violation.
|
||
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
|
||
const setLimits = minLocomotiveLimits(assignedLocomotives);
|
||
if (offYard) {
|
||
warnings.push(
|
||
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
|
||
);
|
||
}
|
||
if (
|
||
setLimits &&
|
||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||
totalWeightTons ||
|
||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||
totalLengthMeters)
|
||
) {
|
||
pushLimit([
|
||
'Assigned locomotives cannot support the total train weight and length',
|
||
]);
|
||
}
|
||
} else {
|
||
const inServiceLocomotives = await this.locomotivesRepository.findAll({
|
||
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||
});
|
||
if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) {
|
||
warnings.push(
|
||
'No locomotive is at the schedule origin yard yet; one must arrive before dispatch',
|
||
);
|
||
}
|
||
if (
|
||
!inServiceLocomotives.some(
|
||
(l) =>
|
||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||
totalWeightTons &&
|
||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||
totalLengthMeters,
|
||
)
|
||
) {
|
||
pushLimit(['No locomotive can support the total train weight and length']);
|
||
}
|
||
}
|
||
|
||
return {
|
||
valid: violations.length === 0,
|
||
violations,
|
||
warnings,
|
||
bookings: fittingBookings,
|
||
wagonType,
|
||
wagonPlan,
|
||
fleetAvailability,
|
||
deferredBookings,
|
||
summary: {
|
||
totalBookings: fittingBookings.length,
|
||
totalWeightTons,
|
||
wagonType:
|
||
resolvedMode === 'MIXED' ? 'MIXED' : wagonType.code,
|
||
wagonsNeeded: wagonPlan.length,
|
||
totalLengthMeters,
|
||
freightMode: resolvedMode,
|
||
},
|
||
};
|
||
}
|
||
|
||
private async loadGlobalRulesRow(): Promise<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?: Pick<
|
||
Locomotive,
|
||
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
|
||
>,
|
||
): Promise<Required<TrainLimitConfig>> {
|
||
const row = await this.loadGlobalRulesRow();
|
||
const configured = this.configService?.get<{
|
||
maxTrainWeightTons?: number;
|
||
maxTrainLengthMeters?: number;
|
||
maxWagonsPerTrain?: number;
|
||
}>('app.trainScheduling');
|
||
|
||
const ruleWeightCap =
|
||
dto?.maxTrainWeightTons ??
|
||
(row?.maxTrainWeightTons != null
|
||
? Number(row.maxTrainWeightTons)
|
||
: configured?.maxTrainWeightTons);
|
||
const ruleLengthCap =
|
||
dto?.maxTrainLengthMeters ??
|
||
(row?.maxTrainLengthMeters != null
|
||
? Number(row.maxTrainLengthMeters)
|
||
: configured?.maxTrainLengthMeters);
|
||
|
||
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
||
|
||
if (locomotive) {
|
||
const derived = deriveTrainCapacityFromLocomotive(
|
||
{
|
||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
|
||
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
||
},
|
||
wagonTypes,
|
||
{
|
||
maxTrainWeightTons: ruleWeightCap,
|
||
maxTrainLengthMeters: ruleLengthCap,
|
||
},
|
||
);
|
||
return {
|
||
maxWeightTons: derived.maxWeightTons,
|
||
maxLengthMeters: derived.maxLengthMeters,
|
||
maxWagonsPerTrain:
|
||
dto?.maxWagonsPerTrain != null
|
||
? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
|
||
: derived.maxWagonSlots,
|
||
max20ftContainerWeightTons: this.positiveNumber(
|
||
undefined,
|
||
Number(row?.max20ftContainerWeightTons) ||
|
||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
||
),
|
||
max20ftPairWeightDiffTons: this.positiveNumber(
|
||
undefined,
|
||
Number(row?.max20ftPairWeightDiffTons) ||
|
||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
||
),
|
||
};
|
||
}
|
||
|
||
const maxWeightTons = this.positiveNumber(
|
||
dto?.maxTrainWeightTons,
|
||
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
|
||
);
|
||
const maxLengthMeters = this.positiveNumber(
|
||
dto?.maxTrainLengthMeters,
|
||
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
|
||
);
|
||
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
|
||
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
|
||
wagonTypes,
|
||
);
|
||
|
||
return {
|
||
maxWeightTons,
|
||
maxLengthMeters,
|
||
maxWagonsPerTrain: Math.floor(
|
||
this.positiveNumber(
|
||
dto?.maxWagonsPerTrain,
|
||
row?.maxWagonsPerTrain != null
|
||
? Number(row.maxWagonsPerTrain)
|
||
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
|
||
),
|
||
),
|
||
max20ftContainerWeightTons: this.positiveNumber(
|
||
undefined,
|
||
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
||
),
|
||
max20ftPairWeightDiffTons: this.positiveNumber(
|
||
undefined,
|
||
Number(row?.max20ftPairWeightDiffTons) ||
|
||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Every active wagon type: the slot count derives from the shortest wagon the
|
||
* fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at
|
||
* 12.228m) and under-report how many wagons the train length allows.
|
||
*/
|
||
private async loadSchedulingWagonTypeDimensions(): Promise<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,
|
||
},
|
||
];
|
||
}
|
||
|
||
private async countFleetAvailability(
|
||
originYardId: string,
|
||
targetScheduleId?: string,
|
||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||
const [wagons, wagonTypes] = await Promise.all([
|
||
this.dataSource.getRepository(Wagon).find(),
|
||
this.dataSource.getRepository(WagonType).find(),
|
||
]);
|
||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||
const counts = new Map<string, { code: string; available: number }>();
|
||
|
||
for (const wagon of wagons) {
|
||
const pinnedOnTarget = targetScheduleId
|
||
? wagon.currentTrainScheduleId === targetScheduleId
|
||
: false;
|
||
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
|
||
if (wagon.currentYardId !== originYardId) continue;
|
||
|
||
const typeId = wagon.wagonTypeId;
|
||
const code = typeCodeById.get(typeId) ?? typeId;
|
||
const existing = counts.get(typeId) ?? { code, available: 0 };
|
||
existing.available += 1;
|
||
counts.set(typeId, existing);
|
||
}
|
||
|
||
return [...counts.entries()].map(([wagonTypeId, value]) => ({
|
||
wagonTypeId,
|
||
wagonTypeCode: value.code,
|
||
available: value.available,
|
||
}));
|
||
}
|
||
|
||
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
|
||
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
||
for (const slot of slots) {
|
||
if (!slot.physicalWagonId) continue;
|
||
await manager.getRepository(Wagon).update(slot.physicalWagonId, {
|
||
status: WagonStatus.Available,
|
||
trainSetWagonId: null,
|
||
currentTrainScheduleId: null,
|
||
});
|
||
}
|
||
}
|
||
|
||
private async autoPinWagonsForSchedule(
|
||
manager: EntityManager,
|
||
scheduleId: string,
|
||
originYardId: string,
|
||
slots: TrainSetWagon[],
|
||
) {
|
||
const wagons = await manager.getRepository(Wagon).find();
|
||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
|
||
|
||
const planSlots = [...slots]
|
||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||
.map((slot) => ({
|
||
sequenceNo: slot.sequenceNo,
|
||
wagonTypeId: slot.wagonTypeId,
|
||
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
|
||
trainSetWagonId: slot.id,
|
||
boardYardId: slot.boardYardId ?? null,
|
||
}));
|
||
|
||
const unpinnable = this.findUnpinnableWagonSlots(
|
||
planSlots,
|
||
wagons,
|
||
scheduleId,
|
||
originYardId,
|
||
);
|
||
if (unpinnable.length) {
|
||
throw new BadRequestException({
|
||
message: 'Insufficient physical wagons to pin all train slots',
|
||
violations: unpinnable,
|
||
});
|
||
}
|
||
|
||
const assignedPhysicalIds = new Set<string>();
|
||
for (const slot of planSlots) {
|
||
const physical = this.pickPhysicalWagonForSlot(
|
||
slot,
|
||
wagons,
|
||
scheduleId,
|
||
originYardId,
|
||
assignedPhysicalIds,
|
||
);
|
||
if (!physical) continue;
|
||
|
||
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
|
||
physicalWagonId: physical.id,
|
||
status: 'RESERVED',
|
||
});
|
||
await manager.getRepository(Wagon).update(physical.id, {
|
||
trainSetWagonId: slot.trainSetWagonId,
|
||
currentTrainScheduleId: scheduleId,
|
||
status: WagonStatus.Assigned,
|
||
});
|
||
assignedPhysicalIds.add(physical.id);
|
||
}
|
||
}
|
||
|
||
/** Pre-assign check: every planned slot must have a matching physical wagon. */
|
||
private async validatePhysicalFleetForPlan(
|
||
wagonPlan: WagonPlanSlot[],
|
||
originYardId: string,
|
||
targetScheduleId?: string,
|
||
): Promise<string[]> {
|
||
if (!wagonPlan.length) return [];
|
||
|
||
const wagons = await this.dataSource.getRepository(Wagon).find();
|
||
return this.findUnpinnableWagonSlots(
|
||
wagonPlan.map((slot) => ({
|
||
sequenceNo: slot.sequenceNo,
|
||
wagonTypeId: slot.wagonTypeId,
|
||
wagonTypeCode: slot.wagonTypeCode,
|
||
boardYardId: slot.boardYardId ?? null,
|
||
})),
|
||
wagons,
|
||
targetScheduleId,
|
||
originYardId,
|
||
);
|
||
}
|
||
|
||
private findUnpinnableWagonSlots(
|
||
slots: Array<{
|
||
sequenceNo: number;
|
||
wagonTypeId: string;
|
||
wagonTypeCode: string;
|
||
boardYardId?: string | null;
|
||
}>,
|
||
wagons: Wagon[],
|
||
scheduleId: string | undefined,
|
||
originYardId: string,
|
||
): string[] {
|
||
const violations: string[] = [];
|
||
const assignedPhysicalIds = new Set<string>();
|
||
|
||
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
|
||
const physical = this.pickPhysicalWagonForSlot(
|
||
slot,
|
||
wagons,
|
||
scheduleId,
|
||
originYardId,
|
||
assignedPhysicalIds,
|
||
);
|
||
if (!physical) {
|
||
violations.push(
|
||
`No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`,
|
||
);
|
||
continue;
|
||
}
|
||
assignedPhysicalIds.add(physical.id);
|
||
}
|
||
|
||
return violations;
|
||
}
|
||
|
||
/**
|
||
* Dynamic consist: a slot's wagon may either ride from the train's origin
|
||
* yard (attaching there, possibly empty until the slot's board yard) or
|
||
* already sit AT the slot's board yard and hook on when the train arrives.
|
||
*/
|
||
private pickPhysicalWagonForSlot(
|
||
slot: { wagonTypeId: string; boardYardId?: string | null },
|
||
wagons: Wagon[],
|
||
scheduleId: string | undefined,
|
||
originYardId: string,
|
||
assignedPhysicalIds: Set<string>,
|
||
): Wagon | undefined {
|
||
const usable = (wagon: Wagon): boolean => {
|
||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||
const pinnedOnSchedule = scheduleId
|
||
? wagon.currentTrainScheduleId === scheduleId
|
||
: false;
|
||
return wagon.status === WagonStatus.Available || pinnedOnSchedule;
|
||
};
|
||
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
|
||
// fall back to one riding from the train's origin.
|
||
if (slot.boardYardId) {
|
||
const atBoardYard = wagons.find(
|
||
(w) => usable(w) && w.currentYardId === slot.boardYardId,
|
||
);
|
||
if (atBoardYard) return atBoardYard;
|
||
}
|
||
return wagons.find((w) => usable(w) && w.currentYardId === originYardId);
|
||
}
|
||
|
||
private positiveNumber(value: number | undefined, fallback: number): number {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
||
}
|
||
|
||
private async validateFleetContainers(
|
||
placements: ContainerPlacementInput[],
|
||
containerBookings: Booking[],
|
||
): Promise<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.
|
||
*/
|
||
private async resolveWagonType(
|
||
freightType: 'CONTAINER' | 'BULK',
|
||
bookingIds: string[],
|
||
): Promise<WagonType> {
|
||
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
|
||
|
||
if (freightType === 'CONTAINER') {
|
||
// First container type present on the batch drives the container wagon
|
||
// type (matches the prior single-wagon-type-per-consist behavior).
|
||
const containerType = bookings
|
||
.flatMap((b) => b.bookingContainers ?? [])
|
||
.map((line) => line.containerType)
|
||
.find((ct): ct is NonNullable<typeof ct> => Boolean(ct));
|
||
if (!containerType) {
|
||
throw new BadRequestException('No container type found on the container booking(s)');
|
||
}
|
||
const wagonType = await this.loadWagonTypeForType(
|
||
containerType.wagonTypeId ?? null,
|
||
`Container type "${containerType.label ?? containerType.code}"`,
|
||
);
|
||
return wagonType;
|
||
}
|
||
|
||
const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct));
|
||
if (!cargoType) {
|
||
throw new BadRequestException('No cargo type found on the bulk booking(s)');
|
||
}
|
||
return this.loadWagonTypeForType(
|
||
cargoType.wagonTypeId ?? null,
|
||
`Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Load an active wagon type by FK id, throwing a clear error when the id is
|
||
* unset (type not configured) or points at a missing/inactive wagon type.
|
||
*/
|
||
private async loadWagonTypeForType(
|
||
wagonTypeId: string | null,
|
||
typeLabel: string,
|
||
): Promise<WagonType> {
|
||
if (!wagonTypeId) {
|
||
throw new BadRequestException(
|
||
`${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`,
|
||
);
|
||
}
|
||
const [wagonType] = await this.wagonTypesRepository.findAll({
|
||
where: { id: wagonTypeId, isActive: true },
|
||
});
|
||
if (!wagonType) {
|
||
throw new NotFoundException(
|
||
`${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`,
|
||
);
|
||
}
|
||
return wagonType;
|
||
}
|
||
|
||
/**
|
||
* Stamp each plan slot with the leg it occupies (dynamic consist): the
|
||
* boarding/alighting yards of the bookings it carries. Null means the
|
||
* schedule's own endpoint (whole-route slot, legacy behavior). A slot
|
||
* carrying bookings with mixed corridors stays whole-route (conservative).
|
||
*/
|
||
private stampSlotLegs(
|
||
wagonPlan: WagonPlanSlot[],
|
||
bookings: Booking[],
|
||
scheduleOriginYardId: string,
|
||
scheduleDestinationYardId: string,
|
||
): void {
|
||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||
for (const slot of wagonPlan) {
|
||
const slotBookings = [
|
||
...new Set(slot.allocations.map((a) => a.bookingId)),
|
||
]
|
||
.map((id) => bookingById.get(id))
|
||
.filter((b): b is Booking => Boolean(b));
|
||
if (!slotBookings.length) continue;
|
||
const [first] = slotBookings;
|
||
const sameCorridor = slotBookings.every(
|
||
(b) =>
|
||
b.originYardId === first.originYardId &&
|
||
b.destinationYardId === first.destinationYardId,
|
||
);
|
||
if (!sameCorridor) continue;
|
||
slot.boardYardId =
|
||
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
|
||
slot.alightYardId =
|
||
first.destinationYardId === scheduleDestinationYardId
|
||
? null
|
||
: first.destinationYardId;
|
||
}
|
||
}
|
||
|
||
private async persistTrainSetWagons(
|
||
manager: EntityManager,
|
||
trainSetId: string,
|
||
wagonType: WagonType,
|
||
wagonPlan: WagonPlanSlot[],
|
||
) {
|
||
const wagons = wagonPlan.map((slot) =>
|
||
manager.getRepository(TrainSetWagon).create({
|
||
trainSetId,
|
||
wagonTypeId: slot.wagonTypeId ?? wagonType.id,
|
||
sequenceNo: slot.sequenceNo,
|
||
capacityTons: slot.capacityTons,
|
||
lengthMeters: slot.lengthMeters,
|
||
assignedWeightTons: slot.assignedWeightTons,
|
||
status: 'PLANNED',
|
||
boardYardId: slot.boardYardId ?? null,
|
||
alightYardId: slot.alightYardId ?? null,
|
||
}),
|
||
);
|
||
return manager.getRepository(TrainSetWagon).save(wagons);
|
||
}
|
||
|
||
private async persistAllocationsAndLoads(
|
||
manager: EntityManager,
|
||
savedWagons: TrainSetWagon[],
|
||
wagonPlan: WagonPlanSlot[],
|
||
bookings: Booking[],
|
||
containerPlacements: ContainerPlacementInput[] = [],
|
||
) {
|
||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||
const lineById = new Map(
|
||
bookings.flatMap((b) =>
|
||
(b.bookingContainers ?? []).map((line) => [line.id, { line, bookingId: b.id }] as const),
|
||
),
|
||
);
|
||
const allocationBySlotBooking = new Map<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[]) {
|
||
const [primary] = locomotives;
|
||
const trainSet = manager.getRepository(TrainSet).create({
|
||
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
|
||
locomotiveId: primary.id,
|
||
totalWeightTons: 0,
|
||
totalLengthMeters: 0,
|
||
wagonCount: 0,
|
||
status: 'DRAFT',
|
||
});
|
||
const saved = await manager.getRepository(TrainSet).save(trainSet);
|
||
|
||
const links = locomotives.map((loco, index) =>
|
||
manager.getRepository(TrainSetLocomotive).create({
|
||
trainSetId: saved.id,
|
||
locomotiveId: loco.id,
|
||
sequenceNo: index,
|
||
}),
|
||
);
|
||
await manager.getRepository(TrainSetLocomotive).save(links);
|
||
|
||
return saved;
|
||
}
|
||
|
||
private async getSchedulableRoute(routeId: string) {
|
||
const route = await this.dataSource.getRepository(Route).findOne({
|
||
where: { id: routeId },
|
||
relations: { originYard: true, destinationYard: true },
|
||
});
|
||
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
|
||
if (route.status !== 'AVAILABLE') {
|
||
throw new BadRequestException(
|
||
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
|
||
);
|
||
}
|
||
// Intercity (same-country) service is not offered yet — only import/export
|
||
// trains can be scheduled.
|
||
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
|
||
throw new BadRequestException(
|
||
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
|
||
);
|
||
}
|
||
return route;
|
||
}
|
||
|
||
/** Stored route direction, deriving from yard countries for pre-migration rows. */
|
||
private resolveRouteDirection(route: Route) {
|
||
return (
|
||
route.direction ??
|
||
deriveScheduleDirection(
|
||
route.originYard ?? { country: null },
|
||
route.destinationYard ?? { country: null },
|
||
)
|
||
);
|
||
}
|
||
|
||
private mapEligibleBooking(booking: Booking) {
|
||
return {
|
||
id: booking.id,
|
||
reference: booking.reference,
|
||
freightType: booking.freightType,
|
||
customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer',
|
||
priorityScore: booking.priorityScore,
|
||
schedulingStatus: booking.schedulingStatus,
|
||
containerType:
|
||
booking.bookingContainers
|
||
?.map((c) => c.containerType?.label ?? c.containerType?.code ?? 'Container')
|
||
.join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'),
|
||
quantity:
|
||
booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0,
|
||
weightTons: roundTons(booking.cargoTotalWeightVgm),
|
||
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||
destination:
|
||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
|
||
status: booking.status,
|
||
};
|
||
}
|
||
|
||
private resolveScheduleFreightType(
|
||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
||
const types = new Set(
|
||
(schedule.scheduleBookings ?? [])
|
||
.map((sb) => sb.booking?.freightType)
|
||
.filter((t): t is string => Boolean(t)),
|
||
);
|
||
if (types.size === 1) return [...types][0] as 'CONTAINER' | 'BULK';
|
||
if (types.size > 1) return 'MIXED';
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
|
||
* past a concurrent insert that grabbed the same sequence (the unique index
|
||
* rejects the loser). Mirrors insertWithGeneratedReference for bookings, but
|
||
* runs inside the caller's transaction manager so the row joins the same commit.
|
||
*/
|
||
private async insertScheduleWithReference(
|
||
manager: EntityManager,
|
||
build: (reference: string) => TrainSchedule,
|
||
): Promise<TrainSchedule> {
|
||
const year = new Date().getFullYear();
|
||
const repo = manager.getRepository(TrainSchedule);
|
||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||
const seq = await this.trainSchedulesRepository.maxReferenceSequence(year);
|
||
const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||
try {
|
||
return await repo.save(build(reference));
|
||
} catch (err) {
|
||
// 23505 = unique_violation on ux_train_schedules_reference; re-read + retry.
|
||
const code = (err as { driverError?: { code?: string } })?.driverError?.code;
|
||
if (err instanceof QueryFailedError && code === '23505' && attempt < 4) {
|
||
continue;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
// Unreachable — the loop either returns or throws — but satisfies the compiler.
|
||
throw new ConflictException('Could not allocate a unique schedule reference');
|
||
}
|
||
|
||
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||
return {
|
||
id: schedule.id,
|
||
reference: schedule.reference ?? null,
|
||
createdAt: schedule.createdAt ?? null,
|
||
scheduleDate: schedule.scheduledDepartureDate,
|
||
trainNumber: schedule.trainNumber ?? null,
|
||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||
destination:
|
||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||
locomotive: schedule.trainSet?.locomotive
|
||
? {
|
||
id: schedule.trainSet.locomotive.id,
|
||
code: schedule.trainSet.locomotive.code,
|
||
name: schedule.trainSet.locomotive.name ?? null,
|
||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||
}
|
||
: null,
|
||
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
|
||
id: loco.id,
|
||
code: loco.code,
|
||
name: loco.name ?? null,
|
||
currentYardId: loco.currentYardId ?? null,
|
||
})),
|
||
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
||
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
||
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
||
freightType: this.resolveScheduleFreightType(schedule),
|
||
status: schedule.status,
|
||
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
|
||
maxWagons: schedule.maxWagons ?? 0,
|
||
remainingWagons: Math.max(
|
||
0,
|
||
(schedule.maxWagons ?? 0) - (schedule.trainSet?.wagonCount ?? 0),
|
||
),
|
||
};
|
||
}
|
||
|
||
/** AVAILABLE locomotives at the route's origin yard. */
|
||
/**
|
||
* All in-service locomotives, annotated for the schedule-creation picker.
|
||
* Advance scheduling means nothing is filtered out — staff see status, whether the
|
||
* locomotive is at the origin yard yet, and how many future schedules it already has.
|
||
*/
|
||
async getAvailableLocomotivesForRoute(routeId: string) {
|
||
const route = await this.getSchedulableRoute(routeId);
|
||
|
||
const locomotives = await this.locomotivesRepository.findAll({
|
||
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||
order: { code: 'ASC' },
|
||
});
|
||
|
||
const counts: { locomotive_id: string; future_count: string }[] = locomotives.length
|
||
? await this.dataSource.query(
|
||
`SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||
JOIN (
|
||
SELECT tsl.train_set_id, tsl.locomotive_id
|
||
FROM freight.train_set_locomotives tsl
|
||
WHERE tsl.deleted_at IS NULL
|
||
UNION
|
||
SELECT t.id AS train_set_id, t.locomotive_id
|
||
FROM freight.train_sets t
|
||
WHERE t.locomotive_id IS NOT NULL
|
||
) loco ON loco.train_set_id = tset.id
|
||
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
|
||
AND ts.deleted_at IS NULL
|
||
AND loco.locomotive_id = ANY($1)
|
||
GROUP BY loco.locomotive_id`,
|
||
[locomotives.map((l) => l.id)],
|
||
)
|
||
: [];
|
||
const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)]));
|
||
|
||
return locomotives.map((loco) => ({
|
||
...loco,
|
||
atOriginYard: loco.currentYardId === route.originYardId,
|
||
futureScheduleCount: futureCounts.get(loco.id) ?? 0,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Upcoming/open booking windows announced on the portal home "booking
|
||
* windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead)
|
||
* are listed so every customer sees what is opening — not just those on their
|
||
* contract lanes; DOMESTIC trains are always open and need no announcement.
|
||
*
|
||
* When `companyId` is given, a matching active contract on the lane is
|
||
* LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling
|
||
* "Book now"); customers with no covering contract still see the window with a
|
||
* null contract, and the portal routes them to the contract list to get one.
|
||
*/
|
||
async getBookingWindowsForCompany(companyId: string | null) {
|
||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||
`SELECT DISTINCT ON (ts.id)
|
||
ts.id AS schedule_id,
|
||
ts.reference AS reference,
|
||
cr.contract_id AS contract_id,
|
||
c.contract_kind AS contract_kind,
|
||
ts.direction,
|
||
ts.window_phase,
|
||
ts.window_opens_at,
|
||
ts.window_closes_at,
|
||
ts.doc_review_ends_at,
|
||
ts.payment_phase_ends_at,
|
||
ts.booking_window_status,
|
||
ts.booking_cycle_no,
|
||
ts.scheduled_departure_date,
|
||
oy.label AS origin_label, oy.code AS origin_code,
|
||
dy.label AS destination_label, dy.code AS destination_code
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.contract_routes cr
|
||
ON cr.origin_yard_id = ts.origin_station_id
|
||
AND cr.destination_yard_id = ts.destination_station_id
|
||
AND cr.deleted_at IS NULL
|
||
LEFT JOIN freight.contracts c
|
||
ON c.id = cr.contract_id
|
||
AND c.company_id = $1
|
||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||
AND c.deleted_at IS NULL
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.deleted_at IS NULL
|
||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||
AND ts.window_phase IS NOT NULL
|
||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||
AND ts.scheduled_departure_date >= now()
|
||
ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`,
|
||
[companyId],
|
||
);
|
||
// Nearest dispatch (departure) date first — the DISTINCT ON above forces a
|
||
// per-row ordering, so re-sort the mapped rows by departure for the client.
|
||
return rows
|
||
.map((r) => this.mapBookingWindowRow(r))
|
||
.sort((a, b) => {
|
||
const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||
const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||
return ta - tb;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Upcoming/open booking windows on a single contract's routes. Used to gate the
|
||
* booking form for the customer AND Ethiopian GL (who books on the customer's
|
||
* behalf): no window row with isOpenNow=true → booking entry is hidden.
|
||
*/
|
||
async getBookingWindowsForContract(contractId: string) {
|
||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||
`SELECT DISTINCT ts.id AS schedule_id,
|
||
ts.reference AS reference,
|
||
cr.contract_id AS contract_id,
|
||
c.contract_kind AS contract_kind,
|
||
ts.direction,
|
||
ts.window_phase,
|
||
ts.window_opens_at,
|
||
ts.window_closes_at,
|
||
ts.doc_review_ends_at,
|
||
ts.payment_phase_ends_at,
|
||
ts.booking_window_status,
|
||
ts.booking_cycle_no,
|
||
ts.scheduled_departure_date,
|
||
oy.label AS origin_label, oy.code AS origin_code,
|
||
dy.label AS destination_label, dy.code AS destination_code
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.contract_routes cr
|
||
ON cr.origin_yard_id = ts.origin_station_id
|
||
AND cr.destination_yard_id = ts.destination_station_id
|
||
AND cr.contract_id = $1
|
||
AND cr.deleted_at IS NULL
|
||
JOIN freight.contracts c
|
||
ON c.id = cr.contract_id
|
||
AND c.deleted_at IS NULL
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.deleted_at IS NULL
|
||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||
AND ts.window_phase IS NOT NULL
|
||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||
AND ts.scheduled_departure_date >= now()
|
||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||
[contractId],
|
||
);
|
||
return rows.map((r) => this.mapBookingWindowRow(r));
|
||
}
|
||
|
||
/**
|
||
* All announced booking windows across every lane — import window cycles AND
|
||
* export FCFS lead windows — for staff dashboards (GL clearance queue). Same
|
||
* phase filter as the customer-facing lists, no contract scoping.
|
||
*/
|
||
async listAllBookingWindows() {
|
||
const rows: Array<
|
||
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
|
||
train_number: string | null;
|
||
}
|
||
> = await this.dataSource.query(
|
||
`SELECT ts.id AS schedule_id,
|
||
ts.reference AS reference,
|
||
ts.train_number,
|
||
ts.direction,
|
||
ts.window_phase,
|
||
ts.window_opens_at,
|
||
ts.window_closes_at,
|
||
ts.doc_review_ends_at,
|
||
ts.payment_phase_ends_at,
|
||
ts.booking_window_status,
|
||
ts.booking_cycle_no,
|
||
ts.scheduled_departure_date,
|
||
oy.label AS origin_label, oy.code AS origin_code,
|
||
dy.label AS destination_label, dy.code AS destination_code
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.deleted_at IS NULL
|
||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||
AND ts.window_phase IS NOT NULL
|
||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||
AND ts.scheduled_departure_date >= now()
|
||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||
);
|
||
return rows.map((r) => ({
|
||
...this.mapBookingWindowRow({
|
||
...r,
|
||
contract_id: null,
|
||
contract_kind: null,
|
||
}),
|
||
trainNumber: r.train_number,
|
||
}));
|
||
}
|
||
|
||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||
return {
|
||
scheduleId: r.schedule_id,
|
||
reference: r.reference ?? null,
|
||
contractId: r.contract_id,
|
||
contractKind: r.contract_kind,
|
||
direction: r.direction,
|
||
windowPhase: r.window_phase,
|
||
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
|
||
windowOpensAt: r.window_opens_at,
|
||
windowClosesAt: r.window_closes_at,
|
||
docReviewEndsAt: r.doc_review_ends_at,
|
||
paymentPhaseEndsAt: r.payment_phase_ends_at,
|
||
bookingWindowStatus: r.booking_window_status,
|
||
bookingCycleNo: r.booking_cycle_no,
|
||
departureDate: r.scheduled_departure_date,
|
||
origin: r.origin_label ?? r.origin_code ?? null,
|
||
destination: r.destination_label ?? r.destination_code ?? null,
|
||
};
|
||
}
|
||
|
||
/** OPEN schedules a new booking may target (with rough remaining capacity).
|
||
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
|
||
* returns schedules whose route passes through both yards in the correct order.
|
||
*/
|
||
/**
|
||
* Raw OPEN same-route schedule entities a new booking may target (with the
|
||
* relations needed for capacity/fleet checks). Shared by getBookableSchedules
|
||
* (which maps to list items) and getAvailableDaysForCargo (which needs the raw
|
||
* originStationId / scheduledDepartureDate / trainSet).
|
||
*/
|
||
private async getBookableScheduleEntities(
|
||
originYardId?: string,
|
||
destinationYardId?: string,
|
||
): Promise<
|
||
import('../train-schedules/entities/train-schedule.entity').TrainSchedule[]
|
||
> {
|
||
const schedules = await this.trainSchedulesRepository.findAll({
|
||
where: {
|
||
bookingWindowStatus: 'OPEN',
|
||
},
|
||
relations: {
|
||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||
route: { milestones: true },
|
||
originStation: true,
|
||
destinationStation: true,
|
||
scheduleBookings: { booking: true },
|
||
},
|
||
order: { scheduledDepartureDate: 'ASC' },
|
||
});
|
||
|
||
// A train that has already departed can never be booked, even if the window
|
||
// engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the
|
||
// `scheduled_departure_date >= now()` guard the booking-window SQL uses so a
|
||
// past-departure schedule never leaks into the portal day pool, the schedule
|
||
// calendar, or the ET GL create-booking gate.
|
||
const now = new Date();
|
||
return schedules
|
||
.filter(
|
||
(s) =>
|
||
s.scheduledDepartureDate != null &&
|
||
s.scheduledDepartureDate > now,
|
||
)
|
||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||
.filter((s) => {
|
||
// Build the full stop list: origin -> milestones (ordered) -> destination
|
||
const milestones = s.route?.milestones ?? [];
|
||
const sortedMilestones = [...milestones].sort((a, b) => a.sequenceNo - b.sequenceNo);
|
||
const stopYardIds = [s.originStationId, ...sortedMilestones.map((m) => m.yardId), s.destinationStationId];
|
||
|
||
// Remove duplicates while preserving order (in case origin/destination appears in milestones)
|
||
const uniqueStopYardIds: string[] = [];
|
||
for (const yardId of stopYardIds) {
|
||
if (!uniqueStopYardIds.includes(yardId)) {
|
||
uniqueStopYardIds.push(yardId);
|
||
}
|
||
}
|
||
|
||
// Check origin yard filter
|
||
if (originYardId) {
|
||
if (!uniqueStopYardIds.includes(originYardId)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Check destination yard filter
|
||
if (destinationYardId) {
|
||
if (!uniqueStopYardIds.includes(destinationYardId)) {
|
||
return false;
|
||
}
|
||
// Ensure destination comes after origin (if both are specified)
|
||
if (originYardId) {
|
||
const originIndex = uniqueStopYardIds.indexOf(originYardId);
|
||
const destIndex = uniqueStopYardIds.indexOf(destinationYardId);
|
||
if (destIndex <= originIndex) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}
|
||
|
||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||
const schedules = await this.getBookableScheduleEntities(
|
||
originYardId,
|
||
destinationYardId,
|
||
);
|
||
return schedules.map((s) => this.mapScheduleListItem(s));
|
||
}
|
||
|
||
/**
|
||
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
|
||
* departure on the route. Customers pick a DAY (not a train) — so this returns
|
||
* only the day strings, no capacity, counts or train info.
|
||
*/
|
||
async getAvailableDays(
|
||
originYardId?: string,
|
||
destinationYardId?: string,
|
||
): Promise<{ days: string[] }> {
|
||
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
|
||
const days = new Set<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). Wagon availability is deliberately NOT
|
||
* checked here: whether a matching wagon currently sits in the right yard is an
|
||
* operational question staff resolve when they approve or reject the booking,
|
||
* not something the customer can act on while choosing a date. Same
|
||
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
|
||
* not a train.
|
||
*/
|
||
async getAvailableDaysForCargo(input: {
|
||
originYardId?: string;
|
||
destinationYardId?: string;
|
||
freightType: 'CONTAINER' | 'BULK';
|
||
cargoTypeCode?: string | null;
|
||
totalWeightTons?: number;
|
||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||
}): Promise<{ days: string[] }> {
|
||
const schedules = await this.getBookableScheduleEntities(
|
||
input.originYardId,
|
||
input.destinationYardId,
|
||
);
|
||
if (schedules.length === 0) return { days: [] };
|
||
|
||
const days = new Set<string>();
|
||
for (const s of schedules) {
|
||
const hasCapacity =
|
||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||
if (!hasCapacity) continue;
|
||
if (s.scheduledDepartureDate)
|
||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||
}
|
||
return { days: [...days].sort() };
|
||
}
|
||
|
||
/**
|
||
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||
* has no route milestones. Shared by corridor (sub-leg) validation everywhere.
|
||
*/
|
||
async stopYardsForSchedule(schedule: TrainSchedule): Promise<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',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async mapScheduleDetail(
|
||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||
) {
|
||
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
|
||
(w) => w.allocations ?? [],
|
||
);
|
||
const allocationIds = allocations.map((a) => a.id);
|
||
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
|
||
|
||
// Import-from-Djibouti trains can only dispatch once loading is confirmed
|
||
// (loadedOnTrainAt on the operation). Other directions have no departure
|
||
// loading gate, so the workspace shows the confirm button as already done.
|
||
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
|
||
let loadingConfirmed = !requiresLoadingConfirmation;
|
||
if (requiresLoadingConfirmation) {
|
||
const op = await this.dataSource
|
||
.getRepository(ImportDjiboutiOperation)
|
||
.findOne({ where: { trainScheduleId: schedule.id } });
|
||
loadingConfirmed = Boolean(op?.loadedOnTrainAt);
|
||
}
|
||
|
||
const windowCfg = await this.getWindowConfig();
|
||
|
||
const [containerItems, bulkLoads] = await Promise.all([
|
||
allocationIds.length
|
||
? this.wagonAllocationContainerItemsRepository.findAll({
|
||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||
relations: { containerType: true, bookingContainer: true },
|
||
})
|
||
: [],
|
||
allocationIds.length
|
||
? this.wagonAllocationBulkLoadsRepository.findAll({
|
||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||
relations: { cargoType: true },
|
||
})
|
||
: [],
|
||
]);
|
||
|
||
const containerItemsByAllocation = new Map<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]),
|
||
);
|
||
|
||
return {
|
||
id: schedule.id,
|
||
reference: schedule.reference ?? null,
|
||
status: schedule.status,
|
||
freightType: this.resolveScheduleFreightType(schedule),
|
||
trainNumber: schedule.trainNumber ?? null,
|
||
direction: schedule.direction ?? null,
|
||
requiresLoadingConfirmation,
|
||
loadingConfirmed,
|
||
// Booking-window phase + phase deadlines drive the countdown timers in the
|
||
// operations workspace (display only — the window engine enforces them).
|
||
windowPhase: schedule.windowPhase ?? null,
|
||
windowOpensAt: schedule.windowOpensAt
|
||
? schedule.windowOpensAt.toISOString()
|
||
: null,
|
||
windowClosesAt: schedule.windowClosesAt
|
||
? schedule.windowClosesAt.toISOString()
|
||
: null,
|
||
docReviewEndsAt: schedule.docReviewEndsAt
|
||
? schedule.docReviewEndsAt.toISOString()
|
||
: null,
|
||
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
|
||
? schedule.paymentPhaseEndsAt.toISOString()
|
||
: null,
|
||
// Per-schedule booking-window rule snapshot — powers the "Booking window
|
||
// settings" editor on the ops board (prefill + save one schedule's
|
||
// override). docReview/payment are not snapshotted per schedule (only their
|
||
// sum, as reopenDelayMinutes), so the editor prefills them from live config.
|
||
windowRule: {
|
||
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
||
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
||
windowDurationHours:
|
||
schedule.ruleWindowDurationHours != null
|
||
? Number(schedule.ruleWindowDurationHours)
|
||
: null,
|
||
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
|
||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||
docReviewMinutes: windowCfg.docReviewMinutes,
|
||
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
|
||
},
|
||
route: schedule.route
|
||
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
|
||
: null,
|
||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||
actualDepartureAt: schedule.actualDepartureAt ?? null,
|
||
originStation: schedule.originStation,
|
||
destinationStation: schedule.destinationStation,
|
||
trainSet: schedule.trainSet
|
||
? {
|
||
id: schedule.trainSet.id,
|
||
status: schedule.trainSet.status,
|
||
wagonCount: schedule.trainSet.wagonCount,
|
||
totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)),
|
||
totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)),
|
||
locomotive: schedule.trainSet.locomotive
|
||
? {
|
||
id: schedule.trainSet.locomotive.id,
|
||
code: schedule.trainSet.locomotive.code,
|
||
name: schedule.trainSet.locomotive.name,
|
||
status: schedule.trainSet.locomotive.status,
|
||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||
maxPullWeightTons: roundTons(
|
||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||
),
|
||
maxTrainLengthMeters: roundTons(
|
||
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
|
||
),
|
||
}
|
||
: null,
|
||
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
|
||
id: loco.id,
|
||
code: loco.code,
|
||
name: loco.name ?? null,
|
||
status: loco.status,
|
||
currentYardId: loco.currentYardId ?? null,
|
||
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
||
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
||
})),
|
||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||
.map((wagon) => ({
|
||
id: wagon.id,
|
||
sequenceNo: wagon.sequenceNo,
|
||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
||
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
||
status: wagon.status,
|
||
physicalWagonId: wagon.physicalWagonId ?? null,
|
||
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||
wagonType: wagon.wagonType
|
||
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
|
||
: null,
|
||
allocations:
|
||
wagon.allocations?.map((allocation) => ({
|
||
id: allocation.id,
|
||
bookingId: allocation.bookingId,
|
||
bookingReference: allocation.booking?.reference ?? null,
|
||
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
|
||
loadType: allocation.loadType ?? null,
|
||
status: allocation.status,
|
||
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
|
||
(item) => ({
|
||
id: item.id,
|
||
containerNumber: item.containerNumber ?? null,
|
||
containerTypeId: item.containerTypeId,
|
||
grossWeightTons: item.grossWeightTons ?? null,
|
||
containerId: item.containerId ?? null,
|
||
positionOnWagon: item.positionOnWagon ?? null,
|
||
bookingContainerId: item.bookingContainerId ?? null,
|
||
}),
|
||
),
|
||
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
|
||
? {
|
||
id: bulkLoadsByAllocation.get(allocation.id)!.id,
|
||
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
|
||
cargoDescription:
|
||
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
|
||
}
|
||
: null,
|
||
})) ?? [],
|
||
})),
|
||
}
|
||
: null,
|
||
bookings:
|
||
schedule.scheduleBookings?.map((sb) => ({
|
||
id: sb.booking?.id ?? sb.bookingId,
|
||
reference: sb.booking?.reference ?? null,
|
||
customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null,
|
||
weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)),
|
||
status: sb.booking?.status ?? null,
|
||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||
freightType: sb.booking?.freightType ?? null,
|
||
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
||
// booking itself — staff flip it per booking in the workspace before
|
||
// dispatch. Defaults UNLOADED for links written before the column.
|
||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||
})) ?? [],
|
||
};
|
||
}
|
||
|
||
private isHoldActive(booking: Booking): boolean {
|
||
if (!booking.holdExpiresAt) return false;
|
||
return booking.holdExpiresAt.getTime() > Date.now();
|
||
}
|
||
|
||
private resolvePostUnassignStatus(booking: Booking | null): string {
|
||
if (!booking) return SchedulingStatus.NotScheduled;
|
||
if (booking.holdExpiresAt && booking.holdExpiresAt.getTime() > Date.now()) {
|
||
return SchedulingStatus.Holding;
|
||
}
|
||
return SchedulingStatus.Eligible;
|
||
}
|
||
|
||
/** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */
|
||
async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!schedule.trainSet?.locomotive) {
|
||
throw new BadRequestException('Schedule has no locomotive — cannot assign booking');
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Cannot assign bookings to schedule in status ${schedule.status}`,
|
||
);
|
||
}
|
||
|
||
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
|
||
if (!booking) {
|
||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||
}
|
||
if (booking.trainScheduleId !== scheduleId) {
|
||
throw new BadRequestException('Booking is not linked to this schedule');
|
||
}
|
||
if (!this.isReadyToLoadBooking(booking)) {
|
||
throw new BadRequestException('Booking is not paid and ready to load');
|
||
}
|
||
|
||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||
if (wagonAssignedIds.has(bookingId)) {
|
||
throw new BadRequestException('Booking is already assigned to a wagon');
|
||
}
|
||
|
||
const allBookingIds = [...wagonAssignedIds, bookingId];
|
||
const previewDto = {
|
||
bookingIds: allBookingIds,
|
||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
};
|
||
const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive);
|
||
|
||
const validation = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
null,
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
scheduleId,
|
||
);
|
||
|
||
if (!validation.valid) {
|
||
throw new BadRequestException({
|
||
message: `Booking validation failed: ${validation.violations.join('; ')}`,
|
||
violations: validation.violations,
|
||
warnings: validation.warnings,
|
||
});
|
||
}
|
||
|
||
if (!validation.bookings.some((b) => b.id === bookingId)) {
|
||
const deferred = validation.deferredBookings.find((d) => d.id === bookingId);
|
||
throw new BadRequestException({
|
||
message: deferred?.reason ?? 'Booking does not fit on available fleet wagons',
|
||
violations: validation.violations,
|
||
warnings: validation.warnings,
|
||
deferredBookings: validation.deferredBookings,
|
||
});
|
||
}
|
||
|
||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||
const placements = autoFillPlacements(units, slots);
|
||
const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
|
||
(m) => m.bookingId === bookingId,
|
||
);
|
||
if (missingForBooking) {
|
||
throw new BadRequestException({
|
||
message: missingForBooking.issue,
|
||
violations: [missingForBooking.issue],
|
||
});
|
||
}
|
||
|
||
const assignableSet = new Set(validation.bookings.map((b) => b.id));
|
||
const assignPlacements = placementsForBookings(placements, assignableSet, units);
|
||
const needsPlacements = containerBookings.length > 0;
|
||
|
||
return this.assignBookingsToSchedule(
|
||
scheduleId,
|
||
{
|
||
bookingIds: validation.bookings.map((b) => b.id),
|
||
containerPlacements: needsPlacements ? assignPlacements : undefined,
|
||
},
|
||
undefined,
|
||
);
|
||
}
|
||
|
||
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||
async previewAllocationForSchedule(
|
||
scheduleId: string,
|
||
): Promise<WagonAllocationAttemptResult> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
return this.buildAllocationAttempt(schedule, false);
|
||
}
|
||
|
||
/** Assign all eligible linked bookings to wagons; returns per-booking issues. */
|
||
async tryAutoWagonAllocation(
|
||
scheduleId: string,
|
||
): Promise<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);
|
||
const previewDto = {
|
||
bookingIds: eligible.map((b) => b.id),
|
||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
};
|
||
const limits = await this.resolveTrainLimitConfig(
|
||
undefined,
|
||
schedule.trainSet.locomotive,
|
||
);
|
||
|
||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||
try {
|
||
validation = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
null,
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
schedule.id,
|
||
);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : 'Validation failed';
|
||
return {
|
||
...empty,
|
||
violations: [message],
|
||
issues: eligible.map((b) => ({
|
||
bookingId: b.id,
|
||
status: 'FAILED' as const,
|
||
issue: message,
|
||
})),
|
||
};
|
||
}
|
||
|
||
const fittingIds = new Set(validation.bookings.map((b) => b.id));
|
||
const deferredMap = new Map(
|
||
validation.deferredBookings.map((d) => [d.id, d.reason]),
|
||
);
|
||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||
const placements = autoFillPlacements(units, slots);
|
||
const missingNumbers = findMissingContainerNumberIssues(units, placements);
|
||
const missingByBooking = new Map<string, string>();
|
||
for (const m of missingNumbers) {
|
||
if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue);
|
||
}
|
||
const placeholderWarnings = new Map<string, string>();
|
||
for (const p of placements) {
|
||
if (!isPlaceholderContainerNumber(p.containerNumber)) continue;
|
||
const unit = units.find(
|
||
(u) => u.bookingContainerId === p.bookingContainerId && u.unitIndex === p.unitIndex,
|
||
);
|
||
if (unit && !placeholderWarnings.has(unit.bookingId)) {
|
||
placeholderWarnings.set(
|
||
unit.bookingId,
|
||
'Container number auto-assigned — verify before dispatch.',
|
||
);
|
||
}
|
||
}
|
||
|
||
const assignableIds = validation.bookings
|
||
.filter((b) => !missingByBooking.has(b.id))
|
||
.map((b) => b.id);
|
||
const assignableSet = new Set(assignableIds);
|
||
const assignPlacements = placementsForBookings(
|
||
placements,
|
||
assignableSet,
|
||
units,
|
||
);
|
||
|
||
const issues: BookingWagonAllocationIssue[] = eligible.map((b) => {
|
||
const placeholderIssue = placeholderWarnings.get(b.id) ?? null;
|
||
if (wagonAssignedIds.has(b.id) && assignableSet.has(b.id)) {
|
||
return { bookingId: b.id, status: 'ASSIGNED', issue: placeholderIssue };
|
||
}
|
||
if (missingByBooking.has(b.id)) {
|
||
return { bookingId: b.id, status: 'FAILED', issue: missingByBooking.get(b.id)! };
|
||
}
|
||
if (deferredMap.has(b.id)) {
|
||
return { bookingId: b.id, status: 'DEFERRED', issue: deferredMap.get(b.id)! };
|
||
}
|
||
if (!fittingIds.has(b.id)) {
|
||
const refIssue = validation.violations.find((v) => v.includes(b.reference ?? b.id));
|
||
return {
|
||
bookingId: b.id,
|
||
status: 'FAILED',
|
||
issue: refIssue ?? 'Does not fit train capacity or fleet constraints',
|
||
};
|
||
}
|
||
if (wagonAssignedIds.has(b.id)) {
|
||
return { bookingId: b.id, status: 'ASSIGNED', issue: null };
|
||
}
|
||
return { bookingId: b.id, status: 'NOT_ATTEMPTED', issue: null };
|
||
});
|
||
|
||
const result: WagonAllocationAttemptResult = {
|
||
assignedBookingIds: [],
|
||
deferred: validation.deferredBookings,
|
||
issues,
|
||
violations: validation.violations,
|
||
};
|
||
|
||
if (!performAssign || !assignableIds.length) return result;
|
||
|
||
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
|
||
if (needsPlacements && !assignPlacements.length) {
|
||
return {
|
||
...result,
|
||
violations: [...result.violations, 'Container placements could not be generated'],
|
||
};
|
||
}
|
||
|
||
try {
|
||
await this.assignBookingsToSchedule(
|
||
schedule.id,
|
||
{
|
||
bookingIds: assignableIds,
|
||
containerPlacements: needsPlacements ? assignPlacements : undefined,
|
||
},
|
||
undefined,
|
||
);
|
||
result.assignedBookingIds = assignableIds;
|
||
for (const issue of result.issues) {
|
||
if (assignableSet.has(issue.bookingId)) {
|
||
issue.status = 'ASSIGNED';
|
||
issue.issue = placeholderWarnings.get(issue.bookingId) ?? null;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
const message =
|
||
err instanceof BadRequestException
|
||
? ((err.getResponse() as { message?: string; violations?: string[] }).violations?.join(
|
||
'; ',
|
||
) ??
|
||
(err.getResponse() as { message?: string }).message ??
|
||
err.message)
|
||
: err instanceof Error
|
||
? err.message
|
||
: 'Allocation failed';
|
||
result.violations = [...result.violations, message];
|
||
for (const issue of result.issues) {
|
||
if (assignableSet.has(issue.bookingId) && issue.status !== 'ASSIGNED') {
|
||
issue.status = 'FAILED';
|
||
issue.issue = message;
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<any> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException('Cannot remove wagon slots from a finalized or dispatched schedule');
|
||
}
|
||
|
||
const wagon = (schedule.trainSet?.wagons ?? []).find((w) => w.id === trainSetWagonId);
|
||
if (!wagon) {
|
||
throw new NotFoundException(`Train set wagon ${trainSetWagonId} not found in this schedule`);
|
||
}
|
||
|
||
if ((wagon.allocations ?? []).length > 0) {
|
||
throw new BadRequestException(
|
||
'Cannot remove a wagon slot that has active allocations; remove the booking first',
|
||
);
|
||
}
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(TrainSetWagon).delete(trainSetWagonId);
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||
wagonCount: Math.max(0, (schedule.trainSet?.wagonCount ?? 0) - 1),
|
||
totalLengthMeters: Math.max(0, (schedule.trainSet?.totalLengthMeters ?? 0) - (wagon.lengthMeters ?? 0)),
|
||
});
|
||
});
|
||
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async updateContainerItem(
|
||
scheduleId: string,
|
||
itemId: string,
|
||
dto: UpdateContainerItemDto,
|
||
): Promise<{ id: string; containerNumber: string | null }> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status === 'DISPATCHED') {
|
||
throw new BadRequestException('Cannot edit a dispatched schedule');
|
||
}
|
||
|
||
const item = await this.dataSource.getRepository(WagonAllocationContainerItem).findOne({
|
||
where: { id: itemId },
|
||
relations: ['wagonBookingAllocation', 'wagonBookingAllocation.trainSetWagon'],
|
||
});
|
||
|
||
if (!item) {
|
||
throw new NotFoundException(`Container item ${itemId} not found`);
|
||
}
|
||
|
||
const wagonId = item.wagonBookingAllocationId;
|
||
const wagonAllocation = await this.dataSource.getRepository(WagonBookingAllocation).findOne({
|
||
where: { id: wagonId },
|
||
relations: ['trainSetWagon'],
|
||
});
|
||
|
||
if (!wagonAllocation?.trainSetWagon) {
|
||
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
|
||
}
|
||
|
||
const trainSetWagonId = wagonAllocation.trainSetWagon.id;
|
||
const wagonIds = (schedule.trainSet?.wagons ?? []).map((w) => w.id);
|
||
if (!wagonIds.includes(trainSetWagonId)) {
|
||
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
|
||
}
|
||
|
||
await this.dataSource.getRepository(WagonAllocationContainerItem).update(itemId, {
|
||
containerNumber: dto.containerNumber ?? null,
|
||
});
|
||
|
||
return { id: itemId, containerNumber: dto.containerNumber ?? null };
|
||
}
|
||
|
||
async getUnassignedBookings(scheduleId: string): Promise<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,
|
||
}));
|
||
|
||
const bookings = await Promise.all(
|
||
unassigned.map(async (b) => {
|
||
const assignability = await this.previewUnassignedBookingAssignability(
|
||
schedule,
|
||
wagonAssignedIds,
|
||
b as Booking,
|
||
fleetByTypeId,
|
||
);
|
||
return {
|
||
id: b.id,
|
||
reference: b.reference ?? null,
|
||
freightType: b.freightType ?? null,
|
||
priorityScore: b.priorityScore ?? 0,
|
||
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
|
||
status: b.status ?? null,
|
||
schedulingStatus: b.schedulingStatus ?? null,
|
||
...assignability,
|
||
};
|
||
}),
|
||
);
|
||
|
||
return { fleetAtOrigin, bookings };
|
||
}
|
||
|
||
private async previewUnassignedBookingAssignability(
|
||
schedule: TrainSchedule,
|
||
wagonAssignedIds: Set<string>,
|
||
booking: Booking,
|
||
fleetByTypeId: Map<string, { code: string; available: number }>,
|
||
): Promise<{
|
||
wagonsRequired: number;
|
||
requiredWagonTypeCode: string;
|
||
yardWagonsAvailable: number;
|
||
canAssign: boolean;
|
||
blockReason: string | null;
|
||
}> {
|
||
if (!schedule.trainSet?.locomotive) {
|
||
return {
|
||
wagonsRequired: 0,
|
||
requiredWagonTypeCode: '',
|
||
yardWagonsAvailable: 0,
|
||
canAssign: false,
|
||
blockReason: 'Schedule has no locomotive',
|
||
};
|
||
}
|
||
|
||
const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER';
|
||
let wagonType: WagonType;
|
||
try {
|
||
wagonType = await this.resolveWagonType(freightType, [booking.id]);
|
||
} catch {
|
||
return {
|
||
wagonsRequired: 0,
|
||
requiredWagonTypeCode: '',
|
||
yardWagonsAvailable: 0,
|
||
canAssign: false,
|
||
blockReason: 'No suitable wagon type found',
|
||
};
|
||
}
|
||
|
||
const bulkCapacity =
|
||
freightType === 'BULK' ? Number(wagonType.capacityTons) : undefined;
|
||
const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]);
|
||
const resolvedBooking = fullBooking ?? booking;
|
||
const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity);
|
||
const yardWagonsAvailable = fleetByTypeId.get(wagonType.id)?.available ?? 0;
|
||
|
||
const allBookingIds = [...wagonAssignedIds, booking.id];
|
||
const previewDto = {
|
||
bookingIds: allBookingIds,
|
||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
};
|
||
const limits = await this.resolveTrainLimitConfig(
|
||
undefined,
|
||
schedule.trainSet.locomotive,
|
||
);
|
||
|
||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||
try {
|
||
validation = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
null,
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
schedule.id,
|
||
);
|
||
} catch (err) {
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode: wagonType.code,
|
||
yardWagonsAvailable,
|
||
canAssign: false,
|
||
blockReason: err instanceof Error ? err.message : 'Validation failed',
|
||
};
|
||
}
|
||
|
||
if (!validation.valid) {
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode: wagonType.code,
|
||
yardWagonsAvailable,
|
||
canAssign: false,
|
||
blockReason: validation.violations[0] ?? 'Booking validation failed',
|
||
};
|
||
}
|
||
|
||
const fittingIds = new Set(validation.bookings.map((b) => b.id));
|
||
if (!fittingIds.has(booking.id)) {
|
||
const deferred = validation.deferredBookings.find((d) => d.id === booking.id);
|
||
const yardShortfall =
|
||
yardWagonsAvailable < wagonsRequired
|
||
? `No ${wagonType.code} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)`
|
||
: null;
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode: wagonType.code,
|
||
yardWagonsAvailable,
|
||
canAssign: false,
|
||
blockReason:
|
||
deferred?.reason ??
|
||
yardShortfall ??
|
||
`Need ${wagonsRequired} ${wagonType.code} wagon(s) at origin yard`,
|
||
};
|
||
}
|
||
|
||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||
if (containerBookings.some((b) => b.id === booking.id)) {
|
||
const units = expandBookingContainerUnits(containerBookings);
|
||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||
const placements = autoFillPlacements(units, slots);
|
||
const missing = findMissingContainerNumberIssues(units, placements).find(
|
||
(m) => m.bookingId === booking.id,
|
||
);
|
||
if (missing) {
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode: wagonType.code,
|
||
yardWagonsAvailable,
|
||
canAssign: false,
|
||
blockReason: missing.issue,
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode: wagonType.code,
|
||
yardWagonsAvailable,
|
||
canAssign: true,
|
||
blockReason: null,
|
||
};
|
||
}
|
||
|
||
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
|
||
private isReadyToLoadBooking(booking: {
|
||
status: string;
|
||
paymentStatus?: string | null;
|
||
isGovernment?: boolean;
|
||
}): boolean {
|
||
if (booking.status === 'EXPIRED') return false;
|
||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||
return false;
|
||
}
|
||
if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true;
|
||
if (booking.isGovernment) return true;
|
||
return false;
|
||
}
|
||
|
||
async getCompositionRemovals(scheduleId: string): Promise<any[]> {
|
||
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
|
||
}
|
||
|
||
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
|
||
if (!wagonIds.length) return new Set();
|
||
|
||
const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
|
||
where: { trainSetWagonId: In(wagonIds) },
|
||
select: ['bookingId'],
|
||
});
|
||
return new Set(allocations.map((a) => a.bookingId));
|
||
}
|
||
}
|