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, type Capacity } from './booking-batch.service'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; /** * 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, ) {} /** * 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, remaining: capacity?.budget ?? null, candidates: waiting.map((booking) => { const need = capacity?.needFor(booking) ?? null; return { ...this.mapBooking(booking), need, fits: need && capacity ? fits(need, capacity.budget) : false, }; }), 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 }> = []; let 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); if (!fits(need, budget)) { rejected.push({ bookingId, reason: 'Does not fit the remaining wagon/weight/length capacity', }); continue; } await this.bookingBatchService.acceptIntercity(booking, scheduleId); budget = subtract(budget, need); accepted.push(bookingId); this.logger.log( `Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`, ); } return { accepted, rejected, remaining: budget }; } /** * Mark an accepted intercity booking's cargo as loaded. Only allowed while * the train is physically at the booking's origin yard: either it has not * departed yet and the booking boards at the train's own origin, or the * latest recorded checkpoint is at the booking's origin yard. */ async loadBooking(scheduleId: string, bookingId: string) { const { schedule, booking } = await this.getAcceptedBooking( scheduleId, bookingId, ); if (booking.status !== 'PAID') { throw new BadRequestException( `Booking must be paid before loading (currently ${booking.status})`, ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); await this.dataSource .getRepository(Booking) .update(bookingId, { status: 'IN_TRANSIT' }); return { bookingId, status: 'IN_TRANSIT' as const }; } /** * 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) { const { schedule, booking } = await this.getAcceptedBooking( scheduleId, bookingId, ); if (booking.status !== 'IN_TRANSIT') { throw new BadRequestException( `Booking must be loaded/in transit before unloading (currently ${booking.status})`, ); } await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); await this.dataSource .getRepository(Booking) .update(bookingId, { status: 'COMPLETED' }); return { bookingId, status: 'COMPLETED' as const }; } // ---- 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 }; } /** * The train is "at" a yard when the latest recorded checkpoint is that yard, * or — for a booking boarding at the train's own origin — when the train has * not recorded any checkpoint yet (still sitting at its origin). */ private async assertTrainAtYard( schedule: TrainSchedule, yardId: string, side: 'origin' | 'destination', ): Promise { const latest = await this.dataSource .getRepository(TrainCheckpointEvent) .findOne({ where: { trainScheduleId: schedule.id }, order: { occurredAt: 'DESC', createdAt: 'DESC' }, }); if (!latest) { if (side === 'origin' && schedule.originStationId === yardId) return; throw new BadRequestException( 'Train has not reached this yard yet — record its checkpoint first', ); } if (latest.yardId !== yardId) { throw new BadRequestException( `Train's last recorded position is not at the booking's ${side} yard`, ); } } 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, }; } } function fits(need: Capacity, budget: Capacity): boolean { return ( need.wagons <= budget.wagons && need.weightTons <= budget.weightTons && need.lengthMeters <= budget.lengthMeters ); } function subtract(budget: Capacity, need: Capacity): Capacity { return { wagons: budget.wagons - need.wagons, weightTons: budget.weightTons - need.weightTons, lengthMeters: budget.lengthMeters - need.lengthMeters, }; }