mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Added Checkin duration configuration
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
-- Rename StopStatus enum values to reflect segment-level booking lifecycle.
|
||||
-- UPCOMING → OPEN (segment is bookable)
|
||||
-- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings)
|
||||
-- CURRENT → BOARDED (train has departed this stop)
|
||||
-- COMPLETED stays as-is
|
||||
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'UPCOMING' TO 'OPEN';
|
||||
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'APPROACHING' TO 'CHECKIN_CLOSED';
|
||||
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED';
|
||||
|
||||
-- Add per-route check-in window. Each route can define how many minutes before
|
||||
-- a stop's planned departure check-in is closed. Defaults to 30 minutes.
|
||||
ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER;
|
||||
@@ -180,10 +180,10 @@ enum NotificationCategory {
|
||||
}
|
||||
|
||||
enum StopStatus {
|
||||
OPEN
|
||||
CHECKIN_CLOSED
|
||||
BOARDED
|
||||
COMPLETED
|
||||
APPROACHING
|
||||
CURRENT
|
||||
UPCOMING
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
@@ -397,7 +397,7 @@ model TripStopTime {
|
||||
plannedArrivalAt DateTime?
|
||||
plannedDepartureAt DateTime?
|
||||
actualArrivalAt DateTime?
|
||||
status StopStatus @default(UPCOMING)
|
||||
status StopStatus @default(OPEN)
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
station Station @relation(fields: [stationId], references: [id])
|
||||
|
||||
@@ -1026,14 +1026,15 @@ model PasswordResetToken {
|
||||
}
|
||||
|
||||
model Route {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
effectiveFrom DateTime
|
||||
effectiveUntil DateTime?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
effectiveFrom DateTime
|
||||
effectiveUntil DateTime?
|
||||
active Boolean @default(true)
|
||||
checkinMinutesBefore Int @default(30)
|
||||
createdAt DateTime @default(now())
|
||||
stops RouteStop[]
|
||||
fareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
@@ -1043,13 +1044,14 @@ model Route {
|
||||
}
|
||||
|
||||
model RouteStop {
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
stationId String
|
||||
sequence Int
|
||||
distanceKm Float?
|
||||
createdAt DateTime @default(now())
|
||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
stationId String
|
||||
sequence Int
|
||||
distanceKm Float?
|
||||
checkinMinutesBefore Int?
|
||||
createdAt DateTime @default(now())
|
||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([routeId, sequence])
|
||||
@@index([routeId, stationId])
|
||||
|
||||
@@ -13,10 +13,17 @@ export const MAX_PAYMENT_HOURS = 2;
|
||||
export const CUTOFF_MINUTES = 30;
|
||||
|
||||
/**
|
||||
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||
* payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes)
|
||||
*
|
||||
* checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level
|
||||
* checkinMinutesBefore so that each route's own window is respected.
|
||||
*/
|
||||
export function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
|
||||
export function computePaymentDeadline(
|
||||
createdAt: Date,
|
||||
departureAt: Date,
|
||||
checkinMinutes: number = CUTOFF_MINUTES,
|
||||
): Date {
|
||||
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
|
||||
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
|
||||
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class LiveService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
const live = schedule.liveStatus;
|
||||
const nextStop = schedule.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
|
||||
const nextStop = schedule.stopTimes.find((s) => s.status === 'OPEN' || s.status === 'CHECKIN_CLOSED');
|
||||
return {
|
||||
scheduleId: schedule.id, trainName: schedule.train.name,
|
||||
fromStationName: schedule.originStation.name, toStationName: schedule.destinationStation.name,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@@ -35,6 +36,7 @@ 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: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
}
|
||||
|
||||
export class UpdateRouteDto {
|
||||
@@ -42,6 +44,7 @@ export class UpdateRouteDto {
|
||||
@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: 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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export class RoutesService {
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -92,6 +93,7 @@ export class RoutesService {
|
||||
description: dto.description,
|
||||
active: dto.active,
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
|
||||
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,6 +105,7 @@ export class RoutesService {
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -221,6 +224,7 @@ export class RoutesService {
|
||||
stationId: dto.stationId,
|
||||
sequence: dto.sequence,
|
||||
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
||||
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ export enum TripStatus {
|
||||
}
|
||||
|
||||
export enum StopStatus {
|
||||
OPEN = 'OPEN',
|
||||
CHECKIN_CLOSED = 'CHECKIN_CLOSED',
|
||||
BOARDED = 'BOARDED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
APPROACHING = 'APPROACHING',
|
||||
CURRENT = 'CURRENT',
|
||||
UPCOMING = 'UPCOMING',
|
||||
}
|
||||
|
||||
export enum PassengerCategory {
|
||||
@@ -65,7 +65,7 @@ export class UpdateScheduleDto {
|
||||
export class UpdateStopTimeDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
||||
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
|
||||
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.OPEN }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
|
||||
}
|
||||
|
||||
export class CreateFareRuleDto {
|
||||
|
||||
@@ -4,10 +4,9 @@ import { SearchService } from './search.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
|
||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
|
||||
@@ -6,7 +6,6 @@ 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 { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
|
||||
@@ -20,6 +19,7 @@ type ScheduleWithIncludes = {
|
||||
train: any;
|
||||
originStation: any;
|
||||
destinationStation: any;
|
||||
route: { checkinMinutesBefore: number; stops: Array<{ stationId: string; checkinMinutesBefore: number | null }> } | null;
|
||||
stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>;
|
||||
coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>;
|
||||
};
|
||||
@@ -28,6 +28,7 @@ const SCHEDULE_INCLUDE = {
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||
@@ -41,13 +42,8 @@ export class SearchService {
|
||||
private currencyService: CurrencyService,
|
||||
private fareEngine: FareEngineService,
|
||||
private segmentsService: SegmentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
private async getCutoffHours(): Promise<number> {
|
||||
return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE);
|
||||
}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
const [direct, transit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
@@ -218,10 +214,11 @@ export class SearchService {
|
||||
const now = new Date();
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
const cutoffHours = await this.getCutoffHours();
|
||||
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
|
||||
// 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 ? cutoffThreshold : date;
|
||||
const earliest = isToday ? now : date;
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
@@ -284,12 +281,9 @@ export class SearchService {
|
||||
}),
|
||||
]);
|
||||
|
||||
const cutoffHours = await this.getCutoffHours();
|
||||
const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) {
|
||||
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
|
||||
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
|
||||
if (!originStop) continue;
|
||||
|
||||
@@ -376,6 +370,16 @@ export class SearchService {
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
|
||||
|
||||
// Segment-level cutoff: use the origin stop's planned departure, not the
|
||||
// schedule's overall departureAt (which is station A's time). This lets
|
||||
// B→D remain bookable even after A→D closes.
|
||||
// Cutoff resolution: stop-level override → route default → 30 min fallback.
|
||||
const now = new Date();
|
||||
const segmentDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const routeStop = schedule.route?.stops?.find(s => s.stationId === originStationId);
|
||||
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
if (segmentDepartureAt.getTime() - now.getTime() <= checkinMinutes * 60 * 1000) return null;
|
||||
|
||||
// Collect all valid seat IDs upfront for a single batch availability check
|
||||
const allValidSeatIds = schedule.coachAssignments.flatMap(a =>
|
||||
a.coach.seats
|
||||
|
||||
@@ -268,23 +268,38 @@ export class SeatsService {
|
||||
if (new Set(seatIds).size !== seatIds.length)
|
||||
throw new BadRequestException('Duplicate seatId in passengers list');
|
||||
|
||||
const [holdMinutes, cutoffHours] = await Promise.all([
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES),
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE),
|
||||
]);
|
||||
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
|
||||
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
select: { departureAt: true },
|
||||
});
|
||||
const [schedule, originStopTime, originRouteStop] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
select: {
|
||||
departureAt: true,
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.tripStopTime.findFirst({
|
||||
where: { scheduleId: dto.scheduleId, stationId: dto.originStationId },
|
||||
select: { plannedDepartureAt: true },
|
||||
}),
|
||||
this.prisma.routeStop.findFirst({
|
||||
where: {
|
||||
route: { schedules: { some: { id: dto.scheduleId } } },
|
||||
stationId: dto.originStationId,
|
||||
},
|
||||
select: { checkinMinutesBefore: true },
|
||||
}),
|
||||
]);
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const msUntilDeparture = schedule.departureAt.getTime() - Date.now();
|
||||
const cutoffMs = cutoffHours * 60 * 60 * 1000;
|
||||
if (msUntilDeparture <= cutoffMs) {
|
||||
// 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;
|
||||
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
|
||||
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
|
||||
throw new BadRequestException(
|
||||
`Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`,
|
||||
`Seats cannot be held within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export class TasksService {
|
||||
const now = new Date();
|
||||
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
|
||||
|
||||
// ── Schedule-level transitions (operational display) ───────────────────
|
||||
const [boarding, departed, arrived] = await Promise.all([
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
|
||||
@@ -57,9 +58,71 @@ export class TasksService {
|
||||
}),
|
||||
]);
|
||||
|
||||
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
|
||||
// ── Per-stop transitions (segment-level status) ────────────────────────
|
||||
// OPEN → CHECKIN_CLOSED: each RouteStop carries its own checkinMinutesBefore
|
||||
// override; falls back to the Route-level value when null.
|
||||
// Group by effective cutoff → one updateMany per (effectiveMins, routeId) pair.
|
||||
const routeStops = await this.prisma.routeStop.findMany({
|
||||
select: {
|
||||
routeId: true,
|
||||
stationId: true,
|
||||
checkinMinutesBefore: true,
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Map: effectiveMins → Map<routeId, stationId[]>
|
||||
const byMins = new Map<number, Map<string, string[]>>();
|
||||
for (const stop of routeStops) {
|
||||
const mins = stop.checkinMinutesBefore ?? stop.route.checkinMinutesBefore;
|
||||
if (!byMins.has(mins)) byMins.set(mins, new Map());
|
||||
const byRoute = byMins.get(mins)!;
|
||||
if (!byRoute.has(stop.routeId)) byRoute.set(stop.routeId, []);
|
||||
byRoute.get(stop.routeId)!.push(stop.stationId);
|
||||
}
|
||||
|
||||
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).
|
||||
const reverted = await this.prisma.tripStopTime.updateMany({
|
||||
where: {
|
||||
status: 'CHECKIN_CLOSED',
|
||||
plannedDepartureAt: { gt: cutoffAt },
|
||||
stationId: { in: stationIds },
|
||||
schedule: { routeId },
|
||||
},
|
||||
data: { status: 'OPEN' },
|
||||
});
|
||||
reopenedCount += reverted.count;
|
||||
|
||||
// Forward: close stops now within the cutoff window.
|
||||
const closed = await this.prisma.tripStopTime.updateMany({
|
||||
where: {
|
||||
status: 'OPEN',
|
||||
plannedDepartureAt: { lte: cutoffAt },
|
||||
stationId: { in: stationIds },
|
||||
schedule: { routeId },
|
||||
},
|
||||
data: { status: 'CHECKIN_CLOSED' },
|
||||
});
|
||||
checkinClosedCount += closed.count;
|
||||
}
|
||||
}
|
||||
|
||||
const boardedStops = await this.prisma.tripStopTime.updateMany({
|
||||
where: { status: 'CHECKIN_CLOSED', plannedDepartureAt: { lte: now } },
|
||||
data: { status: 'BOARDED' },
|
||||
});
|
||||
|
||||
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0 ||
|
||||
reopenedCount > 0 || checkinClosedCount > 0 || boardedStops.count > 0) {
|
||||
this.logger.log(
|
||||
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
|
||||
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED | ` +
|
||||
`Stops: ${reopenedCount} → OPEN (reverted), ${checkinClosedCount} → CHECKIN_CLOSED, ${boardedStops.count} → BOARDED`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -96,13 +159,17 @@ export class TasksService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentReminderSentAt: null,
|
||||
createdAt: { gte: threeHoursAgo },
|
||||
schedule: { departureAt: { gte: now } },
|
||||
} as any,
|
||||
// Do NOT filter by schedule.departureAt here: for multi-stop routes the
|
||||
// passenger's segment may depart well after the schedule's first stop, and
|
||||
// that first-stop time could already be in the past even though B→C is still open.
|
||||
},
|
||||
include: {
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -110,9 +177,15 @@ export class TasksService {
|
||||
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||
const createdAt = booking.createdAt as Date;
|
||||
// Use the booking's origin-segment departure and the route's own check-in window.
|
||||
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;
|
||||
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();
|
||||
|
||||
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
|
||||
@@ -176,6 +249,8 @@ export class TasksService {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
},
|
||||
paymentIntent: { select: { method: true } },
|
||||
@@ -187,9 +262,14 @@ export class TasksService {
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
// 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.
|
||||
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);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
@@ -201,7 +281,7 @@ export class TasksService {
|
||||
// this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even
|
||||
// though the booking is now cancelled. Scoped to this booking's own schedule,
|
||||
// since the same physical Seat row is reused across other recurring dates.
|
||||
const seatIds = booking.seats.map(s => s.seatId);
|
||||
const seatIds = booking.seats.map((s: any) => s.seatId);
|
||||
if (seatIds.length > 0) {
|
||||
await this.prisma.seatHold.deleteMany({
|
||||
where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } },
|
||||
|
||||
@@ -16,6 +16,7 @@ interface RouteStop {
|
||||
sequence: number;
|
||||
distanceKm?: number;
|
||||
distanceFromOrigin?: number;
|
||||
checkinMinutesBefore?: number;
|
||||
}
|
||||
|
||||
type Tab = 'routes' | 'coaches';
|
||||
@@ -167,10 +168,13 @@ export default function RoutesPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('routes');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRoute, setEditingRoute] = useState<any>(null);
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [stops, setStops] = useState<RouteStop[]>([]);
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
||||
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(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 queryClient = useQueryClient();
|
||||
@@ -241,19 +245,22 @@ export default function RoutesPage() {
|
||||
|
||||
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
||||
const stopsArray = [
|
||||
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
|
||||
{ stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined },
|
||||
...sortedMiddleStops.map((stop, idx) => ({
|
||||
stationId: stop.stationId,
|
||||
sequence: idx + 2,
|
||||
distanceKm: stop.distanceFromOrigin || 0,
|
||||
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
|
||||
})),
|
||||
{
|
||||
stationId: destinationStationId,
|
||||
sequence: sortedMiddleStops.length + 2,
|
||||
distanceKm: destinationDistance || 0,
|
||||
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const checkinRaw = formData.get('checkinMinutesBefore') as string;
|
||||
const routeData = {
|
||||
code: formData.get('code') as string,
|
||||
name: formData.get('name') as string,
|
||||
@@ -261,6 +268,7 @@ export default function RoutesPage() {
|
||||
active: !editingRoute ? (formData.get('active') !== 'false') : undefined,
|
||||
effectiveFrom: formData.get('effectiveFrom') as string,
|
||||
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
|
||||
checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined,
|
||||
stops: stopsArray,
|
||||
};
|
||||
|
||||
@@ -341,6 +349,11 @@ export default function RoutesPage() {
|
||||
{ key: 'code', label: 'Route Code', sortable: true },
|
||||
{ key: 'name', label: 'Route Name', sortable: true },
|
||||
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
|
||||
{
|
||||
key: 'checkinMinutesBefore',
|
||||
label: 'Check-in Cutoff',
|
||||
render: (route: any) => `${route.checkinMinutesBefore ?? 30} min`,
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
label: 'Status',
|
||||
@@ -363,29 +376,38 @@ export default function RoutesPage() {
|
||||
);
|
||||
});
|
||||
|
||||
const openEditModal = async (route: any) => {
|
||||
setEditLoading(true);
|
||||
try {
|
||||
const full = await routesApi.getById(route.id) as any;
|
||||
const routeStops: any[] = full?.stops || [];
|
||||
setEditingRoute(full ?? route);
|
||||
if (routeStops.length >= 2) {
|
||||
const originStop = routeStops[0];
|
||||
const destStop = routeStops[routeStops.length - 1];
|
||||
setOriginStationId(originStop.stationId);
|
||||
setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined);
|
||||
setDestinationStationId(destStop.stationId);
|
||||
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
|
||||
setDestinationDistance(destStop.distanceKm || 0);
|
||||
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,
|
||||
})));
|
||||
}
|
||||
setShowModal(true);
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const routeActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: (route: any) => {
|
||||
setEditingRoute(route);
|
||||
const routeStops = route.stops || [];
|
||||
if (routeStops.length >= 2) {
|
||||
setOriginStationId(routeStops[0].stationId);
|
||||
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
|
||||
|
||||
// distanceKm is cumulative from origin — read directly
|
||||
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
|
||||
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
|
||||
stationId: stop.stationId,
|
||||
sequence: stop.sequence,
|
||||
distanceKm: stop.distanceKm,
|
||||
distanceFromOrigin: stop.distanceKm || 0,
|
||||
}));
|
||||
setStops(middleStops);
|
||||
}
|
||||
setShowModal(true);
|
||||
},
|
||||
onClick: openEditModal,
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
@@ -410,10 +432,11 @@ export default function RoutesPage() {
|
||||
onClick={() => {
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setOriginCheckinMinutes(undefined);
|
||||
setDestinationStationId('');
|
||||
setDestinationCheckinMinutes(undefined);
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
@@ -456,7 +479,7 @@ export default function RoutesPage() {
|
||||
data={displayedRoutes}
|
||||
columns={routeColumns}
|
||||
actions={routeActions}
|
||||
loading={routesLoading}
|
||||
loading={routesLoading || editLoading}
|
||||
emptyMessage={search ? 'No routes match your search' : 'No routes found'}
|
||||
/>
|
||||
</>
|
||||
@@ -488,7 +511,9 @@ export default function RoutesPage() {
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setOriginCheckinMinutes(undefined);
|
||||
setDestinationStationId('');
|
||||
setDestinationCheckinMinutes(undefined);
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
@@ -588,6 +613,23 @@ export default function RoutesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Check-in Cutoff (minutes before departure)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="checkinMinutesBefore"
|
||||
className="input"
|
||||
defaultValue={editingRoute?.checkinMinutesBefore ?? 30}
|
||||
min={1}
|
||||
max={480}
|
||||
step={1}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Booking closes and check-in ends this many minutes before each stop's departure. Default: 30.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!editingRoute && (
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
@@ -623,7 +665,7 @@ export default function RoutesPage() {
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="label mb-0">Route Stops</label>
|
||||
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
|
||||
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff min</span> overrides route check-in window per stop (leave blank to inherit)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -641,9 +683,18 @@ export default function RoutesPage() {
|
||||
<span className="text-muted-foreground">Select origin station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
0 km
|
||||
<div className="w-28 flex-shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="cutoff min"
|
||||
value={originCheckinMinutes ?? ''}
|
||||
onChange={(e) => setOriginCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground w-12 text-right flex-shrink-0">0 km</div>
|
||||
</div>
|
||||
|
||||
{stops.map((stop, index) => (
|
||||
@@ -678,7 +729,7 @@ export default function RoutesPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<div className="w-28">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
@@ -690,6 +741,17 @@ export default function RoutesPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="cutoff min"
|
||||
value={stop.checkinMinutesBefore ?? ''}
|
||||
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStop(index)}
|
||||
@@ -728,7 +790,20 @@ export default function RoutesPage() {
|
||||
<span className="text-muted-foreground">Select destination station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<div className="w-28">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="cutoff min"
|
||||
value={destinationCheckinMinutes ?? ''}
|
||||
onChange={(e) => setDestinationCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-28">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
@@ -754,7 +829,9 @@ export default function RoutesPage() {
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setOriginCheckinMinutes(undefined);
|
||||
setDestinationStationId('');
|
||||
setDestinationCheckinMinutes(undefined);
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
markManageBookingPaymentReturn,
|
||||
consumeManageBookingPaymentReturn,
|
||||
} from "@/utils/manage-booking-return";
|
||||
import QRCode from "qrcode.react";
|
||||
|
||||
// Derive display currency from the booking record's own displayCurrency field
|
||||
// (set at booking creation from the passenger's nationality). Falls back to ETB.
|
||||
@@ -985,22 +984,6 @@ function BookingDetailContent() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isConfirmed && (
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Total paid:{" "}
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{booking?.payment?.amountMinor != null
|
||||
? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}`
|
||||
: `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
|
||||
</span>
|
||||
{booking?.payment?.method && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{" "}
|
||||
via {booking.payment.method}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 justify-center mt-6">
|
||||
{isConfirmed && (
|
||||
@@ -1076,17 +1059,6 @@ function BookingDetailContent() {
|
||||
</span>
|
||||
<SeatDetailsGrid seat={seat} />
|
||||
</div>
|
||||
{isConfirmed && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-white p-3 rounded-lg border-2 border-gray-200">
|
||||
<QRCode
|
||||
value={`TICKET:${booking.bookingRef}-${seat?.id || `${idx}-${legLabel}`}`}
|
||||
size={72}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1095,17 +1067,6 @@ function BookingDetailContent() {
|
||||
<div className="flex-1">
|
||||
<SeatDetailsGrid seat={passenger.outboundSeat} />
|
||||
</div>
|
||||
{isConfirmed && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-white p-3 rounded-lg border-2 border-gray-200">
|
||||
<QRCode
|
||||
value={`TICKET:${booking.bookingRef}-${passenger.outboundSeat?.id || idx}`}
|
||||
size={80}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user