From 2cdf8bd16185f8fb21573a42be51998054b534cc Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 30 Jul 2026 13:15:56 +0000 Subject: [PATCH] export flow and fix intercity issue --- .../modules/bookings/bookings.repository.ts | 38 ++++++-- .../train-scheduling.service.ts | 86 ++++++++++++++++--- 2 files changed, 105 insertions(+), 19 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index fb685a37c..7f9b3b9a0 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1088,6 +1088,15 @@ export class BookingsRepository extends BaseRepository { * the whole (route, day) pool rather than bookings pre-targeted to one train. */ day?: string; + /** + * The schedule's ordered route stops. When given, the corridor filter + * replaces the exact origin/destination match: any booking whose BOTH yards + * lie on the route qualifies (sub-corridor bookings like Dire→DCT on a + * GMT→Dire→DCT train — the caller still checks stop ORDER). Dateless + * DOMESTIC (intercity) bookings also join the pool: they ride any train on + * their corridor. + */ + corridorYardIds?: string[]; }): Promise { const qb = this.repository .createQueryBuilder('booking') @@ -1111,8 +1120,11 @@ export class BookingsRepository extends BaseRepository { // single-schedule filter only when no day is supplied (e.g. a staff-pinned // booking that still carries train_schedule_id). if (options.day) { + // Dateless DOMESTIC (intercity) bookings ride any train on their corridor + // — no scheduled_date to match, so the day filter must not hide them. qb.andWhere( - `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + `(DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day + OR (booking.trade_direction = 'DOMESTIC' AND booking.scheduled_date IS NULL))`, { day: options.day }, ); } else if (options.trainScheduleId) { @@ -1125,15 +1137,23 @@ export class BookingsRepository extends BaseRepository { qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType }); } - if (options.originStationId) { - qb.andWhere('booking.originYardId = :originStationId', { - originStationId: options.originStationId, - }); - } - if (options.destinationStationId) { - qb.andWhere('booking.destinationYardId = :destinationStationId', { - destinationStationId: options.destinationStationId, + if (options.corridorYardIds?.length) { + qb.andWhere('booking.originYardId IN (:...corridorYardIds)', { + corridorYardIds: options.corridorYardIds, + }).andWhere('booking.destinationYardId IN (:...corridorYardIds)', { + corridorYardIds: options.corridorYardIds, }); + } else { + if (options.originStationId) { + qb.andWhere('booking.originYardId = :originStationId', { + originStationId: options.originStationId, + }); + } + if (options.destinationStationId) { + qb.andWhere('booking.destinationYardId = :destinationStationId', { + destinationStationId: options.destinationStationId, + }); + } } if (options.schedulingStatus) { qb.andWhere('booking.scheduling_status = :schedulingStatus', { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 658ea7c78..ccab4ebc6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -638,23 +638,41 @@ export class TrainSchedulingService { let day: string | undefined; let originStationId = query.originStationId; let destinationStationId = query.destinationStationId; + // Corridor mode: a schedule with intermediate stops pools every booking + // whose leg lies ON its route (Dire→DCT on a GMT→Dire→DCT train), not just + // exact endpoint matches — otherwise a mid-corridor booking unassigned from + // a wagon vanishes from the "Paid · unassigned" pool forever. + let corridorStops: string[] | undefined; if (query.trainScheduleId) { const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId); if (schedule?.scheduledDepartureDate) { day = eatDay(schedule.scheduledDepartureDate); originStationId = originStationId ?? schedule.originStationId; destinationStationId = destinationStationId ?? schedule.destinationStationId; + const stops = await this.stopYardsForSchedule(schedule); + if (stops.length > 2) corridorStops = stops; } } - const bookings = await this.bookingsRepository.findEligibleForScheduling({ + let bookings = await this.bookingsRepository.findEligibleForScheduling({ freightType: query.freightType, originStationId, destinationStationId, schedulingStatus: query.schedulingStatus, trainScheduleId: query.trainScheduleId, day, + corridorYardIds: corridorStops, }); + if (corridorStops) { + // The IN-filter admits both yards anywhere on the route; only origin + // strictly before destination is actually rideable on this train. + const stopIdx = new Map(corridorStops.map((yardId, i) => [yardId, i])); + bookings = bookings.filter((b) => { + const from = stopIdx.get(b.originYardId); + const to = stopIdx.get(b.destinationYardId); + return from != null && to != null && from < to; + }); + } const tareDims = await this.loadWagonTareDims(); return { count: bookings.length, @@ -4604,8 +4622,12 @@ export class TrainSchedulingService { wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId, trainSetWagonId: slot.id, boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, })); + const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId); + const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : []; + const unpinnable = this.findUnpinnableWagonSlots( planSlots, wagons, @@ -4613,6 +4635,7 @@ export class TrainSchedulingService { originYardId, builtTrainId, pinnedToScheduleIds, + stops, ); if (unpinnable.length) { throw new BadRequestException({ @@ -4621,14 +4644,16 @@ export class TrainSchedulingService { }); } - const assignedPhysicalIds = new Set(); + const occupiedSpans = new Map>(); for (const slot of planSlots) { + const span = this.slotSpanOf(slot, stops); const physical = this.pickPhysicalWagonForSlot( slot, wagons, scheduleId, originYardId, - assignedPhysicalIds, + occupiedSpans, + span, builtTrainId, pinnedToScheduleIds, reverseWagonOrder, @@ -4642,7 +4667,9 @@ export class TrainSchedulingService { physicalWagonId: physical.id, status: 'RESERVED', }); - assignedPhysicalIds.add(physical.id); + const pinnedSpans = occupiedSpans.get(physical.id) ?? []; + pinnedSpans.push(span); + occupiedSpans.set(physical.id, pinnedSpans); } } @@ -4659,44 +4686,74 @@ export class TrainSchedulingService { this.builtTrainIdOfSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), ]); + const targetSchedule = targetScheduleId + ? await this.trainSchedulesRepository.findById(targetScheduleId) + : null; + const stops = targetSchedule + ? await this.stopYardsForSchedule(targetSchedule) + : []; return this.findUnpinnableWagonSlots( wagonPlan.map((slot) => ({ sequenceNo: slot.sequenceNo, wagonTypeId: slot.wagonTypeId, wagonTypeCode: slot.wagonTypeCode, boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, })), wagons, targetScheduleId, originYardId, builtTrainId, pinnedToScheduleIds, + stops, ); } + /** + * Stop-index span [board, alight) a slot occupies along the route. Slots with + * unknown/missing yards conservatively span the whole route (never share). + */ + private slotSpanOf( + slot: { boardYardId?: string | null; alightYardId?: string | null }, + stops: string[], + ): [number, number] { + const last = Math.max(1, stops.length - 1); + const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; + const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : last; + if (from < 0 || to < 0 || from >= to) return [0, last]; + return [from, to]; + } + private findUnpinnableWagonSlots( slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string; boardYardId?: string | null; + alightYardId?: string | null; }>, wagons: Wagon[], scheduleId: string | undefined, originYardId: string, builtTrainId: string | null = null, pinnedToScheduleIds: Set = new Set(), + stops: string[] = [], ): string[] { const violations: string[] = []; - const assignedPhysicalIds = new Set(); + // One physical wagon may serve several slots whose leg spans don't overlap + // (freed at its alight yard, reloaded downstream) — track occupied spans + // per wagon, not a flat taken-set. + const occupiedSpans = new Map>(); for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) { + const span = this.slotSpanOf(slot, stops); const physical = this.pickPhysicalWagonForSlot( slot, wagons, scheduleId, originYardId, - assignedPhysicalIds, + occupiedSpans, + span, builtTrainId, pinnedToScheduleIds, ); @@ -4706,7 +4763,9 @@ export class TrainSchedulingService { ); continue; } - assignedPhysicalIds.add(physical.id); + const spans = occupiedSpans.get(physical.id) ?? []; + spans.push(span); + occupiedSpans.set(physical.id, spans); } return violations; @@ -4722,14 +4781,21 @@ export class TrainSchedulingService { wagons: Wagon[], scheduleId: string | undefined, originYardId: string, - assignedPhysicalIds: Set, + occupiedSpans: Map>, + span: [number, number], builtTrainId: string | null = null, pinnedToScheduleIds: Set = new Set(), reverseWagonOrder = false, ): Wagon | undefined { + // Free for this slot = no already-assigned span on this wagon overlaps the + // slot's own leg. Disjoint legs (alight before board) share the wagon. + const spanFree = (wagonId: string): boolean => + (occupiedSpans.get(wagonId) ?? []).every( + ([from, to]) => to <= span[0] || span[1] <= from, + ); const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; - if (assignedPhysicalIds.has(wagon.id)) return false; + if (!spanFree(wagon.id)) return false; // Loose pool never lends a wagon coupled to a built train's consist. if (wagon.trainId) return false; // Out on a dispatched train right now — physically gone. @@ -4755,7 +4821,7 @@ export class TrainSchedulingService { (w) => w.trainId === builtTrainId && w.wagonTypeId === slot.wagonTypeId && - !assignedPhysicalIds.has(w.id), + spanFree(w.id), ) .sort((a, b) => { if (a.sequenceNumber == null || b.sequenceNumber == null) {