mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
4160 lines
160 KiB
TypeScript
4160 lines
160 KiB
TypeScript
import {
|
|
AllocationLoadType,
|
|
SchedulingStatus,
|
|
TrainCheckpointKind,
|
|
TrainScheduleStatus as TrainScheduleStatusEnum,
|
|
WagonStatus,
|
|
} from '@edr/types';
|
|
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource, EntityManager, In, Not } from 'typeorm';
|
|
|
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
|
import { Booking } from '../bookings/entities/booking.entity';
|
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
|
import { Container } from '../container-management/entities/container.entity';
|
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
|
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
|
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
|
import { 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 { 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 { type BookingWindowConfig } from './booking-window.config';
|
|
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 {
|
|
getDefaultContainerWagonTypeCode,
|
|
pickBulkWagonType,
|
|
} from './wagon-type-resolver.util';
|
|
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
|
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
|
import {
|
|
deriveTrainCapacityFromLocomotive,
|
|
minLocomotiveLimits,
|
|
wagonTypeDimensionsFromEntity,
|
|
} from './train-capacity.util';
|
|
import {
|
|
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
|
} from './booking-batch.constants';
|
|
import {
|
|
computeExportWindowTimes,
|
|
computeImportWindowTimes,
|
|
earliestSchedulableDeparture,
|
|
eatDay,
|
|
} from './batch-window.util';
|
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
|
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;
|
|
|
|
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;
|
|
contract_id: 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 configService?: ConfigService,
|
|
) {}
|
|
|
|
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.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;
|
|
|
|
// 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.windowDurationHours != 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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
ruleWindowOpenHour: cfg.windowOpenHour,
|
|
ruleWindowDurationHours: cfg.windowDurationHours,
|
|
ruleReopenDelayMinutes: cfg.reopenDelayMinutes,
|
|
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
|
|
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
|
|
});
|
|
restamped += 1;
|
|
}
|
|
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),
|
|
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);
|
|
}
|
|
|
|
const direction = deriveScheduleDirection(
|
|
route.originYard ?? { country: null },
|
|
route.destinationYard ?? { country: null },
|
|
);
|
|
|
|
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 = {
|
|
ruleWindowOpenHour: windowCfg.windowOpenHour,
|
|
ruleWindowDurationHours: windowCfg.windowDurationHours,
|
|
ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes,
|
|
ruleImportWindowLeadDays: windowCfg.importWindowLeadDays,
|
|
ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours,
|
|
};
|
|
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 schedule = manager.getRepository(TrainSchedule).create({
|
|
trainSetId: trainSet.id,
|
|
routeId: route.id,
|
|
originStationId: route.originYardId,
|
|
destinationStationId: route.destinationYardId,
|
|
scheduledDepartureDate: departure,
|
|
status: TrainScheduleStatusEnum.Draft,
|
|
direction,
|
|
maxWagons: (
|
|
await this.resolveTrainLimitConfig(dto, limitLoco)
|
|
).maxWagonsPerTrain,
|
|
...windowFields,
|
|
});
|
|
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
|
// 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);
|
|
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);
|
|
const validation = await this.validateBookingsForScheduling(
|
|
previewDto,
|
|
freightType ?? null,
|
|
dto.forceAssign,
|
|
dto.containerPlacements,
|
|
true,
|
|
limits,
|
|
scheduleId,
|
|
);
|
|
|
|
if (!validation.valid) {
|
|
throw new BadRequestException({
|
|
message: 'Booking validation failed',
|
|
violations: validation.violations,
|
|
warnings: validation.warnings,
|
|
});
|
|
}
|
|
|
|
if (!validation.bookings.length) {
|
|
throw new BadRequestException({
|
|
message: 'No bookings fit on available fleet wagons',
|
|
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');
|
|
}
|
|
if (limitLoco.maxPullWeightTons < totalWeightTons) {
|
|
throw new BadRequestException(
|
|
`Train set locomotives cannot pull ${totalWeightTons}T`,
|
|
);
|
|
}
|
|
if (limitLoco.maxTrainLengthMeters < 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,
|
|
dto.containerPlacements ?? [],
|
|
);
|
|
|
|
for (const booking of bookings) {
|
|
await this.bookingsRepository.updateSchedulingFields(
|
|
booking.id,
|
|
{
|
|
schedulingStatus: SchedulingStatus.Eligible,
|
|
wagonsRequired: sumWagonsRequired(booking),
|
|
},
|
|
manager,
|
|
);
|
|
}
|
|
|
|
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
|
|
await this.trainSchedulesRepository.updateStatus(
|
|
scheduleId,
|
|
TrainScheduleStatusEnum.Draft,
|
|
{},
|
|
manager,
|
|
);
|
|
}
|
|
|
|
await this.autoPinWagonsForSchedule(
|
|
manager,
|
|
scheduleId,
|
|
schedule.originStationId,
|
|
savedWagons,
|
|
);
|
|
});
|
|
|
|
const detail = await this.getTrainScheduleById(scheduleId);
|
|
return { ...detail, warnings, deferredBookings };
|
|
}
|
|
|
|
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
|
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
|
if (!schedule) {
|
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
|
}
|
|
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
|
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
|
|
}
|
|
|
|
const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
|
|
if (!link) {
|
|
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
|
|
}
|
|
|
|
const booking = await this.bookingsRepository.findById(bookingId);
|
|
const bookingReference = booking?.reference ?? null;
|
|
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const allocationIds = (schedule.trainSet?.wagons ?? [])
|
|
.flatMap((w) => w.allocations ?? [])
|
|
.filter((a) => a.bookingId === bookingId)
|
|
.map((a) => a.id);
|
|
|
|
if (allocationIds.length) {
|
|
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
|
|
allocationIds,
|
|
manager,
|
|
);
|
|
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
|
|
await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
|
|
}
|
|
|
|
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
|
scheduleId,
|
|
bookingId,
|
|
manager,
|
|
);
|
|
|
|
const booking = await this.bookingsRepository.findById(bookingId);
|
|
const schedulingStatus = this.resolvePostUnassignStatus(booking);
|
|
await this.bookingsRepository.updateSchedulingFields(
|
|
bookingId,
|
|
{ schedulingStatus, wagonsRequired: null },
|
|
manager,
|
|
);
|
|
|
|
const remainingBookings = (schedule.scheduleBookings ?? []).filter(
|
|
(sb) => sb.bookingId !== bookingId,
|
|
);
|
|
if (remainingBookings.length === 0) {
|
|
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
|
|
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
|
|
schedule.trainSetId,
|
|
manager,
|
|
);
|
|
await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId });
|
|
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
|
totalWeightTons: 0,
|
|
totalLengthMeters: 0,
|
|
wagonCount: 0,
|
|
status: 'DRAFT',
|
|
});
|
|
}
|
|
});
|
|
|
|
await this.trainCompositionRemovalLogRepository.create({
|
|
scheduleId,
|
|
bookingId,
|
|
bookingReference,
|
|
removedByUserId: userId ?? null,
|
|
removedAt: new Date(),
|
|
});
|
|
|
|
console.log(
|
|
`[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`,
|
|
);
|
|
|
|
return this.getTrainScheduleById(scheduleId);
|
|
}
|
|
|
|
private async runWarehouseArrivalAutomation(scheduleId: string) {
|
|
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.name 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' };
|
|
|
|
const direction = deriveTradeDirection(
|
|
{ country: schedule.originCountry },
|
|
{ country: schedule.destinationCountry },
|
|
);
|
|
|
|
try {
|
|
if (direction === 'IMPORT') {
|
|
return {
|
|
direction,
|
|
action: 'IMPORT_AUTO_UNLOAD',
|
|
status: 'COMPLETED',
|
|
result: await this.warehouseInventoryService.autoUnloadArrivedBookings(
|
|
scheduleId,
|
|
'SYSTEM_TRAIN_ARRIVAL',
|
|
),
|
|
};
|
|
}
|
|
|
|
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
|
|
return {
|
|
direction,
|
|
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
|
|
status: 'COMPLETED',
|
|
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
|
|
scheduleId,
|
|
'SYSTEM_TRAIN_ARRIVAL',
|
|
),
|
|
};
|
|
}
|
|
|
|
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 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,
|
|
);
|
|
}
|
|
});
|
|
|
|
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,
|
|
);
|
|
}
|
|
// 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.`,
|
|
);
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
console.log(
|
|
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
|
|
);
|
|
return this.getImportDjiboutiOperation(schedule.id);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
|
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
|
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
|
this.assertImportDjiboutiGatepassGranted(operation);
|
|
if (!operation.loadedOnTrainAt) {
|
|
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
|
}
|
|
|
|
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);
|
|
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
|
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);
|
|
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
|
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.physicalWagon?.tareWeight ?? 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);
|
|
if (!operation?.loadedOnTrainAt) {
|
|
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
/** 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',
|
|
});
|
|
}
|
|
|
|
await manager.query(
|
|
`UPDATE freight.bookings b
|
|
SET status = $2,
|
|
scheduling_status = $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.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
|
|
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
|
);
|
|
|
|
// 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;
|
|
await manager.getRepository(Wagon).update(wagon.id, {
|
|
currentTrainScheduleId: null,
|
|
trainSetWagonId: null,
|
|
status: WagonStatus.Available,
|
|
currentYardId: schedule.destinationStationId,
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
const detail = await this.getTrainScheduleById(scheduleId);
|
|
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
|
return Object.assign(detail, { warehouseAutomation });
|
|
}
|
|
|
|
async getContainerTrainSchedules() {
|
|
const schedules = await this.trainSchedulesRepository.findAll({
|
|
relations: {
|
|
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
|
route: true,
|
|
originStation: true,
|
|
destinationStation: true,
|
|
scheduleBookings: { booking: true },
|
|
},
|
|
order: { scheduledDepartureDate: 'DESC', createdAt: '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,
|
|
{},
|
|
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,
|
|
);
|
|
}
|
|
});
|
|
|
|
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 (
|
|
bookings.some((b) => {
|
|
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
|
return false;
|
|
}
|
|
return (
|
|
b.originYardId !== dto.originStationId ||
|
|
b.destinationYardId !== dto.destinationStationId
|
|
);
|
|
})
|
|
) {
|
|
violations.push('Selected bookings must share the same origin and destination as the schedule');
|
|
}
|
|
|
|
if (!forceAssign) {
|
|
for (const booking of bookings) {
|
|
if (this.isHoldActive(booking)) {
|
|
warnings.push(
|
|
`Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`,
|
|
);
|
|
}
|
|
const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight);
|
|
if (overweightLines.length) {
|
|
violations.push(
|
|
`Booking ${booking.reference} has overweight container lines; use forceAssign to override`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let wagonType: WagonType;
|
|
let containerWagonType: WagonType;
|
|
let bulkWagonType: WagonType;
|
|
let demandPlan: WagonPlanSlot[];
|
|
let fittingBookings = bookings;
|
|
let deferredBookings: DeferredBookingRow[] = [];
|
|
let fleetAvailability: FleetAvailabilityRow[] = [];
|
|
|
|
if (resolvedMode === 'MIXED') {
|
|
const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER');
|
|
const bulkBookings = bookings.filter((b) => b.freightType === 'BULK');
|
|
containerWagonType = await this.resolveWagonType('CONTAINER', bookingIds);
|
|
bulkWagonType = await this.resolveWagonType('BULK', bookingIds);
|
|
wagonType = containerWagonType;
|
|
demandPlan = buildMixedWagonPlan(
|
|
containerBookings,
|
|
bulkBookings,
|
|
containerWagonType,
|
|
bulkWagonType,
|
|
);
|
|
} else {
|
|
wagonType = await this.resolveWagonType(resolvedMode, bookingIds);
|
|
containerWagonType = wagonType;
|
|
bulkWagonType = wagonType;
|
|
demandPlan =
|
|
resolvedMode === 'CONTAINER'
|
|
? buildContainerWagonPlan(bookings, wagonType)
|
|
: buildBulkWagonPlan(bookings, wagonType);
|
|
}
|
|
|
|
const originYardId = dto.originStationId;
|
|
const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId);
|
|
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,
|
|
});
|
|
|
|
violations.push(
|
|
...(await this.validatePhysicalFleetForPlan(
|
|
wagonPlan,
|
|
originYardId,
|
|
targetScheduleId,
|
|
)),
|
|
);
|
|
|
|
const placementRules = {
|
|
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
|
|
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
|
};
|
|
|
|
if (resolvedMode === 'MIXED') {
|
|
violations.push(
|
|
...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 {
|
|
violations.push(...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)) {
|
|
violations.push(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 < totalWeightTons ||
|
|
setLimits.maxTrainLengthMeters < totalLengthMeters)
|
|
) {
|
|
violations.push(
|
|
'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) >= totalWeightTons &&
|
|
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
|
)
|
|
) {
|
|
violations.push('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 {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async resolveTrainLimitConfig(
|
|
dto?: {
|
|
maxTrainWeightTons?: number;
|
|
maxTrainLengthMeters?: number;
|
|
maxWagonsPerTrain?: number;
|
|
},
|
|
locomotive?: Pick<Locomotive, 'maxPullWeightTons' | 'maxTrainLengthMeters'>,
|
|
): 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),
|
|
},
|
|
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,
|
|
),
|
|
};
|
|
}
|
|
|
|
private async loadSchedulingWagonTypeDimensions(): Promise<
|
|
Array<{ lengthMeters: number; capacityTons: number }>
|
|
> {
|
|
const types = await this.dataSource.getRepository(WagonType).find({
|
|
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
|
});
|
|
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
|
return [
|
|
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
|
|
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
|
|
];
|
|
}
|
|
|
|
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,
|
|
}));
|
|
|
|
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,
|
|
})),
|
|
wagons,
|
|
targetScheduleId,
|
|
originYardId,
|
|
);
|
|
}
|
|
|
|
private findUnpinnableWagonSlots(
|
|
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
|
|
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;
|
|
}
|
|
|
|
private pickPhysicalWagonForSlot(
|
|
slot: { wagonTypeId: string },
|
|
wagons: Wagon[],
|
|
scheduleId: string | undefined,
|
|
originYardId: string,
|
|
assignedPhysicalIds: Set<string>,
|
|
): Wagon | undefined {
|
|
return wagons.find((wagon) => {
|
|
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
|
if (assignedPhysicalIds.has(wagon.id)) return false;
|
|
const pinnedOnSchedule = scheduleId
|
|
? wagon.currentTrainScheduleId === scheduleId
|
|
: false;
|
|
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
|
return wagon.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;
|
|
}
|
|
|
|
private async resolveWagonType(
|
|
freightType: 'CONTAINER' | 'BULK',
|
|
bookingIds: string[],
|
|
): Promise<WagonType> {
|
|
if (freightType === 'CONTAINER') {
|
|
const [wagonType] = await this.wagonTypesRepository.findAll({
|
|
where: { code: getDefaultContainerWagonTypeCode(), isActive: true },
|
|
});
|
|
if (!wagonType) {
|
|
throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`);
|
|
}
|
|
return wagonType;
|
|
}
|
|
|
|
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
|
|
const cargoCode = bookings[0]?.cargoType?.code ?? null;
|
|
const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } });
|
|
const picked = pickBulkWagonType(wagonTypes, cargoCode);
|
|
if (!picked) {
|
|
throw new NotFoundException('No suitable bulk wagon type found');
|
|
}
|
|
return picked;
|
|
}
|
|
|
|
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',
|
|
}),
|
|
);
|
|
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) < totalWeightTons) {
|
|
throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`);
|
|
}
|
|
if (Number(locomotive.maxTrainLengthMeters) < 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})`,
|
|
);
|
|
}
|
|
return route;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
|
return {
|
|
id: schedule.id,
|
|
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 for a customer's active-contract lanes —
|
|
* powers the portal home "booking windows" section. Only window-engine
|
|
* schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are
|
|
* always open and need no announcement.
|
|
*/
|
|
async getBookingWindowsForCompany(companyId: string) {
|
|
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
|
`SELECT DISTINCT ts.id AS schedule_id,
|
|
cr.contract_id AS contract_id,
|
|
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.deleted_at IS NULL
|
|
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.contract_kind = 'GENERAL'
|
|
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.window_opens_at ASC NULLS LAST`,
|
|
[companyId],
|
|
);
|
|
return rows.map((r) => this.mapBookingWindowRow(r));
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
cr.contract_id AS contract_id,
|
|
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.contract_kind = 'GENERAL'
|
|
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.window_opens_at ASC NULLS LAST`,
|
|
[contractId],
|
|
);
|
|
return rows.map((r) => this.mapBookingWindowRow(r));
|
|
}
|
|
|
|
private mapBookingWindowRow(r: BookingWindowRow) {
|
|
return {
|
|
scheduleId: r.schedule_id,
|
|
contractId: r.contract_id,
|
|
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' },
|
|
});
|
|
|
|
return schedules
|
|
.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 that are actually FEASIBLE for the given
|
|
* cargo. A day is selectable only when ≥1 OPEN schedule on the route that day
|
|
* has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that
|
|
* schedule's origin yard, and (b) remaining train capacity (not fully
|
|
* allocated). Days with trains but not enough matching wagons are excluded.
|
|
* Same `{ days: string[] }` shape as getAvailableDays — the customer still
|
|
* 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 wagonTypes = await this.dataSource.getRepository(WagonType).find();
|
|
|
|
// Resolve the wagon type this cargo needs.
|
|
const requiredType =
|
|
input.freightType === 'BULK'
|
|
? pickBulkWagonType(wagonTypes, input.cargoTypeCode)
|
|
: wagonTypes.find(
|
|
(wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive,
|
|
);
|
|
if (!requiredType) return { days: [] };
|
|
|
|
// How many wagons of that type the cargo needs.
|
|
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
|
|
|
|
// AVAILABLE wagons of the required type, counted once per origin yard.
|
|
const availableByYard = new Map<string, number>();
|
|
const availableAt = async (yardId: string): Promise<number> => {
|
|
const cached = availableByYard.get(yardId);
|
|
if (cached !== undefined) return cached;
|
|
const counts = await this.countFleetAvailability(yardId);
|
|
const n =
|
|
counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
|
availableByYard.set(yardId, n);
|
|
return n;
|
|
};
|
|
|
|
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;
|
|
const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
|
if (!enoughWagons) continue;
|
|
if (s.scheduledDepartureDate)
|
|
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
|
}
|
|
return { days: [...days].sort() };
|
|
}
|
|
|
|
/**
|
|
* Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight /
|
|
* capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per
|
|
* wagon. Mirrors wagon-plan.util without fabricating Booking entities.
|
|
*/
|
|
private wagonsNeededForCargo(
|
|
input: {
|
|
freightType: 'CONTAINER' | 'BULK';
|
|
totalWeightTons?: number;
|
|
containers?: Array<{ containerSize: string; quantity: number }>;
|
|
},
|
|
wagonType: WagonType,
|
|
): number {
|
|
if (input.freightType === 'BULK') {
|
|
const capacity = Number(wagonType.capacityTons) || 1;
|
|
const weight = Number(input.totalWeightTons ?? 0);
|
|
return Math.max(1, Math.ceil(weight / capacity));
|
|
}
|
|
const teu = (input.containers ?? []).reduce((sum, c) => {
|
|
const per = c.containerSize === '40ft' ? 2 : 1;
|
|
return sum + per * Math.max(0, Number(c.quantity ?? 0));
|
|
}, 0);
|
|
return Math.max(1, Math.ceil(teu / 2));
|
|
}
|
|
|
|
/** 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 allocationIds = (schedule.trainSet?.wagons ?? [])
|
|
.flatMap((w) => w.allocations ?? [])
|
|
.map((a) => a.id);
|
|
|
|
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,
|
|
status: schedule.status,
|
|
freightType: this.resolveScheduleFreightType(schedule),
|
|
trainNumber: schedule.trainNumber ?? null,
|
|
direction: schedule.direction ?? null,
|
|
// 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,
|
|
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,
|
|
})) ?? [],
|
|
};
|
|
}
|
|
|
|
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',
|
|
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));
|
|
}
|
|
}
|