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. */ /** * Every intercity booking and where it is in its ride-along, across all trains. * * The per-schedule candidate list answers "what can THIS train carry"; this * answers "what is happening to intercity cargo" — which is what a yard * operator needs when the work is spread over whichever trains happen to pass. * * Carries each end's facility state, because a booking whose origin or * destination has no facility can never be loaded or unloaded there and the * operator should see that before the train arrives, not when the load is * refused. */ async listBookings() { return this.dataSource.query( `SELECT b.id AS "bookingId", b.reference AS "reference", b.status AS "status", b.freight_type AS "freightType", b.cargo_total_weight_vgm AS "weightTons", b.loaded_at AS "loadedAt", b.arrived_at AS "arrivedAt", company.name AS "customer", b.train_schedule_id AS "trainScheduleId", ts.train_number AS "trainNumber", ts.status AS "scheduleStatus", oy.id AS "originYardId", COALESCE(oy.label, oy.code) AS "origin", -- Can that end actually handle THIS booking's cargo? A container -- booking needs a facility with a stacker; bulk needs any facility. (oy.has_facility AND COALESCE( CASE WHEN b.freight_type = 'CONTAINER' THEN ofac.handles_container ELSE ofac.handles_bulk END, false)) AS "originHasFacility", dy.id AS "destinationYardId", COALESCE(dy.label, dy.code) AS "destination", (dy.has_facility AND COALESCE( CASE WHEN b.freight_type = 'CONTAINER' THEN dfac.handles_container ELSE dfac.handles_bulk END, false)) AS "destinationHasFacility", -- Where the train actually is, so the operator knows if the cargo -- can be worked right now. cp.yard_id AS "trainAtYardId", -- Most recent GRN raised for this booking at a facility. fh.grn_number AS "grnNumber" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.yard_facilities ofac ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true LEFT JOIN freight.yard_facilities dfac ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true LEFT JOIN freight.train_schedules ts ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL LEFT JOIN LATERAL ( SELECT c.yard_id FROM freight.train_checkpoint_events c WHERE c.train_schedule_id = b.train_schedule_id ORDER BY c.occurred_at DESC, c.created_at DESC LIMIT 1 ) cp ON true LEFT JOIN LATERAL ( SELECT e.grn_number FROM freight.facility_handling_events e WHERE e.booking_id = b.id AND e.deleted_at IS NULL ORDER BY e.occurred_at DESC LIMIT 1 ) fh ON true WHERE b.deleted_at IS NULL AND b.trade_direction = 'DOMESTIC' ORDER BY b.created_at DESC`, ); } async listCandidates(scheduleId: string) { const schedule = await this.getSchedule(scheduleId); const milestones = await this.routeMilestones(schedule); const milestoneSeq = this.milestoneSequenceOf(schedule, milestones); const capacity = await this.bookingBatchService.intercityCapacity(scheduleId); const waiting = milestoneSeq ? await this.findWaitingIntercityBookings(milestoneSeq) : []; 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; // legForYards: the booking draws only from ITS OWN leg's edges, with a // whole-route fallback when its yards aren't on the budget's stop list. const leg = capacity?.budget.legForYards( booking.originYardId, booking.destinationYardId, ); return { ...this.mapBooking(booking, need), need, fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), }; }), accepted: accepted.map((booking) => { const need = capacity?.needFor(booking) ?? null; return { ...this.mapBooking(booking, need), need }; }), }; } /** * 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: { containerType: true }, cargoType: 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); // legForYards: charge only the edges this booking rides, 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)) { // Offer the part that DOES fit the leg (split-on-payment): customer is // notified with a pay window for the fitting wagons; the remainder can // be re-booked on a later train. Budget is consumed by the offer so the // next booking in this pass sees the reduced room. const offered = await this.bookingBatchService.offerIntercityPartial( booking, scheduleId, budget, ); rejected.push({ bookingId, reason: offered ? 'Does not fit whole — a partial offer for the wagons that fit was sent to the customer' : 'Does not fit the remaining wagon/weight/length capacity for this train', }); 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 routeMilestones( schedule: TrainSchedule, ): 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([ [schedule.originStationId, 1], [schedule.destinationStationId, 2], ]); } 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, ): Promise { const pool = await this.dataSource .getRepository(Booking) .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) .andWhere('booking.train_schedule_id IS NULL') // PAID = customer paid but staff have not placed it on a train yet // (intercity allocation is manual) — it stays in the pool until they do. .andWhere( `((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID')) 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('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .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'; } // Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed, // awaiting manual placement) links straight onto the chosen train. const readyStatuses = booking.isGovernment ? ['APPROVED'] : ['FULLY_EXECUTED', 'PAID']; if (!readyStatuses.includes(booking.status)) { 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 }; } /** * `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is * spent in. Prefer it, so the row's weight sits on the same axis as the * remaining-capacity figure shown beside it; cargo VGM is the fallback. */ private mapBooking(booking: Booking, need?: { weightTons: number } | null) { 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: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0), paymentDeadline: booking.paymentDeadline?.toISOString() ?? null, }; } }