From b491f39d4266dcb29b8aec02cfff5346a667b0ca Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 18 Jun 2026 14:02:11 +0300 Subject: [PATCH] Booking and pricing related updates --- .../migration.sql | 78 ++- apps/edr-passenger-api/prisma/schema.prisma | 52 +- apps/edr-passenger-api/prisma/seed.ts | 29 +- .../modules/fare-engine/fare-engine.dto.ts | 3 + .../fare-engine/fare-engine.service.ts | 99 +++- .../modules/schedules/schedules.controller.ts | 141 ++--- .../modules/schedules/schedules.service.ts | 111 ++-- .../src/modules/search/search.service.ts | 1 + .../backoffice/src/app/currencies/page.tsx | 507 +++++------------- .../backoffice/src/app/pricing/page.tsx | 15 +- 10 files changed, 410 insertions(+), 626 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql index 4f828f075..f252642f6 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql @@ -1,28 +1,52 @@ --- Fix missing columns from 20260617 migration (failed due to missing schema prefix) +-- Create passenger schema if it doesn't exist +CREATE SCHEMA IF NOT EXISTS passenger; + +-- Move all enums from public to passenger schema +DO $$ +DECLARE + e text; +BEGIN + FOR e IN + SELECT typname FROM pg_type + JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e' + LOOP + EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e); + END LOOP; +END $$; + +-- Move all tables from public to passenger schema +DO $$ +DECLARE + t text; +BEGIN + FOR t IN + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations') + LOOP + EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t); + END LOOP; +END $$; + +-- Add missing columns to Booking ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT, ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT, ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT, ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT, - ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT; + ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT, + ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3); -ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; -ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; - -CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType"); - --- Transit leg-2 columns (never migrated) -ALTER TABLE "passenger"."Booking" - ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT; - --- ReturnLegStatus enum + columns (from 20260625 migration, may have also failed) +-- Add ReturnLegStatus enum and column DO $$ BEGIN CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ( 'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED' @@ -30,9 +54,15 @@ DO $$ BEGIN EXCEPTION WHEN duplicate_object THEN NULL; END $$; ALTER TABLE "passenger"."Booking" - ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', - ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3), - ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3); + ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE'; -ALTER TABLE "passenger"."GateValidationLog" - ADD COLUMN IF NOT EXISTS "leg" TEXT; +-- Add missing columns to other tables +ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT; +ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT; +ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3); + +ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; +ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; + +CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 1b98bd3e1..423a03ec9 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["multiSchema"] } datasource db { @@ -79,7 +80,6 @@ model CoachType { updatedAt DateTime @updatedAt coaches Coach[] seatClasses SeatClass[] - @@schema("passenger") } @@ -116,11 +116,11 @@ enum BookingStatus { } enum ReturnLegStatus { - NOT_APPLICABLE // one-way booking - BOTH_USED // passenger used both legs - OUTBOUND_ONLY // return leg not used (no-show on return) - INBOUND_ONLY // outbound leg not used, return leg used - NEITHER_USED // neither leg boarded yet + NOT_APPLICABLE + BOTH_USED + OUTBOUND_ONLY + INBOUND_ONLY + NEITHER_USED @@schema("passenger") } @@ -269,7 +269,6 @@ model User { fraudAlerts FraudAlert[] faydaVerificationSessions FaydaVerificationSession[] - @@schema("passenger") } @@ -283,7 +282,6 @@ model Session { lastActivityAt DateTime @default(now()) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@schema("passenger") } @@ -315,7 +313,6 @@ model TravelerProfile { notes String? createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) - @@schema("passenger") } @@ -350,7 +347,6 @@ model Train { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt schedules TrainSchedule[] - @@schema("passenger") } @@ -412,7 +408,6 @@ model TripLiveStatus { platformLabel String? updatedAt DateTime @updatedAt schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - @@schema("passenger") } @@ -500,7 +495,6 @@ model FareRule { validFrom DateTime validUntil DateTime? createdAt DateTime @default(now()) - @@schema("passenger") } @@ -584,7 +578,6 @@ model BookingSeat { displayFareMinor Int? booking Booking @relation(fields: [bookingId], references: [id]) seat Seat @relation(fields: [seatId], references: [id]) - @@schema("passenger") } @@ -600,7 +593,6 @@ model PaymentMethod { sortOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@schema("passenger") } @@ -661,7 +653,6 @@ model PaymentRefund { status String createdAt DateTime @default(now()) paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id]) - @@schema("passenger") } @@ -681,7 +672,6 @@ model Ticket { booking Booking @relation(fields: [bookingId], references: [id]) validationLogs GateValidationLog[] seats TicketSeat[] - @@schema("passenger") } @@ -709,7 +699,6 @@ model LoyaltyAccount { passenger Passenger @relation(fields: [passengerId], references: [id]) ledger LoyaltyLedgerEntry[] rewards LoyaltyReward[] - @@schema("passenger") } @@ -722,7 +711,6 @@ model LoyaltyLedgerEntry { balanceAfter Int createdAt DateTime @default(now()) account LoyaltyAccount @relation(fields: [accountId], references: [id]) - @@schema("passenger") } @@ -734,7 +722,6 @@ model LoyaltyReward { available Boolean @default(true) description String? account LoyaltyAccount @relation(fields: [accountId], references: [id]) - @@schema("passenger") } @@ -763,7 +750,6 @@ model WalletLedgerEntry { relatedBookingId String? createdAt DateTime @default(now()) wallet WalletAccount @relation(fields: [walletId], references: [id]) - @@schema("passenger") } @@ -778,7 +764,6 @@ model Notification { metadata Json? createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) - @@schema("passenger") } @@ -794,7 +779,6 @@ model Promotion { deepLink String? active Boolean @default(true) createdAt DateTime @default(now()) - @@schema("passenger") } @@ -808,7 +792,6 @@ model StationCrowdSignal { observedAt DateTime? updatedAt DateTime @updatedAt station Station @relation(fields: [stationId], references: [id]) - @@schema("passenger") } @@ -820,7 +803,6 @@ model WeatherAlert { message String validUntil DateTime createdAt DateTime @default(now()) - @@schema("passenger") } @@ -828,7 +810,6 @@ model MenuCategory { id String @id @default(uuid()) name String items MenuItem[] - @@schema("passenger") } @@ -843,7 +824,6 @@ model MenuItem { availableUntil DateTime? schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) category MenuCategory @relation(fields: [categoryId], references: [id]) - @@schema("passenger") } @@ -858,7 +838,6 @@ model FoodOrder { createdAt DateTime @default(now()) booking Booking @relation(fields: [bookingId], references: [id]) items FoodOrderItem[] - @@schema("passenger") } @@ -871,7 +850,6 @@ model FoodOrderItem { unitPriceMinor Int? lineTotalMinor Int order FoodOrder @relation(fields: [orderId], references: [id]) - @@schema("passenger") } @@ -880,7 +858,6 @@ model FaqCategory { title String iconKey String? articles FaqArticle[] - @@schema("passenger") } @@ -891,7 +868,6 @@ model FaqArticle { answerMarkdown String rank Int @default(0) category FaqCategory @relation(fields: [categoryId], references: [id]) - @@schema("passenger") } @@ -902,7 +878,6 @@ model SupportConversation { status SupportConversationStatus @default(OPEN) createdAt DateTime @default(now()) messages SupportMessage[] - @@schema("passenger") } @@ -914,7 +889,6 @@ model SupportMessage { attachments Json? createdAt DateTime @default(now()) conversation SupportConversation @relation(fields: [conversationId], references: [id]) - @@schema("passenger") } @@ -934,7 +908,6 @@ model UserPreferences { darkMode Boolean @default(false) language String @default("en") user User @relation(fields: [userId], references: [id]) - @@schema("passenger") } @@ -947,7 +920,6 @@ model Device { trusted Boolean @default(false) lastSeenAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) - @@schema("passenger") } @@ -961,7 +933,6 @@ model SavedRoute { tripCount Int @default(0) createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) - @@schema("passenger") } @@ -973,7 +944,6 @@ model Journey { currency String @default("ETB") createdAt DateTime @default(now()) journeySegments JourneySegment[] - @@schema("passenger") } @@ -988,7 +958,6 @@ model JourneySegment { arrivalStationId String journey Journey @relation(fields: [journeyId], references: [id]) schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - @@schema("passenger") } @@ -1032,7 +1001,6 @@ model Route { fareRules RouteFareRule[] segmentFares SegmentFareRule[] schedules TrainSchedule[] - @@schema("passenger") } @@ -1102,7 +1070,6 @@ model Agent { bookings AgentBooking[] shifts AgentShift[] commissions AgentCommission[] - @@schema("passenger") } @@ -1117,7 +1084,6 @@ model AgentBooking { createdAt DateTime @default(now()) agent Agent @relation(fields: [agentId], references: [id]) booking Booking @relation(fields: [bookingId], references: [id]) - @@schema("passenger") } @@ -1177,7 +1143,6 @@ model BookingCancellation { processedAt DateTime? createdAt DateTime @default(now()) booking Booking @relation(fields: [bookingId], references: [id]) - @@schema("passenger") } @@ -1205,7 +1170,6 @@ model BaggageAllowance { excessFeePerKg Int currency String @default("ETB") createdAt DateTime @default(now()) - @@schema("passenger") } @@ -1249,7 +1213,6 @@ model NotificationTemplate { bodyTemplate String active Boolean @default(true) createdAt DateTime @default(now()) - @@schema("passenger") } @@ -1288,7 +1251,6 @@ model FraudRule { config Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@schema("passenger") } diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 14907f87e..9e1fffa44 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -212,7 +212,7 @@ async function seedRoute() { create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] }, }); } - + const returnRoute = await prisma.route.upsert({ where: { code: 'Route-102' }, update: {}, @@ -317,7 +317,7 @@ async function seedTrips() { const now = new Date(); const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1); - + const schedules = []; for (let d = 0; d < 5; d++) { @@ -403,7 +403,7 @@ async function seedTrips() { const coachAssignments = []; const liveStatuses = []; - + for (const schedule of createdSchedules) { for (let p = 0; p < coaches.length; p++) { coachAssignments.push({ @@ -418,12 +418,12 @@ async function seedTrips() { progressPercent: 0, }); } - + await Promise.all([ ...coachAssignments.map(ca => prisma.coachAssignment.create({ data: ca })), ...liveStatuses.map(ls => prisma.tripLiveStatus.create({ data: ls })), ]); - + console.log(` ✅ Train with ${createdSchedules.length} upcoming trips created`); } @@ -453,7 +453,7 @@ async function seedFareRules() { validFrom, }); } - + await Promise.all( fareRules.map(fr => prisma.routeFareRule.create({ data: fr })) ); @@ -518,7 +518,7 @@ async function seedSegmentFares() { include: { stops: { orderBy: { sequence: 'asc' } } }, }); const seatClasses = await prisma.seatClass.findMany(); - const validFrom = new Date('2024-01-01'); + const validFrom = new Date('2026-01-01'); if (route && route.stops.length > 2) { for (const sc of seatClasses) { @@ -531,7 +531,7 @@ async function seedSegmentFares() { baseFareMinor: Math.floor(sc.baseFareMinor * 0.4), validFrom, }, - }).catch(() => {}); + }).catch(() => { }); await prisma.segmentFareRule.create({ data: { @@ -542,7 +542,7 @@ async function seedSegmentFares() { baseFareMinor: Math.floor(sc.baseFareMinor * 0.6), validFrom, }, - }).catch(() => {}); + }).catch(() => { }); } console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`); } @@ -586,16 +586,16 @@ async function seedMenuAndFood() { const coffeeId = uuidv4(); const juiceId = uuidv4(); const sandwichId = uuidv4(); - + await prisma.menuItem.create({ data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 }, - }).catch(() => {}); // ignore if exists + }).catch(() => { }); // ignore if exists await prisma.menuItem.create({ data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 }, - }).catch(() => {}); // ignore if exists + }).catch(() => { }); // ignore if exists await prisma.menuItem.create({ data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 }, - }).catch(() => {}); // ignore if exists + }).catch(() => { }); // ignore if exists } console.log(` ✅ Menu categories and items created`); } @@ -682,6 +682,9 @@ async function main() { const steps: Array<[string, () => Promise]> = [ ['system users', seedSystemUsers], + ['fare rules', seedFareRules], + ['segment fares', seedSegmentFares], + ['currency', seedCurrency] ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts index 536ddaa3b..5d3a288cd 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -47,6 +47,9 @@ export class FareCalculateDto { @ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' }) @IsOptional() @IsString() promoCode?: string; + + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Schedule UUID — used to match schedule-scoped FareRules first' }) + @IsOptional() @IsString() scheduleId?: string; } export class FareBreakdownDto { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index b111b5d67..9071f0535 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -32,20 +32,59 @@ export class FareEngineService { s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence, ); - const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined); - if (missingDistance.length > 0) - throw new BadRequestException( - `Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`, - ); - - const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0); - const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }); if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); - const ratePerKmMinor = seatClass.baseFareMinor; - const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; + // Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate + const now = new Date(); + const [originStation, destStation] = await Promise.all([ + this.prisma.station.findUnique({ where: { id: dto.originStationId } }), + this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }), + ]); + const segmentRoute = originStation && destStation + ? `${originStation.code}-${destStation.code}` : null; + const fullRoute = `${route.code}`; + + const fareRuleCandidates = await this.prisma.fareRule.findMany({ + where: { + seatClassId: dto.seatClassId, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + }); + + const fareRule = this.pickBestFareRule( + fareRuleCandidates, + dto.scheduleId, + segmentRoute, + fullRoute, + dto.nationality, + ); + + let baseFarePerPassengerMinor: number; + let ratePerKmMinor: number; + let totalDistanceKm: number; + let fareSource: string; + + if (fareRule) { + // Flat fare from FareRule — distance is informational only + baseFarePerPassengerMinor = fareRule.baseFareMinor; + totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0); + ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; + fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE'; + } else { + // Distance × rate fallback + const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined); + if (missingDistance.length > 0) + throw new BadRequestException( + `Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`, + ); + totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0); + ratePerKmMinor = seatClass.baseFareMinor; + baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; + fareSource = 'DISTANCE_RATE'; + } // Premium and insurance fees applied per passenger const premiumPerPassenger = seatClass.premiumMinor ?? 0; @@ -84,11 +123,6 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate); - const [originStation, destStation] = await Promise.all([ - this.prisma.station.findUnique({ where: { id: dto.originStationId } }), - this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }), - ]); - const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, `Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`, @@ -110,9 +144,11 @@ export class FareEngineService { `Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`, `Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`, `Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`, + `Fare source: ${fareSource}`, ].join('\n'); return { + fareSource, routeCode: route.code, originName: originStation?.name ?? dto.originStationId, destinationName: destStation?.name ?? dto.destinationStationId, @@ -161,6 +197,37 @@ export class FareEngineService { return results.filter(Boolean); } + private pickBestFareRule( + candidates: any[], + scheduleId?: string, + segmentRoute?: string | null, + fullRoute?: string, + nationality?: string, + ): any | null { + const nat = nationality ?? null; + const priorities = [ + { tripId: scheduleId, route: segmentRoute, nationality: nat }, + { tripId: scheduleId, route: segmentRoute, nationality: null }, + { tripId: scheduleId, route: fullRoute, nationality: nat }, + { tripId: scheduleId, route: fullRoute, nationality: null }, + { tripId: scheduleId, route: null, nationality: nat }, + { tripId: scheduleId, route: null, nationality: null }, + { tripId: null, route: segmentRoute, nationality: nat }, + { tripId: null, route: segmentRoute, nationality: null }, + { tripId: null, route: fullRoute, nationality: nat }, + { tripId: null, route: fullRoute, nationality: null }, + { tripId: null, route: null, nationality: nat }, + { tripId: null, route: null, nationality: null }, + ]; + for (const p of priorities) { + const match = candidates.find( + c => c.tripId === p.tripId && c.route === p.route && c.nationality === p.nationality, + ); + if (match) return match; + } + return null; + } + async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, @@ -175,6 +242,7 @@ export class FareEngineService { destinationStationId: schedule.destinationStationId, seatClassId, nationality, + scheduleId, }); } @@ -199,6 +267,7 @@ export class FareEngineService { destinationStationId: schedule.destinationStationId, seatClassId: sc.id, nationality, + scheduleId, }).catch(() => null), ), ); diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 8cb8ea253..378bc8fa7 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -12,34 +12,22 @@ export class SchedulesController { @Post('bulk-generate') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Bulk generate repetitive schedules', - description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.', - }) - @ApiResponse({ status: 201, description: 'Schedules generated successfully' }) - @ApiResponse({ status: 400, description: 'Invalid parameters or route not found' }) + @ApiOperation({ summary: 'Bulk generate repetitive schedules' }) bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) { return this.service.bulkGenerateSchedules(dto); } @Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Create a train schedule from a route template', - description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`, - }) - @ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' }) - @ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' }) - @ApiResponse({ status: 404, description: 'Train or route not found' }) + @ApiOperation({ summary: 'Create a train schedule from a route template' }) createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); } @Get() @ApiOperation({ summary: 'List schedules with optional filters' }) - @ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' }) - @ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' }) - @ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' }) - @ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' }) - @ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' }) + @ApiQuery({ name: 'date', required: false }) + @ApiQuery({ name: 'routeId', required: false }) + @ApiQuery({ name: 'trainId', required: false }) + @ApiQuery({ name: 'status', required: false, enum: TripStatus }) listSchedules( @Query('date') date?: string, @Query('routeId') routeId?: string, @@ -57,57 +45,63 @@ export class SchedulesController { @ApiResponse({ status: 201, description: 'Fare rule created' }) createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); } + @Patch('fares/:id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update a fare rule' }) + @ApiParam({ name: 'id', description: 'FareRule UUID' }) + @ApiResponse({ status: 200, description: 'Fare rule updated' }) + updateFareRule(@Param('id') id: string, @Body() dto: Partial) { + return this.service.updateFareRule(id, dto); + } + + @Delete('fares/:id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete a fare rule' }) + @ApiParam({ name: 'id', description: 'FareRule UUID' }) + @ApiResponse({ status: 200, description: 'Fare rule deleted' }) + deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); } + @Post('segment-fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' }) - @ApiResponse({ status: 201, description: 'Segment fare rule created' }) + @ApiOperation({ summary: 'Create a segment fare rule' }) createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } @Get('routes/:routeId/segment-fares') @ApiOperation({ summary: 'List all segment fare rules for a route' }) @ApiParam({ name: 'routeId', description: 'Route UUID' }) - @ApiResponse({ status: 200, description: 'List of segment fare rules' }) getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); } @Patch('segment-fares/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) - @ApiResponse({ status: 200, description: 'Segment fare rule updated' }) updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); } @Delete('segment-fares/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) - @ApiResponse({ status: 200, description: 'Segment fare rule deleted' }) deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); } // ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) ===== @Get(':id') - @ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' }) + @ApiOperation({ summary: 'Get schedule detail' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); } @Patch(':id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' }) + @ApiOperation({ summary: 'Update a schedule (partial)' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Schedule updated' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) { return this.service.updateSchedulePartial(id, dto); } @Patch(':id/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' }) + @ApiOperation({ summary: 'Update schedule status' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Status updated' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) { return this.service.updateScheduleStatus(id, dto); } @@ -116,26 +110,18 @@ export class SchedulesController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Schedule deleted' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) - deleteSchedule(@Param('id') id: string) { - return this.service.deleteSchedule(id); - } + deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); } @Get(':id/stops') - @ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' }) + @ApiOperation({ summary: 'List all stops for a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) getStops(@Param('id') id: string) { return this.service.getStops(id); } @Patch(':id/stops/:sequence') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update planned times or live status of a specific stop' }) + @ApiOperation({ summary: 'Update a stop time' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'sequence', description: 'Stop sequence number' }) - @ApiResponse({ status: 200, description: 'Stop updated' }) - @ApiResponse({ status: 404, description: 'Stop not found on schedule' }) updateStop( @Param('id') id: string, @Param('sequence', ParseIntPipe) sequence: number, @@ -145,19 +131,26 @@ export class SchedulesController { @Get(':scheduleId/fares/stored') @ApiOperation({ summary: 'Get stored fare rules for a schedule' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' }) getStoredFares(@Param('scheduleId') scheduleId: string) { return this.service.getFareRules(scheduleId); } - @Get(':scheduleId/fares') - @ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' }) + @Get(':scheduleId/fares/all') + @ApiOperation({ summary: 'Get fares for all active seat classes from the fare engine' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' }) - @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' }) - @ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' }) - @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' }) - @ApiResponse({ status: 404, description: 'Schedule or seat class not found' }) + @ApiQuery({ name: 'nationality', required: false }) + getAllFares( + @Param('scheduleId') scheduleId: string, + @Query('nationality') nationality?: string, + ) { + return this.service.getAllFaresFromEngine(scheduleId, nationality); + } + + @Get(':scheduleId/fares') + @ApiOperation({ summary: 'Get fare for a specific seat class from the fare engine' }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'seatClassId', required: true }) + @ApiQuery({ name: 'nationality', required: false }) getFare( @Param('scheduleId') scheduleId: string, @Query('seatClassId') seatClassId: string, @@ -166,42 +159,15 @@ export class SchedulesController { return this.service.getFareFromEngine(scheduleId, seatClassId, nationality); } - @Get(':scheduleId/fares/all') - @ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' }) - @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' }) - @ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' }) - @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) - getAllFares( - @Param('scheduleId') scheduleId: string, - @Query('nationality') nationality?: string, - ) { - return this.service.getAllFaresFromEngine(scheduleId, nationality); - } - @Post(':id/fares/sync') - @ApiOperation({ - summary: 'Sync fares from fare engine', - description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.', - }) + @ApiOperation({ summary: 'Sync fares from fare engine' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' }) - @ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) - syncFares(@Param('id') id: string) { - return this.service.syncFaresFromEngine(id); - } + syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); } @Post(':id/coaches') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Assign coaches to a schedule', - description: 'Assigns selected coaches to a schedule with their position numbers. Replaces any existing coach assignments.' - }) + @ApiOperation({ summary: 'Assign coaches to a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 201, description: 'Coaches assigned successfully' }) - @ApiResponse({ status: 404, description: 'Schedule or coach not found' }) assignCoaches( @Param('id') id: string, @Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> }, @@ -212,21 +178,14 @@ export class SchedulesController { @Get(':id/coaches') @ApiOperation({ summary: 'Get assigned coaches for a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'List of assigned coaches with seat details' }) - getAssignedCoaches(@Param('id') id: string) { - return this.service.getAssignedCoaches(id); - } + getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); } @Delete(':id/coaches/:coachId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Remove a coach assignment from a schedule' }) + @ApiOperation({ summary: 'Remove a coach assignment' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'coachId', description: 'Coach UUID' }) - @ApiResponse({ status: 200, description: 'Coach assignment removed' }) - removeCoachAssignment( - @Param('id') id: string, - @Param('coachId') coachId: string, - ) { + removeCoachAssignment(@Param('id') id: string, @Param('coachId') coachId: string) { return this.service.removeCoachAssignment(id, coachId); } } 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 fd0776141..0234824d1 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -18,7 +18,6 @@ export class SchedulesService { const errors: string[] = []; const scheduleIds: string[] = []; - // Validate route and get stops for plannedTimes generation const route = await this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, @@ -45,7 +44,6 @@ export class SchedulesService { const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); - // Assign coaches if provided if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, @@ -58,15 +56,10 @@ export class SchedulesService { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); } - // Move to next repetition currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000); } - return { - schedulesCreated: scheduleCount, - errors, - scheduleIds, - }; + return { schedulesCreated: scheduleCount, errors, scheduleIds }; } async listSchedules(dto: ListSchedulesDto) { @@ -104,7 +97,6 @@ export class SchedulesService { const arr = new Date(dto.arrivalAt); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); - // Validate route exists and has stops const route = await this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, @@ -113,21 +105,13 @@ export class SchedulesService { if (!route.active) throw new BadRequestException('Route is not active'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); - // Check for duplicate schedule with same train, route, and date const depDate = new Date(dep); depDate.setHours(0, 0, 0, 0); const nextDay = new Date(depDate); nextDay.setDate(nextDay.getDate() + 1); const existingSchedule = await this.prisma.trainSchedule.findFirst({ - where: { - trainId: dto.trainId, - routeId: dto.routeId, - departureAt: { - gte: depDate, - lt: nextDay, - }, - }, + where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } }, }); if (existingSchedule) { @@ -136,7 +120,6 @@ export class SchedulesService { ); } - // Auto-generate plannedTimes if not provided or empty let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { const totalDuration = arr.getTime() - dep.getTime(); @@ -144,7 +127,6 @@ export class SchedulesService { plannedTimes = route.stops.map((stop, index) => { let stopTime: Date; - if (index === 0) { stopTime = dep; } else if (index === route.stops.length - 1) { @@ -154,7 +136,6 @@ export class SchedulesService { 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(), @@ -163,14 +144,12 @@ export class SchedulesService { }); } - // Validate all route stop sequences are covered by plannedTimes const providedSeqs = new Set(plannedTimes.map(t => t.sequence)); const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq)); if (missingSeqs.length > 0) { throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`); } - // Derive origin and destination from first and last route stop const firstStop = route.stops[0]; const lastStop = route.stops[route.stops.length - 1]; @@ -188,9 +167,7 @@ export class SchedulesService { include: { train: true, originStation: true, destinationStation: true }, }); - const plannedTimesMap = Object.fromEntries( - plannedTimes.map(t => [t.sequence, t]), - ); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); return this.getSchedule(schedule.id); @@ -230,10 +207,7 @@ export class SchedulesService { }; } - private async resolveEffectiveStatuses( - scheduleId: string, - seatIds: string[], - ): Promise> { + private async resolveEffectiveStatuses(scheduleId: string, seatIds: string[]): Promise> { const statusMap = new Map(); if (seatIds.length === 0) return statusMap; @@ -304,7 +278,6 @@ export class SchedulesService { plannedTimes = route.stops.map((stop, index) => { let stopTime: Date; - if (index === 0) { stopTime = dep; } else if (index === route.stops.length - 1) { @@ -314,7 +287,6 @@ export class SchedulesService { 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(), @@ -323,9 +295,7 @@ export class SchedulesService { }); } - const plannedTimesMap = Object.fromEntries( - plannedTimes.map(t => [t.sequence, t]), - ); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap); return this.getSchedule(id); @@ -376,9 +346,35 @@ export class SchedulesService { validFrom: new Date(validFrom), validUntil: validUntil ? new Date(validUntil) : null, }, + include: { seatClass: true }, }); } + async updateFareRule(id: string, dto: Partial) { + const existing = await this.prisma.fareRule.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Fare rule not found'); + + const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; + return this.prisma.fareRule.update({ + where: { id }, + data: { + ...rest, + ...(scheduleId !== undefined && { tripId: scheduleId }), + ...(nationality !== undefined && { nationality }), + ...(validFrom && { validFrom: new Date(validFrom) }), + ...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }), + }, + include: { seatClass: true }, + }); + } + + async deleteFareRule(id: string) { + const existing = await this.prisma.fareRule.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Fare rule not found'); + await this.prisma.fareRule.delete({ where: { id } }); + return { deleted: true, id }; + } + createSegmentFareRule(dto: any) { const { validFrom, validUntil, passengerCategory, ...rest } = dto; return this.prisma.segmentFareRule.create({ @@ -419,7 +415,6 @@ export class SchedulesService { async getFareRules(scheduleId?: string) { const where: any = {}; if (scheduleId) where.tripId = scheduleId; - return this.prisma.fareRule.findMany({ where, include: { seatClass: true }, @@ -439,11 +434,10 @@ export class SchedulesService { }); if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route'); - return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality); } catch (error) { throw new BadRequestException( - error instanceof Error ? error.message : 'Failed to calculate fares for schedule' + error instanceof Error ? error.message : 'Failed to calculate fares for schedule', ); } } @@ -483,20 +477,13 @@ export class SchedulesService { return { synced, errors }; } - async assignCoaches( - scheduleId: string, - coaches: Array<{ coachId: string; positionNumber: number }>, - ) { + async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); if (!schedule) throw new NotFoundException('Schedule not found'); const coachIds = coaches.map(c => c.coachId); - const existingCoaches = await this.prisma.coach.findMany({ - where: { id: { in: coachIds } }, - }); - if (existingCoaches.length !== coachIds.length) { - throw new NotFoundException('One or more coaches not found'); - } + const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } }); + if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found'); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); @@ -508,20 +495,13 @@ export class SchedulesService { })); await this.prisma.coachAssignment.createMany({ data }); - return { message: 'Coaches assigned successfully', count: coaches.length }; } async getAssignedCoaches(scheduleId: string) { return this.prisma.coachAssignment.findMany({ where: { scheduleId }, - include: { - coach: { - include: { - seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, - }, - }, - }, + include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } }, orderBy: { positionNumber: 'asc' }, }); } @@ -535,30 +515,22 @@ export class SchedulesService { if (dto.departureAt || dto.arrivalAt) { const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt); const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt); - if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time'); - updateData.departureAt = dep; updateData.arrivalAt = arr; updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000); } - if (dto.status) { - updateData.status = dto.status; - } + if (dto.status) updateData.status = dto.status; if (Object.keys(updateData).length > 0) { - await this.prisma.trainSchedule.update({ - where: { id }, - data: updateData, - }); + await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); } if (dto.coaches !== undefined) { if (dto.coaches.length > 0) { await this.assignCoaches(id, dto.coaches); } else { - // Remove all coach assignments when empty array is sent await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); } } @@ -567,12 +539,9 @@ export class SchedulesService { } async removeCoachAssignment(scheduleId: string, coachId: string) { - const assignment = await this.prisma.coachAssignment.findFirst({ - where: { scheduleId, coachId }, - }); + const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } }); if (!assignment) throw new NotFoundException('Coach assignment not found'); - await this.prisma.coachAssignment.delete({ where: { id: assignment.id } }); return { message: 'Coach assignment removed' }; } -} \ No newline at end of file +} 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 9646c20ea..0ca9a8723 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -441,6 +441,7 @@ export class SearchService { destinationStationId, seatClassId: sc.id, nationality, + scheduleId: schedule.id, }); return { seatClassName: fare.seatClassName, diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index d57a67c83..8de163da9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -2,14 +2,13 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react'; +import { Edit, Loader2, RefreshCw } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; -import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; -interface Currency { +interface CurrencyRate { id: string; code: string; name: string; @@ -18,203 +17,104 @@ interface Currency { exchangeRate: number; isActive: boolean; createdAt: string; - updatedAt: string; } +const CURRENCY_META: Record = { + ETB: { name: 'Ethiopian Birr', symbol: 'Br' }, + DJF: { name: 'Djiboutian Franc', symbol: 'Fdj' }, + USD: { name: 'US Dollar', symbol: '$' }, +}; + export default function CurrenciesPage() { - const [showModal, setShowModal] = useState(false); - const [editingCurrency, setEditingCurrency] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({ - isOpen: false, - id: null, - }); + const [editingRate, setEditingRate] = useState(null); + const [rateInput, setRateInput] = useState(''); const [error, setError] = useState(null); const queryClient = useQueryClient(); - const [currencyForm, setCurrencyForm] = useState({ - code: '', - name: '', - symbol: '', - baseCurrencyCode: 'ETB', - exchangeRate: '', - }); - - const { data: currencies = [], isLoading } = useQuery({ + const { data: currencies = [], isLoading } = useQuery({ queryKey: ['currencies'], queryFn: () => apiClient.get('/currencies'), }); - const createMutation = useMutation({ - mutationFn: (data: any) => apiClient.post('/currencies', data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - resetForm(); - setError(null); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to create currency'); - }, - }); - const updateMutation = useMutation({ - mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data), + mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) => + apiClient.patch(`/currencies/${id}`, { exchangeRate }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setEditingCurrency(null); - resetForm(); + setEditingRate(null); setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update currency'); + setError(err.response?.data?.message || 'Failed to update exchange rate'); }, }); - const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setDeleteConfirm({ isOpen: false, id: null }); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to delete currency'); - }, - }); - - const syncRatesMutation = useMutation({ + const syncMutation = useMutation({ mutationFn: () => apiClient.post('/currencies/sync-rates', {}), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setError(null); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to sync exchange rates'); - }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }), + onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'), }); - const resetForm = () => { - setCurrencyForm({ - code: '', - name: '', - symbol: '', - baseCurrencyCode: 'ETB', - exchangeRate: '', - }); - setEditingCurrency(null); - setShowModal(false); + const handleEdit = (currency: CurrencyRate) => { + setEditingRate(currency); + setRateInput(currency.exchangeRate.toString()); setError(null); }; - const handleEditCurrency = (currency: Currency) => { - setEditingCurrency(currency); - setCurrencyForm({ - code: currency.code, - name: currency.name, - symbol: currency.symbol, - baseCurrencyCode: currency.baseCurrencyCode, - exchangeRate: currency.exchangeRate.toString(), - }); - setError(null); - setShowModal(true); - }; - - const handleSaveCurrency = async () => { - setError(null); - if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) { - setError('All fields are required'); - return; - } - - const rate = parseFloat(currencyForm.exchangeRate); + const handleSave = async () => { + const rate = parseFloat(rateInput); if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; } - - const payload = { - code: currencyForm.code.toUpperCase(), - name: currencyForm.name, - symbol: currencyForm.symbol, - baseCurrencyCode: currencyForm.baseCurrencyCode, - exchangeRate: rate, - }; - - if (editingCurrency) { - await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload }); - } else { - await createMutation.mutateAsync(payload); - } + await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate }); }; - const confirmDelete = async () => { - if (deleteConfirm.id) { - await deleteMutation.mutateAsync(deleteConfirm.id); - } - }; - - const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || []; + const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? []; const columns = [ { key: 'code', - label: 'Code', - render: (currency: Currency) => ( - {currency.code} - ), - }, - { - key: 'name', - label: 'Name', - render: (currency: Currency) => ( - {currency.name} - ), - }, - { - key: 'symbol', - label: 'Symbol', - render: (currency: Currency) => ( - {currency.symbol} - ), - }, - { - key: 'baseCurrencyCode', - label: 'Base Currency', - render: (currency: Currency) => ( - {currency.baseCurrencyCode} - ), - }, - { - key: 'exchangeRate', - label: 'Exchange Rate', - render: (currency: Currency) => ( -
-
- 1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code} -
-
- 1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode} + label: 'Currency', + render: (c: CurrencyRate) => ( +
+ + {CURRENCY_META[c.code]?.symbol ?? c.symbol} + +
+
{c.code}
+
{CURRENCY_META[c.code]?.name ?? c.name}
), }, { - key: 'isActive', - label: 'Status', - render: (currency: Currency) => ( - - {currency.isActive ? 'Active' : 'Inactive'} - + key: 'baseCurrencyCode', + label: 'Base', + render: (c: CurrencyRate) => ( + {c.baseCurrencyCode} ), }, { - key: 'updatedAt', + key: 'exchangeRate', + label: 'Exchange Rate', + render: (c: CurrencyRate) => ( +
+
+ 1 {c.baseCurrencyCode} = {c.exchangeRate} {c.code} +
+
+ 1 {c.code} = {(1 / c.exchangeRate).toFixed(6)} {c.baseCurrencyCode} +
+
+ ), + }, + { + key: 'createdAt', label: 'Last Updated', - render: (currency: Currency) => ( + render: (c: CurrencyRate) => ( - {new Date(currency.updatedAt).toLocaleDateString()} + {new Date(c.createdAt).toLocaleDateString()} ), }, @@ -222,140 +122,93 @@ export default function CurrenciesPage() { const actions = [ { - label: 'Edit', - onClick: handleEditCurrency, + label: 'Edit Rate', + onClick: handleEdit, variant: 'secondary' as const, icon: Edit, }, - { - label: 'Delete', - onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }), - variant: 'danger' as const, - icon: Trash2, - }, ]; return (
-

Currencies

-

Manage exchange rates and display currencies

-
-
- syncRatesMutation.mutate()} - loading={syncRatesMutation.isPending} - > - Sync Rates - - { - setError(null); - setEditingCurrency(null); - setCurrencyForm({ - code: '', - name: '', - symbol: '', - baseCurrencyCode: 'ETB', - exchangeRate: '', - }); - setShowModal(true); - }} - > - Add Currency - +

Exchange Rates

+

+ Manage ETB exchange rates for display currencies (DJF, USD) +

+ syncMutation.mutate()} + loading={syncMutation.isPending} + > + Sync Rates +
+ {error && !editingRate && ( +
+ {error} +
+ )} +
-
-
-
-
Total Currencies
-
- {currenciesArray.length} +
+ {(['ETB', 'DJF', 'USD'] as const).map((code) => { + const entry = currenciesArray.find((c: CurrencyRate) => c.code === code); + return ( +
+
+
{CURRENCY_META[code].name}
+
{code}
+
+
+ {entry ? ( + <> +
{entry.exchangeRate}
+
per ETB
+ + ) : ( + Not configured + )} +
-
-
-
Active
-
- {currenciesArray.filter((c: Currency) => c.isActive).length} -
-
-
-
Base Currency
-
ETB
-
-
-
Last Sync
-
- {currenciesArray.length > 0 - ? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString() - : 'N/A'} -
-
-
- - {isLoading ? ( -
- -
- ) : currenciesArray.length === 0 ? ( -
-

No currencies configured. Click "Add Currency" to create one.

-
- ) : ( - - )} + ); + })}
+ + {isLoading ? ( +
+ +
+ ) : ( + + )}
-
-

Currency Management

-
    -
  • - • Base Currency: All exchange rates are calculated relative to this currency (typically ETB) -
  • -
  • - • Exchange Rate: How many units of the currency equal 1 unit of the base currency -
  • -
  • - • Display Currencies: Configure which currencies customers can view prices in -
  • -
  • - • Sync Rates: Automatically update exchange rates from external sources -
  • -
+
+

How it works

+

• ETB is the transaction currency — all fares are stored in ETB minor units (1 ETB = 100 minor)

+

• DJF and USD rates are used to display prices to passengers in their preferred currency

+

• Rates apply globally; changes take effect immediately on the next booking or fare quote

- {/* Delete Confirmation */} - setDeleteConfirm({ isOpen: false, id: null })} - onConfirm={confirmDelete} - title="Delete Currency" - message="Are you sure you want to delete this currency? This action cannot be undone." - confirmText="Delete" - isDanger={true} - warning="This will remove the currency from the system." - /> - - {/* Add/Edit Modal */} { setEditingRate(null); setError(null); }} + title={`Update Rate — ${editingRate?.code}`} + size="sm" >
{error && ( @@ -364,108 +217,38 @@ export default function CurrenciesPage() {
)} -
-
- - setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })} - className="input w-full" - placeholder="e.g., USD" - maxLength={3} - disabled={!!editingCurrency} - required - /> -

3-letter ISO code (e.g., USD, DJF, GBP)

-
- -
- - setCurrencyForm({ ...currencyForm, name: e.target.value })} - className="input w-full" - placeholder="e.g., United States Dollar" - required - /> -
-
- -
-
- - setCurrencyForm({ ...currencyForm, symbol: e.target.value })} - className="input w-full" - placeholder="e.g., $" - maxLength={3} - required - /> -
- -
- - -

All rates relative to this currency

-
+
+ Currency: + {editingRate?.code} — {CURRENCY_META[editingRate?.code ?? '']?.name}
- -
- setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })} - className="input w-full" - placeholder="e.g., 0.018" - required - /> -
- 1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code} -
-
- {currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && ( -

- ≈ 1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode} + + setRateInput(e.target.value)} + className="input w-full" + placeholder="e.g., 3.25" + autoFocus + /> + {rateInput && parseFloat(rateInput) > 0 && ( +

+ ≈ 1 {editingRate?.code} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.baseCurrencyCode}

)}
-
-

Exchange Rate Example:

-

If 1 ETB = 0.018 USD, enter 0.018

-

If 1 ETB = 3.25 DJF, enter 3.25

-
- -
- +
+ { setEditingRate(null); setError(null); }}> Cancel - - {editingCurrency ? 'Update Currency' : 'Add Currency'} + + Save Rate
diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx index a2dcae6aa..19fd44620 100644 --- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -731,14 +731,19 @@ export default function PricingPage() {
- setFareForm({ ...fareForm, route: e.target.value })} className="input w-full" - placeholder="e.g., ADD-DJI" - /> -

e.g., ADD-DJI for full route

+ > + + {routesArray.map((route: Route) => ( + + ))} + +

Scope this fare to a specific route