fix(train-scheduling): scope numbered marshalling docs to their own stop's coupling

Marshalling 2's wagon list was computed from intercityOnBoardView's
CURRENT state (boardYardId == null || hasLoaded), which can't tell
'coupled at THIS stop' from 'coupled at a LATER stop that has, by
generation time, also already happened' — both read LOADED once the
trip has moved on. Reprinting Marshalling 2 (Dire Dawa) after
Marshalling 3's stop (Adama) had already run leaked Adama's 5 coupled
wagons into Dire Dawa's document (54 wagons became 59), reproduced
live on S-2026-00073.

marshallingDocumentAt now corrects the on-board list against the
adjustment log: a leg-slot wagon belongs on a stop's document only if
it has a logged ADD at or before that stop's own timestamp. Extracted
as wagonsAsOfStop so it's unit-testable without mocking the DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-29 09:35:30 +00:00
parent beb98edbf7
commit 122b6d447a
2 changed files with 64 additions and 1 deletions

View File

@@ -1406,6 +1406,30 @@ describe('TrainSchedulingService', () => {
expect(numbers).toEqual(['W-LEG2']);
});
it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => {
// Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded
// at Adama, a LATER stop) read identically to intercityOnBoardView by
// the time this runs — both LOADED right now. Only the adjustment log
// knows W-ADAMA hadn't coupled yet as of Dire Dawa's own timestamp.
const wholeRoute = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]);
const legDireDawa = { ...makeWagon(2, 'W-DIRE', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-dire' };
const legAdama = { ...makeWagon(3, 'W-ADAMA', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-adama' };
const schedule = { trainSet: { wagons: [wholeRoute, legDireDawa, legAdama] }, scheduleBookings: [] };
const { wagons } = onBoardView(schedule);
const boardedByDireDawa = new Set(['W-DIRE']); // logged ADD only up to Dire Dawa's stop
const wagonsAsOfStop = (service as never as {
wagonsAsOfStop: (w: unknown, s: Set<string>) => Array<{ physicalWagon: { wagonNumber: string } }>;
}).wagonsAsOfStop.bind(service);
const asOfDireDawa = wagonsAsOfStop(wagons, boardedByDireDawa);
expect(asOfDireDawa.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE']);
const boardedByAdama = new Set(['W-DIRE', 'W-ADAMA']); // both stops have now happened
const asOfAdama = wagonsAsOfStop(wagons, boardedByAdama);
expect(asOfAdama.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE', 'W-ADAMA']);
});
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
const rider = {
id: 'booking-9',

View File

@@ -28,6 +28,7 @@ import {
ILike,
In,
IsNull,
LessThanOrEqual,
Not,
QueryFailedError,
Raw,
@@ -3623,6 +3624,24 @@ export class TrainSchedulingService {
return { wagons, unassignedBookings };
}
/**
* Corrects intercityOnBoardView's CURRENT-state wagon list against a
* specific stop's document. intercityOnBoardView's "boardYardId == null ||
* hasLoaded" test reads whatever is true RIGHT NOW — it can't distinguish
* "this leg slot coupled at THIS stop" from "it coupled at a LATER stop
* that has, by generation time, also already happened" (both look LOADED).
* Reprinting an earlier stop's document after a later one has run would
* otherwise leak the later stop's wagons in. `boardedWagonNumbers` is the
* set of physical wagon numbers with a logged ADD at or before this stop
* (see marshallingDocumentAt) — the ground truth a real-time heuristic
* can't provide once multiple stops have already happened.
*/
private wagonsAsOfStop(wagons: TrainSetWagon[], boardedWagonNumbers: Set<string>): TrainSetWagon[] {
return wagons.filter(
(wagon) => wagon.boardYardId == null || boardedWagonNumbers.has(wagon.physicalWagon?.wagonNumber ?? ''),
);
}
/**
* Every corridor stop where the consist actually changed for this schedule
* (coupled, uncoupled, or switched — any flavor), in the order the train
@@ -3717,7 +3736,27 @@ export class TrainSchedulingService {
);
}
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
const { wagons: currentWagons, unassignedBookings } = this.intercityOnBoardView(schedule);
// intercityOnBoardView's "boardYardId == null || hasLoaded" test reads
// CURRENT state — it can't tell "coupled here" from "coupled at a LATER
// stop that has since also happened" (both look LOADED by generation
// time once the trip has moved past this stop). Reprinting Marshalling 2
// after Marshalling 3's stop already ran would otherwise show Marshalling
// 3's coupled wagons too. Correct it against the log: a leg-slot wagon
// belongs on THIS stop's document only if it actually has a logged ADD
// at or before THIS stop's own timestamp.
const boardedByThisStop = new Set(
(
await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: {
trainScheduleId: scheduleId,
action: 'ADD',
occurredAt: LessThanOrEqual(new Date(stop.firstOccurredAt)),
},
})
).map((row) => row.wagonNumber),
);
const wagons = this.wagonsAsOfStop(currentWagons, boardedByThisStop);
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
order: { occurredAt: 'ASC' },