mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
auto allocation and batch managemnt, tracking the train
This commit is contained in:
@@ -75,17 +75,56 @@ import {
|
||||
pickBulkWagonType,
|
||||
} from './wagon-type-resolver.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { flipReadiness, wagonReadinessMatchesSchedule } from './wagon-readiness.util';
|
||||
import {
|
||||
flipReadiness,
|
||||
requiredWagonReadiness,
|
||||
wagonReadinessMatchesSchedule,
|
||||
} from './wagon-readiness.util';
|
||||
import {
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
} from './booking-batch.constants';
|
||||
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[];
|
||||
}
|
||||
|
||||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
maxWeightTons: 3500,
|
||||
maxLengthMeters: 760,
|
||||
maxWagonsPerTrain: 53,
|
||||
maxWagonsPerTrain: Math.floor(760 / 14),
|
||||
max20ftContainerWeightTons: 30,
|
||||
max20ftPairWeightDiffTons: 10,
|
||||
};
|
||||
@@ -245,7 +284,9 @@ export class TrainSchedulingService {
|
||||
scheduledDepartureDate: new Date(dto.scheduleDate),
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
maxWagons: (await this.resolveTrainLimitConfig(dto)).maxWagonsPerTrain,
|
||||
maxWagons: (
|
||||
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
|
||||
).maxWagonsPerTrain,
|
||||
});
|
||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
||||
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
|
||||
@@ -294,10 +335,11 @@ export class TrainSchedulingService {
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
maxTrainWeightTons: dto.maxTrainWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? schedule.maxWagons,
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain,
|
||||
};
|
||||
|
||||
const limits = await this.resolveTrainLimitConfig(previewDto);
|
||||
const locomotive = schedule.trainSet.locomotive;
|
||||
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
freightType ?? null,
|
||||
@@ -329,7 +371,6 @@ export class TrainSchedulingService {
|
||||
const totalWeightTons = validation.summary.totalWeightTons;
|
||||
const totalLengthMeters = validation.summary.totalLengthMeters;
|
||||
|
||||
const locomotive = schedule.trainSet.locomotive;
|
||||
if (!locomotive) {
|
||||
throw new BadRequestException('Schedule train set has no locomotive');
|
||||
}
|
||||
@@ -473,6 +514,7 @@ export class TrainSchedulingService {
|
||||
(sb) => sb.bookingId !== bookingId,
|
||||
);
|
||||
if (remainingBookings.length === 0) {
|
||||
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
|
||||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
|
||||
schedule.trainSetId,
|
||||
manager,
|
||||
@@ -617,7 +659,7 @@ export class TrainSchedulingService {
|
||||
paymentDeadline: null,
|
||||
})
|
||||
.where('train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`status = 'AWAITING_PAYMENT'`)
|
||||
.andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.execute();
|
||||
});
|
||||
|
||||
@@ -1068,6 +1110,14 @@ export class TrainSchedulingService {
|
||||
bulkWagonType,
|
||||
});
|
||||
|
||||
violations.push(
|
||||
...(await this.validatePhysicalFleetForPlan(
|
||||
wagonPlan,
|
||||
scheduleDirection,
|
||||
targetScheduleId,
|
||||
)),
|
||||
);
|
||||
|
||||
const placementRules = {
|
||||
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
|
||||
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
||||
@@ -1120,11 +1170,18 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
const availableLocomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
});
|
||||
const availableLocomotives = (
|
||||
await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
})
|
||||
).filter((l) => wagonReadinessMatchesSchedule(l.readiness, scheduleDirection));
|
||||
if (!availableLocomotives.length) {
|
||||
violations.push('No available locomotive exists for scheduling');
|
||||
const readinessHint = requiredWagonReadiness(scheduleDirection);
|
||||
violations.push(
|
||||
readinessHint
|
||||
? `No available ${readinessHint} locomotive exists for this ${scheduleDirection} schedule`
|
||||
: 'No available locomotive exists for scheduling',
|
||||
);
|
||||
} else if (
|
||||
!availableLocomotives.some(
|
||||
(l) =>
|
||||
@@ -1168,11 +1225,14 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveTrainLimitConfig(dto?: {
|
||||
maxTrainWeightTons?: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
}): Promise<Required<TrainLimitConfig>> {
|
||||
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;
|
||||
@@ -1180,25 +1240,73 @@ export class TrainSchedulingService {
|
||||
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: this.positiveNumber(
|
||||
dto?.maxTrainWeightTons,
|
||||
Number(row?.maxTrainWeightTons) ||
|
||||
configured?.maxTrainWeightTons ||
|
||||
DEFAULT_TRAIN_LIMITS.maxWeightTons,
|
||||
),
|
||||
maxLengthMeters: this.positiveNumber(
|
||||
dto?.maxTrainLengthMeters,
|
||||
Number(row?.maxTrainLengthMeters) ||
|
||||
configured?.maxTrainLengthMeters ||
|
||||
DEFAULT_TRAIN_LIMITS.maxLengthMeters,
|
||||
),
|
||||
maxWeightTons,
|
||||
maxLengthMeters,
|
||||
maxWagonsPerTrain: Math.floor(
|
||||
this.positiveNumber(
|
||||
dto?.maxWagonsPerTrain,
|
||||
Number(row?.maxWagonsPerTrain) ||
|
||||
configured?.maxWagonsPerTrain ||
|
||||
DEFAULT_TRAIN_LIMITS.maxWagonsPerTrain,
|
||||
row?.maxWagonsPerTrain != null
|
||||
? Number(row.maxWagonsPerTrain)
|
||||
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
|
||||
),
|
||||
),
|
||||
max20ftContainerWeightTons: this.positiveNumber(
|
||||
@@ -1213,6 +1321,19 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
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 resolveScheduleDirection(
|
||||
targetScheduleId: string | undefined,
|
||||
bookings: Booking[],
|
||||
@@ -1282,26 +1403,48 @@ export class TrainSchedulingService {
|
||||
slots: TrainSetWagon[],
|
||||
) {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||||
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
|
||||
|
||||
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
|
||||
const candidates = wagons.filter((wagon) => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||
const pinnedOnSchedule = wagon.currentTrainScheduleId === scheduleId;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
||||
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
|
||||
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,
|
||||
scheduleDirection,
|
||||
);
|
||||
if (unpinnable.length) {
|
||||
throw new BadRequestException({
|
||||
message: 'Insufficient physical wagons to pin all train slots',
|
||||
violations: unpinnable,
|
||||
});
|
||||
}
|
||||
|
||||
const physical = candidates[0];
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
for (const slot of planSlots) {
|
||||
const physical = this.pickPhysicalWagonForSlot(
|
||||
slot,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
assignedPhysicalIds,
|
||||
);
|
||||
if (!physical) continue;
|
||||
|
||||
await manager.getRepository(TrainSetWagon).update(slot.id, {
|
||||
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
|
||||
physicalWagonId: physical.id,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
await manager.getRepository(Wagon).update(physical.id, {
|
||||
trainSetWagonId: slot.id,
|
||||
trainSetWagonId: slot.trainSetWagonId,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
@@ -1309,6 +1452,76 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-assign check: every planned slot must have a matching physical wagon. */
|
||||
private async validatePhysicalFleetForPlan(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
scheduleDirection: string | null,
|
||||
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,
|
||||
scheduleDirection,
|
||||
);
|
||||
}
|
||||
|
||||
private findUnpinnableWagonSlots(
|
||||
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
scheduleDirection: string | null,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
const required = requiredWagonReadiness(scheduleDirection);
|
||||
const readinessLabel = required ?? 'any readiness';
|
||||
|
||||
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
|
||||
const physical = this.pickPhysicalWagonForSlot(
|
||||
slot,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
assignedPhysicalIds,
|
||||
);
|
||||
if (!physical) {
|
||||
violations.push(
|
||||
`No ${readinessLabel} ${slot.wagonTypeCode} wagon available for slot #${slot.sequenceNo}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assignedPhysicalIds.add(physical.id);
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private pickPhysicalWagonForSlot(
|
||||
slot: { wagonTypeId: string },
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
scheduleDirection: string | null,
|
||||
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 wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
|
||||
});
|
||||
}
|
||||
|
||||
private positiveNumber(value: number | undefined, fallback: number): number {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
||||
@@ -1639,6 +1852,27 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/** AVAILABLE locomotives whose readiness matches the corridor implied by the route. */
|
||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
||||
const route = await this.getActiveRoute(routeId);
|
||||
const direction = deriveScheduleDirection(
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
);
|
||||
const requiredReadiness = requiredWagonReadiness(direction);
|
||||
|
||||
const locomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
|
||||
if (!requiredReadiness) {
|
||||
return locomotives;
|
||||
}
|
||||
|
||||
return locomotives.filter((l) => wagonReadinessMatchesSchedule(l.readiness, direction));
|
||||
}
|
||||
|
||||
/** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */
|
||||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
@@ -1796,4 +2030,221 @@ export class TrainSchedulingService {
|
||||
}
|
||||
return SchedulingStatus.Eligible;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user