This commit is contained in:
Marshal
2026-07-07 12:28:47 +00:00
parent 1c18fdbd52
commit f300600bfa
63 changed files with 2587 additions and 428 deletions

View File

@@ -15,6 +15,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
@@ -42,13 +43,14 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
import { BookingSplitService } from './booking-split.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
import {
Capacity,
CorridorBudget,
CorridorLeg,
stopYardsFor,
} from './corridor-capacity.util';
/** A train's remaining capacity along the three physical limits the batch enforces. */
export interface Capacity {
wagons: number;
weightTons: number;
lengthMeters: number;
}
export type { Capacity } from './corridor-capacity.util';
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
@@ -459,18 +461,14 @@ export class BookingBatchService implements OnModuleInit {
throw new BadRequestException('Booking has no scheduled date');
}
const day = eatDay(new Date(booking.scheduledDate));
// Corridor-aware: any train whose route carries the booking's origin
// strictly before its destination qualifies — a Dire→Djibouti booking may
// ride an Addis→…→Djibouti train. The leg check below (legOf) enforces the
// stop order, so we fetch the day's open trains without endpoint filters.
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Draft,
},
{
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
},
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const candidates = corridor
@@ -493,6 +491,7 @@ export class BookingBatchService implements OnModuleInit {
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const required = need ?? this.needFor(booking, wagonLengths);
let corridorMatched = false;
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
@@ -500,8 +499,16 @@ export class BookingBatchService implements OnModuleInit {
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (this.fits(required, budget)) return schedule.id;
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
corridorMatched = true;
if (budget.fits(required, leg)) return schedule.id;
}
if (!corridorMatched) {
throw new ConflictException(
'No export train is accepting bookings for this day',
);
}
throw new ConflictException('Train is full — no export capacity left for this day');
}
@@ -1009,8 +1016,8 @@ export class BookingBatchService implements OnModuleInit {
const wagonLengths = await this.loadWagonLengths();
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (budget.wagons <= 0) {
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
if (budget.maxRemaining().wagons <= 0) {
await this.setWindow(scheduleId, "FULL");
return;
}
@@ -1026,16 +1033,20 @@ export class BookingBatchService implements OnModuleInit {
? this.combinedNeed(booking, partner, wagonLengths)
: this.needFor(booking, wagonLengths);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
// Consolidated partners always share one corridor, so the primary's leg
// stands for the pair.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
if (!this.fits(need, budget)) {
if (!budget.fits(need, leg)) {
if (isGov) {
budget = await this.preemptForGovernment(
const freed = await this.preemptForGovernment(
scheduleId,
need,
leg,
budget,
wagonLengths,
);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
if (!freed) continue; // still doesn't fit even after preempt
} else {
continue; // skip a unit that exceeds weight/length/wagons, try the next
}
@@ -1049,11 +1060,11 @@ export class BookingBatchService implements OnModuleInit {
if (partner) await this.reserve(partner, scheduleId);
armed = true;
}
budget = this.subtract(budget, need);
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
budget.subtract(need, leg);
if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board
}
if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
@@ -1106,8 +1117,8 @@ export class BookingBatchService implements OnModuleInit {
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
// Live per-schedule budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
// Live per-schedule corridor budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
for (const id of scheduleIds) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
@@ -1120,18 +1131,18 @@ export class BookingBatchService implements OnModuleInit {
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingCapacity(
schedule,
limits,
wagonLengths,
);
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return [];
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
originYardId,
destinationYardId,
// The day pool covers every booking whose leg lies somewhere on one of the
// day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
// Addis→Djibouti train). Which train actually takes a booking is decided
// by the per-train legOf check below.
const corridorYards = [...new Set(trains.flatMap((t) => t.budget.stops))];
const pool = await this.bookingsRepository.findBatchPoolByCorridorDay(
corridorYards,
day,
);
// Consolidated partners collapse into one atomic unit (both-or-neither); a
@@ -1146,20 +1157,30 @@ export class BookingBatchService implements OnModuleInit {
: this.needFor(booking, wagonLengths);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
// First train (earliest departure) that fits this unit as-is.
let target = trains.find((t) => this.fits(need, t.budget));
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
t.budget.legOf(booking.originYardId, booking.destinationYardId);
// First train (earliest departure) whose corridor carries this booking's
// leg and still fits it as-is.
let target = trains.find((t) => {
const leg = legOn(t);
return leg != null && t.budget.fits(need, leg);
});
if (!target && isGov) {
// Government fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room.
// on each corridor-matching train (earliest first) until one frees room.
for (const t of trains) {
t.budget = await this.preemptForGovernment(
const leg = legOn(t);
if (!leg) continue;
const freed = await this.preemptForGovernment(
t.id,
need,
leg,
t.budget,
wagonLengths,
);
if (this.fits(need, t.budget)) {
if (freed) {
target = t;
break;
}
@@ -1170,10 +1191,15 @@ export class BookingBatchService implements OnModuleInit {
// A consolidated pair is placed whole or not at all — never split.
if (!isPair) {
// Fits no train whole. Import GENERAL-contract commercial bookings get a
// partial-capacity offer on the train with the most free wagons.
const partialTarget = [...trains]
.filter((t) => t.budget.wagons >= 1)
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
// partial-capacity offer on the train with the most free wagons on the
// booking's own leg.
const partialTarget = trains
.map((t) => {
const leg = legOn(t);
return leg ? { t, leg, room: t.budget.remainingFor(leg) } : null;
})
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
if (
partialTarget &&
!booking.isGovernment &&
@@ -1183,13 +1209,13 @@ export class BookingBatchService implements OnModuleInit {
) {
const offered = await this.tryPartialOffer(
booking,
partialTarget.id,
partialTarget.budget,
partialTarget.t.id,
partialTarget.room,
need,
);
if (offered) {
partialTarget.budget = this.subtract(partialTarget.budget, offered);
partialTarget.armed = true;
partialTarget.t.budget.subtract(offered, partialTarget.leg);
partialTarget.t.armed = true;
continue;
}
}
@@ -1208,11 +1234,11 @@ export class BookingBatchService implements OnModuleInit {
if (partner) await this.reserve(partner, target.id);
target.armed = true;
}
target.budget = this.subtract(target.budget, need);
target.budget.subtract(need, legOn(target)!);
}
for (const t of trains) {
if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
@@ -1414,10 +1440,10 @@ export class BookingBatchService implements OnModuleInit {
"Target schedule is not accepting bookings",
);
}
if (
schedule.originStationId !== booking.originYardId ||
schedule.destinationStationId !== booking.destinationYardId
) {
const stops = await this.stopsForSchedule(schedule);
const fromIdx = stops.indexOf(booking.originYardId);
const toIdx = stops.indexOf(booking.destinationYardId);
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
throw new BadRequestException(
"Target schedule is not on the booking route",
);
@@ -1461,13 +1487,14 @@ export class BookingBatchService implements OnModuleInit {
// ---- intercity ride-along API ---------------------------------------------
/**
* Remaining capacity budget (wagons / weight / length) for a schedule, and
* the per-booking need calculator — exposed for the intercity accept flow,
* which reserves ride-along bookings onto import/export trains outside the
* batch engine.
* Remaining corridor capacity budget (per-edge wagons / weight / length) for
* a schedule, and the per-booking need calculator — exposed for the intercity
* accept flow, which reserves ride-along bookings onto import/export trains
* outside the batch engine. Segment-based: an intercity booking fits whenever
* ITS leg has room, even if the train is full on other legs.
*/
async intercityCapacity(scheduleId: string): Promise<{
budget: Capacity;
budget: CorridorBudget;
needFor: (booking: Booking) => Capacity;
} | null> {
const schedule =
@@ -1477,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
}
@@ -1635,13 +1662,17 @@ export class BookingBatchService implements OnModuleInit {
/**
* Free capacity for a government booking by displacing the lowest-priority commercial
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
* Only victims whose legs overlap the government booking's leg actually free useful
* room, so others are skipped. Mutates `budget`; returns whether the need now fits.
*/
private async preemptForGovernment(
scheduleId: string,
need: Capacity,
budget: Capacity,
leg: CorridorLeg,
budget: CorridorBudget,
wagonLengths: WagonLengths,
): Promise<Capacity> {
): Promise<boolean> {
if (budget.fits(need, leg)) return true;
const reservedCommercial = (
await this.bookingsRepository.findReservedForSchedule(scheduleId)
).filter((b) => !b.isGovernment);
@@ -1655,9 +1686,16 @@ export class BookingBatchService implements OnModuleInit {
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
);
let freed = budget;
for (const victim of candidates) {
if (this.fits(need, freed)) break;
if (budget.fits(need, leg)) break;
const victimLeg = budget.legForYards(
victim.originYardId,
victim.destinationYardId,
);
// Displacing a booking on a disjoint leg frees nothing the government
// booking can use — don't kill it for nothing.
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
if (!overlaps) continue;
await this.dataSource.transaction(async (manager) => {
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
scheduleId,
@@ -1680,9 +1718,9 @@ export class BookingBatchService implements OnModuleInit {
);
});
this.notifier.displaced(victim);
freed = this.add(freed, this.needFor(victim, wagonLengths));
budget.add(this.needFor(victim, wagonLengths), victimLeg);
}
return freed;
return budget.fits(need, leg);
}
// ---- capacity helpers -----------------------------------------------------
@@ -1790,22 +1828,6 @@ export class BookingBatchService implements OnModuleInit {
);
}
private subtract(budget: Capacity, need: Capacity): Capacity {
return {
wagons: budget.wagons - need.wagons,
weightTons: budget.weightTons - need.weightTons,
lengthMeters: budget.lengthMeters - need.lengthMeters,
};
}
private add(budget: Capacity, freed: Capacity): Capacity {
return {
wagons: budget.wagons + freed.wagons,
weightTons: budget.weightTons + freed.weightTons,
lengthMeters: budget.lengthMeters + freed.lengthMeters,
};
}
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
private async capacityLimits(
locomotive: Locomotive,
@@ -1883,37 +1905,68 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: {} });
}
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
private async remainingCapacity(
/**
* Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent.
*/
private async stopsForSchedule(schedule: TrainSchedule): Promise<string[]> {
let milestoneYards: string[] | null = null;
if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
if (milestones.length >= 2) milestoneYards = milestones.map((m) => m.yardId);
}
return stopYardsFor(
milestoneYards,
schedule.originStationId,
schedule.destinationStationId,
);
}
/**
* Remaining capacity per corridor edge = hard caps minus what allocated +
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
* Dire→Djibouti leaves the Addis→Dire edges untouched.
*/
private async remainingBudget(
schedule: TrainSchedule,
limits: Capacity,
wagonLengths: WagonLengths,
): Promise<Capacity> {
): Promise<CorridorBudget> {
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits);
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const used = [...allocated, ...reserved].reduce<Capacity>(
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
);
return this.subtract(limits, used);
for (const b of [...allocated, ...reserved]) {
budget.subtract(
this.needFor(b, wagonLengths),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
}
/** maxWagons minus wagons already taken by allocated + reserved bookings. */
/**
* Wagon slots still boardable somewhere on the corridor (most-open edge).
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
*/
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
const wagonLengths = await this.loadWagonLengths();
const budget = await this.remainingBudget(
schedule,
{
wagons: schedule.maxWagons ?? 0,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
wagonLengths,
);
const used =
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
return (schedule.maxWagons ?? 0) - used;
return budget.maxRemaining().wagons;
}
async setWindow(