From 64e1130f7d7a47e9c49889c9766a2c8b139e2944 Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Thu, 23 Jul 2026 20:11:11 +0300 Subject: [PATCH] Commiting stop based booking --- .gitignore | 1 + apps/edr-passenger-api/prisma/schema.prisma | 1 + .../modules/schedules/routes.controller.ts | 2 +- .../src/modules/schedules/routes.dto.ts | 12 +- .../src/modules/schedules/routes.service.ts | 50 +++++- .../src/modules/schedules/schedules.dto.ts | 4 + .../modules/schedules/schedules.service.ts | 152 +++++++++++------- .../src/modules/search/search.service.ts | 48 +++--- .../src/modules/seats/seats.service.ts | 6 +- .../src/modules/tasks/tasks.service.ts | 72 ++++++--- .../test/fixtures/seed-core.ts | 23 ++- .../backoffice/src/app/routes/page.tsx | 78 ++++++++- .../backoffice/src/app/schedules/page.tsx | 39 +++-- .../src/providers/waafi/waafi.provider.ts | 13 +- 14 files changed, 354 insertions(+), 147 deletions(-) diff --git a/.gitignore b/.gitignore index cadb36cea..316f08dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ e2e-ui-report/ test-results/ playwright-report/ blob-report/ +RUNNING_LOCALLY.md diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index db46df025..de086e465 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1078,6 +1078,7 @@ model RouteStop { sequence Int distanceKm Float? checkinMinutesBefore Int? + travelMinutesToStop Int? plannedArrivalTime DateTime? plannedDepartureTime DateTime? createdAt DateTime @default(now()) diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index d468bba7c..5e3237e93 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -42,7 +42,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, @Patch(':id') @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') - @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' }) + @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiResponse({ status: 200, description: 'Route updated' }) @ApiResponse({ status: 404, description: 'Route not found' }) 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..1036138c9 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -5,8 +5,9 @@ import { Type } from 'class-transformer'; export class RouteStopInputDto { @ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string; @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: 120.5, description: 'Cumulative distance in km from the route origin (not from the 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: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Ignored for sequence 1 (origin, no predecessor). Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number; } export class CreateRouteDto { @@ -14,8 +15,9 @@ export class CreateRouteDto { @ApiProperty({ example: 'Addis Ababa – Djibouti' }) @IsString() name: string; @ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string; @ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string; - @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string | null; @ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean; + @ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route (defaults to 30 if omitted)' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiProperty({ type: [RouteStopInputDto], description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.', @@ -35,15 +37,17 @@ export class CreateRouteDto { export class AddRouteStopDto { @ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string; @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; + @ApiPropertyOptional({ example: 75.5, description: 'Cumulative distance in km from the route origin (not from the 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: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number; } export class UpdateRouteDto { @ApiPropertyOptional({ example: 'Addis Ababa – Djibouti Express' }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional() @IsOptional() @IsString() description?: string; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean; - @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsOptional() @IsDateString() effectiveFrom?: string; + @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z', description: 'Send null to clear (open-ended route)' }) @IsOptional() @IsDateString() effectiveUntil?: string | null; @ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } 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..1a78e906e 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { parseEthiopianTime } from '../../common/utils/timezone.utils'; @Injectable() export class RoutesService { @@ -10,6 +11,33 @@ export class RoutesService { // ── Route CRUD ───────────────────────────────────────────────────────────── + /** + * distanceKm is CUMULATIVE distance from the route origin, not distance from the previous + * stop (that's what travelMinutesToStop is for) — fare pricing computes a segment's distance + * as destStop.distanceKm - originStop.distanceKm, so a route with equal or decreasing values + * across stops silently produces zero/negative segment distances, which the fare engine + * rejects (caught and swallowed by search into a bare "N/A" instead of a visible error). Catch + * the mistake here instead, with a message that names the exact stops involved. + */ + private validateStopDistances(stops: { sequence: number; stationId: string; distanceKm?: number | null }[]): void { + const sorted = [...stops].sort((a, b) => a.sequence - b.sequence); + let prevDistance = sorted[0]?.distanceKm ?? 0; + for (let i = 1; i < sorted.length; i++) { + const stop = sorted[i]; + if (stop.distanceKm == null) { + throw new BadRequestException( + `Stop ${stop.sequence} is missing distanceKm (cumulative distance in km from the route origin). This is required for fare pricing.`, + ); + } + if (stop.distanceKm <= prevDistance) { + throw new BadRequestException( + `Stop ${stop.sequence}'s distanceKm (${stop.distanceKm}) must be greater than stop ${sorted[i - 1].sequence}'s distanceKm (${prevDistance}) — distanceKm is cumulative distance from the route origin, not distance from the previous stop. Equal or decreasing values make fare pricing between these stops fail silently.`, + ); + } + prevDistance = stop.distanceKm; + } + } + async createRoute(dto: CreateRouteDto) { const existing = await this.prisma.route.findUnique({ where: { code: dto.code } }); if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`); @@ -19,6 +47,8 @@ export class RoutesService { const seqs = dto.stops.map(s => s.sequence); if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list'); + this.validateStopDistances(dto.stops); + const stationIds = [...new Set(dto.stops.map(s => s.stationId))]; const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } }); if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found'); @@ -29,14 +59,16 @@ export class RoutesService { name: dto.name, description: dto.description, active: dto.active ?? true, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null, + ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), + effectiveFrom: parseEthiopianTime(dto.effectiveFrom), + effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null, stops: { create: dto.stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + travelMinutesToStop: s.travelMinutesToStop ?? null, })), }, }, @@ -86,13 +118,20 @@ export class RoutesService { const route = await this.prisma.route.findUnique({ where: { id } }); if (!route) throw new NotFoundException('Route not found'); + if (dto.stops && dto.stops.length >= 2) this.validateStopDistances(dto.stops); + await this.prisma.route.update({ where: { id }, data: { name: dto.name, description: dto.description, active: dto.active, - effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined, + ...(dto.effectiveFrom ? { effectiveFrom: parseEthiopianTime(dto.effectiveFrom) } : {}), + // effectiveUntil is nullable (open-ended route) — distinguish "field not sent" (leave + // untouched) from "explicitly cleared" (null → set to null), not just truthy/falsy. + ...(dto.effectiveUntil !== undefined + ? { effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null } + : {}), ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), }, }); @@ -106,6 +145,7 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + travelMinutesToStop: s.travelMinutesToStop ?? null, })), }); } @@ -218,6 +258,9 @@ export class RoutesService { }); if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`); + const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } }); + this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]); + return this.prisma.routeStop.create({ data: { routeId, @@ -225,6 +268,7 @@ export class RoutesService { sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, + travelMinutesToStop: dto.travelMinutesToStop ?? 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..49dec1d96 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -52,6 +52,10 @@ export class CreateScheduleDto { }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; + + @ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' }) + @IsOptional() @IsArray() @IsString({ each: true }) + coachIds?: string[]; } export class UpdateScheduleDto { 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 4c2870a8a..be1c936b4 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; @@ -9,6 +9,8 @@ import { AuditService } from '../../common/audit.service'; @Injectable() export class SchedulesService { + private readonly logger = new Logger(SchedulesService.name); + constructor( private prisma: PrismaService, private routesService: RoutesService, @@ -16,6 +18,44 @@ export class SchedulesService { private auditService: AuditService, ) { } + /** + * Computes each stop's planned arrival/departure time by walking the route in sequence + * order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the + * previous stop). Falls back to distance-proportional interpolation over `distanceKm` for + * any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed + * overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays + * authoritative even if per-stop estimates drift. + */ + private computePlannedTimes( + route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] }, + dep: Date, + arr: Date, + ) { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + let cursor = dep; + return route.stops.map((stop, index) => { + if (index === 0) { + cursor = dep; + } else if (index === route.stops.length - 1) { + cursor = arr; + } else if (stop.travelMinutesToStop != null) { + cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000); + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + cursor = new Date(dep.getTime() + totalDuration * progress); + this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(), + }; + }); + } + async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) { const startDate = parseEthiopianTime(dto.startDateTime); const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000); @@ -43,20 +83,14 @@ export class SchedulesService { departureAt: departureAt.toISOString(), arrivalAt: arrivalAt.toISOString(), plannedTimes: dto.plannedTimes || [], + coachIds: dto.coachIds, }; + // createSchedule applies coachIds if given, else auto-applies the route coach template, + // and rejects the day outright (caught below) if it would end up with zero coaches. const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); - // createSchedule already auto-applies the route coach template; - // only override if explicit coachIds are provided - if (dto.coachIds && dto.coachIds.length > 0) { - await this.assignCoaches( - schedule.id, - dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), - ); - } - scheduleCount++; } catch (error) { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); @@ -135,26 +169,7 @@ 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; - - 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(), - }; - }); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -183,15 +198,34 @@ export class SchedulesService { const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); - // Auto-apply route coach template if one is defined - const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ - where: { routeId: dto.routeId }, - orderBy: { positionNumber: 'asc' }, - }); - if (coachTemplates.length > 0) { + // Explicit coachIds (from the schedule form's Coaches step) override the route's coach + // template; otherwise auto-apply the template if one is defined. + if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, - coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), + ); + } else { + const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ + where: { routeId: dto.routeId }, + orderBy: { positionNumber: 'asc' }, + }); + if (coachTemplates.length > 0) { + await this.assignCoaches( + schedule.id, + coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + ); + } + } + + // A schedule with zero coaches has zero seats and is silently invisible to search (and + // unbookable) with no indication why — block creation instead of leaving a dead schedule. + const assignedCoachCount = await this.prisma.coachAssignment.count({ where: { scheduleId: schedule.id } }); + if (assignedCoachCount === 0) { + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: schedule.id } }); + await this.prisma.trainSchedule.delete({ where: { id: schedule.id } }); + throw new BadRequestException( + 'A schedule must have at least one coach assigned to be bookable. Add coaches in the Coaches step, or set a Route Coach Template on this route so new schedules auto-assign coaches.', ); } @@ -306,26 +340,7 @@ 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; - - 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(), - }; - }); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -655,11 +670,14 @@ export class SchedulesService { if (!schedule) throw new NotFoundException('Schedule not found'); const updateData: any = {}; + let dep: Date | undefined; + let arr: Date | undefined; if (dto.departureAt || dto.arrivalAt) { - const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt); - const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt); + dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt); + arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt); if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time'); + if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future'); updateData.departureAt = dep; updateData.arrivalAt = arr; updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000); @@ -672,6 +690,22 @@ export class SchedulesService { await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); } + // departureAt/arrivalAt changed — the per-stop TripStopTime rows were computed against the + // OLD times and are now stale (same interpolation createSchedule/updateSchedule use). Left + // unfixed, check-in cutoff enforcement and search silently keep using outdated per-stop + // arrival/departure estimates for every intermediate stop. + if (dep && arr && schedule.routeId) { + const route = await this.prisma.route.findUnique({ + where: { id: schedule.routeId }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }); + if (route && route.stops.length >= 2) { + const plannedTimes = this.computePlannedTimes(route, dep, arr); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); + await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); + } + } + if (dto.coaches !== undefined) { if (dto.coaches.length > 0) { await this.assignCoaches(id, dto.coaches); 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 6ed657975..78fbcb955 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -10,7 +10,7 @@ import { CurrencyService } from "../currency/currency.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; import { SegmentsService } from "../segments/segments.service"; import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto"; -import { Currency } from "@prisma/client"; +import { Currency, Prisma } from "@prisma/client"; const POINTS_TO_MINOR = 10; @@ -220,12 +220,18 @@ export class SearchService { const totalPassengers = adultCount + (childCount ?? 0); const NEEDED = 3; - const baseWhere = { - status: "SCHEDULED", + // Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the + // schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) — + // it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable + // (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live + // check against each stop's estimated arrival/departure. Excluding BOARDING here would + // silently impose a hidden, non-configurable 30-minute cutoff on top of that. + const baseWhere: Prisma.TrainScheduleWhereInput = { + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, - } as const; + }; // Fetch candidates before and after in parallel; take more than needed to // account for routes that don't serve the destination or have no availability. @@ -300,23 +306,21 @@ export class SearchService { const nextDay = new Date( `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`, ); - const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); - // Use now as the lower bound for today so we don't fetch schedules that have - // already fully departed. The per-segment cutoff check in buildScheduleResult - // handles the exact check using each stop's own plannedDepartureAt. - const isToday = - now.getFullYear() === y && - now.getMonth() === m - 1 && - now.getDate() === d; - const earliest = isToday ? now : date; - + // Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here. + // A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g. + // Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would + // wrongly exclude the whole schedule for those still-bookable downstream segments. The + // per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS + // specific origin stop is still bookable, using each stop's own estimated arrival/departure. const schedules = await this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + // EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display + // statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere). + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, - departureAt: { gte: earliest, lt: nextDay }, + departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }, @@ -368,7 +372,8 @@ export class SearchService { const [leg1Schedules, allCandidates] = await Promise.all([ this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + // BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere. + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, @@ -378,7 +383,7 @@ export class SearchService { }), this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, coachAssignments: { some: {} }, @@ -547,13 +552,14 @@ export class SearchService { if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; - // Segment-level cutoff: use the origin stop's planned departure, not the + // Segment-level cutoff: use the origin stop's own estimated arrival time, not the // schedule's overall departureAt (which is station A's time). This lets - // B→D remain bookable even after A→D closes. + // B→D remain bookable even after A→D closes. The first stop has no arrival (nothing + // to arrive at), so it falls back to its own departure. // Cutoff resolution: stop-level override → route default → 30 min fallback. const now = new Date(); const segmentDepartureAt = - originStop.plannedDepartureAt ?? schedule.departureAt; + originStop.plannedArrivalAt ?? originStop.plannedDepartureAt ?? schedule.departureAt; const routeStop = schedule.route?.stops?.find( (s) => s.stationId === originStationId, ); 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 bd73c10f5..bd0a3cadf 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -281,7 +281,7 @@ export class SeatsService { }), this.prisma.tripStopTime.findFirst({ where: { scheduleId: dto.scheduleId, stationId: dto.originStationId }, - select: { plannedDepartureAt: true }, + select: { plannedArrivalAt: true, plannedDepartureAt: true }, }), this.prisma.routeStop.findFirst({ where: { @@ -295,7 +295,9 @@ export class SeatsService { // Stop-level override wins; falls back to route-level; then to 30 min. const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; - const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt; + // Arrival basis: the origin stop's own estimated arrival, not its departure. The first + // stop of a route has no arrival (nothing to arrive at), so it falls back to its departure. + const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt; const msUntilDeparture = segmentDepartureAt.getTime() - Date.now(); if (msUntilDeparture <= checkinMinutes * 60 * 1000) { throw new BadRequestException( 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 4767c7eba..99d6b4d4e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -81,17 +81,23 @@ export class TasksService { byRoute.get(stop.routeId)!.push(stop.stationId); } + // Arrival basis: each stop's own estimated arrival time, not its departure. The first + // stop of a route has no arrival (nothing to arrive at), so it falls back to its + // departure — expressed below as COALESCE(plannedArrivalAt, plannedDepartureAt). let reopenedCount = 0; let checkinClosedCount = 0; for (const [mins, byRoute] of byMins) { const cutoffAt = new Date(now.getTime() + mins * 60 * 1000); for (const [routeId, stationIds] of byRoute) { // Revert first: if the cutoff was reduced, stops that were prematurely closed - // should reopen (departure is still beyond the new cutoff window). + // should reopen (arrival is still beyond the new cutoff window). const reverted = await this.prisma.tripStopTime.updateMany({ where: { status: 'CHECKIN_CLOSED', - plannedDepartureAt: { gt: cutoffAt }, + OR: [ + { plannedArrivalAt: { gt: cutoffAt } }, + { AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { gt: cutoffAt } }] }, + ], stationId: { in: stationIds }, schedule: { routeId }, }, @@ -103,7 +109,10 @@ export class TasksService { const closed = await this.prisma.tripStopTime.updateMany({ where: { status: 'OPEN', - plannedDepartureAt: { lte: cutoffAt }, + OR: [ + { plannedArrivalAt: { lte: cutoffAt } }, + { AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { lte: cutoffAt } }] }, + ], stationId: { in: stationIds }, schedule: { routeId }, }, @@ -169,8 +178,8 @@ export class TasksService { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, - stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, - route: { select: { checkinMinutesBefore: true } }, + stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, }, }, }, @@ -179,12 +188,17 @@ export class TasksService { for (const booking of bookings) { try { const createdAt = booking.createdAt as Date; - // Use the booking's origin-segment departure and the route's own check-in window. + // Use the booking's origin-segment estimated arrival (falling back to its departure + // for the first stop) and that stop's own check-in window (falling back to the route + // default), same resolution as holdSeats/search. const originStop = (booking.schedule as any).stopTimes?.find( (s: any) => s.stationId === (booking as any).originStationId, ); - const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; - const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30; + const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; + const originRouteStop = (booking.schedule as any).route?.stops?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30; if (dep <= now) continue; // segment has already departed; cancel job handles clean-up const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); @@ -230,13 +244,28 @@ export class TasksService { // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(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 twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); - // payment_deadline = MIN(createdAt + 2h, departureAt - 30min) + // The departure pre-filter below is a query-scoping optimization only — the real + // deadline check happens per-row further down. It must be widened to the largest + // configured checkinMinutes across all routes/stops, or a booking on a route with a + // cutoff bigger than the CUTOFF_MINUTES default would never even be fetched here, + // silently never getting auto-cancelled. + const [maxRouteCutoff, maxStopCutoff] = await Promise.all([ + this.prisma.route.aggregate({ _max: { checkinMinutesBefore: true } }), + this.prisma.routeStop.aggregate({ _max: { checkinMinutesBefore: true } }), + ]); + const effectiveMaxCutoffMinutes = Math.max( + CUTOFF_MINUTES, + maxRouteCutoff._max.checkinMinutesBefore ?? 0, + maxStopCutoff._max.checkinMinutesBefore ?? 0, + ); + const departureCutoff = new Date(now.getTime() + effectiveMaxCutoffMinutes * 60 * 1000); + + // payment_deadline = MIN(createdAt + 2h, segment_arrival - checkinMinutes) // Deadline is reached when either branch of the MIN is in the past: - // (a) createdAt ≤ now - 2h → 2-hour max window elapsed - // (b) departureAt ≤ now + 30min → departure within 30 min + // (a) createdAt ≤ now - 2h → 2-hour max window elapsed + // (b) departureAt ≤ now + effectiveMaxCutoff → within the widest possible cutoff window const expiredBookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', @@ -250,8 +279,8 @@ export class TasksService { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, - stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, - route: { select: { checkinMinutesBefore: true } }, + stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, }, }, paymentIntent: { select: { method: true } }, @@ -264,14 +293,19 @@ export class TasksService { for (const booking of expiredBookings) { try { // Re-verify exact deadline to avoid racing with a concurrent payment confirmation. - // Use the booking's origin-segment departure for the deadline so that a B→C booking - // on an A→B→C→D schedule gets the correct payment window anchored to B, not A. + // Use the booking's origin-segment estimated arrival (falling back to its departure + // for the first stop) and that stop's own check-in window, so a B→C booking on an + // A→B→C→D schedule gets the correct payment window anchored to B, not A. const createdAt = booking.createdAt as Date; const originStop = (booking.schedule as any).stopTimes?.find( (s: any) => s.stationId === (booking as any).originStationId, ); - const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; - const paymentDeadline = computePaymentDeadline(createdAt, dep); + const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; + const originRouteStop = (booking.schedule as any).route?.stops?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); if (now < paymentDeadline) continue; // 1a. Release held seats (Journey rows are the occupancy source of truth once paid) diff --git a/apps/edr-passenger-api/test/fixtures/seed-core.ts b/apps/edr-passenger-api/test/fixtures/seed-core.ts index 098489dab..115df217f 100644 --- a/apps/edr-passenger-api/test/fixtures/seed-core.ts +++ b/apps/edr-passenger-api/test/fixtures/seed-core.ts @@ -23,6 +23,17 @@ export const IDS = { /** Route stop distances (km from origin). A=0, B=100, C=250 → A→B is 100km, A→C is 250km. */ export const DISTANCE = { A: 0, B: 100, C: 250 } as const; +/** + * Optional per-stop check-in-cutoff/travel-time overrides, keyed by station label (A/B/C). + * Lets a spec seed a distinct `checkinMinutesBefore` override and/or `travelMinutesToStop` + * per stop without changing the zero-arg call sites the other specs rely on. + */ +export interface RouteStopOverrides { + A?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; + B?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; + C?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; +} + /** * FX rate chosen so the seat-class distance formula (which multiplies an ETB/km rate by the * USD→ETB rate — see fare-engine.service.ts:157) yields whole ETB-minor amounts. 100 makes the @@ -45,7 +56,7 @@ export async function truncateAllPassenger(prisma: PrismaClient): Promise } /** Insert the deterministic core graph. Call after truncateAllPassenger. */ -export async function seedCore(prisma: PrismaClient): Promise { +export async function seedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise { const past = new Date("2020-01-01T00:00:00.000Z"); await prisma.coachType.create({ @@ -103,9 +114,9 @@ export async function seedCore(prisma: PrismaClient): Promise { active: true, stops: { create: [ - { stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A }, - { stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B }, - { stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C }, + { stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A, ...stopOverrides.A }, + { stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B, ...stopOverrides.B }, + { stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C, ...stopOverrides.C }, ], }, }, @@ -122,7 +133,7 @@ export async function seedCore(prisma: PrismaClient): Promise { } /** Convenience: reset + seed in one call. */ -export async function resetAndSeedCore(prisma: PrismaClient): Promise { +export async function resetAndSeedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise { await truncateAllPassenger(prisma); - await seedCore(prisma); + await seedCore(prisma, stopOverrides); } 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..6b310d137 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; @@ -10,6 +10,7 @@ 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 { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; interface RouteStop { stationId: string; @@ -17,6 +18,7 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; + travelMinutesToStop?: number; } type Tab = 'routes' | 'coaches'; @@ -175,8 +177,18 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); + const [destinationTravelMinutes, setDestinationTravelMinutes] = useState(undefined); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + const errorBannerRef = useRef(null); + + // The form scrolls internally (long stop lists push the error banner above the fold), so a + // submit failure can land silently off-screen with no visible indication anything went wrong. + // Scroll the banner into view whenever a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); const queryClient = useQueryClient(); const { data: routes, isLoading: routesLoading } = useQuery({ @@ -198,6 +210,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to create route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -207,6 +224,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to update route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -244,6 +266,8 @@ export default function RoutesPage() { const sortedMiddleStops = stops; // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) + // travelMinutesToStop = minutes of travel from the PREVIOUS stop, used to estimate this + // stop's arrival time. The origin (sequence 1) has no predecessor, so it gets none. const stopsArray = [ { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, ...sortedMiddleStops.map((stop, idx) => ({ @@ -251,12 +275,14 @@ export default function RoutesPage() { sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, + travelMinutesToStop: stop.travelMinutesToStop ?? undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, checkinMinutesBefore: destinationCheckinMinutes ?? undefined, + travelMinutesToStop: destinationTravelMinutes ?? undefined, }, ]; @@ -266,8 +292,10 @@ export default function RoutesPage() { name: formData.get('name') as string, description: formData.get('description') as string || undefined, active: !editingRoute ? (formData.get('active') !== 'false') : undefined, - effectiveFrom: formData.get('effectiveFrom') as string, - effectiveUntil: formData.get('effectiveUntil') as string || undefined, + effectiveFrom: eatLocalToISO(formData.get('effectiveFrom') as string), + // null (not undefined) so clearing the field on an edit explicitly clears effectiveUntil + // server-side, instead of being silently dropped as "no change". + effectiveUntil: formData.get('effectiveUntil') ? eatLocalToISO(formData.get('effectiveUntil') as string) : null, checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined, stops: stopsArray, }; @@ -390,14 +418,17 @@ export default function RoutesPage() { setDestinationStationId(destStop.stationId); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); setDestinationDistance(destStop.distanceKm || 0); + setDestinationTravelMinutes(destStop.travelMinutesToStop ?? undefined); setStops(routeStops.slice(1, -1).map((s: any) => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm, distanceFromOrigin: s.distanceKm || 0, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + travelMinutesToStop: s.travelMinutesToStop ?? undefined, }))); } + setError(null); setShowModal(true); } finally { setEditLoading(false); @@ -436,7 +467,9 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); + setError(null); setShowModal(true); }} > @@ -515,13 +548,20 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} size="lg" >
+ {error && ( +
+ {error} +
+ )} {editingRoute && (

⚠ Warning

@@ -647,7 +687,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveFrom" className="input" - defaultValue={editingRoute?.effectiveFrom ? new Date(editingRoute.effectiveFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)} + defaultValue={editingRoute?.effectiveFrom ? isoToEATLocal(editingRoute.effectiveFrom) : isoToEATLocal(new Date())} required />
@@ -657,7 +697,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveUntil" className="input" - defaultValue={editingRoute?.effectiveUntil ? new Date(editingRoute.effectiveUntil).toISOString().slice(0, 16) : ''} + defaultValue={editingRoute?.effectiveUntil ? isoToEATLocal(editingRoute.effectiveUntil) : ''} /> @@ -665,7 +705,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit) + Drag to rearrange · Travel min estimates arrival from the previous stop (falls back to distance if blank) · Cutoff min overrides route check-in window per stop (leave blank to inherit)
@@ -741,6 +781,17 @@ export default function RoutesPage() { required />
+
+ updateStop(index, 'travelMinutesToStop', e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> +
)}
+
+ {destinationStationId && ( + setDestinationTravelMinutes(e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> + )} +
@@ -833,8 +897,10 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} > Cancel 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 0f37825fb..dcaf6261c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; @@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; +import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import DateTimePicker from '@/components/ui/DateTimePicker'; interface Schedule { @@ -60,8 +61,15 @@ export default function SchedulesPage() { { isOpen: false, item: null } ); const [error, setError] = useState(null); + const errorBannerRef = useRef(null); const queryClient = useQueryClient(); + // These modals can scroll internally — a submit failure can land silently off-screen with no + // visible indication anything went wrong. Scroll the banner into view when a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); + const [bulkForm, setBulkForm] = useState({ trainId: '', routeId: '', @@ -167,7 +175,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to generate schedules'); + const msg = err.response?.data?.message || 'Failed to generate schedules'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -181,7 +190,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to create schedule'); + const msg = err.response?.data?.message || 'Failed to create schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -195,7 +205,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update schedule'); + const msg = err.response?.data?.message || 'Failed to update schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -229,20 +240,6 @@ export default function SchedulesPage() { }, }); - /** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */ - const eatLocalToISO = (local: string): string => { - if (!local) return ''; - return new Date(local + ':00+03:00').toISOString(); - }; - - /** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */ - const isoToEATLocal = (iso: string): string => { - if (!iso) return ''; - const utcMs = new Date(iso).getTime(); - const eatMs = utcMs + 3 * 60 * 60 * 1000; - return new Date(eatMs).toISOString().slice(0, 16); - }; - const handleBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -685,7 +682,7 @@ export default function SchedulesPage() { size="xl" > - {error &&
{error}
} + {error &&
{error}
}
@@ -803,7 +800,7 @@ export default function SchedulesPage() { > {error && ( -
+
{error}
)} @@ -1022,7 +1019,7 @@ export default function SchedulesPage() { {editingSchedule && ( {error && ( -
+
{error}
)} diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index e7d78f752..dd5978021 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -135,15 +135,18 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit { // Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an // unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206 - // "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING), - // never terminal — so the intent keeps waiting for the webhook / its expiry rather than being - // wrongly resolved off a "no info" response. + // "Failed to get transaction info") with no status — i.e. the payer hasn't done anything at + // the hosted page yet. That's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING: + // returning PROCESSING here would let the reconciliation sweep persist that guess and block + // the payer from switching providers on a session they never touched (see cac-bank.provider's + // queryStatus for the same convention). The intent still resolves correctly either way — via + // the webhook on a genuine payment, or via expiresAt once the 5-minute HPP session lapses. if (response.responseCode !== WAAFI_SUCCESS_CODE) { this.logger.warn( - `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`, + `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as still awaiting the payer`, ); return { - status: ProviderPaymentStatus.PROCESSING, + status: ProviderPaymentStatus.REQUIRES_ACTION, rawResponse: response as unknown as Record, }; }