import { BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingBatchService } from './booking-batch.service'; import { BookingJourneyService } from './booking-journey.service'; /** * Intercity (DOMESTIC) ride-along: intercity bookings never get their own * train — they ride a passing import/export schedule whose route milestones * contain the booking's origin strictly before its destination. * * Flow: the customer books a corridor with no date; at finalize time staff see * every waiting intercity booking whose corridor lies on the schedule's route, * with its wagon/weight/length need against the train's remaining capacity; * accepting reserves it (pay window → payment → allocation, same lifecycle as * a batch reservation). Cargo is loaded manually when the train reaches the * booking's origin yard and unloaded at its destination yard. */ @Injectable() export class IntercityService { private readonly logger = new Logger(IntercityService.name); constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly bookingBatchService: BookingBatchService, private readonly bookingJourneyService: BookingJourneyService, ) {} /** * Waiting intercity bookings this schedule could carry, with the train's * remaining capacity along all three axes (wagons, weight, length) and each * booking's need, so staff can pick what fits. */ async listCandidates(scheduleId: string) { const schedule = await this.getSchedule(scheduleId); const milestoneSeq = await this.routeMilestoneSequence(schedule); const capacity = await this.bookingBatchService.intercityCapacity(scheduleId); const waiting = milestoneSeq ? await this.findWaitingIntercityBookings(milestoneSeq) : []; const accepted = await this.findAcceptedIntercityBookings(scheduleId); return { scheduleId, routeId: schedule.routeId ?? null, // 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( booking.originYardId, booking.destinationYardId, ); return { ...this.mapBooking(booking), need, fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), }; }), accepted: accepted.map((booking) => ({ ...this.mapBooking(booking), need: capacity?.needFor(booking) ?? null, })), }; } /** * Accept selected waiting intercity bookings onto this train, in the given * order, each re-checked against the shrinking capacity budget. Commercial * bookings open a pay window (payment → allocation runs on the existing * settle lifecycle); government bookings allocate immediately. */ async acceptBookings(scheduleId: string, bookingIds: string[]) { if (bookingIds.length === 0) { throw new BadRequestException('Select at least one intercity booking'); } const schedule = await this.getSchedule(scheduleId); const milestoneSeq = await this.routeMilestoneSequence(schedule); if (!milestoneSeq) { throw new BadRequestException( 'Schedule has no route milestones — cannot serve intercity corridors', ); } const capacity = await this.bookingBatchService.intercityCapacity(scheduleId); if (!capacity) { throw new BadRequestException( 'Schedule has no locomotive/train set — capacity unknown', ); } const accepted: string[] = []; const rejected: Array<{ bookingId: string; reason: string }> = []; const budget = capacity.budget; for (const bookingId of bookingIds) { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId }, relations: { bookingContainers: true } }); if (!booking) { rejected.push({ bookingId, reason: 'Booking not found' }); continue; } const notWaiting = this.whyNotWaiting(booking, milestoneSeq); if (notWaiting) { rejected.push({ bookingId, reason: notWaiting }); 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)) { rejected.push({ bookingId, reason: 'Does not fit the remaining wagon/weight/length capacity on its leg', }); continue; } await this.bookingBatchService.acceptIntercity(booking, scheduleId); budget.subtract(need, leg); accepted.push(bookingId); this.logger.log( `Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`, ); } return { accepted, rejected, remaining: budget.maxRemaining() }; } /** * Mark an accepted intercity booking's cargo as loaded. Delegates to the * shared per-booking journey flow (same checkpoint gating as import/export). */ async loadBooking(scheduleId: string, bookingId: string) { await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard return this.bookingJourneyService.loadBooking(scheduleId, bookingId); } /** * Mark an intercity booking's cargo as unloaded at its destination yard — * requires the latest checkpoint to be at that yard. Completes the booking. */ async unloadBooking(scheduleId: string, bookingId: string) { await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard return this.bookingJourneyService.unloadBooking(scheduleId, bookingId); } // ---- helpers --------------------------------------------------------------- private async getSchedule(scheduleId: string): Promise { const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: scheduleId } }); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } return schedule; } /** * yardId → sequenceNo for the schedule's route. Falls back to a two-stop * origin/destination pseudo-route for legacy schedules without a routeId, * so an intercity booking exactly matching the train's own corridor still * qualifies. */ private async routeMilestoneSequence( 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])); } } if (schedule.originStationId && schedule.destinationStationId) { return new Map([ [schedule.originStationId, 1], [schedule.destinationStationId, 2], ]); } return null; } /** Waiting = ready intercity bookings not yet on any train, corridor on this route. */ private async findWaitingIntercityBookings( milestoneSeq: Map, ): Promise { const pool = await this.dataSource .getRepository(Booking) .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) .andWhere('booking.train_schedule_id IS NULL') .andWhere( `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') OR (booking.is_government = true AND booking.status = 'APPROVED'))`, ) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.created_at', 'ASC') .getMany(); return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq)); } /** Intercity bookings already reserved/allocated on this schedule. */ private async findAcceptedIntercityBookings( scheduleId: string, ): Promise { return this.dataSource .getRepository(Booking) .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) .andWhere('booking.train_schedule_id = :scheduleId', { scheduleId }) .orderBy('booking.created_at', 'ASC') .getMany(); } private corridorOnRoute( booking: Booking, milestoneSeq: Map, ): boolean { const originSeq = milestoneSeq.get(booking.originYardId); const destinationSeq = milestoneSeq.get(booking.destinationYardId); return ( originSeq != null && destinationSeq != null && originSeq < destinationSeq ); } private whyNotWaiting( booking: Booking, milestoneSeq: Map, ): string | null { if (booking.tradeDirection !== 'DOMESTIC') { return 'Not an intercity booking'; } if (booking.trainScheduleId) { return 'Already assigned to a train'; } const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED'; if (booking.status !== readyStatus) { return `Not ready to board (status ${booking.status})`; } if (!this.corridorOnRoute(booking, milestoneSeq)) { return "Corridor is not on this schedule's route"; } return null; } private async getAcceptedBooking(scheduleId: string, bookingId: string) { const schedule = await this.getSchedule(scheduleId); const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (booking.trainScheduleId !== scheduleId) { throw new BadRequestException('Booking is not assigned to this schedule'); } if (booking.tradeDirection !== 'DOMESTIC') { throw new BadRequestException('Not an intercity booking'); } return { schedule, booking }; } private mapBooking(booking: Booking) { return { id: booking.id, reference: booking.reference, status: booking.status, freightType: booking.freightType, isGovernment: booking.isGovernment, customer: booking.company?.name ?? 'Unknown customer', originYardId: booking.originYardId, destinationYardId: booking.destinationYardId, origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', weightTons: Number(booking.cargoTotalWeightVgm ?? 0), paymentDeadline: booking.paymentDeadline?.toISOString() ?? null, }; } }