mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
Added stops departure and arrival datetime
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user