mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
changes
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user