mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
2783 lines
100 KiB
TypeScript
2783 lines
100 KiB
TypeScript
import {
|
|
AllocationLoadType,
|
|
SchedulingStatus,
|
|
TrainCheckpointKind,
|
|
TrainScheduleStatus as TrainScheduleStatusEnum,
|
|
WagonStatus,
|
|
} from '@edr/types';
|
|
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource, EntityManager, In } 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 { Route } from '../routes/entities/route.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 { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
|
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 {
|
|
deriveTrainCapacityFromLocomotive,
|
|
wagonTypeDimensionsFromEntity,
|
|
} from './train-capacity.util';
|
|
import {
|
|
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
|
} from './booking-batch.constants';
|
|
import { 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 {
|
|
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,
|
|
};
|
|
|
|
@Injectable()
|
|
export class TrainSchedulingService {
|
|
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 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;
|
|
}
|
|
return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row);
|
|
}
|
|
|
|
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.getActiveRoute(dto.routeId);
|
|
const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0);
|
|
|
|
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
|
|
const lockedLocomotive = await manager.getRepository(Locomotive).findOne({
|
|
where: { id: locomotive.id },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!lockedLocomotive) {
|
|
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
|
|
}
|
|
if (lockedLocomotive.status !== 'AVAILABLE') {
|
|
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
|
|
}
|
|
|
|
const direction = deriveScheduleDirection(
|
|
route.originYard ?? { country: null },
|
|
route.destinationYard ?? { country: null },
|
|
);
|
|
if (lockedLocomotive.currentYardId !== route.originYardId) {
|
|
throw new ConflictException(
|
|
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
|
|
);
|
|
}
|
|
|
|
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
|
|
const schedule = manager.getRepository(TrainSchedule).create({
|
|
trainSetId: trainSet.id,
|
|
routeId: route.id,
|
|
originStationId: route.originYardId,
|
|
destinationStationId: route.destinationYardId,
|
|
scheduledDepartureDate: new Date(dto.scheduleDate),
|
|
status: TrainScheduleStatusEnum.Draft,
|
|
direction,
|
|
maxWagons: (
|
|
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
|
|
).maxWagonsPerTrain,
|
|
});
|
|
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
|
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
|
|
return saved.id;
|
|
});
|
|
|
|
return this.getTrainScheduleById(createdScheduleId);
|
|
}
|
|
|
|
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 locomotive = schedule.trainSet.locomotive;
|
|
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
|
|
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 (!locomotive) {
|
|
throw new BadRequestException('Schedule train set has no locomotive');
|
|
}
|
|
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`,
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
const now = new Date();
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await this.trainSchedulesRepository.updateStatus(
|
|
scheduleId,
|
|
TrainScheduleStatusEnum.Dispatched,
|
|
{ actualDepartureAt: now },
|
|
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();
|
|
});
|
|
|
|
return this.getTrainScheduleById(scheduleId);
|
|
}
|
|
|
|
/** Open or close a schedule's booking window (staff override). */
|
|
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
|
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',
|
|
});
|
|
}
|
|
|
|
if (schedule.trainSet?.locomotiveId) {
|
|
const loco = await manager
|
|
.getRepository(Locomotive)
|
|
.findOne({ where: { id: schedule.trainSet.locomotiveId } });
|
|
if (loco) {
|
|
await manager.getRepository(Locomotive).update(loco.id, {
|
|
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,
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
return this.getTrainScheduleById(scheduleId);
|
|
}
|
|
|
|
async getContainerTrainSchedules() {
|
|
const schedules = await this.trainSchedulesRepository.findAll({
|
|
relations: {
|
|
trainSet: { 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' });
|
|
}
|
|
if (schedule.trainSet?.locomotiveId) {
|
|
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
|
|
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 assignedLocomotive: Locomotive | null = null;
|
|
if (targetScheduleId) {
|
|
const targetSchedule =
|
|
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
|
|
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
|
|
}
|
|
|
|
if (assignedLocomotive) {
|
|
if (assignedLocomotive.currentYardId !== originYardId) {
|
|
violations.push(
|
|
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
|
|
);
|
|
} else if (
|
|
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
|
|
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
|
|
) {
|
|
violations.push(
|
|
'Assigned locomotive cannot support the total train weight and length',
|
|
);
|
|
}
|
|
} else {
|
|
const availableLocomotives = (
|
|
await this.locomotivesRepository.findAll({
|
|
where: { status: 'AVAILABLE' },
|
|
})
|
|
).filter((l) => l.currentYardId === originYardId);
|
|
if (!availableLocomotives.length) {
|
|
violations.push('No available locomotive at the schedule origin yard');
|
|
} else if (
|
|
!availableLocomotives.some(
|
|
(l) =>
|
|
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
|
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
|
)
|
|
) {
|
|
violations.push('No available 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);
|
|
}
|
|
}
|
|
|
|
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, locomotive: Locomotive) {
|
|
const trainSet = manager.getRepository(TrainSet).create({
|
|
locomotiveId: locomotive.id,
|
|
totalWeightTons: 0,
|
|
totalLengthMeters: 0,
|
|
wagonCount: 0,
|
|
status: 'DRAFT',
|
|
});
|
|
return manager.getRepository(TrainSet).save(trainSet);
|
|
}
|
|
|
|
private async getActiveRoute(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.isActive) throw new BadRequestException(`Route ${route.name} is inactive`);
|
|
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?.name ?? 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,
|
|
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. */
|
|
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
|
const route = await this.getActiveRoute(routeId);
|
|
|
|
const locomotives = await this.locomotivesRepository.findAll({
|
|
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
|
order: { code: 'ASC' },
|
|
});
|
|
|
|
return locomotives;
|
|
}
|
|
|
|
/** 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.
|
|
*/
|
|
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
|
const schedules = await this.trainSchedulesRepository.findAll({
|
|
where: {
|
|
bookingWindowStatus: 'OPEN',
|
|
},
|
|
relations: {
|
|
trainSet: { locomotive: true },
|
|
route: { milestones: true },
|
|
originStation: true,
|
|
destinationStation: true,
|
|
scheduleBookings: { booking: true },
|
|
},
|
|
order: { scheduledDepartureDate: 'ASC' },
|
|
});
|
|
|
|
const filteredSchedules = 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;
|
|
})
|
|
.map((s) => this.mapScheduleListItem(s));
|
|
|
|
return filteredSchedules;
|
|
}
|
|
|
|
/**
|
|
* 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() };
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
|
|
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,
|
|
route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : 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,
|
|
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));
|
|
}
|
|
}
|