mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +00:00
@@ -15,6 +15,10 @@ export class DynamicThrottlerGuard extends ThrottlerGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (context.getType() !== 'http') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
|
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),
|
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
||||||
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import {
|
import {
|
||||||
PAYMENT_EVENTS_DLX,
|
PAYMENT_EVENTS_DLX,
|
||||||
PAYMENT_EVENTS_EXCHANGE,
|
PAYMENT_EVENTS_EXCHANGE,
|
||||||
@@ -19,6 +20,7 @@ export class PaymentEventsConsumer {
|
|||||||
|
|
||||||
constructor(private readonly paymentsService: PaymentsService) {}
|
constructor(private readonly paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
|
@IsPublic()
|
||||||
@RabbitSubscribe({
|
@RabbitSubscribe({
|
||||||
exchange: PAYMENT_EVENTS_EXCHANGE,
|
exchange: PAYMENT_EVENTS_EXCHANGE,
|
||||||
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.*
|
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.*
|
||||||
|
|||||||
@@ -9,6 +9,30 @@ import { Currency } from '@prisma/client';
|
|||||||
|
|
||||||
const POINTS_TO_MINOR = 10;
|
const POINTS_TO_MINOR = 10;
|
||||||
|
|
||||||
|
// Shape returned by the heavy schedule include used throughout this service
|
||||||
|
type ScheduleWithIncludes = {
|
||||||
|
id: string;
|
||||||
|
routeId: string | null;
|
||||||
|
departureAt: Date;
|
||||||
|
arrivalAt: Date;
|
||||||
|
status: string;
|
||||||
|
train: any;
|
||||||
|
originStation: any;
|
||||||
|
destinationStation: any;
|
||||||
|
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 } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHEDULE_INCLUDE = {
|
||||||
|
train: true,
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||||
|
coachAssignments: {
|
||||||
|
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -124,7 +148,7 @@ export class SearchService {
|
|||||||
if (windowStart < now) windowStart.setTime(now.getTime());
|
if (windowStart < now) windowStart.setTime(now.getTime());
|
||||||
|
|
||||||
const windowEnd = new Date(requestedDate);
|
const windowEnd = new Date(requestedDate);
|
||||||
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound
|
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1);
|
||||||
|
|
||||||
const totalPassengers = adultCount + (childCount ?? 0);
|
const totalPassengers = adultCount + (childCount ?? 0);
|
||||||
|
|
||||||
@@ -139,30 +163,16 @@ export class SearchService {
|
|||||||
],
|
],
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: SCHEDULE_INCLUDE,
|
||||||
train: true,
|
|
||||||
originStation: true,
|
|
||||||
destinationStation: true,
|
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
|
||||||
coachAssignments: {
|
|
||||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { departureAt: 'asc' },
|
orderBy: { departureAt: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const results: any[] = [];
|
const results = await Promise.all(
|
||||||
for (const schedule of schedules) {
|
schedules.map(schedule =>
|
||||||
const result = await this.buildScheduleResult(
|
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
||||||
schedule,
|
)
|
||||||
originStationId,
|
);
|
||||||
destinationStationId,
|
return results.filter(Boolean);
|
||||||
totalPassengers,
|
|
||||||
nationality,
|
|
||||||
);
|
|
||||||
if (result) results.push(result);
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async searchSchedules(
|
private async searchSchedules(
|
||||||
@@ -185,29 +195,18 @@ export class SearchService {
|
|||||||
departureAt: { gte: date < now ? now : date, lt: nextDay },
|
departureAt: { gte: date < now ? now : date, lt: nextDay },
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: SCHEDULE_INCLUDE,
|
||||||
train: true,
|
|
||||||
originStation: true,
|
|
||||||
destinationStation: true,
|
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
|
||||||
coachAssignments: {
|
|
||||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const results: any[] = [];
|
const results = await Promise.all(
|
||||||
for (const schedule of schedules) {
|
schedules.map(schedule =>
|
||||||
const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
|
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
||||||
if (result) results.push(result);
|
)
|
||||||
}
|
);
|
||||||
return results;
|
return results.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Transit search ─────────────────────────────────────────────────────────
|
// ── Transit search ─────────────────────────────────────────────────────────
|
||||||
// Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination)
|
|
||||||
// where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes
|
|
||||||
// to change trains at the transit station.
|
|
||||||
private readonly MIN_CONNECTION_MINUTES = 30;
|
private readonly MIN_CONNECTION_MINUTES = 30;
|
||||||
private readonly MAX_CONNECTION_MINUTES = 360;
|
private readonly MAX_CONNECTION_MINUTES = 360;
|
||||||
|
|
||||||
@@ -219,82 +218,67 @@ export class SearchService {
|
|||||||
childCount?: number,
|
childCount?: number,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
) {
|
) {
|
||||||
// Find all stations that can serve as transit points:
|
|
||||||
// they must be a stop after origin on some schedule AND
|
|
||||||
// a stop before destination on another schedule on the same day.
|
|
||||||
const [y, m, d] = dateStr.split('-').map(Number);
|
const [y, m, d] = dateStr.split('-').map(Number);
|
||||||
const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0);
|
const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||||
const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||||
|
const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
|
||||||
const totalPassengers = adultCount + (childCount ?? 0);
|
const totalPassengers = adultCount + (childCount ?? 0);
|
||||||
|
|
||||||
// Load all schedules on this date that pass through origin
|
// Load leg1 and all potential leg2 candidates in one parallel round-trip
|
||||||
const leg1Schedules = await this.prisma.trainSchedule.findMany({
|
// instead of firing a separate DB query per transit stop.
|
||||||
where: {
|
const [leg1Schedules, allCandidates] = await Promise.all([
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
this.prisma.trainSchedule.findMany({
|
||||||
departureAt: { gte: dayStart, lt: dayEnd },
|
where: {
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||||
},
|
departureAt: { gte: dayStart, lt: dayEnd },
|
||||||
include: {
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
train: true,
|
|
||||||
originStation: true,
|
|
||||||
destinationStation: true,
|
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
|
||||||
coachAssignments: {
|
|
||||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
|
||||||
},
|
},
|
||||||
},
|
include: SCHEDULE_INCLUDE,
|
||||||
});
|
}),
|
||||||
|
this.prisma.trainSchedule.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||||
|
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||||
|
},
|
||||||
|
include: SCHEDULE_INCLUDE,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
for (const leg1 of leg1Schedules) {
|
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
|
||||||
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
|
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
|
||||||
if (!originStop) continue;
|
if (!originStop) continue;
|
||||||
|
|
||||||
// Every stop after origin on leg1 is a candidate transit station
|
|
||||||
const candidateTransitStops = leg1.stopTimes.filter(
|
const candidateTransitStops = leg1.stopTimes.filter(
|
||||||
(s: any) => s.sequence > originStop.sequence,
|
s => s.sequence > originStop.sequence,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const transitStop of candidateTransitStops) {
|
for (const transitStop of candidateTransitStops) {
|
||||||
// leg1 must NOT already contain the final destination
|
const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId);
|
||||||
const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId);
|
if (leg1HasDest) continue;
|
||||||
if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
|
|
||||||
|
|
||||||
const transitStationId = transitStop.stationId;
|
const transitStationId = transitStop.stationId;
|
||||||
const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
|
const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
|
||||||
|
|
||||||
// Find leg2 schedules departing from the transit station within the connection window,
|
|
||||||
// and reaching the final destination. Search up to the next calendar day to handle
|
|
||||||
// overnight connections.
|
|
||||||
const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000);
|
const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000);
|
||||||
const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
|
const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
|
||||||
|
|
||||||
const leg2Schedules = await this.prisma.trainSchedule.findMany({
|
// Filter from pre-loaded candidates in memory — no extra DB query
|
||||||
where: {
|
const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => {
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
const dep = new Date(s.departureAt).getTime();
|
||||||
departureAt: { gte: connWindowStart, lte: connWindowEnd },
|
return dep >= connWindowStart.getTime()
|
||||||
stopTimes: { some: { stationId: transitStationId } },
|
&& dep <= connWindowEnd.getTime()
|
||||||
},
|
&& s.stopTimes.some(st => st.stationId === transitStationId);
|
||||||
include: {
|
|
||||||
train: true,
|
|
||||||
originStation: true,
|
|
||||||
destinationStation: true,
|
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
|
||||||
coachAssignments: {
|
|
||||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const leg2 of leg2Schedules) {
|
for (const leg2 of leg2Schedules) {
|
||||||
const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId);
|
const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId);
|
||||||
const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId);
|
||||||
|
|
||||||
if (!leg2TransitStop || !leg2DestStop) continue;
|
if (!leg2TransitStop || !leg2DestStop) continue;
|
||||||
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
|
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
|
||||||
|
|
||||||
// Build individual leg result objects (reuse existing per-schedule logic)
|
|
||||||
const [leg1Result, leg2Result] = await Promise.all([
|
const [leg1Result, leg2Result] = await Promise.all([
|
||||||
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
|
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
|
||||||
this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
|
this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
|
||||||
@@ -326,7 +310,6 @@ export class SearchService {
|
|||||||
displayCurrency,
|
displayCurrency,
|
||||||
combinedMinFareMinor,
|
combinedMinFareMinor,
|
||||||
combinedMinFareDisplay,
|
combinedMinFareDisplay,
|
||||||
// Convenience top-level fields so round-trip filter can read them uniformly
|
|
||||||
departureAt: leg1Result.departureAt,
|
departureAt: leg1Result.departureAt,
|
||||||
arrivalAt: leg2Result.arrivalAt,
|
arrivalAt: leg2Result.arrivalAt,
|
||||||
totalDurationMinutes:
|
totalDurationMinutes:
|
||||||
@@ -339,19 +322,37 @@ export class SearchService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds the same result shape as searchSchedules for a single schedule+leg,
|
|
||||||
// extracted so both direct and transit paths share identical output.
|
|
||||||
private async buildScheduleResult(
|
private async buildScheduleResult(
|
||||||
schedule: any,
|
schedule: ScheduleWithIncludes,
|
||||||
originStationId: string,
|
originStationId: string,
|
||||||
destinationStationId: string,
|
destinationStationId: string,
|
||||||
totalPassengers: number,
|
totalPassengers: number,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
) {
|
) {
|
||||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
|
const originStop = schedule.stopTimes.find(s => s.stationId === originStationId);
|
||||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
|
||||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
|
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
|
||||||
|
|
||||||
|
// Collect all valid seat IDs upfront for a single batch availability check
|
||||||
|
const allValidSeatIds = schedule.coachAssignments.flatMap(a =>
|
||||||
|
a.coach.seats
|
||||||
|
.filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim())
|
||||||
|
.map((s: any) => s.id as string)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run availability batch and fare calculation in parallel
|
||||||
|
const [freeSeats, faresByClass] = await Promise.all([
|
||||||
|
this.segmentsService.getFreeSeatIds(
|
||||||
|
schedule.id,
|
||||||
|
allValidSeatIds,
|
||||||
|
schedule.stopTimes,
|
||||||
|
originStop.sequence,
|
||||||
|
destStop.sequence,
|
||||||
|
),
|
||||||
|
this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Compute per-class availability using the pre-computed free seat set
|
||||||
const availabilityByClass: Record<string, number> = {};
|
const availabilityByClass: Record<string, number> = {};
|
||||||
for (const assignment of schedule.coachAssignments) {
|
for (const assignment of schedule.coachAssignments) {
|
||||||
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||||
@@ -362,8 +363,7 @@ export class SearchService {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
for (const seat of assignment.coach.seats) {
|
for (const seat of assignment.coach.seats) {
|
||||||
if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
||||||
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
|
if (freeSeats.has(seat.id)) count++;
|
||||||
if (free) count++;
|
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
|
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
|
||||||
@@ -374,15 +374,13 @@ export class SearchService {
|
|||||||
let available = 0;
|
let available = 0;
|
||||||
for (const seat of assignment.coach.seats) {
|
for (const seat of assignment.coach.seats) {
|
||||||
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
||||||
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
|
if (freeSeats.has(seat.id)) available++;
|
||||||
if (free) available++;
|
|
||||||
}
|
}
|
||||||
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
|
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality);
|
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
|
||||||
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
|
|
||||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||||
|
|
||||||
@@ -400,8 +398,8 @@ export class SearchService {
|
|||||||
durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
|
durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
stops: schedule.stopTimes
|
stops: schedule.stopTimes
|
||||||
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||||
.map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
|
.map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
|
||||||
availabilityByClass,
|
availabilityByClass,
|
||||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
@@ -500,46 +498,31 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async calculateFaresForSegment(
|
private async calculateFaresForSegment(
|
||||||
schedule: any,
|
schedule: ScheduleWithIncludes,
|
||||||
originStationId: string,
|
originStationId: string,
|
||||||
destinationStationId: string,
|
destinationStationId: string,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||||
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
||||||
|
|
||||||
const seatClassIds: string[] = Array.from(
|
// Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
|
||||||
new Set(
|
const seatClassMap = new Map<string, any>();
|
||||||
schedule.coachAssignments
|
for (const a of schedule.coachAssignments) {
|
||||||
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
|
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
|
||||||
.map((sc: any) => sc.id)
|
if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
|
||||||
.filter((id: any) => id)
|
}
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (seatClassIds.length === 0) {
|
|
||||||
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
const seatClasses = Array.from(seatClassMap.values())
|
||||||
|
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
|
||||||
|
|
||||||
const seatClasses = await this.prisma.seatClass.findMany({
|
if (seatClasses.length === 0) return [];
|
||||||
where: {
|
|
||||||
isActive: true,
|
|
||||||
id: { in: seatClassIds }
|
|
||||||
},
|
|
||||||
orderBy: { baseFareMinor: 'asc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (seatClasses.length === 0) {
|
|
||||||
console.log(`No active seat classes for schedule ${schedule.id}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schedule.routeId) {
|
if (schedule.routeId) {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
seatClasses.map(async (sc) => {
|
seatClasses.map(async (sc) => {
|
||||||
try {
|
try {
|
||||||
const fare = await this.fareEngine.calculate({
|
const fare = await this.fareEngine.calculate({
|
||||||
routeId: schedule.routeId,
|
routeId: schedule.routeId!,
|
||||||
originStationId,
|
originStationId,
|
||||||
destinationStationId,
|
destinationStationId,
|
||||||
seatClassId: sc.id,
|
seatClassId: sc.id,
|
||||||
@@ -552,8 +535,7 @@ export class SearchService {
|
|||||||
displayCurrency: fare.billingCurrency as Currency,
|
displayCurrency: fare.billingCurrency as Currency,
|
||||||
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -562,36 +544,32 @@ export class SearchService {
|
|||||||
const validResults = results.filter(
|
const validResults = results.filter(
|
||||||
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
|
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
|
||||||
);
|
);
|
||||||
if (validResults.length > 0) {
|
if (validResults.length > 0) return validResults;
|
||||||
return validResults;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
|
// Fallback: use station codes from already-loaded stopTimes when available
|
||||||
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
|
const originStop = schedule.stopTimes.find(st => st.stationId === originStationId);
|
||||||
|
const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId);
|
||||||
|
const originCode = originStop?.station?.code;
|
||||||
|
const destCode = destStop?.station?.code;
|
||||||
|
|
||||||
if (originStation && destStation) {
|
if (originCode && destCode) {
|
||||||
const segmentRoute = `${originStation.code}-${destStation.code}`;
|
const segmentRoute = `${originCode}-${destCode}`;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const fareRules = await this.prisma.fareRule.findMany({
|
const fareRules = await this.prisma.fareRule.findMany({
|
||||||
where: {
|
where: {
|
||||||
route: segmentRoute,
|
route: segmentRoute,
|
||||||
seatClassId: { in: seatClassIds },
|
seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
|
||||||
validFrom: { lte: now },
|
validFrom: { lte: now },
|
||||||
OR: [
|
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||||
{ validUntil: null },
|
|
||||||
{ validUntil: { gte: now } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fareRules.length > 0) {
|
if (fareRules.length > 0) {
|
||||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
|
||||||
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
|
|
||||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
||||||
return fareRules.map(rule => ({
|
return fareRules.map(rule => ({
|
||||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
|
||||||
baseFareMinor: rule.baseFareMinor,
|
baseFareMinor: rule.baseFareMinor,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
|
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
|
||||||
@@ -599,20 +577,20 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildCoachTypeDetails(
|
// buildCoachTypeDetails is pure in-memory — no async needed
|
||||||
schedule: any,
|
private buildCoachTypeDetails(
|
||||||
|
schedule: ScheduleWithIncludes,
|
||||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
||||||
): Promise<Array<{
|
): Array<{
|
||||||
coachTypeId: string;
|
coachTypeId: string;
|
||||||
coachTypeName: string;
|
coachTypeName: string;
|
||||||
coachTypeCode: string;
|
coachTypeCode: string;
|
||||||
coachId: string;
|
coachId: string;
|
||||||
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
|
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
|
||||||
}>> {
|
}> {
|
||||||
const coachTypeMap = new Map<
|
const coachTypeMap = new Map<
|
||||||
string,
|
string,
|
||||||
{ coachType: any; classNames: Set<string>; coachId: string }
|
{ coachType: any; classNames: Set<string>; coachId: string }
|
||||||
@@ -682,14 +660,6 @@ export class SearchService {
|
|||||||
return fare.baseFarePerPassengerMinor;
|
return fare.baseFarePerPassengerMinor;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultFareForClass(_className: string): never {
|
|
||||||
throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
|
|
||||||
}
|
|
||||||
|
|
||||||
private defaultFare(_seatClassName: string): never {
|
|
||||||
throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
|
|
||||||
}
|
|
||||||
|
|
||||||
private selectBestFareRule(
|
private selectBestFareRule(
|
||||||
candidates: any[],
|
candidates: any[],
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
|
|||||||
@@ -146,6 +146,96 @@ export class SegmentsService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch availability check for multiple seats on a single schedule.
|
||||||
|
* Replaces N×isSeatFreeForLeg calls with 2 queries total.
|
||||||
|
* Returns a Set of seat IDs that are free for [reqFrom, reqTo).
|
||||||
|
*/
|
||||||
|
async getFreeSeatIds(
|
||||||
|
scheduleId: string,
|
||||||
|
seatIds: string[],
|
||||||
|
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
|
||||||
|
reqFrom: number,
|
||||||
|
reqTo: number,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
if (seatIds.length === 0) return new Set();
|
||||||
|
|
||||||
|
const seqOf = (stationId: string) =>
|
||||||
|
stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence;
|
||||||
|
|
||||||
|
const seatIdSet = new Set(seatIds);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const [allHolds, bookedLegs] = await Promise.all([
|
||||||
|
this.prisma.seatHold.findMany({
|
||||||
|
where: { scheduleId, expiresAt: { gt: now } },
|
||||||
|
select: { seatIds: true, createdBy: true },
|
||||||
|
}),
|
||||||
|
this.prisma.journeySegment.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId,
|
||||||
|
seatId: { in: seatIds },
|
||||||
|
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||||
|
},
|
||||||
|
select: { seatId: true, journeyId: true, departureStationId: true, arrivalStationId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Determine which seats are blocked by active holds
|
||||||
|
const holdBlockedSeats = new Set<string>();
|
||||||
|
for (const hold of allHolds) {
|
||||||
|
let holdFrom: number | undefined;
|
||||||
|
let holdTo: number | undefined;
|
||||||
|
try {
|
||||||
|
if (hold.createdBy) {
|
||||||
|
const meta = JSON.parse(hold.createdBy as string);
|
||||||
|
holdFrom = seqOf(meta.originStationId);
|
||||||
|
holdTo = seqOf(meta.destinationStationId);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
for (const sid of hold.seatIds) {
|
||||||
|
if (!seatIdSet.has(sid)) continue;
|
||||||
|
// Conservative block if leg can't be resolved; otherwise check overlap
|
||||||
|
if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) {
|
||||||
|
holdBlockedSeats.add(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full journey ranges per seat (group multi-leg journeys)
|
||||||
|
const journeyRangesBySeat = new Map<string, Map<string, { from: number; to: number }>>();
|
||||||
|
for (const leg of bookedLegs) {
|
||||||
|
if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue;
|
||||||
|
const depSeq = seqOf(leg.departureStationId);
|
||||||
|
const arrSeq = seqOf(leg.arrivalStationId);
|
||||||
|
if (depSeq === undefined || arrSeq === undefined) continue;
|
||||||
|
|
||||||
|
let rangeMap = journeyRangesBySeat.get(leg.seatId);
|
||||||
|
if (!rangeMap) { rangeMap = new Map(); journeyRangesBySeat.set(leg.seatId, rangeMap); }
|
||||||
|
|
||||||
|
const existing = rangeMap.get(leg.journeyId);
|
||||||
|
rangeMap.set(leg.journeyId, existing
|
||||||
|
? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) }
|
||||||
|
: { from: depSeq, to: arrSeq });
|
||||||
|
}
|
||||||
|
|
||||||
|
const freeSeats = new Set<string>();
|
||||||
|
for (const seatId of seatIds) {
|
||||||
|
if (holdBlockedSeats.has(seatId)) continue;
|
||||||
|
let blocked = false;
|
||||||
|
const rangeMap = journeyRangesBySeat.get(seatId);
|
||||||
|
if (rangeMap) {
|
||||||
|
for (const { from, to } of rangeMap.values()) {
|
||||||
|
if (from < reqTo && reqFrom < to) { blocked = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!blocked) freeSeats.add(seatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return freeSeats;
|
||||||
|
}
|
||||||
|
|
||||||
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
|
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
|
||||||
async getOverlappingReservations(
|
async getOverlappingReservations(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
|
|||||||
@@ -259,20 +259,14 @@ export default function SeatsPage() {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const coachesWithSeats = coaches.filter(
|
const coachesWithSeats = coaches.filter((c: any) => {
|
||||||
(c: any) => c.seats && c.seats.length > 0,
|
// Bed coaches store occupants in rooms.beds, not seats
|
||||||
);
|
if (c.rooms?.length > 0) return c.rooms.some((r: any) => r.beds?.length > 0);
|
||||||
|
return c.seats && c.seats.length > 0;
|
||||||
if (!currentSchedule?.selectedSeatClass) {
|
});
|
||||||
console.log(
|
|
||||||
"✅ No filter applied, returning all coaches:",
|
|
||||||
coachesWithSeats.length,
|
|
||||||
);
|
|
||||||
return coachesWithSeats;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"✅ No seat class filter - returning all coaches with seats:",
|
"✅ Returning all coaches with seats/beds:",
|
||||||
coachesWithSeats.length,
|
coachesWithSeats.length,
|
||||||
);
|
);
|
||||||
return coachesWithSeats;
|
return coachesWithSeats;
|
||||||
@@ -333,6 +327,8 @@ export default function SeatsPage() {
|
|||||||
return seatLabel && !seatLabel.startsWith("-");
|
return seatLabel && !seatLabel.startsWith("-");
|
||||||
});
|
});
|
||||||
const isBedCoach =
|
const isBedCoach =
|
||||||
|
selectedCoachData?.isBedCoach === true ||
|
||||||
|
seats.some((s: any) => s.bedPosition) ||
|
||||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||||
|
|
||||||
@@ -1071,6 +1067,8 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
const allSelected = selectedSeats.length === passengers.length;
|
const allSelected = selectedSeats.length === passengers.length;
|
||||||
const isBedCoach =
|
const isBedCoach =
|
||||||
|
selectedCoachData?.isBedCoach === true ||
|
||||||
|
selectedCoachData?.rooms?.length > 0 ||
|
||||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||||
|
|
||||||
@@ -1314,6 +1312,8 @@ export default function SeatsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isBed =
|
const isBed =
|
||||||
|
coach.isBedCoach === true ||
|
||||||
|
coach.rooms?.length > 0 ||
|
||||||
coach.seatClass?.toLowerCase().includes("bed") ||
|
coach.seatClass?.toLowerCase().includes("bed") ||
|
||||||
coach.mode?.toLowerCase().includes("bed");
|
coach.mode?.toLowerCase().includes("bed");
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
|
||||||
|
|
||||||
export default function AppHeader() {
|
export default function AppHeader() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -72,9 +71,6 @@ export default function AppHeader() {
|
|||||||
<HelpCircle className="w-5 h-5" />
|
<HelpCircle className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Language Switcher */}
|
|
||||||
<LanguageSwitcher />
|
|
||||||
|
|
||||||
{/* Theme Toggler */}
|
{/* Theme Toggler */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
): Promise<ProviderInitiationResult> {
|
): Promise<ProviderInitiationResult> {
|
||||||
const fabricToken = await this.applyFabricToken();
|
const fabricToken = await this.applyFabricToken();
|
||||||
const requestBody = this.buildPreOrderRequest(input);
|
const requestBody = this.buildPreOrderRequest(input);
|
||||||
|
this.logger.log(
|
||||||
|
`D-Money preOrder send request merchOrderId=${input.merchantOrderId} body=${JSON.stringify(this.sanitize(requestBody))}`,
|
||||||
|
);
|
||||||
const response = await this.postJson<DMoneyPreOrderResponse>(
|
const response = await this.postJson<DMoneyPreOrderResponse>(
|
||||||
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`,
|
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`,
|
||||||
requestBody,
|
requestBody,
|
||||||
|
|||||||
Reference in New Issue
Block a user