mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Added optimization for search result and fix sea map
This commit is contained in:
@@ -9,6 +9,30 @@ import { Currency } from '@prisma/client';
|
||||
|
||||
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()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
@@ -124,7 +148,7 @@ export class SearchService {
|
||||
if (windowStart < now) windowStart.setTime(now.getTime());
|
||||
|
||||
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);
|
||||
|
||||
@@ -139,30 +163,16 @@ export class SearchService {
|
||||
],
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
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,
|
||||
orderBy: { departureAt: 'asc' },
|
||||
});
|
||||
|
||||
const results: any[] = [];
|
||||
for (const schedule of schedules) {
|
||||
const result = await this.buildScheduleResult(
|
||||
schedule,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
totalPassengers,
|
||||
nationality,
|
||||
);
|
||||
if (result) results.push(result);
|
||||
}
|
||||
return results;
|
||||
const results = await Promise.all(
|
||||
schedules.map(schedule =>
|
||||
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
||||
)
|
||||
);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
private async searchSchedules(
|
||||
@@ -185,29 +195,18 @@ export class SearchService {
|
||||
departureAt: { gte: date < now ? now : date, lt: nextDay },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
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,
|
||||
});
|
||||
|
||||
const results: any[] = [];
|
||||
for (const schedule of schedules) {
|
||||
const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
|
||||
if (result) results.push(result);
|
||||
}
|
||||
return results;
|
||||
const results = await Promise.all(
|
||||
schedules.map(schedule =>
|
||||
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
||||
)
|
||||
);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
// ── 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 MAX_CONNECTION_MINUTES = 360;
|
||||
|
||||
@@ -219,82 +218,67 @@ export class SearchService {
|
||||
childCount?: number,
|
||||
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 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 leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
// Load all schedules on this date that pass through origin
|
||||
const leg1Schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: dayStart, lt: dayEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||
// Load leg1 and all potential leg2 candidates in one parallel round-trip
|
||||
// instead of firing a separate DB query per transit stop.
|
||||
const [leg1Schedules, allCandidates] = await Promise.all([
|
||||
this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: dayStart, lt: dayEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
include: SCHEDULE_INCLUDE,
|
||||
}),
|
||||
this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||
},
|
||||
include: SCHEDULE_INCLUDE,
|
||||
}),
|
||||
]);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const leg1 of leg1Schedules) {
|
||||
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
|
||||
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
|
||||
if (!originStop) continue;
|
||||
|
||||
// Every stop after origin on leg1 is a candidate transit station
|
||||
const candidateTransitStops = leg1.stopTimes.filter(
|
||||
(s: any) => s.sequence > originStop.sequence,
|
||||
s => s.sequence > originStop.sequence,
|
||||
);
|
||||
|
||||
for (const transitStop of candidateTransitStops) {
|
||||
// leg1 must NOT already contain the final destination
|
||||
const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId);
|
||||
if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
|
||||
const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId);
|
||||
if (leg1HasDest) continue;
|
||||
|
||||
const transitStationId = transitStop.stationId;
|
||||
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 connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
|
||||
|
||||
const leg2Schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: connWindowStart, lte: connWindowEnd },
|
||||
stopTimes: { some: { 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 } } } } },
|
||||
},
|
||||
},
|
||||
// Filter from pre-loaded candidates in memory — no extra DB query
|
||||
const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => {
|
||||
const dep = new Date(s.departureAt).getTime();
|
||||
return dep >= connWindowStart.getTime()
|
||||
&& dep <= connWindowEnd.getTime()
|
||||
&& s.stopTimes.some(st => st.stationId === transitStationId);
|
||||
});
|
||||
|
||||
for (const leg2 of leg2Schedules) {
|
||||
const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId);
|
||||
const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
||||
const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId);
|
||||
const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId);
|
||||
|
||||
if (!leg2TransitStop || !leg2DestStop) continue;
|
||||
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
|
||||
|
||||
// Build individual leg result objects (reuse existing per-schedule logic)
|
||||
const [leg1Result, leg2Result] = await Promise.all([
|
||||
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
|
||||
this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
|
||||
@@ -326,7 +310,6 @@ export class SearchService {
|
||||
displayCurrency,
|
||||
combinedMinFareMinor,
|
||||
combinedMinFareDisplay,
|
||||
// Convenience top-level fields so round-trip filter can read them uniformly
|
||||
departureAt: leg1Result.departureAt,
|
||||
arrivalAt: leg2Result.arrivalAt,
|
||||
totalDurationMinutes:
|
||||
@@ -339,19 +322,37 @@ export class SearchService {
|
||||
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(
|
||||
schedule: any,
|
||||
schedule: ScheduleWithIncludes,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
totalPassengers: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === originStationId);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
|
||||
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> = {};
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||
@@ -362,8 +363,7 @@ export class SearchService {
|
||||
let count = 0;
|
||||
for (const seat of assignment.coach.seats) {
|
||||
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 (free) count++;
|
||||
if (freeSeats.has(seat.id)) count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
|
||||
@@ -374,15 +374,13 @@ export class SearchService {
|
||||
let available = 0;
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
|
||||
if (free) available++;
|
||||
if (freeSeats.has(seat.id)) available++;
|
||||
}
|
||||
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
|
||||
}
|
||||
}
|
||||
|
||||
const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality);
|
||||
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
|
||||
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
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),
|
||||
status: schedule.status,
|
||||
stops: schedule.stopTimes
|
||||
.filter((st: any) => 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 })),
|
||||
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||
.map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
displayCurrency,
|
||||
@@ -500,46 +498,31 @@ export class SearchService {
|
||||
}
|
||||
|
||||
private async calculateFaresForSegment(
|
||||
schedule: any,
|
||||
schedule: ScheduleWithIncludes,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
nationality?: string,
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
||||
|
||||
const seatClassIds: string[] = Array.from(
|
||||
new Set(
|
||||
schedule.coachAssignments
|
||||
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
|
||||
.map((sc: any) => sc.id)
|
||||
.filter((id: any) => id)
|
||||
)
|
||||
);
|
||||
|
||||
if (seatClassIds.length === 0) {
|
||||
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
||||
return [];
|
||||
// Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
|
||||
const seatClassMap = new Map<string, any>();
|
||||
for (const a of schedule.coachAssignments) {
|
||||
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
|
||||
if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
|
||||
}
|
||||
}
|
||||
const seatClasses = Array.from(seatClassMap.values())
|
||||
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
|
||||
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
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 (seatClasses.length === 0) return [];
|
||||
|
||||
if (schedule.routeId) {
|
||||
const results = await Promise.all(
|
||||
seatClasses.map(async (sc) => {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
routeId: schedule.routeId!,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
seatClassId: sc.id,
|
||||
@@ -552,8 +535,7 @@ export class SearchService {
|
||||
displayCurrency: fare.billingCurrency as Currency,
|
||||
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
@@ -562,36 +544,32 @@ export class SearchService {
|
||||
const validResults = results.filter(
|
||||
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
|
||||
);
|
||||
if (validResults.length > 0) {
|
||||
return validResults;
|
||||
}
|
||||
if (validResults.length > 0) return validResults;
|
||||
}
|
||||
|
||||
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
|
||||
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
|
||||
// Fallback: use station codes from already-loaded stopTimes when available
|
||||
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) {
|
||||
const segmentRoute = `${originStation.code}-${destStation.code}`;
|
||||
if (originCode && destCode) {
|
||||
const segmentRoute = `${originCode}-${destCode}`;
|
||||
const now = new Date();
|
||||
|
||||
const fareRules = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
route: segmentRoute,
|
||||
seatClassId: { in: seatClassIds },
|
||||
seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
return fareRules.map(rule => ({
|
||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
||||
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
displayCurrency,
|
||||
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 [];
|
||||
}
|
||||
|
||||
private async buildCoachTypeDetails(
|
||||
schedule: any,
|
||||
// buildCoachTypeDetails is pure in-memory — no async needed
|
||||
private buildCoachTypeDetails(
|
||||
schedule: ScheduleWithIncludes,
|
||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
||||
): Promise<Array<{
|
||||
): Array<{
|
||||
coachTypeId: string;
|
||||
coachTypeName: string;
|
||||
coachTypeCode: string;
|
||||
coachId: string;
|
||||
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
|
||||
}>> {
|
||||
}> {
|
||||
const coachTypeMap = new Map<
|
||||
string,
|
||||
{ coachType: any; classNames: Set<string>; coachId: string }
|
||||
@@ -682,14 +660,6 @@ export class SearchService {
|
||||
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(
|
||||
candidates: any[],
|
||||
scheduleId: string,
|
||||
|
||||
@@ -146,6 +146,96 @@ export class SegmentsService {
|
||||
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 */
|
||||
async getOverlappingReservations(
|
||||
scheduleId: string,
|
||||
|
||||
@@ -259,20 +259,14 @@ export default function SeatsPage() {
|
||||
})),
|
||||
});
|
||||
|
||||
const coachesWithSeats = coaches.filter(
|
||||
(c: any) => c.seats && c.seats.length > 0,
|
||||
);
|
||||
|
||||
if (!currentSchedule?.selectedSeatClass) {
|
||||
console.log(
|
||||
"✅ No filter applied, returning all coaches:",
|
||||
coachesWithSeats.length,
|
||||
);
|
||||
return coachesWithSeats;
|
||||
}
|
||||
const coachesWithSeats = coaches.filter((c: any) => {
|
||||
// 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;
|
||||
});
|
||||
|
||||
console.log(
|
||||
"✅ No seat class filter - returning all coaches with seats:",
|
||||
"✅ Returning all coaches with seats/beds:",
|
||||
coachesWithSeats.length,
|
||||
);
|
||||
return coachesWithSeats;
|
||||
@@ -333,6 +327,8 @@ export default function SeatsPage() {
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
const isBedCoach =
|
||||
selectedCoachData?.isBedCoach === true ||
|
||||
seats.some((s: any) => s.bedPosition) ||
|
||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||
|
||||
@@ -1071,6 +1067,8 @@ export default function SeatsPage() {
|
||||
|
||||
const allSelected = selectedSeats.length === passengers.length;
|
||||
const isBedCoach =
|
||||
selectedCoachData?.isBedCoach === true ||
|
||||
selectedCoachData?.rooms?.length > 0 ||
|
||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||
|
||||
@@ -1314,6 +1312,8 @@ export default function SeatsPage() {
|
||||
}
|
||||
|
||||
const isBed =
|
||||
coach.isBedCoach === true ||
|
||||
coach.rooms?.length > 0 ||
|
||||
coach.seatClass?.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 Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
|
||||
export default function AppHeader() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -72,9 +71,6 @@ export default function AppHeader() {
|
||||
<HelpCircle className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Theme Toggler */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
|
||||
Reference in New Issue
Block a user