diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index ab8a8dfa7..7c33b1e3e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { Readable } from 'stream'; @@ -22,6 +23,8 @@ import { ContractSignerRole } from './entities/booking-contract-signature.entity @Injectable() export class BookingContractService { + private readonly logger = new Logger(BookingContractService.name); + constructor( private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, @@ -92,7 +95,16 @@ export class BookingContractService { const templateKey = this.templateResolver.resolve(booking); const summary = this.buildContractSummary(booking); - await this.upsertContractPdf(bookingId, booking.reference, templateKey); + + // PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract + // from becoming ready — the document is (re)rendered lazily on view/download. + try { + await this.upsertContractPdf(bookingId, booking.reference, templateKey); + } catch (err) { + this.logger.warn( + `Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`, + ); + } const now = new Date(); const updated = await this.bookingsRepository.update(bookingId, { @@ -191,11 +203,17 @@ export class BookingContractService { } const updated = await this.bookingsRepository.update(bookingId, updates as never); - await this.upsertContractPdf( - bookingId, - booking.reference, - booking.contractTemplateKey ?? this.templateResolver.resolve(booking), - ); + try { + await this.upsertContractPdf( + bookingId, + booking.reference, + booking.contractTemplateKey ?? this.templateResolver.resolve(booking), + ); + } catch (err) { + this.logger.warn( + `Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, + ); + } return updated!; } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index a5180eb85..3e78bb8ad 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -659,6 +659,7 @@ export class BookingsRepository extends BaseRepository { originStationId?: string; destinationStationId?: string; schedulingStatus?: string; + trainScheduleId?: string; }): Promise { const qb = this.repository .createQueryBuilder('booking') @@ -676,6 +677,14 @@ export class BookingsRepository extends BaseRepository { .where('booking.status = :paidStatus', { paidStatus: 'PAID' }) .andWhere('scheduleBooking.id IS NULL'); + // Mirror the automatic batch pool: a schedule only ever considers bookings that + // targeted THAT schedule (same as findBatchPool's train_schedule_id filter). + if (options.trainScheduleId) { + qb.andWhere('booking.train_schedule_id = :trainScheduleId', { + trainScheduleId: options.trainScheduleId, + }); + } + if (options.freightType) { qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType }); } @@ -726,6 +735,19 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */ + findAllBySchedule(scheduleId: string): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .where('booking.train_schedule_id = :scheduleId', { scheduleId }) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** Bookings currently reserved (AWAITING_PAYMENT) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository 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 83b6e3655..f03f20a4a 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 @@ -13,3 +13,10 @@ export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; + +/** + * Fallback per-wagon length (m) for the batch length budget when global rules don't yet + * define maxTrainLength / maxWagons to derive it from. Used only to estimate train length + * against the locomotive's max train length. + */ +export const DEFAULT_WAGON_LENGTH_METERS = 14; 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 b35db8761..78c16030a 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 @@ -11,25 +11,86 @@ import { DataSource } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { BookingNotifierService } from './booking-notifier.service'; import { BATCH_CRON, BATCH_TIMEZONE, + DEFAULT_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, PAYMENT_WINDOW_MS, } from './booking-batch.constants'; +/** A train's remaining capacity along the three physical limits the batch enforces. */ +interface Capacity { + wagons: number; + weightTons: number; + lengthMeters: number; +} + +export type BatchBoardBookingState = + | 'ALLOCATED' + | 'AWAITING_PAYMENT' + | 'WAITING' + | 'PENDING_CONTRACT' + | 'EXPIRED'; + +export interface BatchBoardBooking { + id: string; + reference: string; + company: string; + isGovernment: boolean; + wagons: number; + weightTons: number; + paymentDeadline: string | null; + state: BatchBoardBookingState; +} + +export interface BatchBoardSchedule { + scheduleId: string; + trainNumber: string | null; + routeName: string | null; + origin: string | null; + destination: string | null; + scheduleDate: string | null; + status: string; + bookingWindowStatus: string; + locomotive: { + code: string; + name: string | null; + maxPullWeightTons: number; + maxTrainLengthMeters: number; + } | null; + capacity: { + maxWagons: number; + usedWagons: number; + remainingWagons: number; + usedWeightTons: number; + maxWeightTons: number | null; + }; + counts: { + allocated: number; + awaitingPayment: number; + waiting: number; + pendingContract: number; + expired: number; + }; + bookings: BatchBoardBooking[]; +} + /** * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool - * by priority, greedily fills the train to capacity (skipping oversized bookings), + * by priority, greedily fills the train to capacity (skipping bookings that don't fit), * reserves a 1h pay window for commercial customers (government allocated unpaid, * preempting lower-priority commercial if needed), then settles each batch 1h later — * allocating those who paid and expiring those who didn't, topping up from the waiting list. - * Capacity here is modelled by wagon count (`schedule.maxWagons`); locomotive weight/length - * is still enforced by the existing assignment path when staff pin wagons. + * Capacity is bounded on three axes at once: wagon count (`schedule.maxWagons`), the + * locomotive's max pull weight, and its max train length (also capped by global rules). */ @Injectable() export class BookingBatchService implements OnModuleInit { @@ -73,19 +134,122 @@ export class BookingBatchService implements OnModuleInit { } } + // ---- monitoring board ----------------------------------------------------- + + /** + * Read model for the batch monitoring page: every still-relevant schedule (not arrived/ + * cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle + * state (allocated / awaiting payment / paid-waiting / pending contract / expired). + */ + async getBatchBoard(): Promise { + const schedules = await this.trainSchedulesRepository.findAll({ + relations: { + trainSet: { locomotive: true }, + originStation: true, + destinationStation: true, + route: true, + }, + order: { scheduledDepartureDate: 'ASC' }, + }); + + const rules = await this.loadGlobalRules(); + const perWagonLength = this.perWagonLength(rules); + const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const board: BatchBoardSchedule[] = []; + for (const s of schedules) { + if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue; + + const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); + const linkedIds = new Set(links.map((l) => l.bookingId)); + const bookings = await this.bookingsRepository.findAllBySchedule(s.id); + + const items: BatchBoardBooking[] = bookings.map((b) => { + const need = this.needFor(b, perWagonLength); + return { + id: b.id, + reference: b.reference ?? b.id.slice(0, 8), + company: b.isGovernment + ? (b.governmentInstitution ?? 'Government') + : (b.company?.name ?? '—'), + isGovernment: Boolean(b.isGovernment), + wagons: need.wagons, + weightTons: need.weightTons, + paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + state: this.boardState(b, linkedIds.has(b.id)), + }; + }); + + const usedWagons = items + .filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT') + .reduce((sum, i) => sum + i.wagons, 0); + const usedWeight = items + .filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT') + .reduce((sum, i) => sum + i.weightTons, 0); + const loco = s.trainSet?.locomotive ?? null; + + board.push({ + scheduleId: s.id, + trainNumber: s.trainNumber ?? null, + routeName: s.route?.name ?? null, + origin: s.originStation?.label ?? s.originStation?.code ?? null, + destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + status: s.status, + bookingWindowStatus: s.bookingWindowStatus, + locomotive: loco + ? { + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } + : null, + capacity: { + maxWagons: s.maxWagons ?? 0, + usedWagons, + remainingWagons: Math.max(0, (s.maxWagons ?? 0) - usedWagons), + usedWeightTons: Math.round(usedWeight * 100) / 100, + maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + }, + counts: { + allocated: items.filter((i) => i.state === 'ALLOCATED').length, + awaitingPayment: items.filter((i) => i.state === 'AWAITING_PAYMENT').length, + waiting: items.filter((i) => i.state === 'WAITING').length, + pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, + expired: items.filter((i) => i.state === 'EXPIRED').length, + }, + bookings: items, + }); + } + return board; + } + + private boardState(booking: Booking, linked: boolean): BatchBoardBookingState { + if (linked) return 'ALLOCATED'; + if (booking.status === 'AWAITING_PAYMENT') return 'AWAITING_PAYMENT'; + if (booking.status === 'EXPIRED') return 'EXPIRED'; + if (booking.status === 'PAID') return 'WAITING'; + return 'PENDING_CONTRACT'; + } + // ---- core fill ------------------------------------------------------------ /** 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.trainSetId || !schedule.trainSet?.locomotive) { + const locomotive = schedule.trainSet?.locomotive; + if (!schedule.trainSetId || !locomotive) { this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); return; } - let remaining = await this.remainingWagons(schedule); - if (remaining <= 0) { + const rules = await this.loadGlobalRules(); + const perWagonLength = this.perWagonLength(rules); + const limits = this.capacityLimits(schedule, locomotive, rules); + let budget = await this.remainingCapacity(schedule, limits, perWagonLength); + if (budget.wagons <= 0) { await this.setWindow(scheduleId, 'FULL'); return; } @@ -94,14 +258,14 @@ export class BookingBatchService implements OnModuleInit { let armed = false; for (const booking of pool) { - const need = this.wagonsFor(booking); + const need = this.needFor(booking, perWagonLength); - if (need > remaining) { + if (!this.fits(need, budget)) { if (booking.isGovernment) { - remaining = await this.preemptForGovernment(scheduleId, need, remaining); - if (need > remaining) continue; // still doesn't fit even after preempt + budget = await this.preemptForGovernment(scheduleId, need, budget, perWagonLength); + if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt } else { - continue; // skip oversized commercial, try the next + continue; // skip a booking that exceeds weight/length/wagons, try the next } } @@ -111,11 +275,11 @@ export class BookingBatchService implements OnModuleInit { await this.reserve(booking); armed = true; } - remaining -= need; - if (remaining <= 0) break; + budget = this.subtract(budget, need); + if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board } - if (remaining <= 0) await this.setWindow(scheduleId, 'FULL'); + if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL'); if (armed) this.armSettle(scheduleId); } @@ -280,9 +444,10 @@ export class BookingBatchService implements OnModuleInit { */ private async preemptForGovernment( scheduleId: string, - need: number, - remaining: number, - ): Promise { + need: Capacity, + budget: Capacity, + perWagonLength: number, + ): Promise { const reservedCommercial = ( await this.bookingsRepository.findReservedForSchedule(scheduleId) ).filter((b) => !b.isGovernment); @@ -294,8 +459,9 @@ export class BookingBatchService implements OnModuleInit { (a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0), ); + let freed = budget; for (const victim of candidates) { - if (need <= remaining) break; + if (this.fits(need, freed)) break; await this.dataSource.transaction(async (manager) => { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( scheduleId, @@ -309,9 +475,9 @@ export class BookingBatchService implements OnModuleInit { } as never); }); this.notifier.displaced(victim); - remaining += this.wagonsFor(victim); + freed = this.add(freed, this.needFor(victim, perWagonLength)); } - return remaining; + return freed; } // ---- capacity helpers ----------------------------------------------------- @@ -327,6 +493,86 @@ export class BookingBatchService implements OnModuleInit { return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING); } + /** What one booking consumes along all three capacity axes. */ + private needFor(booking: Booking, perWagonLength: number): Capacity { + const wagons = this.wagonsFor(booking); + return { + wagons, + weightTons: Number(booking.cargoTotalWeightVgm ?? 0), + lengthMeters: wagons * perWagonLength, + }; + } + + private fits(need: Capacity, budget: Capacity): boolean { + return ( + need.wagons <= budget.wagons && + need.weightTons <= budget.weightTons && + need.lengthMeters <= budget.lengthMeters + ); + } + + private subtract(budget: Capacity, need: Capacity): Capacity { + return { + wagons: budget.wagons - need.wagons, + weightTons: budget.weightTons - need.weightTons, + lengthMeters: budget.lengthMeters - need.lengthMeters, + }; + } + + private add(budget: Capacity, freed: Capacity): Capacity { + return { + wagons: budget.wagons + freed.wagons, + weightTons: budget.weightTons + freed.weightTons, + lengthMeters: budget.lengthMeters + freed.lengthMeters, + }; + } + + /** The train's hard caps: wagon count, locomotive pull weight, locomotive/global length. */ + private capacityLimits( + schedule: TrainSchedule, + locomotive: Locomotive, + rules: TrainSchedulingGlobalRules | null, + ): Capacity { + const locoWeight = Number(locomotive.maxPullWeightTons) || Infinity; + const locoLength = Number(locomotive.maxTrainLengthMeters) || Infinity; + const ruleWeight = rules?.maxTrainWeightTons ? Number(rules.maxTrainWeightTons) : Infinity; + const ruleLength = rules?.maxTrainLengthMeters ? Number(rules.maxTrainLengthMeters) : Infinity; + return { + wagons: schedule.maxWagons ?? 0, + weightTons: Math.min(locoWeight, ruleWeight), + lengthMeters: Math.min(locoLength, ruleLength), + }; + } + + /** Per-wagon length, derived from global rules (maxLength / maxWagons) or a fallback. */ + private perWagonLength(rules: TrainSchedulingGlobalRules | null): number { + const len = rules ? Number(rules.maxTrainLengthMeters) : 0; + const wagons = rules ? Number(rules.maxWagonsPerTrain) : 0; + if (len > 0 && wagons > 0) return len / wagons; + return DEFAULT_WAGON_LENGTH_METERS; + } + + private async loadGlobalRules(): Promise { + return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} }); + } + + /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ + private async remainingCapacity( + schedule: TrainSchedule, + limits: Capacity, + perWagonLength: number, + ): Promise { + const allocated = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b)); + const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const used = [...allocated, ...reserved].reduce( + (acc, b) => this.add(acc, this.needFor(b, perWagonLength)), + { wagons: 0, weightTons: 0, lengthMeters: 0 }, + ); + return this.subtract(limits, used); + } + /** maxWagons minus wagons already taken by allocated + reserved bookings. */ private async remainingWagons(schedule: TrainSchedule): Promise { const allocated = (schedule.scheduleBookings ?? []) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts index 3660426ed..363e9b5e8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts @@ -17,6 +17,14 @@ export class GetEligibleBookingsDto { @IsUUID() destinationStationId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: 'Scope to bookings that targeted this specific schedule (batch parity).', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional() @IsOptional() schedulingStatus?: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts index 9a2cafa2f..c8fde0c07 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts @@ -12,6 +12,11 @@ export class GetEligibleBulkBookingsDto { @IsUUID() destinationStationId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ example: 'HOLDING' }) @IsOptional() schedulingStatus?: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts index e33327f38..5d11192d6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -12,6 +12,11 @@ export class GetEligibleContainerBookingsDto { @IsUUID() destinationStationId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ example: 'HOLDING' }) @IsOptional() schedulingStatus?: string; 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 72f051f12..64833d492 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 @@ -57,6 +57,13 @@ export class TrainSchedulingController { return this.trainSchedulingService.getEligibleBookings(query); } + @Get('batch-board') + @TrainSchedulingView() + @ApiOperation({ summary: 'Batch monitoring board: schedules with bookings grouped by state' }) + getBatchBoard() { + return this.bookingBatchService.getBatchBoard(); + } + @Get('bookable-schedules') @TrainSchedulingView() @ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' }) 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 8a6a04d97..23d253736 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 @@ -113,6 +113,7 @@ export class TrainSchedulingService { originStationId: query.originStationId, destinationStationId: query.destinationStationId, schedulingStatus: query.schedulingStatus, + trainScheduleId: query.trainScheduleId, }); return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; } @@ -272,6 +273,20 @@ export class TrainSchedulingService { throw new BadRequestException('Schedule has no train set'); } + // Batch parity: a schedule may only allocate bookings that targeted it. This mirrors + // the automatic fill, which only pulls bookings whose train_schedule_id is this schedule. + if (dto.bookingIds.length) { + const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds); + const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId); + if (stray.length) { + throw new BadRequestException( + `These bookings are not assigned to this schedule: ${stray + .map((b) => b.reference ?? b.id) + .join(', ')}`, + ); + } + } + const previewDto = { bookingIds: dto.bookingIds, scheduleDate: schedule.scheduledDepartureDate.toISOString(), diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index b66486642..6ba338a7c 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -3,6 +3,7 @@ import { Boxes, FileText, LayoutDashboard, + LayoutGrid, Network, Paperclip, Settings, @@ -37,6 +38,7 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainsPage from "./pages/trains/TrainsPage"; import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; @@ -76,6 +78,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/operations/train-scheduling-v2", icon: , }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + }, ], }, { @@ -257,6 +264,7 @@ const App = () => { element={} /> } /> + } /> } diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index d892fc68d..3424c4570 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -48,6 +48,7 @@ export const QUERY_KEYS = { schedules: () => ["train-scheduling", "schedules"] as const, scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const, track: (id: string) => ["train-scheduling", "track", id] as const, + batchBoard: () => ["train-scheduling", "batch-board"] as const, }, FLEET: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 1ebc58fb3..d59a92aa6 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -69,6 +69,11 @@ export const URL_CONSTANTS = { BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, }, + COMPANIES: { + BASE: "/companies", + BY_ID: (id: string | number) => `/companies/${id}`, + }, + CUSTOMERS_API: { BASE: "/api/customers", BY_ID: (id: string) => `/api/customers/${id}`, @@ -133,6 +138,7 @@ export const URL_CONSTANTS = { TRAIN_SCHEDULING: { ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings", BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules", + BATCH_BOARD: "/train-scheduling/batch-board", RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`, BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`, MARK_BOOKING_PAID: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts index c29a99838..858bd8792 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts @@ -18,6 +18,13 @@ export const useScheduleList = (freightType?: FreightType) => queryFn: () => trainSchedulingService.listSchedules(freightType), }); +export const useBatchBoard = () => + useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), + queryFn: () => trainSchedulingService.getBatchBoard(), + refetchInterval: 30_000, + }); + export const useScheduleDetail = (id: string | undefined, freightType?: FreightType) => useQuery({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id ?? ""), diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index c92255212..67df4b688 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -49,11 +49,10 @@ import { api } from "@/auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; -interface CustomerOption { +interface CompanyOption { id: string; - companyName?: string | null; - firstName?: string | null; - lastName?: string | null; + name?: string | null; + tin?: string | null; email?: string | null; } @@ -192,21 +191,17 @@ export default function NewBookingPage() { queryFn: () => bookingsService.getReferenceData() as Promise, }); - const { data: customers, isLoading: customersLoading } = useQuery({ - queryKey: ["customers", "list"], + const { data: companies, isLoading: companiesLoading } = useQuery({ + queryKey: ["companies", "list"], queryFn: async () => { - const res = await api.get(URL_CONSTANTS.CUSTOMERS.BASE); - return unwrap(res.data) as CustomerOption[]; + const res = await api.get(URL_CONSTANTS.COMPANIES.BASE); + return unwrap(res.data) as CompanyOption[]; }, }); - const customerOptions = (customers ?? []).map((c) => ({ + const companyOptions = (companies ?? []).map((c) => ({ value: c.id, - label: - c.companyName || - [c.firstName, c.lastName].filter(Boolean).join(" ") || - c.email || - c.id, + label: c.name || c.email || c.tin || c.id, })); const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules( @@ -219,6 +214,14 @@ export default function NewBookingPage() { s.scheduleDate, ).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`, })); + const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId); + + // When a schedule is chosen its date IS the departure; otherwise fall back to the manual field. + const effectiveDepartureIso = selectedSchedule + ? new Date(selectedSchedule.scheduleDate).toISOString() + : scheduledDate + ? new Date(scheduledDate).toISOString() + : ""; const yards = (refData?.yard ?? []).map((y) => ({ value: y.id, label: y.name ?? y.code })); const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code })); @@ -268,7 +271,7 @@ export default function NewBookingPage() { !sameYard && Boolean(trainScheduleId) && Boolean(serviceTypeId) && - Boolean(scheduledDate) && + (Boolean(selectedSchedule) || Boolean(scheduledDate)) && (isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) && (freightType === "BULK" ? Boolean(cargoTypeId) && bulkWeight > 0 @@ -291,7 +294,7 @@ export default function NewBookingPage() { tradeDirection, paymentCurrency, isHazardous, - scheduledDate: scheduledDate ? new Date(scheduledDate).toISOString() : new Date().toISOString(), + scheduledDate: effectiveDepartureIso || new Date().toISOString(), originYardId, destinationYardId, trainScheduleId: trainScheduleId || undefined, @@ -399,14 +402,14 @@ export default function NewBookingPage() { ) : ( diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx new file mode 100644 index 000000000..04a93ff1e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -0,0 +1,304 @@ +import { useNavigate } from "react-router-dom"; +import { + Badge, + Box, + Button, + Container, + Group, + Loader, + Paper, + Progress, + ScrollArea, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, + Tooltip, +} from "@mantine/core"; +import { + ArrowRight, + CheckCircle2, + Clock, + Hourglass, + LayoutGrid, + RefreshCw, + Train, + Weight, + XCircle, +} from "lucide-react"; + +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling"; +import type { + BatchBoardBooking, + BatchBoardBookingState, + BatchBoardSchedule, +} from "@/types/trainScheduling"; + +const STATE_META: Record< + BatchBoardBookingState, + { label: string; color: string; icon: typeof CheckCircle2 } +> = { + ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 }, + AWAITING_PAYMENT: { label: "Awaiting payment", color: "orange", icon: Clock }, + WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass }, + PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass }, + EXPIRED: { label: "Expired", color: "red", icon: XCircle }, +}; + +const fmtTons = (n: number) => + `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`; + +function StateBadge({ state }: { state: BatchBoardBookingState }) { + const meta = STATE_META[state]; + const Icon = meta.icon; + return ( + }> + {meta.label} + + ); +} + +function BookingRow({ booking }: { booking: BatchBoardBooking }) { + return ( + + + + {booking.reference} + + {booking.isGovernment ? ( + + Gov + + ) : null} + + {booking.company} + + + + + {booking.wagons}w · {fmtTons(booking.weightTons)} + + + + + ); +} + +function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { + const navigate = useNavigate(); + const { capacity, counts, locomotive } = schedule; + const wagonPct = + capacity.maxWagons > 0 ? (capacity.usedWagons / capacity.maxWagons) * 100 : 0; + const weightPct = + capacity.maxWeightTons && capacity.maxWeightTons > 0 + ? (capacity.usedWeightTons / capacity.maxWeightTons) * 100 + : 0; + + const windowColor = + schedule.bookingWindowStatus === "OPEN" + ? "green" + : schedule.bookingWindowStatus === "FULL" + ? "orange" + : "gray"; + + return ( + + + + + + + + + + + {schedule.trainNumber ?? schedule.routeName ?? "Schedule"} + + + {schedule.origin ?? "—"} → {schedule.destination ?? "—"} + + + {schedule.scheduleDate + ? new Date(schedule.scheduleDate).toLocaleString() + : "No date"} + + + + + + {schedule.bookingWindowStatus} + + + {schedule.status} + + + + + {locomotive ? ( + + Loco {locomotive.code} · max {fmtTons(locomotive.maxPullWeightTons)} ·{" "} + {locomotive.maxTrainLengthMeters} m + + ) : ( + + No locomotive — won't allocate + + )} + + {/* Capacity meters */} + + + + Wagons + + + {capacity.usedWagons}/{capacity.maxWagons} + + + = 100 ? "orange" : "green"} radius="xl" size="sm" /> + + {capacity.maxWeightTons ? ( + + + + + + Weight + + + + {fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)} + + + = 100 ? "red" : "teal"} radius="xl" size="sm" /> + + ) : null} + + {/* Count chips */} + + + + {counts.allocated} allocated + + + + + {counts.awaitingPayment} to pay + + + + + {counts.waiting} waiting + + + {counts.expired ? ( + + {counts.expired} expired + + ) : null} + + + {/* Bookings */} + {schedule.bookings.length ? ( + + + {schedule.bookings.map((b) => ( + + ))} + + + ) : ( + + No bookings targeting this schedule yet. + + )} + + + + + ); +} + +export default function BatchBoardPage() { + const { data, isLoading, isFetching, refetch } = useBatchBoard(); + const schedules = data ?? []; + + return ( + + + + + + + + + + + + Allocation · Live + + + Batch board + + + Every active schedule with its bookings grouped by state — allocated, awaiting + payment, paid-waiting and expired. Filling is automatic; this is the live view. + + + + + + + + {isLoading ? ( + + + + ) : schedules.length === 0 ? ( + + + No active schedules to show. + + + ) : ( + + {schedules.map((s) => ( + + ))} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 855fbaf6b..ac9d85b76 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -102,9 +102,11 @@ export default function TrainScheduleV2DetailPage() { ? { originStationId: schedule.originStation?.id, destinationStationId: schedule.destinationStation?.id, + // Only this schedule's own bookings are eligible — same rule as the auto batch. + trainScheduleId: scheduleId, } : undefined, - [schedule], + [schedule, scheduleId], ); const eligibleFreightType = diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index a2ee08e6c..e4221b3f9 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -2,6 +2,7 @@ import { api as client } from '../auth/http'; import { unwrap } from '@/utils/endpoint'; import { URL_CONSTANTS } from '@/constants/URLS'; import type { + BatchBoardSchedule, BookableSchedule, AssignBookingsPayload, CreateTrainSchedulePayload, @@ -78,6 +79,13 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getBatchBoard: async (): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD, + ); + return unwrap(response.data); + }, + getBookableSchedules: async ( originYardId?: string, destinationYardId?: string, diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index c072b8754..4bfa6ce34 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -178,6 +178,56 @@ export interface BookableSchedule { locomotive: { id: string; code: string; name?: string | null } | null; } +export type BatchBoardBookingState = + | "ALLOCATED" + | "AWAITING_PAYMENT" + | "WAITING" + | "PENDING_CONTRACT" + | "EXPIRED"; + +export interface BatchBoardBooking { + id: string; + reference: string; + company: string; + isGovernment: boolean; + wagons: number; + weightTons: number; + paymentDeadline: string | null; + state: BatchBoardBookingState; +} + +export interface BatchBoardSchedule { + scheduleId: string; + trainNumber: string | null; + routeName: string | null; + origin: string | null; + destination: string | null; + scheduleDate: string | null; + status: string; + bookingWindowStatus: string; + locomotive: { + code: string; + name: string | null; + maxPullWeightTons: number; + maxTrainLengthMeters: number; + } | null; + capacity: { + maxWagons: number; + usedWagons: number; + remainingWagons: number; + usedWeightTons: number; + maxWeightTons: number | null; + }; + counts: { + allocated: number; + awaitingPayment: number; + waiting: number; + pendingContract: number; + expired: number; + }; + bookings: BatchBoardBooking[]; +} + export interface TrainScheduleWagonAllocation { id: string; bookingId: string; @@ -314,6 +364,8 @@ export interface TrainScheduleFilters { originStationId?: string; destinationStationId?: string; schedulingStatus?: SchedulingStatus; + /** Scope eligible bookings to a single schedule (batch parity). */ + trainScheduleId?: string; } export interface TrainSchedulePreviewPayload {