mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
Adding train delay minutes and also disabling no schedule days in the booking selection
This commit is contained in:
@@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto';
|
||||
|
||||
@ApiTags('Search')
|
||||
@Controller('search')
|
||||
@@ -67,6 +67,21 @@ Nationality-Based:
|
||||
return this.service.getFareQuote(dto);
|
||||
}
|
||||
|
||||
@Get('available-dates')
|
||||
@ApiOperation({
|
||||
summary: 'Which dates in a range have a bookable schedule for an origin/destination pair',
|
||||
description: `Used to disable schedule-less dates on the search date picker before the user submits a search.
|
||||
|
||||
For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one
|
||||
schedule exists for the origin→destination pair whose status/package/coach state is bookable and
|
||||
whose check-in cutoff has not yet passed. This does not check seat-level availability — a date
|
||||
can be marked available and still turn out fully booked when actually searched.`,
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' })
|
||||
getAvailableDates(@Query() dto: AvailableDatesQueryDto) {
|
||||
return this.service.getAvailableDates(dto);
|
||||
}
|
||||
|
||||
@Get('fare-breakdown')
|
||||
@ApiOperation({
|
||||
summary: 'Per-passenger fare breakdown for booking review page',
|
||||
|
||||
@@ -29,6 +29,20 @@ export class SearchTripsDto {
|
||||
@IsOptional() @IsDateString() returnDate?: string;
|
||||
}
|
||||
|
||||
export class AvailableDatesQueryDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' })
|
||||
@IsDateString() from: string;
|
||||
|
||||
@ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' })
|
||||
@IsDateString() to: string;
|
||||
}
|
||||
|
||||
export class FareQuoteDto {
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FareQuoteDto,
|
||||
FareBreakdownRequestDto,
|
||||
FareBreakdownPassengerDto,
|
||||
AvailableDatesQueryDto,
|
||||
} from "./search.dto";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
@@ -383,16 +384,9 @@ export class SearchService {
|
||||
|
||||
// 1. Does any active route connect these two stations, in this direction, at all —
|
||||
// ignoring date entirely?
|
||||
const candidateRoutes = await this.prisma.route.findMany({
|
||||
where: { active: true, stops: { some: { stationId: originStationId } } },
|
||||
select: { stops: { select: { stationId: true, sequence: true } } },
|
||||
});
|
||||
const routeExists = candidateRoutes.some((r) => {
|
||||
const o = r.stops.find((s) => s.stationId === originStationId);
|
||||
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
||||
return !!o && !!d && o.sequence < d.sequence;
|
||||
});
|
||||
if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
||||
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||
return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
||||
}
|
||||
|
||||
// 2. A route exists — is there any schedule at all on the requested date for this pair
|
||||
// (regardless of status/package/coach/cutoff — those are checked next)?
|
||||
@@ -425,12 +419,7 @@ export class SearchService {
|
||||
|
||||
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
||||
// (right status, not package-only, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter(
|
||||
(s) =>
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.coachAssignments.length > 0,
|
||||
);
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
|
||||
if (bookable.length === 0) {
|
||||
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||
@@ -452,6 +441,110 @@ export class SearchService {
|
||||
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any active route connects originStationId → destinationStationId in this
|
||||
* direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and
|
||||
* getAvailableDates.
|
||||
*/
|
||||
private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise<boolean> {
|
||||
const candidateRoutes = await this.prisma.route.findMany({
|
||||
where: { active: true, stops: { some: { stationId: originStationId } } },
|
||||
select: { stops: { select: { stationId: true, sequence: true } } },
|
||||
});
|
||||
return candidateRoutes.some((r) => {
|
||||
const o = r.stops.find((s) => s.stationId === originStationId);
|
||||
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
||||
return !!o && !!d && o.sequence < d.sequence;
|
||||
});
|
||||
}
|
||||
|
||||
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
|
||||
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
|
||||
return (
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.coachAssignments.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90;
|
||||
private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||
private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */
|
||||
private toAddisDateStr(d: Date): string {
|
||||
return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* For each date in the (server-clamped) range, whether at least one bookable schedule exists
|
||||
* for originStationId → destinationStationId — used to disable schedule-less dates on the
|
||||
* search date picker before the user submits a search. Reuses the same route-existence and
|
||||
* bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used
|
||||
* throughout this service, but does not compute seat-level availability (see buildScheduleResult)
|
||||
* — a date can be marked available and still turn out fully booked when actually searched.
|
||||
*/
|
||||
async getAvailableDates(dto: AvailableDatesQueryDto) {
|
||||
const { originStationId, destinationStationId } = dto;
|
||||
|
||||
const todayStr = this.toAddisDateStr(new Date());
|
||||
const from = dto.from > todayStr ? dto.from : todayStr;
|
||||
const fromDate = new Date(`${from}T00:00:00+03:00`);
|
||||
|
||||
const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS);
|
||||
const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`);
|
||||
const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate;
|
||||
const to = this.toAddisDateStr(toDate);
|
||||
|
||||
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||
return {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
from,
|
||||
to,
|
||||
routeExists: false,
|
||||
dates: [] as { date: string; available: boolean }[],
|
||||
};
|
||||
}
|
||||
|
||||
const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS);
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
departureAt: { gte: fromDate, lt: rangeEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
select: {
|
||||
departureAt: true,
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
},
|
||||
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
||||
coachAssignments: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const availableDays = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const originStop = s.stopTimes.find((st) => st.stationId === originStationId);
|
||||
const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
||||
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue;
|
||||
if (!this.isBookableSchedule(s)) continue;
|
||||
if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue;
|
||||
availableDays.add(this.toAddisDateStr(s.departureAt));
|
||||
}
|
||||
|
||||
const dates: { date: string; available: boolean }[] = [];
|
||||
for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) {
|
||||
const dateStr = this.toAddisDateStr(cursor);
|
||||
dates.push({ date: dateStr, available: availableDays.has(dateStr) });
|
||||
}
|
||||
|
||||
return { originStationId, destinationStationId, from, to, routeExists: true, dates };
|
||||
}
|
||||
|
||||
// ── Transit search ─────────────────────────────────────────────────────────
|
||||
private readonly MIN_CONNECTION_MINUTES = 30;
|
||||
private readonly MAX_CONNECTION_MINUTES = 360;
|
||||
|
||||
Reference in New Issue
Block a user