diff --git a/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts new file mode 100644 index 000000000..a98fcd80d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface { + name = "AddBookingWindowGlobalRules1861000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_window_lead_days, + DROP COLUMN IF EXISTS export_booking_lead_hours, + DROP COLUMN IF EXISTS window_open_hour, + DROP COLUMN IF EXISTS window_duration_hours, + DROP COLUMN IF EXISTS doc_review_minutes, + DROP COLUMN IF EXISTS payment_window_minutes, + DROP COLUMN IF EXISTS reopen_delay_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts new file mode 100644 index 000000000..0d5155391 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * arriveSchedule used to release only the primary locomotive of a train set, leaving + * secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out + * on a dispatched train — release every ASSIGNED locomotive that is not attached to a + * currently-DISPATCHED schedule. + */ +export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface { + name = "ReleaseStuckAssignedLocomotives1861000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.locomotives l + SET status = 'AVAILABLE' + WHERE l.status = 'ASSIGNED' + AND NOT EXISTS ( + SELECT 1 + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND loco.locomotive_id = l.id + ); + `); + } + + public async down(): Promise { + // Data fix — not reversible. + } +} diff --git a/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts new file mode 100644 index 000000000..cae33a534 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddScheduleWindowPhases1862000000000 implements MigrationInterface { + name = "AddScheduleWindowPhases1862000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN window_phase varchar(20) NULL, + ADD COLUMN window_opens_at timestamptz NULL, + ADD COLUMN window_closes_at timestamptz NULL, + ADD COLUMN doc_review_ends_at timestamptz NULL, + ADD COLUMN doc_review_completed_at timestamptz NULL, + ADD COLUMN payment_phase_ends_at timestamptz NULL, + ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0; + `); + await queryRunner.query(` + CREATE INDEX idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) + WHERE window_phase IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS window_phase, + DROP COLUMN IF EXISTS window_opens_at, + DROP COLUMN IF EXISTS window_closes_at, + DROP COLUMN IF EXISTS doc_review_ends_at, + DROP COLUMN IF EXISTS doc_review_completed_at, + DROP COLUMN IF EXISTS payment_phase_ends_at, + DROP COLUMN IF EXISTS booking_cycle_no; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts new file mode 100644 index 000000000..779807a57 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateBookingBatchOffers1863000000000 implements MigrationInterface { + name = "CreateBookingBatchOffers1863000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.booking_batch_offers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + offered_wagons integer NOT NULL, + total_wagons integer NOT NULL, + offered_lines jsonb NULL, + offered_weight_tons numeric(12, 3) NOT NULL, + offered_amount numeric(14, 2) NOT NULL, + offered_pricing_breakdown jsonb NULL, + invoice_id uuid NULL, + payment_deadline timestamptz NOT NULL, + status varchar(10) NOT NULL DEFAULT 'OFFERED', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ); + `); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 898f78cd5..031e521a9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -991,6 +991,15 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + // Export is FCFS: fail the accept up-front (409) when no export train on the + // booking's day still has capacity — nothing below runs and the request stays + // pending for staff to move/decline. + const isExportTrain = + booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); + const exportScheduleId = isExportTrain + ? await this.bookingBatchService.pickExportSchedule(booking) + : null; + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, @@ -1014,7 +1023,15 @@ export class BookingTransitionService { lockedAt: booking.lockedAt ?? now, } as never); - if (booking.scheduledDate) { + if (exportScheduleId) { + // FCFS: reserve the slot and send the payment notification immediately; + // paid → auto-allocated by the settle/paid pipeline. + const fresh = await this.bookingsService.findById(booking.id); + await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId); + } else if (booking.tradeDirection === "IMPORT") { + // Import bookings wait for their booking-day window cycle — the batch runs + // after staff document review, never at accept time. + } else if (booking.scheduledDate) { this.bookingBatchService.enqueueRouteDayProcessing( booking.originYardId, booking.destinationYardId, @@ -1029,6 +1046,12 @@ export class BookingTransitionService { latestChangeRequestNote?: string | null; contractSummary?: string | null; nextStep: BookingNextStep | null; + activeBatchOffer?: { + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null; } > { const note = await this.bookingsRepository.findLatestReviewNote( @@ -1044,11 +1067,16 @@ export class BookingTransitionService { ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) : null; const nextStep = computeNextStep(booking, nextPending); + const activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, + activeBatchOffer, }; } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index d1ed23ef7..d67d58811 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -83,6 +83,34 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; + /** + * Booking-window lifecycle for the one-booking-day cycle + * (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → reopen | CLOSED_FOR_DAY | DONE). + * NULL on legacy and DOMESTIC schedules — the window engine ignores those. + */ + @Column({ name: 'window_phase', type: 'varchar', length: 20, nullable: true }) + windowPhase?: string | null; + + @Column({ name: 'window_opens_at', type: 'timestamptz', nullable: true }) + windowOpensAt?: Date | null; + + @Column({ name: 'window_closes_at', type: 'timestamptz', nullable: true }) + windowClosesAt?: Date | null; + + @Column({ name: 'doc_review_ends_at', type: 'timestamptz', nullable: true }) + docReviewEndsAt?: Date | null; + + /** Staff finished document review early — starts the batch/payment phase immediately. */ + @Column({ name: 'doc_review_completed_at', type: 'timestamptz', nullable: true }) + docReviewCompletedAt?: Date | null; + + @Column({ name: 'payment_phase_ends_at', type: 'timestamptz', nullable: true }) + paymentPhaseEndsAt?: Date | null; + + /** 1-based count of open→settle cycles run on the booking day. */ + @Column({ name: 'booking_cycle_no', type: 'int', default: 0 }) + bookingCycleNo!: number; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 7316e1610..650da3adc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -121,6 +121,66 @@ function windowFromEatStart( }; } +/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */ +export function eatDayToUtc(day: string, hour: number, minute = 0): Date { + const [year, month, dayOfMonth] = day.split('-').map(Number); + return eatToUtc(year, month, dayOfMonth, hour, minute); +} + +/** Shift a `yyyy-MM-dd` EAT day key by whole days. */ +export function shiftEatDay(day: string, deltaDays: number): string { + // Noon UTC keeps the +3h EAT offset from crossing a day boundary. + const [year, month, dayOfMonth] = day.split('-').map(Number); + const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12)); + return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String( + shifted.getUTCDate(), + ).padStart(2, '0')}`; +} + +export interface InitialWindowTimes { + windowOpensAt: Date; + windowClosesAt: Date; +} + +/** + * Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus + * `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its + * computed window has fully passed gets a same-day window starting now instead, + * capped at departure. + */ +export function computeImportWindowTimes( + departure: Date, + cfg: { + importWindowLeadDays: number; + windowOpenHour: number; + windowDurationHours: number; + }, + now: Date, +): InitialWindowTimes { + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); + let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); + let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); + if (closesAt.getTime() <= now.getTime()) { + opensAt = now; + closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000); + } + if (closesAt.getTime() > departure.getTime()) { + closesAt = departure; + } + return { windowOpensAt: opensAt, windowClosesAt: closesAt }; +} + +/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */ +export function computeExportWindowTimes( + departure: Date, + cfg: { exportBookingLeadHours: number }, +): InitialWindowTimes { + return { + windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000), + windowClosesAt: departure, + }; +} + /** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */ export function getBatchWindowForTimestamp(date: Date): BatchWindow { const { year, month, day, hour } = eatParts(date); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 5c9cb6119..d8dc6b116 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -1,20 +1,14 @@ /** * Tunables for the demand-batching booking → allocation flow. - * Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock. + * Times run in EAT so window boundaries match the local operating clock. + * + * Cadence and pay-window durations moved to the train_scheduling_global_rules + * table (TrainSchedulingService.getWindowConfig) — the window engine + * (BookingWindowService) drives all timing off that config. */ -/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */ -// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *'; -// export const BATCH_CRON = '*/3 * * * *'; -export const BATCH_CRON = '*/5 * * * *'; -// export const BATCH_CRON = '0 */3 * * *';// - export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; -/** How long a selected commercial customer has to pay before their slot expires. */ -// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour -export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode) - /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 9e974b31e..2712c6b48 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -35,6 +35,7 @@ describe('BookingBatchService — PAID reconcile', () => { let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; getBookableSchedules: jest.Mock; + getWindowConfig: jest.Mock; }; let dataSource: { getRepository: jest.Mock; @@ -77,6 +78,15 @@ describe('BookingBatchService — PAID reconcile', () => { violations: [], }), getBookableSchedules: jest.fn().mockResolvedValue([]), + getWindowConfig: jest.fn().mockResolvedValue({ + importWindowLeadDays: 3, + exportBookingLeadHours: 24, + windowOpenHour: 8, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + reopenDelayMinutes: 90, + }), }; const bookingRepo = { @@ -187,17 +197,24 @@ describe('BookingBatchService — PAID reconcile', () => { }) as unknown as Booking; beforeEach(() => { - // Two OPEN trains on the same route + day, train A earlier than train B. - trainSchedulingService.getBookableSchedules.mockResolvedValue([ + // Two OPEN legacy trains on the same route + day, train A earlier than train B. + // fillRouteDay now selects fillable schedules straight from the repository. + trainSchedulesRepository.findAll.mockResolvedValue([ { id: trainA, - scheduleDate: '2026-06-20T06:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, { id: trainB, - scheduleDate: '2026-06-20T09:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, ]); trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => 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 218a20436..722744c2a 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 @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, Injectable, Logger, NotFoundException, @@ -7,7 +8,7 @@ import { Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { Cron, SchedulerRegistry } from '@nestjs/schedule'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; @@ -22,17 +23,14 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; -import { Freight } from "@edr/types"; +import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; import { BillingService } from "../billing/billing.service"; import { - BATCH_CRON, - BATCH_TIMEZONE, DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, - PAYMENT_WINDOW_MS, } from "./booking-batch.constants"; import { bookingTrainLengthMeters, @@ -41,6 +39,7 @@ import { } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { BookingSplitService } from './booking-split.service'; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -121,6 +120,13 @@ export interface BatchBoardScheduleDetail { scheduleDate: string | null; status: string; bookingWindowStatus: string; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; @@ -138,6 +144,13 @@ export interface BatchBoardSchedule { scheduleDate: string | null; status: string; bookingWindowStatus: string; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; locomotive: { code: string; name: string | null; @@ -189,6 +202,7 @@ export class BookingBatchService implements OnModuleInit { private readonly billing: BillingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, + @Optional() private readonly splitService?: BookingSplitService, ) {} @@ -284,11 +298,17 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } - /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ + /** + * Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules — + * schedules with a `windowPhase` are driven exclusively by the window engine + * (BookingWindowService), never by the periodic legacy fill. + */ private async openRouteDayGroups(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: "OPEN" }, - }); + const open = ( + await this.trainSchedulesRepository.findAll({ + where: { bookingWindowStatus: "OPEN" }, + }) + ).filter((s) => s.windowPhase == null); const groups = new Map(); for (const s of open) { if (!s.scheduledDepartureDate) continue; @@ -340,6 +360,12 @@ export class BookingBatchService implements OnModuleInit { .update(bookingId, { paymentStatus: "PAID" }); } + // Paying inside the window accepts an open partial offer — reduce the booking + // to the offered part before it boards (remainder returns to the contract cap). + if (this.splitService) { + await this.splitService.applySplit(bookingId); + } + const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { @@ -381,6 +407,99 @@ export class BookingBatchService implements OnModuleInit { await this.ensurePaidBookingAllocated(bookingId); } + /** Open partial-capacity offer summary for booking detail payloads (null when none). */ + async getOpenOfferSummary(bookingId: string): Promise<{ + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null> { + if (!this.splitService) return null; + const offer = await this.splitService.findOpenOffer(bookingId); + if (!offer) return null; + return { + offeredWagons: offer.offeredWagons, + totalWagons: offer.totalWagons, + offeredAmount: Number(offer.offeredAmount), + paymentDeadline: offer.paymentDeadline, + }; + } + + // ---- export FCFS ----------------------------------------------------------- + + /** + * Export is first-come-first-serve: no window cycle, no priority, no batch. + * Pick the earliest open export train on the booking's corridor/day that still + * fits the booking. Throws ConflictException when every train is full — the + * staff accept fails and no more export bookings are taken. + */ + async pickExportSchedule(booking: Booking): Promise { + if (!booking.scheduledDate) { + throw new BadRequestException('Booking has no scheduled date'); + } + const day = eatDay(new Date(booking.scheduledDate)); + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), + ) + .sort( + (a, b) => + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), + ); + if (!candidates.length) { + throw new ConflictException( + 'No export train is accepting bookings for this day', + ); + } + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + const need = this.needFor(booking, wagonLengths); + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + if (this.fits(need, budget)) return schedule.id; + } + throw new ConflictException('Train is full — no export capacity left for this day'); + } + + /** + * Reserve an accepted export booking on its picked train and open the pay + * window immediately (payment notification goes out on reserve). Marks the + * train FULL when this reservation exhausts the wagon budget. + */ + async reserveExportBooking(booking: Booking, scheduleId: string): Promise { + await this.reserve(booking, scheduleId); + this.armSettle(scheduleId); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule && (await this.remainingWagons(schedule)) <= 0) { + await this.setWindow(scheduleId, 'FULL'); + } + } + /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { const unlinked = @@ -393,9 +512,13 @@ export class BookingBatchService implements OnModuleInit { } } - // ---- cron entry point ----------------------------------------------------- + // ---- legacy fill entry point ---------------------------------------------- - @Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE }) + /** + * Legacy periodic fill for schedules without a window phase (DOMESTIC and + * pre-migration trains). Invoked by BookingWindowService's tick — the old + * standalone cron was replaced by the window engine. + */ async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); @@ -595,6 +718,15 @@ export class BookingBatchService implements OnModuleInit { : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { code: loco.code, @@ -679,6 +811,15 @@ export class BookingBatchService implements OnModuleInit { : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { code: loco.code, @@ -722,11 +863,27 @@ export class BookingBatchService implements OnModuleInit { // ---- core fill ------------------------------------------------------------ + /** + * Whether the batch engine may reserve/allocate onto this schedule right now. + * Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate. + * Import window cycle: the engine fills while the customer window is CLOSED — + * during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups). + * Export: FCFS while the booking window is open. + */ + isFillable(schedule: TrainSchedule): boolean { + if (schedule.bookingWindowStatus === "FULL") return false; + if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN"; + if (schedule.direction === "EXPORT") { + return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN"; + } + return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT"; + } + /** Fill one schedule from its priority-ordered pool until full. */ async fillSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || schedule.bookingWindowStatus !== "OPEN") return; + if (!schedule || !this.isFillable(schedule)) return; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { this.logger.warn( @@ -793,22 +950,33 @@ export class BookingBatchService implements OnModuleInit { destinationYardId: string, day: string, ): Promise { - // The day's OPEN bookable schedules on this exact corridor, earliest first. - const bookable = await this.trainSchedulingService.getBookableSchedules( - originYardId, - destinationYardId, - ); - const scheduleIds = bookable + // The day's fillable schedules on this exact corridor, earliest first. Fillable + // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — + // the batch must run while the customer window is closed. + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const scheduleIds = corridor .filter( (s) => - s.bookingWindowStatus === "OPEN" && - s.scheduleDate != null && - eatDay(new Date(s.scheduleDate)) === day, + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), ) .sort( (a, b) => - new Date(a.scheduleDate).getTime() - - new Date(b.scheduleDate).getTime(), + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), ) .map((s) => s.id); @@ -870,7 +1038,33 @@ export class BookingBatchService implements OnModuleInit { } if (!target) { - // Fits no train this day — stays in the pool, retried next batch. + // Fits no train whole. Import GENERAL-contract commercial bookings get a + // partial-capacity offer on the train with the most free wagons: pay = + // accept the split (remainder returns to the contract cap), no pay = + // booking stays whole and expires for this train. + const partialTarget = [...trains] + .filter((t) => t.budget.wagons >= 1) + .sort((a, b) => b.budget.wagons - a.budget.wagons)[0]; + if ( + partialTarget && + !booking.isGovernment && + booking.tradeDirection === "IMPORT" && + booking.contractKind === "GENERAL" && + this.splitService + ) { + const offered = await this.tryPartialOffer( + booking, + partialTarget.id, + partialTarget.budget, + need, + ); + if (offered) { + partialTarget.budget = this.subtract(partialTarget.budget, offered); + partialTarget.armed = true; + continue; + } + } + // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); continue; } @@ -893,6 +1087,62 @@ export class BookingBatchService implements OnModuleInit { return trains.map((t) => t.id); } + /** + * Offer the largest fitting part of an over-capacity booking as a partial + * (split-on-payment). Returns the capacity the offer consumes, or null when no + * meaningful partial fits / an offer is already open. + */ + private async tryPartialOffer( + booking: Booking, + scheduleId: string, + budget: Capacity, + need: Capacity, + ): Promise { + if (!this.splitService) return null; + if (await this.splitService.findOpenOffer(booking.id)) return null; + + const wagonLengths = await this.loadWagonLengths(); + const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); + const sized = await this.splitService.sizeOffer( + booking, + budget.wagons, + need.wagons, + bulkCapacityTons, + ); + if (!sized) return null; + + const offeredNeed: Capacity = { + wagons: sized.offeredWagons, + weightTons: sized.offeredWeightTons, + lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + if (!this.fits(offeredNeed, budget)) return null; + + const deadline = new Date(Date.now() + (await this.paymentWindowMs())); + await this.splitService.createOffer(booking, scheduleId, sized, deadline); + // Reserve like a normal batch selection, but the partial invoice + partial + // pay-now notification were already produced by createOffer. + await this.bookingsRepository.update(booking.id, { + trainScheduleId: scheduleId, + status: "SELECTED_FOR_BATCH", + selectedForBatchAt: new Date(), + paymentDeadline: deadline, + } as never); + booking.trainScheduleId = scheduleId; + return offeredNeed; + } + + private async loadBulkWagonCapacityTons(): Promise { + const cw3 = await this.dataSource + .getRepository(WagonType) + .findOne({ where: { code: "CW3" } }); + const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60; + return capacity > 0 ? capacity : 60; + } + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { const reserved = @@ -1063,7 +1313,7 @@ export class BookingBatchService implements OnModuleInit { */ private async reserve(booking: Booking, scheduleId: string): Promise { const now = new Date(); - const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); + const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, status: "SELECTED_FOR_BATCH", @@ -1136,6 +1386,10 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // An unpaid partial offer dies with the reservation — the booking stays whole. + if (this.splitService) { + await this.splitService.expireOpenOffer(booking.id); + } // Pay window closed before settlement → expire the booking's open invoice too // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays // source-agnostic. @@ -1359,7 +1613,7 @@ export class BookingBatchService implements OnModuleInit { return (schedule.maxWagons ?? 0) - used; } - private async setWindow( + async setWindow( scheduleId: string, status: "OPEN" | "FULL" | "CLOSED", ): Promise { @@ -1368,22 +1622,48 @@ export class BookingBatchService implements OnModuleInit { .update(scheduleId, { bookingWindowStatus: status }); } + /** No wagon slots left for allocated + reserved bookings. */ + async isScheduleFull(scheduleId: string): Promise { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return false; + return (await this.remainingWagons(schedule)) <= 0; + } + // ---- timer plumbing ------------------------------------------------------- + /** Configured customer pay window in ms (global rules, with defaults). */ + private async paymentWindowMs(): Promise { + const cfg = await this.trainSchedulingService.getWindowConfig(); + return cfg.paymentWindowMinutes * 60_000; + } + private timeoutName(scheduleId: string): string { return `settle:${scheduleId}`; } + /** + * In-process accelerator only — the durable settle enforcement is the window + * engine's minute tick calling settleDueReservations off `paymentDeadline`. + */ private armSettle(scheduleId: string): void { - this.removeTimeout(scheduleId); - const handle = setTimeout(() => { - void this.settleBatch(scheduleId).catch((err) => - this.logger.error( - `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + void this.paymentWindowMs() + .then((delayMs) => { + this.removeTimeout(scheduleId); + const handle = setTimeout(() => { + void this.settleBatch(scheduleId).catch((err) => + this.logger.error( + `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + ), + ); + }, delayMs); + this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); + }) + .catch((err) => + this.logger.warn( + `armSettle ${scheduleId} skipped: ${(err as Error).message}`, ), ); - }, PAYMENT_WINDOW_MS); - this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); } private removeTimeout(scheduleId: string): void { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e8f272123..49df19758 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; -import { PAYMENT_WINDOW_MS } from './booking-batch.constants'; @Injectable() export class BookingNotifierService { @@ -43,12 +42,32 @@ export class BookingNotifierService { } async payNow(b: Booking, deadline: Date): Promise { - const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000); + const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW'); } + /** + * Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit + * this train. Paying accepts the split; letting the deadline pass keeps the + * booking whole and expires it for this train. + */ + async payNowPartial( + b: Booking, + deadline: Date, + offeredWagons: number, + totalWagons: number, + ): Promise { + const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); + const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const msg = + `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + + `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; + await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + } + secured(b: Booking, reason: 'paid' | 'gov'): void { const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${ reason === 'gov' ? ' (government)' : '' diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts new file mode 100644 index 000000000..2cd2df6d9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -0,0 +1,270 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { Freight } from '@edr/types'; + +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { BillingService } from '../billing/billing.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { + BookingBatchOffer, + OfferedLine, +} from './entities/booking-batch-offer.entity'; +import { BookingNotifierService } from './booking-notifier.service'; + +export interface SizedOffer { + offeredWagons: number; + totalWagons: number; + offeredLines: OfferedLine[] | null; + offeredWeightTons: number; + offeredAmount: number; + offeredPricingBreakdown: Record; +} + +/** + * Partial-capacity booking splits (import batch). The offer is sized and priced + * against an in-memory clone — the booking row is untouched until the customer + * pays, which is the act of accepting the split (applySplit). No payment → + * offer expires and the booking stays whole. + * + * Only GENERAL-contract commercial bookings are offered partials: the remainder + * returns to the contract's quantity cap (derived live from booking_container + * rows, so reducing the lines releases it automatically) and can be rebooked in + * any later window within contract validity. + */ +@Injectable() +export class BookingSplitService { + private readonly logger = new Logger(BookingSplitService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + @Inject(forwardRef(() => BookingPricingService)) + private readonly pricing: BookingPricingService, + @Inject(forwardRef(() => BookingInvoiceService)) + private readonly invoiceService: BookingInvoiceService, + private readonly billing: BillingService, + private readonly notifier: BookingNotifierService, + ) {} + + /** + * Size the largest part of the booking that fits `freeWagons`, priced via an + * in-memory clone. Returns null when nothing meaningful fits (no whole + * container unit / no bulk tonnage, or pricing failed). + */ + async sizeOffer( + booking: Booking, + freeWagons: number, + totalWagons: number, + bulkWagonCapacityTons: number, + ): Promise { + if (freeWagons < 1 || freeWagons >= totalWagons) return null; + + const containers = booking.bookingContainers ?? []; + let offeredLines: OfferedLine[] | null = null; + let offeredWeightTons = 0; + let offeredWagons = 0; + const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking); + clone.adjustedTotalAmount = null; + + if (containers.length) { + offeredLines = []; + let remaining = freeWagons; + const clonedContainers: BookingContainer[] = []; + for (const line of containers) { + const quantity = Number(line.quantity ?? 0); + const lineWagons = Number(line.wagonsRequired ?? 0); + if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue; + const perUnit = lineWagons / quantity; + // Largest unit count whose wagon need still fits the remaining budget. + let take = Math.min(quantity, Math.floor(remaining / perUnit)); + while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1; + if (take <= 0) continue; + const takeWagons = Math.ceil(take * perUnit); + const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0); + offeredLines.push({ + bookingContainerId: line.id, + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000, + }); + offeredWeightTons += take * vgmPerUnit; + offeredWagons += takeWagons; + remaining -= takeWagons; + + const clonedLine: BookingContainer = Object.assign( + Object.create(Object.getPrototypeOf(line)), + line, + { + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: take * vgmPerUnit, + }, + ); + clonedContainers.push(clonedLine); + } + if (!offeredLines.length || offeredWagons <= 0) return null; + clone.bookingContainers = clonedContainers; + } else { + // Bulk: split by weight — the offered part is what freeWagons can carry. + const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0); + if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null; + offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons); + if (offeredWeightTons <= 0) return null; + offeredWagons = Math.min( + freeWagons, + Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)), + ); + } + + offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000; + clone.cargoTotalWeightVgm = offeredWeightTons; + clone.wagonsRequired = offeredWagons; + + try { + const priced = await this.pricing.computePriceForBooking(clone); + return { + offeredWagons, + totalWagons, + offeredLines, + offeredWeightTons, + offeredAmount: priced.totalAmount, + offeredPricingBreakdown: { + lineItems: priced.lineItems, + totalAmount: priced.totalAmount, + currency: priced.currency, + generatedAt: new Date().toISOString(), + partialOfWagons: totalWagons, + }, + }; + } catch (err) { + this.logger.warn( + `Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`, + ); + return null; + } + } + + /** + * Persist the offer and swap the booking's payable to a partial invoice for the + * offered amount. Any previous open offer for the booking is superseded. + */ + async createOffer( + booking: Booking, + scheduleId: string, + sized: SizedOffer, + deadline: Date, + ): Promise { + const repo = this.dataSource.getRepository(BookingBatchOffer); + await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' }); + + // The full-amount invoice must not stay payable next to the partial one. + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID'); + const invoice = await this.invoiceService.ensureInvoiceForBooking( + { ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking, + { dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending }, + ); + + const offer = await repo.save( + repo.create({ + bookingId: booking.id, + trainScheduleId: scheduleId, + offeredWagons: sized.offeredWagons, + totalWagons: sized.totalWagons, + offeredLines: sized.offeredLines, + offeredWeightTons: sized.offeredWeightTons, + offeredAmount: sized.offeredAmount, + offeredPricingBreakdown: sized.offeredPricingBreakdown, + invoiceId: invoice.id, + paymentDeadline: deadline, + status: 'OFFERED', + }), + ); + await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons); + return offer; + } + + /** + * Payment received inside the window — the customer accepted the split. + * Reduce the booking to the offered lines/weight; the remainder returns to the + * contract cap automatically (bookedQuantities derives from live lines). + * Idempotent: no OFFERED offer → no-op. + */ + async applySplit(bookingId: string): Promise { + const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + if (!offer) return; + + await this.dataSource.transaction(async (manager) => { + if (offer.offeredLines?.length) { + const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l])); + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId }, + }); + for (const line of lines) { + const kept = keptByLine.get(line.id); + if (!kept) { + await manager.getRepository(BookingContainer).softDelete(line.id); + await manager + .getRepository(BookingContainerUnit) + .softDelete({ bookingContainerId: line.id }); + continue; + } + const dropCount = Number(line.quantity) - kept.quantity; + await manager.getRepository(BookingContainer).update(line.id, { + quantity: kept.quantity, + wagonsRequired: kept.wagonsRequired, + totalVgmTons: kept.totalVgmTons, + hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity), + reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity), + }); + if (dropCount > 0) { + // Trim surplus physical units, last-entered first. + const units = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + order: { sortOrder: 'DESC', createdAt: 'DESC' }, + take: dropCount, + }); + if (units.length) { + await manager + .getRepository(BookingContainerUnit) + .softDelete(units.map((u) => u.id)); + } + } + } + } + + await manager.getRepository(Booking).update(bookingId, { + wagonsRequired: offer.offeredWagons, + cargoTotalWeightVgm: offer.offeredWeightTons, + totalAmount: offer.offeredAmount, + pricingBreakdown: offer.offeredPricingBreakdown, + } as never); + + await manager + .getRepository(BookingBatchOffer) + .update(offer.id, { status: 'APPLIED' }); + }); + this.logger.log( + `Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`, + ); + } + + /** Pay window closed without payment — offer dies, booking stays whole. */ + async expireOpenOffer(bookingId: string): Promise { + await this.dataSource + .getRepository(BookingBatchOffer) + .update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' }); + } + + async findOpenOffer(bookingId: string): Promise { + return this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts new file mode 100644 index 000000000..c694eed11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -0,0 +1,30 @@ +/** + * Booking-window timings sourced from the train_scheduling_global_rules singleton, + * with hardcoded fallbacks when the row is missing (see TrainSchedulingService.getWindowConfig). + */ +export interface BookingWindowConfig { + /** Days before departure the single import booking-window day falls on. */ + importWindowLeadDays: number; + /** Hours before departure an export booking becomes acceptable (FCFS). */ + exportBookingLeadHours: number; + /** Local (Africa/Addis_Ababa) hour at which the import window opens. */ + windowOpenHour: number; + windowDurationHours: number; + /** Max staff document-review time after the window closes. */ + docReviewMinutes: number; + paymentWindowMinutes: number; + /** Delay after window close before reopening when the train is not full. */ + reopenDelayMinutes: number; +} + +/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */ +export const WINDOW_PHASES = [ + 'PRE_WINDOW', + 'OPEN', + 'DOC_REVIEW', + 'PAYMENT', + 'CLOSED_FOR_DAY', + 'DONE', +] as const; + +export type WindowPhase = (typeof WINDOW_PHASES)[number]; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts new file mode 100644 index 000000000..f2fe5db07 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -0,0 +1,346 @@ +import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { BookingBatchService } from './booking-batch.service'; +import { TrainSchedulingService } from './train-scheduling.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; +import { eatDay } from './batch-window.util'; +import { type BookingWindowConfig } from './booking-window.config'; + +/** + * Drives the one-booking-day window cycle for IMPORT schedules and the FCFS + * booking window for EXPORT schedules. All state lives in DB timestamps on the + * schedule row, so every transition is derived purely from the clock — a restart + * resumes mid-phase with no loss (onModuleInit runs one tick immediately). + * + * Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept + * documents) → PAYMENT (batch reserves in priority order, customers pay) → + * reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized). + * Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority). + * Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy + * fill (runBatchFill), which this tick invokes every 5th minute. + */ +@Injectable() +export class BookingWindowService implements OnModuleInit { + private readonly logger = new Logger(BookingWindowService.name); + private ticking = false; + private tickCount = 0; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly bookingBatchService: BookingBatchService, + private readonly trainSchedulingService: TrainSchedulingService, + ) {} + + async onModuleInit(): Promise { + await this.tick().catch((err) => + this.logger.warn(`Boot window tick failed: ${(err as Error).message}`), + ); + } + + @Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) + async tick(): Promise { + if (this.ticking) return; + this.ticking = true; + try { + const now = new Date(); + const cfg = await this.trainSchedulingService.getWindowConfig(); + + const active = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }) + ).filter( + (s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY', + ); + + for (const schedule of active) { + try { + await this.advanceSchedule(schedule, cfg, now); + } catch (err) { + this.logger.error( + `Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`, + ); + } + } + + await this.settleOverdueReservations(); + + // Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick. + this.tickCount += 1; + if (this.tickCount % 5 === 0) { + await this.bookingBatchService.runBatchFill(); + } + } finally { + this.ticking = false; + } + } + + /** Staff finished document review early — start the batch/payment phase now. */ + async completeDocReview(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.windowPhase !== 'DOC_REVIEW') { + // Idempotent for the whole route-day group: only DOC_REVIEW schedules move. + return schedule; + } + const now = new Date(); + const cfg = await this.trainSchedulingService.getWindowConfig(); + // Stamp the whole route-day group so one staff action releases every train + // sharing this booking day's pool. + const group = ( + await this.trainSchedulesRepository.findAll({ + where: { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }, + }) + ).filter( + (s) => + s.windowPhase === 'DOC_REVIEW' && + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === eatDay(schedule.scheduledDepartureDate), + ); + for (const s of group) { + await this.dataSource + .getRepository(TrainSchedule) + .update(s.id, { docReviewCompletedAt: now }); + s.docReviewCompletedAt = now; + await this.advanceSchedule(s, cfg, now); + } + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + return fresh ?? schedule; + } + + // ---- transitions ------------------------------------------------------------ + + private async advanceSchedule( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + // Apply every transition that is due, in order (fast-forwards after downtime). + for (let guard = 0; guard < 6; guard += 1) { + const advanced = + schedule.direction === 'EXPORT' + ? await this.advanceExport(schedule, now) + : await this.advanceImport(schedule, cfg, now); + if (!advanced) return; + } + } + + /** Export: PRE_WINDOW → OPEN at opensAt, OPEN → DONE at closesAt (= departure). */ + private async advanceExport(schedule: TrainSchedule, now: Date): Promise { + if ( + schedule.windowPhase === 'PRE_WINDOW' && + schedule.windowOpensAt && + now >= schedule.windowOpensAt + ) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + this.logger.log(`Export booking window opened for schedule ${schedule.id}`); + return true; + } + if ( + schedule.windowPhase === 'OPEN' && + schedule.windowClosesAt && + now >= schedule.windowClosesAt + ) { + await this.setPhase(schedule, { windowPhase: 'DONE' }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + return true; + } + return false; + } + + private async advanceImport( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const { windowPhase, windowOpensAt, windowClosesAt } = schedule; + + if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + this.logger.log( + `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, + ); + return true; + } + + if (windowPhase === 'OPEN' && windowClosesAt && now >= windowClosesAt) { + const docReviewEndsAt = new Date( + windowClosesAt.getTime() + cfg.docReviewMinutes * 60_000, + ); + await this.setPhase(schedule, { windowPhase: 'DOC_REVIEW', docReviewEndsAt }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + this.logger.log( + `Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'DOC_REVIEW' && + (schedule.docReviewCompletedAt != null || + (schedule.docReviewEndsAt != null && now >= schedule.docReviewEndsAt)) + ) { + const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000); + await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); + // Run the batch: priority fill over the route-day pool, reserving pay windows + // (or allocating government) — skipped automatically for everyone who fits + // is handled inside the fill (all fit → all reserved → all notified). + await this.bookingBatchService.processRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }); + this.logger.log( + `Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'PAYMENT' && + schedule.paymentPhaseEndsAt != null && + now >= schedule.paymentPhaseEndsAt + ) { + await this.bookingBatchService.settleDueReservations(schedule.id); + await this.concludeCycle(schedule, cfg, now); + return true; + } + + return false; + } + + /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */ + private async concludeCycle( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const full = await this.bookingBatchService.isScheduleFull(schedule.id); + if (full) { + await this.bookingBatchService.setWindow(schedule.id, 'FULL'); + await this.setPhase(schedule, { windowPhase: 'DONE' }); + await this.tryAutoFinalize(schedule.id); + return; + } + + const closesAt = schedule.windowClosesAt ?? now; + const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000); + const nextOpensAt = reopenAt > now ? reopenAt : now; + let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000); + if (nextClosesAt > schedule.scheduledDepartureDate) { + nextClosesAt = schedule.scheduledDepartureDate; + } + + const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt); + const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate; + if (sameBookingDay && beforeDeparture) { + await this.setPhase(schedule, { + windowPhase: 'PRE_WINDOW', + windowOpensAt: nextOpensAt, + windowClosesAt: nextClosesAt, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + this.logger.log( + `Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`, + ); + } else { + await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' }); + this.logger.log( + `Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`, + ); + } + } + + private async tryAutoFinalize(scheduleId: string): Promise { + try { + await this.trainSchedulingService.finalizeSchedule(scheduleId); + this.logger.log(`Schedule ${scheduleId} is full — auto-finalized`); + } catch (err) { + // Not DRAFT / no linked bookings yet — staff finalize manually. + this.logger.warn( + `Auto-finalize skipped for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + + /** Durable settle backstop: expire/allocate reservations whose deadline passed. */ + private async settleOverdueReservations(): Promise { + const overdue = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .select('DISTINCT b.train_schedule_id', 'scheduleId') + .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere('b.payment_deadline <= now()') + .andWhere('b.train_schedule_id IS NOT NULL') + .getRawMany<{ scheduleId: string }>(); + for (const { scheduleId } of overdue) { + try { + await this.bookingBatchService.settleDueReservations(scheduleId); + } catch (err) { + this.logger.warn( + `Overdue settle failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + } + + private async setPhase( + schedule: TrainSchedule, + patch: Partial< + Pick< + TrainSchedule, + | 'windowPhase' + | 'windowOpensAt' + | 'windowClosesAt' + | 'docReviewEndsAt' + | 'docReviewCompletedAt' + | 'paymentPhaseEndsAt' + | 'bookingCycleNo' + > + >, + ): Promise { + await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch); + Object.assign(schedule, patch); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index d47195976..1171b0c90 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; +import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { @ApiPropertyOptional({ example: 760 }) @@ -37,4 +37,55 @@ export class UpdateTrainSchedulingGlobalRulesDto { @IsNumber() @Min(0) max20ftPairWeightDiffTons?: number; + + @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; + + @ApiPropertyOptional({ example: 24, description: 'Hours before departure an export booking becomes acceptable' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; + + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowOpenHour?: number; + + @ApiPropertyOptional({ example: 3 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.25) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ example: 90 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + reopenDelayMinutes?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts new file mode 100644 index 000000000..4b6f7c685 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts @@ -0,0 +1,75 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +export const BOOKING_BATCH_OFFER_STATUSES = ['OFFERED', 'APPLIED', 'EXPIRED'] as const; +export type BookingBatchOfferStatus = (typeof BOOKING_BATCH_OFFER_STATUSES)[number]; + +/** One reduced container line of a partial offer (per original booking_container row). */ +export interface OfferedLine { + bookingContainerId: string; + /** Units of this line that ride the offered train (≤ original quantity). */ + quantity: number; + wagonsRequired: number; + totalVgmTons: number; +} + +/** + * A partial-capacity payment offer made by the batch when a booking needs more + * wagons than the train has left (e.g. needs 20, 3 free). The booking itself is + * NOT mutated at offer time — paying inside the window accepts the split + * (BookingSplitService.applySplit reduces the booking to the offered lines and + * the remainder returns to the contract's quantity cap); letting the deadline + * pass expires the offer and the booking stays whole. + */ +@Entity({ schema: 'freight', name: 'booking_batch_offers' }) +@Index(['bookingId']) +@Index(['trainScheduleId']) +@Index(['status']) +export class BookingBatchOffer extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'offered_wagons', type: 'int' }) + offeredWagons!: number; + + /** Booking's full wagon need at offer time (for messaging / audit). */ + @Column({ name: 'total_wagons', type: 'int' }) + totalWagons!: number; + + /** Reduced container lines (null for bulk offers — bulk splits by weight). */ + @Column({ name: 'offered_lines', type: 'jsonb', nullable: true }) + offeredLines?: OfferedLine[] | null; + + @Column({ name: 'offered_weight_tons', type: 'numeric', precision: 12, scale: 3 }) + offeredWeightTons!: number; + + @Column({ name: 'offered_amount', type: 'numeric', precision: 14, scale: 2 }) + offeredAmount!: number; + + @Column({ name: 'offered_pricing_breakdown', type: 'jsonb', nullable: true }) + offeredPricingBreakdown?: Record | null; + + /** The partial PREPAID invoice generated for the offered part. */ + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + @Column({ name: 'payment_deadline', type: 'timestamptz' }) + paymentDeadline!: Date; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'OFFERED' }) + status!: BookingBatchOfferStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 326915933..7b8d9b26a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -41,4 +41,36 @@ export class TrainSchedulingGlobalRules extends BaseEntity { default: 10, }) max20ftPairWeightDiffTons!: number; + + /** Days before departure the single import booking-window day falls on. */ + @Column({ name: 'import_window_lead_days', type: 'int', default: 3 }) + importWindowLeadDays!: number; + + /** Hours before departure an export booking becomes acceptable (FCFS, no window cycle). */ + @Column({ name: 'export_booking_lead_hours', type: 'int', default: 24 }) + exportBookingLeadHours!: number; + + /** Local (Africa/Addis_Ababa) hour at which the import window opens on its window day. */ + @Column({ name: 'window_open_hour', type: 'int', default: 8 }) + windowOpenHour!: number; + + @Column({ + name: 'window_duration_hours', + type: 'numeric', + precision: 4, + scale: 2, + default: 3, + }) + windowDurationHours!: number; + + /** Max time staff have to accept booking documents after the window closes. */ + @Column({ name: 'doc_review_minutes', type: 'int', default: 30 }) + docReviewMinutes!: number; + + @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) + paymentWindowMinutes!: number; + + /** Delay after window close before the window reopens when the train is not yet full. */ + @Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 }) + reopenDelayMinutes!: number; } 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 1c15ab801..8887fb4c6 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 @@ -43,6 +43,8 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; +import { BookingWindowService } from "./booking-window.service"; +import { BillingService } from "../billing/billing.service"; @ApiTags("train-scheduling") @ApiBearerAuth() @@ -51,8 +53,23 @@ export class TrainSchedulingController { constructor( private readonly trainSchedulingService: TrainSchedulingService, private readonly bookingBatchService: BookingBatchService, + private readonly bookingWindowService: BookingWindowService, + private readonly billingService: BillingService, ) { } + @Get("my-booking-windows") + @ApiOperation({ + summary: + "Upcoming/open booking windows on the signed-in customer's active contract lanes", + }) + async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) { + const companyId = await this.billingService.resolveCompanyId( + resolveAuthUserId(user), + ); + if (!companyId) return []; + return this.trainSchedulingService.getBookingWindowsForCompany(companyId); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) @@ -457,6 +474,17 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Post("schedules/:id/doc-review-complete") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", + }) + async completeDocReview(@Param("id", ParseUUIDPipe) id: string) { + await this.bookingWindowService.completeDocReview(id); + return this.bookingBatchService.getBatchBoardDetail(id); + } + @Post("bookings/:bookingId/mark-paid") @TrainSchedulingManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 0f2e22076..7e281a72f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -25,6 +25,9 @@ import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingWindowService } from './booking-window.service'; +import { BookingSplitService } from './booking-split.service'; +import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { NotificationsModule } from '../notifications/notifications.module'; import { ContractsModule } from '../contracts/contracts.module'; @@ -42,6 +45,7 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainSchedulingGlobalRules, TrainCheckpointEvent, ImportDjiboutiOperation, + BookingBatchOffer, ]), forwardRef(() => BookingsModule), BillingModule, @@ -60,7 +64,9 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainCheckpointEventsRepository, BookingBatchService, BookingNotifierService, + BookingWindowService, + BookingSplitService, ], - exports: [TrainSchedulingService, BookingBatchService], + exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], }) export class TrainSchedulingModule {} 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 e87dbdd88..ce835e1e4 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 @@ -13,7 +13,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In } from 'typeorm'; +import { DataSource, EntityManager, In, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -58,6 +58,7 @@ import { UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; +import { type BookingWindowConfig } from './booking-window.config'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -98,7 +99,11 @@ import { DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, } from './booking-batch.constants'; -import { eatDay } from './batch-window.util'; +import { + computeExportWindowTimes, + computeImportWindowTimes, + eatDay, +} from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; @@ -236,9 +241,37 @@ export class TrainSchedulingService { if (dto.max20ftPairWeightDiffTons != null) { row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; } + if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; + if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; + if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; + if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; + if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; + if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; + if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes; return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row); } + /** + * Booking-window timings with hardcoded fallbacks for a missing/legacy config row. + * Numeric columns come back from pg as strings — normalize every field. + */ + async getWindowConfig(): Promise { + const row = await this.loadGlobalRulesRow(); + const num = (v: unknown, fallback: number) => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + return { + importWindowLeadDays: num(row?.importWindowLeadDays, 3), + exportBookingLeadHours: num(row?.exportBookingLeadHours, 24), + windowOpenHour: num(row?.windowOpenHour, 8), + windowDurationHours: num(row?.windowDurationHours, 3), + docReviewMinutes: num(row?.docReviewMinutes, 30), + paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + reopenDelayMinutes: num(row?.reopenDelayMinutes, 90), + }; + } + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { const limits = await this.resolveTrainLimitConfig(dto); return this.buildPreviewResponse( @@ -310,8 +343,12 @@ export class TrainSchedulingService { throw new BadRequestException('A train must be pulled by at least two locomotives'); } + const scheduleWarnings: string[] = []; const createdScheduleId = await this.dataSource.transaction(async (manager) => { - // Lock and validate every locomotive: all must be AVAILABLE and at the origin yard. + // Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on + // multiple future schedules and does not need to be at the origin yard yet — staff + // plan around its arrival. Only decommissioned locomotives are hard-blocked; + // everything else surfaces as a warning. const lockedLocomotives: Locomotive[] = []; for (const locomotiveId of locomotiveIds) { const locked = await manager.getRepository(Locomotive).findOne({ @@ -321,12 +358,17 @@ export class TrainSchedulingService { if (!locked) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } + if (locked.status === 'OUT_OF_SERVICE') { + throw new ConflictException(`Locomotive ${locked.code} is out of service`); + } if (locked.status !== 'AVAILABLE') { - throw new ConflictException(`Locomotive ${locked.code} is not available`); + scheduleWarnings.push( + `Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`, + ); } if (locked.currentYardId !== route.originYardId) { - throw new ConflictException( - `Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`, + scheduleWarnings.push( + `Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`, ); } lockedLocomotives.push(locked); @@ -340,27 +382,46 @@ export class TrainSchedulingService { const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); // Effective capacity is capped by the weakest locomotive in the set. const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; + const departure = new Date(dto.scheduleDate); + // IMPORT/EXPORT trains start with a CLOSED customer window; the window engine + // opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead). + // DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL). + const windowCfg = await this.getWindowConfig(); + const windowFields = + direction === 'IMPORT' + ? { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...computeImportWindowTimes(departure, windowCfg, new Date()), + } + : direction === 'EXPORT' + ? { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...computeExportWindowTimes(departure, windowCfg), + } + : {}; const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, routeId: route.id, originStationId: route.originYardId, destinationStationId: route.destinationYardId, - scheduledDepartureDate: new Date(dto.scheduleDate), + scheduledDepartureDate: departure, status: TrainScheduleStatusEnum.Draft, direction, maxWagons: ( await this.resolveTrainLimitConfig(dto, limitLoco) ).maxWagonsPerTrain, + ...windowFields, }); const saved = await manager.getRepository(TrainSchedule).save(schedule); - await manager.getRepository(Locomotive).update( - { id: In(lockedLocomotives.map((l) => l.id)) }, - { status: 'ASSIGNED' }, - ); + // Locomotives stay in their current status until dispatch — advance scheduling + // must not block the locomotive from serving earlier trains. return saved.id; }); - return this.getTrainScheduleById(createdScheduleId); + const created = await this.getTrainScheduleById(createdScheduleId); + return { ...created, warnings: scheduleWarnings }; } async assignBookingsToSchedule( @@ -778,10 +839,19 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // A locomotive may sit on many future schedules, but it can only pull one train + // at a time — block dispatch while any set locomotive is out on a dispatched train. + const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId); const now = new Date(); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); + if (setLocomotiveIds.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' }); + } await this.trainSchedulesRepository.updateStatus( scheduleId, @@ -1461,6 +1531,12 @@ export class TrainSchedulingService { /** Open or close a schedule's booking window (staff override). */ async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise { + if (status === 'OPEN') { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule?.bookingWindowStatus === 'FULL') { + throw new ConflictException('Train is full — the booking window cannot be reopened'); + } + } await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); @@ -1685,16 +1761,14 @@ export class TrainSchedulingService { [scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched], ); - if (schedule.trainSet?.locomotiveId) { - const loco = await manager - .getRepository(Locomotive) - .findOne({ where: { id: schedule.trainSet.locomotiveId } }); - if (loco) { - await manager.getRepository(Locomotive).update(loco.id, { - status: 'AVAILABLE', - currentYardId: schedule.destinationStationId, - }); - } + // Release every locomotive of the set (not just the legacy primary) and move it + // to the destination yard where it physically arrived. + const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (arrivedLocoIds.length) { + await manager.getRepository(Locomotive).update( + { id: In(arrivedLocoIds) }, + { status: 'AVAILABLE', currentYardId: schedule.destinationStationId }, + ); } for (const slot of schedule.trainSet?.wagons ?? []) { @@ -1769,11 +1843,21 @@ export class TrainSchedulingService { if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); } + // Locomotives are only ASSIGNED while out on a dispatched train. Release ours, + // but never stomp a locomotive that is currently pulling another dispatched train. const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); if (cancelledLocoIds.length) { - await manager - .getRepository(Locomotive) - .update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' }); + const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere( + cancelledLocoIds, + id, + manager, + ); + const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId)); + if (releasable.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' }); + } } for (const wagon of schedule.trainSet?.wagons ?? []) { if (wagon.physicalWagonId) { @@ -2016,15 +2100,17 @@ export class TrainSchedulingService { } if (assignedLocomotives.length) { - // Every locomotive of the set must sit at the origin yard, and the weakest - // one must still be able to pull the train (min limits across the set). + // Advance scheduling: a locomotive that hasn't reached the origin yard yet is a + // warning (it must arrive before dispatch), but a set too weak to pull the train + // is a hard violation. const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); const setLimits = minLocomotiveLimits(assignedLocomotives); if (offYard) { - violations.push( - `Locomotive ${offYard.code} is not at the schedule origin yard`, + warnings.push( + `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, ); - } else if ( + } + if ( setLimits && (setLimits.maxPullWeightTons < totalWeightTons || setLimits.maxTrainLengthMeters < totalLengthMeters) @@ -2034,21 +2120,22 @@ export class TrainSchedulingService { ); } } else { - const availableLocomotives = ( - await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE' }, - }) - ).filter((l) => l.currentYardId === originYardId); - if (!availableLocomotives.length) { - violations.push('No available locomotive at the schedule origin yard'); - } else if ( - !availableLocomotives.some( + const inServiceLocomotives = await this.locomotivesRepository.findAll({ + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, + }); + if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) { + warnings.push( + 'No locomotive is at the schedule origin yard yet; one must arrive before dispatch', + ); + } + if ( + !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) >= totalWeightTons && Number(l.maxTrainLengthMeters) >= totalLengthMeters, ) ) { - violations.push('No available locomotive can support the total train weight and length'); + violations.push('No locomotive can support the total train weight and length'); } } @@ -2596,6 +2683,58 @@ export class TrainSchedulingService { return trainSet.locomotive ? [trainSet.locomotive] : []; } + /** + * Locomotive ids (among the given ones) that are attached to a DISPATCHED train + * other than `excludeScheduleId`. Covers both the multi-loco link rows and the + * legacy single-locomotive column on the train set. + */ + private async findLocomotiveIdsDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + manager?: EntityManager, + ): Promise> { + if (!locomotiveIds.length) return new Set(); + const runner = manager ?? this.dataSource; + const rows: { locomotive_id: string }[] = await runner.query( + `SELECT DISTINCT loco.locomotive_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND ts.id <> $1 + AND loco.locomotive_id = ANY($2)`, + [excludeScheduleId, locomotiveIds], + ); + return new Set(rows.map((r) => r.locomotive_id)); + } + + private async assertLocomotivesNotDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + ): Promise { + const busy = await this.findLocomotiveIdsDispatchedElsewhere( + locomotiveIds, + excludeScheduleId, + ); + if (!busy.size) return; + const locos = await this.dataSource + .getRepository(Locomotive) + .find({ where: { id: In([...busy]) } }); + const codes = locos.map((l) => l.code).join(', '); + throw new ConflictException( + `Locomotive(s) ${codes} are currently out on another dispatched train`, + ); + } + async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, @@ -2732,15 +2871,113 @@ export class TrainSchedulingService { } /** AVAILABLE locomotives at the route's origin yard. */ - async getAvailableLocomotivesForRoute(routeId: string): Promise { + /** + * All in-service locomotives, annotated for the schedule-creation picker. + * Advance scheduling means nothing is filtered out — staff see status, whether the + * locomotive is at the origin yard yet, and how many future schedules it already has. + */ + async getAvailableLocomotivesForRoute(routeId: string) { const route = await this.getSchedulableRoute(routeId); const locomotives = await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE', currentYardId: route.originYardId }, + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, order: { code: 'ASC' }, }); - return locomotives; + const counts: { locomotive_id: string; future_count: string }[] = locomotives.length + ? await this.dataSource.query( + `SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.deleted_at IS NULL + AND loco.locomotive_id = ANY($1) + GROUP BY loco.locomotive_id`, + [locomotives.map((l) => l.id)], + ) + : []; + const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)])); + + return locomotives.map((loco) => ({ + ...loco, + atOriginYard: loco.currentYardId === route.originYardId, + futureScheduleCount: futureCounts.get(loco.id) ?? 0, + })); + } + + /** + * Upcoming/open booking windows for a customer's active-contract lanes — + * powers the portal home "booking windows" section. Only window-engine + * schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are + * always open and need no announcement. + */ + async getBookingWindowsForCompany(companyId: string) { + const rows: Array<{ + schedule_id: string; + direction: string | null; + window_phase: string | null; + window_opens_at: Date | null; + window_closes_at: Date | null; + booking_window_status: string; + booking_cycle_no: number; + scheduled_departure_date: Date; + origin_label: string | null; + origin_code: string | null; + destination_label: string | null; + destination_code: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT ts.id AS schedule_id, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + JOIN freight.contract_routes cr + ON cr.origin_yard_id = ts.origin_station_id + AND cr.destination_yard_id = ts.destination_station_id + AND cr.deleted_at IS NULL + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.company_id = $1 + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + [companyId], + ); + return rows.map((r) => ({ + scheduleId: r.schedule_id, + direction: r.direction, + windowPhase: r.window_phase, + isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN', + windowOpensAt: r.window_opens_at, + windowClosesAt: r.window_closes_at, + bookingWindowStatus: r.booking_window_status, + bookingCycleNo: r.booking_cycle_no, + departureDate: r.scheduled_departure_date, + origin: r.origin_label ?? r.origin_code ?? null, + destination: r.destination_label ?? r.destination_code ?? null, + })); } /** OPEN schedules a new booking may target (with rough remaining capacity). diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 567ecf074..8c2453e4d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -419,10 +419,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Contract validity", href: "/dashboard/configuration/contract-validity-periods", }, - // { - // label: "Train scheduling rules", - // href: "/dashboard/configuration/train-scheduling-rules", - // }, + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index e1ff69412..ff69d0290 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -48,6 +48,7 @@ import type { } from "@/types/trainScheduling"; import { ContainerPlacementGrid } from "./ContainerPlacementGrid"; +import { locomotiveOption, showScheduleWarnings } from "./locomotiveOptions"; import { autoFillPlacements, mergePlacementsWithSaved, @@ -274,6 +275,7 @@ export function AllocateBookingWizard({ const created = await create.mutateAsync({ payload: { routeId, scheduleDate, locomotiveIds }, }); + showScheduleWarnings(created.warnings); setSelectedScheduleId(created.id); return created.id; }; @@ -536,10 +538,9 @@ export function AllocateBookingWizard({ placeholder={ routeId ? "Select at least two locomotives" : "Select a route first" } - data={(locomotivesQuery.data ?? []).map((l) => ({ - value: l.id, - label: `${l.code}${l.name ? ` · ${l.name}` : ""}`, - }))} + data={(locomotivesQuery.data ?? []).map((l) => + locomotiveOption(l, " · "), + )} value={locomotiveIds} onChange={setLocomotiveIds} searchable diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchVisuals.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchVisuals.tsx index d67daad30..5206aa4ea 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchVisuals.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchVisuals.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import { Box, Group, Progress, Text, Tooltip } from "@mantine/core"; +import type { BookingWindowPhase } from "@/types/trainScheduling"; + import "./batchVisuals.css"; /** @@ -213,3 +215,69 @@ export function HeroChip({ ); } + +const PHASE_META: Record< + BookingWindowPhase, + { color: string; label: string; pulse: boolean } +> = { + PRE_WINDOW: { color: "gray", label: "Pre-window", pulse: false }, + OPEN: { color: "edr-green", label: "Booking open", pulse: true }, + DOC_REVIEW: { color: "yellow", label: "Doc review", pulse: true }, + PAYMENT: { color: "blue", label: "Payment", pulse: true }, + CLOSED_FOR_DAY: { color: "dark", label: "Closed for day", pulse: false }, + DONE: { color: "dark", label: "Done", pulse: false }, +}; + +/** + * Import booking-cycle phase pill (OPEN → DOC_REVIEW → PAYMENT → …) with an + * optional cycle number. Same visual language as `WindowStatusPill`. + */ +export function WindowPhasePill({ + phase, + cycleNo, + size = "md", +}: { + phase: BookingWindowPhase; + cycleNo?: number; + size?: "sm" | "md"; +}) { + const meta = PHASE_META[phase] ?? { + color: "gray", + label: phase, + pulse: false, + }; + const compact = size === "sm"; + return ( + + + + {meta.label} + {cycleNo && cycleNo > 1 ? ` · cycle ${cycleNo}` : ""} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/locomotiveOptions.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/locomotiveOptions.ts new file mode 100644 index 000000000..499fa7d0d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/locomotiveOptions.ts @@ -0,0 +1,50 @@ +import hotToast from "react-hot-toast"; + +import type { LocomotiveRecord } from "@/types/trainScheduling"; + +/** + * Locomotives can now be scheduled in advance: not-at-origin-yard or + * already-on-future-schedules is allowed with a warning (only OUT_OF_SERVICE + * is blocked server-side). This returns the hint to surface in the picker, + * or null when the locomotive is ready at the origin yard. + */ +export function locomotiveWarning(loco: LocomotiveRecord): string | null { + const hints: string[] = []; + if (loco.atOriginYard === false) hints.push("not at origin yard"); + const futureCount = loco.futureScheduleCount ?? 0; + if (futureCount > 0) { + hints.push(`on ${futureCount} future schedule${futureCount === 1 ? "" : "s"}`); + } + return hints.length ? hints.join(" · ") : null; +} + +/** MultiSelect option for the schedule-creation locomotive picker. */ +export function locomotiveOption( + loco: LocomotiveRecord, + nameSeparator = " — ", +): { value: string; label: string } { + const base = `${loco.code}${loco.name ? `${nameSeparator}${loco.name}` : ""}`; + const warning = locomotiveWarning(loco); + return { + value: loco.id, + label: warning ? `${base} · ⚠ ${warning}` : base, + }; +} + +/** + * Yellow toast listing create-schedule warnings (e.g. locomotive not at the + * origin yard yet). The shared `useToast` hook only knows success/error, so + * this styles a react-hot-toast directly. + */ +export function showScheduleWarnings(warnings?: string[] | null): void { + if (!warnings?.length) return; + hotToast(warnings.join("\n"), { + icon: "⚠️", + duration: 8000, + style: { + background: "var(--mantine-color-yellow-0)", + color: "var(--mantine-color-yellow-9)", + border: "1px solid var(--mantine-color-yellow-4)", + }, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index bc7461073..c59be5d7d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -261,6 +261,8 @@ export const URL_CONSTANTS = { BATCH_BOARD_DETAIL: (scheduleId: string) => `/train-scheduling/batch-board/${scheduleId}`, RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`, + DOC_REVIEW_COMPLETE: (id: string) => + `/train-scheduling/schedules/${id}/doc-review-complete`, RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`, ASSIGN_UNASSIGNED_BOOKING: (id: string) => `/train-scheduling/schedules/${id}/assign-unassigned-booking`, diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index 9085f2868..b02bf7e22 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -41,6 +41,7 @@ import { BookingPipeline, HeroChip, totalBookingCount, + WindowPhasePill, WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; @@ -233,7 +234,16 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { - + + + {schedule.windowPhase ? ( + + ) : null} + }).format(new Date(iso)) : "—"; +const eatDayFmt = new Intl.DateTimeFormat("en-CA", { + timeZone: "Africa/Addis_Ababa", + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +/** "11:00 EAT" if the timestamp falls on today (EAT), else "05 Jun, 11:00 EAT". */ +const fmtPhaseTime = (iso: string) => { + const date = new Date(iso); + const time = new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Africa/Addis_Ababa", + }).format(date); + if (eatDayFmt.format(date) === eatDayFmt.format(new Date())) { + return `${time} EAT`; + } + const day = new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + timeZone: "Africa/Addis_Ababa", + }).format(date); + return `${day}, ${time} EAT`; +}; + +/** Countdown label for the current booking-cycle phase, e.g. "Closes 11:00 EAT". */ +function phaseCountdown(data: BatchBoardScheduleDetail): string | null { + switch (data.windowPhase) { + case "PRE_WINDOW": + return data.windowOpensAt + ? `Opens ${fmtPhaseTime(data.windowOpensAt)}` + : null; + case "OPEN": + return data.windowClosesAt + ? `Closes ${fmtPhaseTime(data.windowClosesAt)}` + : null; + case "DOC_REVIEW": + return data.docReviewEndsAt + ? `Doc review ends ${fmtPhaseTime(data.docReviewEndsAt)}` + : null; + case "PAYMENT": + return data.paymentPhaseEndsAt + ? `Payment ends ${fmtPhaseTime(data.paymentPhaseEndsAt)}` + : null; + case "CLOSED_FOR_DAY": + return data.windowOpensAt + ? `Reopens ${fmtPhaseTime(data.windowOpensAt)}` + : null; + default: + return null; + } +} + const initials = (name: string) => name .split(/\s+/) @@ -462,6 +520,9 @@ export default function BatchScheduleDetailPage() { const runAllocation = useMutation( api.trainScheduling.runAllocation.mutationOptions(), ); + const completeDocReview = useMutation( + api.trainScheduling.completeDocReview.mutationOptions(), + ); const hasAssignedWagons = useMemo( () => @@ -607,6 +668,24 @@ export default function BatchScheduleDetailPage() { ); const selectedDay = dayGroups[selectedIndex]; + const handleCompleteDocReview = () => { + completeDocReview + .mutateAsync(scheduleId ?? "") + .then(() => { + toast({ + title: "Document review complete", + description: "Batch is running for this route-day group", + }); + void refetch(); + }) + .catch(() => { + toast({ + title: "Could not complete document review", + variant: "destructive", + }); + }); + }; + const handleRunAllocation = () => { runAllocation .mutateAsync({ scheduleId: scheduleId ?? "" }) @@ -641,6 +720,7 @@ export default function BatchScheduleDetailPage() { } const totalBookings = totalBookingCount(data.counts); + const countdown = phaseCountdown(data); return ( @@ -689,6 +769,12 @@ export default function BatchScheduleDetailPage() { {data.trainNumber ?? data.routeName ?? "Schedule"} + {data.windowPhase ? ( + + ) : null} {data.status} ) : null} + {data.windowPhase ? ( + }> + Cycle {data.bookingCycleNo} + {countdown ? ` · ${countdown}` : ""} + + ) : null} @@ -730,6 +822,17 @@ export default function BatchScheduleDetailPage() { > Refresh + {data.windowPhase === "DOC_REVIEW" ? ( + + ) : null}