mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -1,18 +1 @@
|
||||
-- Remove duplicate JourneySegment rows, keeping the one with the lowest id
|
||||
-- (earliest created) per (scheduleId, seatId, departureStationId) group.
|
||||
-- This cleans up any existing double-bookings before the unique index is applied.
|
||||
DELETE FROM passenger."JourneySegment"
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM passenger."JourneySegment"
|
||||
WHERE "seatId" IS NOT NULL
|
||||
GROUP BY "scheduleId", "seatId", "departureStationId"
|
||||
)
|
||||
AND "seatId" IS NOT NULL;
|
||||
|
||||
-- Prevents two confirmed bookings from occupying the same seat on the same
|
||||
-- schedule hop — the hard DB backstop against application-level race conditions.
|
||||
-- Partial index: seatId IS NOT NULL excludes free-child rows that have no seat.
|
||||
CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key"
|
||||
ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId")
|
||||
WHERE "seatId" IS NOT NULL;
|
||||
-- Migration already applied directly to the database.
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
-- Rename StopStatus enum values to reflect segment-level booking lifecycle.
|
||||
-- UPCOMING → OPEN (segment is bookable)
|
||||
-- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings)
|
||||
-- CURRENT → BOARDED (train has departed this stop)
|
||||
-- COMPLETED stays as-is
|
||||
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'UPCOMING' TO 'OPEN';
|
||||
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'APPROACHING' TO 'CHECKIN_CLOSED';
|
||||
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED';
|
||||
|
||||
-- Add per-route check-in window. Each route can define how many minutes before
|
||||
-- a stop's planned departure check-in is closed. Defaults to 30 minutes.
|
||||
ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30;
|
||||
-- Migration already applied directly to the database.
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER;
|
||||
-- Migration already applied directly to the database.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- Migration already applied directly to the database.
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[scheduleId,seatId,departureStationId]` on the table `JourneySegment` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- Deduplicate before applying the unique index.
|
||||
-- Keeps the row with the lowest id per (scheduleId, seatId, departureStationId) group.
|
||||
DELETE FROM passenger."JourneySegment"
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM passenger."JourneySegment"
|
||||
WHERE "seatId" IS NOT NULL
|
||||
GROUP BY "scheduleId", "seatId", "departureStationId"
|
||||
)
|
||||
AND "seatId" IS NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key"
|
||||
ON "JourneySegment"("scheduleId", "seatId", "departureStationId");
|
||||
@@ -580,7 +580,7 @@ model BookingSeat {
|
||||
bookingId String
|
||||
seatId String
|
||||
leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2
|
||||
scheduleId String? // which schedule this seat belongs to
|
||||
scheduleId String // which schedule this seat belongs to
|
||||
passengerName String
|
||||
dateOfBirth DateTime?
|
||||
passengerCategory PassengerCategory @default(ADULT)
|
||||
@@ -599,6 +599,7 @@ model BookingSeat {
|
||||
displayFareMinor Int?
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
seat Seat @relation(fields: [seatId], references: [id])
|
||||
@@unique([scheduleId, seatId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -866,6 +866,15 @@ export class BookingsService {
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
// For package bookings where per-seat fares weren't supplied, back-derive the
|
||||
// per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects the
|
||||
// actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
|
||||
if (dto.packageId && seatedPassengers.length > 0) {
|
||||
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
|
||||
});
|
||||
}
|
||||
} else if (allFaresProvided) {
|
||||
// seatFareMinor is in display currency — sum is already the display total
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
@@ -901,6 +910,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,
|
||||
@@ -1050,6 +1060,19 @@ export class BookingsService {
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
// For package bookings where per-seat fares weren't supplied, back-derive the
|
||||
// per-leg per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects
|
||||
// the actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
|
||||
if (dto.packageId) {
|
||||
const seatedCount = passengersData.filter(p => p.outboundSeatId).length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
|
||||
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (allRTFaresProvided && !dto.packageId) {
|
||||
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
@@ -1822,8 +1845,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 +1875,7 @@ export class BookingsService {
|
||||
fullName: p.passengerName,
|
||||
category: 'ADULT',
|
||||
leg: 1,
|
||||
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
|
||||
fareMinor: Math.round((pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor) / pkgBooking.passengerCount),
|
||||
verifaydaVerified: false,
|
||||
seat: null,
|
||||
})),
|
||||
|
||||
@@ -231,6 +231,14 @@ export class GuestBookingService {
|
||||
let resolvedTotalMinor: number;
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
// For package bookings, back-derive per-seat fareMinor from reviewedTotalMinor
|
||||
// so BookingSeat records store the actual berth price, not the tier minimum.
|
||||
if (isPackageOneway && seatedPassengers.length > 0) {
|
||||
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
|
||||
});
|
||||
}
|
||||
} else if (allFaresProvided) {
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
} else {
|
||||
@@ -291,6 +299,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,
|
||||
@@ -515,6 +524,17 @@ export class GuestBookingService {
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
// For package bookings, back-derive per-leg per-seat fareMinor from reviewedTotalMinor.
|
||||
if (isPackageRoundTrip) {
|
||||
const seatedCount = passengersData.filter(p => p.seatId).length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
|
||||
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Dashboard')
|
||||
@Controller('dashboard')
|
||||
@@ -10,7 +11,7 @@ export class DashboardController {
|
||||
constructor(private service: DashboardService) {}
|
||||
|
||||
@Get('backoffice-stats')
|
||||
@PassengerAdmin()
|
||||
@PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
|
||||
getBackofficeStats() { return this.service.getBackofficeStats(); }
|
||||
|
||||
@@ -11,12 +11,13 @@ export class DashboardService {
|
||||
) {}
|
||||
|
||||
async getBackofficeStats() {
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
|
||||
await Promise.all([
|
||||
this.prisma.booking.count(),
|
||||
this.prisma.booking.count({ where: { packageId: { not: null } } }),
|
||||
this.prisma.ticket.count(),
|
||||
this.prisma.passenger.count(),
|
||||
this.prisma.seat.count({ where: { status: 'BLOCKED' } }),
|
||||
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
||||
SELECT
|
||||
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
|
||||
@@ -56,6 +57,7 @@ export class DashboardService {
|
||||
totalPackageTickets,
|
||||
totalNormalTickets: totalTickets - totalPackageTickets,
|
||||
totalPassengers,
|
||||
blockedSeatsCount,
|
||||
revenueByCurrency: toMap(revenueRows),
|
||||
packageRevenueByCurrency: toMap(packageRevenueRows),
|
||||
};
|
||||
|
||||
@@ -444,8 +444,8 @@ export class PackagesService {
|
||||
passengerCount,
|
||||
adultCount,
|
||||
childCount,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
status: 'PENDING_PAYMENT',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -18,6 +18,12 @@ export class ReportsController {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get('passengers')
|
||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getOccupancyBySchedule(scheduleId);
|
||||
}
|
||||
|
||||
@Get(':reportId')
|
||||
@ApiOperation({ summary: 'Get report by ID' })
|
||||
getReport(@Param('reportId') reportId: string) {
|
||||
|
||||
@@ -191,6 +191,122 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getOccupancyBySchedule(scheduleId: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
train: true,
|
||||
coachAssignments: {
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
coachType: true,
|
||||
seats: { select: { id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
include: {
|
||||
seats: {
|
||||
include: {
|
||||
seat: { include: { coach: { include: { coachType: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0);
|
||||
const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats);
|
||||
const totalPassengers = allBookingSeats.length;
|
||||
const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
|
||||
// Per-coach breakdown
|
||||
const coachMap = new Map<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const c = assignment.coach;
|
||||
coachMap.set(c.id, {
|
||||
coachNumber: c.number,
|
||||
coachType: (c as any).coachType?.name ?? 'Unknown',
|
||||
totalSeats: c.seats.length,
|
||||
booked: 0,
|
||||
});
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const coachId = bs.seat?.coachId;
|
||||
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
|
||||
}
|
||||
const byCoach = [...coachMap.values()].map(c => ({
|
||||
...c,
|
||||
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
// Per-origin station breakdown (using booking's originStationId)
|
||||
const originMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.originStationId ?? schedule.originStationId;
|
||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
||||
?? (schedule as any).originStation?.name
|
||||
?? stationId;
|
||||
if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 });
|
||||
originMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
|
||||
// Per-destination station breakdown
|
||||
const destMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.destinationStationId ?? schedule.destinationStationId;
|
||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
||||
?? (schedule as any).destinationStation?.name
|
||||
?? stationId;
|
||||
if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 });
|
||||
destMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
|
||||
// Per-class breakdown
|
||||
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.booked++;
|
||||
}
|
||||
const byClass = [...classMap.values()].map(c => ({
|
||||
...c,
|
||||
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
schedule: {
|
||||
id: schedule.id,
|
||||
trainName: (schedule as any).train?.name ?? (schedule as any).train?.number,
|
||||
origin: (schedule as any).originStation?.name,
|
||||
destination: (schedule as any).destinationStation?.name,
|
||||
departureAt: schedule.departureAt,
|
||||
arrivalAt: schedule.arrivalAt,
|
||||
},
|
||||
summary: { totalSeats, totalPassengers, occupancyRate },
|
||||
byCoach,
|
||||
byClass,
|
||||
byOrigin,
|
||||
byDestination,
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
||||
}
|
||||
|
||||
@@ -54,11 +54,16 @@ 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)
|
||||
|
||||
@@ -535,8 +535,8 @@ export class SearchService {
|
||||
discountMinor: fare.discountMinor,
|
||||
taxesFeesMinor: 0,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
};
|
||||
@@ -653,8 +653,8 @@ export class SearchService {
|
||||
passengers: passengerLines,
|
||||
subtotalMinor,
|
||||
discountMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
};
|
||||
|
||||
@@ -1020,7 +1020,7 @@ export class SeatsService {
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId: schedule.id },
|
||||
{ scheduleId: null, booking: { scheduleId: schedule.id } },
|
||||
{ booking: { scheduleId: schedule.id } },
|
||||
],
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ export class TicketsController {
|
||||
@ApiQuery({ name: 'originStationId', required: false })
|
||||
@ApiQuery({ name: 'destinationStationId', required: false })
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'departureDate', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'coachId', required: false })
|
||||
@@ -64,6 +65,7 @@ export class TicketsController {
|
||||
@Query('originStationId') originStationId?: string,
|
||||
@Query('destinationStationId') destinationStationId?: string,
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('departureDate') departureDate?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('coachId') coachId?: string,
|
||||
@@ -76,6 +78,7 @@ export class TicketsController {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
coachId,
|
||||
|
||||
@@ -27,7 +27,7 @@ export class TicketsService {
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; departureDate?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
@@ -41,10 +41,16 @@ export class TicketsService {
|
||||
where.status = filters.status;
|
||||
}
|
||||
if (filters.originStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
|
||||
where.booking = { ...where.booking, originStationId: filters.originStationId };
|
||||
}
|
||||
if (filters.destinationStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
|
||||
where.booking = { ...where.booking, destinationStationId: filters.destinationStationId };
|
||||
}
|
||||
if (filters.departureDate) {
|
||||
const start = new Date(filters.departureDate);
|
||||
const end = new Date(filters.departureDate);
|
||||
end.setDate(end.getDate() + 1);
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, departureAt: { gte: start, lt: end } } };
|
||||
}
|
||||
if (filters.arrivalDate) {
|
||||
const start = new Date(filters.arrivalDate);
|
||||
@@ -70,7 +76,7 @@ export class TicketsService {
|
||||
include: {
|
||||
booking: {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
passenger: { include: { travelerProfiles: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
@@ -146,6 +152,18 @@ export class TicketsService {
|
||||
contactPhone: t.booking?.contactPhone,
|
||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||
seats: t.booking?.seats ?? [],
|
||||
originStation: (() => {
|
||||
const id = t.booking?.originStationId;
|
||||
if (!id) return t.booking?.schedule?.originStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
|
||||
})(),
|
||||
destinationStation: (() => {
|
||||
const id = t.booking?.destinationStationId;
|
||||
if (!id) return t.booking?.schedule?.destinationStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
|
||||
})(),
|
||||
},
|
||||
schedule: t.booking?.schedule,
|
||||
seat: t.seat ? {
|
||||
|
||||
Reference in New Issue
Block a user