mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
Merge branch 'dev' into quick-fix-main
This commit is contained in:
@@ -85,6 +85,7 @@ export class AgentsService {
|
||||
seats: {
|
||||
create: dto.passengers.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.fullName,
|
||||
idDocumentType: p.idDocumentType as IdDocumentType | undefined,
|
||||
idDocumentNumber: p.idDocumentNumber
|
||||
|
||||
@@ -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,
|
||||
@@ -834,44 +834,63 @@ export class BookingsService {
|
||||
|
||||
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
|
||||
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
|
||||
let freeChildUsed = false;
|
||||
let pkgChildIdx = 0;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
|
||||
pkgChildIdx++;
|
||||
} else {
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||
// Free children have no seat (frontend excludes them from the DTO).
|
||||
// Guard by seatId: unseated = free (0), seated = paid child.
|
||||
// Applies to both package and regular bookings.
|
||||
fareMinor = p.seatId ? (p.seatFareMinor ?? fareCalculation.baseFareMinor) : 0;
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
|
||||
// Use the sum of per-seat fares as the authoritative total when the client supplied
|
||||
// seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
|
||||
// (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
|
||||
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
||||
// Determine the authoritative total.
|
||||
// Priority (one-way, non-package):
|
||||
// 1. Server-computed sum of per-seat fares when every seated passenger supplied
|
||||
// seatFareMinor — this captures berth-specific pricing (Upper/Middle/Lower)
|
||||
// exactly as shown to the user and cannot be corrupted by a frontend race
|
||||
// condition that sends reviewedTotalMinor before all fares are resolved.
|
||||
// 2. reviewedTotalMinor from the frontend — fallback when the server doesn't
|
||||
// have complete per-seat data (e.g. auto-assign with no seat map loaded).
|
||||
// 3. Fare engine total — last resort when neither is available.
|
||||
// For package bookings reviewedTotalMinor always wins because the tier price
|
||||
// may include berth-specific adjustments the server cannot derive alone.
|
||||
// Free children have no seatId and no seatFareMinor — exclude from the check.
|
||||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
// seatFareMinor values from the client are in display-currency minor units (matching
|
||||
// displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor.
|
||||
// In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor.
|
||||
let resolvedTotalMinor: number;
|
||||
let displayTotalMinor: number;
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
} else if (allFaresProvided) {
|
||||
// seatFareMinor is in display currency — sum is already the display total
|
||||
|
||||
if (allFaresProvided && !dto.packageId) {
|
||||
// Server has every passenger's berth fare — sum is the authoritative display total.
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
|
||||
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
|
||||
}
|
||||
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
|
||||
// Package booking: client-supplied tier-adjusted total.
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
if (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 (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
// Partial data on server: use client's total as best available.
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
} else {
|
||||
resolvedTotalMinor = fareCalculation.totalMinor;
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
@@ -901,6 +920,7 @@ export class BookingsService {
|
||||
seats: {
|
||||
create: passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
@@ -1018,8 +1038,6 @@ export class BookingsService {
|
||||
|
||||
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
|
||||
// present (berth-specific pricing). Fall back to fare engine values.
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let outboundFareMinor: number;
|
||||
let returnFareMinor: number;
|
||||
@@ -1027,14 +1045,12 @@ export class BookingsService {
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
outboundFareMinor = 0;
|
||||
returnFareMinor = 0;
|
||||
} else {
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||
// Free children have no outbound seat (frontend excludes them).
|
||||
// Guard by outboundSeatId: unseated = free (0), seated = paid child.
|
||||
// Applies to both package and regular bookings.
|
||||
outboundFareMinor = p.outboundSeatId ? (p.seatFareMinor ?? outboundFare.baseFareMinor) : 0;
|
||||
returnFareMinor = p.outboundSeatId ? (p.returnSeatFareMinor ?? returnFare.baseFareMinor) : 0;
|
||||
}
|
||||
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
@@ -1042,20 +1058,39 @@ export class BookingsService {
|
||||
|
||||
// Override totalMinor with the sum of actual per-seat fares when all seated passengers
|
||||
// supplied their fares — free children (no seatId) are excluded from the check.
|
||||
// Same priority logic as one-way: server-computed sum wins when all per-seat fares
|
||||
// are present; reviewedTotalMinor is used only as fallback to avoid a frontend
|
||||
// race condition from under-counting passengers.
|
||||
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
|
||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
} else if (allRTFaresProvided && !dto.packageId) {
|
||||
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
|
||||
|
||||
if (allRTFaresProvided && !dto.packageId) {
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
|
||||
this.logger.warn(`createRoundTripBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
|
||||
}
|
||||
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
const seatedCount = rtSeatedPassengers.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 (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
@@ -1822,8 +1857,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 +1887,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,
|
||||
})),
|
||||
|
||||
@@ -204,35 +204,41 @@ export class GuestBookingService {
|
||||
const taxesMinor = 0;
|
||||
|
||||
// Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing).
|
||||
// Free children (first child, non-package) get fareMinor=0.
|
||||
let freeChildUsed = false;
|
||||
let pkgChildIdx = 0;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = p.seatFareMinor ?? baseFareMinor;
|
||||
} else if (isPackageOneway) {
|
||||
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare);
|
||||
pkgChildIdx++;
|
||||
} else {
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = p.seatFareMinor ?? childUnitFare;
|
||||
// Free children have no seat (frontend excludes them from the DTO).
|
||||
// Guard by seatId: unseated = free (0), seated = paid child.
|
||||
// Applies to both package and regular bookings.
|
||||
fareMinor = p.seatId ? (p.seatFareMinor ?? childUnitFare) : 0;
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
|
||||
// reviewedTotalMinor and seatFareMinor are both in display-currency minor units.
|
||||
// Store as displayTotalMinor as-is; back-convert to ETB for totalMinor.
|
||||
// Server-computed sum from per-seat fares is the authoritative total when all
|
||||
// seated passengers supplied seatFareMinor. This prevents a frontend race
|
||||
// condition (fareBreakdown not yet loaded → only partial fares summed →
|
||||
// reviewedTotalMinor reflects one passenger's fare instead of all).
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
|
||||
let displayTotalMinor: number;
|
||||
let resolvedTotalMinor: number;
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
} else if (allFaresProvided) {
|
||||
if (allFaresProvided && !isPackageOneway) {
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
} else if (isPackageOneway && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
if (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 (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
} else {
|
||||
// fare engine returns ETB — convert forward to display currency
|
||||
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
|
||||
@@ -291,6 +297,7 @@ export class GuestBookingService {
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
@@ -485,22 +492,18 @@ export class GuestBookingService {
|
||||
: totalMinor;
|
||||
|
||||
// Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present.
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let outboundFareMinor: number;
|
||||
let returnFareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
|
||||
returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
|
||||
} else if (isPackageRoundTrip) {
|
||||
outboundFareMinor = 0;
|
||||
returnFareMinor = 0;
|
||||
} else {
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
|
||||
// Free children have no seat (frontend excludes them from the DTO).
|
||||
// Guard by seatId: unseated = free (0), seated = paid child.
|
||||
// Applies to both package and regular bookings.
|
||||
outboundFareMinor = p.seatId ? (p.seatFareMinor ?? outboundChildUnitFare) : 0;
|
||||
returnFareMinor = p.seatId ? (p.returnSeatFareMinor ?? returnChildUnitFare) : 0;
|
||||
}
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
});
|
||||
@@ -510,14 +513,28 @@ export class GuestBookingService {
|
||||
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
|
||||
if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||
// Server-computed sum is authoritative — prevents race-condition under-count.
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
} else if (isPackageRoundTrip && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
} 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);
|
||||
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 (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
|
||||
@@ -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({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
|
||||
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, 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,
|
||||
|
||||
@@ -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;
|
||||
@@ -235,18 +236,27 @@ export class PackagesService {
|
||||
});
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
|
||||
// Fetch live route stops so the departure station dropdown always reflects
|
||||
// the current route definition, not stale TripStopTime snapshots.
|
||||
// Build departure station list from live route stops when a routeId exists,
|
||||
// falling back to the schedule's own stopTimes (already included in the query).
|
||||
let routeStops: { sequence: number; station: any }[] = [];
|
||||
if (pkg.outboundSchedule.routeId) {
|
||||
const stops = await this.prisma.routeStop.findMany({
|
||||
where: { routeId: pkg.outboundSchedule.routeId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
const stationIds = stops.map((s) => s.stationId);
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
|
||||
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
|
||||
if (stops.length > 0) {
|
||||
const stationIds = stops.map((s) => s.stationId);
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
|
||||
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
|
||||
}
|
||||
}
|
||||
// Fall back to the schedule's own TripStopTimes when RouteStop table has no rows
|
||||
// for this route (e.g. route exists but stops were never seeded).
|
||||
if (routeStops.length === 0) {
|
||||
routeStops = (pkg.outboundSchedule.stopTimes ?? [])
|
||||
.filter((st: any) => st.station)
|
||||
.map((st: any) => ({ sequence: st.sequence, station: st.station }));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -382,7 +392,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');
|
||||
@@ -440,8 +453,8 @@ export class PackagesService {
|
||||
passengerCount,
|
||||
adultCount,
|
||||
childCount,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
status: 'PENDING_PAYMENT',
|
||||
@@ -477,6 +490,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: {
|
||||
|
||||
@@ -12,8 +12,15 @@ import {
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderStatus,
|
||||
} from "@edr/types";
|
||||
|
||||
/** Side-by-side DB row + live provider status from the payment service diagnostic endpoints. */
|
||||
export interface PaymentDiagnostic {
|
||||
db: Record<string, unknown> | null;
|
||||
provider: ProviderStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
|
||||
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
|
||||
@@ -55,6 +62,28 @@ export class PaymentClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payments/diagnostic?… — DB intent row + live provider status for a domain reference,
|
||||
* side by side. Returns { db: null, provider: null } when the payment service has no intent.
|
||||
*/
|
||||
async getDiagnosticByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentDiagnostic> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/diagnostic?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404)
|
||||
return { db: null, provider: null };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
|
||||
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,33 @@ export class PaymentsController {
|
||||
return this.service.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Get("status/:bookingRefOrId")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
summary: "Get payment status by booking id or booking reference (PNR)",
|
||||
description:
|
||||
"Accepts either a booking UUID or a booking reference / PNR (e.g. EDR-20240001), " +
|
||||
"resolves it to the booking, and returns the authoritative payment status pulled from " +
|
||||
"the payment microservice.",
|
||||
})
|
||||
getStatusByBookingRefOrId(@Param("bookingRefOrId") bookingRefOrId: string) {
|
||||
return this.service.getIntentByBookingRefOrId(bookingRefOrId);
|
||||
}
|
||||
|
||||
@Get("diagnostic/:bookingRefOrId")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Get { db, provider } by booking id or booking reference (PNR) — diagnostic",
|
||||
description:
|
||||
"Accepts a booking UUID or a booking reference / PNR (e.g. EDR-20240001), resolves it to " +
|
||||
"the booking, and returns { db, provider }: the payment service's stored intent row and a " +
|
||||
"live provider status query, side by side. Pure read — does not reconcile the booking.",
|
||||
})
|
||||
getPaymentDiagnostic(@Param("bookingRefOrId") bookingRefOrId: string) {
|
||||
return this.service.getPaymentDiagnosticByBookingRefOrId(bookingRefOrId);
|
||||
}
|
||||
|
||||
@Post(":bookingId/confirm")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -118,6 +118,7 @@ describe("Payments E2E", () => {
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
seatId: seat.id,
|
||||
scheduleId: schedule.id,
|
||||
passengerName: "Test Passenger",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
ForceConfirmDto,
|
||||
} from "./payments.dto";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import {
|
||||
PaymentClientService,
|
||||
PaymentDiagnostic,
|
||||
} from "./payment-client.service";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { AuditService } from "../../common/audit.service";
|
||||
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
|
||||
@@ -534,6 +537,51 @@ export class PaymentsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001).
|
||||
* Resolves the PNR to its booking id, then pulls the authoritative status from the payment
|
||||
* microservice (via {@link getIntentByBookingId}).
|
||||
*/
|
||||
async getIntentByBookingRefOrId(
|
||||
bookingRefOrId: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
const bookingId = await this.resolveBookingId(bookingRefOrId);
|
||||
return this.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's
|
||||
* stored intent row and a live provider status query, side by side ({ db, provider }).
|
||||
* Pure read — does not reconcile or confirm the booking.
|
||||
*/
|
||||
async getPaymentDiagnosticByBookingRefOrId(
|
||||
bookingRefOrId: string,
|
||||
): Promise<PaymentDiagnostic> {
|
||||
const bookingId = await this.resolveBookingId(bookingRefOrId);
|
||||
return this.paymentClient.getDiagnosticByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
bookingId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */
|
||||
private async resolveBookingId(bookingRefOrId: string): Promise<string> {
|
||||
const isUuid =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
bookingRefOrId,
|
||||
);
|
||||
if (isUuid) return bookingRefOrId;
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: bookingRefOrId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking not found: ${bookingRefOrId}`);
|
||||
}
|
||||
return booking.id;
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId },
|
||||
|
||||
@@ -1,56 +1,50 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { GenerateReportDto } from './reports.dto';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { GenerateReportDto } from "./reports.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@ApiTags('Reports')
|
||||
@Controller('reports')
|
||||
@ApiTags("Reports")
|
||||
@Controller("reports")
|
||||
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
export class ReportsController {
|
||||
constructor(private service: ReportsService) {}
|
||||
|
||||
@Post('generate')
|
||||
@ApiOperation({ summary: 'Generate operational report' })
|
||||
@Post("generate")
|
||||
@ApiOperation({ summary: "Generate operational report" })
|
||||
generateReport(@Body() dto: GenerateReportDto) {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get('schedules')
|
||||
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
|
||||
@Get("schedules")
|
||||
@ApiOperation({ summary: "List schedules for the passengers report picker" })
|
||||
listSchedulesForPicker() {
|
||||
return this.service.listSchedulesForPicker();
|
||||
}
|
||||
|
||||
@Get('passengers/list')
|
||||
@ApiOperation({ summary: 'Flat passenger list for a specific schedule' })
|
||||
getPassengerList(@Query('scheduleId') scheduleId: string) {
|
||||
@Get("passengers/list")
|
||||
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
|
||||
getPassengerList(@Query("scheduleId") scheduleId: string) {
|
||||
return this.service.getPassengerList(scheduleId);
|
||||
}
|
||||
|
||||
@Get('seats')
|
||||
@ApiOperation({ summary: 'Seat status report for a specific schedule' })
|
||||
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getSeatStatusReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get('passengers')
|
||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
||||
@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) {
|
||||
@Get(":reportId")
|
||||
@ApiOperation({ summary: "Get report by ID" })
|
||||
getReport(@Param("reportId") reportId: string) {
|
||||
return this.service.getReport(reportId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List reports' })
|
||||
listReports(@Query('type') type?: string) {
|
||||
@ApiOperation({ summary: "List reports" })
|
||||
listReports(@Query("type") type?: string) {
|
||||
return this.service.listReports(type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { GenerateReportDto, ReportType } from './reports.dto';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { GenerateReportDto, ReportType } from "./reports.dto";
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
@@ -15,7 +15,7 @@ export class ReportsService {
|
||||
async generateReport(dto: GenerateReportDto) {
|
||||
const dateFrom = new Date(dto.dateFrom);
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
const dateTo = new Date(dto.dateTo);
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
|
||||
@@ -28,7 +28,11 @@ export class ReportsService {
|
||||
data = await this.generateOccupancyReport(dateFrom, dateTo);
|
||||
break;
|
||||
case ReportType.AGENT_SALES:
|
||||
data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId);
|
||||
data = await this.generateAgentSalesReport(
|
||||
dateFrom,
|
||||
dateTo,
|
||||
dto.agentId,
|
||||
);
|
||||
break;
|
||||
case ReportType.CANCELLATIONS:
|
||||
data = await this.generateCancellationsReport(dateFrom, dateTo);
|
||||
@@ -45,8 +49,8 @@ export class ReportsService {
|
||||
reportType: dto.reportType,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
data
|
||||
}
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
return { reportId: report.id, reportType: dto.reportType, data };
|
||||
@@ -56,37 +60,43 @@ export class ReportsService {
|
||||
// Fetch all bookings in date range, regardless of status
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo }
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
},
|
||||
include: { paymentIntent: true }
|
||||
include: { paymentIntent: true },
|
||||
});
|
||||
|
||||
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
||||
acc[method] = (acc[method] || 0) + b.totalMinor;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const byPaymentMethod = bookings.reduce(
|
||||
(acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? "UNKNOWN";
|
||||
acc[method] = (acc[method] || 0) + b.totalMinor;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
// Group by date for charts
|
||||
const byDate = bookings.reduce((acc, b) => {
|
||||
const date = b.createdAt.toISOString().split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { totalMinor: 0, count: 0 };
|
||||
}
|
||||
acc[date].totalMinor += b.totalMinor;
|
||||
acc[date].count += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
const byDate = bookings.reduce(
|
||||
(acc, b) => {
|
||||
const date = b.createdAt.toISOString().split("T")[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { totalMinor: 0, count: 0 };
|
||||
}
|
||||
acc[date].totalMinor += b.totalMinor;
|
||||
acc[date].count += 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
totalRevenueMinor: totalRevenue,
|
||||
totalRevenue: totalRevenue / 100,
|
||||
currency: 'ETB',
|
||||
currency: "ETB",
|
||||
byPaymentMethod,
|
||||
byDate,
|
||||
cancellationRate: 0
|
||||
cancellationRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,79 +106,115 @@ export class ReportsService {
|
||||
include: {
|
||||
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
||||
bookings: {
|
||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
include: { seats: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tripData = schedules.map(schedule => {
|
||||
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
|
||||
const bookedSeats = schedule.bookings.reduce(
|
||||
(sum, b) => sum + b.seats.filter((s: any) => s.leg === 1).length, 0,
|
||||
const tripData = schedules.map((schedule) => {
|
||||
const totalSeats = schedule.coachAssignments.reduce(
|
||||
(sum, a) => sum + a.coach.seats.length,
|
||||
0,
|
||||
);
|
||||
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
|
||||
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
|
||||
const bookedSeats = schedule.bookings.reduce(
|
||||
(sum, b) =>
|
||||
sum + b.seats.filter((s: any) => s.scheduleId === schedule.id).length,
|
||||
0,
|
||||
);
|
||||
const occupancyRate =
|
||||
totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
|
||||
return {
|
||||
scheduleId: schedule.id,
|
||||
departureAt: schedule.departureAt,
|
||||
totalSeats,
|
||||
bookedSeats,
|
||||
occupancyRate: +occupancyRate.toFixed(2),
|
||||
};
|
||||
});
|
||||
|
||||
const avgOccupancy = tripData.length > 0 ? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length : 0;
|
||||
return { totalSchedules: schedules.length, averageOccupancyRate: +avgOccupancy.toFixed(2), schedules: tripData };
|
||||
const avgOccupancy =
|
||||
tripData.length > 0
|
||||
? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) /
|
||||
tripData.length
|
||||
: 0;
|
||||
return {
|
||||
totalSchedules: schedules.length,
|
||||
averageOccupancyRate: +avgOccupancy.toFixed(2),
|
||||
schedules: tripData,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateAgentSalesReport(dateFrom: Date, dateTo: Date, agentId?: string) {
|
||||
private async generateAgentSalesReport(
|
||||
dateFrom: Date,
|
||||
dateTo: Date,
|
||||
agentId?: string,
|
||||
) {
|
||||
const agentBookings = await this.prisma.agentBooking.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
...(agentId ? { agentId } : {})
|
||||
...(agentId ? { agentId } : {}),
|
||||
},
|
||||
include: {
|
||||
agent: { select: { id: true, iamUserId: true, agentCode: true } },
|
||||
booking: true
|
||||
}
|
||||
booking: true,
|
||||
},
|
||||
});
|
||||
|
||||
const iamUserIds = [...new Set(
|
||||
agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[]
|
||||
)];
|
||||
const iamRows = iamUserIds.length > 0
|
||||
? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>(
|
||||
`SELECT id, name FROM iam.users WHERE id = ANY($1)`,
|
||||
[iamUserIds],
|
||||
)
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
const iamUserIds = [
|
||||
...new Set(
|
||||
agentBookings
|
||||
.map((ab) => ab.agent.iamUserId)
|
||||
.filter(Boolean) as string[],
|
||||
),
|
||||
];
|
||||
const iamRows =
|
||||
iamUserIds.length > 0
|
||||
? await this.dataSource.query<
|
||||
{ id: string; name: { en?: string; am?: string } | null }[]
|
||||
>(`SELECT id, name FROM iam.users WHERE id = ANY($1)`, [iamUserIds])
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map((r) => [r.id, r]));
|
||||
|
||||
const byAgent = agentBookings.reduce((acc, ab) => {
|
||||
const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined;
|
||||
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
|
||||
if (!acc[agentName]) {
|
||||
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
||||
}
|
||||
acc[agentName].bookings += 1;
|
||||
acc[agentName].revenueMinor += ab.booking.totalMinor;
|
||||
acc[agentName].cashCollected += ab.cashReceived ?? 0;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
const byAgent = agentBookings.reduce(
|
||||
(acc, ab) => {
|
||||
const iam = ab.agent.iamUserId
|
||||
? iamMap.get(ab.agent.iamUserId)
|
||||
: undefined;
|
||||
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
|
||||
if (!acc[agentName]) {
|
||||
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
||||
}
|
||||
acc[agentName].bookings += 1;
|
||||
acc[agentName].revenueMinor += ab.booking.totalMinor;
|
||||
acc[agentName].cashCollected += ab.cashReceived ?? 0;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
return {
|
||||
totalAgentBookings: agentBookings.length,
|
||||
byAgent
|
||||
byAgent,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateCancellationsReport(dateFrom: Date, dateTo: Date) {
|
||||
const cancellations = await this.prisma.bookingCancellation.findMany({
|
||||
where: { createdAt: { gte: dateFrom, lte: dateTo } },
|
||||
include: { booking: true }
|
||||
include: { booking: true },
|
||||
});
|
||||
|
||||
const totalRefunded = cancellations.reduce((sum, c) => sum + c.refundAmount, 0);
|
||||
const totalRefunded = cancellations.reduce(
|
||||
(sum, c) => sum + c.refundAmount,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
totalCancellations: cancellations.length,
|
||||
totalRefundedMinor: totalRefunded,
|
||||
totalRefunded: totalRefunded / 100,
|
||||
currency: 'ETB'
|
||||
currency: "ETB",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,23 +222,26 @@ export class ReportsService {
|
||||
const payments = await this.prisma.paymentIntent.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
status: 'SUCCEEDED'
|
||||
}
|
||||
status: "SUCCEEDED",
|
||||
},
|
||||
});
|
||||
|
||||
const byMethod = payments.reduce((acc, p) => {
|
||||
const method = p.method;
|
||||
if (!acc[method]) {
|
||||
acc[method] = { count: 0, totalMinor: 0 };
|
||||
}
|
||||
acc[method].count += 1;
|
||||
acc[method].totalMinor += p.amountMinor;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
const byMethod = payments.reduce(
|
||||
(acc, p) => {
|
||||
const method = p.method;
|
||||
if (!acc[method]) {
|
||||
acc[method] = { count: 0, totalMinor: 0 };
|
||||
}
|
||||
acc[method].count += 1;
|
||||
acc[method].totalMinor += p.amountMinor;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
return {
|
||||
totalPayments: payments.length,
|
||||
byMethod
|
||||
byMethod,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,7 +263,7 @@ export class ReportsService {
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
include: {
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
@@ -224,61 +273,131 @@ export class ReportsService {
|
||||
},
|
||||
},
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
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 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;
|
||||
const occupancyRate =
|
||||
totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
|
||||
const coachMap = new Map<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
|
||||
// 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 });
|
||||
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 }));
|
||||
const byCoach = [...coachMap.values()].map((c) => ({
|
||||
...c,
|
||||
occupancyRate:
|
||||
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
const originMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
// 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 });
|
||||
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,
|
||||
);
|
||||
|
||||
const destMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
// 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 });
|
||||
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,
|
||||
);
|
||||
|
||||
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
|
||||
// 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 });
|
||||
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 });
|
||||
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 }));
|
||||
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,
|
||||
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,
|
||||
@@ -287,8 +406,8 @@ export class ReportsService {
|
||||
summary: { totalSeats, totalPassengers, occupancyRate },
|
||||
byCoach,
|
||||
byClass,
|
||||
byOrigin: [...originMap.values()].sort((a, b) => b.passengers - a.passengers),
|
||||
byDestination: [...destMap.values()].sort((a, b) => b.passengers - a.passengers),
|
||||
byOrigin,
|
||||
byDestination,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,12 +420,12 @@ export class ReportsService {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'desc' },
|
||||
orderBy: { departureAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
return schedules.map(s => ({
|
||||
return schedules.map((s) => ({
|
||||
id: s.id,
|
||||
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`,
|
||||
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -314,7 +433,7 @@ export class ReportsService {
|
||||
const seats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
leg: 1,
|
||||
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
booking: { scheduleId, status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
},
|
||||
include: {
|
||||
booking: {
|
||||
@@ -330,10 +449,9 @@ export class ReportsService {
|
||||
},
|
||||
seat: { select: { seatNumber: true, bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } } } },
|
||||
},
|
||||
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
|
||||
orderBy: [{ seat: { coach: { number: "asc" } } }, { seat: { seatNumber: "asc" } }],
|
||||
});
|
||||
|
||||
// Resolve station names in one query
|
||||
const stationIds = [...new Set(
|
||||
seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[],
|
||||
)];
|
||||
@@ -347,7 +465,7 @@ export class ReportsService {
|
||||
select: { departureAt: true },
|
||||
});
|
||||
|
||||
return seats.map(bs => ({
|
||||
return seats.map((bs) => ({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
passengerCategory: bs.passengerCategory,
|
||||
@@ -474,14 +592,16 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
||||
return this.prisma.operationalReport.findUnique({
|
||||
where: { id: reportId },
|
||||
});
|
||||
}
|
||||
|
||||
async listReports(reportType?: string) {
|
||||
return this.prisma.operationalReport.findMany({
|
||||
where: reportType ? { reportType } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ 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;
|
||||
@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 {
|
||||
@@ -35,6 +38,9 @@ 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;
|
||||
@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 {
|
||||
@@ -42,6 +48,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,9 @@ export class RoutesService {
|
||||
stationId: s.stationId,
|
||||
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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -92,6 +95,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 +107,9 @@ export class RoutesService {
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
plannedArrivalTime: s.plannedArrivalTime ?? null,
|
||||
plannedDepartureTime: s.plannedDepartureTime ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -221,6 +228,9 @@ export class RoutesService {
|
||||
stationId: dto.stationId,
|
||||
sequence: dto.sequence,
|
||||
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
||||
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
||||
plannedArrivalTime: dto.plannedArrivalTime ?? null,
|
||||
plannedDepartureTime: dto.plannedDepartureTime ?? 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,26 @@ 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;
|
||||
@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 {
|
||||
@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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
@@ -316,4 +318,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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,6 +325,18 @@ export class SeatsService {
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||
|
||||
// Schedule-scoped blocks prevent holding a seat on this specific schedule
|
||||
// even if its global Seat.status is AVAILABLE.
|
||||
const scheduleBlockRecords = await tx.seatBlock.findMany({
|
||||
where: { scheduleId: dto.scheduleId, seatId: { in: seatIds } },
|
||||
select: { seatId: true },
|
||||
});
|
||||
if (scheduleBlockRecords.length > 0) {
|
||||
const blockedNums = scheduleBlockRecords.map(b => seatLabelById[b.seatId]).join(', ');
|
||||
throw new ConflictException(`Seat(s) ${blockedNums} are blocked for this schedule`);
|
||||
}
|
||||
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId: dto.scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
@@ -600,9 +629,6 @@ export class SeatsService {
|
||||
|
||||
async getBlockedSeats() {
|
||||
const blocks = await this.prisma.seatBlock.findMany({
|
||||
where: {
|
||||
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
|
||||
},
|
||||
include: {
|
||||
seat: { include: { coach: { select: { number: true } } } },
|
||||
},
|
||||
@@ -697,11 +723,18 @@ export class SeatsService {
|
||||
const reqFrom = seqOf(schedule.originStationId) ?? 0;
|
||||
const reqTo = seqOf(schedule.destinationStationId) ?? stopTimes.length;
|
||||
|
||||
const unavailable = await this.segmentsService.getSeatAvailabilityMap(
|
||||
scheduleId, allSeatIds, stopTimes, reqFrom, reqTo,
|
||||
);
|
||||
const [unavailable, scheduleBlocks] = await Promise.all([
|
||||
this.segmentsService.getSeatAvailabilityMap(
|
||||
scheduleId, allSeatIds, stopTimes, reqFrom, reqTo,
|
||||
),
|
||||
this.prisma.seatBlock.findMany({
|
||||
where: { scheduleId, seatId: { in: allSeatIds } },
|
||||
select: { seatId: true },
|
||||
}),
|
||||
]);
|
||||
const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId));
|
||||
|
||||
const availableSeats = seats.filter(s => !unavailable.has(s.id));
|
||||
const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id));
|
||||
|
||||
if (availableSeats.length < count) {
|
||||
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`);
|
||||
@@ -983,4 +1016,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 },
|
||||
{ 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +146,7 @@ export class TasksService {
|
||||
await Promise.all([
|
||||
this.sendPaymentReminders(now),
|
||||
this.cancelExpiredPendingBookings(now),
|
||||
this.cancelExpiredPendingPackageBookings(now),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -101,13 +160,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 +178,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 +250,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 +263,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 +282,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,84 +334,75 @@ 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;
|
||||
// ── 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 bookings = await this.prisma.booking.findMany({
|
||||
const expiredBookings = await this.prisma.packageBooking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
|
||||
OR: [
|
||||
{ createdAt: { lte: twoHoursAgo } },
|
||||
{ package: { outboundSchedule: { departureAt: { lte: departureCutoff } } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
package: {
|
||||
include: {
|
||||
outboundSchedule: {
|
||||
include: { route: { select: { checkinMinutesBefore: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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;
|
||||
let cancelledCount = 0;
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
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;
|
||||
|
||||
if (!snapshot) 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 },
|
||||
},
|
||||
});
|
||||
|
||||
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++;
|
||||
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);
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
|
||||
this.logger.log(`Auto-cancelled package booking: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
|
||||
cancelledCount++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
`Auto-cancel failed for package booking ${(booking as any).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`,
|
||||
);
|
||||
if (cancelledCount > 0) {
|
||||
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending package booking(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Tickets')
|
||||
@@ -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,
|
||||
@@ -207,9 +210,9 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Delete ticket (admin only)',
|
||||
description: 'Permanently deletes a ticket record and removes associated seat blocks'
|
||||
})
|
||||
|
||||
@@ -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