Added stops departure and arrival datetime

This commit is contained in:
Roba Boru
2026-07-18 21:51:54 +03:00
parent d7a66cfac5
commit c1858e76d4
16 changed files with 1026 additions and 94 deletions

View File

@@ -128,4 +128,13 @@ export class BookPackageDto {
/** Number of child passengers (<5 years). Derived from passengers array if omitted. */
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
/**
* SeatHold UUID returned by POST /seats/hold when the user selected seats on the
* seatmap before proceeding to book. When provided, the hold's expiry is extended
* to the payment deadline so the specific seat stays reserved on the seatmap for
* the full payment window, matching the behaviour of normal bookings.
*/
@ApiPropertyOptional({ description: 'SeatHold ID from seatmap selection' })
@IsOptional() @IsUUID() holdId?: string;
}

View File

@@ -6,6 +6,7 @@ import { Currency } from '@prisma/client';
import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service';
import { AuditService } from '../../common/audit.service';
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
/** Package-specific fare rules */
const PKG_MAX_ADULTS = 5;
@@ -382,7 +383,10 @@ export class PackagesService {
async book(dto: BookPackageDto, passengerId?: string) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id: dto.packageId },
include: { priceTiers: true },
include: {
priceTiers: true,
outboundSchedule: { select: { departureAt: true, route: { select: { checkinMinutesBefore: true } } } },
},
});
if (!pkg) throw new NotFoundException('Package not found');
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
@@ -477,6 +481,27 @@ export class PackagesService {
]);
});
// Extend the seatmap SeatHold (if one was passed) to the payment deadline so the
// specific seat remains visually reserved on the seatmap during the full payment
// window — matching the behaviour of normal bookings (which call confirmSeats).
if (dto.holdId) {
const dep = (pkg as any).outboundSchedule?.departureAt as Date | undefined;
if (dep) {
const checkinMinutes = (pkg as any).outboundSchedule?.route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(booking.createdAt as Date, dep, checkinMinutes);
const hold = await this.prisma.seatHold.findUnique({
where: { id: dto.holdId },
select: { expiresAt: true },
});
if (hold && paymentDeadline > hold.expiresAt) {
await this.prisma.seatHold.update({
where: { id: dto.holdId },
data: { expiresAt: paymentDeadline },
});
}
}
}
return {
...booking,
fareBreakdown: {

View File

@@ -7,6 +7,8 @@ export class RouteStopInputDto {
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for first stop.' }) @IsOptional() @IsDateString() plannedArrivalTime?: string;
@ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for last stop.' }) @IsOptional() @IsDateString() plannedDepartureTime?: string;
}
export class CreateRouteDto {
@@ -37,6 +39,8 @@ export class AddRouteStopDto {
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedArrivalTime?: string;
@ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedDepartureTime?: string;
}
export class UpdateRouteDto {

View File

@@ -37,6 +37,8 @@ export class RoutesService {
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
plannedArrivalTime: s.plannedArrivalTime ? new Date(s.plannedArrivalTime) : null,
plannedDepartureTime: s.plannedDepartureTime ? new Date(s.plannedDepartureTime) : null,
})),
},
},
@@ -106,6 +108,8 @@ export class RoutesService {
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
plannedArrivalTime: s.plannedArrivalTime ?? null,
plannedDepartureTime: s.plannedDepartureTime ?? null,
})),
});
}
@@ -225,6 +229,8 @@ export class RoutesService {
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
plannedArrivalTime: dto.plannedArrivalTime ?? null,
plannedDepartureTime: dto.plannedDepartureTime ?? null,
},
});
}

View File

@@ -60,6 +60,9 @@ export class UpdateScheduleDto {
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Planned times per stop — when provided, replaces all existing stop times for the schedule' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
}
export class UpdateStopTimeDto {

View File

@@ -133,26 +133,62 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
const hasRouteTimes = route.stops.some(
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
);
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
if (hasRouteTimes) {
// Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date.
const EAT_MS = 3 * 60 * 60 * 1000;
const depEATMs = dep.getTime() + EAT_MS;
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
const templateToScheduleUTC = (templateDt: Date): Date => {
// Pull the time-of-day in EAT from the template DateTime
const templateEATMs = templateDt.getTime() + EAT_MS;
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
// Overnight: if the stop time lands before departure, move to next day
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
return candidate;
};
});
plannedTimes = route.stops.map((stop, index) => {
const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null;
const depDt: Date | null = (stop as any).plannedDepartureTime ?? null;
return {
sequence: stop.sequence,
plannedArrivalAt: index > 0 && arrDt != null
? templateToScheduleUTC(arrDt).toISOString()
: undefined,
plannedDepartureAt: index < route.stops.length - 1 && depDt != null
? templateToScheduleUTC(depDt).toISOString()
: undefined,
};
});
} else {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
}
}
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
@@ -304,26 +340,59 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
const hasRouteTimes = route.stops.some(
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
);
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
if (hasRouteTimes) {
const EAT_MS = 3 * 60 * 60 * 1000;
const depEATMs = dep.getTime() + EAT_MS;
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
const templateToScheduleUTC = (templateDt: Date): Date => {
const templateEATMs = templateDt.getTime() + EAT_MS;
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
return candidate;
};
});
plannedTimes = route.stops.map((stop, index) => {
const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null;
const depDt: Date | null = (stop as any).plannedDepartureTime ?? null;
return {
sequence: stop.sequence,
plannedArrivalAt: index > 0 && arrDt != null
? templateToScheduleUTC(arrDt).toISOString()
: undefined,
plannedDepartureAt: index < route.stops.length - 1 && depDt != null
? templateToScheduleUTC(depDt).toISOString()
: undefined,
};
});
} else {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
}
}
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
@@ -678,6 +747,12 @@ export class SchedulesService {
}
}
if (dto.plannedTimes && dto.plannedTimes.length > 0 && schedule.routeId) {
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
const plannedTimesMap = Object.fromEntries(dto.plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
}
return this.getSchedule(id);
}

View File

@@ -146,6 +146,7 @@ export class TasksService {
await Promise.all([
this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now),
this.cancelExpiredPendingPackageBookings(now),
]);
}
@@ -333,6 +334,78 @@ export class TasksService {
}
}
// ── Cancel PackageBookings whose payment deadline has passed ──────────────
private async cancelExpiredPendingPackageBookings(now: Date) {
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
const expiredBookings = await this.prisma.packageBooking.findMany({
where: {
status: 'PENDING_PAYMENT',
OR: [
{ createdAt: { lte: twoHoursAgo } },
{ package: { outboundSchedule: { departureAt: { lte: departureCutoff } } } },
],
},
include: {
package: {
include: {
outboundSchedule: {
include: { route: { select: { checkinMinutesBefore: true } } },
},
},
},
},
});
let cancelledCount = 0;
for (const booking of expiredBookings) {
try {
const createdAt = booking.createdAt as Date;
const dep = (booking.package as any).outboundSchedule.departureAt as Date;
const checkinMinutes = (booking.package as any).outboundSchedule.route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
if (now < paymentDeadline) continue;
// Revert the tier's seat counters that were incremented when the booking was created.
const seatsReserved = booking.adultCount + Math.max(0, booking.childCount - booking.adultCount);
await this.prisma.packagePriceTier.update({
where: { id: booking.priceTierId },
data: {
bookedSeats: { decrement: seatsReserved },
availableSeats: { increment: seatsReserved },
},
});
await this.prisma.packageBooking.update({
where: { id: booking.id },
data: { status: 'CANCELLED' },
});
const message =
`EDR: Your package booking ${booking.bookingRef} ` +
`(departs ${fmtTime(dep)}) has been cancelled ` +
`because payment was not completed by ${fmtTime(paymentDeadline)}.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
this.logger.log(`Auto-cancelled package booking: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
cancelledCount++;
} catch (err) {
this.logger.error(
`Auto-cancel failed for package booking ${(booking as any).bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (cancelledCount > 0) {
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending package booking(s)`);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ─────────────────────────────────────────────────────────────────────────