feat(intercity): only load/unload where the yard has a facility

Intercity cargo rides a passing train and is handled at the booking's own yards,
so those yards need the equipment to do it — a train stopping somewhere is not
the same as somewhere being able to load it. Loading at an origin or unloading at
a destination without has_facility is now refused, naming the yard.

The check sits inside loadBooking/unloadBooking rather than the intercity
controller wrapper, because recording a checkpoint auto-unloads every booking
destined at that yard (autoUnloadAtYard) and would otherwise route around it.

Import/export are untouched: their cargo is handled at the route's terminal
ports, not at an arbitrary mid-corridor yard, so the gate returns early for
anything that isn't DOMESTIC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-17 09:55:06 +00:00
parent ce3ef15e7c
commit a411e0bdd0

View File

@@ -9,6 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Freight } from '@edr/types';
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
import { Booking } from '../bookings/entities/booking.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -43,6 +44,7 @@ export class BookingJourneyService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly yardFacilities: YardFacilitiesService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
@@ -63,6 +65,7 @@ export class BookingJourneyService {
);
}
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
const now = new Date();
await this.dataSource.transaction(async (manager) => {
@@ -99,6 +102,7 @@ export class BookingJourneyService {
);
}
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
// Intercity has no clearance/delivery tail — unloading completes it. Import/
// export continue into clearance, keyed on the booking's own arrival.
@@ -306,6 +310,33 @@ export class BookingJourneyService {
});
}
/**
* INTERCITY ONLY. Intercity cargo rides a passing train and is handled at the
* booking's own yards, so those yards need the equipment to do it — a train
* stopping somewhere is not the same as somewhere being able to load it.
*
* Import/export are untouched: their cargo is handled at the route's terminal
* ports, not at an arbitrary mid-corridor yard, and gating them here would
* block existing traffic.
*
* Lives here rather than in the controller so the checkpoint-driven
* autoUnloadAtYard path cannot route around it.
*/
private async assertYardCanHandleCargo(
booking: Booking,
yardId: string,
side: 'origin' | 'destination',
): Promise<void> {
if (booking.tradeDirection !== 'DOMESTIC') return;
const facility = await this.yardFacilities.facilityForYard(yardId);
if (!facility?.hasFacility) {
throw new BadRequestException(
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` +
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`,
);
}
}
/**
* The train is "at" a yard when the latest recorded checkpoint is that yard,
* or — for a booking boarding at the train's own origin — when the train has