Adding train delay minutes and also disabling no schedule days in the booking selection

This commit is contained in:
Muluhabt
2026-07-27 14:42:57 +03:00
parent d175514f85
commit d71f6dc1b3
19 changed files with 845 additions and 54 deletions

View File

@@ -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',

View File

@@ -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;

View File

@@ -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;