fix issue

This commit is contained in:
Marshal
2026-07-31 13:27:44 +00:00
parent 66062ac1a2
commit b74b51dcc1
6 changed files with 102 additions and 119 deletions

View File

@@ -1328,10 +1328,9 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => {
// Export b→c holds every wagon of the border crossing: no further export
// can board anywhere (they all must ride that edge), so the window closes —
// while intercity keeps booking the free a→b leg through the per-leg budget.
it('is NOT full when the border edge is sold out but a home leg still has room', async () => {
// FULL is corridor-wide now: b→dj holds every wagon, but a→b is empty, so
// sub-corridor bookings can still sell that leg — the window stays open.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
@@ -1345,6 +1344,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL once every leg of the corridor is sold out', async () => {
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
yardCountries: {
'yard-a': 'ETHIOPIA',
'yard-b': 'ETHIOPIA',
'yard-dj': 'DJIBOUTI',
},
reserved: [
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-dj' }),
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-dj' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});

View File

@@ -4594,14 +4594,12 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* FULL is DIRECTIONAL: the schedule's trade direction is full when the
* border-crossing edge (which every export/import must ride) can't take one
* FULL is CORRIDOR-WIDE: the train is full only when NO leg can take one
* more minimal wagon on any axis — slots for built trains (the consist is
* the capacity, weight/length settled at build), all three axes otherwise
* (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
* Home-side legs may still run empty; intercity ride-alongs keep filling
* them via the per-leg budget and never consult this flag. Domestic routes
* (no border) are full only when every edge is closed.
* A full DCT→Dire leg alone does NOT close the window while Dire→GMP still
* has room — sub-corridor bookings keep selling the open legs.
*/
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =
@@ -4652,13 +4650,9 @@ export class BookingBatchService implements OnModuleInit {
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
// "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and
// every import must cross the ET↔DJ border edge, so once that edge can't
// take one more minimal wagon the booking window may close — even while
// home-side legs still run empty. Intercity ride-alongs never consult this
// flag; they keep booking the free legs through the per-leg budget.
// A single-country (domestic) corridor has no mandatory edge, so it is
// full only when EVERY edge is closed on some axis.
// Full only when EVERY edge is closed on some axis: a full border edge
// still leaves the home-side legs bookable by sub-corridor cargo, so the
// window must stay open until not even the smallest wagon fits anywhere.
const wagonDims = await this.loadWagonDims();
const physicalWagons = await this.builtTrainWagonCount(schedule);
let limits: TrainLimits;
@@ -4681,42 +4675,9 @@ export class BookingBatchService implements OnModuleInit {
}
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const minNeed = this.minPerWagonNeed(wagonDims);
const border = await this.borderLeg(budget.stops);
if (border) {
return !budget.fits(
{
wagons: 1,
weightTons: minNeed.grossWeightTons,
lengthMeters: minNeed.lengthMeters,
},
border,
);
}
return budget.isExhausted(minNeed);
}
/**
* The corridor's single border-crossing edge (last home-country stop → first
* far-country stop), or null when every stop is in one country. This is the
* edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it
* books — which makes it the train's directional fullness gauge.
*/
private async borderLeg(stops: string[]): Promise<CorridorLeg | null> {
if (stops.length < 2) return null;
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: { id: In(stops) } });
const countryOf = new Map(yards.map((y) => [y.id, y.country]));
const first = countryOf.get(stops[0]);
if (!first) return null;
const crossIdx = stops.findIndex((id) => {
const country = countryOf.get(id);
return country != null && country !== first;
});
if (crossIdx <= 0) return null;
return { fromEdge: crossIdx - 1, toEdge: crossIdx };
}
/**
* Smallest gross weight / shortest length one more wagon could add: the
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
@@ -4762,6 +4723,31 @@ export class BookingBatchService implements OnModuleInit {
// nothing can board.
if (await this.isTrainFull(schedule)) return;
// FULL concluded the cycle (phase DONE) and DONE rows are skipped by the
// window tick forever — so when wagons free up before departure, restart
// the cycle or nobody (customer or batch) can ever book the freed space.
// ponytail: reopens now and closes at departure; the office-hours clamp
// reapplies on the next conclude cycle.
const departure = schedule.scheduledDepartureDate;
if (
schedule.windowPhase === "DONE" &&
["DRAFT", "SCHEDULED"].includes(schedule.status) &&
departure &&
departure.getTime() > Date.now()
) {
await this.dataSource.getRepository(TrainSchedule).update(scheduleId, {
windowPhase: "PRE_WINDOW",
windowOpensAt: new Date(),
windowClosesAt: departure,
});
await this.setWindow(scheduleId, "OPEN");
this.logger.log(
`[BATCH] ${scheduleId} FULL cleared after wagons freed — window revived ` +
`(PRE_WINDOW, reopens immediately, closes at departure)`,
);
return;
}
const customerWindowOpen =
schedule.windowPhase == null || schedule.windowPhase === "OPEN";
await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED");

View File

@@ -2031,6 +2031,11 @@ export class TrainSchedulingService {
});
});
// Freed wagons may un-full the train — re-derive the window status (this
// also revives a DONE window pre-departure so the freed space is bookable
// again for import/export).
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
await this.trainCompositionRemovalLogRepository.create({
scheduleId,
bookingId,