From 85de05cb40e3d1efe74ad3a4109eb32d0635ebe7 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 18 Jul 2026 09:55:09 +0300 Subject: [PATCH 01/54] feat: ( payments ) add merchant-order and booking/PNR payment status lookups --- .../payments/payment-client.service.ts | 29 +++++++ .../modules/payments/payments.controller.ts | 27 ++++++ .../src/modules/payments/payments.service.ts | 50 ++++++++++- .../src/modules/intents/intents.controller.ts | 44 +++++++++- .../src/modules/intents/intents.service.ts | 84 +++++++++++++++++++ 5 files changed, 231 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts index c9264eeda..84ca92c02 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -12,8 +12,15 @@ import { PaymentIntentSnapshot, PaymentReferenceType, PaymentService, + ProviderStatus, } from "@edr/types"; +/** Side-by-side DB row + live provider status from the payment service diagnostic endpoints. */ +export interface PaymentDiagnostic { + db: Record | null; + provider: ProviderStatus | null; +} + /** * Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's * side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here; @@ -55,6 +62,28 @@ export class PaymentClientService { } } + /** + * GET /payments/diagnostic?… — DB intent row + live provider status for a domain reference, + * side by side. Returns { db: null, provider: null } when the payment service has no intent. + */ + async getDiagnosticByReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const query = new URLSearchParams({ + service: PaymentService.PASSENGER, + referenceType, + referenceId, + }); + try { + return await this.call("GET", `/payments/diagnostic?${query.toString()}`); + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) + return { db: null, provider: null }; + throw err; + } + } + /** * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank). * A wrong/expired OTP comes back as 400 from the payment service; surface that as a diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 9a0288656..88510f1a5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -133,6 +133,33 @@ export class PaymentsController { return this.service.getIntentByBookingId(bookingId); } + @Get("status/:bookingRefOrId") + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: "Get payment status by booking id or booking reference (PNR)", + description: + "Accepts either a booking UUID or a booking reference / PNR (e.g. EDR-20240001), " + + "resolves it to the booking, and returns the authoritative payment status pulled from " + + "the payment microservice.", + }) + getStatusByBookingRefOrId(@Param("bookingRefOrId") bookingRefOrId: string) { + return this.service.getIntentByBookingRefOrId(bookingRefOrId); + } + + @Get("diagnostic/:bookingRefOrId") + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: + "Get { db, provider } by booking id or booking reference (PNR) — diagnostic", + description: + "Accepts a booking UUID or a booking reference / PNR (e.g. EDR-20240001), resolves it to " + + "the booking, and returns { db, provider }: the payment service's stored intent row and a " + + "live provider status query, side by side. Pure read — does not reconcile the booking.", + }) + getPaymentDiagnostic(@Param("bookingRefOrId") bookingRefOrId: string) { + return this.service.getPaymentDiagnosticByBookingRefOrId(bookingRefOrId); + } + @Post(":bookingId/confirm") @SetMetadata("isPublic", true) @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index a9e4557f1..e57d94643 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -24,7 +24,10 @@ import { ForceConfirmDto, } from "./payments.dto"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; -import { PaymentClientService } from "./payment-client.service"; +import { + PaymentClientService, + PaymentDiagnostic, +} from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { AuditService } from "../../common/audit.service"; import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util"; @@ -534,6 +537,51 @@ export class PaymentsService { }; } + /** + * Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001). + * Resolves the PNR to its booking id, then pulls the authoritative status from the payment + * microservice (via {@link getIntentByBookingId}). + */ + async getIntentByBookingRefOrId( + bookingRefOrId: string, + ): Promise { + const bookingId = await this.resolveBookingId(bookingRefOrId); + return this.getIntentByBookingId(bookingId); + } + + /** + * Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's + * stored intent row and a live provider status query, side by side ({ db, provider }). + * Pure read — does not reconcile or confirm the booking. + */ + async getPaymentDiagnosticByBookingRefOrId( + bookingRefOrId: string, + ): Promise { + const bookingId = await this.resolveBookingId(bookingRefOrId); + return this.paymentClient.getDiagnosticByReference( + PaymentReferenceType.BOOKING, + bookingId, + ); + } + + /** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */ + private async resolveBookingId(bookingRefOrId: string): Promise { + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + bookingRefOrId, + ); + if (isUuid) return bookingRefOrId; + + const booking = await this.prisma.booking.findUnique({ + where: { bookingRef: bookingRefOrId }, + select: { id: true }, + }); + if (!booking) { + throw new NotFoundException(`Booking not found: ${bookingRefOrId}`); + } + return booking.id; + } + async getIntentByBookingId(bookingId: string): Promise { const local = await this.prisma.paymentIntent.findUnique({ where: { bookingId }, diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts index 35f3461d2..dc47adf6c 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.controller.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -8,8 +8,12 @@ import { Query, UseGuards, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { PaymentIntentSnapshot } from "@edr/types"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { + PaymentIntentSnapshot, + ProviderMethod, + ProviderStatus, +} from "@edr/types"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { InitiatePaymentRequestDto, @@ -17,6 +21,7 @@ import { } from "./dto/initiate-payment.dto"; import { ConfirmPaymentDto } from "./dto/confirm-payment.dto"; import { IntentsService } from "./intents.service"; +import { PaymentIntent } from "./entities/payment-intent.entity"; /** * Internal surface — called only by the domain apps (service-authenticated), never by @@ -68,6 +73,41 @@ export class IntentsController { ); } + @Get("diagnostic") + @ApiOperation({ + summary: "DB row + live provider status by domain reference (diagnostic)", + description: + "Returns { db, provider } for a domain reference (service + referenceType + referenceId): " + + "the active stored intent row and a live provider status query, side by side. Pure read — " + + "does not mutate the intent. `db` is null when no active intent exists for the reference.", + }) + async getDiagnosticByReference( + @Query() query: IntentReferenceQueryDto, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + return this.intentsService.getDiagnosticByReference( + query.service, + query.referenceType, + query.referenceId, + ); + } + + @Get("by-merchant-order/:merchantOrderId") + @ApiOperation({ + summary: "DB row + live provider status by merchant order id (diagnostic)", + description: + "Returns { db, provider } for a provider-facing merchant order id (PSG-/FRT-…): the " + + "stored intent row and a live provider status query, side by side. Pure read — does not " + + "mutate the intent. `db` is null when no intent has this merchant order id; supply " + + "`?provider=` in that case so the provider can still be queried by merchant order id.", + }) + @ApiQuery({ name: "provider", enum: ProviderMethod, required: false }) + async getByMerchantOrderId( + @Param("merchantOrderId") merchantOrderId: string, + @Query("provider") provider?: ProviderMethod, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + return this.intentsService.getByMerchantOrderId(merchantOrderId, provider); + } + @Post("intents/:id/confirm") @ApiOperation({ summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)", diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index a14469dd1..5a751c3b5 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -304,6 +304,90 @@ export class IntentsService { return this.toSnapshot(await this.refreshIfStale(intent)); } + /** + * Diagnostic lookup by domain reference (service + referenceType + referenceId). Returns the + * stored intent AND a LIVE provider status query side by side — the reference-keyed twin of + * {@link getByMerchantOrderId}, used by the domain apps to resolve a booking/shipment without + * knowing the merchant order id. Pure read (no state-machine mutation). + * + * - `db`: the active stored intent for the reference, or `null` when none exists. + * - `provider`: the raw provider status response (queried using the intent's own provider), or + * `null` when there is no intent or the query fails. + */ + async getDiagnosticByReference( + service: PaymentService, + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + const intent = await this.intentsRepository.findActiveByReference( + service, + referenceType, + referenceId, + ); + const providerStatus = intent + ? await this.queryProviderForMerchantOrder( + intent.merchantOrderId, + intent, + undefined, + ) + : null; + return { db: intent ?? null, provider: providerStatus }; + } + + /** + * Diagnostic lookup by provider-facing merchant order id (PSG-/FRT-…). Returns the stored + * intent AND a LIVE provider status query side by side, so the caller can compare what the + * platform believes against what the provider currently reports. This is a pure read — it + * does NOT mutate the intent (no state-machine transition, no outbox event). + * + * - `db`: the full stored intent row, or `null` when no intent has this merchant order id. + * - `provider`: the raw provider status response. When there is a DB row its provider is + * used; when there is no DB row a `providerHint` must be supplied to know which provider + * to ask (the merchant-order prefix only identifies the service). `null` if the provider + * is unknown or the query fails. + */ + async getByMerchantOrderId( + merchantOrderId: string, + providerHint?: ProviderMethod, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + const intent = + await this.intentsRepository.findByMerchantOrderId(merchantOrderId); + + const providerStatus = await this.queryProviderForMerchantOrder( + merchantOrderId, + intent, + providerHint, + ); + + return { db: intent ?? null, provider: providerStatus }; + } + + /** Best-effort live provider status for a merchant order id; never throws (returns null). */ + private async queryProviderForMerchantOrder( + merchantOrderId: string, + intent: PaymentIntent | null, + providerHint?: ProviderMethod, + ): Promise { + try { + if (intent) { + const provider = this.providers.get(intent.provider); + if (!provider) return null; + return await this.queryProviderStatus(intent); + } + // No DB row — fall back to the caller-supplied provider hint keyed on merchantOrderId. + if (!providerHint) return null; + const provider = this.providers.get(providerHint); + if (!provider) return null; + return await provider.queryStatus(merchantOrderId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `provider status query failed for merchantOrderId ${merchantOrderId}: ${message}`, + ); + return null; + } + } + /** * Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the * provider for the truth and run the answer through the state machine. The browser From 854177c93b61df6ba88caeb336a4148bf7872e20 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 11:30:43 +0300 Subject: [PATCH 02/54] Seat reservation unique constrains, package totalMinor updates --- .../migration.sql | 33 +++++++++++++++++++ apps/edr-passenger-api/prisma/schema.prisma | 3 +- .../src/modules/agents/agents.service.ts | 1 + .../src/modules/bookings/bookings.service.ts | 1 + .../modules/bookings/guest-booking.service.ts | 1 + .../src/modules/packages/packages.service.ts | 4 +-- .../src/modules/payments/payments.e2e-spec.ts | 1 + .../src/modules/search/search.service.ts | 8 ++--- .../src/modules/seats/seats.service.ts | 2 +- 9 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql new file mode 100644 index 000000000..2a9a1be65 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -0,0 +1,33 @@ +-- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. +UPDATE passenger."BookingSeat" bs +SET schedule_id = b.schedule_id +FROM passenger."Booking" b +WHERE bs.booking_id = b.id + AND bs.schedule_id IS NULL + AND bs.leg = 1; + +-- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. +UPDATE passenger."BookingSeat" bs +SET schedule_id = COALESCE( + (b.return_schedule_id), + (b.leg2_schedule_id), + b.schedule_id +) +FROM passenger."Booking" b +WHERE bs.booking_id = b.id + AND bs.schedule_id IS NULL + AND bs.leg = 2; + +-- Catch any remaining NULLs (leg 3/4 from ROUND_TRIP_TRANSIT) using the booking's schedule. +UPDATE passenger."BookingSeat" bs +SET schedule_id = b.schedule_id +FROM passenger."Booking" b +WHERE bs.booking_id = b.id + AND bs.schedule_id IS NULL; + +-- Now enforce NOT NULL. +ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; + +-- Add the unique constraint that is the actual double-booking guard. +CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" + ON passenger."BookingSeat"(schedule_id, seat_id); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index fb9db346f..601410fae 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -580,7 +580,7 @@ model BookingSeat { bookingId String seatId String leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2 - scheduleId String? // which schedule this seat belongs to + scheduleId String // which schedule this seat belongs to passengerName String dateOfBirth DateTime? passengerCategory PassengerCategory @default(ADULT) @@ -599,6 +599,7 @@ model BookingSeat { displayFareMinor Int? booking Booking @relation(fields: [bookingId], references: [id]) seat Seat @relation(fields: [seatId], references: [id]) + @@unique([scheduleId, seatId]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index 4ba7afcf1..57f33c5aa 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -85,6 +85,7 @@ export class AgentsService { seats: { create: dto.passengers.map(p => ({ seat: { connect: { id: p.seatId } }, + scheduleId: dto.scheduleId, passengerName: p.fullName, idDocumentType: p.idDocumentType as IdDocumentType | undefined, idDocumentNumber: p.idDocumentNumber diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index a0994d1be..ada6e2a78 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -910,6 +910,7 @@ export class BookingsService { seats: { create: passengersWithFares.map(p => ({ seat: { connect: { id: p.seatId } }, + scheduleId: dto.scheduleId, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 43470c160..17e3a585e 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -299,6 +299,7 @@ export class GuestBookingService { seats: { create: passengersWithFares.map((p) => ({ seat: { connect: { id: p.seatId } }, + scheduleId: dto.scheduleId, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 9ddc1fb0a..1528b2967 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -440,8 +440,8 @@ export class PackagesService { passengerCount, adultCount, childCount, - totalMinor, - currency: 'ETB', + totalMinor: displayTotalMinor, + currency: displayCurrency, displayCurrency, displayTotalMinor, status: 'PENDING_PAYMENT', diff --git a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts index 5393696bf..058b2f9f6 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts @@ -118,6 +118,7 @@ describe("Payments E2E", () => { data: { bookingId: booking.id, seatId: seat.id, + scheduleId: schedule.id, passengerName: "Test Passenger", }, }); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index a51dc0142..29be84adc 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -535,8 +535,8 @@ export class SearchService { discountMinor: fare.discountMinor, taxesFeesMinor: 0, loyaltyRedemptionMinor: loyaltyMinor, - totalMinor, - currency: 'ETB', + totalMinor: displayTotalMinor, + currency: displayCurrency, displayCurrency, displayTotalMinor, }; @@ -653,8 +653,8 @@ export class SearchService { passengers: passengerLines, subtotalMinor, discountMinor, - totalMinor, - currency: 'ETB', + totalMinor: displayTotalMinor, + currency: displayCurrency, displayCurrency, displayTotalMinor, }; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 963cc571d..3d87c8331 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -1020,7 +1020,7 @@ export class SeatsService { where: { OR: [ { scheduleId: schedule.id }, - { scheduleId: null, booking: { scheduleId: schedule.id } }, + { booking: { scheduleId: schedule.id } }, ], booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, }, From 20eb54521b6bf1b861f06b1137035c943e184ff6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 18 Jul 2026 08:34:43 +0000 Subject: [PATCH 03/54] fix --- .../src/modules/wagons/train-runs.const.ts | 41 +++++ .../src/scripts/update-wagon-runs.ts | 174 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/wagons/train-runs.const.ts create mode 100644 apps/edr-freight-api/src/scripts/update-wagon-runs.ts diff --git a/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts b/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts new file mode 100644 index 000000000..85bb5e88a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts @@ -0,0 +1,41 @@ +/** + * EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The + * even IMPORT run (Djibouti → Ethiopia) is fixed by the export run. + * + * Run numbers are always 4 digits (8401, never 84001). Pairs are listed out + * rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks + * the convention stays correct here. + * + * SeedWagonRunNumbers2280000000000 carries its own frozen copy on purpose: a + * migration must keep doing what it did when it was applied, whereas this list + * is live config for the update script. Add or retire runs HERE. + */ +export const TRAIN_RUN_PAIRS: Record = { + '8001': '8002', + '8101': '8102', + '8201': '8202', + '8301': '8302', + '8401': '8402', + '8501': '8502', + '8601': '8602', + '8701': '8702', + '8801': '8802', + '8901': '8902', + '9001': '9002', +}; + +/** Even IMPORT run -> its odd EXPORT run. Derived so the two cannot drift. */ +export const EXPORT_BY_IMPORT: Record = Object.fromEntries( + Object.entries(TRAIN_RUN_PAIRS).map(([exportRun, importRun]) => [importRun, exportRun]), +); + +/** + * Normalise any run number to its EXPORT run. Accepts either half of a pair, so + * a sheet listing "8002" and one listing "8001" both resolve to the same train. + * Returns null when the number belongs to no known run. + */ +export const toExportRun = (run: string): string | null => { + const value = run.trim(); + if (TRAIN_RUN_PAIRS[value]) return value; + return EXPORT_BY_IMPORT[value] ?? null; +}; diff --git a/apps/edr-freight-api/src/scripts/update-wagon-runs.ts b/apps/edr-freight-api/src/scripts/update-wagon-runs.ts new file mode 100644 index 000000000..d24f6245e --- /dev/null +++ b/apps/edr-freight-api/src/scripts/update-wagon-runs.ts @@ -0,0 +1,174 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { AppDataSource } from '../data-source'; +import { TRAIN_RUN_PAIRS, toExportRun } from '../modules/wagons/train-runs.const'; + +/** + * Update wagon run numbers from a roster file — the tool for making the DB match + * the operator's sheet. + * + * pnpm seed:wagon-runs [--apply] + * + * CSV: two columns, header optional. Either half of a run pair is accepted, so + * "8001" and "8002" both mean the same train. + * + * wagon_number,run + * ER0744,8001 + * ER0458,8102 + * + * FULL REPLACEMENT: wagons absent from the file have their runs cleared, so the + * DB ends up matching the file exactly rather than accumulating stale rows. + * + * Dry run by default — it validates and prints what would change. Nothing is + * written without `--apply`. Validation is fatal on: an unknown run, a wagon not + * in the database, or the same wagon claimed by two runs (a wagon holds one run, + * so a double-booking has no correct answer and must be fixed in the sheet). + */ +interface Row { + line: number; + wagonNumber: string; + exportRun: string; +} + +function parseCsv(path: string) { + const text = readFileSync(path, 'utf8'); + const rows: Row[] = []; + const unknownRuns: string[] = []; + + text.split(/\r?\n/).forEach((raw, i) => { + const line = i + 1; + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith('#')) return; + + const [rawWagon = '', rawRun = ''] = trimmed.split(',').map((c) => c.trim()); + // Skip a header row without needing it to be declared. + if (/wagon/i.test(rawWagon) && /run|train/i.test(rawRun)) return; + if (!rawWagon || !rawRun) { + throw new Error(`line ${line}: expected "wagon_number,run", got "${trimmed}"`); + } + + const exportRun = toExportRun(rawRun); + if (!exportRun) { + unknownRuns.push(`line ${line}: "${rawRun}" (wagon ${rawWagon})`); + return; + } + rows.push({ line, wagonNumber: rawWagon.toUpperCase(), exportRun }); + }); + + return { rows, unknownRuns }; +} + +async function updateWagonRuns() { + const [fileArg, ...flags] = process.argv.slice(2); + const apply = flags.includes('--apply'); + + if (!fileArg) { + console.error('usage: pnpm seed:wagon-runs [--apply]'); + process.exit(2); + } + + const path = resolve(process.cwd(), fileArg); + const { rows, unknownRuns } = parseCsv(path); + + // A wagon in two runs cannot be represented — surface every instance rather + // than silently keeping whichever line happened to come first. + const seen = new Map(); + const doubleBooked: string[] = []; + for (const row of rows) { + const prior = seen.get(row.wagonNumber); + if (prior && prior.exportRun !== row.exportRun) { + doubleBooked.push( + `${row.wagonNumber}: run ${prior.exportRun} (line ${prior.line}) vs ${row.exportRun} (line ${row.line})`, + ); + continue; + } + if (!prior) seen.set(row.wagonNumber, row); + } + + await AppDataSource.initialize(); + try { + const wagonNumbers = [...seen.keys()]; + const existing: Array<{ wagon_number: string }> = wagonNumbers.length + ? await AppDataSource.query( + `SELECT wagon_number FROM freight.wagons + WHERE deleted_at IS NULL AND wagon_number = ANY($1::text[]);`, + [wagonNumbers], + ) + : []; + const known = new Set(existing.map((r) => r.wagon_number)); + const missing = wagonNumbers.filter((w) => !known.has(w)); + + const problems = [ + ...unknownRuns.map((u) => `unknown run ${u}`), + ...doubleBooked.map((d) => `double-booked ${d}`), + ...missing.map((m) => `not in database ${m}`), + ]; + + const perRun = new Map(); + for (const row of seen.values()) { + if (known.has(row.wagonNumber)) { + perRun.set(row.exportRun, (perRun.get(row.exportRun) ?? 0) + 1); + } + } + + console.log(`\nFile: ${path}`); + console.log(`Rows read: ${rows.length + unknownRuns.length} | assignable: ${known.size}`); + console.table( + Object.keys(TRAIN_RUN_PAIRS).map((exportRun) => ({ + export_run: exportRun, + import_run: TRAIN_RUN_PAIRS[exportRun], + wagons: perRun.get(exportRun) ?? 0, + })), + ); + + if (problems.length) { + console.error(`\n${problems.length} problem(s) — nothing was written:`); + problems.forEach((p) => console.error(` ${p}`)); + console.error('\nFix these in the source sheet, then re-run.'); + process.exit(1); + } + + if (!apply) { + console.log('\nDry run — no changes written. Re-run with --apply to write.'); + return; + } + + await AppDataSource.transaction(async (manager) => { + // Full replacement: clear first so a wagon dropped from the sheet does not + // keep a run it no longer has. + await manager.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + + for (const exportRun of new Set([...seen.values()].map((r) => r.exportRun))) { + const wagons = [...seen.values()] + .filter((r) => r.exportRun === exportRun) + .map((r) => r.wagonNumber); + await manager.query( + `UPDATE freight.wagons + SET export_train_number = $1, + import_train_number = $2, + updated_at = now() + WHERE wagon_number = ANY($3::text[]);`, + [exportRun, TRAIN_RUN_PAIRS[exportRun], wagons], + ); + } + }); + + const [totals] = await AppDataSource.query(` + SELECT COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run + FROM freight.wagons WHERE deleted_at IS NULL; + `); + console.log(`\nApplied. ${totals.on_a_run} wagons now on a run.`); + } finally { + await AppDataSource.destroy(); + } +} + +updateWagonRuns().catch((error) => { + console.error('Failed to update wagon runs:', error instanceof Error ? error.message : error); + process.exit(1); +}); From af39ab03947729b3bc62ba7a7aaf22a33aa13e88 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 11:44:11 +0300 Subject: [PATCH 04/54] Migration issue resolution --- .../migration.sql | 4 + .../migration.sql | 4 + .../migration.sql | 4 + .../migration.sql | 33 ++ booking-checker.html | 530 ++++++++++++++++++ booking-extractor.html | 256 +++++++++ booking-proxy.mjs | 53 ++ ticket-extractor.html | 239 ++++++++ 8 files changed, 1123 insertions(+) create mode 100644 booking-checker.html create mode 100644 booking-extractor.html create mode 100644 booking-proxy.mjs create mode 100644 ticket-extractor.html diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql index 2608b4cbf..83a49ef67 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Remove duplicate JourneySegment rows, keeping the one with the lowest id -- (earliest created) per (scheduleId, seatId, departureStationId) group. -- This cleans up any existing double-bookings before the unique index is applied. @@ -16,3 +17,6 @@ AND "seatId" IS NOT NULL; CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") WHERE "seatId" IS NOT NULL; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql index 375f40f7e..e04c02621 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Rename StopStatus enum values to reflect segment-level booking lifecycle. -- UPCOMING → OPEN (segment is bookable) -- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) @@ -10,3 +11,6 @@ ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; -- Add per-route check-in window. Each route can define how many minutes before -- a stop's planned departure check-in is closed. Defaults to 30 minutes. ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql index 0b0995292..5fd9ae0f5 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -1 +1,5 @@ +<<<<<<< Updated upstream ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql index 2a9a1be65..8553fc81b 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -1,13 +1,21 @@ -- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = b.schedule_id FROM passenger."Booking" b WHERE bs.booking_id = b.id AND bs.schedule_id IS NULL +======= +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL +>>>>>>> Stashed changes AND bs.leg = 1; -- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = COALESCE( (b.return_schedule_id), (b.leg2_schedule_id), @@ -31,3 +39,28 @@ ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; -- Add the unique constraint that is the actual double-booking guard. CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" ON passenger."BookingSeat"(schedule_id, seat_id); +======= +SET "scheduleId" = COALESCE( + b."returnScheduleId", + b."leg2ScheduleId", + b."scheduleId" +) +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL + AND bs.leg = 2; + +-- Catch any remaining NULLs using the booking's schedule. +UPDATE passenger."BookingSeat" bs +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL; + +-- Now enforce NOT NULL. +ALTER TABLE passenger."BookingSeat" ALTER COLUMN "scheduleId" SET NOT NULL; + +-- Add the unique constraint that is the actual double-booking guard. +CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" + ON passenger."BookingSeat"("scheduleId", "seatId"); +>>>>>>> Stashed changes diff --git a/booking-checker.html b/booking-checker.html new file mode 100644 index 000000000..9d2b7ded4 --- /dev/null +++ b/booking-checker.html @@ -0,0 +1,530 @@ + + + + + + EDR Booking Checker + + + + +

EDR Booking Checker

+ +
+ + +

No trailing slash. e.g. https://api.edrsc.com

+
+ +
+ + +

Supports any format: one per line, comma-separated, or {REF1,REF2} groups.

+ +
+ + + + +
+
+
+
+
+ +
+
+ +
+ + + + + +
+
+ + + + + + + +
+ +
+ + + + + + + + +
JourneyDuplicate Bookings
+
+
+ + + + + diff --git a/booking-extractor.html b/booking-extractor.html new file mode 100644 index 000000000..844c98b2c --- /dev/null +++ b/booking-extractor.html @@ -0,0 +1,256 @@ + + + + + + EDR Booking Extractor + + + + +

EDR Booking Extractor

+ +
+ +
+ + Drop bookings.json here or click to browse +
+

Accepts a JSON array of bookings or an object with a bookings key.

+
+ + + +
+
+ +
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
#Booking RefStatusBooking TypePhoneEmailDepartureOriginDestinationPassenger(s)Coach - SeatPayment MethodPayment StatusTotal (DJF)Created At
+
+
+ + + + + diff --git a/booking-proxy.mjs b/booking-proxy.mjs new file mode 100644 index 000000000..27f9248ee --- /dev/null +++ b/booking-proxy.mjs @@ -0,0 +1,53 @@ +import http from 'http'; +import https from 'https'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const PORT = 8080; +const __dir = path.dirname(fileURLToPath(import.meta.url)); + +const server = http.createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + // Serve any .html file in the same directory + if (req.url === '/' || req.url.endsWith('.html')) { + const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1); + const filepath = path.join(__dir, filename); + if (fs.existsSync(filepath)) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + fs.createReadStream(filepath).pipe(res); + } else { + res.writeHead(404); res.end('Not found'); + } + return; + } + + // Proxy /proxy?url= + if (req.url.startsWith('/proxy?url=')) { + const target = decodeURIComponent(req.url.slice('/proxy?url='.length)); + const parsed = new URL(target); + const mod = parsed.protocol === 'https:' ? https : http; + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: req.method, + headers: { ...req.headers, host: parsed.hostname }, + }; + const proxy = mod.request(options, (apiRes) => { + res.writeHead(apiRes.statusCode, apiRes.headers); + apiRes.pipe(res); + }); + proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); }); + req.pipe(proxy); + return; + } + + res.writeHead(404); res.end(); +}); + +server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`)); diff --git a/ticket-extractor.html b/ticket-extractor.html new file mode 100644 index 000000000..17be60576 --- /dev/null +++ b/ticket-extractor.html @@ -0,0 +1,239 @@ + + + + + + EDR Ticket Extractor + + + + +

EDR Ticket Extractor

+ +
+ +
+ + Drop tickets.json here or click to browse +
+

Accepts a JSON array of tickets or an object with a tickets key.

+
+ + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
#Ticket No.Booking RefPassengerPhoneEmailJourney TypeOriginDestinationSeat ClassCoachSeat
+
+
+ + + + + From 282bb949bae8525bb460914313ccc1757618b8de Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 11:45:35 +0300 Subject: [PATCH 05/54] Migration issue resolution --- .../migration.sql | 4 +++ .../migration.sql | 4 +++ .../migration.sql | 4 +++ .../migration.sql | 33 +++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql index 2608b4cbf..83a49ef67 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Remove duplicate JourneySegment rows, keeping the one with the lowest id -- (earliest created) per (scheduleId, seatId, departureStationId) group. -- This cleans up any existing double-bookings before the unique index is applied. @@ -16,3 +17,6 @@ AND "seatId" IS NOT NULL; CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") WHERE "seatId" IS NOT NULL; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql index 375f40f7e..e04c02621 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Rename StopStatus enum values to reflect segment-level booking lifecycle. -- UPCOMING → OPEN (segment is bookable) -- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) @@ -10,3 +11,6 @@ ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; -- Add per-route check-in window. Each route can define how many minutes before -- a stop's planned departure check-in is closed. Defaults to 30 minutes. ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql index 0b0995292..5fd9ae0f5 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -1 +1,5 @@ +<<<<<<< Updated upstream ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql index 2a9a1be65..8553fc81b 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -1,13 +1,21 @@ -- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = b.schedule_id FROM passenger."Booking" b WHERE bs.booking_id = b.id AND bs.schedule_id IS NULL +======= +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL +>>>>>>> Stashed changes AND bs.leg = 1; -- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = COALESCE( (b.return_schedule_id), (b.leg2_schedule_id), @@ -31,3 +39,28 @@ ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; -- Add the unique constraint that is the actual double-booking guard. CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" ON passenger."BookingSeat"(schedule_id, seat_id); +======= +SET "scheduleId" = COALESCE( + b."returnScheduleId", + b."leg2ScheduleId", + b."scheduleId" +) +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL + AND bs.leg = 2; + +-- Catch any remaining NULLs using the booking's schedule. +UPDATE passenger."BookingSeat" bs +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL; + +-- Now enforce NOT NULL. +ALTER TABLE passenger."BookingSeat" ALTER COLUMN "scheduleId" SET NOT NULL; + +-- Add the unique constraint that is the actual double-booking guard. +CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" + ON passenger."BookingSeat"("scheduleId", "seatId"); +>>>>>>> Stashed changes From d5e87449b511dff8246d5b5da49de868f0d5ad7f Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 18 Jul 2026 09:04:34 +0000 Subject: [PATCH 06/54] fix gps --- .../2300000000000-RepairGpsTrackingTables.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts diff --git a/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts new file mode 100644 index 000000000..f4628db36 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Repair for environments missing the GPS tracking tables. + * + * AddGpsTracking2000000000000 creates freight.gps_devices / gps_positions, but + * some databases have it RECORDED in public.migrations without the tables ever + * landing. TypeORM never re-runs a recorded migration, so those environments + * stay broken through any number of restarts — the GT06 listener accepts tracker + * packets on its TCP port regardless of schema state and fails per packet with + * `relation "freight.gps_devices" does not exist`, dropping position fixes. + * + * This re-issues the same DDL under a new name so it is applied afresh. Every + * statement is IF NOT EXISTS, so it is a no-op where the tables already exist + * and safe on every environment. + * + * Kept byte-identical to the original DDL on purpose: this must converge on the + * schema the entities expect, not a variant of it. + */ +export class RepairGpsTrackingTables2300000000000 implements MigrationInterface { + name = "RepairGpsTrackingTables2300000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imei varchar(20) NOT NULL UNIQUE, + name varchar, + vehicle_id uuid REFERENCES freight.vehicles(id), + status varchar(16) NOT NULL DEFAULT 'REGISTERED', + last_seen_at timestamptz, + last_lat numeric(10,6), + last_lng numeric(10,6), + last_speed numeric(6,2), + last_course int, + last_fix_at timestamptz, + voltage_level int, + gsm_level int, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE" + ON freight.gps_devices (vehicle_id) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_id uuid NOT NULL, + imei varchar(20) NOT NULL, + vehicle_id uuid, + lat numeric(10,6) NOT NULL, + lng numeric(10,6) NOT NULL, + speed numeric(6,2) NOT NULL DEFAULT 0, + course int NOT NULL DEFAULT 0, + satellites int NOT NULL DEFAULT 0, + positioned boolean NOT NULL DEFAULT false, + gps_time timestamptz NOT NULL, + alarm int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME" + ON freight.gps_positions (device_id, gps_time) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME" + ON freight.gps_positions (vehicle_id, gps_time) + `); + } + + public async down(): Promise { + // No-op: dropping the tables would discard tracker history on environments + // where this migration was the one that created them. AddGpsTracking owns + // the teardown. + } +} From 406fbf6c45c57e22e277addec69e8a1adaf4fb29 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 18 Jul 2026 09:12:52 +0000 Subject: [PATCH 07/54] split export --- .../contract-rate-schedule.builder.spec.ts | 98 +++++ ...00000000-RefreshContractPricingArticles.ts | 69 ++++ .../bookings/booking-transition.service.ts | 21 +- .../contracts/contract-booking.service.ts | 42 ++- .../rule-engine/dto/create-rate.dto.ts | 9 +- .../rule-engine/services/rates.service.ts | 22 +- .../train-scheduling/booking-batch.service.ts | 230 +++++++++++- .../booking-notifier.service.ts | 27 +- .../remainder-placement.service.spec.ts | 211 +++++++++++ .../remainder-placement.service.ts | 342 ++++++++++++++++++ .../train-scheduling.module.ts | 2 + .../backoffice/src/auth/http.ts | 22 +- .../pages/contracts/GlClearanceDetailPage.tsx | 24 +- .../src/services/contracts.service.ts | 14 +- 14 files changed, 1092 insertions(+), 41 deletions(-) create mode 100644 apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts create mode 100644 apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts new file mode 100644 index 000000000..a7b007617 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -0,0 +1,98 @@ +import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; + +/** Minimal Rate factory for the builder unit tests. */ +function rate(partial: Partial): Rate { + return { + trigger: 'ALWAYS', + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + ...partial, + } as Rate; +} + +describe('ContractRateScheduleBuilder', () => { + const LIVE: Rate[] = [ + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + originYard: { label: 'Negad' } as never, + destinationYard: { label: 'Mojo Dry Port' } as never, + containerType: { label: '40ft GP' } as never, + }), + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import + rateType: 'CONTAINER_EXPORT', + rateValue: 819, + originYard: { label: 'GMP' } as never, + destinationYard: { label: 'SGTD' } as never, + }), + rate({ + appliesTo: 'BULK', // wrong freight — filtered out for a container contract + tradeDirection: 'IMPORT', + rateType: 'BULK_IMPORT', + rateUnit: 'PER_WAGON', + rateValue: 100, + }), + rate({ + appliesTo: 'FIRST_MILE', + trigger: 'ALWAYS', + tradeDirection: null, + rateUnit: 'PER_CONTAINER', + rateValue: 50, + }), + rate({ + appliesTo: 'OTHER', + trigger: 'CUSTOMS_CLEARANCE', + tradeDirection: null, + rateType: 'CUSTOMS_CLEARANCE', + rateUnit: 'FLAT', + rateValue: 120, + }), + ]; + + const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) }; + return new ContractRateScheduleBuilder(service as never).build(dir, freight); + }; + + it('shows only import container lanes for an import container contract', async () => { + const s = await build('IMP', 'CON'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ + route: 'Negad → Mojo Dry Port', + cargo: '40ft GP', + currency: 'USD', + amount: '200', + unit: 'per container', + }); + }); + + it('always lists route-agnostic services and surcharges', async () => { + const s = await build('IMP', 'CON'); + expect(s.additionalServices).toHaveLength(1); + expect(s.additionalServices[0].route).toBe('First-mile pickup by truck'); + expect(s.surcharges).toHaveLength(1); + expect(s.surcharges[0].route).toBe('Customs clearance service'); + }); + + it('excludes container lanes from a bulk contract', async () => { + const s = await build('IMP', 'BULK'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' }); + }); + + it('flags an empty schedule when nothing priced matches', async () => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) }; + const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON'); + expect(s.isEmpty).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..566d0308e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the `pricing` article body of the six seeded contract templates to + * the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per + * wagon") are now rendered from the LIVE rate config instead of frozen prose, + * so any template whose pricing article still carries a hardcoded price token + * is rewritten to the current seed text. + * + * The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original + * prose (which always quoted a currency + figure) and matches neither an + * already-migrated body nor a hand-edited one that adopted the schedule + * wording — so admin edits are preserved. Idempotent: after the rewrite the + * price token is gone, so a re-run is a no-op. Fresh databases seed the new + * text directly (CreateContractTemplates imports the same seed), making this + * a targeted backfill for databases seeded before the seed changed. + */ +const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]'; + +export class RefreshContractPricingArticles2360000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((a) => a.id === 'pricing'); + if (!pricing) continue; + + // Rewrite only the article whose id = 'pricing', in place, and only when + // its body still quotes a hardcoded currency figure. jsonb_agg keeps the + // rest of the article (id/title/order) and every other article intact. + await queryRunner.query( + ` + UPDATE freight.contract_templates AS t + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true) + ELSE elem + END + ORDER BY ord + ) + FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord) + ), + updated_at = now() + WHERE t.code = $1 + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(t.articles) AS x + WHERE x->>'id' = 'pricing' + AND x->>'body' ~ $3 + ); + `, + [seed.code, pricing.body, HARDCODED_PRICE_TOKEN], + ); + } + } + + /** + * Irreversible in practice — the original per-lane figures are not restored. + * A no-op down keeps the migration reversible-by-contract without + * resurrecting stale hardcoded prices. + */ + public async down(): Promise { + // intentionally empty + } +} 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 690b66385..aacc68012 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 @@ -42,6 +42,7 @@ export class BookingTransitionService { private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, + @Inject(forwardRef(() => BookingContractService)) private readonly contractService: BookingContractService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @@ -1049,7 +1050,25 @@ export class BookingTransitionService { booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); if (isExportTrain) { - await this.bookingBatchService.pickExportSchedule(scheduledBooking); + // With export split ON the booking no longer has to ride ONE train whole: + // the largest fitting part is offered and the leftover rebooks on the next + // train. So the day is only unbookable when NO export train that day has + // any room at all — reject on the day total, not on a single-train fit. + // With the flag off this stays the strict whole-booking gate. + if (process.env.FREIGHT_EXPORT_SPLIT === "true") { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + scheduledBooking, + eatDay(date), + "EXPORT", + ); + if (!fitting.length) { + throw new ConflictException( + "No export train on this day has space left — pick another shipment day.", + ); + } + } else { + await this.bookingBatchService.pickExportSchedule(scheduledBooking); + } } await this.bookingsRepository.update(bookingId, { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 826bc33b0..8ef819eaf 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; @@ -54,6 +55,18 @@ export interface CreateBookingUnderContractResult { warnings: string[]; } +/** + * Outstanding split remainder of a contract: what was booked in the first split + * booking's pre-split snapshot MINUS everything currently booked. Container + * contracts report per size; bulk reports one tonnage figure. `null` when the + * contract has no live split chain. Consumed by the remainder-placement engine + * to size the auto-created remainder booking. + */ +export type SplitOutstanding = { + bySize: Map; + bulk: { total: number; outstanding: number } | null; +}; + /** * The single create path for shipment bookings under a contract. * @@ -966,9 +979,11 @@ export class ContractBookingService { * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the * contract has no live split booking. */ - private async splitOutstanding( - contract: Contract, - ): Promise<{ bySize: Map; bulk: { total: number; outstanding: number } | null } | null> { + /** + * Public: the remainder-placement engine reads this to size the auto-created + * remainder booking. Returns `null` when there is no live split chain. + */ + async splitOutstanding(contract: Contract): Promise { const first = await this.dataSource .getRepository(Booking) .createQueryBuilder('b') @@ -1015,6 +1030,25 @@ export class ContractBookingService { const probe = await this.buildExportProbe(contract, route, dto, yards); const report = await this.bookingBatchService.exportSpaceReport(probe); if (report.scheduleId) return; + + // With export split ON a booking no longer has to ride ONE train whole: the + // largest fitting part is offered and the leftover is rebooked on the next + // train. Rejecting on the single-train fit here would block exactly the + // bookings the split exists to serve — including the auto-created remainder, + // which by definition did not fit the train it was split off. Fall back to + // the day total: unbookable only when NO export train that day has room. + if (process.env.FREIGHT_EXPORT_SPLIT === 'true') { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + probe, + eatDay(new Date(dto.scheduledDate)), + 'EXPORT', + ); + if (fitting.length > 0) return; + throw new BadRequestException( + 'No export train on this day has space left — pick another shipment day.', + ); + } + throw new BadRequestException( report.fullMessage ?? 'Not enough train space for this day.', ); @@ -1636,6 +1670,8 @@ export class ContractBookingService { isGovernment: contract.isGovernment, shippingLineId: null, contractRouteId: route?.id ?? null, + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 5507692d9..41971bbf1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -74,9 +74,14 @@ export class CreateRateDto { @Transform(({ value }) => Number(value)) rateValue!: number; - @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) + @ApiPropertyOptional({ + enum: RATE_UNITS, + description: + 'Unit basis for the rate. Optional for shapes with a forced unit (overweight is always PER_TON — the admin form hides the field and omits it); required otherwise.', + }) + @IsOptional() @IsIn([...RATE_UNITS]) - rateUnit!: string; + rateUnit?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index edd9ac9f0..488865f38 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -68,15 +68,21 @@ export class RatesService { private resolveRateUnit( appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], - requestedUnit: Rate['rateUnit'], + requestedUnit: Rate['rateUnit'] | undefined, ): Rate['rateUnit'] { - // Overweight is per-ton, full stop. + // Overweight is per-ton, full stop — the admin form hides the unit field + // for it and omits rateUnit from the payload entirely. if (trigger === 'OVERWEIGHT') return 'PER_TON'; - if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { - const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + const allowed = allowedRateUnits({ appliesTo, trigger }); + if (!requestedUnit) { throw new BadRequestException( - `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`, + ); + } + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`, ); } return requestedUnit; @@ -272,7 +278,11 @@ export class RatesService { tradeDirection, isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); - const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + const rateUnit = this.resolveRateUnit( + appliesTo, + trigger, + dto.rateUnit as Rate['rateUnit'] | undefined, + ); await this.assertNoDuplicatePattern({ rateType, 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 866885074..c06f0ee81 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 @@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; +import { RemainderPlacementService } from './remainder-placement.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, @@ -317,9 +318,29 @@ export class BookingBatchService implements OnModuleInit { @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, + @Optional() + @Inject(forwardRef(() => RemainderPlacementService)) + private readonly remainderPlacement?: RemainderPlacementService, ) {} + /** + * Auto-place a paid booking's split remainder onto the next fitting train. + * Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true. + */ + private get autoRemainderEnabled(): boolean { + return process.env.FREIGHT_AUTO_REMAINDER === "true"; + } + + /** + * Let EXPORT bookings split (offer the largest fitting part, leftover rebooks + * on the next train). Separate flag from auto-remainder: export touches the + * FCFS money path, so partial-offer can be enabled independently. + */ + private get exportSplitEnabled(): boolean { + return process.env.FREIGHT_EXPORT_SPLIT === "true"; + } + /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { const groups = await this.openRouteDayGroups(); @@ -496,6 +517,34 @@ export class BookingBatchService implements OnModuleInit { // to the offered part before it boards (remainder returns to the contract cap). if (this.splitService) { await this.splitService.applySplit(bookingId); + + // The split only happens on payment (here) — so auto-placing the remainder + // also only happens once the customer has accepted+paid. Re-read to see if + // applySplit actually reduced this booking (an open offer existed); if so, + // auto-create + place the remainder booking on the next fitting train. + // applySplit committed its own transaction before returning, so this reads + // the reduced lines. Best-effort: a placement failure never blocks the + // paid booking from boarding — the remainder falls back to manual rebook. + if (this.autoRemainderEnabled && this.remainderPlacement) { + const split = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + // Export remainders only auto-place when export split is on — otherwise + // an export booking never splits in the first place. + const directionOn = + split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled; + if (split?.isSplit && directionOn) { + await this.remainderPlacement + .placeRemainder(split) + .catch((err) => + this.logger.error( + `Auto-place remainder failed for ${split.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + } } const linked = @@ -731,6 +780,76 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Trains that can carry a booking's leg on a given day, earliest departure + * first, each with the largest number of wagons it could still admit for the + * booking's wagon type. Direction-filtered: EXPORT bookings see export trains, + * IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL + * allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a + * non-primary allowed type still counts. The remainder placer uses this to + * pick the next fitting train; the `free` wagon count is the best across the + * allowed types (a train fits under whichever allowed type gives most room). + */ + async fittingTrainsForDay( + booking: Booking, + day: string, + direction: "IMPORT" | "EXPORT", + ): Promise> { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.bookingWindowStatus !== "FULL" && + (direction === "EXPORT" + ? s.direction === "EXPORT" + : s.direction !== "EXPORT"), + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + + const wagonDims = await this.loadWagonDims(); + const dimsOptions = this.dimsForAllowed(booking, wagonDims); + const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = []; + + 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); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + const room = budget.remainingFor(leg); + // Best usable wagons across the allowed types — a train fits under + // whichever configured wagon type gives it the most room. + let freeWagons = 0; + for (const dims of dimsOptions) { + const w = this.bookableWithin(room, dims).wagons; + if (w > freeWagons) freeWagons = w; + } + if (freeWagons > 0) { + out.push({ + scheduleId: schedule.id, + departure: schedule.scheduledDepartureDate!, + freeWagons, + }); + } + } + return out; + } + /** * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day, * summed across every train on the booking's corridor that day. Unlike the @@ -789,6 +908,58 @@ export class BookingBatchService implements OnModuleInit { return { freeWagons, need, trainsForDay }; } + /** + * Export split: no single train carries the whole booking, so offer the + * largest fitting part on the export train with the most room for its leg. + * Returns true when an offer was opened (the caller must NOT then reserve — + * the offer already opened its own pay window), false when the booking fits + * whole somewhere (normal FCFS path) or no meaningful partial exists. + * + * Only the offer is written here: the booking is reduced to the offered part + * on payment (applySplit), and the leftover is auto-placed afterwards. So an + * unpaid export booking stays whole and the customer may still cancel it. + */ + private async tryExportPartialOffer(booking: Booking): Promise { + if (!this.splitService) return false; + const report = await this.exportSpaceReport(booking); + // A train fits it whole — nothing to split, take the normal path. + if (report.scheduleId) return false; + if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false; + + if (!booking.scheduledDate) return false; + const day = eatDay(new Date(booking.scheduledDate)); + const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT"); + if (!fitting.length) return false; + // Most room first — the largest single part ships now, the smallest leftover + // is what has to find another train. + const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0]; + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + target.scheduleId, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) return false; + const wagonDims = await this.loadWagonDims(); + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) return false; + + const offered = await this.tryPartialOffer( + booking, + schedule.id, + budget.remainingFor(leg), + report.need, + ); + if (!offered) return false; + this.logger.log( + `[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` + + `${schedule.id} — leftover rebooks on the next train once paid.`, + ); + this.notifyBoardChanged(schedule.id, "batch_fill"); + return true; + } + /** * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. * A consolidated booking reserves as a pair only once BOTH partners are ready @@ -800,6 +971,15 @@ export class BookingBatchService implements OnModuleInit { async acceptExportBooking(booking: Booking): Promise { const partnerId = booking.consolidationPartnerId ?? null; if (!partnerId) { + // Export split: when no single train carries the whole booking, offer the + // largest fitting part instead of failing the accept. The customer pays + // that part; on payment applySplit reduces this booking to it and the + // leftover is auto-placed as its own booking on the next train. Pairs are + // excluded (handled below) — a shared wagon is never split. + if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) { + const offered = await this.tryExportPartialOffer(booking); + if (offered) return; + } const scheduleId = await this.pickExportSchedule(booking); await this.reserveOnExport([booking], scheduleId); return; @@ -1852,15 +2032,23 @@ export class BookingBatchService implements OnModuleInit { } /** - * A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be - * offered a partial (split-on-payment). Consolidated pairs never split (both-or- - * neither shared wagon) and government bookings never split (they preempt). + * A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a + * partial (split-on-payment). Consolidated pairs never split (both-or-neither + * shared wagon) and government bookings never split (they preempt). + * + * IMPORT is always eligible. EXPORT is eligible only when export split is + * enabled: export historically rides one train whole, so splitting it changes + * the FCFS money path — each split part still rides ONE train whole, and the + * leftover becomes its own booking on the next train. */ private isSplitEligible(booking: Booking, isPair: boolean): boolean { + const directionOk = + booking.tradeDirection === "IMPORT" || + (booking.tradeDirection === "EXPORT" && this.exportSplitEnabled); return ( !isPair && !booking.isGovernment && - booking.tradeDirection === "IMPORT" && + directionOk && (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") && this.splitService != null ); @@ -3174,6 +3362,40 @@ export class BookingBatchService implements OnModuleInit { }; } + /** + * EVERY wagon-type dimension a booking may ride — its cargo/container type's + * full allowed (many-to-many) wagon-type list, not just the first like + * {@link dimsFor}. The remainder placer needs the whole set so a train that + * stocks a non-primary allowed type still counts as fitting: a container type + * mapped to both NW5 and (say) NW7 must be measured against whichever a given + * train actually has free. Deduped by wagon-type id; falls back to the single + * representative dims when no allowed type is configured. + */ + private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] { + const fallback = + booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; + const ids = + booking.freightType === "BULK" + ? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id) + : (booking.bookingContainers ?? []) + .flatMap((line) => line.containerType?.wagonTypes ?? []) + .map((wt) => wt.id); + const seen = new Set(); + const dims: PerWagonDims[] = []; + for (const id of ids) { + if (!id || seen.has(id)) continue; + seen.add(id); + const d = wagonDims.byWagonTypeId.get(id); + if (d) { + dims.push({ + ...d, + capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + }); + } + } + return dims.length ? dims : [fallback]; + } + /** * Ordered stop yards of the schedule's route (origin → milestones → * destination); the legacy two-stop pseudo-route when milestones are absent. 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 3d505e113..e17445b4d 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 @@ -150,10 +150,18 @@ export class BookingNotifierService { ): 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 leftover = totalWagons - offeredWagons; + // With auto-placement on, the leftover is booked FOR the customer on another + // train (its own invoice) — telling them to rebook it themselves would be + // wrong. Without it, the leftover returns to the contract to rebook. + const leftoverCopy = + process.env.FREIGHT_AUTO_REMAINDER === 'true' + ? `The remaining ${leftover} will be booked for you on another train, with its own invoice. ` + : `The remaining ${leftover} return${leftover === 1 ? 's' : ''} to your contract — book them yourself in a later window. `; 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 remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` + + leftoverCopy + `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)'); // HIGH: a split is a change to what the customer ordered AND a live payment @@ -164,6 +172,23 @@ export class BookingNotifierService { }); } + /** + * The wagons that did not fit the train the customer just paid for have been + * auto-booked as their own booking (`remainder`) — they ride another train and + * are billed separately. Sent instead of leaving the customer to rebook. + */ + remainderPlaced(remainder: Booking, parentReference: string): void { + const msg = + `The wagons left over from booking ${parentReference} have been booked as ` + + `${remainder.reference ?? remainder.id} on another train. ` + + `It carries its own invoice — pay it to secure that slot.`; + void this.notifyContact(remainder, msg, 'REMAINDER BOOKED'); + this.inApp(remainder, 'Leftover wagons booked', msg, { + type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, + }); + } + secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void { void (async () => { const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts new file mode 100644 index 000000000..55cacaf03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts @@ -0,0 +1,211 @@ +import { RemainderPlacementService } from './remainder-placement.service'; + +/** + * The remainder placer reconstructs the outstanding split remainder as a new + * booking. The delicate parts under test: bulk sizes from the outstanding tons; + * container recovers real numbers from the SOFT-DELETED units (never fabricates) + * and throws on a shortfall; and nothing is placed when there's no outstanding + * or no fitting train. + */ +describe('RemainderPlacementService', () => { + const DAY = '2026-07-20'; + + function make(opts: { + freightType: 'CONTAINER' | 'BULK'; + contractKind?: 'ONE_TIME' | 'GENERAL'; + outstanding: unknown; + createThrows?: Error; + deferredUnits?: Array<{ + containerNumber: string; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + }>; + fittingTrains?: Array<{ scheduleId: string }>; + }) { + const contract = { + id: 'c-1', + freightType: opts.freightType, + contractKind: opts.contractKind ?? 'ONE_TIME', + }; + const contractsRepository = { + findByIdWithRelations: jest.fn().mockResolvedValue(contract), + }; + const createUnderContract = opts.createThrows + ? jest.fn().mockRejectedValue(opts.createThrows) + : jest + .fn() + .mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] }); + const contractBookingService = { + splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding), + createUnderContract, + }; + const bookingBatchService = { + fittingTrainsForDay: jest + .fn() + .mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]), + }; + // getRepository is only hit on the container path (recoverDeferredUnits). + const lineRepo = { + find: jest.fn().mockResolvedValue([{ id: 'line-1' }]), + }; + const unitRepo = { + find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []), + }; + const dataSource = { + getRepository: jest.fn((entity: { name?: string }) => { + const n = entity?.name ?? ''; + if (n.includes('Unit')) return unitRepo; + return lineRepo; + }), + }; + const notifier = { remainderPlaced: jest.fn() }; + const service = new RemainderPlacementService( + dataSource as never, + contractsRepository as never, + contractBookingService as never, + bookingBatchService as never, + notifier as never, + ); + return { + service, + createUnderContract, + contractBookingService, + bookingBatchService, + notifier, + }; + } + + const splitBooking = { + id: 'bk-1', + reference: 'BKG-1', + contractId: 'c-1', + scheduledDate: new Date('2026-07-20T06:00:00Z'), + createdByUserId: 'u-1', + } as never; + + it('sizes a BULK remainder from the outstanding tons', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBe('rem-1'); + const dto = createUnderContract.mock.calls[0][1]; + expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]); + expect(dto.scheduledDate).toBe(DAY); + }); + + it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => { + const deferredUnits = [ + { containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true }, + { containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true }, + ]; + const { service, createUnderContract } = make({ + freightType: 'CONTAINER', + outstanding: { + bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]), + bulk: null, + }, + deferredUnits, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBe('rem-1'); + const dto = createUnderContract.mock.calls[0][1]; + expect(dto.containers).toHaveLength(1); + const line = dto.containers[0]; + expect(line.containerSize).toBe('40ft'); + expect(line.quantity).toBe(2); + expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([ + 'ABCD1234567', + 'ABCD7654321', + ]); + expect(line.reeferQuantity).toBe(1); + expect(line.hazardousQuantity).toBe(1); + }); + + it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => { + const { service, createUnderContract } = make({ + freightType: 'CONTAINER', + outstanding: { + bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]), + bulk: null, + }, + deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3 + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); + + it('is a no-op when there is no outstanding remainder', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); + + it('tells the customer the leftover wagons were booked on another train', async () => { + const { service, notifier } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + await service.placeRemainder(splitBooking); + expect(notifier.remainderPlaced).toHaveBeenCalledWith( + expect.objectContaining({ id: 'rem-1' }), + 'BKG-1', + ); + }); + + it('never double-books the leftover when two payments land together', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + // Both callers enter before either create commits. + await Promise.all([ + service.placeRemainder(splitBooking), + service.placeRemainder(splitBooking), + ]); + expect(createUnderContract).toHaveBeenCalledTimes(1); + }); + + // splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's + // snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and + // either drops a real remainder or double-draws the cap, so we must not place. + it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => { + const { service, createUnderContract, contractBookingService } = make({ + freightType: 'BULK', + contractKind: 'GENERAL', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled(); + }); + + // The paid booking has already boarded — a create-gate rejection (e.g. the + // export whole-train gate) must leave the remainder rebookable, not escape. + it('swallows a create rejection and leaves the remainder for manual rebook', async () => { + const { service } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + createThrows: new Error('Not enough train space for this day.'), + }); + await expect(service.placeRemainder(splitBooking)).resolves.toBeNull(); + }); + + it('is a no-op when the contract has no split chain', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: null, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts new file mode 100644 index 000000000..85b6d0878 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts @@ -0,0 +1,342 @@ +import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common'; +import { DataSource, IsNull, Not } from 'typeorm'; + +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 { Contract } from '../contracts/entities/contract.entity'; +import { + ContractBookingService, + SplitOutstanding, +} from '../contracts/contract-booking.service'; +import { ContractsRepository } from '../contracts/contracts.repository'; +import { + CreateBookingUnderContractDto, + CreateContainerUnitDto, +} from '../contracts/dto/create-booking-under-contract.dto'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingBatchService } from './booking-batch.service'; +import { BookingNotifierService } from './booking-notifier.service'; +import { eatDay } from './batch-window.util'; + +/** + * Auto-creates and places the OUTSTANDING split remainder of a contract as a new + * booking, so the customer doesn't have to manually rebook the wagons that did + * not fit the train they just paid for. + * + * Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only + * once the customer has actually accepted+paid the offered part. Before payment + * nothing is split: the booking stays whole and the customer may still edit or + * cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}. + * + * IMPORT/DOMESTIC: the remainder booking is created with the next fitting + * shipment day set and then follows the normal windowed batch flow (train + * assigned at window close, paid in its own window). It is NOT force-reserved on + * a specific train — import is not FCFS. + * + * Container reconstruction is HYBRID: the remainder's quantities come from the + * split snapshot (`splitOutstanding`), but the actual container numbers / VGM / + * seals are read back from the units `applySplit` SOFT-DELETED off the parent + * (they survive as valid ISO records). We never `restore()` those rows — the new + * booking gets fresh rows — so the contract cap is never double-counted. + */ +@Injectable() +export class RemainderPlacementService { + private readonly logger = new Logger(RemainderPlacementService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly contractsRepository: ContractsRepository, + @Inject(forwardRef(() => ContractBookingService)) + private readonly contractBookingService: ContractBookingService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + private readonly notifier: BookingNotifierService, + ) {} + + /** + * Create + place the outstanding split remainder of the contract that owns + * `splitBooking`. No-op when there is no live remainder or no fitting day. + * Returns the created remainder booking id, or null when nothing was placed + * (residual falls back to the customer's manual rebook, as today). + */ + async placeRemainder(splitBooking: Booking): Promise { + if (!splitBooking.contractId) return null; + // Two payment webhooks for the same contract landing together would both see + // the remainder as unbooked (the placing create has not committed yet) and + // each create one — double-booking the leftover. Serialize per contract: the + // second caller returns immediately and the first one's create is what the + // (now smaller) outstanding reflects. + if (this.inFlight.has(splitBooking.contractId)) { + this.logger.debug( + `Remainder placement already running for contract ${splitBooking.contractId} — skipped.`, + ); + return null; + } + this.inFlight.add(splitBooking.contractId); + try { + return await this.placeRemainderInner(splitBooking); + } finally { + this.inFlight.delete(splitBooking.contractId); + } + } + + /** Contracts with a placement in flight — see {@link placeRemainder}. */ + private readonly inFlight = new Set(); + + private async placeRemainderInner( + splitBooking: Booking, + ): Promise { + const contract = await this.contractsRepository.findByIdWithRelations( + splitBooking.contractId!, + ); + if (!contract) return null; + + // ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total + // from a SINGLE booking's pre-split snapshot, which is only coherent when + // the contract has exactly one live chain — that is the ONE_TIME invariant + // (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL + // contract with other live bookings the subtraction mixes scopes: it either + // clamps to 0 and silently drops a real remainder, or sizes one that then + // draws the quantity cap a second time. GENERAL remainders keep the existing + // manual-rebook behaviour until the remainder can be derived from the + // offer's own dropped lines rather than from the contract-wide ledger. + if (contract.contractKind !== 'ONE_TIME') { + this.logger.debug( + `Contract ${contract.id} is ${contract.contractKind} — remainder left ` + + `for manual rebook (auto-placement is ONE_TIME only).`, + ); + return null; + } + + const outstanding = await this.contractBookingService.splitOutstanding( + contract, + ); + if (!outstanding || !this.hasOutstanding(contract, outstanding)) { + return null; + } + + // The next fitting day: the earliest day on/after the split booking's own day + // that still has an import train with room for this cargo type. We reuse the + // split booking as the capacity probe — it carries the leg + cargo relations. + const day = await this.nextFittingDay(splitBooking); + if (!day) { + this.logger.warn( + `No train with room for the remainder of contract ${contract.id} ` + + `(booking ${splitBooking.reference}) — left for manual rebook.`, + ); + return null; + } + + let dto: CreateBookingUnderContractDto; + try { + dto = await this.buildRemainderDto( + contract, + outstanding, + splitBooking.id, + day, + ); + } catch (err) { + // A reconstruction shortfall (fewer recoverable units than outstanding) + // must NOT fabricate container numbers — fail loudly, leave manual rebook. + this.logger.error( + `Could not reconstruct the remainder of contract ${contract.id}: ` + + `${err instanceof Error ? err.message : String(err)} — left for manual rebook.`, + ); + return null; + } + + // Any create-gate rejection (no train space, cap, container clash) must not + // escape: the customer's paid booking has already boarded, and a thrown + // error here would only be logged upstream while the remainder vanished + // silently. Fall back to leaving it rebookable, which is the pre-feature + // behaviour, and say so in the log. + let created: Awaited< + ReturnType + >; + try { + created = await this.contractBookingService.createUnderContract( + contract.id, + dto, + { id: splitBooking.createdByUserId ?? undefined }, + // System actor: a permission-bag carrying the contract create-booking key + // so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B + // contracts; harmless for customer (Path A) contracts. + { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + ); + } catch (err) { + this.logger.error( + `Could not create the remainder booking for contract ${contract.id} ` + + `(from ${splitBooking.reference}): ${ + err instanceof Error ? err.message : String(err) + } — left for manual rebook.`, + ); + return null; + } + // EXPORT is FCFS — there is no window to wait for, so the remainder is + // reserved on the next export train right away (its own pay window opens). + // If it does not fit one train whole either, the export accept offers it a + // partial and the chain repeats on ITS payment: each pass leaves a strictly + // smaller remainder, so it terminates at the day's train count. + // IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next + // fitting day and rides the normal windowed batch flow. + if (splitBooking.tradeDirection === 'EXPORT') { + const fresh = await this.dataSource + .getRepository(Booking) + .findOne({ + where: { id: created.booking.id }, + relations: { + company: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + if (fresh) { + await this.bookingBatchService + .acceptExportBooking(fresh) + .catch((err) => + // No export train took it — it stays created and rebookable, which + // is the same place a customer-driven rebook would leave it. + this.logger.warn( + `Export remainder ${fresh.reference} created but not reserved: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + } + + this.notifier.remainderPlaced( + created.booking, + splitBooking.reference ?? splitBooking.id, + ); + this.logger.log( + `Auto-placed split remainder of contract ${contract.id} as booking ` + + `${created.booking.reference} on ${day}.`, + ); + return created.booking.id; + } + + private hasOutstanding( + contract: Contract, + outstanding: SplitOutstanding, + ): boolean { + if (contract.freightType === 'CONTAINER') { + return [...outstanding.bySize.values()].some((s) => s.outstanding > 0); + } + return (outstanding.bulk?.outstanding ?? 0) > 0.001; + } + + /** + * The shipment day to create the remainder on — the split booking's own day. + * + * EXPORT is FCFS and must actually board a train that day, so a day with NO + * export train having room is rejected (null → left for manual rebook on a day + * the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is + * assigned by the batch engine at window close, not now, and the window may + * still free up — forcing a different day here would override the customer's + * binding shipment day. + */ + private async nextFittingDay(booking: Booking): Promise { + if (!booking.scheduledDate) return null; + const day = eatDay(new Date(booking.scheduledDate)); + if (booking.tradeDirection !== 'EXPORT') return day; + + const fitting = await this.bookingBatchService.fittingTrainsForDay( + booking, + day, + 'EXPORT', + ); + return fitting.length > 0 ? day : null; + } + + /** + * Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the + * outstanding tonnage directly. Container reads the deferred (soft-deleted) + * units of the split booking back into real unit records. + */ + private async buildRemainderDto( + contract: Contract, + outstanding: SplitOutstanding, + splitBookingId: string, + day: string, + ): Promise { + const dto: CreateBookingUnderContractDto = { scheduledDate: day }; + + if (contract.freightType !== 'CONTAINER') { + const tons = outstanding.bulk?.outstanding ?? 0; + dto.bulkLines = [{ cargoWeightTons: tons }]; + return dto; + } + + // Container: recover the deferred units per size from the split booking's + // soft-deleted rows and reshape into DTO units. + const containers: NonNullable = []; + for (const [size, { outstanding: need }] of outstanding.bySize) { + if (need <= 0) continue; + const units = await this.recoverDeferredUnits(splitBookingId, size, need); + if (units.length < need) { + throw new Error( + `size ${size}: recovered ${units.length} deferred container(s) but ` + + `${need} are outstanding`, + ); + } + const line: NonNullable[number] = { + containerSize: size, + quantity: need, + units, + }; + line.hazardousQuantity = units.filter((u) => u.isHazardous).length; + line.reeferQuantity = units.filter((u) => u.isReefer).length; + containers.push(line); + } + dto.containers = containers; + return dto; + } + + /** + * The `need` deferred container units of a given size for the split booking, + * read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the + * LIFO trim in applySplit so the same physical containers deferred are the + * ones rebooked). Returns them as DTO units; does NOT restore the rows. + */ + private async recoverDeferredUnits( + splitBookingId: string, + containerSize: string, + need: number, + ): Promise { + // The line ids of this booking for this size (live + soft-deleted): units + // key on bookingContainerId, so gather every line of the size first. + const lines = await this.dataSource + .getRepository(BookingContainer) + .find({ + where: { bookingId: splitBookingId, containerSize }, + withDeleted: true, + select: { id: true }, + }); + const lineIds = lines.map((l) => l.id); + if (!lineIds.length) return []; + + // Only the DELETED units are the deferred ones (live units stayed on the + // paid part). Oldest-first to match the deferred set. + const deferred = await this.dataSource + .getRepository(BookingContainerUnit) + .find({ + where: lineIds.map((bookingContainerId) => ({ + bookingContainerId, + deletedAt: Not(IsNull()), + })), + withDeleted: true, + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + take: need, + }); + + return deferred.map((u) => ({ + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? undefined, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + })); + } +} 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 1e1eb1695..3b2b257c8 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 @@ -32,6 +32,7 @@ import { IntercityService } from './intercity.service'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingJourneyService } from './booking-journey.service'; import { BookingSplitService } from './booking-split.service'; +import { RemainderPlacementService } from './remainder-placement.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -79,6 +80,7 @@ import { ContractsModule } from '../contracts/contracts.module'; WsAuthService, BookingWindowService, BookingSplitService, + RemainderPlacementService, IntercityService, BookingJourneyService, ], diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index e43351015..9fe86ab1e 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -15,10 +15,23 @@ import { } from "./cookies"; import type { AuthTokens } from "./types"; +declare module "axios" { + export interface AxiosRequestConfig { + /** + * When true, the response interceptor does NOT raise the global error modal + * for this request's failure. For calls the caller handles itself — e.g. a + * probe that is expected to 404 before falling back (GL clearance detail + * tries /contracts/:id then /bookings/:id). The rejection still propagates. + */ + suppressErrorModal?: boolean; + } +} + type RetriableRequest = { _retry?: boolean; headers?: Record; url?: string; + suppressErrorModal?: boolean; }; const api = axios.create({ @@ -100,8 +113,13 @@ api.interceptors.response.use( originalRequest.url?.includes("/auth/refresh-token") ) { // Surface the server's actual error message in the global error modal - // (401s are handled by the session-refresh flow, so skip them). - if (error.response && error.response.status !== 401) { + // (401s are handled by the session-refresh flow, so skip them). A request + // may opt out via `suppressErrorModal` when it handles the failure itself. + if ( + error.response && + error.response.status !== 401 && + !originalRequest?.suppressErrorModal + ) { const payload = extractApiErrorPayload(error); if (payload) emitApiError(payload); } diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index daff48939..f68d6562b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { Alert, Badge, @@ -18,7 +18,6 @@ import { AlertCircle, ClipboardList, FileText, - PackagePlus, Upload, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -61,9 +60,12 @@ type GlClearanceDetail = async function loadGlClearanceDetail(id: string): Promise { try { + // Probe the contract endpoints first; a booking-id row 404s here by design + // and falls back to the booking lookup below. Suppress the global error + // modal so that expected 404 never surfaces to the user. const [clearance, contract] = await Promise.all([ - contractsService.getClearance(id), - contractsService.getById(id), + contractsService.getClearance(id, { suppressErrorModal: true }), + contractsService.getById(id, { suppressErrorModal: true }), ]); return { kind: "contract", @@ -89,7 +91,6 @@ async function loadGlClearanceDetail(id: string): Promise { /** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */ export default function GlClearanceDetailPage() { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const { user } = useAuth(); const { view, viewer } = useFileViewer(); const [uploadKind, setUploadKind] = useState(null); @@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() { {hasRo ? "Replace RO" : "Upload RO"} )} - {canCompleteBooking && shipmentBooking ? ( - - ) : null} } /> diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index eb027dde7..7e07d2345 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -164,8 +164,11 @@ export const contractsService = { }; }, - getById: async (id: string): Promise => { - const response = await client.get(C.BY_ID(id)); + getById: async ( + id: string, + opts?: { suppressErrorModal?: boolean }, + ): Promise => { + const response = await client.get(C.BY_ID(id), opts); return unwrap(response.data) as Freight.IContract; }, @@ -258,8 +261,11 @@ export const contractsService = { }; }, - getClearance: async (id: string): Promise => { - const response = await client.get(C.CLEARANCE(id)); + getClearance: async ( + id: string, + opts?: { suppressErrorModal?: boolean }, + ): Promise => { + const response = await client.get(C.CLEARANCE(id), opts); return unwrap(response.data) as Freight.ContractClearanceView; }, From 8f0670ff9c4abd064eeec6e2f32c07bb4bbeb98c Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:27:59 +0300 Subject: [PATCH 08/54] Passengers report --- .../src/modules/reports/reports.controller.ts | 6 + .../src/modules/reports/reports.service.ts | 95 ++++++++ .../src/app/reports/passengers/layout.tsx | 3 + .../src/app/reports/passengers/page.tsx | 214 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 1 + 5 files changed, 319 insertions(+) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 0c0d02ea4..a4da5b208 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -18,6 +18,12 @@ export class ReportsController { return this.service.generateReport(dto); } + @Get('passengers') + @ApiOperation({ summary: 'Passengers report for a specific schedule' }) + getOccupancyReport(@Query('scheduleId') scheduleId: string) { + return this.service.getOccupancyBySchedule(scheduleId); + } + @Get(':reportId') @ApiOperation({ summary: 'Get report by ID' }) getReport(@Param('reportId') reportId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index e37cac0d3..f4141ecb6 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -191,6 +191,101 @@ export class ReportsService { }; } + async getOccupancyBySchedule(scheduleId: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + include: { + originStation: true, + destinationStation: true, + train: true, + coachAssignments: { + include: { + coach: { + include: { + coachType: true, + seats: { select: { id: true } }, + }, + }, + }, + }, + bookings: { + where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + include: { + seats: { + include: { + seat: { include: { coach: { include: { coachType: true } } } }, + }, + }, + }, + }, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + }, + }); + + if (!schedule) return null; + + const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0); + const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats); + const totalPassengers = allBookingSeats.length; + const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0; + + const coachMap = new Map(); + for (const assignment of (schedule as any).coachAssignments) { + const c = assignment.coach; + coachMap.set(c.id, { coachNumber: c.number, coachType: (c as any).coachType?.name ?? 'Unknown', totalSeats: c.seats.length, booked: 0 }); + } + for (const bs of allBookingSeats) { + const coachId = bs.seat?.coachId; + if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++; + } + const byCoach = [...coachMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 })); + + const originMap = new Map(); + for (const booking of (schedule as any).bookings) { + const stationId = booking.originStationId ?? schedule.originStationId; + const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name ?? (schedule as any).originStation?.name ?? stationId; + if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 }); + originMap.get(stationId)!.passengers += booking.seats.length; + } + + const destMap = new Map(); + for (const booking of (schedule as any).bookings) { + const stationId = booking.destinationStationId ?? schedule.destinationStationId; + const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name ?? (schedule as any).destinationStation?.name ?? stationId; + if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 }); + destMap.get(stationId)!.passengers += booking.seats.length; + } + + const classMap = new Map(); + for (const assignment of (schedule as any).coachAssignments) { + const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown'; + if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); + classMap.get(typeName)!.totalSeats += assignment.coach.seats.length; + } + for (const bs of allBookingSeats) { + const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown'; + if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); + classMap.get(typeName)!.booked++; + } + const byClass = [...classMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 })); + + return { + schedule: { + id: schedule.id, + trainName: (schedule as any).train?.name ?? (schedule as any).train?.number, + origin: (schedule as any).originStation?.name, + destination: (schedule as any).destinationStation?.name, + departureAt: schedule.departureAt, + arrivalAt: schedule.arrivalAt, + }, + summary: { totalSeats, totalPassengers, occupancyRate }, + byCoach, + byClass, + byOrigin: [...originMap.values()].sort((a, b) => b.passengers - a.passengers), + byDestination: [...destMap.values()].sort((a, b) => b.passengers - a.passengers), + }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId } }); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx new file mode 100644 index 000000000..790272de1 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx new file mode 100644 index 000000000..8b7ef240f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Users, Armchair, BarChart3, Download } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import ActionButton from '@/components/ui/ActionButton'; +import { formatDateTime } from '@/lib/utils'; + +interface PassengersReport { + schedule: { + id: string; + trainName: string; + origin: string; + destination: string; + departureAt: string; + arrivalAt: string; + }; + summary: { totalSeats: number; totalPassengers: number; occupancyRate: number }; + byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[]; + byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[]; + byOrigin: { stationName: string; passengers: number }[]; + byDestination: { stationName: string; passengers: number }[]; +} + +export default function PassengersReportPage() { + const [scheduleId, setScheduleId] = useState(''); + const [submittedId, setSubmittedId] = useState(''); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['passengers-report', submittedId], + queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${submittedId}`), + enabled: !!submittedId, + }); + + const doExport = () => { + if (!data) return; + const rows = data.byCoach.map((c) => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); + const headers = ['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy']; + const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `passengers-report-${submittedId}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Passengers Report

+

Occupancy and passenger breakdown for a schedule

+
+ + {/* Schedule ID input */} +
+
+
+ + setScheduleId(e.target.value)} + /> +
+ setSubmittedId(scheduleId)} disabled={!scheduleId.trim() || isLoading}> + Load Report + + {data && ( + + Export CSV + + )} +
+ {isLoading &&

Loading…

} + {isError &&

Failed to load report. Check the schedule ID.

} +
+ + {data && ( + <> + {/* Schedule info */} +
+

Schedule

+
+
Train

{data.schedule.trainName ?? '—'}

+
Route

{data.schedule.origin} → {data.schedule.destination}

+
Departure

{formatDateTime(data.schedule.departureAt)}

+
+
+ + {/* Summary cards */} +
+
+
+

Total Seats

+
+
+

{data.summary.totalSeats}

+
+
+
+

Passengers

+
+
+

{data.summary.totalPassengers}

+
+
+
+

Occupancy Rate

+
+
+

{data.summary.occupancyRate}%

+
+
+
+
+
+ + {/* By Coach */} +
+

By Coach

+
+ + + + + + + + + + + + {data.byCoach.map((c) => ( + + + + + + + + ))} + +
CoachTypeSeatsBookedOccupancy
{c.coachNumber}{c.coachType}{c.totalSeats}{c.booked} +
+
+
+
+ {c.occupancyRate}% +
+
+
+
+ + {/* By Class + By Origin/Destination */} +
+ {/* By Class */} +
+

By Class

+
+ {data.byClass.map((c) => ( +
+
+ {c.className} + {c.booked}/{c.totalSeats} +
+
+
+
+
+ {c.occupancyRate}% +
+
+ ))} +
+
+ + {/* By Origin */} +
+

By Boarding Station

+
+ {data.byOrigin.map((o) => ( +
+ {o.stationName} + {o.passengers} +
+ ))} + {data.byOrigin.length === 0 &&

No data

} +
+
+ + {/* By Destination */} +
+

By Alighting Station

+
+ {data.byDestination.map((d) => ( +
+ {d.stationName} + {d.passengers} +
+ ))} + {data.byDestination.length === 0 &&

No data

} +
+
+
+ + )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 45da6d8ca..83c7271d0 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -121,6 +121,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ items: [ { name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, + { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, ] }, From a7041ee70f3c999ee594654149df7cf310446003 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 18 Jul 2026 09:28:41 +0000 Subject: [PATCH 09/54] split export --- .../bookings/new-booking-form/LocationPicker.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index b2f14b16f..6a53be5cc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -409,7 +409,11 @@ function LocationPickerInline({ const geocoder = useGeocoder(); const places = usePlacesSearch(); const placesLib = useMapsLibrary("places"); - const [query, setQuery] = useState(""); + // `null` means "not editing" (show the saved address); any string — including + // "" after the user clears the field — is live edit state. A plain `query || + // value.address` fallback would snap the saved address back the moment the + // user cleared the input, making it impossible to retype the location. + const [query, setQuery] = useState(null); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [resolving, setResolving] = useState(false); @@ -430,7 +434,7 @@ function LocationPickerInline({ // than one per keystroke. While it runs, the input shows a spinner; the // dropdown itself only appears once there are predictions to show. useEffect(() => { - const q = query.trim(); + const q = (query ?? "").trim(); if (q.length < MIN_QUERY_LEN) { setResults([]); setSearching(false); @@ -469,7 +473,7 @@ function LocationPickerInline({ async (prediction: PlacePrediction) => { // Clear the query/results immediately so the pending debounce can't fire // a search for the picked address and pop the dropdown back open. - setQuery(""); + setQuery(null); setResults([]); // Predictions carry no coordinates — resolve them now via Place Details. if (!places) return; @@ -498,6 +502,9 @@ function LocationPickerInline({ const handlePin = useCallback( async (lat: number, lng: number) => { // Show the pin immediately; fill the address once reverse geocoding lands. + // Leave edit mode so the input reflects the reverse-geocoded address + // instead of whatever half-typed query the user abandoned for the map. + setQuery(null); onChange({ address: value.address, lat, lng }); if (!geocoder) return; // Mark any in-flight reverse lookup stale — only the latest pin counts. @@ -525,7 +532,7 @@ function LocationPickerInline({ [handlePin], ); - const inputValue = query || value.address; + const inputValue = query ?? value.address; const center = hasPin ? { lat: value.lat as number, lng: value.lng as number } : DEFAULT_CENTER; From 56057a1e1621b52042ba9be84162790ede96194c Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:31:19 +0300 Subject: [PATCH 10/54] Migration conflict issues resolution --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/edr-passenger-api/prisma/migrations/{20260717000003_booking_seat_schedule_unique => 20260717000004_booking_seat_schedule_unique}/migration.sql (100%) diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql similarity index 100% rename from apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql From e55dd01c500f6e56516d778df13866c1335648d8 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:47:05 +0300 Subject: [PATCH 11/54] Migration issues resolution --- .../migration.sql | 21 ------ .../migration.sql | 15 ----- .../migration.sql | 4 -- .../migration.sql | 1 + .../migration.sql | 66 ------------------- 5 files changed, 1 insertion(+), 106 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql index 83a49ef67..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -1,22 +1 @@ -<<<<<<< Updated upstream --- Remove duplicate JourneySegment rows, keeping the one with the lowest id --- (earliest created) per (scheduleId, seatId, departureStationId) group. --- This cleans up any existing double-bookings before the unique index is applied. -DELETE FROM passenger."JourneySegment" -WHERE id NOT IN ( - SELECT MIN(id) - FROM passenger."JourneySegment" - WHERE "seatId" IS NOT NULL - GROUP BY "scheduleId", "seatId", "departureStationId" -) -AND "seatId" IS NOT NULL; - --- Prevents two confirmed bookings from occupying the same seat on the same --- schedule hop — the hard DB backstop against application-level race conditions. --- Partial index: seatId IS NOT NULL excludes free-child rows that have no seat. -CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" -ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") -WHERE "seatId" IS NOT NULL; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql index e04c02621..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -1,16 +1 @@ -<<<<<<< Updated upstream --- Rename StopStatus enum values to reflect segment-level booking lifecycle. --- UPCOMING → OPEN (segment is bookable) --- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) --- CURRENT → BOARDED (train has departed this stop) --- COMPLETED stays as-is -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'UPCOMING' TO 'OPEN'; -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'APPROACHING' TO 'CHECKIN_CLOSED'; -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; - --- Add per-route check-in window. Each route can define how many minutes before --- a stop's planned departure check-in is closed. Defaults to 30 minutes. -ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql index 5fd9ae0f5..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -1,5 +1 @@ -<<<<<<< Updated upstream -ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql new file mode 100644 index 000000000..bcea78e50 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -0,0 +1 @@ +-- Migration already applied directly to the database. diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql deleted file mode 100644 index 8553fc81b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql +++ /dev/null @@ -1,66 +0,0 @@ --- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. -UPDATE passenger."BookingSeat" bs -<<<<<<< Updated upstream -SET schedule_id = b.schedule_id -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL -======= -SET "scheduleId" = b."scheduleId" -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL ->>>>>>> Stashed changes - AND bs.leg = 1; - --- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. -UPDATE passenger."BookingSeat" bs -<<<<<<< Updated upstream -SET schedule_id = COALESCE( - (b.return_schedule_id), - (b.leg2_schedule_id), - b.schedule_id -) -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL - AND bs.leg = 2; - --- Catch any remaining NULLs (leg 3/4 from ROUND_TRIP_TRANSIT) using the booking's schedule. -UPDATE passenger."BookingSeat" bs -SET schedule_id = b.schedule_id -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL; - --- Now enforce NOT NULL. -ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; - --- Add the unique constraint that is the actual double-booking guard. -CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" - ON passenger."BookingSeat"(schedule_id, seat_id); -======= -SET "scheduleId" = COALESCE( - b."returnScheduleId", - b."leg2ScheduleId", - b."scheduleId" -) -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL - AND bs.leg = 2; - --- Catch any remaining NULLs using the booking's schedule. -UPDATE passenger."BookingSeat" bs -SET "scheduleId" = b."scheduleId" -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL; - --- Now enforce NOT NULL. -ALTER TABLE passenger."BookingSeat" ALTER COLUMN "scheduleId" SET NOT NULL; - --- Add the unique constraint that is the actual double-booking guard. -CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" - ON passenger."BookingSeat"("scheduleId", "seatId"); ->>>>>>> Stashed changes From aac162ca3ca886452dc797aba1f0614cd1d3fda0 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:52:28 +0300 Subject: [PATCH 12/54] Reports update --- .../src/modules/reports/reports.controller.ts | 6 ++ .../src/modules/reports/reports.service.ts | 18 +++++ .../src/app/reports/passengers/page.tsx | 73 +++++++++++++------ 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index a4da5b208..82a07f98f 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -18,6 +18,12 @@ export class ReportsController { return this.service.generateReport(dto); } + @Get('schedules') + @ApiOperation({ summary: 'List schedules for the passengers report picker' }) + listSchedulesForPicker() { + return this.service.listSchedulesForPicker(); + } + @Get('passengers') @ApiOperation({ summary: 'Passengers report for a specific schedule' }) getOccupancyReport(@Query('scheduleId') scheduleId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index f4141ecb6..9037188e2 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -286,6 +286,24 @@ export class ReportsService { }; } + async listSchedulesForPicker() { + const schedules = await this.prisma.trainSchedule.findMany({ + select: { + id: true, + departureAt: true, + train: { select: { number: true } }, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + orderBy: { departureAt: 'desc' }, + take: 200, + }); + return schedules.map(s => ({ + id: s.id, + label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`, + })); + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId } }); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 8b7ef240f..83b803331 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -7,15 +7,10 @@ import { apiClient } from '@/lib/api-client'; import ActionButton from '@/components/ui/ActionButton'; import { formatDateTime } from '@/lib/utils'; +interface ScheduleOption { id: string; label: string; } + interface PassengersReport { - schedule: { - id: string; - trainName: string; - origin: string; - destination: string; - departureAt: string; - arrivalAt: string; - }; + schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; }; summary: { totalSeats: number; totalPassengers: number; occupancyRate: number }; byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[]; byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[]; @@ -25,8 +20,20 @@ interface PassengersReport { export default function PassengersReportPage() { const [scheduleId, setScheduleId] = useState(''); + const [search, setSearch] = useState(''); const [submittedId, setSubmittedId] = useState(''); + const { data: schedules = [], isLoading: loadingSchedules } = useQuery({ + queryKey: ['report-schedules'], + queryFn: () => apiClient.get('/reports/schedules'), + }); + + const filtered = search.trim() + ? schedules.filter(s => s.label.toLowerCase().includes(search.toLowerCase())) + : schedules; + + const selected = schedules.find(s => s.id === scheduleId); + const { data, isLoading, isError } = useQuery({ queryKey: ['passengers-report', submittedId], queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${submittedId}`), @@ -54,20 +61,43 @@ export default function PassengersReportPage() {

Occupancy and passenger breakdown for a schedule

- {/* Schedule ID input */} + {/* Schedule picker */}
-
- - setScheduleId(e.target.value)} - /> +
+ +
+ { setSearch(e.target.value); setScheduleId(''); }} + onFocus={(e) => { setSearch(e.target.value); }} + /> + {(search || scheduleId) && ( + + )} +
+ {search && !scheduleId && ( +
+ {filtered.length === 0 + ?

No schedules found

+ : filtered.map(s => ( + + )) + } +
+ )}
- setSubmittedId(scheduleId)} disabled={!scheduleId.trim() || isLoading}> + setSubmittedId(scheduleId)} disabled={!scheduleId || isLoading}> Load Report {data && ( @@ -77,7 +107,7 @@ export default function PassengersReportPage() { )}
{isLoading &&

Loading…

} - {isError &&

Failed to load report. Check the schedule ID.

} + {isError &&

Failed to load report.

}
{data && ( @@ -158,7 +188,6 @@ export default function PassengersReportPage() { {/* By Class + By Origin/Destination */}
- {/* By Class */}

By Class

@@ -179,7 +208,6 @@ export default function PassengersReportPage() {
- {/* By Origin */}

By Boarding Station

@@ -193,7 +221,6 @@ export default function PassengersReportPage() {
- {/* By Destination */}

By Alighting Station

From 594aaf17abd46ce1901b7e12a5263d9516c33e92 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 17:53:44 +0300 Subject: [PATCH 13/54] Migration issue resolution --- .../migration.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql new file mode 100644 index 000000000..2b67ec9cf --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[scheduleId,seatId,departureStationId]` on the table `JourneySegment` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key" ON "JourneySegment"("scheduleId", "seatId", "departureStationId"); From f366e834e765c20e016ec391a8119e7445f287d6 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 18:05:41 +0300 Subject: [PATCH 14/54] Migration issue resolution --- .github/workflows/deploy.yml | 11 +++++++++++ .../migration.sql | 14 +++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 44be07550..912c6bbd7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -162,6 +162,17 @@ jobs: -t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ . + - name: Resolve failed migrations for ${{ matrix.service }} + if: matrix.service == 'passenger-api' + run: | + set -euo pipefail + docker run --rm --env-file "${SERVICE_ENV_FILE}" \ + --entrypoint npx \ + "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ + prisma migrate resolve \ + --applied 20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id \ + || true + - name: Run migrations for ${{ matrix.service }} if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) run: | diff --git a/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql index 2b67ec9cf..a512eeccd 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql @@ -4,5 +4,17 @@ - A unique constraint covering the columns `[scheduleId,seatId,departureStationId]` on the table `JourneySegment` will be added. If there are existing duplicate values, this will fail. */ +-- Deduplicate before applying the unique index. +-- Keeps the row with the lowest id per (scheduleId, seatId, departureStationId) group. +DELETE FROM passenger."JourneySegment" +WHERE id NOT IN ( + SELECT MIN(id) + FROM passenger."JourneySegment" + WHERE "seatId" IS NOT NULL + GROUP BY "scheduleId", "seatId", "departureStationId" +) +AND "seatId" IS NOT NULL; + -- CreateIndex -CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key" ON "JourneySegment"("scheduleId", "seatId", "departureStationId"); +CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key" + ON "JourneySegment"("scheduleId", "seatId", "departureStationId"); From 3fc36b358f8c2d185cc7fab619e7784e9f9458eb Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 18:21:15 +0300 Subject: [PATCH 15/54] Passengers report updates --- .../src/modules/reports/reports.controller.ts | 6 + .../src/modules/reports/reports.service.ts | 29 ++ .../src/app/reports/passengers/page.tsx | 404 +++++++++++------- 3 files changed, 282 insertions(+), 157 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 82a07f98f..74bad3514 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -24,6 +24,12 @@ export class ReportsController { return this.service.listSchedulesForPicker(); } + @Get('passengers/list') + @ApiOperation({ summary: 'Flat passenger list for a specific schedule' }) + getPassengerList(@Query('scheduleId') scheduleId: string) { + return this.service.getPassengerList(scheduleId); + } + @Get('passengers') @ApiOperation({ summary: 'Passengers report for a specific schedule' }) getOccupancyReport(@Query('scheduleId') scheduleId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 9037188e2..daf1a7538 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -286,6 +286,35 @@ export class ReportsService { }; } + async getPassengerList(scheduleId: string) { + const seats = await this.prisma.bookingSeat.findMany({ + where: { + scheduleId, + booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + }, + include: { + booking: { select: { bookingRef: true, status: true, originStationId: true, destinationStationId: true } }, + seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, + }, + orderBy: [{ seat: { coach: { number: 'asc' } } }], + }); + + return seats.map(bs => ({ + bookingRef: bs.booking.bookingRef, + bookingStatus: bs.booking.status, + passengerName: bs.passengerName, + dateOfBirth: bs.dateOfBirth, + passengerCategory: bs.passengerCategory, + idDocumentType: bs.idDocumentType, + idDocumentNumber: bs.idDocumentNumber, + passportNumber: bs.passportNumber, + passportCountry: bs.passportCountry, + seatLabel: bs.seatLabelSnapshot, + coachNumber: bs.seat?.coach?.number ?? null, + coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + })); + } + async listSchedulesForPicker() { const schedules = await this.prisma.trainSchedule.findMany({ select: { diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 83b803331..fe39e7e51 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -18,42 +18,82 @@ interface PassengersReport { byDestination: { stationName: string; passengers: number }[]; } +interface PassengerRow { + bookingRef: string; + bookingStatus: string; + passengerName: string; + dateOfBirth: string | null; + passengerCategory: string; + idDocumentType: string | null; + idDocumentNumber: string | null; + passportNumber: string | null; + passportCountry: string | null; + seatLabel: string | null; + coachNumber: string | null; + coachType: string | null; +} + +type Tab = 'occupancy' | 'list'; + export default function PassengersReportPage() { const [scheduleId, setScheduleId] = useState(''); - const [search, setSearch] = useState(''); - const [submittedId, setSubmittedId] = useState(''); + const [tab, setTab] = useState('occupancy'); + const [listSearch, setListSearch] = useState(''); const { data: schedules = [], isLoading: loadingSchedules } = useQuery({ queryKey: ['report-schedules'], queryFn: () => apiClient.get('/reports/schedules'), }); - const filtered = search.trim() - ? schedules.filter(s => s.label.toLowerCase().includes(search.toLowerCase())) - : schedules; - - const selected = schedules.find(s => s.id === scheduleId); - const { data, isLoading, isError } = useQuery({ - queryKey: ['passengers-report', submittedId], - queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${submittedId}`), - enabled: !!submittedId, + queryKey: ['passengers-report', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), + enabled: !!scheduleId, }); - const doExport = () => { - if (!data) return; - const rows = data.byCoach.map((c) => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); - const headers = ['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy']; - const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n'); + const { data: passengerList = [], isLoading: listLoading } = useQuery({ + queryKey: ['passengers-list', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); + + const filteredList = listSearch.trim() + ? passengerList.filter(p => + p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || + p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) || + (p.idDocumentNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()) || + (p.passportNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()), + ) + : passengerList; + + const downloadCsv = (csv: string, filename: string) => { const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = url; - a.download = `passengers-report-${submittedId}.csv`; - a.click(); + a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }; + const doExportOccupancy = () => { + if (!data) return; + const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); + const csv = [['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n'); + downloadCsv(csv, `occupancy-${scheduleId}.csv`); + }; + + const doExportList = () => { + if (!passengerList.length) return; + const headers = ['Booking Ref', 'Status', 'Name', 'DOB', 'Category', 'ID Type', 'ID Number', 'Passport', 'Country', 'Seat', 'Coach', 'Class']; + const rows = passengerList.map(p => [ + p.bookingRef, p.bookingStatus, p.passengerName, p.dateOfBirth ?? '', + p.passengerCategory, p.idDocumentType ?? '', p.idDocumentNumber ?? '', + p.passportNumber ?? '', p.passportCountry ?? '', + p.seatLabel ?? '', p.coachNumber ?? '', p.coachType ?? '', + ].map(v => `"${String(v).replace(/"/g, '""')}"`)); + const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); + downloadCsv(csv, `passengers-${scheduleId}.csv`); + }; + return (
@@ -61,52 +101,31 @@ export default function PassengersReportPage() {

Occupancy and passenger breakdown for a schedule

- {/* Schedule picker */} + {/* Schedule selector */}
-
- { setSearch(e.target.value); setScheduleId(''); }} - onFocus={(e) => { setSearch(e.target.value); }} - /> - {(search || scheduleId) && ( - - )} -
- {search && !scheduleId && ( -
- {filtered.length === 0 - ?

No schedules found

- : filtered.map(s => ( - - )) - } -
- )} +
- setSubmittedId(scheduleId)} disabled={!scheduleId || isLoading}> - Load Report - - {data && ( - - Export CSV - + {data && tab === 'occupancy' && ( + Export CSV + )} + {passengerList.length > 0 && tab === 'list' && ( + Export CSV )}
- {isLoading &&

Loading…

} + {(isLoading || listLoading) &&

Loading…

} {isError &&

Failed to load report.

}
@@ -122,118 +141,189 @@ export default function PassengersReportPage() {
- {/* Summary cards */} -
-
-
-

Total Seats

-
-
-

{data.summary.totalSeats}

-
-
-
-

Passengers

-
-
-

{data.summary.totalPassengers}

-
-
-
-

Occupancy Rate

-
-
-

{data.summary.occupancyRate}%

-
-
-
-
+ {/* Tabs */} +
+ {(['occupancy', 'list'] as Tab[]).map(t => ( + + ))}
- {/* By Coach */} -
-

By Coach

-
- - - - - - - - - - - - {data.byCoach.map((c) => ( - - - - - - - - ))} - -
CoachTypeSeatsBookedOccupancy
{c.coachNumber}{c.coachType}{c.totalSeats}{c.booked} + {/* Occupancy tab */} + {tab === 'occupancy' && ( +
+
+
+
+

Total Seats

+
+
+

{data.summary.totalSeats}

+
+
+
+

Passengers

+
+
+

{data.summary.totalPassengers}

+
+
+
+

Occupancy Rate

+
+
+

{data.summary.occupancyRate}%

+
+
+
+
+
+ +
+

By Coach

+
+ + + + + + + + + + {data.byCoach.map(c => ( + + + + + + + + ))} + +
CoachTypeSeatsBookedOccupancy
{c.coachNumber}{c.coachType}{c.totalSeats}{c.booked} +
+
+
+
+ {c.occupancyRate}% +
+
+
+
+ +
+
+

By Class

+
+ {data.byClass.map(c => ( +
+
+ {c.className} + {c.booked}/{c.totalSeats} +
-
+
- {c.occupancyRate}% + {c.occupancyRate}%
-
-
-
- - {/* By Class + By Origin/Destination */} -
-
-

By Class

-
- {data.byClass.map((c) => ( -
-
- {c.className} - {c.booked}/{c.totalSeats} -
-
-
-
- {c.occupancyRate}% -
+ ))}
- ))} +
+
+

By Boarding Station

+
+ {data.byOrigin.map(o => ( +
+ {o.stationName} + {o.passengers} +
+ ))} + {data.byOrigin.length === 0 &&

No data

} +
+
+
+

By Alighting Station

+
+ {data.byDestination.map(d => ( +
+ {d.stationName} + {d.passengers} +
+ ))} + {data.byDestination.length === 0 &&

No data

} +
+
+ )} -
-

By Boarding Station

-
- {data.byOrigin.map((o) => ( -
- {o.stationName} - {o.passengers} -
- ))} - {data.byOrigin.length === 0 &&

No data

} + {/* Passenger List tab */} + {tab === 'list' && ( +
+ setListSearch(e.target.value)} + /> +
+ + + + + + + + + + + + + + + {filteredList.map((p, i) => ( + + + + + + + + + + + ))} + {filteredList.length === 0 && ( + + )} + +
#NameCategoryID / PassportSeatCoachBooking RefStatus
{i + 1}{p.passengerName} + + {p.passengerCategory} + + + {p.idDocumentNumber ?? p.passportNumber ?? '—'} + {p.passportCountry && ({p.passportCountry})} + {p.seatLabel ?? '—'} + {p.coachNumber ?? '—'} + {p.coachType && ({p.coachType})} + {p.bookingRef} + + {p.bookingStatus} + +
No passengers found
- -
-

By Alighting Station

-
- {data.byDestination.map((d) => ( -
- {d.stationName} - {d.passengers} -
- ))} - {data.byDestination.length === 0 &&

No data

} -
-
-
+ )} )}
From e8408ef68451e9c6ed85b8d33adc19ffd0249310 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 18:30:03 +0300 Subject: [PATCH 16/54] Migration issue resolution job removed --- .github/workflows/deploy.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 912c6bbd7..44be07550 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -162,17 +162,6 @@ jobs: -t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ . - - name: Resolve failed migrations for ${{ matrix.service }} - if: matrix.service == 'passenger-api' - run: | - set -euo pipefail - docker run --rm --env-file "${SERVICE_ENV_FILE}" \ - --entrypoint npx \ - "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ - prisma migrate resolve \ - --applied 20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id \ - || true - - name: Run migrations for ${{ matrix.service }} if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) run: | From 82ba2df33cb941f743b13136b0bd0302c5213312 Mon Sep 17 00:00:00 2001 From: "Stephanos A." Date: Sat, 18 Jul 2026 18:43:57 +0300 Subject: [PATCH 17/54] Revert "Quickfix" From e84ab9e2b5cfbd469d871e04d3f09d431ac76899 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 18:59:02 +0300 Subject: [PATCH 18/54] Passenger list report --- .../src/modules/reports/reports.controller.ts | 12 + .../src/modules/reports/reports.service.ts | 45 ++ .../src/app/reports/passengers/page.tsx | 397 ++++++++++++------ 3 files changed, 315 insertions(+), 139 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index a4da5b208..74bad3514 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -18,6 +18,18 @@ export class ReportsController { return this.service.generateReport(dto); } + @Get('schedules') + @ApiOperation({ summary: 'List schedules for the passengers report picker' }) + listSchedulesForPicker() { + return this.service.listSchedulesForPicker(); + } + + @Get('passengers/list') + @ApiOperation({ summary: 'Flat passenger list for a specific schedule' }) + getPassengerList(@Query('scheduleId') scheduleId: string) { + return this.service.getPassengerList(scheduleId); + } + @Get('passengers') @ApiOperation({ summary: 'Passengers report for a specific schedule' }) getOccupancyReport(@Query('scheduleId') scheduleId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index f4141ecb6..785bf3c17 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -286,6 +286,51 @@ export class ReportsService { }; } + async listSchedulesForPicker() { + const schedules = await this.prisma.trainSchedule.findMany({ + select: { + id: true, + departureAt: true, + train: { select: { number: true } }, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + orderBy: { departureAt: 'desc' }, + take: 200, + }); + return schedules.map(s => ({ + id: s.id, + label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`, + })); + } + + async getPassengerList(scheduleId: string) { + const seats = await this.prisma.bookingSeat.findMany({ + where: { + scheduleId, + booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + }, + include: { + booking: { select: { bookingRef: true, status: true } }, + seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, + }, + orderBy: [{ seat: { coach: { number: 'asc' } } }], + }); + return seats.map(bs => ({ + bookingRef: bs.booking.bookingRef, + bookingStatus: bs.booking.status, + passengerName: bs.passengerName, + passengerCategory: bs.passengerCategory, + idDocumentType: bs.idDocumentType, + idDocumentNumber: bs.idDocumentNumber, + passportNumber: bs.passportNumber, + passportCountry: bs.passportCountry, + seatLabel: bs.seatLabelSnapshot, + coachNumber: bs.seat?.coach?.number ?? null, + coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + })); + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId } }); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 8b7ef240f..8c6958565 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -7,15 +7,10 @@ import { apiClient } from '@/lib/api-client'; import ActionButton from '@/components/ui/ActionButton'; import { formatDateTime } from '@/lib/utils'; +interface ScheduleOption { id: string; label: string; } + interface PassengersReport { - schedule: { - id: string; - trainName: string; - origin: string; - destination: string; - departureAt: string; - arrivalAt: string; - }; + schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; }; summary: { totalSeats: number; totalPassengers: number; occupancyRate: number }; byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[]; byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[]; @@ -23,30 +18,79 @@ interface PassengersReport { byDestination: { stationName: string; passengers: number }[]; } +interface PassengerRow { + bookingRef: string; + bookingStatus: string; + passengerName: string; + passengerCategory: string; + idDocumentType: string | null; + idDocumentNumber: string | null; + passportNumber: string | null; + passportCountry: string | null; + seatLabel: string | null; + coachNumber: string | null; + coachType: string | null; +} + +type Tab = 'occupancy' | 'list'; + export default function PassengersReportPage() { const [scheduleId, setScheduleId] = useState(''); - const [submittedId, setSubmittedId] = useState(''); + const [tab, setTab] = useState('occupancy'); + const [listSearch, setListSearch] = useState(''); + + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ['report-schedules'], + queryFn: () => apiClient.get('/reports/schedules'), + }); + const schedules = schedulesRaw ?? []; const { data, isLoading, isError } = useQuery({ - queryKey: ['passengers-report', submittedId], - queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${submittedId}`), - enabled: !!submittedId, + queryKey: ['passengers-report', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), + enabled: !!scheduleId, }); - const doExport = () => { - if (!data) return; - const rows = data.byCoach.map((c) => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); - const headers = ['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy']; - const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n'); + const { data: passengerList = [], isLoading: listLoading } = useQuery({ + queryKey: ['passengers-list', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); + + const filteredList = listSearch.trim() + ? passengerList.filter(p => + p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || + p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) || + (p.idDocumentNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()) || + (p.passportNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()), + ) + : passengerList; + + const downloadCsv = (csv: string, filename: string) => { const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = url; - a.download = `passengers-report-${submittedId}.csv`; - a.click(); + a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }; + const doExportOccupancy = () => { + if (!data) return; + const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); + downloadCsv([['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n'), `occupancy-${scheduleId}.csv`); + }; + + const doExportList = () => { + if (!passengerList.length) return; + const headers = ['Booking Ref', 'Status', 'Name', 'Category', 'ID Type', 'ID Number', 'Passport', 'Country', 'Seat', 'Coach', 'Class']; + const rows = passengerList.map(p => [ + p.bookingRef, p.bookingStatus, p.passengerName, p.passengerCategory, + p.idDocumentType ?? '', p.idDocumentNumber ?? '', p.passportNumber ?? '', + p.passportCountry ?? '', p.seatLabel ?? '', p.coachNumber ?? '', p.coachType ?? '', + ].map(v => `"${String(v).replace(/"/g, '""')}"`)); + downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`); + }; + return (
@@ -54,30 +98,32 @@ export default function PassengersReportPage() {

Occupancy and passenger breakdown for a schedule

- {/* Schedule ID input */} + {/* Schedule selector */}
-
- - + +
- setSubmittedId(scheduleId)} disabled={!scheduleId.trim() || isLoading}> - Load Report - - {data && ( - - Export CSV - + {data && tab === 'occupancy' && ( + Export CSV + )} + {passengerList.length > 0 && tab === 'list' && ( + Export CSV )}
- {isLoading &&

Loading…

} - {isError &&

Failed to load report. Check the schedule ID.

} + {(isLoading || listLoading) &&

Loading…

} + {isError &&

Failed to load report.

}
{data && ( @@ -92,121 +138,194 @@ export default function PassengersReportPage() {
- {/* Summary cards */} -
-
-
-

Total Seats

-
-
-

{data.summary.totalSeats}

-
-
-
-

Passengers

-
-
-

{data.summary.totalPassengers}

-
-
-
-

Occupancy Rate

-
-
-

{data.summary.occupancyRate}%

-
-
-
-
+ {/* Tabs */} +
+ +
- {/* By Coach */} -
-

By Coach

-
- - - - - - - - - - - - {data.byCoach.map((c) => ( - - - - - - - - ))} - -
CoachTypeSeatsBookedOccupancy
{c.coachNumber}{c.coachType}{c.totalSeats}{c.booked} + {/* Occupancy tab */} + {tab === 'occupancy' && ( +
+
+
+
+

Total Seats

+
+
+

{data.summary.totalSeats}

+
+
+
+

Passengers

+
+
+

{data.summary.totalPassengers}

+
+
+
+

Occupancy Rate

+
+
+

{data.summary.occupancyRate}%

+
+
+
+
+
+ +
+

By Coach

+
+ + + + + + + + + + + + {data.byCoach.map(c => ( + + + + + + + + ))} + +
CoachTypeSeatsBookedOccupancy
{c.coachNumber}{c.coachType}{c.totalSeats}{c.booked} +
+
+
+
+ {c.occupancyRate}% +
+
+
+
+ +
+
+

By Class

+
+ {data.byClass.map(c => ( +
+
+ {c.className} + {c.booked}/{c.totalSeats} +
-
+
- {c.occupancyRate}% + {c.occupancyRate}%
-
-
-
- - {/* By Class + By Origin/Destination */} -
- {/* By Class */} -
-

By Class

-
- {data.byClass.map((c) => ( -
-
- {c.className} - {c.booked}/{c.totalSeats} -
-
-
-
- {c.occupancyRate}% -
+ ))}
- ))} +
+
+

By Boarding Station

+
+ {data.byOrigin.map(o => ( +
+ {o.stationName} + {o.passengers} +
+ ))} + {data.byOrigin.length === 0 &&

No data

} +
+
+
+

By Alighting Station

+
+ {data.byDestination.map(d => ( +
+ {d.stationName} + {d.passengers} +
+ ))} + {data.byDestination.length === 0 &&

No data

} +
+
+ )} - {/* By Origin */} -
-

By Boarding Station

-
- {data.byOrigin.map((o) => ( -
- {o.stationName} - {o.passengers} -
- ))} - {data.byOrigin.length === 0 &&

No data

} + {/* Passenger List tab */} + {tab === 'list' && ( +
+ setListSearch(e.target.value)} + /> +
+ + + + + + + + + + + + + + + {filteredList.map((p, i) => ( + + + + + + + + + + + ))} + {filteredList.length === 0 && ( + + )} + +
#NameCategoryID / PassportSeatCoachBooking RefStatus
{i + 1}{p.passengerName} + + {p.passengerCategory} + + + {p.idDocumentNumber ?? p.passportNumber ?? '—'} + {p.passportCountry && ({p.passportCountry})} + {p.seatLabel ?? '—'} + {p.coachNumber ?? '—'} + {p.coachType && ({p.coachType})} + {p.bookingRef} + + {p.bookingStatus} + +
No passengers found
- - {/* By Destination */} -
-

By Alighting Station

-
- {data.byDestination.map((d) => ( -
- {d.stationName} - {d.passengers} -
- ))} - {data.byDestination.length === 0 &&

No data

} -
-
-
+ )} )}
From 6ddcc738473b91033723030b50f3fad1a7ed4886 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 19:12:02 +0300 Subject: [PATCH 19/54] Boarding icon added to dashboard --- .../backoffice/src/app/dashboard/page.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index bc657106d..6d1e19649 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; -import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react'; +import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react'; import { dashboardApi } from '@/lib/api/dashboard'; import { apiClient } from '@/lib/api-client'; import { formatCurrency } from '@/lib/utils'; @@ -143,9 +143,18 @@ function DashboardPageContent() { return (
-
-

Dashboard

-

Welcome back! Here's your operational summary.

+
+
+

Dashboard

+

Welcome back! Here's your operational summary.

+
+ + + Boarding +
{statsError && ( From 554baf2595b11a82d023ac21b893bc43f7116dbd Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 19:51:27 +0300 Subject: [PATCH 20/54] Build issue resolution --- apps/edr-passenger-api/src/modules/reports/reports.service.ts | 3 ++- .../backoffice/src/app/reports/passengers/page.tsx | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 785bf3c17..57c5db895 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -95,7 +95,7 @@ export class ReportsService { where: { departureAt: { gte: dateFrom, lte: dateTo } }, include: { coachAssignments: { include: { coach: { include: { seats: true } } } }, - bookings: { include: { seats: true } }, + bookings: { include: { seats: { where: { scheduleId: schedule.id } } } }, }, }); @@ -212,6 +212,7 @@ export class ReportsService { where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, include: { seats: { + where: { scheduleId }, include: { seat: { include: { coach: { include: { coachType: true } } } }, }, diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index d80110aa7..8c6958565 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -9,10 +9,7 @@ import { formatDateTime } from '@/lib/utils'; interface ScheduleOption { id: string; label: string; } -interface ScheduleOption { id: string; label: string; } - interface PassengersReport { - schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; }; schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; }; summary: { totalSeats: number; totalPassengers: number; occupancyRate: number }; byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[]; From 4805a54bf7bc1c1c861120781922f6f39de58fdf Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 20:16:24 +0300 Subject: [PATCH 21/54] Report figures correction updates --- .../modules/dashboard/dashboard.service.ts | 4 +- .../src/modules/reports/reports.service.ts | 9 ++++- .../src/modules/seats/seats.controller.ts | 10 +++++ .../src/modules/seats/seats.service.ts | 20 ++++++++++ .../backoffice/src/app/dashboard/page.tsx | 15 +------- .../backoffice/src/app/reports/seats/page.tsx | 38 +++++++++++++++++-- .../backoffice/src/lib/api/index.ts | 1 + 7 files changed, 77 insertions(+), 20 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index aed950b73..155391814 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -13,8 +13,8 @@ export class DashboardService { async getBackofficeStats() { const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] = await Promise.all([ - this.prisma.booking.count(), - this.prisma.booking.count({ where: { packageId: { not: null } } }), + this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }), + this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }), this.prisma.ticket.count(), this.prisma.passenger.count(), this.prisma.$queryRaw<{ currency: string; total: bigint }[]>` diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 57c5db895..0da670ec7 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -95,13 +95,18 @@ export class ReportsService { where: { departureAt: { gte: dateFrom, lte: dateTo } }, include: { coachAssignments: { include: { coach: { include: { seats: true } } } }, - bookings: { include: { seats: { where: { scheduleId: schedule.id } } } }, + bookings: { + where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + include: { seats: true }, + }, }, }); const tripData = schedules.map(schedule => { const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0); - const bookedSeats = schedule.bookings.reduce((sum, b) => sum + b.seats.length, 0); + const bookedSeats = schedule.bookings.reduce( + (sum, b) => sum + b.seats.filter((s: any) => s.scheduleId === schedule.id).length, 0, + ); const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0; return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) }; }); diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 596c88bb3..a302a01ba 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -29,6 +29,16 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; export class SeatsController { constructor(private service: SeatsService) {} + // ── Blocked Seats ───────────────────────────────────────────────────────── + @Get('blocks') + @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List all blocked seats with reason and coach info' }) + @ApiResponse({ status: 200, description: 'Blocked seat records' }) + getBlockedSeats() { + return this.service.getBlockedSeats(); + } + // ── Coach Availability ──────────────────────────────────────────────────── @Get('coaches/:scheduleId') @SetMetadata('isPublic', true) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 10368f380..6d5301cfb 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -598,6 +598,26 @@ export class SeatsService { await this.prisma.journey.deleteMany({ where: { bookingId } as any }); } + async getBlockedSeats() { + const blocks = await this.prisma.seatBlock.findMany({ + include: { + seat: { include: { coach: { select: { number: true } } } }, + }, + orderBy: { blockedAt: 'desc' }, + }); + return blocks.map(b => ({ + id: b.id, + seatId: b.seatId, + seatNumber: b.seat.seatNumber, + coachNumber: b.seat.coach.number, + scheduleId: b.scheduleId, + reason: b.reason, + blockedBy: b.blockedBy, + blockedAt: b.blockedAt, + unblockAt: b.unblockAt, + })); + } + async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 6d1e19649..8c2b38570 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -170,7 +170,7 @@ function DashboardPageContent() { )} {/* Stat cards */} -
+
} iconBg="bg-blue-100 dark:bg-blue-900/30" @@ -183,18 +183,7 @@ function DashboardPageContent() { { label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' }, ]} /> - } - iconBg="bg-emerald-100 dark:bg-emerald-900/30" - label="Tickets" - total={stats?.totalTickets ?? 0} - loading={statsLoading} - href="/tickets" - rows={[ - { label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' }, - { label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' }, - ]} - /> + {/* Tickets card hidden temporarily */} {/* Revenue card */}
diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index ea2d65cf0..196e83c46 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -2,8 +2,8 @@ import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download, Armchair, CheckCircle, Clock, AlertCircle } from 'lucide-react'; -import { bookingsApi } from '@/lib/api'; +import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react'; +import { bookingsApi, seatsApi } from '@/lib/api'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import { formatDateTime, formatCurrency } from '@/lib/utils'; @@ -46,6 +46,11 @@ export default function SeatStatusReportPage() { const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL'); const [search, setSearch] = useState(''); + const { data: blockedSeats = [] } = useQuery({ + queryKey: ['blocked-seats'], + queryFn: () => seatsApi.getBlocked().then((r: any) => Array.isArray(r) ? r : r?.data ?? []), + }); + const { data: bookingsData, isLoading } = useQuery({ queryKey: ['seat-report-bookings'], queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), @@ -150,7 +155,7 @@ export default function SeatStatusReportPage() {
{/* Summary Cards */} -
+
@@ -189,6 +194,33 @@ export default function SeatStatusReportPage() {
+ +
+
+
+

Blocked Seats

+

+ {blockedSeats.length} +

+

Manually blocked

+
+ +
+ {blockedSeats.length > 0 && ( +
+ {blockedSeats.map((b: any) => ( +
+ + Seat {b.seatNumber} · Coach {b.coachNumber} + + + {b.reason} + +
+ ))} +
+ )} +
{/* Filters */} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index e8ed56195..07ebefa56 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -151,6 +151,7 @@ export const seatsApi = { return apiClient.get(`/seats/seatmap/${scheduleId}${params}`); }, getBySchedule: (scheduleId: string) => apiClient.get(`/seats/schedule/${scheduleId}`), + getBlocked: () => apiClient.get('/seats/blocks'), hold: (data: any) => apiClient.post('/seats/hold', data), release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`), block: (seatId: string, data: any) => apiClient.post(`/seats/${seatId}/block`, data), From c1858e76d4ba17cdf240bc26e249e6aeb23bbee1 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sat, 18 Jul 2026 21:51:54 +0300 Subject: [PATCH 22/54] Added stops departure and arrival datetime --- .../migration.sql | 2 + .../migration.sql | 4 + apps/edr-passenger-api/prisma/schema.prisma | 2 + .../src/modules/packages/packages.dto.ts | 9 + .../src/modules/packages/packages.service.ts | 27 +- .../src/modules/schedules/routes.dto.ts | 4 + .../src/modules/schedules/routes.service.ts | 6 + .../src/modules/schedules/schedules.dto.ts | 3 + .../modules/schedules/schedules.service.ts | 147 +++++-- .../src/modules/tasks/tasks.service.ts | 73 ++++ .../edr-passenger-web/backoffice/package.json | 1 + .../backoffice/src/app/routes/page.tsx | 82 +++- .../backoffice/src/app/schedules/page.tsx | 377 ++++++++++++++++-- .../src/components/layout/Sidebar.tsx | 14 +- .../src/components/ui/DateTimePicker.tsx | 358 +++++++++++++++++ pnpm-lock.yaml | 11 + 16 files changed, 1026 insertions(+), 94 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql create mode 100644 apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx diff --git a/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql new file mode 100644 index 000000000..012ec84db --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" INTEGER; +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" INTEGER; diff --git a/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql new file mode 100644 index 000000000..4ff235be2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "passenger"."RouteStop" DROP COLUMN "plannedArrivalTime"; +ALTER TABLE "passenger"."RouteStop" DROP COLUMN "plannedDepartureTime"; +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" TIMESTAMP(3); +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index fb9db346f..838ee8826 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1050,6 +1050,8 @@ model RouteStop { sequence Int distanceKm Float? checkinMinutesBefore Int? + plannedArrivalTime DateTime? + plannedDepartureTime DateTime? createdAt DateTime @default(now()) route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index d7257f179..95b3a916b 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -128,4 +128,13 @@ export class BookPackageDto { /** Number of child passengers (<5 years). Derived from passengers array if omitted. */ @ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number; + + /** + * SeatHold UUID returned by POST /seats/hold when the user selected seats on the + * seatmap before proceeding to book. When provided, the hold's expiry is extended + * to the payment deadline so the specific seat stays reserved on the seatmap for + * the full payment window, matching the behaviour of normal bookings. + */ + @ApiPropertyOptional({ description: 'SeatHold ID from seatmap selection' }) + @IsOptional() @IsUUID() holdId?: string; } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 9ddc1fb0a..43d96f86c 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -6,6 +6,7 @@ import { Currency } from '@prisma/client'; import { BookingsService } from '../bookings/bookings.service'; import { GuestBookingService } from '../bookings/guest-booking.service'; import { AuditService } from '../../common/audit.service'; +import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; @@ -382,7 +383,10 @@ export class PackagesService { async book(dto: BookPackageDto, passengerId?: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id: dto.packageId }, - include: { priceTiers: true }, + include: { + priceTiers: true, + outboundSchedule: { select: { departureAt: true, route: { select: { checkinMinutesBefore: true } } } }, + }, }); if (!pkg) throw new NotFoundException('Package not found'); if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking'); @@ -477,6 +481,27 @@ export class PackagesService { ]); }); + // Extend the seatmap SeatHold (if one was passed) to the payment deadline so the + // specific seat remains visually reserved on the seatmap during the full payment + // window — matching the behaviour of normal bookings (which call confirmSeats). + if (dto.holdId) { + const dep = (pkg as any).outboundSchedule?.departureAt as Date | undefined; + if (dep) { + const checkinMinutes = (pkg as any).outboundSchedule?.route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(booking.createdAt as Date, dep, checkinMinutes); + const hold = await this.prisma.seatHold.findUnique({ + where: { id: dto.holdId }, + select: { expiresAt: true }, + }); + if (hold && paymentDeadline > hold.expiresAt) { + await this.prisma.seatHold.update({ + where: { id: dto.holdId }, + data: { expiresAt: paymentDeadline }, + }); + } + } + } + return { ...booking, fareBreakdown: { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index f2db36e7c..fefa26ad1 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -7,6 +7,8 @@ export class RouteStopInputDto { @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for first stop.' }) @IsOptional() @IsDateString() plannedArrivalTime?: string; + @ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for last stop.' }) @IsOptional() @IsDateString() plannedDepartureTime?: string; } export class CreateRouteDto { @@ -37,6 +39,8 @@ export class AddRouteStopDto { @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedArrivalTime?: string; + @ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedDepartureTime?: string; } export class UpdateRouteDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 58e647180..dcbdbc3b0 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -37,6 +37,8 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + plannedArrivalTime: s.plannedArrivalTime ? new Date(s.plannedArrivalTime) : null, + plannedDepartureTime: s.plannedDepartureTime ? new Date(s.plannedDepartureTime) : null, })), }, }, @@ -106,6 +108,8 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + plannedArrivalTime: s.plannedArrivalTime ?? null, + plannedDepartureTime: s.plannedDepartureTime ?? null, })), }); } @@ -225,6 +229,8 @@ export class RoutesService { sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, + plannedArrivalTime: dto.plannedArrivalTime ?? null, + plannedDepartureTime: dto.plannedDepartureTime ?? null, }, }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 675168cf9..f56883464 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -60,6 +60,9 @@ export class UpdateScheduleDto { @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; + @ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Planned times per stop — when provided, replaces all existing stop times for the schedule' }) + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) + plannedTimes?: PlannedStopTimeDto[]; } export class UpdateStopTimeDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 7cca548aa..ecdf51666 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -133,26 +133,62 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + const hasRouteTimes = route.stops.some( + s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, + ); - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + if (hasRouteTimes) { + // Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date. + const EAT_MS = 3 * 60 * 60 * 1000; + const depEATMs = dep.getTime() + EAT_MS; + const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); + const eatMidnightUTC = dep.getTime() - depMsIntoDay; + + const templateToScheduleUTC = (templateDt: Date): Date => { + // Pull the time-of-day in EAT from the template DateTime + const templateEATMs = templateDt.getTime() + EAT_MS; + const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); + const candidate = new Date(eatMidnightUTC + timeOfDayMs); + // Overnight: if the stop time lands before departure, move to next day + if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); + return candidate; }; - }); + + plannedTimes = route.stops.map((stop, index) => { + const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null; + const depDt: Date | null = (stop as any).plannedDepartureTime ?? null; + return { + sequence: stop.sequence, + plannedArrivalAt: index > 0 && arrDt != null + ? templateToScheduleUTC(arrDt).toISOString() + : undefined, + plannedDepartureAt: index < route.stops.length - 1 && depDt != null + ? templateToScheduleUTC(depDt).toISOString() + : undefined, + }; + }); + } else { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + plannedTimes = route.stops.map((stop, index) => { + let stopTime: Date; + if (index === 0) { + stopTime = dep; + } else if (index === route.stops.length - 1) { + stopTime = arr; + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + stopTime = new Date(dep.getTime() + totalDuration * progress); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + }; + }); + } } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -304,26 +340,59 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + const hasRouteTimes = route.stops.some( + s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, + ); - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + if (hasRouteTimes) { + const EAT_MS = 3 * 60 * 60 * 1000; + const depEATMs = dep.getTime() + EAT_MS; + const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); + const eatMidnightUTC = dep.getTime() - depMsIntoDay; + + const templateToScheduleUTC = (templateDt: Date): Date => { + const templateEATMs = templateDt.getTime() + EAT_MS; + const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); + const candidate = new Date(eatMidnightUTC + timeOfDayMs); + if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); + return candidate; }; - }); + + plannedTimes = route.stops.map((stop, index) => { + const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null; + const depDt: Date | null = (stop as any).plannedDepartureTime ?? null; + return { + sequence: stop.sequence, + plannedArrivalAt: index > 0 && arrDt != null + ? templateToScheduleUTC(arrDt).toISOString() + : undefined, + plannedDepartureAt: index < route.stops.length - 1 && depDt != null + ? templateToScheduleUTC(depDt).toISOString() + : undefined, + }; + }); + } else { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + plannedTimes = route.stops.map((stop, index) => { + let stopTime: Date; + if (index === 0) { + stopTime = dep; + } else if (index === route.stops.length - 1) { + stopTime = arr; + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + stopTime = new Date(dep.getTime() + totalDuration * progress); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + }; + }); + } } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -678,6 +747,12 @@ export class SchedulesService { } } + if (dto.plannedTimes && dto.plannedTimes.length > 0 && schedule.routeId) { + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); + const plannedTimesMap = Object.fromEntries(dto.plannedTimes.map(t => [t.sequence, t])); + await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); + } + return this.getSchedule(id); } diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 2355485d4..4767c7eba 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -146,6 +146,7 @@ export class TasksService { await Promise.all([ this.sendPaymentReminders(now), this.cancelExpiredPendingBookings(now), + this.cancelExpiredPendingPackageBookings(now), ]); } @@ -333,6 +334,78 @@ export class TasksService { } } + // ── Cancel PackageBookings whose payment deadline has passed ────────────── + private async cancelExpiredPendingPackageBookings(now: Date) { + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + + const expiredBookings = await this.prisma.packageBooking.findMany({ + where: { + status: 'PENDING_PAYMENT', + OR: [ + { createdAt: { lte: twoHoursAgo } }, + { package: { outboundSchedule: { departureAt: { lte: departureCutoff } } } }, + ], + }, + include: { + package: { + include: { + outboundSchedule: { + include: { route: { select: { checkinMinutesBefore: true } } }, + }, + }, + }, + }, + }); + + let cancelledCount = 0; + + for (const booking of expiredBookings) { + try { + const createdAt = booking.createdAt as Date; + const dep = (booking.package as any).outboundSchedule.departureAt as Date; + const checkinMinutes = (booking.package as any).outboundSchedule.route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); + if (now < paymentDeadline) continue; + + // Revert the tier's seat counters that were incremented when the booking was created. + const seatsReserved = booking.adultCount + Math.max(0, booking.childCount - booking.adultCount); + await this.prisma.packagePriceTier.update({ + where: { id: booking.priceTierId }, + data: { + bookedSeats: { decrement: seatsReserved }, + availableSeats: { increment: seatsReserved }, + }, + }); + + await this.prisma.packageBooking.update({ + where: { id: booking.id }, + data: { status: 'CANCELLED' }, + }); + + const message = + `EDR: Your package booking ${booking.bookingRef} ` + + `(departs ${fmtTime(dep)}) has been cancelled ` + + `because payment was not completed by ${fmtTime(paymentDeadline)}.`; + + if (booking.contactPhone) { + await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); + } + + this.logger.log(`Auto-cancelled package booking: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`); + cancelledCount++; + } catch (err) { + this.logger.error( + `Auto-cancel failed for package booking ${(booking as any).bookingRef}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (cancelledCount > 0) { + this.logger.log(`Auto-cancelled ${cancelledCount} expired pending package booking(s)`); + } + } + // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index 858fcd781..9d4327a42 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -19,6 +19,7 @@ "lucide-react": "^0.446.0", "next": "^14.2.0", "react": "^18.3.1", + "react-day-picker": "^9.14.0", "react-dom": "^18.3.1", "recharts": "^2.12.0", "socket.io-client": "^4.8.3", diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index feca7e3b5..5fa655a5e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -10,6 +10,14 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import DateTimePicker from '@/components/ui/DateTimePicker'; + +// EAT ↔ UTC helpers (same as schedules page) +const EAT_MS = 3 * 60 * 60 * 1000; +const isoToEAT = (iso: string): string => + new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16); +const eatToISO = (local: string): string => + new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString(); interface RouteStop { stationId: string; @@ -17,6 +25,8 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; + plannedArrivalTime?: string; + plannedDepartureTime?: string; } type Tab = 'routes' | 'coaches'; @@ -175,6 +185,8 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); + const [originDepartureTime, setOriginDepartureTime] = useState(''); + const [destinationArrivalTime, setDestinationArrivalTime] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); const queryClient = useQueryClient(); @@ -245,18 +257,27 @@ export default function RoutesPage() { // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) const stopsArray = [ - { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, + { + stationId: originStationId, + sequence: 1, + distanceKm: 0, + checkinMinutesBefore: originCheckinMinutes ?? undefined, + plannedDepartureTime: originDepartureTime ? eatToISO(originDepartureTime) : undefined, + }, ...sortedMiddleStops.map((stop, idx) => ({ stationId: stop.stationId, sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, + plannedArrivalTime: stop.plannedArrivalTime ? eatToISO(stop.plannedArrivalTime) : undefined, + plannedDepartureTime: stop.plannedDepartureTime ? eatToISO(stop.plannedDepartureTime) : undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, checkinMinutesBefore: destinationCheckinMinutes ?? undefined, + plannedArrivalTime: destinationArrivalTime ? eatToISO(destinationArrivalTime) : undefined, }, ]; @@ -387,8 +408,10 @@ export default function RoutesPage() { const destStop = routeStops[routeStops.length - 1]; setOriginStationId(originStop.stationId); setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined); + setOriginDepartureTime(originStop.plannedDepartureTime ? isoToEAT(originStop.plannedDepartureTime) : ''); setDestinationStationId(destStop.stationId); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); + setDestinationArrivalTime(destStop.plannedArrivalTime ? isoToEAT(destStop.plannedArrivalTime) : ''); setDestinationDistance(destStop.distanceKm || 0); setStops(routeStops.slice(1, -1).map((s: any) => ({ stationId: s.stationId, @@ -396,6 +419,8 @@ export default function RoutesPage() { distanceKm: s.distanceKm, distanceFromOrigin: s.distanceKm || 0, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + plannedArrivalTime: s.plannedArrivalTime ? isoToEAT(s.plannedArrivalTime) : '', + plannedDepartureTime: s.plannedDepartureTime ? isoToEAT(s.plannedDepartureTime) : '', }))); } setShowModal(true); @@ -433,8 +458,10 @@ export default function RoutesPage() { setEditingRoute(null); setOriginStationId(''); setOriginCheckinMinutes(undefined); + setOriginDepartureTime(''); setDestinationStationId(''); setDestinationCheckinMinutes(undefined); + setDestinationArrivalTime(''); setDestinationDistance(undefined); setStops([]); setShowModal(true); @@ -519,7 +546,7 @@ export default function RoutesPage() { setSearch(''); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} - size="lg" + size="xl" >
{editingRoute && ( @@ -665,7 +692,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit) + Drag to rearrange · Cutoff overrides check-in · Arr/Dep time sets default times (auto-filled on schedule creation)
@@ -683,7 +710,7 @@ export default function RoutesPage() { Select origin station above )}
-
+
-
0 km
+
+ +
+
0 km
{stops.map((stop, index) => ( @@ -729,7 +764,7 @@ export default function RoutesPage() { ))}
-
+
-
+
updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)} min={1} title="Check-in cutoff override (minutes) for this stop" />
+
+ updateStop(index, 'plannedDepartureTime', v)} + placeholder="Dep time" + label="Planned Departure" + /> +
+
+ updateStop(index, 'plannedArrivalTime', v)} + placeholder="Arr time" + label="Planned Arrival" + /> +
-
+
{destinationStationId && ( )}
-
+
+
+ {destinationStationId && ( + + )} +
+
{destinationStationId && ( + new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16); +// EAT "YYYY-MM-DDTHH:mm" → UTC ISO string for API submission +const eatToISO = (local: string): string => + new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString(); +// Extract HH:mm in EAT from a UTC ISO datetime (e.g. route stop planned time) +const isoToEATTimePart = (iso: string): string | null => { + if (!iso) return null; + const eatMs = new Date(iso).getTime() + EAT_MS; + const msIntoDay = eatMs % (24 * 60 * 60 * 1000); + const h = Math.floor(msIntoDay / 3600000); + const m = Math.floor((msIntoDay % 3600000) / 60000); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +}; + interface Schedule { id: string; trainId: string; @@ -24,6 +43,13 @@ interface Schedule { destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; + stopTimes?: Array<{ + sequence: number; + stationId: string; + plannedDepartureAt: string | null; + plannedArrivalAt: string | null; + station?: { name: string }; + }>; } interface Train { @@ -72,6 +98,8 @@ export default function SchedulesPage() { const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); + const [addStopTimes, setAddStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]); + const [editStopTimes, setEditStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]); const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({ queryKey: ['route-coaches', addForm.routeId], @@ -79,12 +107,42 @@ export default function SchedulesPage() { enabled: !!addForm.routeId, }); + const { data: addRouteDetail } = useQuery({ + queryKey: ['route-detail', addForm.routeId], + queryFn: () => apiClient.get(`/routes/${addForm.routeId}`), + enabled: !!addForm.routeId, + }); + + const { data: editRouteDetail } = useQuery({ + queryKey: ['route-detail', editingSchedule?.routeId], + queryFn: () => apiClient.get(`/routes/${editingSchedule!.routeId}`), + enabled: !!editingSchedule?.routeId, + }); + useEffect(() => { if (!addForm.routeId) { setAddCoachRows([]); return; } const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? []; setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []); }, [singleRouteTemplate, addForm.routeId]); + useEffect(() => { + const stops: any[] = (addRouteDetail as any)?.stops ?? []; + if (!stops.length) { setAddStopTimes([]); return; } + + const eatDateStr = addForm.departureAt ? addForm.departureAt.slice(0, 10) : null; + + setAddStopTimes(stops.map((s: any) => { + const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null; + const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null; + return { + sequence: s.sequence, + stationName: s.station?.name ?? `Stop ${s.sequence}`, + plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '', + plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', + }; + })); + }, [addRouteDetail, addForm.departureAt]); + // Fetch route coach template when route changes const { data: routeTemplate, isLoading: templateLoading } = useQuery({ queryKey: ['route-coaches', bulkForm.routeId], @@ -110,6 +168,24 @@ export default function SchedulesPage() { isPackageOnly: false, }); + useEffect(() => { + const stops: any[] = (editRouteDetail as any)?.stops ?? []; + if (!stops.length || !editingSchedule) return; + const hasRouteTimes = stops.some((s: any) => s.plannedArrivalTime || s.plannedDepartureTime); + if (!hasRouteTimes) return; + const eatDateStr = editForm.departureAt ? editForm.departureAt.slice(0, 10) : null; + setEditStopTimes(stops.map((s: any) => { + const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null; + const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null; + return { + sequence: s.sequence, + stationName: s.station?.name ?? `Stop ${s.sequence}`, + plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '', + plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', + }; + })); + }, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); + const [filters, setFilters] = useState({ search: '', trainId: '', @@ -175,6 +251,7 @@ export default function SchedulesPage() { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); + setAddStopTimes([]); setError(null); }, onError: (err: any) => { @@ -189,6 +266,7 @@ export default function SchedulesPage() { queryClient.invalidateQueries({ queryKey: ['schedules'] }); setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }, onError: (err: any) => { @@ -255,15 +333,30 @@ export default function SchedulesPage() { const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - const dep = new Date(addForm.departureAt); - const arr = new Date(addForm.arrivalAt); - if (arr <= dep) { setError('Arrival must be after departure'); return; } + if (!addForm.departureAt || !addForm.arrivalAt) { + setError('Please select departure and arrival date & time'); + return; + } + if (new Date(addForm.arrivalAt + ':00Z') <= new Date(addForm.departureAt + ':00Z')) { + setError('Arrival must be after departure'); return; + } + + const filledStops = addStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt); + const plannedTimes = filledStops.length === addStopTimes.length && addStopTimes.length > 0 + ? addStopTimes.map(s => ({ + sequence: s.sequence, + ...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}), + ...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}), + })) + : undefined; + const validCoaches = addCoachRows.filter((r) => r.coachId); await createScheduleMutation.mutateAsync({ trainId: addForm.trainId, routeId: addForm.routeId, - departureAt: dep.toISOString(), - arrivalAt: arr.toISOString(), + departureAt: eatToISO(addForm.departureAt), + arrivalAt: eatToISO(addForm.arrivalAt), + ...(plannedTimes ? { plannedTimes } : {}), ...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }), }); }; @@ -274,24 +367,35 @@ export default function SchedulesPage() { if (!editingSchedule) return; - // Convert local datetime-local values to UTC for API - const depLocal = new Date(editForm.departureAt); - const arrLocal = new Date(editForm.arrivalAt); - - if (arrLocal <= depLocal) { + if (!editForm.departureAt || !editForm.arrivalAt) { + setError('Please select departure and arrival date & time'); + return; + } + + if (new Date(editForm.arrivalAt + ':00Z') <= new Date(editForm.departureAt + ':00Z')) { setError('Arrival time must be after departure time'); return; } + const filledEditStops = editStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt); + const editPlannedTimes = filledEditStops.length === editStopTimes.length && editStopTimes.length > 0 + ? editStopTimes.map(s => ({ + sequence: s.sequence, + ...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}), + ...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}), + })) + : undefined; + const payload: any = { - departureAt: depLocal.toISOString(), - arrivalAt: arrLocal.toISOString(), + departureAt: eatToISO(editForm.departureAt), + arrivalAt: eatToISO(editForm.arrivalAt), status: editForm.status, isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, })), + ...(editPlannedTimes ? { plannedTimes: editPlannedTimes } : {}), }; await updateScheduleMutation.mutateAsync({ @@ -327,26 +431,26 @@ export default function SchedulesPage() { const handleEditClick = (schedule: Schedule) => { setEditingSchedule(schedule); - // Convert UTC dates to local time for datetime-local input - // datetime-local expects local time (no timezone info) - const dep = new Date(schedule.departureAt); - const arr = new Date(schedule.arrivalAt); - - // Convert to local time by adding the timezone offset - const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000); - const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000); - - // Format for datetime-local input (YYYY-MM-DDTHH:mm) - const depStr = depLocal.toISOString().slice(0, 16); - const arrStr = arrLocal.toISOString().slice(0, 16); - setEditForm({ - departureAt: depStr, - arrivalAt: arrStr, + departureAt: isoToEAT(schedule.departureAt), + arrivalAt: isoToEAT(schedule.arrivalAt), status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], isPackageOnly: schedule.isPackageOnly ?? false, }); + + if (schedule.stopTimes && schedule.stopTimes.length > 0) { + const toDatetimeLocal = (iso: string | null) => iso ? isoToEAT(iso) : ''; + setEditStopTimes(schedule.stopTimes.map(st => ({ + sequence: st.sequence, + stationName: st.station?.name ?? `Stop ${st.sequence}`, + plannedArrivalAt: toDatetimeLocal(st.plannedArrivalAt), + plannedDepartureAt: toDatetimeLocal(st.plannedDepartureAt), + }))); + } else { + setEditStopTimes([]); + } + setError(null); setShowEditModal(true); }; @@ -672,7 +776,7 @@ export default function SchedulesPage() { { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }} + onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setAddStopTimes([]); setError(null); }} title="Add Schedule" size="lg" > @@ -699,14 +803,114 @@ export default function SchedulesPage() {
- setAddForm({ ...addForm, departureAt: e.target.value })} required /> + setAddForm({ ...addForm, departureAt: v })} + placeholder="Select departure" + />
- setAddForm({ ...addForm, arrivalAt: e.target.value })} required /> + setAddForm({ ...addForm, arrivalAt: v })} + placeholder="Select arrival" + />
+ {addStopTimes.length > 0 && ( +
+
+
+ +

+ Set planned times for each stop. Leave all blank to auto-generate from distance. +

+
+ +
+
+ + + + + + + + + + + {addStopTimes.map((stop, i) => { + const isFirst = i === 0; + const isLast = i === addStopTimes.length - 1; + return ( + + + + + + + ); + })} + +
#StationPlanned ArrivalPlanned Departure
{stop.sequence}{stop.stationName} + {isFirst ? ( + + ) : ( + { + const updated = [...addStopTimes]; + updated[i] = { ...updated[i], plannedArrivalAt: v }; + setAddStopTimes(updated); + }} + placeholder="Pick arrival" + /> + )} + + {isLast ? ( + + ) : ( + { + const updated = [...addStopTimes]; + updated[i] = { ...updated[i], plannedDepartureAt: v }; + setAddStopTimes(updated); + }} + placeholder="Pick departure" + /> + )} +
+
+
+ )} +
@@ -1006,6 +1210,7 @@ export default function SchedulesPage() { onClose={() => { setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }} title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`} @@ -1022,23 +1227,19 @@ export default function SchedulesPage() {
- setEditForm({ ...editForm, departureAt: e.target.value })} - className="input" - required + onChange={(v) => setEditForm({ ...editForm, departureAt: v })} + placeholder="Select departure" />
- setEditForm({ ...editForm, arrivalAt: e.target.value })} - className="input" - required + onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })} + placeholder="Select arrival" />
@@ -1072,6 +1273,101 @@ export default function SchedulesPage() {
+ {editStopTimes.length > 0 && ( +
+
+
+ +

+ Edit planned times for each stop. All stops must be filled to update. +

+
+ +
+
+ + + + + + + + + + + {editStopTimes.map((stop, i) => { + const isFirst = i === 0; + const isLast = i === editStopTimes.length - 1; + return ( + + + + + + + ); + })} + +
#StationPlanned ArrivalPlanned Departure
{stop.sequence}{stop.stationName} + {isFirst ? ( + + ) : ( + { + const updated = [...editStopTimes]; + updated[i] = { ...updated[i], plannedArrivalAt: v }; + setEditStopTimes(updated); + }} + placeholder="Pick arrival" + /> + )} + + {isLast ? ( + + ) : ( + { + const updated = [...editStopTimes]; + updated[i] = { ...updated[i], plannedDepartureAt: v }; + setEditStopTimes(updated); + }} + placeholder="Pick departure" + /> + )} +
+
+
+ )} +
@@ -1129,6 +1425,7 @@ export default function SchedulesPage() { onClick={() => { setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }} > diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 62fa655c7..0f9666695 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -80,13 +80,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view }, - { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view }, - { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view }, - { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, - { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, - { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, + { name: 'Stations', href: '/stations', icon: MapPin }, + { name: 'Trains', href: '/trains', icon: Train }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, + { name: 'Seats', href: '/seats', icon: Armchair }, + { name: 'Classes', href: '/classes', icon: Settings }, + { name: 'Routes', href: '/routes', icon: Route }, + { name: 'Schedules', href: '/schedules', icon: Calendar }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx new file mode 100644 index 000000000..6e71fc268 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { DayPicker } from 'react-day-picker'; +import { ChevronLeft, ChevronRight, Calendar, ChevronUp, ChevronDown, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface DateTimePickerProps { + value: string; // YYYY-MM-DDTHH:mm (datetime-local format) + onChange: (value: string) => void; + required?: boolean; + id?: string; + placeholder?: string; + label?: string; +} + +function parseLocalString(s: string) { + if (!s) return null; + const [datePart, timePart] = s.split('T'); + if (!datePart || !timePart) return null; + const [yyyy, mm, dd] = datePart.split('-').map(Number); + const [h, m] = timePart.split(':').map(Number); + if (isNaN(yyyy) || isNaN(mm) || isNaN(dd) || isNaN(h) || isNaN(m)) return null; + const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM'; + const hours12 = h % 12 === 0 ? 12 : h % 12; + const date = new Date(yyyy, mm - 1, dd); + return { date, hours12, minutes: m, period }; +} + +function toLocalString(date: Date, hours12: number, minutes: number, period: 'AM' | 'PM') { + let h = hours12 % 12; + if (period === 'PM') h += 12; + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(h).padStart(2, '0'); + const min = String(minutes).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}T${hh}:${min}`; +} + +function formatDisplay(parsed: ReturnType): string { + if (!parsed) return ''; + const { date, hours12, minutes, period } = parsed; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const dateStr = `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`; + const timeStr = `${String(hours12).padStart(2, '0')}:${String(minutes).padStart(2, '0')} ${period}`; + return `${dateStr} ${timeStr}`; +} + +export default function DateTimePicker({ + value, + onChange, + id, + placeholder = 'Select date & time', + label, +}: DateTimePickerProps) { + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { setMounted(true); }, []); + + const parsed = parseLocalString(value); + const [selectedDate, setSelectedDate] = useState(parsed?.date); + const [hours12, setHours12] = useState(parsed?.hours12 ?? 12); + const [minutes, setMinutes] = useState(parsed?.minutes ?? 0); + const [period, setPeriod] = useState<'AM' | 'PM'>(parsed?.period ?? 'AM'); + + // Sync internal state when value changes externally + useEffect(() => { + const p = parseLocalString(value); + if (p) { + setSelectedDate(p.date); + setHours12(p.hours12); + setMinutes(p.minutes); + setPeriod(p.period); + } + }, [value]); + + // Close on Escape + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open]); + + const emit = useCallback( + (date: Date | undefined, h: number, m: number, p: 'AM' | 'PM') => { + if (!date) return; + onChange(toLocalString(date, h, m, p)); + }, + [onChange], + ); + + const handleDaySelect = (date: Date | undefined) => { + setSelectedDate(date); + if (date) emit(date, hours12, minutes, period); + }; + + const cycleHour = (dir: 1 | -1) => { + const next = hours12 + dir; + const h = next > 12 ? 1 : next < 1 ? 12 : next; + setHours12(h); + emit(selectedDate, h, minutes, period); + }; + + const cycleMinute = (dir: 1 | -1) => { + const next = minutes + dir; + const m = next > 59 ? 0 : next < 0 ? 59 : next; + setMinutes(m); + emit(selectedDate, hours12, m, period); + }; + + const togglePeriod = (p: 'AM' | 'PM') => { + setPeriod(p); + emit(selectedDate, hours12, minutes, p); + }; + + const handleHourInput = (raw: string) => { + const h = parseInt(raw); + if (isNaN(h)) return; + const clamped = Math.max(1, Math.min(12, h)); + setHours12(clamped); + emit(selectedDate, clamped, minutes, period); + }; + + const handleMinuteInput = (raw: string) => { + const m = parseInt(raw); + if (isNaN(m)) return; + const clamped = Math.max(0, Math.min(59, m)); + setMinutes(clamped); + emit(selectedDate, hours12, clamped, period); + }; + + const modal = open && mounted ? createPortal( +
+ {/* Backdrop */} +
setOpen(false)} + /> + + {/* Panel */} +
+ {/* Header */} +
+

+ {label ?? placeholder} +

+ +
+ + {/* Calendar */} + + orientation === 'left' ? ( + + ) : ( + + ), + DayButton: ({ day, modifiers, className, ...props }) => ( + + handleHourInput(e.target.value)} + onFocus={e => e.target.select()} + className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ + : + + {/* Minute spinner */} +
+ + handleMinuteInput(e.target.value)} + onFocus={e => e.target.select()} + className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ + {/* AM / PM */} +
+ + +
+
+
+ + {/* Confirm */} + +
+
, + document.body, + ) : null; + + const displayText = parsed ? formatDisplay(parsed) : placeholder; + + return ( +
+ + {modal} +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 108b45d8b..6ace365ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -953,6 +953,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -22123,6 +22126,14 @@ snapshots: date-fns: 3.6.0 react: 19.2.6 + react-day-picker@9.14.0(react@18.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.4.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + react-day-picker@9.14.0(react@19.2.6): dependencies: '@date-fns/tz': 1.5.0 From 0dead281ce559a111c800a1057f8eb87a96ffe59 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 18 Jul 2026 19:20:45 +0000 Subject: [PATCH 23/54] add per-container handling options for hazardous, reefer, and return services - Introduced new boolean fields (isHazardous, isReefer, isReturn) in UnitDraft and related interfaces to allow individual container handling options. - Updated emptyUnit function to initialize these new fields. - Modified GlCreateBookingForm to handle and display these options for each container. - Adjusted calculations for hazardous, reefer, and return quantities based on the new handling options. - Updated the schema for container units and booking container lines to include handling options. - Added migration to support the new return flag in the database. - Enhanced various components to reflect gross weight calculations, ensuring consistency across the application. --- ...370000000000-AddContainerUnitReturnFlag.ts | 28 ++ .../bookings/booking-pricing.service.ts | 4 + .../entities/booking-container-unit.entity.ts | 4 + .../contracts/contract-booking.service.ts | 83 ++++-- .../dto/create-booking-under-contract.dto.ts | 9 + .../entities/rate-type.util.spec.ts | 28 ++ .../rule-engine/entities/rate-type.util.ts | 6 + .../rule-engine/rule-engine.service.ts | 35 ++- .../booking-batch.service.spec.ts | 40 ++- .../train-scheduling/booking-batch.service.ts | 39 ++- .../train-scheduling/intercity.service.ts | 19 +- .../train-scheduling.service.ts | 34 ++- .../contracts/GlCreateBookingForm.tsx | 159 +++++++----- .../trainScheduling/ScheduleWarningsAlert.tsx | 6 +- .../ScheduleWorkspacePanel.tsx | 5 +- .../TrainCompositionDiagram.tsx | 17 +- .../trainScheduling/WagonPlanGrid.tsx | 24 +- .../compositionEditor/BookingDetailModal.tsx | 2 +- .../InteractiveTrainConsist.tsx | 7 +- .../compositionEditor/RemoveBookingModal.tsx | 4 +- .../compositionEditor/TrainConsistView.tsx | 8 +- .../compositionEditor/TrainStatsBar.tsx | 2 +- .../UnassignedBookingsPanel.tsx | 4 +- .../compositionEditor/WagonCard.tsx | 8 +- .../backoffice/src/types/trainScheduling.ts | 6 + .../src/pages/contracts/NewShipmentPage.tsx | 241 +++++++++--------- .../contracts/new-shipment-form/schema.ts | 5 + packages/types/src/freight/contracts.ts | 8 + 28 files changed, 585 insertions(+), 250 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts new file mode 100644 index 000000000..1971f6166 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container handling opt-in: each physical container can now be marked + * hazardous / reefer / with-return individually, next to its VGM. The hazardous + * and reefer flags already existed on the unit row; only the return leg was + * missing, so a booking of 20 containers with 10 returning empty can bill the + * WITH_RETURN surcharge on 10 instead of all 20. + * + * Backfill: existing rows keep false. The line-level counts + * (booking_container.return_quantity etc.) stay authoritative for bookings made + * before this change — the rule engine falls back to them when no unit is flagged. + */ +export class AddContainerUnitReturnFlag2370000000000 implements MigrationInterface { + name = 'AddContainerUnitReturnFlag2370000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" ADD COLUMN IF NOT EXISTS "is_return" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index d3fe02d5f..cbb630794 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -305,6 +305,10 @@ export class BookingPricingService { vgmPerUnitTons: vgm, totalVgmTons: qty * vgm, isReefer: ct.isReefer, + // Per-container opt-ins — PER_CONTAINER surcharges bill these. + hazardousQuantity: Number(bc.hazardousQuantity ?? 0), + reeferQuantity: Number(bc.reeferQuantity ?? 0), + returnQuantity: Number(bc.returnQuantity ?? 0), }, perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index 619013280..217ffe5f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; + /** This container ships back empty after unloading (equipment return). */ + @Column({ name: 'is_return', type: 'boolean', default: false }) + isReturn!: boolean; + @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 8ef819eaf..97dc7767f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -40,7 +40,10 @@ import { ContractsRepository } from './contracts.repository'; import { ClearanceFeeService } from './clearance-fee.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; -import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +import { + CreateBookingContainerLineDto, + CreateBookingUnderContractDto, +} from './dto/create-booking-under-contract.dto'; /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; @@ -283,8 +286,8 @@ export class ContractBookingService { tradeDirection: contract.tradeDirection, freightType, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, @@ -1451,6 +1454,53 @@ export class ContractBookingService { ); } + /** + * Per-line handling counts. Each physical container carries its own hazardous + * / reefer / return switch (entered next to its VGM), so the count is however + * many units opted in. Forms that predate per-unit switches send line-level + * counts and no unit flags — those are honoured as-is. + */ + private handlingCounts(line: CreateBookingContainerLineDto): { + hazardousQuantity: number; + reeferQuantity: number; + returnQuantity: number; + } { + const units = line.units ?? []; + const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn); + if (!flagged) { + return { + hazardousQuantity: Number(line.hazardousQuantity ?? 0), + reeferQuantity: Number(line.reeferQuantity ?? 0), + returnQuantity: Number(line.returnQuantity ?? 0), + }; + } + return { + hazardousQuantity: units.filter((u) => u.isHazardous).length, + reeferQuantity: units.filter((u) => u.isReefer).length, + returnQuantity: units.filter((u) => u.isReturn).length, + }; + } + + /** + * Booking-level hazardous / reefer flags. The CONTRACT gates the service; the + * per-container opt-ins decide whether THIS shipment actually uses it. A + * container contract that allows hazardous but a booking where nobody ticked + * the switch is not a hazardous booking, and must not fire the surcharge. + * Bulk keeps the contract flag — it has its own bulk*Quantity fields. + */ + private resolveShipmentHandlingFlag( + contract: Contract, + dto: CreateBookingUnderContractDto, + field: 'hazardousQuantity' | 'reeferQuantity', + ): boolean { + const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer; + if (!gated) return false; + if (contract.freightType !== 'CONTAINER') return true; + const lines = dto.containers ?? []; + if (!lines.length) return Boolean(gated); + return lines.some((l) => this.handlingCounts(l)[field] > 0); + } + /** * Resolve the booking's equipment return from the per-line return quantities * (container freight). The CONTRACT gates the service — like hazardous: @@ -1470,7 +1520,7 @@ export class ContractBookingService { const lines = dto.containers ?? []; for (const line of lines) { - const qty = Number(line.returnQuantity ?? 0); + const qty = this.handlingCounts(line).returnQuantity; if (qty === 0) continue; if (contract.equipmentReturn !== 'WITH_RETURN') { throw new BadRequestException( @@ -1486,7 +1536,7 @@ export class ContractBookingService { } if (contract.equipmentReturn === 'WITH_RETURN') { - const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0); + const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0); return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN'; } return legacy; @@ -1524,9 +1574,10 @@ export class ContractBookingService { ); } + const counts = this.handlingCounts(line); const containerType = await this.resolveContainerTypeForSize( line.containerSize, - contract.isReefer || (line.reeferQuantity ?? 0) > 0, + contract.isReefer || counts.reeferQuantity > 0, ); const vgmPerUnit = line.units.length @@ -1540,12 +1591,10 @@ export class ContractBookingService { containerTypeId: containerType.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: counts.hazardousQuantity, + reeferQuantity: counts.reeferQuantity, returnQuantity: - contract.equipmentReturn === 'WITH_RETURN' - ? (line.returnQuantity ?? 0) - : 0, + contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), @@ -1564,6 +1613,8 @@ export class ContractBookingService { vgmTons: unit.vgmTons, isHazardous: unit.isHazardous ?? false, isReefer: unit.isReefer ?? false, + isReturn: + contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false), sortOrder: sortOrder++, }), ); @@ -1664,8 +1715,8 @@ export class ContractBookingService { paymentCurrency: contract.paymentCurrency, serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), isGovernment: contract.isGovernment, shippingLineId: null, @@ -1680,11 +1731,11 @@ export class ContractBookingService { containerTypeId: ct.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: this.handlingCounts(line).hazardousQuantity, + reeferQuantity: this.handlingCounts(line).reeferQuantity, returnQuantity: contract.equipmentReturn === 'WITH_RETURN' - ? (line.returnQuantity ?? 0) + ? this.handlingCounts(line).returnQuantity : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 256b11b63..3b5a30ca0 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -50,6 +50,15 @@ export class CreateContainerUnitDto { @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'This container ships back empty (equipment return).', + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReturn?: boolean; } export class CreateBookingContainerLineDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts new file mode 100644 index 000000000..480b2c484 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -0,0 +1,28 @@ +import { deriveRateType } from './rate-type.util'; + +describe('deriveRateType — surcharge triggers', () => { + // Every surcharge trigger must land on its own rateType. A trigger with no + // mapping falls through to the base-freight branch and is silently stored as + // CANCELLATION_FEE, which both mislabels the booking's rate snapshot and + // hides the rate from contract pricing (which looks rateTypes up by name). + it.each([ + ['HAZARDOUS', 'HAZARD_SURCHARGE'], + ['REEFER', 'REEFER_SURCHARGE'], + ['WITH_RETURN', 'RETURN_SURCHARGE'], + ['OVERWEIGHT', 'OVERWEIGHT_PER_TON'], + ['SHIPPING_LINE', 'DOUBLE_HANDLING'], + ['CONSOLIDATION', 'LASHING'], + ['CANCELLATION', 'CANCELLATION_FEE'], + ['DEMURRAGE', 'DEMURRAGE'], + ['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'], + ['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'], + ] as const)('maps trigger %s to %s', (trigger, expected) => { + expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected); + }); + + it('does not fall back to CANCELLATION_FEE for the empty-return service', () => { + expect(deriveRateType({ appliesTo: 'OTHER', trigger: 'WITH_RETURN' })).not.toBe( + 'CANCELLATION_FEE', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 09f35c458..a5b5bfc30 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -25,6 +25,12 @@ export function deriveRateType(input: { return 'HAZARD_SURCHARGE'; case 'REEFER': return 'REEFER_SURCHARGE'; + // Empty-container return service. Contract pricing looks this rateType up + // by name, so without the mapping a WITH_RETURN rate fell through to the + // base-freight branch and was stored as CANCELLATION_FEE — invisible to + // the contract, and mislabelled on the booking's snapshot. + case 'WITH_RETURN': + return 'RETURN_SURCHARGE'; case 'OVERWEIGHT': return 'OVERWEIGHT_PER_TON'; case 'SHIPPING_LINE': diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 03be1b3d0..d1715d9fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -42,6 +42,14 @@ export interface BookingContainerEvalInput { isReefer?: boolean; isOverweight?: boolean; overweightExcessTons?: number | null; + /** + * How many individual containers on this line opted into each handling + * service. PER_CONTAINER surcharges bill these counts, not the line + * quantity — 20 containers with 10 hazardous bill hazard on 10. + */ + hazardousQuantity?: number; + reeferQuantity?: number; + returnQuantity?: number; } export interface BookingEvaluationInput { @@ -270,6 +278,27 @@ export class RuleEngineService { (sum, r) => sum + (r.overweightExcessTons ?? 0), 0, ); + /** + * Containers that opted into this trigger's handling service, summed + * across lines. null when the trigger isn't per-container handling (or + * no line carries a count) so the caller falls back to the full count. + */ + const optedInCount = (trigger: string | null): number | null => { + const field = + trigger === 'HAZARDOUS' + ? 'hazardousQuantity' + : trigger === 'REEFER' + ? 'reeferQuantity' + : trigger === 'WITH_RETURN' + ? 'returnQuantity' + : null; + if (!field) return null; + const total = input.containers.reduce( + (sum, c) => sum + Number(c[field] ?? 0), + 0, + ); + return total > 0 ? total : null; + }; let triggerValue: number | null = null; let calculatedAmount: number; @@ -285,7 +314,11 @@ export class RuleEngineService { calculatedAmount = triggerValue * rateValue; break; case 'PER_CONTAINER': - triggerValue = containerCount; + // Handling surcharges bill only the containers that opted in, not the + // whole line — 20 containers with 10 hazardous bill hazard on 10. + // Legacy bookings carry no per-container counts (all 0) while their + // booking-level flag is set, so fall back to the full count there. + triggerValue = optedInCount(rate.trigger) ?? containerCount; calculatedAmount = triggerValue * rateValue; break; case 'PER_WAGON': 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 c19140d8a..fc97f772b 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 @@ -958,20 +958,21 @@ describe('BookingBatchService — built-train wagon capacity', () => { // assertion below that says "not full" proves those axes are ignored. const scheduleId = 'schedule-built'; - const reservedBooking = (id: string) => + const reservedBooking = (id: string, leg?: { origin: string; dest: string }) => ({ id, freightType: 'BULK', cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload bookingContainers: [], - originYardId: 'yard-a', - destinationYardId: 'yard-b', + originYardId: leg?.origin ?? 'yard-a', + destinationYardId: leg?.dest ?? 'yard-b', }) as unknown as Booking; const buildService = (opts: { physicalWagons: number; reserved: Booking[]; maxWagons?: number; + routeStops?: string[]; }) => { const schedule = { id: scheduleId, @@ -979,7 +980,7 @@ describe('BookingBatchService — built-train wagon capacity', () => { bookingWindowStatus: 'OPEN', originStationId: 'yard-a', destinationStationId: 'yard-b', - routeId: null, + routeId: opts.routeStops ? 'route-1' : null, scheduleBookings: [], trainSet: { locomotive: { @@ -992,14 +993,23 @@ describe('BookingBatchService — built-train wagon capacity', () => { }, }; const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) }; + const milestoneRepo = { + find: jest + .fn() + .mockResolvedValue( + (opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })), + ), + }; const genericRepo = { find: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; const dataSource = { - getRepository: jest.fn((entity: { name?: string }) => - entity?.name === 'Wagon' ? wagonRepo : genericRepo, - ), + getRepository: jest.fn((entity: { name?: string }) => { + if (entity?.name === 'Wagon') return wagonRepo; + if (entity?.name === 'RouteMilestone') return milestoneRepo; + return genericRepo; + }), transaction: jest.fn(), }; const service = new BookingBatchService( @@ -1040,6 +1050,22 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); + it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => { + // Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor + // left the pass-through edges reading "free" in the per-edge budget, so the + // full train's window cycled OPEN forever and the day pool never expired. + // A wagon is committed for the whole trip — leg-free edges are not capacity. + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'], + reserved: [ + reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }), + reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }), + ], + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + it('reports over-allocation when the consist is trimmed below committed bookings', async () => { const { service } = buildService({ physicalWagons: 1, 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 c06f0ee81..5837e4cd2 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 @@ -3607,11 +3607,19 @@ export class BookingBatchService implements OnModuleInit { /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { + // Built train: the physical consist is the only capacity axis, and a wagon + // is committed to its booking for the WHOLE trip — wagon allocation has no + // leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for + // the Doraleh→Negad edge it merely passes through. Count commitments + // train-wide, not per corridor edge: the per-edge budget read "free slots" + // on pass-through legs of a sold-out consist, so the window of a full train + // cycled OPEN forever instead of concluding DONE (and the day pool's + // leftover bookings were never expired). + const physicalWagons = await this.builtTrainWagonCount(schedule); + if (physicalWagons != null) { + return (await this.committedWagons(schedule)) >= physicalWagons; + } if ((await this.remainingWagons(schedule)) <= 0) return true; - // Built train: the physical consist is the only capacity axis. Weight and - // length were enforced when the consist was assembled (builder / - // adjust-consist), so a free wagon slot means the train genuinely has room. - if ((await this.builtTrainWagonCount(schedule)) != null) return false; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against const wagonDims = await this.loadWagonDims(); @@ -3620,6 +3628,29 @@ export class BookingBatchService implements OnModuleInit { return budget.isExhausted(this.minPerWagonNeed(wagonDims)); } + /** + * Wagons the schedule's allocated + reserved bookings occupy train-wide, + * regardless of which corridor leg each rides. Deduped by booking id — a + * booking mid-settle can momentarily be both linked and reserved. + */ + private async committedWagons(schedule: TrainSchedule): Promise { + const wagonDims = await this.loadWagonDims(); + const allocated = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b)); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); + const byId = new Map( + [...allocated, ...reserved].map((b) => [b.id, b] as const), + ); + let total = 0; + for (const booking of byId.values()) { + total += this.wagonsFor(booking, wagonDims); + } + return total; + } + /** * Smallest gross weight / shortest length one more wagon could add: the * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index d2f1dd7b1..2f539bb43 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -64,15 +64,15 @@ export class IntercityService { booking.destinationYardId, ); return { - ...this.mapBooking(booking), + ...this.mapBooking(booking, need), need, fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), }; }), - accepted: accepted.map((booking) => ({ - ...this.mapBooking(booking), - need: capacity?.needFor(booking) ?? null, - })), + accepted: accepted.map((booking) => { + const need = capacity?.needFor(booking) ?? null; + return { ...this.mapBooking(booking, need), need }; + }), }; } @@ -294,7 +294,12 @@ export class IntercityService { return { schedule, booking }; } - private mapBooking(booking: Booking) { + /** + * `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is + * spent in. Prefer it, so the row's weight sits on the same axis as the + * remaining-capacity figure shown beside it; cargo VGM is the fallback. + */ + private mapBooking(booking: Booking, need?: { weightTons: number } | null) { return { id: booking.id, reference: booking.reference, @@ -310,7 +315,7 @@ export class IntercityService { booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', - weightTons: Number(booking.cargoTotalWeightVgm ?? 0), + weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0), paymentDeadline: booking.paymentDeadline?.toISOString() ?? null, }; } 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 eb3fd7201..38bc21156 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 @@ -267,6 +267,8 @@ export interface CompositionUnassignedBookingRow { freightType: string | null; priorityScore: number; cargoTotalWeightVgm: number; + /** GROSS: cargo VGM + tare of every wagon the booking occupies. */ + grossWeightTons: number; status: string | null; schedulingStatus: string | null; wagonsRequired: number; @@ -3866,11 +3868,18 @@ export class TrainSchedulingService { } const totalWeightTons = totalAssignedWeight(fittingBookings); + // Every weight limit below (global max, loco pull) is a GROSS axis, so the + // figure spent against it must be gross too — cargo alone under-reports the + // train by the full consist tare and disagrees with the assign path. + const totalTareTons = roundTons( + wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0), + ); + const grossWeightTons = roundTons(totalWeightTons + totalTareTons); const totalLengthMeters = roundTons( wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), ); - if (totalWeightTons > trainLimits.maxWeightTons) { - const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; + if (grossWeightTons > trainLimits.maxWeightTons) { + const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`; if (!violations.includes(message) && !warnings.includes(message)) { pushLimit([message]); } @@ -3897,7 +3906,7 @@ export class TrainSchedulingService { if ( setLimits && (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < - totalWeightTons || + grossWeightTons || setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < totalLengthMeters) ) { @@ -3918,7 +3927,7 @@ export class TrainSchedulingService { !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >= - totalWeightTons && + grossWeightTons && Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= totalLengthMeters, ) @@ -3940,6 +3949,9 @@ export class TrainSchedulingService { summary: { totalBookings: fittingBookings.length, totalWeightTons, + /** GROSS: cargo + the tare of every wagon in the plan. */ + grossWeightTons, + totalTareTons, // Human-readable wagon type(s) of the plan — mixed consists list all. wagonType: plannedTypeCodes.join('/') || 'NONE', wagonsNeeded: wagonPlan.length, @@ -7036,6 +7048,15 @@ export class TrainSchedulingService { shortfall: 0, })); + // Gross weight needs the scheduling graph (containers, cargo type, wagon + // types) that the trimmed select above deliberately skips. + const tareDims = await this.loadWagonTareDims(); + const fullById = new Map( + (await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map( + (b) => [b.id, b], + ), + ); + const bookings = await Promise.all( unassigned.map(async (b) => { const assignability = await this.previewUnassignedBookingAssignability( @@ -7050,6 +7071,11 @@ export class TrainSchedulingService { freightType: b.freightType ?? null, priorityScore: b.priorityScore ?? 0, cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0), + // GROSS: cargo + tare of the wagons the booking occupies. + grossWeightTons: this.grossBookingWeightTons( + (fullById.get(b.id) ?? b) as Booking, + tareDims, + ), status: b.status ?? null, schedulingStatus: b.schedulingStatus ?? null, ...assignability, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 5633745aa..5225dc6b9 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -128,6 +128,10 @@ interface UnitDraft { containerNumber: string; sealNumber: string; vgmTons: string; + /** Handling is per physical container; the line counts roll these up. */ + isHazardous: boolean; + isReefer: boolean; + isReturn: boolean; } /** Mirrors the portal shipment form's container line: line-level quantity + @@ -150,7 +154,14 @@ interface BulkDraft { } function emptyUnit(): UnitDraft { - return { containerNumber: "", sealNumber: "", vgmTons: "" }; + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, + }; } function emptyLine(size: string): ContainerLineDraft { @@ -285,6 +296,20 @@ export default function GlCreateBookingForm() { // Legacy contracts (no equipment return chosen at creation) keep the old // booking-level toggle. const legacyReturnToggle = isContainer && !contract?.equipmentReturn; + /** + * Handling switches offered on each container row — only the services this + * contract was created with, since the server rejects the others. + */ + const handlingColumns = ( + [ + contract?.isHazardous && { key: "isHazardous", label: "Hazardous" }, + contract?.isReefer && { key: "isReefer", label: "Refrigerated" }, + contractWithReturn && { key: "isReturn", label: "With return" }, + ] as Array + ).filter(Boolean) as Array<{ + key: "isHazardous" | "isReefer" | "isReturn"; + label: string; + }>; // Intercity shipments ride a passing import/export train staff pick at // finalize time — no shipment day is chosen and no window gate applies. const isIntercity = contract?.tradeDirection === "DOMESTIC"; @@ -488,6 +513,18 @@ export default function GlCreateBookingForm() { enabled: cargoQuery !== null && !isIntercity, }); + /** + * Line handling totals are a roll-up of the per-container switches — the + * count is however many containers ticked each service. Recomputed on every + * unit change so the price estimate and payload follow the switches. + */ + const withDerivedCounts = (line: ContainerLineDraft): ContainerLineDraft => ({ + ...line, + hazardousQuantity: String(line.units.filter((u) => u.isHazardous).length), + reeferQuantity: String(line.units.filter((u) => u.isReefer).length), + returnQuantity: String(line.units.filter((u) => u.isReturn).length), + }); + // Keep the units array length in sync with the entered quantity. const syncUnits = (lineIdx: number, qty: number) => { setContainerLines((prev) => @@ -496,7 +533,7 @@ export default function GlCreateBookingForm() { const next = [...line.units]; while (next.length < qty) next.push(emptyUnit()); next.length = Math.max(0, qty); - return { ...line, units: next }; + return withDerivedCounts({ ...line, units: next }); }), ); }; @@ -511,11 +548,16 @@ export default function GlCreateBookingForm() { unitIdx: number, patch: Partial, ) => - patchLine(lineIdx, { - units: containerLines[lineIdx].units.map((u, i) => - i === unitIdx ? { ...u, ...patch } : u, + setContainerLines((prev) => + prev.map((l, i) => + i === lineIdx + ? withDerivedCounts({ + ...l, + units: l.units.map((u, j) => (j === unitIdx ? { ...u, ...patch } : u)), + }) + : l, ), - }); + ); // Same client-side validation as the customer portal shipment form // (new-shipment-form/schema.ts): ISO container numbers unique within the @@ -560,10 +602,15 @@ export default function GlCreateBookingForm() { hazardousQuantity: String(imported.filter((r) => r.hazardous).length), reeferQuantity: String(imported.filter((r) => r.reefer).length), returnQuantity: String(imported.filter((r) => r.withReturn).length), + // The spreadsheet marks handling per row — carry it onto the + // container it belongs to rather than collapsing it to a line count. units: imported.map((r) => ({ containerNumber: r.containerNumber, sealNumber: r.sealNumber, vgmTons: String(r.vgmTons), + isHazardous: Boolean(r.hazardous), + isReefer: Boolean(r.reefer), + isReturn: Boolean(r.withReturn), })), }; }), @@ -742,6 +789,11 @@ export default function GlCreateBookingForm() { containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), vgmTons: Number(u.vgmTons) || 0, + // Per-container handling — the server rolls these into the line + // counts and bills each surcharge on the ticked containers only. + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + ...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}), })), })); } else { @@ -1175,73 +1227,24 @@ export default function GlCreateBookingForm() { radius={10} styles={fieldStyles} /> - {contract.isHazardous && ( - - patchLine(lineIdx, { - hazardousQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} - {contract.isReefer && ( - - patchLine(lineIdx, { - reeferQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} - {contractWithReturn && ( - - patchLine(lineIdx, { - returnQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} Per-container details + {handlingColumns.length > 0 ? ( + + Tick the services each individual container needs — + charges apply only to the containers ticked + {handlingColumns + .map((col) => { + const count = line.units.filter( + (u) => u[col.key], + ).length; + return count > 0 ? ` · ${count} ${col.label.toLowerCase()}` : ""; + }) + .join("")} + . + + ) : null} {line.units.map((unit, unitIdx) => ( @@ -1296,6 +1299,22 @@ export default function GlCreateBookingForm() { radius={10} styles={fieldStyles} /> + {handlingColumns.map((col) => ( + + patchUnit(lineIdx, unitIdx, { + [col.key]: e.currentTarget.checked, + }) + } + label={unitIdx === 0 ? col.label : undefined} + labelPosition="right" + size="sm" + mt={unitIdx === 0 ? 26 : 6} + /> + ))} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx index 25bf2480f..d84d4e8be 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx @@ -40,17 +40,21 @@ export function PreviewSummary({ summary?: { totalBookings: number; totalWeightTons: number; + grossWeightTons?: number; + totalTareTons?: number; wagonType: string; wagonsNeeded: number; totalLengthMeters: number; }; }) { if (!summary) return null; + // GROSS — the axis every train limit is spent against. + const gross = summary.grossWeightTons ?? summary.totalWeightTons; const stats = [ { label: "Bookings", value: String(summary.totalBookings) }, { label: "Wagons", value: String(summary.wagonsNeeded) }, { label: "Wagon type", value: summary.wagonType }, - { label: "Total weight", value: `${summary.totalWeightTons}T` }, + { label: "Gross weight", value: `${gross}T` }, { label: "Train length", value: `${summary.totalLengthMeters}m` }, ]; return ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 867a0f895..c7fc49b18 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -99,9 +99,8 @@ function usedWeight(schedule: TrainScheduleDetail): number { /** * Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when * unknown). The API caps at the weakest loco, not the sum of all locos — a - * consist can only pull as hard as its weakest engine. Note: the API also adds - * the consist tare to the used weight when it checks this cap; tare isn't - * available client-side, so this meter compares cargo-only load against pull. + * consist can only pull as hard as its weakest engine. Both sides of this meter + * are gross: `usedWeight` sums per-booking gross (cargo + wagon tare). */ function pullCapacity(schedule: TrainScheduleDetail): number { const set = schedule.trainSet; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index 2805ba60a..af8917e00 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -46,6 +46,8 @@ type NormalizedWagon = { const CAR_WIDTH = 150; // car body + coupler footprint +const round1 = (n: number) => Math.round(n * 10) / 10; + function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon { const allocations = w.allocations ?? []; const firstLoad = ( @@ -268,10 +270,11 @@ const CONTAINER_BORDERS = [ ]; function WagonCar({ wagon }: { wagon: NormalizedWagon }) { + // GROSS on both sides: cargo + tare vs rated payload + tare. + const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons); + const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons); const utilization = - wagon.capacityTons > 0 - ? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100)) - : 0; + maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 100)) : 0; const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan"; const accentVar = `var(--mantine-color-${accent}-6)`; @@ -281,8 +284,8 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : "" }${ wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : "" - }${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${ - wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : "" + }${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${ + wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : "" }`; // container blocks: one per container number (cap visual at 2 = TEU per wagon) @@ -369,7 +372,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { /> - {wagon.assignedWeightTons}/{wagon.capacityTons}T + {grossTons}/{maxGrossTons}T ) : ( @@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { {!wagon.isEmpty ? ( - {wagon.assignedWeightTons}T + {grossTons}T ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx index 086f78ddc..e5b63ea24 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx @@ -6,6 +6,7 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | { sequenceNo: number; capacityTons: number; assignedWeightTons: number; + tareWeightTons?: number | null; slotLoadType?: string; wagonType?: { code: string } | null; wagonTypeCode?: string; @@ -21,6 +22,8 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | { }>; }; +const round1 = (n: number) => Math.round(n * 10) / 10; + function loadTypeColor(loadType: string | undefined, freightType?: string | null) { const normalized = loadType?.toUpperCase() ?? ""; if (normalized.includes("BULK")) return "orange"; @@ -68,8 +71,16 @@ export function WagonPlanGrid({ ); } - const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0); - const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0); + // GROSS on both sides: cargo + tare vs rated payload + tare. + const totalTare = round1( + wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0), + ); + const totalCapacity = round1( + wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0) + totalTare, + ); + const totalAssigned = round1( + wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0) + totalTare, + ); const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length; const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk")); @@ -82,7 +93,7 @@ export function WagonPlanGrid({ {isBulk ? ( - Load: {totalAssigned} / {totalCapacity}T + Gross: {totalAssigned} / {totalCapacity}T ) : null} @@ -90,8 +101,9 @@ export function WagonPlanGrid({ {wagonPlan.map((wagon) => { const seq = wagon.sequenceNo; - const capacity = wagon.capacityTons; - const assigned = wagon.assignedWeightTons; + const tare = Number(wagon.tareWeightTons) || 0; + const capacity = round1(wagon.capacityTons + tare); + const assigned = round1(wagon.assignedWeightTons + tare); const allocations = wagon.allocations ?? []; const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0; const label = slotLabel(wagon, freightType); @@ -149,7 +161,7 @@ export function WagonPlanGrid({ {label === "BULK" ? ( - {alloc.allocatedWeightTons}T + {alloc.allocatedWeightTons}T cargo ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx index 00df4700c..9e7062ad6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx @@ -132,7 +132,7 @@ export const BookingDetailModal = ({ /> } - label="Weight" + label="Gross weight" value={ {booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 8af9a6e34..8ac374c1a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -161,8 +161,11 @@ function WagonCar({ const allocation = wagon.allocations?.[0]; const isEmpty = !allocation; const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); - const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0; - const capacity = wagon.capacityTons ?? 0; + // GROSS on both sides: cargo + tare vs rated payload + tare. + const tare = wagon.tareWeightTons ?? 0; + const assigned = + (allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare; + const capacity = (wagon.capacityTons ?? 0) + tare; const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0; const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan"; const accentVar = `var(--mantine-color-${accent}-6)`; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx index 4d2b1369c..80cbeaad6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx @@ -21,6 +21,8 @@ export const RemoveBookingModal = ({ if (!wagon || !wagon.allocations?.[0]) return null; const allocation = wagon.allocations[0]; + // GROSS: allocated cargo + the tare of the wagon it sits on. + const grossTons = (allocation.allocatedWeightTons ?? 0) + (wagon.tareWeightTons ?? 0); return ( @@ -40,7 +42,7 @@ export const RemoveBookingModal = ({ - Weight: {allocation.allocatedWeightTons?.toFixed(2) || 0} T + Gross weight: {grossTons.toFixed(2)} T Wagon Slot: #{wagon.sequenceNo} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 6ece98ef2..e5b12fe1c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -93,10 +93,14 @@ export const TrainConsistView = ({ } }; - const weightUsed = wagons.reduce( - (sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0), + // GROSS: cargo on every allocation + the tare of every wagon in the consist. + // maxPullWeightTons is a gross limit, so the numerator must be gross too. + const cargoUsed = wagons.reduce( + (sum, w) => sum + (w.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0), 0, ); + const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0); + const weightUsed = cargoUsed + tareUsed; const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0); return ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx index 8ec1c7f4e..3403258ef 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx @@ -98,7 +98,7 @@ export const TrainStatsBar = ({ } - label="Weight" + label="Gross weight" pct={weightPct} current={weightUsed.toFixed(1)} max={weightMax?.toFixed(1) ?? "∞"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx index e5225f8dd..3af00fd88 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx @@ -130,7 +130,9 @@ export const UnassignedBookingsPanel = ({ {bookings.map((booking) => { const isActive = selectedBookingId === booking.id; - const weight = Number(booking.cargoTotalWeightVgm ?? 0); + // GROSS (cargo + wagon tare) so this badge shares the axis every other + // weight on the page uses — cargo-only here read ~25% light. + const weight = Number(booking.grossWeightTons ?? booking.cargoTotalWeightVgm ?? 0); const fits = booking.canAssign; const blockReason = booking.blockReason; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index f735e6474..915cfa629 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -36,8 +36,12 @@ export const WagonCard = ({ const hasAllocations = Boolean(allocation); const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); - const weightUsed = allocation?.allocatedWeightTons ?? 0; - const weightMax = wagon.capacityTons ?? 0; + // GROSS on both sides: loaded cargo + wagon tare, against the wagon's max + // gross (rated payload + tare). Keeps the wagon axis identical to the train + // axis in TrainStatsBar. + const tare = wagon.tareWeightTons ?? 0; + const weightUsed = (allocation?.allocatedWeightTons ?? 0) + tare; + const weightMax = (wagon.capacityTons ?? 0) + tare; const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0; const wagonType = wagon.wagonType?.code || "UNKNOWN"; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index e8aaf71ec..40337a4cd 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -134,7 +134,11 @@ export interface TrainSchedulePreviewResponse { deferredBookings?: DeferredBookingRow[]; summary: { totalBookings: number; + /** Cargo VGM only — display gross instead. */ totalWeightTons: number; + /** GROSS: cargo + the tare of every wagon in the plan. */ + grossWeightTons: number; + totalTareTons: number; wagonType: string; wagonsNeeded: number; totalLengthMeters: number; @@ -845,6 +849,8 @@ export interface CompositionUnassignedBooking { freightType: FreightType | null; priorityScore: number; cargoTotalWeightVgm: number; + /** GROSS: cargo VGM + tare of every wagon the booking occupies. */ + grossWeightTons: number; status: string | null; schedulingStatus: SchedulingStatus | null; wagonsRequired: number; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index ea83c8163..7bdb06f84 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -59,8 +59,6 @@ import { StepCard, StepHeader, StepLabel, - ToggleRow, - UnitCountToggles, fieldStyles, } from "./new-contract-form/shared"; import { formatRateUnit } from "./new-contract-form/unit-rates"; @@ -362,6 +360,11 @@ function NewShipmentBookingForm({ containerNumber: u.containerNumber, sealNumber: u.sealNumber || undefined, vgmTons: Number(u.vgmTons), + // Per-container handling — the server rolls these up into the + // line counts and bills each surcharge on the ticked containers. + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + ...(withReturnService ? { isReturn: Boolean(u.isReturn) } : {}), })), })), } @@ -1133,7 +1136,7 @@ function CargoStep({ hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }], + units: [emptyUnit()], })), { shouldValidate: false }, ); @@ -1177,7 +1180,7 @@ function CargoStep({ hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }], + units: [emptyUnit()], } ); } @@ -1187,10 +1190,15 @@ function CargoStep({ hazardousQuantity: String(imported.filter((r) => r.hazardous).length), reeferQuantity: String(imported.filter((r) => r.reefer).length), returnQuantity: String(imported.filter((r) => r.withReturn).length), + // The spreadsheet already marks handling per row — carry it onto the + // container it belongs to rather than collapsing it to a line count. units: imported.map((r) => ({ containerNumber: r.containerNumber, sealNumber: r.sealNumber, vgmTons: r.vgmTons, + isHazardous: Boolean(r.hazardous), + isReefer: Boolean(r.reefer), + isReturn: Boolean(r.withReturn), })), }; }); @@ -1511,6 +1519,16 @@ function NotesSection({ form }: { form: ShipmentForm }) { ); } +/** A blank container row — handling switches start off. */ +const emptyUnit = () => ({ + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, +}); + function ContainerLineEditor({ form, index, @@ -1536,76 +1554,81 @@ function ContainerLineEditor({ const current = form.getValues(`containers.${index}.units`) ?? []; const next = [...current]; while (next.length < qty) - next.push({ containerNumber: "", sealNumber: "", vgmTons: "" }); + next.push({ + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, + }); next.length = Math.max(0, qty); form.setValue(`containers.${index}.units`, next, { shouldValidate: false }); + syncHandlingCounts(next); }; - // Lowering the line quantity must pull every cargo-handling count back within - // it, or a stale count silently exceeds the line and fails validation on a - // field the customer can no longer see a cause for. - const clampHandlingCounts = (qty: number) => { - (["hazardousQuantity", "reeferQuantity", "returnQuantity"] as const).forEach( - (key) => { - const path = `containers.${index}.${key}` as const; - const current = Number(form.getValues(path) || 0); - if (current > qty) - form.setValue(path, String(Math.max(0, qty)), { - shouldDirty: true, - shouldValidate: true, - }); - }, - ); + /** + * Line totals are a roll-up of the per-container switches — the count is + * however many containers ticked each service. Kept in form state so the + * price estimate and the submitted payload stay in step with the switches. + */ + const syncHandlingCounts = ( + units: Array<{ isHazardous?: boolean; isReefer?: boolean; isReturn?: boolean }>, + ) => { + const set = ( + key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity", + count: number, + ) => + form.setValue(`containers.${index}.${key}`, String(count), { + shouldDirty: true, + shouldValidate: true, + }); + set("hazardousQuantity", units.filter((u) => u.isHazardous).length); + set("reeferQuantity", units.filter((u) => u.isReefer).length); + set("returnQuantity", units.filter((u) => u.isReturn).length); }; - /** Switch state is derived from the count — a line is hazardous iff qty > 0. */ - const handlingToggle = ( - key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity", - opts: { - icon: ReactNode; - iconBg: string; - iconColor: string; - title: string; - description: string; - pickLabel: string; - activeBg: string; - activeBorder: string; - activeColor: string; + /** Flip one container's handling switch, then re-roll the line totals. */ + const toggleUnitHandling = ( + unitIndex: number, + key: "isHazardous" | "isReefer" | "isReturn", + on: boolean, + ) => { + form.setValue(`containers.${index}.units.${unitIndex}.${key}`, on, { + shouldDirty: true, + }); + syncHandlingCounts(form.getValues(`containers.${index}.units`) ?? []); + }; + + /** + * The handling columns offered on each container row — only the services this + * contract was created with, since the server rejects quantities for the others. + */ + const handlingColumns = [ + isHazardous && { + key: "isHazardous" as const, + label: "Hazardous", + icon: , + color: "#C0392B", }, - ) => ( - ( - 0} - onChange={(on) => field.onChange(on ? "1" : "0")} - > -
- - {fieldState.error?.message ? ( - - {fieldState.error.message} - - ) : null} -
-
- )} - /> - ); + isReefer && { + key: "isReefer" as const, + label: "Refrigerated", + icon: , + color: "#2E5B96", + }, + withReturnService && { + key: "isReturn" as const, + label: "With return", + icon: , + color: "#0A6F4D", + }, + ].filter(Boolean) as Array<{ + key: "isHazardous" | "isReefer" | "isReturn"; + label: string; + icon: ReactNode; + color: string; + }>; return ( - {/* Cargo handling — only the services this contract was created with are - offered, since the server rejects quantities for the others. Each - switch reveals a bounded picker: tap the containers it applies to. */} - {(isHazardous || isReefer || withReturnService) && quantity > 0 && ( - <> - Cargo handling -
- {isHazardous && - handlingToggle("hazardousQuantity", { - icon: , - iconBg: "#FBEAE7", - iconColor: "#C0392B", - title: "Hazardous", - description: "Some of these containers carry hazardous cargo.", - pickLabel: "Tap the hazardous containers", - activeBg: "#FBEAE7", - activeBorder: "#E4A69B", - activeColor: "#C0392B", - })} - {isReefer && - handlingToggle("reeferQuantity", { - icon: , - iconBg: "#E9F0F8", - iconColor: "#2E5B96", - title: "Refrigerated", - description: "Some of these containers need reefer transport.", - pickLabel: "Tap the refrigerated containers", - activeBg: "#E9F0F8", - activeBorder: "#A9C2E0", - activeColor: "#2E5B96", - })} - {withReturnService && - handlingToggle("returnQuantity", { - icon: , - iconBg: "#ECF6F1", - iconColor: "#0A6F4D", - title: "With return", - description: "Some of these containers come back to EDR empty.", - pickLabel: "Tap the containers EDR returns", - activeBg: "#ECF6F1", - activeBorder: "#A9D6C2", - activeColor: "#0A6F4D", - })} -
- - )} - Per-container details + {handlingColumns.length > 0 && quantity > 0 ? ( + + Tick the services each individual container needs — charges apply only + to the containers you tick. + + ) : null} {Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => ( @@ -1739,6 +1721,37 @@ function ContainerLineEditor({ /> )} /> + {handlingColumns.map((col) => ( + ( + + toggleUnitHandling(u, col.key, e.currentTarget.checked) + } + label={ + u === 0 ? ( + + + {col.icon} + + + {col.label} + + + ) : undefined + } + labelPosition="right" + size="sm" + mt={u === 0 ? 26 : 6} + /> + )} + /> + ))} ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 8610753c9..9c7efb8e3 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -48,6 +48,11 @@ const containerUnitSchema = z.object({ .string() .refine((v) => v.trim().length > 0, "VGM is required.") .refine((v) => !Number.isNaN(Number(v)) && Number(v) > 0, "Enter a valid VGM."), + // Handling is per physical container, recorded next to its VGM. The line + // totals below are derived from these. + isHazardous: z.boolean().default(false), + isReefer: z.boolean().default(false), + isReturn: z.boolean().default(false), }); const containerLineSchema = z.object({ diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 9aa36374a..362d3c6ef 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -729,14 +729,22 @@ export interface CreateContainerUnitDto { containerNumber: string; sealNumber?: string; vgmTons: number; + /** Per-container handling opt-ins, entered alongside this container's VGM. */ isHazardous?: boolean; isReefer?: boolean; + /** This container ships back empty (equipment return). */ + isReturn?: boolean; } export interface CreateBookingContainerLineDto { /** "20ft" | "40ft" — must be in the contract's cargo scope. */ containerSize: string; quantity: number; + /** + * Line totals, derived from the per-unit switches above. The API recomputes + * them from `units` whenever any unit carries a flag, so they are only + * authoritative for callers that don't send per-unit flags. + */ hazardousQuantity?: number; reeferQuantity?: number; /** From 93c583edf643c9419f07a24f4cef7c1d17030199 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 19 Jul 2026 08:07:59 +0300 Subject: [PATCH 24/54] Report number updates --- .../src/modules/reports/reports.service.ts | 51 ++++--- .../src/modules/seats/seats.service.ts | 3 + .../src/app/reports/passengers/page.tsx | 127 ++++++++---------- .../backoffice/src/app/reports/seats/page.tsx | 16 +-- 4 files changed, 92 insertions(+), 105 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 0da670ec7..3d6345af7 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -105,7 +105,7 @@ export class ReportsService { const tripData = schedules.map(schedule => { const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0); const bookedSeats = schedule.bookings.reduce( - (sum, b) => sum + b.seats.filter((s: any) => s.scheduleId === schedule.id).length, 0, + (sum, b) => sum + b.seats.filter((s: any) => s.leg === 1).length, 0, ); const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0; return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) }; @@ -217,7 +217,7 @@ export class ReportsService { where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, include: { seats: { - where: { scheduleId }, + where: { leg: 1 }, include: { seat: { include: { coach: { include: { coachType: true } } } }, }, @@ -313,27 +313,46 @@ export class ReportsService { async getPassengerList(scheduleId: string) { const seats = await this.prisma.bookingSeat.findMany({ where: { - scheduleId, - booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + leg: 1, + booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } }, }, include: { - booking: { select: { bookingRef: true, status: true } }, - seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, + booking: { + select: { + bookingRef: true, + status: true, + originStationId: true, + destinationStationId: true, + }, + }, + seat: { include: { coach: { select: { number: true } } } }, }, - orderBy: [{ seat: { coach: { number: 'asc' } } }], + orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }], }); + + // Resolve station names in one query + const stationIds = [...new Set( + seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[], + )]; + const stations = stationIds.length > 0 + ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } }) + : []; + const stationName = new Map(stations.map(s => [s.id, s.name])); + + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { departureAt: true }, + }); + return seats.map(bs => ({ bookingRef: bs.booking.bookingRef, - bookingStatus: bs.booking.status, passengerName: bs.passengerName, - passengerCategory: bs.passengerCategory, - idDocumentType: bs.idDocumentType, - idDocumentNumber: bs.idDocumentNumber, - passportNumber: bs.passportNumber, - passportCountry: bs.passportCountry, - seatLabel: bs.seatLabelSnapshot, - coachNumber: bs.seat?.coach?.number ?? null, - coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + coachSeat: bs.seat?.coach?.number && bs.seatLabelSnapshot + ? `${bs.seat.coach.number}·${bs.seatLabelSnapshot}` + : (bs.seatLabelSnapshot ?? '—'), + origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? '—') : '—', + destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? '—') : '—', + departureAt: schedule?.departureAt ?? null, })); } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 6d5301cfb..46bb34b76 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -600,6 +600,9 @@ export class SeatsService { async getBlockedSeats() { const blocks = await this.prisma.seatBlock.findMany({ + where: { + NOT: { reason: { startsWith: 'MAINTENANCE:' } }, + }, include: { seat: { include: { coach: { select: { number: true } } } }, }, diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 8c6958565..fa164dbca 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -20,16 +20,11 @@ interface PassengersReport { interface PassengerRow { bookingRef: string; - bookingStatus: string; passengerName: string; - passengerCategory: string; - idDocumentType: string | null; - idDocumentNumber: string | null; - passportNumber: string | null; - passportCountry: string | null; - seatLabel: string | null; - coachNumber: string | null; - coachType: string | null; + coachSeat: string; + origin: string; + destination: string; + departureAt: string | null; } type Tab = 'occupancy' | 'list'; @@ -60,9 +55,7 @@ export default function PassengersReportPage() { const filteredList = listSearch.trim() ? passengerList.filter(p => p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || - p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) || - (p.idDocumentNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()) || - (p.passportNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()), + p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()), ) : passengerList; @@ -82,11 +75,10 @@ export default function PassengersReportPage() { const doExportList = () => { if (!passengerList.length) return; - const headers = ['Booking Ref', 'Status', 'Name', 'Category', 'ID Type', 'ID Number', 'Passport', 'Country', 'Seat', 'Coach', 'Class']; - const rows = passengerList.map(p => [ - p.bookingRef, p.bookingStatus, p.passengerName, p.passengerCategory, - p.idDocumentType ?? '', p.idDocumentNumber ?? '', p.passportNumber ?? '', - p.passportCountry ?? '', p.seatLabel ?? '', p.coachNumber ?? '', p.coachType ?? '', + const headers = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref']; + const rows = passengerList.map((p, i) => [ + String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination, + p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef, ].map(v => `"${String(v).replace(/"/g, '""')}"`)); downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`); }; @@ -118,9 +110,6 @@ export default function PassengersReportPage() { {data && tab === 'occupancy' && ( Export CSV )} - {passengerList.length > 0 && tab === 'list' && ( - Export CSV - )}
{(isLoading || listLoading) &&

Loading…

} {isError &&

Failed to load report.

} @@ -267,62 +256,52 @@ export default function PassengersReportPage() {
)} - {/* Passenger List tab */} {tab === 'list' && ( -
- setListSearch(e.target.value)} - /> -
- - - - - - - - - - - - - - - {filteredList.map((p, i) => ( - - - - - - - - - +
+
+ setListSearch(e.target.value)} + /> + {passengerList.length > 0 && ( + Export CSV + )} +
+
+
+
#NameCategoryID / PassportSeatCoachBooking RefStatus
{i + 1}{p.passengerName} - - {p.passengerCategory} - - - {p.idDocumentNumber ?? p.passportNumber ?? '—'} - {p.passportCountry && ({p.passportCountry})} - {p.seatLabel ?? '—'} - {p.coachNumber ?? '—'} - {p.coachType && ({p.coachType})} - {p.bookingRef} - - {p.bookingStatus} - -
+ + + + + + + + + - ))} - {filteredList.length === 0 && ( - - )} - -
#NameCoach · SeatOriginDestinationDateBooking Ref
No passengers found
+ + + {filteredList.map((p, i) => ( + + {i + 1} + {p.passengerName} + {p.coachSeat} + {p.origin} + {p.destination} + {p.departureAt ? formatDateTime(p.departureAt) : '—'} + {p.bookingRef} + + ))} + {filteredList.length === 0 && ( + No passengers found + )} + + +
)} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index 196e83c46..f56f26e8e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -155,7 +155,7 @@ export default function SeatStatusReportPage() {
{/* Summary Cards */} -
+
@@ -206,20 +206,6 @@ export default function SeatStatusReportPage() {
- {blockedSeats.length > 0 && ( -
- {blockedSeats.map((b: any) => ( -
- - Seat {b.seatNumber} · Coach {b.coachNumber} - - - {b.reason} - -
- ))} -
- )}
From 20718d5b98cb22543f72a65a841f2fa7eeabacfa Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 19 Jul 2026 09:08:30 +0300 Subject: [PATCH 25/54] Build issue resolution --- .../src/app/reports/passengers/page.tsx | 538 +++++------------- .../backoffice/src/app/schedules/page.tsx | 2 +- 2 files changed, 148 insertions(+), 392 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 0170a3b8e..3a9a65353 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -1,264 +1,129 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Users, Armchair, TrendingUp, Train, Download } from "lucide-react"; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Cell, -} from "recharts"; -import { apiClient } from "@/lib/api-client"; -import { formatDateTime } from "@/lib/utils"; -import ActionButton from "@/components/ui/ActionButton"; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Users, Armchair, BarChart3, Train, Download } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import { formatDateTime } from '@/lib/utils'; +import ActionButton from '@/components/ui/ActionButton'; -const COLORS = [ - "#10b981", - "#3b82f6", - "#f59e0b", - "#8b5cf6", - "#ef4444", - "#06b6d4", -]; +interface ScheduleOption { id: string; label: string; } -function StatCard({ - label, - value, - sub, - icon: Icon, - color, -}: { - label: string; - value: string | number; - sub?: string; - icon: any; - color: string; -}) { - return ( -
-
-

- {label} -

-
- -
-
-

{value}

- {sub &&

{sub}

} -
- ); +interface PassengersReport { + schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; }; + summary: { totalSeats: number; totalPassengers: number; occupancyRate: number }; + byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[]; + byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[]; + byOrigin: { stationName: string; passengers: number }[]; + byDestination: { stationName: string; passengers: number }[]; } interface PassengerRow { bookingRef: string; - bookingStatus: string; passengerName: string; - passengerCategory: string; - idDocumentType: string | null; - idDocumentNumber: string | null; - passportNumber: string | null; - passportCountry: string | null; - seatLabel: string | null; - coachNumber: string | null; - coachType: string | null; + coachSeat: string; + origin: string; + destination: string; + departureAt: string | null; } -type Tab = "occupancy" | "list"; +type Tab = 'occupancy' | 'list'; export default function PassengersReportPage() { - const [scheduleId, setScheduleId] = useState(""); - const [tab, setTab] = useState("occupancy"); - const [listSearch, setListSearch] = useState(""); + const [scheduleId, setScheduleId] = useState(''); + const [tab, setTab] = useState('occupancy'); + const [listSearch, setListSearch] = useState(''); - const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery< - ScheduleOption[] - >({ - queryKey: ["report-schedules"], - queryFn: () => apiClient.get("/reports/schedules"), + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ['report-schedules'], + queryFn: () => apiClient.get('/reports/schedules'), }); const schedules = schedulesRaw ?? []; const { data, isLoading, isError } = useQuery({ - queryKey: ["passengers-report", scheduleId], - queryFn: () => - apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), + queryKey: ['passengers-report', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), enabled: !!scheduleId, }); - const { data: passengerList = [], isLoading: listLoading } = useQuery< - PassengerRow[] - >({ - queryKey: ["passengers-list", scheduleId], - queryFn: () => - apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`), + const { data: passengerList = [], isLoading: listLoading } = useQuery({ + queryKey: ['passengers-list', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`), enabled: !!scheduleId, }); const filteredList = listSearch.trim() - ? passengerList.filter( - (p) => - p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || - p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) || - (p.idDocumentNumber ?? "") - .toLowerCase() - .includes(listSearch.toLowerCase()) || - (p.passportNumber ?? "") - .toLowerCase() - .includes(listSearch.toLowerCase()), + ? passengerList.filter(p => + p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || + p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()), ) : passengerList; const downloadCsv = (csv: string, filename: string) => { - const blob = new Blob([csv], { type: "text/csv" }); + const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); + const a = document.createElement('a'); + a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }; const doExportOccupancy = () => { if (!data) return; - const rows = data.byCoach.map((c) => [ - c.coachNumber, - c.coachType, - String(c.totalSeats), - String(c.booked), - `${c.occupancyRate}%`, - ]); - downloadCsv( - [ - ["Coach", "Type", "Total Seats", "Booked", "Occupancy"].join(","), - ...rows.map((r) => r.join(",")), - ].join("\n"), - `occupancy-${scheduleId}.csv`, - ); + const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); + downloadCsv([['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n'), `occupancy-${scheduleId}.csv`); }; const doExportList = () => { if (!passengerList.length) return; - const headers = [ - "Booking Ref", - "Status", - "Name", - "Category", - "ID Type", - "ID Number", - "Passport", - "Country", - "Seat", - "Coach", - "Class", - ]; - const rows = passengerList.map((p) => - [ - p.bookingRef, - p.bookingStatus, - p.passengerName, - p.passengerCategory, - p.idDocumentType ?? "", - p.idDocumentNumber ?? "", - p.passportNumber ?? "", - p.passportCountry ?? "", - p.seatLabel ?? "", - p.coachNumber ?? "", - p.coachType ?? "", - ].map((v) => `"${String(v).replace(/"/g, '""')}"`), - ); - downloadCsv( - [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"), - `passengers-${scheduleId}.csv`, + const headers = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref']; + const rows = passengerList.map((p, i) => + [String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination, p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef] + .map(v => `"${String(v).replace(/"/g, '""')}"`) ); + downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`); }; return (
-

- Passengers Report -

-

- Select a schedule to view passenger occupancy breakdown -

+

Passengers Report

+

Occupancy and passenger breakdown for a schedule

{/* Schedule selector */}
-
-
+
+
- {data && tab === "occupancy" && ( - - Export CSV - - )} - {passengerList.length > 0 && tab === "list" && ( - - Export CSV - + {data && tab === 'occupancy' && ( + Export CSV )}
- {(isLoading || listLoading) && ( -

Loading…

- )} - {isError && ( -

Failed to load report.

- )} + {(isLoading || listLoading) &&

Loading…

} + {isError &&

Failed to load report.

}
- {isFetching && ( -
- Loading passengers data… -
- )} - - {report && ( + {data && ( <> - {/* Schedule Info */} + {/* Schedule info */}
-

{report.schedule.trainName}

+

{data.schedule.trainName}

- {report.schedule.origin} → {report.schedule.destination} · - Departure: {formatDateTime(report.schedule.departureAt)} + {data.schedule.origin} → {data.schedule.destination} · Departure: {formatDateTime(data.schedule.departureAt)}

@@ -266,75 +131,51 @@ export default function PassengersReportPage() { {/* Tabs */}
{/* Occupancy tab */} - {tab === "occupancy" && ( + {tab === 'occupancy' && (
-

- Total Seats -

-
- -
+

Total Seats

+
-

- {data.summary.totalSeats} -

+

{data.summary.totalSeats}

-

- Passengers -

-
- -
+

Passengers

+
-

- {data.summary.totalPassengers} -

+

{data.summary.totalPassengers}

-

- Occupancy Rate -

-
- -
+

Occupancy Rate

+
-

- {data.summary.occupancyRate}% -

+

{data.summary.occupancyRate}%

-
+
-

- By Coach -

+

By Coach

@@ -347,31 +188,18 @@ export default function PassengersReportPage() { - {data.byCoach.map((c) => ( + {data.byCoach.map(c => ( - - - - + + + + @@ -383,77 +211,46 @@ export default function PassengersReportPage() {
-

- By Class -

+

By Class

- {data.byClass.map((c) => ( + {data.byClass.map(c => (
{c.className} - - {c.booked}/{c.totalSeats} - + {c.booked}/{c.totalSeats}
-
+
- - {c.occupancyRate}% - + {c.occupancyRate}%
))}
-

- By Boarding Station -

+

By Boarding Station

- {data.byOrigin.map((o) => ( -
- - {o.stationName} - - - {o.passengers} - + {data.byOrigin.map(o => ( +
+ {o.stationName} + {o.passengers}
))} - {data.byOrigin.length === 0 && ( -

No data

- )} + {data.byOrigin.length === 0 &&

No data

}
-

- By Alighting Station -

+

By Alighting Station

- {data.byDestination.map((d) => ( -
- - {d.stationName} - - - {d.passengers} - + {data.byDestination.map(d => ( +
+ {d.stationName} + {d.passengers}
))} - {data.byDestination.length === 0 && ( -

No data

- )} + {data.byDestination.length === 0 &&

No data

}
@@ -461,101 +258,60 @@ export default function PassengersReportPage() { )} {/* Passenger List tab */} - {tab === "list" && ( -
- setListSearch(e.target.value)} - /> -
-
- {c.coachNumber} - - {c.coachType} - - {c.totalSeats} - - {c.booked} - {c.coachNumber}{c.coachType}{c.totalSeats}{c.booked}
-
+
- - {c.occupancyRate}% - + {c.occupancyRate}%
- - - - - - - - - - - - - - {filteredList.map((p, i) => ( - - - - - - - - - + {tab === 'list' && ( +
+
+ setListSearch(e.target.value)} + /> + {passengerList.length > 0 && ( + Export CSV + )} +
+
+
+
#NameCategoryID / PassportSeatCoachBooking RefStatus
- {i + 1} - - {p.passengerName} - - - {p.passengerCategory} - - - {p.idDocumentNumber ?? p.passportNumber ?? "—"} - {p.passportCountry && ( - - ({p.passportCountry}) - - )} - - {p.seatLabel ?? "—"} - - {p.coachNumber ?? "—"} - {p.coachType && ( - - ({p.coachType}) - - )} - - {p.bookingRef} - - - {p.bookingStatus} - -
+ + + + + + + + + - ))} - {filteredList.length === 0 && ( - - - - )} - -
#NameCoach · SeatOriginDestinationDateBooking Ref
- No passengers found -
+ + + {filteredList.map((p, i) => ( + + {i + 1} + {p.passengerName} + {p.coachSeat} + {p.origin} + {p.destination} + {p.departureAt ? formatDateTime(p.departureAt) : '—'} + {p.bookingRef} + + ))} + {filteredList.length === 0 && ( + No passengers found + )} + + +
)} )} - {!report && !isFetching && scheduleId && ( -
- No data found for this schedule. -
+ {!data && !isLoading && scheduleId && ( +
No data found for this schedule.
)} {!scheduleId && ( diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index d48e10d51..2298ebf9f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -184,7 +184,7 @@ export default function SchedulesPage() { plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', }; })); - }, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); + }, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); // eslint-disable-line react-hooks/exhaustive-deps const [filters, setFilters] = useState({ search: '', From 856d14387467551221ac561b315e90b043f123d2 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 19 Jul 2026 09:16:36 +0300 Subject: [PATCH 26/54] Reports update --- .../src/modules/reports/reports.service.ts | 50 ++++++++++++------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 9c16ff5b0..6bab9a316 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -266,7 +266,7 @@ export class ReportsService { where: { status: { in: ["CONFIRMED", "BOARDED"] } }, include: { seats: { - where: { scheduleId }, + where: { leg: 1 }, include: { seat: { include: { coach: { include: { coachType: true } } } }, }, @@ -432,33 +432,45 @@ export class ReportsService { async getPassengerList(scheduleId: string) { const seats = await this.prisma.bookingSeat.findMany({ where: { - scheduleId, - booking: { status: { in: ["CONFIRMED", "BOARDED"] } }, + leg: 1, + booking: { scheduleId, status: { in: ["CONFIRMED", "BOARDED"] } }, }, include: { - booking: { select: { bookingRef: true, status: true } }, - seat: { - include: { - coach: { - select: { number: true, coachType: { select: { name: true } } }, - }, + booking: { + select: { + bookingRef: true, + status: true, + originStationId: true, + destinationStationId: true, }, }, + seat: { include: { coach: { select: { number: true } } } }, }, - orderBy: [{ seat: { coach: { number: "asc" } } }], + orderBy: [{ seat: { coach: { number: "asc" } } }, { seat: { seatNumber: "asc" } }], }); + + const stationIds = [...new Set( + seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[], + )]; + const stations = stationIds.length > 0 + ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } }) + : []; + const stationName = new Map(stations.map(s => [s.id, s.name])); + + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { departureAt: true }, + }); + return seats.map((bs) => ({ bookingRef: bs.booking.bookingRef, - bookingStatus: bs.booking.status, passengerName: bs.passengerName, - passengerCategory: bs.passengerCategory, - idDocumentType: bs.idDocumentType, - idDocumentNumber: bs.idDocumentNumber, - passportNumber: bs.passportNumber, - passportCountry: bs.passportCountry, - seatLabel: bs.seatLabelSnapshot, - coachNumber: bs.seat?.coach?.number ?? null, - coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + coachSeat: bs.seat?.coach?.number && bs.seatLabelSnapshot + ? `${bs.seat.coach.number}·${bs.seatLabelSnapshot}` + : (bs.seatLabelSnapshot ?? '—'), + origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? '—') : '—', + destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? '—') : '—', + departureAt: schedule?.departureAt ?? null, })); } From 17dc505d503ec604bb878a22fbf2863da7d2bfdf Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 19 Jul 2026 07:06:58 +0000 Subject: [PATCH 27/54] streamline booking detail components and enhance journey visualization - Removed the BookingClearanceWorkflowBanner from ClearanceCard as the clearance progress is now integrated into the unified journey wizard. - Eliminated the MilestoneTimeline component from DocumentsTab, consolidating customs progress into the JourneyWizard. - Updated PageHeader to include a ContractReferenceLink for better navigation to contract details. - Simplified ShipmentTrackingCard to focus on duty/tax payment slip upload, removing unnecessary milestone display. - Integrated JourneyWizard component to visualize the booking journey, replacing the previous progress tracker. - Enhanced ContractDetailPage to better categorize documents and improve user experience with clearer sections for profile, business license, clearance, and other documents. - Introduced CSS for contracts table to manage column sizing and sticky headers effectively. - Added ContractReferenceLink component for backoffice to link to contract details, ensuring consistent navigation across applications. --- .../bookings/ContractReferenceLink.tsx | 43 ++++ .../bookings/detail/BookingRequestHero.tsx | 13 +- .../features/bookings/mapBookingListRow.ts | 1 + .../backoffice/src/lib/queryClient.ts | 3 +- .../pages/bookings/BookingRequestsPage.tsx | 13 +- .../backoffice/src/types/booking.ts | 2 + .../MyPortalPage/components/BookingRow.tsx | 2 + .../components/ClearanceCard.tsx | 6 +- .../components/DocumentsTab.tsx | 124 +--------- .../components/JourneyWizard.tsx | 211 ++++++++++++++++++ .../components/PageHeader.tsx | 14 +- .../components/ShipmentTrackingCard.tsx | 99 +------- .../components/StatusHero.tsx | 140 ++---------- .../src/pages/bookings/booking-display.tsx | 35 +++ .../pages/contracts/ContractDetailPage.tsx | 171 ++++++++------ .../src/pages/contracts/ContractsList.tsx | 14 +- .../src/pages/contracts/NewShipmentPage.tsx | 1 - .../src/pages/contracts/contracts-table.css | 78 +++++++ 18 files changed, 548 insertions(+), 422 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/JourneyWizard.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/contracts-table.css diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx new file mode 100644 index 000000000..94061eee2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx @@ -0,0 +1,43 @@ +import { Link } from "react-router-dom"; + +/** + * The parent contract's reference, linking to that contract's detail page. + * + * Backoffice-local on purpose: the contract detail route differs per app + * (`/dashboard/contract-requests/:id` here vs `/contracts/:id` in the portal), + * so the portal keeps its own copy in `pages/bookings/booking-display.tsx` + * rather than the two sharing a component that would have to take the route as + * a prop at every call site. + * + * Renders nothing when either field is missing: `contractId` is nullable on the + * booking, and only the bookings list/detail endpoints join `contractReference` + * — other endpoints (warehouse, fleet, payments) return booking rows without it, + * and a link with no id would be a dead one. + * + * `stopPropagation` matters: booking rows are click-to-navigate, so without it a + * click here would race the row handler and land on the booking instead. + */ +export function ContractReferenceLink({ + contractId, + contractReference, + className, +}: { + contractId?: string | null; + contractReference?: string | null; + className?: string; +}) { + if (!contractId || !contractReference) return null; + + return ( + e.stopPropagation()} + className={ + className ?? + "block truncate font-mono text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground" + } + > + {contractReference} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index aa14fe8cd..3ee5d72a0 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -24,6 +24,7 @@ import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { NextStepBanner } from "@/components/bookings/NextStepBanner"; @@ -94,9 +95,15 @@ export function BookingRequestHero({ Booking reference - - {booking.reference} - + + + {booking.reference} + + + {booking.schedulingStatus ? ( diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index bc10fd064..a0594094c 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -19,6 +19,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow { id: booking.id, reference: booking.reference, contractReference: booking.contractReference ?? null, + contractId: booking.contractId ?? null, approvalSteps: booking.approvalSteps, customerLabel: booking.isGovernment ? (booking.governmentInstitution ?? "Government") diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index 5b2bcb255..0a270372e 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -27,7 +27,8 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, - staleTime: 30_000, + // staleTime: 30_000, + staleTime:0, // Data freshness is driven by mutation invalidation (MutationCache above), // socket pushes, and explicit polling — not by tab focus. Focus refetch // just re-fires every mounted query each time the window is refocused. diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 72c16215b..3eb775e95 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -48,6 +48,7 @@ import { useBookingList, useBookingListSummary, } from "@/hooks/bookings/useBookings"; +import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { api } from "@/services/api"; import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListRow } from "@/types/booking"; @@ -308,7 +309,17 @@ export default function BookingRequestsPage() { return (
{ref ? ( - {ref} + // Fall back to plain text when the id is missing — the reference is + // still worth showing, it just has nowhere to link to. + (row.original.contractId ? ( + + ) : ( + {ref} + )) ) : ( )} diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index ec31e11fa..4b5b5257b 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -231,6 +231,8 @@ export interface BookingListRow { id: string; reference: string; contractReference?: string | null; + /** Needed to link the reference to the contract's detail page. */ + contractId?: string | null; customerLabel: string; approvalSteps?: BookingApprovalStep[]; status: BookingStatus; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx index 007fc7fe9..56226594e 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx @@ -10,6 +10,7 @@ import { bookingIsSignable, } from "@/pages/bookings/contract/ContractSignButton"; import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton"; +import { ContractReferenceLink } from "@/pages/bookings/booking-display"; interface BookingRowProps { booking: any; @@ -73,6 +74,7 @@ export const BookingRow = memo(function BookingRow({ {booking.reference} + {commodity} · {origin} → {dest} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index dcc455ada..a8cc43e14 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -13,7 +13,6 @@ import type { Freight } from "@edr/types"; import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal"; import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction"; -import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner"; import { CardTitle, SectionCard } from "./layout"; @@ -65,8 +64,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { return ( - - + {/* The "Clearance progress" stepper moved into the unified journey + wizard at the top of the page — this card keeps only the actions. */} + Clearance documents {action && (