diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 05f216a7b..9f839f613 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -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) => 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', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index d20cc8dcf..ccbaa08c7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -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): 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' },