Coaches, seats, schedules, and pricing related updates

This commit is contained in:
Stephanos A
2026-06-07 17:41:31 +03:00
parent af14535e08
commit bb10e7fdf2
55 changed files with 5119 additions and 2930 deletions

View File

@@ -22,8 +22,6 @@ export class SearchService {
const nextDay = new Date(date.getTime() + 86_400_000);
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
// Find all schedules that have BOTH origin and destination as stops
// (not just terminal-to-terminal) and depart on the requested date
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
@@ -36,7 +34,7 @@ export class SearchService {
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, seatClass: true } } },
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
});
@@ -44,36 +42,46 @@ export class SearchService {
const results = [];
for (const schedule of schedules) {
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
// Both stops must exist and origin must come before destination
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
// Compute per-seat availability for the requested segment range
// A seat is available if no active booking/hold overlaps [originSeq, destSeq)
const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) {
const className = assignment.coach.seatClass.name;
if (!availabilityByClass[className]) availabilityByClass[className] = 0;
// Get seat class names from coach type
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
}
// Count available seats (skip blocked and removed seats)
for (const seat of assignment.coach.seats) {
// Skip blocked seats
if (seat.status === 'BLOCKED') continue;
// Use segment-aware check — a seat booked A→B is still free for B→D
// Skip removed seats (empty seatNumber)
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availabilityByClass[className]++;
if (free) {
// Group by seat class - use the first seat class for now
// In a full implementation, seats would have a seatClassId
const className = seatClassNames[0] || 'Standard';
availabilityByClass[className]++;
}
}
}
// Departure/arrival times for the requested leg (not the full schedule)
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
// Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals
const faresByClass = await this.calculateFaresForSegment(
schedule,
dto.originStationId,
@@ -106,14 +114,14 @@ export class SearchService {
),
status: schedule.status,
stops: schedule.stopTimes
.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,
})),
.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,
@@ -134,51 +142,19 @@ export class SearchService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
throw new NotFoundException('Origin or destination not found on this schedule');
}
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
// Compute route codes for fare lookup
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const now = new Date();
const nationality = dto.nationality;
// Query fare rules with specificity ordering:
// 1. schedule+segment+nationality
// 2. schedule+segment
// 3. schedule+full-route+nationality
// 4. schedule+full-route
// 5. schedule+global
// 6. segment+nationality
// 7. segment
// 8. full-route+nationality
// 9. full-route
// 10. global
const fareRule = await this.prisma.fareRule.findFirst({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
orderBy: [
// Prioritize schedule-specific rules
{ tripId: { sort: 'desc', nulls: 'last' } },
// Then prioritize nationality match
{ nationality: { sort: 'desc', nulls: 'last' } },
// Most recent validFrom
{ validFrom: 'desc' },
],
});
// Manual specificity filtering to find best match
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId: seatClass?.id,
@@ -243,38 +219,40 @@ export class SearchService {
};
}
/**
* Calculate fares for a specific segment of a schedule
*/
private async calculateFaresForSegment(
schedule: any,
originStationId: string,
destinationStationId: string,
nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
// Get seat classes that are actually assigned to this schedule via coaches
const assignedSeatClassIds: string[] = Array.from(
// Get unique seat classes from all coaches assigned to this schedule via their coach types
const seatClassIds: string[] = Array.from(
new Set(
schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string)
schedule.coachAssignments
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
.map((sc: any) => sc.id)
.filter((id: any) => id)
)
);
// Get only the seat classes that are assigned to this schedule
const seatClasses = await this.prisma.seatClass.findMany({
where: {
isActive: true,
id: { in: assignedSeatClassIds }
},
orderBy: { basePrice: 'asc' },
});
// If no coaches assigned, return empty array
if (seatClasses.length === 0) {
if (seatClassIds.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
}
// If schedule has a route, use route-based calculation
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 (schedule.routeId) {
const results = await Promise.all(
seatClasses.map(async (sc) => {
@@ -303,7 +281,6 @@ export class SearchService {
}
}
// Fallback: Try to get fares from FareRule table
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
@@ -314,26 +291,25 @@ export class SearchService {
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
seatClassId: { in: assignedSeatClassIds },
seatClassId: { in: seatClassIds },
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: { seatClass: true },
});
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]));
return fareRules.map(rule => ({
seatClassName: rule.seatClass.name,
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
baseFareMinor: rule.baseFareMinor,
}));
}
}
// Last resort: Return default fares only for assigned seat classes
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
return seatClasses.map(sc => ({
seatClassName: sc.name,
@@ -359,60 +335,6 @@ export class SearchService {
return fares[seatClassName] ?? 45000;
}
/**
* Fallback method to get fares from FareRule table when fare engine fails
*/
private async getFallbackFares(
scheduleId: string,
originCode: string,
destCode: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
const segmentRoute = `${originCode}-${destCode}`;
const now = new Date();
// Try to find fare rules for this segment
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: { seatClass: true },
});
if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
return fareRules.map(rule => ({
seatClassName: rule.seatClass.name,
baseFareMinor: rule.baseFareMinor,
}));
}
// If no segment-specific rules, return default fares
console.log(`No fare rules found for ${segmentRoute}, using defaults`);
return [
{ seatClassName: 'Economy Regular', baseFareMinor: 35000 },
{ seatClassName: 'Economy Bed', baseFareMinor: 49000 },
{ seatClassName: 'VIP Bed', baseFareMinor: 63000 },
];
}
/**
* Select the best matching fare rule based on specificity:
* 1. schedule+segment+nationality
* 2. schedule+segment
* 3. schedule+full-route+nationality
* 4. schedule+full-route
* 5. schedule+global
* 6. segment+nationality
* 7. segment
* 8. full-route+nationality
* 9. full-route
* 10. global
*/
private selectBestFareRule(
candidates: any[],
scheduleId: string,
@@ -421,19 +343,16 @@ export class SearchService {
nationality?: string,
): any | null {
const priorities = [
// Schedule-specific rules
{ tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null },
// Route-specific rules (no schedule)
{ tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null },
// Global rules
{ tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null },
];