mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -320,8 +320,8 @@ export class BookingsService {
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalMinor: b.totalMinor,
|
||||
currency: b.currency || null,
|
||||
totalMinor: b.displayTotalMinor ?? b.totalMinor,
|
||||
currency: b.displayCurrency ?? b.currency ?? null,
|
||||
displayCurrency: b.displayCurrency ?? null,
|
||||
displayTotalMinor: b.displayTotalMinor ?? null,
|
||||
adultCount: b.adultCount,
|
||||
@@ -578,7 +578,7 @@ export class BookingsService {
|
||||
|
||||
const mappedPkg = pkgItems.map((b: any) => ({
|
||||
id: b.id, bookingRef: b.bookingRef, status: b.status,
|
||||
totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency,
|
||||
totalMinor: b.displayTotalMinor ?? b.totalMinor, currency: b.displayCurrency || b.currency,
|
||||
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
|
||||
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
|
||||
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
|
||||
@@ -732,8 +732,8 @@ export class BookingsService {
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalMinor: b.totalMinor,
|
||||
currency: b.currency || b.displayCurrency,
|
||||
totalMinor: b.displayTotalMinor ?? b.totalMinor,
|
||||
currency: b.displayCurrency || b.currency,
|
||||
displayCurrency: b.displayCurrency,
|
||||
displayTotalMinor: b.displayTotalMinor,
|
||||
contactEmail: b.contactEmail,
|
||||
@@ -866,6 +866,15 @@ export class BookingsService {
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
// For package bookings where per-seat fares weren't supplied, back-derive the
|
||||
// per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects the
|
||||
// actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
|
||||
if (dto.packageId && seatedPassengers.length > 0) {
|
||||
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
|
||||
});
|
||||
}
|
||||
} else if (allFaresProvided) {
|
||||
// seatFareMinor is in display currency — sum is already the display total
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
@@ -1050,6 +1059,19 @@ export class BookingsService {
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
// For package bookings where per-seat fares weren't supplied, back-derive the
|
||||
// per-leg per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects
|
||||
// the actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
|
||||
if (dto.packageId) {
|
||||
const seatedCount = passengersData.filter(p => p.outboundSeatId).length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
|
||||
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (allRTFaresProvided && !dto.packageId) {
|
||||
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
@@ -1822,8 +1844,8 @@ export class BookingsService {
|
||||
id: pkgBooking.id,
|
||||
bookingRef: pkgBooking.bookingRef,
|
||||
status: pkgBooking.status,
|
||||
totalMinor: pkgBooking.totalMinor,
|
||||
currency: pkgBooking.currency || pkgBooking.displayCurrency,
|
||||
totalMinor: pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor,
|
||||
currency: pkgBooking.displayCurrency || pkgBooking.currency,
|
||||
adultCount: pkgBooking.passengerCount,
|
||||
childCount: 0,
|
||||
displayCurrency: pkgBooking.displayCurrency,
|
||||
@@ -1852,7 +1874,7 @@ export class BookingsService {
|
||||
fullName: p.passengerName,
|
||||
category: 'ADULT',
|
||||
leg: 1,
|
||||
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
|
||||
fareMinor: Math.round((pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor) / pkgBooking.passengerCount),
|
||||
verifaydaVerified: false,
|
||||
seat: null,
|
||||
})),
|
||||
|
||||
@@ -231,6 +231,14 @@ export class GuestBookingService {
|
||||
let resolvedTotalMinor: number;
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
// For package bookings, back-derive per-seat fareMinor from reviewedTotalMinor
|
||||
// so BookingSeat records store the actual berth price, not the tier minimum.
|
||||
if (isPackageOneway && seatedPassengers.length > 0) {
|
||||
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
|
||||
});
|
||||
}
|
||||
} else if (allFaresProvided) {
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
} else {
|
||||
@@ -515,6 +523,17 @@ export class GuestBookingService {
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
// For package bookings, back-derive per-leg per-seat fareMinor from reviewedTotalMinor.
|
||||
if (isPackageRoundTrip) {
|
||||
const seatedCount = passengersData.filter(p => p.seatId).length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
|
||||
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Dashboard')
|
||||
@Controller('dashboard')
|
||||
@@ -10,7 +11,7 @@ export class DashboardController {
|
||||
constructor(private service: DashboardService) {}
|
||||
|
||||
@Get('backoffice-stats')
|
||||
@PassengerAdmin()
|
||||
@PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
|
||||
getBackofficeStats() { return this.service.getBackofficeStats(); }
|
||||
|
||||
@@ -11,12 +11,13 @@ export class DashboardService {
|
||||
) {}
|
||||
|
||||
async getBackofficeStats() {
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
|
||||
await Promise.all([
|
||||
this.prisma.booking.count(),
|
||||
this.prisma.booking.count({ where: { packageId: { not: null } } }),
|
||||
this.prisma.ticket.count(),
|
||||
this.prisma.passenger.count(),
|
||||
this.prisma.seat.count({ where: { status: 'BLOCKED' } }),
|
||||
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
||||
SELECT
|
||||
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
|
||||
@@ -56,6 +57,7 @@ export class DashboardService {
|
||||
totalPackageTickets,
|
||||
totalNormalTickets: totalTickets - totalPackageTickets,
|
||||
totalPassengers,
|
||||
blockedSeatsCount,
|
||||
revenueByCurrency: toMap(revenueRows),
|
||||
packageRevenueByCurrency: toMap(packageRevenueRows),
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export class LiveService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
const live = schedule.liveStatus;
|
||||
const nextStop = schedule.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
|
||||
const nextStop = schedule.stopTimes.find((s) => s.status === 'OPEN' || s.status === 'CHECKIN_CLOSED');
|
||||
return {
|
||||
scheduleId: schedule.id, trainName: schedule.train.name,
|
||||
fromStationName: schedule.originStation.name, toStationName: schedule.destinationStation.name,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { PaymentClientService } from './payment-client.service';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
|
||||
|
||||
// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up).
|
||||
// This service keeps only singleton deps so its @Cron method registers correctly,
|
||||
// then resolves PaymentsService per-tick via ModuleRef (same pattern as
|
||||
// PaymentEventsConsumer).
|
||||
@Injectable()
|
||||
export class PaymentSyncService {
|
||||
private readonly logger = new Logger(PaymentSyncService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// whose payment intent has moved to SUCCEEDED on the gateway but whose
|
||||
// confirmation event was never delivered (missed RabbitMQ message, network
|
||||
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
|
||||
// it for an already-confirmed booking is safe.
|
||||
//
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
|
||||
},
|
||||
include: { paymentIntent: true },
|
||||
take: BATCH_SIZE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
|
||||
// resolve() (not get()) because PaymentsService is scoped — same pattern
|
||||
// as PaymentEventsConsumer.
|
||||
const paymentsService = await this.moduleRef.resolve(
|
||||
PaymentsService,
|
||||
undefined,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.paymentIntent) continue;
|
||||
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
|
||||
if (!snapshot) continue;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const result = await paymentsService.finalizePaymentSuccess({
|
||||
intentId: booking.paymentIntent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
if (!result.alreadyFinalized) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
} else if (
|
||||
snapshot.status === ProviderPaymentStatus.FAILED ||
|
||||
snapshot.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` +
|
||||
`booking will be auto-cancelled at payment deadline`,
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
errored++;
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { SupplementaryChargesService } from "./supplementary-charges.service";
|
||||
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
import { PaymentSyncService } from "./payment-sync.service";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { SeatsModule } from "../seats/seats.module";
|
||||
import { TicketsModule } from "../tickets/tickets.module";
|
||||
@@ -70,6 +71,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
SupplementaryChargesService,
|
||||
PaymentClientService,
|
||||
PaymentEventsConsumer,
|
||||
PaymentSyncService,
|
||||
ServiceAuthGuard,
|
||||
],
|
||||
exports: [PaymentClientService, PaymentsService],
|
||||
|
||||
@@ -129,7 +129,7 @@ export class SupplementaryChargesService {
|
||||
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
|
||||
referenceId: charge.id,
|
||||
orderRef: `SC-${charge.id.substring(0, 8)}`,
|
||||
amountMinor: charge.amountMinor,
|
||||
amountMinor: charge.amountMinor / 100,
|
||||
currency: charge.currency,
|
||||
provider: paymentMethod,
|
||||
platform,
|
||||
|
||||
@@ -18,6 +18,12 @@ export class ReportsController {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get('passengers')
|
||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getOccupancyBySchedule(scheduleId);
|
||||
}
|
||||
|
||||
@Get(':reportId')
|
||||
@ApiOperation({ summary: 'Get report by ID' })
|
||||
getReport(@Param('reportId') reportId: string) {
|
||||
|
||||
@@ -191,6 +191,122 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getOccupancyBySchedule(scheduleId: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
train: true,
|
||||
coachAssignments: {
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
coachType: true,
|
||||
seats: { select: { id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
include: {
|
||||
seats: {
|
||||
include: {
|
||||
seat: { include: { coach: { include: { coachType: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0);
|
||||
const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats);
|
||||
const totalPassengers = allBookingSeats.length;
|
||||
const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
|
||||
// Per-coach breakdown
|
||||
const coachMap = new Map<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const c = assignment.coach;
|
||||
coachMap.set(c.id, {
|
||||
coachNumber: c.number,
|
||||
coachType: (c as any).coachType?.name ?? 'Unknown',
|
||||
totalSeats: c.seats.length,
|
||||
booked: 0,
|
||||
});
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const coachId = bs.seat?.coachId;
|
||||
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
|
||||
}
|
||||
const byCoach = [...coachMap.values()].map(c => ({
|
||||
...c,
|
||||
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
// Per-origin station breakdown (using booking's originStationId)
|
||||
const originMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.originStationId ?? schedule.originStationId;
|
||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
||||
?? (schedule as any).originStation?.name
|
||||
?? stationId;
|
||||
if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 });
|
||||
originMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
|
||||
// Per-destination station breakdown
|
||||
const destMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.destinationStationId ?? schedule.destinationStationId;
|
||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
||||
?? (schedule as any).destinationStation?.name
|
||||
?? stationId;
|
||||
if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 });
|
||||
destMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
|
||||
// Per-class breakdown
|
||||
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.booked++;
|
||||
}
|
||||
const byClass = [...classMap.values()].map(c => ({
|
||||
...c,
|
||||
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
schedule: {
|
||||
id: schedule.id,
|
||||
trainName: (schedule as any).train?.name ?? (schedule as any).train?.number,
|
||||
origin: (schedule as any).originStation?.name,
|
||||
destination: (schedule as any).destinationStation?.name,
|
||||
departureAt: schedule.departureAt,
|
||||
arrivalAt: schedule.arrivalAt,
|
||||
},
|
||||
summary: { totalSeats, totalPassengers, occupancyRate },
|
||||
byCoach,
|
||||
byClass,
|
||||
byOrigin,
|
||||
byDestination,
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export class RouteStopInputDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
|
||||
@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;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@@ -35,6 +36,7 @@ export class AddRouteStopDto {
|
||||
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
|
||||
@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;
|
||||
}
|
||||
|
||||
export class UpdateRouteDto {
|
||||
@@ -42,6 +44,7 @@ export class UpdateRouteDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export class RoutesService {
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -92,6 +93,7 @@ export class RoutesService {
|
||||
description: dto.description,
|
||||
active: dto.active,
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
|
||||
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,6 +105,7 @@ export class RoutesService {
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -221,6 +224,7 @@ export class RoutesService {
|
||||
stationId: dto.stationId,
|
||||
sequence: dto.sequence,
|
||||
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
||||
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ export enum TripStatus {
|
||||
}
|
||||
|
||||
export enum StopStatus {
|
||||
OPEN = 'OPEN',
|
||||
CHECKIN_CLOSED = 'CHECKIN_CLOSED',
|
||||
BOARDED = 'BOARDED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
APPROACHING = 'APPROACHING',
|
||||
CURRENT = 'CURRENT',
|
||||
UPCOMING = 'UPCOMING',
|
||||
}
|
||||
|
||||
export enum PassengerCategory {
|
||||
@@ -54,18 +54,23 @@ export class CreateScheduleDto {
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class CoachAssignmentDto {
|
||||
@ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string;
|
||||
@ApiProperty({ example: 1 }) @IsInt() @Min(1) positionNumber: number;
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
|
||||
@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({ type: [CoachAssignmentDto], description: 'List of coaches to assign' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CoachAssignmentDto) coaches?: CoachAssignmentDto[];
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateStopTimeDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
||||
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
|
||||
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.OPEN }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
|
||||
}
|
||||
|
||||
export class CreateFareRuleDto {
|
||||
|
||||
@@ -4,10 +4,9 @@ import { SearchService } from './search.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
|
||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
|
||||
@@ -20,6 +19,7 @@ type ScheduleWithIncludes = {
|
||||
train: any;
|
||||
originStation: any;
|
||||
destinationStation: any;
|
||||
route: { checkinMinutesBefore: number; stops: Array<{ stationId: string; checkinMinutesBefore: number | null }> } | null;
|
||||
stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>;
|
||||
coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>;
|
||||
};
|
||||
@@ -28,6 +28,7 @@ const SCHEDULE_INCLUDE = {
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||
@@ -41,13 +42,8 @@ export class SearchService {
|
||||
private currencyService: CurrencyService,
|
||||
private fareEngine: FareEngineService,
|
||||
private segmentsService: SegmentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
private async getCutoffHours(): Promise<number> {
|
||||
return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE);
|
||||
}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
const [direct, transit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
@@ -218,10 +214,11 @@ export class SearchService {
|
||||
const now = new Date();
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
const cutoffHours = await this.getCutoffHours();
|
||||
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
|
||||
// Use now as the lower bound for today so we don't fetch schedules that have
|
||||
// already fully departed. The per-segment cutoff check in buildScheduleResult
|
||||
// handles the exact check using each stop's own plannedDepartureAt.
|
||||
const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d;
|
||||
const earliest = isToday ? cutoffThreshold : date;
|
||||
const earliest = isToday ? now : date;
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
@@ -284,12 +281,9 @@ export class SearchService {
|
||||
}),
|
||||
]);
|
||||
|
||||
const cutoffHours = await this.getCutoffHours();
|
||||
const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) {
|
||||
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
|
||||
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
|
||||
if (!originStop) continue;
|
||||
|
||||
@@ -376,6 +370,16 @@ export class SearchService {
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
|
||||
|
||||
// Segment-level cutoff: use the origin stop's planned departure, not the
|
||||
// schedule's overall departureAt (which is station A's time). This lets
|
||||
// B→D remain bookable even after A→D closes.
|
||||
// Cutoff resolution: stop-level override → route default → 30 min fallback.
|
||||
const now = new Date();
|
||||
const segmentDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const routeStop = schedule.route?.stops?.find(s => s.stationId === originStationId);
|
||||
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
if (segmentDepartureAt.getTime() - now.getTime() <= checkinMinutes * 60 * 1000) return null;
|
||||
|
||||
// Collect all valid seat IDs upfront for a single batch availability check
|
||||
const allValidSeatIds = schedule.coachAssignments.flatMap(a =>
|
||||
a.coach.seats
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class GetDuplicateSeatsQuery {
|
||||
@ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' })
|
||||
@IsDateString()
|
||||
date: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter to a specific schedule ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
scheduleId?: string;
|
||||
}
|
||||
|
||||
export class ResolveDuplicatesDto {
|
||||
@ApiProperty({
|
||||
description: 'BookingSeat IDs of the duplicate bookings to reassign',
|
||||
type: [String],
|
||||
example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
bookingSeatIds: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)',
|
||||
type: [String],
|
||||
example: ['uuid-coach-1', 'uuid-coach-2'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
coachIds: string[];
|
||||
}
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiBody,
|
||||
} from "@nestjs/swagger";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
|
||||
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
@@ -306,4 +308,90 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
) {
|
||||
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
|
||||
}
|
||||
|
||||
// ── Duplicate seat management (backoffice) ────────────────────────────────
|
||||
|
||||
@Get("duplicates")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: "List duplicate seat assignments by schedule date",
|
||||
description:
|
||||
"Returns all schedules on the given date that have bookings sharing " +
|
||||
"the same seat, grouped by coach. Each coach entry includes the duplicate " +
|
||||
"groups (with full booking info) and the list of currently available seats " +
|
||||
"that can be used for reassignment.",
|
||||
})
|
||||
@ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" })
|
||||
@ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Duplicate seat report grouped by schedule → coach",
|
||||
schema: {
|
||||
example: {
|
||||
date: "2026-07-17",
|
||||
totalDuplicates: 1,
|
||||
schedules: [{
|
||||
scheduleId: "uuid",
|
||||
departureAt: "2026-07-17T06:00:00.000Z",
|
||||
origin: "Addis Ababa",
|
||||
destination: "Dire Dawa",
|
||||
coaches: [{
|
||||
coachId: "uuid",
|
||||
coachNumber: "C1",
|
||||
coachTypeName: "SBC",
|
||||
duplicates: [{
|
||||
seatId: "uuid",
|
||||
seatNumber: "12A",
|
||||
leg: 1,
|
||||
bookings: [
|
||||
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" },
|
||||
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" },
|
||||
],
|
||||
}],
|
||||
availableSeats: [
|
||||
{ seatId: "uuid", seatNumber: "14B" },
|
||||
{ seatId: "uuid", seatNumber: "15A" },
|
||||
],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) {
|
||||
return this.service.getDuplicateSeats(query.date, query.scheduleId);
|
||||
}
|
||||
|
||||
@Post("duplicates/resolve")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: "Auto-assign duplicate bookings to seats in selected coaches",
|
||||
description:
|
||||
"Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " +
|
||||
"The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " +
|
||||
"for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " +
|
||||
"change immediately, then sends an SMS notification to the passenger. " +
|
||||
"Coaches are searched in the order provided; seats within each coach are assigned by row then column.",
|
||||
})
|
||||
@ApiBody({ type: ResolveDuplicatesDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Resolution summary — resolved count, unresolved count, per-booking results",
|
||||
schema: {
|
||||
example: {
|
||||
resolved: 2,
|
||||
unresolved: 0,
|
||||
results: [
|
||||
{ bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" },
|
||||
{ bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" })
|
||||
@ApiResponse({ status: 404, description: "BookingSeat ID not found" })
|
||||
resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) {
|
||||
return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import { SeatsService } from './seats.service';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule],
|
||||
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule],
|
||||
controllers: [SeatsController],
|
||||
providers: [SeatsService],
|
||||
exports: [SeatsService],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
|
||||
|
||||
@@ -17,6 +18,7 @@ export class SeatsService {
|
||||
private segmentsService: SegmentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private auditService: AuditService,
|
||||
private sms: SmsClientService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
|
||||
@@ -266,23 +268,38 @@ export class SeatsService {
|
||||
if (new Set(seatIds).size !== seatIds.length)
|
||||
throw new BadRequestException('Duplicate seatId in passengers list');
|
||||
|
||||
const [holdMinutes, cutoffHours] = await Promise.all([
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES),
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE),
|
||||
]);
|
||||
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
|
||||
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
select: { departureAt: true },
|
||||
});
|
||||
const [schedule, originStopTime, originRouteStop] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
select: {
|
||||
departureAt: true,
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.tripStopTime.findFirst({
|
||||
where: { scheduleId: dto.scheduleId, stationId: dto.originStationId },
|
||||
select: { plannedDepartureAt: true },
|
||||
}),
|
||||
this.prisma.routeStop.findFirst({
|
||||
where: {
|
||||
route: { schedules: { some: { id: dto.scheduleId } } },
|
||||
stationId: dto.originStationId,
|
||||
},
|
||||
select: { checkinMinutesBefore: true },
|
||||
}),
|
||||
]);
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const msUntilDeparture = schedule.departureAt.getTime() - Date.now();
|
||||
const cutoffMs = cutoffHours * 60 * 60 * 1000;
|
||||
if (msUntilDeparture <= cutoffMs) {
|
||||
// Stop-level override wins; falls back to route-level; then to 30 min.
|
||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt;
|
||||
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
|
||||
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
|
||||
throw new BadRequestException(
|
||||
`Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`,
|
||||
`Seats cannot be held within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -960,4 +977,416 @@ export class SeatsService {
|
||||
skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Duplicate-seat management (backoffice)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async getDuplicateSeats(date: string, scheduleId?: string) {
|
||||
const dayStart = new Date(`${date}T00:00:00.000Z`);
|
||||
const dayEnd = new Date(`${date}T23:59:59.999Z`);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
departureAt: { gte: dayStart, lte: dayEnd },
|
||||
...(scheduleId ? { id: scheduleId } : {}),
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
coachAssignments: {
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
coachType: { select: { name: true } },
|
||||
seats: {
|
||||
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
||||
select: { id: true, seatNumber: true, status: true, coachId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
// All confirmed BookingSeat rows for this schedule
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId: schedule.id },
|
||||
{ scheduleId: null, booking: { scheduleId: schedule.id } },
|
||||
],
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
},
|
||||
select: {
|
||||
id: true, seatId: true, scheduleId: true, leg: true, passengerName: true,
|
||||
seat: { select: { coachId: true } },
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
createdAt: true, contactPhone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Seats occupied by any confirmed journey on this schedule (source of truth)
|
||||
const journeySegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: schedule.id,
|
||||
seatId: { not: null },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
});
|
||||
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
|
||||
|
||||
// Group BookingSeat rows by (seatId::leg) to detect duplicates
|
||||
type BS = (typeof bookingSeats)[number];
|
||||
const groups = new Map<string, BS[]>();
|
||||
for (const bs of bookingSeats) {
|
||||
const key = `${bs.seatId}::${bs.leg}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
// All seats held by any confirmed BookingSeat — union of JourneySegment-based
|
||||
// occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments
|
||||
// are missing (e.g. created via enhanced-seats path without bookingId) are still
|
||||
// excluded from the available list.
|
||||
const bookedSeatIds = new Set<string>([
|
||||
...occupiedIds,
|
||||
...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined),
|
||||
]);
|
||||
|
||||
const coachReports = [];
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const coach = assignment.coach;
|
||||
|
||||
// Duplicate groups whose seat belongs to this coach
|
||||
const duplicates = [];
|
||||
for (const [key, group] of groups) {
|
||||
if (group.length <= 1) continue;
|
||||
if (group[0].seat.coachId !== coach.id) continue;
|
||||
const [seatId] = key.split('::');
|
||||
const seat = coach.seats.find(s => s.id === seatId);
|
||||
duplicates.push({
|
||||
seatId,
|
||||
seatNumber: seat?.seatNumber ?? seatId,
|
||||
leg: group[0].leg,
|
||||
bookings: group.map(bs => ({
|
||||
bookingSeatId: bs.id,
|
||||
bookingId: bs.booking.id,
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
createdAt: bs.booking.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat
|
||||
// assignments, and all confirmed JourneySegment occupancies.
|
||||
const availableSeats = coach.seats
|
||||
.filter(s =>
|
||||
(s.status as string) !== 'BLOCKED' &&
|
||||
!s.seatNumber.startsWith('-') &&
|
||||
!bookedSeatIds.has(s.id),
|
||||
)
|
||||
.map(s => ({ seatId: s.id, seatNumber: s.seatNumber }));
|
||||
|
||||
coachReports.push({
|
||||
coachId: coach.id,
|
||||
coachNumber: coach.number,
|
||||
coachTypeName: coach.coachType.name,
|
||||
duplicates,
|
||||
availableSeats,
|
||||
});
|
||||
}
|
||||
|
||||
if (coachReports.some(c => c.duplicates.length > 0)) {
|
||||
result.push({
|
||||
scheduleId: schedule.id,
|
||||
departureAt: schedule.departureAt,
|
||||
origin: schedule.originStation.name,
|
||||
destination: schedule.destinationStation.name,
|
||||
coaches: coachReports,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuplicates = result.reduce(
|
||||
(sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return { date, schedules: result, totalDuplicates };
|
||||
}
|
||||
|
||||
async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) {
|
||||
if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] };
|
||||
|
||||
// Load BookingSeat rows with full booking + schedule context
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: { id: { in: bookingSeatIds } },
|
||||
select: {
|
||||
id: true, seatId: true, leg: true, scheduleId: true,
|
||||
seat: { select: { seatNumber: true } },
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
status: true, contactPhone: true, passengerId: true,
|
||||
totalMinor: true, currency: true,
|
||||
originStationId: true, destinationStationId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
departureAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (bookingSeats.length !== bookingSeatIds.length) {
|
||||
const found = new Set(bookingSeats.map(bs => bs.id));
|
||||
const missing = bookingSeatIds.filter(id => !found.has(id));
|
||||
throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status));
|
||||
if (invalid.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick)
|
||||
const coachSeats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coachId: { in: coachIds },
|
||||
status: { not: 'BLOCKED' },
|
||||
NOT: { seatNumber: { startsWith: '-' } },
|
||||
},
|
||||
select: { id: true, seatNumber: true, coachId: true, row: true, col: true },
|
||||
orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
|
||||
// Build occupied-seat sets per schedule from confirmed JourneySegments
|
||||
const scheduleIds = [
|
||||
...new Set(
|
||||
bookingSeats
|
||||
.map(bs => bs.scheduleId ?? bs.booking.scheduleId)
|
||||
.filter((id): id is string => id !== null && id !== undefined),
|
||||
),
|
||||
];
|
||||
|
||||
const occupiedBySchedule = new Map<string, Set<string>>();
|
||||
await Promise.all(
|
||||
scheduleIds.map(async scheduleId => {
|
||||
const segments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId: { not: null },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
});
|
||||
occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!)));
|
||||
}),
|
||||
);
|
||||
|
||||
// Track seats assigned within this batch to prevent double-assignment
|
||||
const assignedInBatch = new Set<string>();
|
||||
|
||||
const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = [];
|
||||
const unresolved: { bookingRef: string; reason: string }[] = [];
|
||||
|
||||
for (const bs of bookingSeats) {
|
||||
const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!;
|
||||
const occupied = occupiedBySchedule.get(scheduleId) ?? new Set<string>();
|
||||
|
||||
// Pick the first available seat across the selected coaches
|
||||
const newSeat = coachSeats.find(
|
||||
seat =>
|
||||
!occupied.has(seat.id) &&
|
||||
!assignedInBatch.has(seat.id) &&
|
||||
seat.id !== bs.seatId,
|
||||
);
|
||||
|
||||
if (!newSeat) {
|
||||
unresolved.push({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
reason: 'No available seat found in selected coaches',
|
||||
});
|
||||
this.logger.warn(
|
||||
`Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
// 1. Change the seat on the booking and ticket.
|
||||
await tx.bookingSeat.update({
|
||||
where: { id: bs.id },
|
||||
data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber },
|
||||
});
|
||||
await tx.ticket.updateMany({
|
||||
where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
|
||||
// 2. Point the existing JourneySegments to the new seat.
|
||||
// The Journey is already linked to this booking via bookingId;
|
||||
// just update the seatId in its hop rows for this schedule.
|
||||
const journey = await tx.journey.findFirst({
|
||||
where: { bookingId: bs.booking.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!journey) {
|
||||
// No Journey/JourneySegment for this booking (e.g. duplicate that was never
|
||||
// processed by finalizePaymentSuccess). Create them now using the same logic,
|
||||
// scoped to the booking's origin→destination leg so the seatmap shows BOOKED
|
||||
// only for the correct range of stops.
|
||||
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
|
||||
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
|
||||
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true },
|
||||
});
|
||||
|
||||
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
|
||||
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
|
||||
const fromIdx = originIdx >= 0 ? originIdx : 0;
|
||||
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
|
||||
|
||||
const newJourney = await tx.journey.create({
|
||||
data: {
|
||||
passengerId: bs.booking.passengerId,
|
||||
bookingId: bs.booking.id,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: bs.booking.totalMinor,
|
||||
currency: bs.booking.currency,
|
||||
} as any,
|
||||
});
|
||||
|
||||
const segments = [];
|
||||
for (let i = fromIdx; i < toIdx; i++) {
|
||||
segments.push({
|
||||
journeyId: newJourney.id,
|
||||
scheduleId,
|
||||
segmentOrder: i - fromIdx,
|
||||
seatId: newSeat.id,
|
||||
coachId: newSeat.coachId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
|
||||
}
|
||||
this.logger.log(
|
||||
`No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { count } = await tx.journeySegment.updateMany({
|
||||
where: { journeyId: journey.id, scheduleId, seatId: bs.seatId },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
|
||||
// Journey exists but had no segments (e.g. booking confirmed via a path
|
||||
// that skipped JourneySegment creation). Create them now for the new seat
|
||||
// so the seatmap reflects BOOKED.
|
||||
if (count === 0) {
|
||||
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
|
||||
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true },
|
||||
});
|
||||
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
|
||||
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
|
||||
const fromIdx = originIdx >= 0 ? originIdx : 0;
|
||||
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
|
||||
const segments = [];
|
||||
for (let i = fromIdx; i < toIdx; i++) {
|
||||
segments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId,
|
||||
segmentOrder: i - fromIdx,
|
||||
seatId: newSeat.id,
|
||||
coachId: newSeat.coachId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
|
||||
}
|
||||
this.logger.log(
|
||||
`Seat reassigned: ${bs.booking.bookingRef} ` +
|
||||
`${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` +
|
||||
`(0 existing segments — created ${segments.length} new hop(s))`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Seat reassigned: ${bs.booking.bookingRef} ` +
|
||||
`${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` +
|
||||
`(${count} segment hop(s) updated)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Mark as taken so the next booking in this batch doesn't get the same seat
|
||||
assignedInBatch.add(newSeat.id);
|
||||
occupied.add(newSeat.id);
|
||||
|
||||
const oldSeatNumber = bs.seat?.seatNumber ?? '?';
|
||||
const origin = bs.booking.schedule?.originStation?.name ?? '';
|
||||
const dest = bs.booking.schedule?.destinationStation?.name ?? '';
|
||||
|
||||
if (bs.booking.contactPhone) {
|
||||
const message =
|
||||
`EDR: Your booking ${bs.booking.bookingRef} (${origin} → ${dest}): ` +
|
||||
`your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` +
|
||||
`We apologize for any inconvenience.`;
|
||||
await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber} → ${newSeat.seatNumber}`,
|
||||
);
|
||||
|
||||
results.push({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
oldSeatNumber,
|
||||
newSeatNumber: newSeat.seatNumber,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
resolved: results.length,
|
||||
unresolved: unresolved.length,
|
||||
results,
|
||||
...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -3,9 +3,6 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { PaymentClientService } from '../payments/payment-client.service';
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
|
||||
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
|
||||
// Retention windows
|
||||
@@ -31,8 +28,6 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -47,6 +42,7 @@ export class TasksService {
|
||||
const now = new Date();
|
||||
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
|
||||
|
||||
// ── Schedule-level transitions (operational display) ───────────────────
|
||||
const [boarding, departed, arrived] = await Promise.all([
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
|
||||
@@ -62,9 +58,71 @@ export class TasksService {
|
||||
}),
|
||||
]);
|
||||
|
||||
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
|
||||
// ── Per-stop transitions (segment-level status) ────────────────────────
|
||||
// OPEN → CHECKIN_CLOSED: each RouteStop carries its own checkinMinutesBefore
|
||||
// override; falls back to the Route-level value when null.
|
||||
// Group by effective cutoff → one updateMany per (effectiveMins, routeId) pair.
|
||||
const routeStops = await this.prisma.routeStop.findMany({
|
||||
select: {
|
||||
routeId: true,
|
||||
stationId: true,
|
||||
checkinMinutesBefore: true,
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Map: effectiveMins → Map<routeId, stationId[]>
|
||||
const byMins = new Map<number, Map<string, string[]>>();
|
||||
for (const stop of routeStops) {
|
||||
const mins = stop.checkinMinutesBefore ?? stop.route.checkinMinutesBefore;
|
||||
if (!byMins.has(mins)) byMins.set(mins, new Map());
|
||||
const byRoute = byMins.get(mins)!;
|
||||
if (!byRoute.has(stop.routeId)) byRoute.set(stop.routeId, []);
|
||||
byRoute.get(stop.routeId)!.push(stop.stationId);
|
||||
}
|
||||
|
||||
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).
|
||||
const reverted = await this.prisma.tripStopTime.updateMany({
|
||||
where: {
|
||||
status: 'CHECKIN_CLOSED',
|
||||
plannedDepartureAt: { gt: cutoffAt },
|
||||
stationId: { in: stationIds },
|
||||
schedule: { routeId },
|
||||
},
|
||||
data: { status: 'OPEN' },
|
||||
});
|
||||
reopenedCount += reverted.count;
|
||||
|
||||
// Forward: close stops now within the cutoff window.
|
||||
const closed = await this.prisma.tripStopTime.updateMany({
|
||||
where: {
|
||||
status: 'OPEN',
|
||||
plannedDepartureAt: { lte: cutoffAt },
|
||||
stationId: { in: stationIds },
|
||||
schedule: { routeId },
|
||||
},
|
||||
data: { status: 'CHECKIN_CLOSED' },
|
||||
});
|
||||
checkinClosedCount += closed.count;
|
||||
}
|
||||
}
|
||||
|
||||
const boardedStops = await this.prisma.tripStopTime.updateMany({
|
||||
where: { status: 'CHECKIN_CLOSED', plannedDepartureAt: { lte: now } },
|
||||
data: { status: 'BOARDED' },
|
||||
});
|
||||
|
||||
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0 ||
|
||||
reopenedCount > 0 || checkinClosedCount > 0 || boardedStops.count > 0) {
|
||||
this.logger.log(
|
||||
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
|
||||
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED | ` +
|
||||
`Stops: ${reopenedCount} → OPEN (reverted), ${checkinClosedCount} → CHECKIN_CLOSED, ${boardedStops.count} → BOARDED`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,13 +159,17 @@ export class TasksService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentReminderSentAt: null,
|
||||
createdAt: { gte: threeHoursAgo },
|
||||
schedule: { departureAt: { gte: now } },
|
||||
} as any,
|
||||
// Do NOT filter by schedule.departureAt here: for multi-stop routes the
|
||||
// passenger's segment may depart well after the schedule's first stop, and
|
||||
// that first-stop time could already be in the past even though B→C is still open.
|
||||
},
|
||||
include: {
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -115,9 +177,15 @@ export class TasksService {
|
||||
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||
const createdAt = booking.createdAt as Date;
|
||||
// Use the booking's origin-segment departure and the route's own check-in window.
|
||||
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;
|
||||
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();
|
||||
|
||||
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
|
||||
@@ -181,6 +249,8 @@ export class TasksService {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
},
|
||||
paymentIntent: { select: { method: true } },
|
||||
@@ -192,9 +262,14 @@ export class TasksService {
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
// 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.
|
||||
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);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
@@ -206,7 +281,7 @@ export class TasksService {
|
||||
// this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even
|
||||
// though the booking is now cancelled. Scoped to this booking's own schedule,
|
||||
// since the same physical Seat row is reused across other recurring dates.
|
||||
const seatIds = booking.seats.map(s => s.seatId);
|
||||
const seatIds = booking.seats.map((s: any) => s.seatId);
|
||||
if (seatIds.length > 0) {
|
||||
await this.prisma.seatHold.deleteMany({
|
||||
where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } },
|
||||
@@ -258,87 +333,6 @@ export class TasksService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// whose payment intent has moved to SUCCEEDED on the gateway but whose
|
||||
// confirmation event was never delivered (missed RabbitMQ message, network
|
||||
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
|
||||
// it for an already-confirmed booking is safe.
|
||||
//
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
|
||||
},
|
||||
include: { paymentIntent: true },
|
||||
take: BATCH_SIZE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.paymentIntent) continue;
|
||||
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
|
||||
if (!snapshot) continue;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const result = await this.paymentsService.finalizePaymentSuccess({
|
||||
intentId: booking.paymentIntent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
if (!result.alreadyFinalized) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
} else if (
|
||||
snapshot.status === ProviderPaymentStatus.FAILED ||
|
||||
snapshot.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
// The payment deadline enforcer will cancel the booking when its
|
||||
// window expires; log now so operations can see failed intents early.
|
||||
this.logger.warn(
|
||||
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` +
|
||||
`booking will be auto-cancelled at payment deadline`,
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
errored++;
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,6 +53,7 @@ export class TicketsController {
|
||||
@ApiQuery({ name: 'originStationId', required: false })
|
||||
@ApiQuery({ name: 'destinationStationId', required: false })
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'departureDate', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'coachId', required: false })
|
||||
@@ -64,6 +65,7 @@ export class TicketsController {
|
||||
@Query('originStationId') originStationId?: string,
|
||||
@Query('destinationStationId') destinationStationId?: string,
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('departureDate') departureDate?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('coachId') coachId?: string,
|
||||
@@ -76,6 +78,7 @@ export class TicketsController {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
coachId,
|
||||
|
||||
@@ -27,7 +27,7 @@ export class TicketsService {
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; departureDate?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
@@ -41,10 +41,16 @@ export class TicketsService {
|
||||
where.status = filters.status;
|
||||
}
|
||||
if (filters.originStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
|
||||
where.booking = { ...where.booking, originStationId: filters.originStationId };
|
||||
}
|
||||
if (filters.destinationStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
|
||||
where.booking = { ...where.booking, destinationStationId: filters.destinationStationId };
|
||||
}
|
||||
if (filters.departureDate) {
|
||||
const start = new Date(filters.departureDate);
|
||||
const end = new Date(filters.departureDate);
|
||||
end.setDate(end.getDate() + 1);
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, departureAt: { gte: start, lt: end } } };
|
||||
}
|
||||
if (filters.arrivalDate) {
|
||||
const start = new Date(filters.arrivalDate);
|
||||
@@ -70,7 +76,7 @@ export class TicketsService {
|
||||
include: {
|
||||
booking: {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
passenger: { include: { travelerProfiles: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
@@ -146,6 +152,18 @@ export class TicketsService {
|
||||
contactPhone: t.booking?.contactPhone,
|
||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||
seats: t.booking?.seats ?? [],
|
||||
originStation: (() => {
|
||||
const id = t.booking?.originStationId;
|
||||
if (!id) return t.booking?.schedule?.originStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
|
||||
})(),
|
||||
destinationStation: (() => {
|
||||
const id = t.booking?.destinationStationId;
|
||||
if (!id) return t.booking?.schedule?.destinationStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
|
||||
})(),
|
||||
},
|
||||
schedule: t.booking?.schedule,
|
||||
seat: t.seat ? {
|
||||
|
||||
Reference in New Issue
Block a user