Commiting stop based booking

This commit is contained in:
Muluhabt
2026-07-23 20:11:11 +03:00
parent 8bba882710
commit 64e1130f7d
14 changed files with 354 additions and 147 deletions

View File

@@ -81,17 +81,23 @@ export class TasksService {
byRoute.get(stop.routeId)!.push(stop.stationId);
}
// Arrival basis: each stop's own estimated arrival time, not its departure. The first
// stop of a route has no arrival (nothing to arrive at), so it falls back to its
// departure — expressed below as COALESCE(plannedArrivalAt, plannedDepartureAt).
let reopenedCount = 0;
let checkinClosedCount = 0;
for (const [mins, byRoute] of byMins) {
const cutoffAt = new Date(now.getTime() + mins * 60 * 1000);
for (const [routeId, stationIds] of byRoute) {
// Revert first: if the cutoff was reduced, stops that were prematurely closed
// should reopen (departure is still beyond the new cutoff window).
// should reopen (arrival is still beyond the new cutoff window).
const reverted = await this.prisma.tripStopTime.updateMany({
where: {
status: 'CHECKIN_CLOSED',
plannedDepartureAt: { gt: cutoffAt },
OR: [
{ plannedArrivalAt: { gt: cutoffAt } },
{ AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { gt: cutoffAt } }] },
],
stationId: { in: stationIds },
schedule: { routeId },
},
@@ -103,7 +109,10 @@ export class TasksService {
const closed = await this.prisma.tripStopTime.updateMany({
where: {
status: 'OPEN',
plannedDepartureAt: { lte: cutoffAt },
OR: [
{ plannedArrivalAt: { lte: cutoffAt } },
{ AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { lte: cutoffAt } }] },
],
stationId: { in: stationIds },
schedule: { routeId },
},
@@ -169,8 +178,8 @@ export class TasksService {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
},
@@ -179,12 +188,17 @@ export class TasksService {
for (const booking of bookings) {
try {
const createdAt = booking.createdAt as Date;
// Use the booking's origin-segment departure and the route's own check-in window.
// Use the booking's origin-segment estimated arrival (falling back to its departure
// for the first stop) and that stop's own check-in window (falling back to the route
// default), same resolution as holdSeats/search.
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const originRouteStop = (booking.schedule as any).route?.stops?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
@@ -230,13 +244,28 @@ export class TasksService {
// ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(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 twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
// payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
// The departure pre-filter below is a query-scoping optimization only — the real
// deadline check happens per-row further down. It must be widened to the largest
// configured checkinMinutes across all routes/stops, or a booking on a route with a
// cutoff bigger than the CUTOFF_MINUTES default would never even be fetched here,
// silently never getting auto-cancelled.
const [maxRouteCutoff, maxStopCutoff] = await Promise.all([
this.prisma.route.aggregate({ _max: { checkinMinutesBefore: true } }),
this.prisma.routeStop.aggregate({ _max: { checkinMinutesBefore: true } }),
]);
const effectiveMaxCutoffMinutes = Math.max(
CUTOFF_MINUTES,
maxRouteCutoff._max.checkinMinutesBefore ?? 0,
maxStopCutoff._max.checkinMinutesBefore ?? 0,
);
const departureCutoff = new Date(now.getTime() + effectiveMaxCutoffMinutes * 60 * 1000);
// payment_deadline = MIN(createdAt + 2h, segment_arrival - checkinMinutes)
// Deadline is reached when either branch of the MIN is in the past:
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
// (b) departureAt ≤ now + 30min → departure within 30 min
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
// (b) departureAt ≤ now + effectiveMaxCutoff → within the widest possible cutoff window
const expiredBookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
@@ -250,8 +279,8 @@ export class TasksService {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
paymentIntent: { select: { method: true } },
@@ -264,14 +293,19 @@ export class TasksService {
for (const booking of expiredBookings) {
try {
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
// Use the booking's origin-segment departure for the deadline so that a B→C booking
// on an A→B→C→D schedule gets the correct payment window anchored to B, not A.
// Use the booking's origin-segment estimated arrival (falling back to its departure
// for the first stop) and that stop's own check-in window, so a B→C booking on an
// A→B→C→D schedule gets the correct payment window anchored to B, not A.
const createdAt = booking.createdAt as Date;
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const originRouteStop = (booking.schedule as any).route?.stops?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
if (now < paymentDeadline) continue;
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid)