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 aeae7d10a..c93ebd9c7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1287,6 +1287,23 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** Same as {@link findAllBySchedule} but for a page of schedules at once — + * one query instead of one per schedule (batch monitoring board). */ + findAllBySchedules(scheduleIds: string[]): Promise { + if (!scheduleIds.length) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds }) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository @@ -1342,6 +1359,10 @@ export class BookingsRepository extends BaseRepository { if (!bookingIds.length) return Promise.resolve([]); return this.bookingRepo(manager).find({ where: { id: In(bookingIds) }, + // Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins + // multiply rows badly in a single join (hot path for every allocation + // preview / assignment validation). + relationLoadStrategy: 'query', relations: { company: true, originYard: true, diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 8e40c0384..08b906e84 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -21,6 +21,10 @@ export class TrainSchedulesRepository extends BaseRepository { findByIdWithFullGraph(id: string, manager?: EntityManager): Promise { return this.repo(manager).findOne({ where: { id }, + // One SELECT per relation instead of a single monster join — the nested + // wagon×allocation×booking×container branches multiply rows catastrophically + // when joined (measured ~925ms vs ~84ms on a 21-wagon schedule). + relationLoadStrategy: 'query', relations: { // Yards carry the route's display name; without them formatRouteLabel // degrades to the literal "Origin → Destination". Milestones (with diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 5837e4cd2..4a836060e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -33,7 +33,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; -import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { eatDay } from './batch-window.util'; import { BATCH_BOARD_STATUSES, BatchBoardQueryDto, @@ -172,23 +172,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking { consolidationPartnerRef: string | null; } -export interface BatchWindowGroup { - key: string; - label: string; - /** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */ - date: string; - /** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */ - dateLabel: string; - start: string; - end: string; - counts: { - allocated: number; - selectedForBatch: number; - ready: number; - waiting: number; - expired: number; - pendingContract: number; - }; +export interface BatchBoardCounts { + allocated: number; + selectedForBatch: number; + ready: number; + waiting: number; + expired: number; + pendingContract: number; +} + +/** A booking bucket on the detail board (in-window vs pending-contract). */ +export interface BatchBoardBucket { + counts: BatchBoardCounts; bookings: BatchBoardBookingDetail[]; } @@ -215,8 +210,9 @@ export interface BatchBoardScheduleDetail { locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; - windows: BatchWindowGroup[]; - pendingContract: BatchWindowGroup; + /** Bookings inside the schedule's booking window (fully-executed contracts). */ + bookings: BatchBoardBookingDetail[]; + pendingContract: BatchBoardBucket; allocationViolations: string[]; } @@ -1193,11 +1189,32 @@ export class BookingBatchService implements OnModuleInit { const wagonDims = await this.loadWagonDims(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); + // One links query + one bookings query for the whole page (was 2 per card). + const scheduleIds = schedules.map((s) => s.id); + const [allLinks, allBookings] = await Promise.all([ + scheduleIds.length + ? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } }) + : Promise.resolve([]), + this.bookingsRepository.findAllBySchedules(scheduleIds), + ]); + const linkedIdsBySchedule = new Map>(); + for (const l of allLinks) { + let set = linkedIdsBySchedule.get(l.trainScheduleId); + if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set())); + set.add(l.bookingId); + } + const bookingsBySchedule = new Map(); + for (const b of allBookings) { + if (!b.trainScheduleId) continue; + let list = bookingsBySchedule.get(b.trainScheduleId); + if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = [])); + list.push(b); + } + const board: BatchBoardSchedule[] = []; for (const s of schedules) { - const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); - const linkedIds = new Set(links.map((l) => l.bookingId)); - const bookings = await this.bookingsRepository.findAllBySchedule(s.id); + const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set(); + const bookings = bookingsBySchedule.get(s.id) ?? []; const items: BatchBoardBooking[] = bookings.map((b) => { const need = this.needFor(b, wagonDims); @@ -1226,7 +1243,8 @@ export class BookingBatchService implements OnModuleInit { return { items: board, meta: buildPaginationMeta(total, page, pageSize) }; } - /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ + /** Schedule-level batch board: the schedule's own booking window plus its + * bookings split into in-window (contract executed) vs pending-contract. */ async getBatchBoardDetail( scheduleId: string, ): Promise { @@ -1244,17 +1262,24 @@ export class BookingBatchService implements OnModuleInit { } const wagonDims = await this.loadWagonDims(); - const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); - const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); - const linkedIds = new Set(links.map((l) => l.bookingId)); + // The full graph already carries the schedule↔booking links — no separate + // link query needed. + const linkedIds = new Set( + (s.scheduleBookings ?? []).map((l) => l.bookingId), + ); const bookings = await this.bookingsRepository.findAllBySchedule(s.id); let allocationPreview: Awaited< ReturnType >; try { + // Reuse the graph loaded above — the preview otherwise re-loads the same + // heavy schedule graph a second time per request. allocationPreview = - await this.trainSchedulingService.previewAllocationForSchedule(s.id); + await this.trainSchedulingService.previewAllocationForSchedule( + s.id, + s, + ); } catch { allocationPreview = { assignedBookingIds: [], @@ -1324,73 +1349,22 @@ export class BookingBatchService implements OnModuleInit { const loco = s.trainSet?.locomotive ?? null; - // Display windows are the REAL booking-window cycles this schedule was FROZEN - // with at creation (import: opens at its stored window time, lasts its rule's - // duration, reopens per its rule's delay; export: single FCFS lead window) — - // NOT the live global config. A later global-rules edit only re-derives - // not-yet-open schedules (restampPendingWindows), so an already-open schedule - // must keep drawing from its own snapshot, anchored on its stored open time. - // Legacy rows with no snapshot fall back to the live config. - const liveCfg = await this.trainSchedulingService.getWindowConfig(); - const num = (v: unknown, fallback: number) => { - const n = v == null ? NaN : Number(v); - return Number.isFinite(n) ? n : fallback; - }; - const windowCfg = { - windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour), - windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour), - windowDurationHours: num( - s.ruleWindowDurationHours, - liveCfg.windowDurationHours, - ), - // Frozen doc-review + payment sum; legacy rows fall back to the live sum. - reopenGapMinutes: num( - s.ruleReopenDelayMinutes, - liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes, - ), - importWindowLeadDays: num( - s.ruleImportWindowLeadDays, - liveCfg.importWindowLeadDays, - ), - exportBookingLeadHours: num( - s.ruleExportBookingLeadHours, - liveCfg.exportBookingLeadHours, - ), - // Frozen close offsets: a snapshot null means "no offset for this train" - // and stays null (not the live offset); only legacy rows lacking the - // column (undefined) fall back to live config. - importCloseOffsetMinutes: - s.ruleImportCloseOffsetMinutes !== undefined - ? s.ruleImportCloseOffsetMinutes - : liveCfg.importCloseOffsetMinutes, - exportCloseOffsetMinutes: - s.ruleExportCloseOffsetMinutes !== undefined - ? s.ruleExportCloseOffsetMinutes - : liveCfg.exportCloseOffsetMinutes, - }; - const departureDate = s.scheduledDepartureDate ?? new Date(); - const windowBuckets = groupBookingsIntoBoardWindows( - items, - (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), - s.direction ?? null, - departureDate, - windowCfg, - undefined, - s.windowOpensAt ?? null, - ); - - const emptyCounts = () => ({ - allocated: 0, - selectedForBatch: 0, - ready: 0, - waiting: 0, - expired: 0, - pendingContract: 0, - }); - - const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { - const counts = emptyCounts(); - for (const b of bookingsInWindow) { + // The board renders ONE booking window — the schedule's own frozen window + // (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings + // split into two buckets: contract executed (in the window) vs pending + // contract. The old per-cycle window projection was dropped — the UI never + // showed it, and reconstructing every cycle cost a config load + grouping + // pass per request. + const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => { + const counts: BatchBoardCounts = { + allocated: 0, + selectedForBatch: 0, + ready: 0, + waiting: 0, + expired: 0, + pendingContract: 0, + }; + for (const b of bucket) { if (b.state === "ALLOCATED") counts.allocated += 1; else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; else if (b.state === "READY") counts.ready += 1; @@ -1401,26 +1375,8 @@ export class BookingBatchService implements OnModuleInit { return counts; }; - const windows: BatchWindowGroup[] = []; - for (const [key, bucket] of windowBuckets) { - if (key === "pending-contract" || !bucket.window) continue; - const w = bucket.window; - windows.push({ - key: w.key, - label: w.label, - date: w.date, - dateLabel: w.dateLabel, - start: w.start.toISOString(), - end: w.end.toISOString(), - counts: countFor(bucket.items), - bookings: bucket.items, - }); - } - windows.sort( - (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), - ); - - const pendingBookings = windowBuckets.get("pending-contract")?.items ?? []; + const windowBookings = items.filter((i) => i.fullyExecutedAt); + const pendingBookings = items.filter((i) => !i.fullyExecutedAt); return { scheduleId: s.id, @@ -1470,14 +1426,8 @@ export class BookingBatchService implements OnModuleInit { .length, expired: items.filter((i) => i.state === "EXPIRED").length, }, - windows, + bookings: windowBookings, pendingContract: { - key: "pending-contract", - label: "Pending contract", - date: "", - dateLabel: "", - start: "", - end: "", counts: countFor(pendingBookings), bookings: pendingBookings, }, @@ -2511,7 +2461,13 @@ export class BookingBatchService implements OnModuleInit { if (!schedule || !locomotive) return null; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); - const budget = await this.remainingBudget(schedule, limits, wagonDims); + // Built trains: collapse to a single train-wide pool so the freed capacity of + // a booking that alights mid-corridor is NOT re-offered on the pass-through + // leg (see remainingBudget). Keeps intercity accept consistent with the + // train-wide isTrainFull / committedWagons finalize signal. + const budget = await this.remainingBudget(schedule, limits, wagonDims, { + collapseForBuiltTrain: true, + }); return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } @@ -3305,7 +3261,14 @@ export class BookingBatchService implements OnModuleInit { * (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has * no wagon type configured yet. */ + /** Wagon types are near-static reference data — a short TTL cache spares one + * table scan per board/detail request without letting edits go stale long. */ + private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null; + private async loadWagonDims(): Promise { + if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) { + return this.wagonDimsCache.value; + } const types = await this.dataSource.getRepository(WagonType).find(); const byCode = new Map( types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), @@ -3319,7 +3282,7 @@ export class BookingBatchService implements OnModuleInit { // must fall back rather than yield an infinite wagon count. const payload = (value: number | undefined, fallback: number): number => value && value > 0 ? value : fallback; - return { + const value: WagonDims = { container: { lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS, @@ -3332,6 +3295,8 @@ export class BookingBatchService implements OnModuleInit { }, byWagonTypeId, }; + this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 }; + return value; } /** @@ -3433,6 +3398,7 @@ export class BookingBatchService implements OnModuleInit { schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, + opts?: { collapseForBuiltTrain?: boolean }, ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); if (physicalWagons != null) { @@ -3445,7 +3411,21 @@ export class BookingBatchService implements OnModuleInit { tolerance: { weightTons: 0, lengthMeters: 0 }, }; } - const stops = await this.stopsForSchedule(schedule); + // A built train's wagons are coupled for the WHOLE trip, and the allocator + // commits each booking to a wagon for the entire route — it never reloads a + // wagon at a mid-corridor alight yard. So a built train has no leg concept: + // its capacity is one train-wide pool, exactly as isTrainFull / + // committedWagons already count it. When a caller opts in, collapse the + // corridor to a single whole-route edge so every booking (full-route OR + // mid-corridor) draws from that one pool — a train full of import-to-DireDawa + // then correctly shows NO room for a DireDawa->Addis intercity booking on the + // leg it merely passes through, instead of over-promising the freed slots. + // Locomotive-derived schedules keep the leg-aware multi-edge corridor: their + // abstract slot/weight/length budget genuinely frees past an alight yard. + const stops = + physicalWagons != null && opts?.collapseForBuiltTrain + ? [schedule.originStationId, schedule.destinationStationId] + : await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 9145824c7..af0f410a6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -104,7 +104,8 @@ export class IntercityService { async listCandidates(scheduleId: string) { const schedule = await this.getSchedule(scheduleId); - const milestoneSeq = await this.routeMilestoneSequence(schedule); + const milestones = await this.routeMilestones(schedule); + const milestoneSeq = this.milestoneSequenceOf(schedule, milestones); const capacity = await this.bookingBatchService.intercityCapacity(scheduleId); const waiting = milestoneSeq @@ -112,16 +113,32 @@ export class IntercityService { : []; const accepted = await this.findAcceptedIntercityBookings(scheduleId); + // Mid-corridor intercity matching needs a real stop list (>= 2 route + // milestones). Without one the fallback is a 2-stop origin->destination + // pseudo-route that only matches bookings on the train's exact corridor — + // surface that so an empty candidate list isn't misread as "nobody waiting". + const warning = + milestoneSeq == null + ? 'This schedule has no route or origin/destination set, so no intercity corridors can be served.' + : schedule.routeId && milestones.length < 2 + ? "This schedule's route has no stop list (needs at least 2 route milestones), so mid-corridor intercity bookings cannot be matched — only bookings on the train's exact origin→destination will appear." + : null; + return { scheduleId, routeId: schedule.routeId ?? null, + warning, // Segment-based: "remaining" is the most-open edge; each candidate's // `fits` is judged against ITS OWN leg, so a booking on a free leg fits // even when the train is full elsewhere. remaining: capacity?.budget.maxRemaining() ?? null, candidates: waiting.map((booking) => { const need = capacity?.needFor(booking) ?? null; - const leg = capacity?.budget.legOf( + // legForYards, not legOf: on a built train the budget is a single + // whole-route edge (see intercityCapacity), so a mid-corridor booking + // must draw from that one pool via the whole-route fallback. On a + // locomotive-derived schedule it still resolves to the booking's own leg. + const leg = capacity?.budget.legForYards( booking.originYardId, booking.destinationYardId, ); @@ -186,14 +203,17 @@ export class IntercityService { continue; } const need = capacity.needFor(booking); - const leg = budget.legOf(booking.originYardId, booking.destinationYardId); - // Segment-based: only the booking's own leg must have room, so an - // intercity booking still boards a train that is full on other legs. - if (!leg || !budget.fits(need, leg)) { + // legForYards, not legOf: a built train's budget is a single whole-route + // pool (mid-corridor wagons are committed for the whole trip and never + // reloaded), so the booking draws from that pool via the whole-route + // fallback; a locomotive-derived schedule still gets the booking's own + // leg, so it can still board a train that is full only on other legs. + const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); + if (!budget.fits(need, leg)) { rejected.push({ bookingId, reason: - 'Does not fit the remaining wagon/weight/length capacity on its leg', + 'Does not fit the remaining wagon/weight/length capacity for this train', }); continue; } @@ -244,16 +264,21 @@ export class IntercityService { * so an intercity booking exactly matching the train's own corridor still * qualifies. */ - private async routeMilestoneSequence( + private async routeMilestones( schedule: TrainSchedule, - ): Promise | null> { - if (schedule.routeId) { - const milestones = await this.dataSource - .getRepository(RouteMilestone) - .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); - if (milestones.length >= 2) { - return new Map(milestones.map((m) => [m.yardId, m.sequenceNo])); - } + ): Promise { + if (!schedule.routeId) return []; + return this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + } + + private milestoneSequenceOf( + schedule: TrainSchedule, + milestones: RouteMilestone[], + ): Map | null { + if (milestones.length >= 2) { + return new Map(milestones.map((m) => [m.yardId, m.sequenceNo])); } if (schedule.originStationId && schedule.destinationStationId) { return new Map([ @@ -264,6 +289,15 @@ export class IntercityService { return null; } + private async routeMilestoneSequence( + schedule: TrainSchedule, + ): Promise | null> { + return this.milestoneSequenceOf( + schedule, + await this.routeMilestones(schedule), + ); + } + /** Waiting = ready intercity bookings not yet on any train, corridor on this route. */ private async findWaitingIntercityBookings( milestoneSeq: Map, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 6bc36c4fc..3e142f778 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -139,7 +139,8 @@ export class TrainSchedulingController { @Get("batch-board/:scheduleId") @TrainSchedulingView() @ApiOperation({ - summary: "Batch board detail for one schedule with EAT 3h windows", + summary: + "Batch board detail for one schedule: its booking window, in-window bookings and pending-contract bucket", }) getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) { return this.bookingBatchService.getBatchBoardDetail(scheduleId); 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 38bc21156..1edd27792 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 @@ -479,6 +479,38 @@ export class TrainSchedulingService { return qb.getMany(); } + /** + * A built train makes at most ONE departure per route per EAT day. Returns + * the non-cancelled schedule already holding this train on this route for + * `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay + * legal — siblings must be different trains. + */ + private async findTrainRouteDayConflict( + trainId: string, + routeId: string, + departure: Date, + excludeScheduleId?: string, + ): Promise { + const day = eatDay(departure); + const dayStart = eatDayToUtc(day, 0); + const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0); + const qb = this.dataSource + .getRepository(TrainSchedule) + .createQueryBuilder('s') + .innerJoin('s.trainSet', 'ts') + .where('ts.trainId = :trainId', { trainId }) + .andWhere('s.routeId = :routeId', { routeId }) + .andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart }) + .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart }) + .andWhere('s.status != :cancelledStatus', { + cancelledStatus: TrainScheduleStatusEnum.Cancelled, + }); + if (excludeScheduleId) { + qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId }); + } + return qb.getOne(); + } + /** * The window timeline a brand-new schedule must adopt to join its route+day * group. Returns the canonical open/close times + rule snapshot copied from an @@ -882,6 +914,28 @@ export class TrainSchedulingService { ); } + // Moving onto a day where this same built train already runs this route + // would double-book the physical train — blocked for planning moves. + if (schedule.trainSetId && schedule.routeId) { + const trainSet = await this.dataSource + .getRepository(TrainSet) + .findOne({ where: { id: schedule.trainSetId } }); + if (trainSet?.trainId) { + const conflict = await this.findTrainRouteDayConflict( + trainSet.trainId, + schedule.routeId, + departure, + id, + ); + if (conflict) { + throw new ConflictException( + `This train is already scheduled on this route for that day ` + + `(${conflict.reference ?? conflict.id}) — one departure per route per day`, + ); + } + } + } + // Re-derive the window from the schedule's own rule snapshot (falling back to // the live config where a legacy row has no snapshot) against the new date. const merged = effectiveWindowConfig(schedule, windowCfg); @@ -915,10 +969,37 @@ export class TrainSchedulingService { scheduledDepartureDate: departure, ...windowFields, }); + + // Only customers whose bookings already HOLD wagons on this train are told + // about the move (SMS + email + portal inbox). Linked-but-unallocated + // bookings are skipped — nothing of theirs is riding this departure yet. + let notifiedCount = 0; + if (schedule.trainSetId) { + const allocations = await this.dataSource + .getRepository(WagonBookingAllocation) + .createQueryBuilder('a') + .innerJoin('a.trainSetWagon', 'slot') + .where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId }) + .getMany(); + const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))]; + if (allocatedBookingIds.length) { + const allocatedBookings = await this.dataSource.getRepository(Booking).find({ + where: { id: In(allocatedBookingIds) }, + relations: { company: true }, + }); + for (const booking of allocatedBookings) { + if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue; + this.bookingNotifier.rescheduled(booking, departure); + notifiedCount += 1; + } + } + } + this.logger.log( `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + `(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` + - `${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`, + `${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` + + `${notifiedCount} allocated customer booking(s) notified`, ); void this.emitWindowState(id); @@ -1222,6 +1303,17 @@ export class TrainSchedulingService { `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`, ); } + const conflict = await this.findTrainRouteDayConflict( + builtTrain.id, + route.id, + new Date(dto.scheduleDate), + ); + if (conflict) { + throw new ConflictException( + `Train ${builtTrain.code} is already scheduled on this route for that day ` + + `(${conflict.reference ?? conflict.id}) — one departure per route per day`, + ); + } } else { locomotiveIds = [...new Set(dto.locomotiveIds ?? [])]; if (locomotiveIds.length < 2) { @@ -1681,6 +1773,7 @@ export class TrainSchedulingService { scheduleId, schedule.originStationId, savedWagons, + schedule.reverseWagonOrder ?? false, ); }); @@ -4283,6 +4376,7 @@ export class TrainSchedulingService { scheduleId: string, originYardId: string, slots: TrainSetWagon[], + reverseWagonOrder = false, ) { const wagons = await manager.getRepository(Wagon).find(); const wagonTypes = await manager.getRepository(WagonType).find(); @@ -4328,6 +4422,7 @@ export class TrainSchedulingService { assignedPhysicalIds, builtTrainId, pinnedToScheduleIds, + reverseWagonOrder, ); if (!physical) continue; @@ -4421,6 +4516,7 @@ export class TrainSchedulingService { assignedPhysicalIds: Set, builtTrainId: string | null = null, pinnedToScheduleIds: Set = new Set(), + reverseWagonOrder = false, ): Wagon | undefined { const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; @@ -4441,12 +4537,26 @@ export class TrainSchedulingService { // wherever they currently sit (they travel with the train), never a loose // yard wagon. if (builtTrainId) { - return wagons.find( - (w) => - w.trainId === builtTrainId && - w.wagonTypeId === slot.wagonTypeId && - !assignedPhysicalIds.has(w.id), - ); + // Pin in the train's as-built coupling order (wagon.sequenceNumber) so the + // consist views draw the schedule exactly like the train builder; a schedule + // created with reverseWagonOrder pins back-to-front (physically-last wagon + // takes slot #1). Unsequenced wagons sort after every sequenced one. + const candidates = wagons + .filter( + (w) => + w.trainId === builtTrainId && + w.wagonTypeId === slot.wagonTypeId && + !assignedPhysicalIds.has(w.id), + ) + .sort((a, b) => { + if (a.sequenceNumber == null || b.sequenceNumber == null) { + return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0); + } + return reverseWagonOrder + ? b.sequenceNumber - a.sequenceNumber + : a.sequenceNumber - b.sequenceNumber; + }); + return candidates[0]; } // Prefer a wagon already waiting at the slot's board yard (no empty haul); // fall back to one riding from the train's origin. @@ -6220,11 +6330,21 @@ export class TrainSchedulingService { * engine's representative fallbacks for bookings whose cargo/container type * has no wagon type configured. Loaded once per request before mapping. */ + /** Wagon types are near-static reference data — a short TTL cache spares one + * table scan per detail/board request without letting edits go stale long. */ + private wagonTareDimsCache: { + value: Awaited>; + expiresAt: number; + } | null = null; + private async loadWagonTareDims(): Promise<{ byWagonTypeId: Map; bulk: { tareWeightTons: number; capacityTons: number }; container: { tareWeightTons: number; capacityTons: number }; }> { + if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) { + return this.wagonTareDimsCache.value; + } const types = await this.dataSource.getRepository(WagonType).find(); const byWagonTypeId = new Map( types.map((t) => [ @@ -6235,7 +6355,7 @@ export class TrainSchedulingService { }, ]), ); - return { + const value = { byWagonTypeId, bulk: { tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, @@ -6246,6 +6366,8 @@ export class TrainSchedulingService { capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, }, }; + this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 }; + return value; } /** @@ -6302,49 +6424,14 @@ export class TrainSchedulingService { ); const allocationIds = allocations.map((a) => a.id); const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); - // Booking weights are reported GROSS (cargo + wagon tare) — the number the - // locomotive actually hauls and the axis its pull limit is compared against. - const tareDims = await this.loadWagonTareDims(); // Import-from-Djibouti trains can only dispatch once loading is confirmed // (loadedOnTrainAt on the operation). Other directions have no departure // loading gate, so the workspace shows the confirm button as already done. const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule); - let loadingConfirmed = !requiresLoadingConfirmation; - if (requiresLoadingConfirmation) { - const op = await this.dataSource - .getRepository(ImportDjiboutiOperation) - .findOne({ where: { trainScheduleId: schedule.id } }); - loadingConfirmed = Boolean(op?.loadedOnTrainAt); - } - - const windowCfg = await this.getWindowConfig(); - - const [containerItems, bulkLoads] = await Promise.all([ - allocationIds.length - ? this.wagonAllocationContainerItemsRepository.findAll({ - where: { wagonBookingAllocationId: In(allocationIds) }, - relations: { containerType: true, bookingContainer: true }, - }) - : [], - allocationIds.length - ? this.wagonAllocationBulkLoadsRepository.findAll({ - where: { wagonBookingAllocationId: In(allocationIds) }, - relations: { cargoType: true }, - }) - : [], - ]); - - const containerItemsByAllocation = new Map(); - for (const item of containerItems) { - const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? []; - list.push(item); - containerItemsByAllocation.set(item.wagonBookingAllocationId, list); - } - const bulkLoadsByAllocation = new Map( - bulkLoads.map((load) => [load.wagonBookingAllocationId, load]), - ); + // Snapshot state decides below whether the live consist may be drawn at + // all, so it is derived before the consist wagons are fetched. // Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released // and re-pinned onto later trains — the live wagon↔slot joins no longer // describe THIS train. If a frozen snapshot was captured at the transition, @@ -6359,6 +6446,55 @@ export class TrainSchedulingService { (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), ); + // All independent lookups fired at once — they used to run one after + // another, stacking round-trips onto every detail request. + // tareDims: booking weights are reported GROSS (cargo + wagon tare) — the + // number the locomotive actually hauls against its pull limit. + const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] = + await Promise.all([ + this.loadWagonTareDims(), + requiresLoadingConfirmation + ? this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: schedule.id } }) + : null, + this.getWindowConfig(), + allocationIds.length + ? this.wagonAllocationContainerItemsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { containerType: true, bookingContainer: true }, + }) + : [], + allocationIds.length + ? this.wagonAllocationBulkLoadsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { cargoType: true }, + }) + : [], + schedule.trainSet?.trainId && !isWagonAllocationFrozen + ? this.dataSource.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + // Mirror the pinning direction: a reverse-order schedule draws the + // whole consist back-to-front, empties included. + order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' }, + }) + : [], + ]); + const loadingConfirmed = requiresLoadingConfirmation + ? Boolean(importOp?.loadedOnTrainAt) + : true; + + const containerItemsByAllocation = new Map(); + for (const item of containerItems) { + const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? []; + list.push(item); + containerItemsByAllocation.set(item.wagonBookingAllocationId, list); + } + const bulkLoadsByAllocation = new Map( + bulkLoads.map((load) => [load.wagonBookingAllocationId, load]), + ); + // The trainSet slots below are the PLANNED wagons (one per allocation). A // schedule tied to a built train hauls EVERY coupled wagon — empty ones // included (the pull-limit check already counts their tare) — so append the @@ -6381,41 +6517,32 @@ export class TrainSchedulingService { 0, ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo), ); - const emptyConsistWagons = - schedule.trainSet?.trainId && !isWagonAllocationFrozen - ? ( - await this.dataSource.getRepository(Wagon).find({ - where: { trainId: schedule.trainSet.trainId }, - relations: { wagonType: true }, - order: { sequenceNumber: 'ASC' }, - }) - ) - .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) - .map((wagon, index) => ({ - // Physical wagon id — there is no TrainSetWagon slot behind this - // row, so remove/edit affordances must stay disabled (consistOnly). - id: wagon.id, - sequenceNo: maxSlotSequenceNo + index + 1, - capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)), - lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)), - assignedWeightTons: 0, - tareWeightTons: wagon.wagonType - ? roundTons(Number(wagon.wagonType.tareWeightTons)) - : null, - status: 'EMPTY', - physicalWagonId: wagon.id, - physicalWagonNumber: wagon.wagonNumber ?? null, - wagonType: wagon.wagonType - ? { - id: wagon.wagonType.id, - code: wagon.wagonType.code, - name: wagon.wagonType.name, - } - : null, - allocations: [], - consistOnly: true, - })) - : []; + const emptyConsistWagons = rawConsistWagons + .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) + .map((wagon, index) => ({ + // Physical wagon id — there is no TrainSetWagon slot behind this + // row, so remove/edit affordances must stay disabled (consistOnly). + id: wagon.id, + sequenceNo: maxSlotSequenceNo + index + 1, + capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)), + lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)), + assignedWeightTons: 0, + tareWeightTons: wagon.wagonType + ? roundTons(Number(wagon.wagonType.tareWeightTons)) + : null, + status: 'EMPTY', + physicalWagonId: wagon.id, + physicalWagonNumber: wagon.wagonNumber ?? null, + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, + } + : null, + allocations: [], + consistOnly: true, + })); return { id: schedule.id, @@ -6425,6 +6552,7 @@ export class TrainSchedulingService { trainNumber: schedule.trainNumber ?? null, maxWagons: schedule.maxWagons ?? null, direction: schedule.direction ?? null, + reverseWagonOrder: schedule.reverseWagonOrder ?? false, requiresLoadingConfirmation, loadingConfirmed, // Booking-window phase + phase deadlines drive the countdown timers in the @@ -6725,8 +6853,13 @@ export class TrainSchedulingService { /** Preview wagon allocation issues per linked booking without mutating the schedule. */ async previewAllocationForSchedule( scheduleId: string, + // Callers that already hold the full schedule graph (batch board detail) + // pass it in so the preview doesn't re-load the same heavy graph. + preloadedSchedule?: TrainSchedule, ): Promise { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + const schedule = + preloadedSchedule ?? + (await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } @@ -6773,7 +6906,10 @@ export class TrainSchedulingService { ); if (!eligible.length) return empty; - const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id); + const wagonAssignedIds = await this.getWagonAssignedBookingIds( + schedule.id, + schedule, + ); const previewDto = { bookingIds: eligible.map((b) => b.id), scheduleDate: schedule.scheduledDepartureDate.toISOString(), @@ -7304,8 +7440,15 @@ export class TrainSchedulingService { return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId); } - private async getWagonAssignedBookingIds(scheduleId: string): Promise> { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + private async getWagonAssignedBookingIds( + scheduleId: string, + // Pass when the caller already holds the schedule with trainSet.wagons — + // only wagon ids are read here, the old full-graph reload was pure waste. + preloadedSchedule?: TrainSchedule, + ): Promise> { + const schedule = + preloadedSchedule ?? + (await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)); const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id); if (!wagonIds.length) return new Set(); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 1549caf0b..1deedd14d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1926,7 +1926,8 @@ export class WarehouseInventoryService { // 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries). const [schedule] = await this.dataSource.query( - `SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry" + `SELECT ts.id, ts.status, ts.destination_station_id AS "destinationStationId", + oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -1957,14 +1958,20 @@ export class WarehouseInventoryService { tradeDirection: string | null; cargoTypeCode: string | null; }[] = await this.dataSource.query( + // Only bookings whose destination IS this train's final yard unload into + // this (final-destination) warehouse. A mid-corridor import that alighted + // at an intermediate yard was already unloaded there by the checkpoint + // auto-unload; without this filter it would be mis-located into the final + // yard's inventory too. `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", cgt.code AS "cargoTypeCode" FROM freight.train_schedule_bookings tsb JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id - WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`, - [scheduleId], + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + AND b.destination_yard_id = $2`, + [scheduleId, schedule.destinationStationId], ); const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null; diff --git a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx index fb780261a..07c9a39d1 100644 --- a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx @@ -104,6 +104,11 @@ export function ApiErrorModal() { onClose={close} centered radius="md" + // Mounted at the app root, so its portal is FIRST in — at the + // default z-index (200) any page modal opened later (create schedule, + // allocation wizard, …) paints over it and the error hides underneath. + // Hoist above every Mantine overlay and the react-hot-toast layer (9999). + zIndex={10000} title={ diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 5cfe550a2..58d027c8c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -66,8 +66,8 @@ import { useToast } from "@/hooks/use-toast"; import type { BatchBoardBookingDetail, BatchBoardBookingState, + BatchBoardCounts, BatchBoardScheduleDetail, - BatchWindowGroup, BookingAllocationStatus, } from "@/types/trainScheduling"; @@ -398,7 +398,7 @@ const BookingTable = memo(function BookingTable({ ); }); -function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { +function WindowCountChips({ counts }: { counts: BatchBoardCounts }) { const chips: Array<{ value: number; color: string; label: string }> = [ { value: counts.allocated, color: "edr-green", label: "allocated" }, { value: counts.selectedForBatch, color: "orange", label: "selected" }, @@ -609,9 +609,7 @@ export default function BatchScheduleDetailPage() { const hasAssignedWagons = useMemo( () => Boolean( - data?.windows.some((w) => - w.bookings.some((b) => b.allocationStatus === "ASSIGNED"), - ) || + data?.bookings.some((b) => b.allocationStatus === "ASSIGNED") || data?.pendingContract.bookings.some( (b) => b.allocationStatus === "ASSIGNED", ), @@ -619,36 +617,33 @@ export default function BatchScheduleDetailPage() { [data], ); + const [activeTab, setActiveTab] = useState("overview"); + const scheduleDetailQuery = useQuery( api.trainScheduling.scheduleDetail.queryOptions({ input: { id: scheduleId ?? "", freightType: "CONTAINER" }, - enabled: Boolean(scheduleId), + // The heavy composition graph is only rendered by the composition tab and + // the overview diagram (which needs assigned wagons) — don't fetch it + // until one of them can actually show something. + enabled: + Boolean(scheduleId) && + (hasAssignedWagons || activeTab === "composition"), // Composition data only changes through mutations, which invalidate the // whole train-scheduling root — no need to refetch on remounts in between. staleTime: 5 * 60_000, }), ); - // Every booking on this schedule, flattened across windows + pending-contract, - // de-duplicated (a booking only appears once). Feeds the management table. + // Every booking on this schedule: in-window + pending-contract (the two + // buckets are disjoint). Feeds the management table. const allBookings = useMemo(() => { if (!data) return [] as BatchBoardBookingDetail[]; - const merged = [ - ...data.windows.flatMap((w) => w.bookings), - ...data.pendingContract.bookings, - ]; - const byId = new Map(); - for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b); - return [...byId.values()]; + return [...data.bookings, ...data.pendingContract.bookings]; }, [data]); - // All bookings that fall inside the schedule's booking window (every window - // cycle, flattened) — the window is one booking day, so these belong to the - // single window panel above. - const windowBookings = useMemo( - () => (data?.windows ?? []).flatMap((w) => w.bookings), - [data?.windows], - ); + // Bookings inside the schedule's booking window — they belong to the single + // window panel above. + const windowBookings = data?.bookings ?? []; const windowCounts = useMemo(() => { const counts = { @@ -684,7 +679,6 @@ export default function BatchScheduleDetailPage() { [data?.status], ); - const [activeTab, setActiveTab] = useState("overview"); const [adjustConsistOpen, setAdjustConsistOpen] = useState(false); const [selectedBookingId, setSelectedBookingId] = useState( null, diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index c66992901..97f14f1d8 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -400,23 +400,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking { consolidationPartnerRef: string | null; } -export interface BatchWindowGroup { - key: string; - label: string; - /** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */ - date: string; - /** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */ - dateLabel: string; - start: string; - end: string; - counts: { - allocated: number; - selectedForBatch: number; - ready: number; - waiting: number; - expired: number; - pendingContract: number; - }; +export interface BatchBoardCounts { + allocated: number; + selectedForBatch: number; + ready: number; + waiting: number; + expired: number; + pendingContract: number; +} + +/** A booking bucket on the detail board (in-window vs pending-contract). */ +export interface BatchBoardBucket { + counts: BatchBoardCounts; bookings: BatchBoardBookingDetail[]; } @@ -443,8 +438,9 @@ export interface BatchBoardScheduleDetail { locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; - windows: BatchWindowGroup[]; - pendingContract: BatchWindowGroup; + /** Bookings inside the schedule's booking window (fully-executed contracts). */ + bookings: BatchBoardBookingDetail[]; + pendingContract: BatchBoardBucket; allocationViolations: string[]; }