From 5a89e296d6936483d5d893ec12fd0ce34f5a60c7 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 24 Jul 2026 11:37:52 +0300 Subject: [PATCH] Fix checkin time cutoff --- .../src/common/utils/checkin-cutoff.utils.ts | 28 ++++--- .../src/common/utils/schedule-times.utils.ts | 76 +++++++++++++++++++ .../src/modules/schedules/routes.service.ts | 15 ++++ .../modules/schedules/schedules.controller.ts | 6 ++ .../modules/schedules/schedules.service.ts | 63 ++++++--------- .../src/modules/seats/seats.service.ts | 7 +- .../backoffice/src/app/schedules/page.tsx | 18 ++++- 7 files changed, 153 insertions(+), 60 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/utils/schedule-times.utils.ts diff --git a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts index 5a0fb50b1..36c0f872c 100644 --- a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts @@ -1,21 +1,19 @@ /** - * Resolves the booking/check-in cutoff for one boarding stop: stop-level - * `RouteStop.checkinMinutesBefore` override wins, else the route-level default - * (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with - * neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching - * that stop), not its departure or the schedule's overall origin departure — a downstream - * stop's cutoff must be independent of how long ago the train left its origin. The first stop - * of a route has no arrival (nothing to arrive at), so it falls back to its own departure. + * Resolves the booking/check-in cutoff for one boarding stop. * - * Single source of truth for this computation — SeatsService.holdSeats and - * SearchService.buildScheduleResult already applied it (search results only ever showed a - * segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking - * used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin - * departure, which could reject a booking the search/hold steps had just accepted under the - * route's actual configured cutoff. + * Priority for checkinMinutes: RouteStop.checkinMinutesBefore → Route.checkinMinutesBefore → 30. + * + * Anchor (segmentTime): plannedDepartureAt ?? plannedArrivalAt ?? schedule.departureAt. + * - For the origin stop: plannedDepartureAt = schedule.departureAt (no arrival). + * - For intermediate stops: plannedDepartureAt = plannedArrivalAt + dwell (checkinMinutesBefore). + * cutoffAt = departureAt − checkinMinutesBefore = arrivalAt, so booking closes the + * moment the train reaches the stop — independent of how long ago it left the origin. + * + * Single source of truth — SeatsService.holdSeats and SearchService.buildScheduleResult both + * apply it; GuestBookingService.createGuestBooking also applies it per boarding stop. */ export interface CheckinCutoff { - /** The stop's own estimated arrival time (or departure, for the first stop / missing data). */ + /** The stop's planned departure time (or arrival / schedule departure as fallback). */ segmentTime: Date; /** Minutes before segmentTime that booking/holding closes. */ checkinMinutes: number; @@ -34,7 +32,7 @@ export function resolveCheckinCutoff( stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined, stationId: string | null | undefined, ): CheckinCutoff { - const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt; + const segmentTime = stopTime?.plannedDepartureAt ?? stopTime?.plannedArrivalAt ?? schedule.departureAt; const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined; const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; return { diff --git a/apps/edr-passenger-api/src/common/utils/schedule-times.utils.ts b/apps/edr-passenger-api/src/common/utils/schedule-times.utils.ts new file mode 100644 index 000000000..59d5f1d7e --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/schedule-times.utils.ts @@ -0,0 +1,76 @@ +import { Logger } from '@nestjs/common'; + +const logger = new Logger('ScheduleTimesUtils'); + +export type StopForTiming = { + sequence: number; + distanceKm: number | null; + travelMinutesToStop: number | null; + checkinMinutesBefore: number | null; +}; + +export type PlannedStopTime = { + sequence: number; + plannedArrivalAt: string | undefined; + plannedDepartureAt: string | undefined; +}; + +/** + * Computes each stop's planned arrival/departure time by walking the route in sequence order. + * + * Model per intermediate stop: + * arrival = departureCursor + travelMinutesToStop (falls back to distance interpolation) + * departure = arrival + checkinMinutesBefore (dwell time; 0 if null) + * next-stop travel starts from this departure, not from arrival. + * + * This means booking for stop B closes at B.departureAt − checkinMinutesBefore = B.arrivalAt, + * i.e. the train must not yet have arrived at the stop for a booking to succeed. + * + * The last stop is always locked to arr so schedule.arrivalAt stays authoritative. + */ +export function computePlannedStopTimes( + route: { id: string; stops: StopForTiming[] }, + dep: Date, + arr: Date, +): PlannedStopTime[] { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + // cursor tracks the DEPARTURE time from the most-recently processed stop. + let departureCursor = dep; + + return route.stops.map((stop, index) => { + if (index === 0) { + // Origin: train starts here, no arrival. + departureCursor = dep; + return { sequence: stop.sequence, plannedArrivalAt: undefined, plannedDepartureAt: dep.toISOString() }; + } + + if (index === route.stops.length - 1) { + // Final destination: arrival is authoritative; no departure. + return { sequence: stop.sequence, plannedArrivalAt: arr.toISOString(), plannedDepartureAt: undefined }; + } + + // Intermediate stop: compute arrival from the previous stop's departure. + let arrivalAt: Date; + if (stop.travelMinutesToStop != null) { + arrivalAt = new Date(departureCursor.getTime() + stop.travelMinutesToStop * 60_000); + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + arrivalAt = new Date(dep.getTime() + totalDuration * progress); + logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`); + } + + // Dwell at this stop = checkinMinutesBefore (the boarding window). + const dwell = stop.checkinMinutesBefore ?? 0; + const departureAt = new Date(arrivalAt.getTime() + dwell * 60_000); + departureCursor = departureAt; + + return { + sequence: stop.sequence, + plannedArrivalAt: arrivalAt.toISOString(), + plannedDepartureAt: departureAt.toISOString(), + }; + }); +} 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 1a78e906e..56ff8ddfe 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -4,6 +4,7 @@ import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateD import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; import { parseEthiopianTime } from '../../common/utils/timezone.utils'; +import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; @Injectable() export class RoutesService { @@ -148,6 +149,20 @@ export class RoutesService { travelMinutesToStop: s.travelMinutesToStop ?? null, })), }); + + // Propagate new stop timing to all future schedules on this route so that + // per-stop check-in cutoffs reflect the updated travelMinutesToStop values. + const futureSchedules = await this.prisma.trainSchedule.findMany({ + where: { routeId: id, status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gt: new Date() } }, + select: { id: true, departureAt: true, arrivalAt: true }, + }); + const stopsForTiming = dto.stops + .map(s => ({ sequence: s.sequence, distanceKm: s.distanceKm ?? null, travelMinutesToStop: s.travelMinutesToStop ?? null, checkinMinutesBefore: s.checkinMinutesBefore ?? null })) + .sort((a, b) => a.sequence - b.sequence); + for (const sched of futureSchedules) { + const times = computePlannedStopTimes({ id, stops: stopsForTiming }, new Date(sched.departureAt), new Date(sched.arrivalAt)); + await this.applyRouteToSchedule(id, sched.id, Object.fromEntries(times.map(t => [t.sequence, t]))); + } } await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } }); 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 b11ac3098..8cee1e94f 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -154,6 +154,12 @@ export class SchedulesController { @ApiQuery({ name: 'cascade', required: false, type: Boolean }) deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); } + @Post(':id/recalculate-stops') + @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' }) + @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) + recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); } + @Get(':id/stops') @IsPublic() @ApiOperation({ summary: 'List all stops for a schedule' }) 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 be1c936b4..d6dfda6ec 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -6,6 +6,7 @@ import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateSchedule import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils'; import { AuditService } from '../../common/audit.service'; +import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; @Injectable() export class SchedulesService { @@ -18,44 +19,6 @@ 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); @@ -169,7 +132,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - plannedTimes = this.computePlannedTimes(route, dep, arr); + plannedTimes = computePlannedStopTimes(route, dep, arr); } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -340,7 +303,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - plannedTimes = this.computePlannedTimes(route, dep, arr); + plannedTimes = computePlannedStopTimes(route, dep, arr); } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -634,6 +597,24 @@ export class SchedulesService { return { synced, errors }; } + async recalculateStopTimes(scheduleId: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + include: { route: { include: { stops: { orderBy: { sequence: 'asc' } } } } }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId || !schedule.route) throw new BadRequestException('Schedule has no associated route'); + + const plannedTimes = computePlannedStopTimes( + schedule.route, + new Date(schedule.departureAt), + new Date(schedule.arrivalAt), + ); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); + await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap); + return { recalculated: true, scheduleId, stopCount: plannedTimes.length }; + } + 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'); @@ -700,7 +681,7 @@ export class SchedulesService { include: { stops: { orderBy: { sequence: 'asc' } } }, }); if (route && route.stops.length >= 2) { - const plannedTimes = this.computePlannedTimes(route, dep, arr); + const plannedTimes = computePlannedStopTimes(route, dep, arr); const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); } 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 bd0a3cadf..79773a712 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -295,9 +295,10 @@ export class SeatsService { // Stop-level override wins; falls back to route-level; then to 30 min. const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; - // 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; + // Departure basis: plannedDepartureAt = arrival + dwell. For the origin there is no + // arrival so plannedDepartureAt = schedule.departureAt. cutoffAt = departure - dwell = arrival, + // so holding closes the moment the train reaches the boarding stop. + const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? originStopTime?.plannedArrivalAt ?? schedule.departureAt; const msUntilDeparture = segmentDepartureAt.getTime() - Date.now(); if (msUntilDeparture <= checkinMinutes * 60 * 1000) { throw new BadRequestException( 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 dcaf6261c..17a31832a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -2,7 +2,7 @@ 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 { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -210,6 +210,13 @@ export default function SchedulesPage() { }, }); + const recalculateStopsMutation = useMutation({ + mutationFn: (id: string) => apiClient.post(`/schedules/${id}/recalculate-stops`, {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['schedules'] }); + }, + }); + const deleteScheduleMutation = useMutation({ mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { @@ -1130,6 +1137,15 @@ export default function SchedulesPage() { > Cancel + editingSchedule && recalculateStopsMutation.mutate(editingSchedule.id)} + > + Recalculate Stop Times + Update Schedule