auto allocation and batch managemnt, tracking the train

This commit is contained in:
marshal
2026-06-12 11:42:46 +03:00
parent 8618ea2aa8
commit ef0abf1c41
61 changed files with 3541 additions and 378 deletions

View File

@@ -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));
}
}