mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
feat: enhance search and ticketing for multi-leg (round trip + transit) journeys
This commit is contained in:
@@ -71,27 +71,38 @@ export class FareQuoteDto {
|
||||
}
|
||||
|
||||
export class CoachTypeOptionClass {
|
||||
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
|
||||
baseFareMinor: number;
|
||||
@ApiProperty({ example: 'Economy Regular' }) name: string;
|
||||
@ApiProperty({ example: 35000 }) baseFareMinor: number;
|
||||
}
|
||||
|
||||
export class CoachTypeOption {
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
|
||||
coachTypeId: string;
|
||||
|
||||
@ApiProperty({ example: 'Economy', description: 'Coach type display name' })
|
||||
coachTypeName: string;
|
||||
|
||||
@ApiProperty({ example: 'ECO', description: 'Coach type code' })
|
||||
coachTypeCode: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
|
||||
description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
|
||||
})
|
||||
classes: CoachTypeOptionClass[];
|
||||
@ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
|
||||
@ApiProperty({ example: 'Economy' }) coachTypeName: string;
|
||||
@ApiProperty({ example: 'ECO' }) coachTypeCode: string;
|
||||
@ApiProperty({ type: 'array', items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' } }) classes: CoachTypeOptionClass[];
|
||||
}
|
||||
|
||||
export class TransitLegDto {
|
||||
@ApiProperty({ example: 'schedule-uuid' }) scheduleId: string;
|
||||
@ApiProperty() trainNumber: string;
|
||||
@ApiProperty() trainName: string;
|
||||
@ApiProperty() origin: object;
|
||||
@ApiProperty() destination: object;
|
||||
@ApiProperty() departureAt: Date;
|
||||
@ApiProperty() arrivalAt: Date;
|
||||
@ApiProperty() durationMinutes: number;
|
||||
@ApiProperty() availabilityByClass: object;
|
||||
@ApiProperty() faresByClass: object[];
|
||||
@ApiProperty() coachTypes: CoachTypeOption[];
|
||||
}
|
||||
|
||||
export class TransitResultDto {
|
||||
@ApiProperty({ example: 'TRANSIT' }) type: string;
|
||||
@ApiProperty({ example: 'station-uuid' }) transitStationId: string;
|
||||
@ApiProperty({ example: 'Dire Dawa' }) transitStationName: string;
|
||||
@ApiProperty({ description: 'Connection wait time in minutes' }) connectionMinutes: number;
|
||||
@ApiProperty({ type: TransitLegDto }) leg1: TransitLegDto;
|
||||
@ApiProperty({ type: TransitLegDto }) leg2: TransitLegDto;
|
||||
@ApiProperty({ description: 'Combined minimum fare across all shared classes', example: 70000 }) combinedMinFareMinor: number;
|
||||
@ApiProperty({ description: 'Total travel time including connection in minutes' }) totalDurationMinutes: number;
|
||||
}
|
||||
|
||||
@@ -18,31 +18,54 @@ export class SearchService {
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
const outbound = await this.searchSchedules(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
|
||||
if (dto.journeyType === 'ROUND_TRIP') {
|
||||
const allInbound = await this.searchSchedules(
|
||||
dto.destinationStationId,
|
||||
const [direct, transit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
),
|
||||
]);
|
||||
|
||||
const outbound = [...direct, ...transit];
|
||||
|
||||
if (dto.journeyType === 'ROUND_TRIP') {
|
||||
const [returnDirect, returnTransit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
dto.destinationStationId,
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.destinationStationId,
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
),
|
||||
]);
|
||||
|
||||
const allReturn = [...returnDirect, ...returnTransit];
|
||||
const latestOutboundArrival = outbound.length > 0
|
||||
? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
|
||||
? Math.max(...outbound.map((s: any) => new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime()))
|
||||
: Date.now();
|
||||
|
||||
const inbound = allInbound.filter((schedule) =>
|
||||
new Date(schedule.departureAt).getTime() > latestOutboundArrival
|
||||
const inbound = allReturn.filter((s: any) =>
|
||||
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
|
||||
);
|
||||
|
||||
return { journeyType: 'ROUND_TRIP', outbound, inbound };
|
||||
@@ -60,7 +83,7 @@ export class SearchService {
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
@@ -81,126 +104,211 @@ export class SearchService {
|
||||
},
|
||||
});
|
||||
|
||||
const results = [];
|
||||
|
||||
const results: any[] = [];
|
||||
for (const schedule of schedules) {
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
||||
const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
|
||||
if (result) results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
|
||||
// ── 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;
|
||||
|
||||
const availabilityByClass: Record<string, number> = {};
|
||||
private async searchTransitOptions(
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
adultCount: number,
|
||||
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 totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
|
||||
// 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 } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (isBedCoach) {
|
||||
const bedPositions = ['upper', 'middle', 'lower'];
|
||||
for (const bedPosition of bedPositions) {
|
||||
let count = 0;
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.bedPosition !== bedPosition) continue;
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||
const results: any[] = [];
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
if (free) count++;
|
||||
}
|
||||
for (const leg1 of leg1Schedules) {
|
||||
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||
if (!originStop) continue;
|
||||
|
||||
if (count > 0) {
|
||||
const matchingClass = seatClassNames.find((className: string) => {
|
||||
const classNameLower = className.toLowerCase();
|
||||
return (
|
||||
(bedPosition === 'upper' && classNameLower.includes('upper')) ||
|
||||
(bedPosition === 'middle' && classNameLower.includes('middle')) ||
|
||||
(bedPosition === 'lower' && classNameLower.includes('lower'))
|
||||
);
|
||||
});
|
||||
if (matchingClass) {
|
||||
if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0;
|
||||
availabilityByClass[matchingClass] += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let availableSeatsInCoach = 0;
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
if (free) availableSeatsInCoach++;
|
||||
}
|
||||
|
||||
for (const seatClassName of seatClassNames) {
|
||||
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
|
||||
availabilityByClass[seatClassName] += availableSeatsInCoach;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
const faresByClass = await this.calculateFaresForSegment(
|
||||
schedule,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
nationality,
|
||||
// Every stop after origin on leg1 is a candidate transit station
|
||||
const candidateTransitStops = leg1.stopTimes.filter(
|
||||
(s: any) => s.sequence > originStop.sequence,
|
||||
);
|
||||
|
||||
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
|
||||
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
|
||||
|
||||
results.push({
|
||||
scheduleId: schedule.id,
|
||||
trainNumber: schedule.train.number,
|
||||
trainName: schedule.train.name,
|
||||
origin: {
|
||||
id: originStop.stationId,
|
||||
code: originStop.station.code,
|
||||
name: originStop.station.name,
|
||||
city: originStop.station.city,
|
||||
sequence: originStop.sequence,
|
||||
},
|
||||
destination: {
|
||||
id: destStop.stationId,
|
||||
code: destStop.station.code,
|
||||
name: destStop.station.name,
|
||||
city: destStop.station.city,
|
||||
sequence: destStop.sequence,
|
||||
},
|
||||
departureAt: legDepartureAt,
|
||||
arrivalAt: legArrivalAt,
|
||||
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,
|
||||
})),
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
faresByClass,
|
||||
coachTypes,
|
||||
});
|
||||
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 } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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),
|
||||
]);
|
||||
|
||||
if (!leg1Result || !leg2Result) continue;
|
||||
if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) continue;
|
||||
|
||||
const leg2DepartureAt = leg2TransitStop.plannedDepartureAt ?? leg2.departureAt;
|
||||
const connectionMinutes = Math.round(
|
||||
(new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000,
|
||||
);
|
||||
|
||||
const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
|
||||
|
||||
results.push({
|
||||
type: 'TRANSIT',
|
||||
transitStationId,
|
||||
transitStationName: transitStop.station.name,
|
||||
connectionMinutes,
|
||||
leg1: leg1Result,
|
||||
leg2: leg2Result,
|
||||
combinedMinFareMinor,
|
||||
// Convenience top-level fields so round-trip filter can read them uniformly
|
||||
departureAt: leg1Result.departureAt,
|
||||
arrivalAt: leg2Result.arrivalAt,
|
||||
totalDurationMinutes:
|
||||
leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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);
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
|
||||
|
||||
const availabilityByClass: Record<string, number> = {};
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
|
||||
|
||||
if (isBedCoach) {
|
||||
for (const bedPosition of ['upper', 'middle', 'lower']) {
|
||||
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 (count > 0) {
|
||||
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
|
||||
if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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++;
|
||||
}
|
||||
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 legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
return {
|
||||
type: 'DIRECT',
|
||||
scheduleId: schedule.id,
|
||||
trainNumber: schedule.train.number,
|
||||
trainName: schedule.train.name,
|
||||
origin: { id: originStop.stationId, code: originStop.station.code, name: originStop.station.name, city: originStop.station.city, sequence: originStop.sequence },
|
||||
destination: { id: destStop.stationId, code: destStop.station.code, name: destStop.station.name, city: destStop.station.city, sequence: destStop.sequence },
|
||||
departureAt: legDepartureAt,
|
||||
arrivalAt: legArrivalAt,
|
||||
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 })),
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
faresByClass,
|
||||
coachTypes,
|
||||
};
|
||||
}
|
||||
|
||||
async getFareQuote(dto: FareQuoteDto) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
|
||||
Reference in New Issue
Block a user