mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 14:20:58 +00:00
Round trip booking and additional enhancements
This commit is contained in:
@@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10
|
||||
|
||||
GITHUB_PACKAGE_TOKEN=
|
||||
|
||||
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
|
||||
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
|
||||
RABBITMQ_ENABLED=false
|
||||
RABBITMQ_URL=amqp://localhost:5672
|
||||
EMAIL_QUEUE=email_queue
|
||||
SMS_QUEUE=sms_queue
|
||||
|
||||
# --- Payment event consumer (RabbitMQ) -------------------------------------------------------
|
||||
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
|
||||
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
|
||||
|
||||
-- AlterTable: add return leg tracking columns to Booking
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
|
||||
|
||||
-- Set NEITHER_USED for existing confirmed round-trip bookings
|
||||
UPDATE "passenger"."Booking"
|
||||
SET "returnLegStatus" = 'NEITHER_USED'
|
||||
WHERE "bookingType" = 'ROUND_TRIP'
|
||||
AND "status" IN ('CONFIRMED', 'COMPLETED');
|
||||
|
||||
-- AlterTable: add leg column to GateValidationLog
|
||||
ALTER TABLE "passenger"."GateValidationLog"
|
||||
ADD COLUMN "leg" TEXT;
|
||||
@@ -115,6 +115,16 @@ enum BookingStatus {
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
enum ReturnLegStatus {
|
||||
NOT_APPLICABLE // one-way booking
|
||||
BOTH_USED // passenger used both legs
|
||||
OUTBOUND_ONLY // return leg not used (no-show on return)
|
||||
INBOUND_ONLY // outbound leg not used, return leg used
|
||||
NEITHER_USED // neither leg boarded yet
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
enum PaymentRegion {
|
||||
ETHIOPIA
|
||||
DJIBOUTI
|
||||
@@ -364,7 +374,8 @@ model TrainSchedule {
|
||||
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
|
||||
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
|
||||
coachAssignments CoachAssignment[]
|
||||
bookings Booking[]
|
||||
bookings Booking[] @relation("OutboundSchedule")
|
||||
returnBookings Booking[] @relation("ReturnSchedule")
|
||||
stopTimes TripStopTime[]
|
||||
liveStatus TripLiveStatus?
|
||||
menuItems MenuItem[]
|
||||
@@ -511,6 +522,9 @@ model Booking {
|
||||
returnDestinationStationId String?
|
||||
returnHoldId String?
|
||||
returnSeatClassId String?
|
||||
returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE)
|
||||
outboundBoardedAt DateTime?
|
||||
returnBoardedAt DateTime?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
userAgent String?
|
||||
@@ -520,7 +534,8 @@ model Booking {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
|
||||
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
|
||||
seats BookingSeat[]
|
||||
paymentIntent PaymentIntent?
|
||||
ticket Ticket?
|
||||
@@ -1159,6 +1174,7 @@ model GateValidationLog {
|
||||
ticketId String
|
||||
validatorId String
|
||||
gateId String?
|
||||
leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets
|
||||
status String
|
||||
reason String?
|
||||
validatedAt DateTime @default(now())
|
||||
|
||||
@@ -75,7 +75,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
|
||||
PaymentsModule,
|
||||
TicketsModule,
|
||||
PassengersModule,
|
||||
NotificationsModule,
|
||||
NotificationsModule.register(),
|
||||
LoyaltyModule,
|
||||
WalletModule,
|
||||
PromosModule,
|
||||
|
||||
@@ -34,19 +34,23 @@ async function bootstrap() {
|
||||
## Overview
|
||||
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
|
||||
|
||||
## 🆕 Latest Updates
|
||||
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
|
||||
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
|
||||
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
|
||||
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
|
||||
- **Multi-Currency Display:** Bookings track display currency and converted amounts
|
||||
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
|
||||
## Latest Updates
|
||||
- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND or RETURN).
|
||||
- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned.
|
||||
- **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch.
|
||||
- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office.
|
||||
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display.
|
||||
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles.
|
||||
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing.
|
||||
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories.
|
||||
- **Multi-Currency Display:** Bookings track display currency and converted amounts.
|
||||
- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🎫 Booking Lifecycle
|
||||
### Booking Lifecycle
|
||||
- Search trips with real-time availability
|
||||
- Age-based passenger categorization (Adult ≥5 years, Child <5 years)
|
||||
- Age-based passenger categorization (Adult 5+ years, Child under 5)
|
||||
- Nationality-based verification (Ethiopian Fayda, International Passport)
|
||||
- Passenger information collection with verification
|
||||
- Coach and seat selection with real-time availability
|
||||
@@ -55,130 +59,129 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
||||
- Modify bookings (seat changes, passenger updates)
|
||||
- Cancel bookings with automatic refunds
|
||||
- Multi-segment journey support
|
||||
- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti)
|
||||
- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti)
|
||||
- Round-trip booking with return journey scheduling
|
||||
- Coach type selection with seat class and pricing options
|
||||
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
|
||||
- **NEW:** Display currency and converted pricing per booking
|
||||
- NEW: Booking type tracking (ONE_WAY vs ROUND_TRIP)
|
||||
- NEW: Display currency and converted pricing per booking
|
||||
- NEW: returnLegStatus field tracks which legs of a round-trip were used
|
||||
- NEW: GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
|
||||
|
||||
### 👤 Passenger Verification
|
||||
1. **Ethiopian Nationals:**
|
||||
- Automatic Fayda verification for adults (≥5 years)
|
||||
### Passenger Verification
|
||||
1. Ethiopian Nationals:
|
||||
- Automatic Fayda verification for adults (5+ years)
|
||||
- Real-time national ID verification via government database
|
||||
- Retrieves verified passenger data (name, DOB, gender)
|
||||
- National IDs not stored (policy compliant)
|
||||
|
||||
2. **International Passengers:**
|
||||
2. International Passengers:
|
||||
- Passport information collection
|
||||
- Manual verification for Djiboutian and other nationals
|
||||
- No government database verification required
|
||||
|
||||
### 💰 Age-Based Pricing
|
||||
- **ADULT** (≥5 years): Pay 100% of base fare
|
||||
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
|
||||
### Age-Based Pricing
|
||||
- ADULT (5+ years): Pay 100% of base fare
|
||||
- CHILD (under 5): First child travels FREE, subsequent children pay 100%
|
||||
- Automatic age calculation from date of birth
|
||||
- Example: 2 adults + 3 children = 4× base fare (first child free)
|
||||
- **NEW:** Premium charges and insurance fees per seat class
|
||||
- **NEW:** Transparent fee breakdown in pricing calculations
|
||||
- Example: 2 adults + 3 children = 4x base fare (first child free)
|
||||
- NEW: Premium charges and insurance fees per seat class
|
||||
- NEW: Transparent fee breakdown in pricing calculations
|
||||
|
||||
### 💳 Payment Integration
|
||||
1. **Ethiopian Payment Methods:**
|
||||
- **Telebirr** - Ethiopia's leading mobile money
|
||||
- **CBE Birr** - Commercial Bank of Ethiopia
|
||||
### Payment Integration
|
||||
1. Ethiopian Payment Methods: Telebirr, CBE Birr
|
||||
2. Djiboutian Payment Methods: Waafi
|
||||
3. International Payment Methods: Card, Wallet
|
||||
|
||||
2. **Djiboutian Payment Methods:**
|
||||
- **Waafi** - Djibouti's mobile money service
|
||||
|
||||
3. **International Payment Methods:**
|
||||
- **Card** - International card payments (Visa, Mastercard)
|
||||
- **Wallet** - Internal wallet system
|
||||
|
||||
### 🪑 Seat Management
|
||||
### Seat Management
|
||||
- Real-time seat availability by coach and class
|
||||
- Seat holds with 15-minute expiry
|
||||
- Auto-assign seats with contiguous algorithm
|
||||
- Seat blocking for maintenance
|
||||
- Coach-level seat maps (ordered by sequence)
|
||||
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
|
||||
- **NEW:** Sequence-based coach ordering for consistent display
|
||||
- NEW: Sequence-based coach ordering for consistent display
|
||||
|
||||
### 🎟️ Ticketing
|
||||
### Ticketing
|
||||
- QR code and barcode generation
|
||||
- PDF ticket generation
|
||||
- Gate validation with audit logs
|
||||
- Offline validation support
|
||||
- Multi-passenger tickets
|
||||
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
|
||||
- **NEW:** Complete audit trail for compliance and reporting
|
||||
- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
|
||||
- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
|
||||
- NEW: Complete audit trail per leg for compliance and reporting
|
||||
|
||||
### 🏆 Loyalty Program
|
||||
- 4 tiers: Bronze, Silver, Gold, Platinum
|
||||
- Points accumulation on trips
|
||||
- Reward redemption
|
||||
- Tier-based benefits
|
||||
### Round-Trip Leg Tracking (NEW)
|
||||
- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED
|
||||
- Gate validation POST /tickets/:ref/validate accepts optional leg field (OUTBOUND or RETURN)
|
||||
- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned
|
||||
- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows
|
||||
- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking
|
||||
|
||||
### 💰 Wallet System
|
||||
- Top-up via payment methods
|
||||
- Pay with wallet balance
|
||||
- Transaction ledger
|
||||
- Refund to wallet
|
||||
|
||||
### 📍 Live Tracking
|
||||
- Real-time trip status
|
||||
- Location updates
|
||||
- Delay notifications
|
||||
- Station crowd signals
|
||||
|
||||
### 🔒 Fraud Detection
|
||||
- Velocity checks (multiple bookings)
|
||||
- High-value transaction monitoring
|
||||
- Failed payment pattern detection
|
||||
- Automatic user blocking
|
||||
|
||||
### 👤 Passenger Profiles
|
||||
- Comprehensive profile data: gender, date of birth, nationality
|
||||
- National ID for Ethiopian citizens (Fayda verified)
|
||||
- Passport information for international passengers
|
||||
- **NEW:** Complete demographic data for personalized services
|
||||
- **NEW:** Improved user targeting and communications
|
||||
|
||||
### 🌍 Internationalization
|
||||
- Multi-language support (English, Amharic, French, Oromo)
|
||||
- Locale-based responses
|
||||
- Currency formatting (ETB, DJF, USD)
|
||||
- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
|
||||
|
||||
### 🚌 Transit Stop Management
|
||||
- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
|
||||
- Dire Dawa as mandatory transit hub for international journeys
|
||||
- Dual-leg fare calculation (domestic + international)
|
||||
- Age-based pricing applied independently per leg
|
||||
- Seamless multi-segment booking workflow
|
||||
- Transit stop optimization and route planning
|
||||
|
||||
### 🔄 Round-Trip Booking
|
||||
### Round-Trip Booking
|
||||
- One-way and round-trip journey options
|
||||
- Flexible return date selection
|
||||
- Combined pricing for outbound + return legs
|
||||
- Separate seat management per leg
|
||||
- Independent modification/cancellation per leg
|
||||
- Return journey tracking and notifications
|
||||
- **NEW:** Booking type stored for analytics and reporting
|
||||
- NEW: Booking type stored for analytics and reporting
|
||||
|
||||
### 🚐 Coach Type & Class Selection
|
||||
- Browse available coach types per route (standard coaches, premium coaches)
|
||||
### Loyalty Program
|
||||
- 4 tiers: Bronze, Silver, Gold, Platinum
|
||||
- Points accumulation on trips
|
||||
- Reward redemption
|
||||
- Tier-based benefits
|
||||
|
||||
### Wallet System
|
||||
- Top-up via payment methods
|
||||
- Pay with wallet balance
|
||||
- Transaction ledger
|
||||
- Refund to wallet
|
||||
|
||||
### Live Tracking
|
||||
- Real-time trip status
|
||||
- Location updates
|
||||
- Delay notifications
|
||||
- Station crowd signals
|
||||
|
||||
### Fraud Detection
|
||||
- Velocity checks (multiple bookings)
|
||||
- High-value transaction monitoring
|
||||
- Failed payment pattern detection
|
||||
- Automatic user blocking
|
||||
|
||||
### Passenger Profiles
|
||||
- Comprehensive profile data: gender, date of birth, nationality
|
||||
- National ID for Ethiopian citizens (Fayda verified)
|
||||
- Passport information for international passengers
|
||||
- NEW: Complete demographic data for personalized services
|
||||
|
||||
### Internationalization
|
||||
- Multi-language support (English, Amharic, French, Oromo)
|
||||
- Locale-based responses
|
||||
- Currency formatting (ETB, DJF, USD)
|
||||
- NEW: Multi-currency display per booking (ETB, DJF, USD)
|
||||
|
||||
### Transit Stop Management
|
||||
- Automatic detection of cross-border journeys (Ethiopia to Djibouti)
|
||||
- Dire Dawa as mandatory transit hub for international journeys
|
||||
- Dual-leg fare calculation (domestic + international)
|
||||
- Age-based pricing applied independently per leg
|
||||
- Seamless multi-segment booking workflow
|
||||
|
||||
### Coach Type & Class Selection
|
||||
- Browse available coach types per route
|
||||
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
|
||||
- Compare base prices by coach type and class
|
||||
- Real-time availability per coach configuration
|
||||
- Deferred pricing at seat selection stage
|
||||
- Coach amenities and features display
|
||||
- **NEW:** Sequence-based coach ordering for consistent UI
|
||||
- **NEW:** Premium and insurance fee transparency per class
|
||||
- NEW: Sequence-based coach ordering for consistent UI
|
||||
- NEW: Premium and insurance fee transparency per class
|
||||
|
||||
### 📊 Data Organization
|
||||
- **Stations:** Ordered by sequence (1-15) for consistent route display
|
||||
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
|
||||
- **Booking History:** Sorted chronologically with filtering options
|
||||
### Data Organization
|
||||
- Stations ordered by sequence (1-15) for consistent route display
|
||||
- Coaches ordered by sequence (1+) per type for predictable configuration
|
||||
- Booking history sorted chronologically with filtering options
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -202,7 +205,7 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
|
||||
|
||||
### Step 3: Passenger Information & Verification
|
||||
**For Ethiopian Passengers:**
|
||||
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (≥5 years)
|
||||
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5+ years)
|
||||
|
||||
**For International Passengers:**
|
||||
\`POST /passengers/register-international\` - Passport information collection
|
||||
@@ -265,7 +268,7 @@ Payment providers send notifications to:
|
||||
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
|
||||
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
|
||||
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports returnLegStatus filter for round-trip no-show management")
|
||||
.addTag("Config", "System settings, feature flags, and configuration management")
|
||||
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
|
||||
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
|
||||
@@ -291,7 +294,7 @@ Payment providers send notifications to:
|
||||
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
|
||||
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
|
||||
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
|
||||
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
|
||||
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN), and audit trails")
|
||||
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
|
||||
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
|
||||
//.addServer('http://localhost:4000', 'Development')
|
||||
|
||||
@@ -75,21 +75,24 @@ export class BookingsController {
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all bookings with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of bookings with search and status filters'
|
||||
description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('returnLegStatus') returnLegStatus?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
status,
|
||||
status,
|
||||
returnLegStatus,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ function calculateAge(dateOfBirth: Date): number {
|
||||
interface BookingFilters {
|
||||
search?: string;
|
||||
status?: string;
|
||||
returnLegStatus?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -82,6 +83,8 @@ export class BookingsService {
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
@@ -159,6 +162,8 @@ export class BookingsService {
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
@@ -180,7 +185,7 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
@@ -194,9 +199,8 @@ export class BookingsService {
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
if (status) where.status = status;
|
||||
if (returnLegStatus) where.returnLegStatus = returnLegStatus;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
@@ -225,6 +229,8 @@ export class BookingsService {
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
contactEmail: booking.contactEmail,
|
||||
contactPhone: booking.contactPhone,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
passenger: booking.passenger?.user,
|
||||
schedule: {
|
||||
@@ -391,6 +397,7 @@ export class BookingsService {
|
||||
returnDestinationStationId: dto.returnDestinationStationId,
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId: dto.returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
seats: {
|
||||
create: passengersData.map(p => ({
|
||||
seat: { connect: { id: p.outboundSeatId } },
|
||||
@@ -406,7 +413,7 @@ export class BookingsService {
|
||||
displayCurrency
|
||||
}))
|
||||
}
|
||||
},
|
||||
} as any,
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||
});
|
||||
|
||||
@@ -646,7 +653,11 @@ export class BookingsService {
|
||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
|
||||
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
|
||||
bookingType: booking.bookingType, createdAt: booking.createdAt,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
|
||||
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
number: booking.schedule.train.number,
|
||||
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
|
||||
@@ -751,6 +762,37 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark round-trip bookings where the return train has departed but the return leg
|
||||
// was never scanned. Runs every minute; only acts on CONFIRMED bookings whose
|
||||
// returnSchedule.departureAt is in the past and returnBoardedAt is still null.
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async markReturnLegNoShows() {
|
||||
const now = new Date();
|
||||
const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000);
|
||||
|
||||
const candidates = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
bookingType: 'ROUND_TRIP',
|
||||
status: 'CONFIRMED',
|
||||
returnLegStatus: 'NEITHER_USED' as any,
|
||||
outboundBoardedAt: { not: null },
|
||||
returnBoardedAt: null,
|
||||
returnScheduleId: { not: null },
|
||||
},
|
||||
include: { returnSchedule: { select: { departureAt: true } } },
|
||||
} as any);
|
||||
|
||||
for (const b of candidates) {
|
||||
const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt;
|
||||
if (returnDep && returnDep < graceCutoff) {
|
||||
await this.prisma.booking.update({
|
||||
where: { id: b.id },
|
||||
data: { returnLegStatus: 'OUTBOUND_ONLY' } as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private selectBestFareRule(
|
||||
candidates: any[],
|
||||
scheduleId: string,
|
||||
|
||||
@@ -11,7 +11,10 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
private readonly emailServiceClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.enabled) return;
|
||||
this.emailServiceClient
|
||||
.connect()
|
||||
.then(() => this.logger.log('Connected to Email service'))
|
||||
@@ -19,10 +22,8 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async sendEmail(dto: SendEmail) {
|
||||
this.emailServiceClient.emit('send-email', {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
});
|
||||
if (!this.enabled) return {};
|
||||
this.emailServiceClient.emit('send-email', { ...dto, appKey: 'EDR-PASSENGER-API' });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||
@@ -8,49 +8,64 @@ import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ClientsModule.registerAsync([
|
||||
{
|
||||
name: 'EMAIL_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
}),
|
||||
const rmqClientsModule = ClientsModule.registerAsync([
|
||||
{
|
||||
name: 'EMAIL_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
{
|
||||
name: 'SMS_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'SMS_SERVICE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
|
||||
queueOptions: { durable: true },
|
||||
noAck: true,
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
EmailAdapter,
|
||||
SmsAdapter,
|
||||
PushAdapter,
|
||||
EmailClientService,
|
||||
SmsClientService,
|
||||
],
|
||||
exports: [NotificationsService, EmailClientService, SmsClientService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
@Module({})
|
||||
export class NotificationsModule {
|
||||
static register(): DynamicModule {
|
||||
const rmqEnabled = process.env.RABBITMQ_ENABLED !== 'false';
|
||||
|
||||
return {
|
||||
module: NotificationsModule,
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
...(rmqEnabled ? [rmqClientsModule] : []),
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
EmailAdapter,
|
||||
SmsAdapter,
|
||||
PushAdapter,
|
||||
...(rmqEnabled
|
||||
? [EmailClientService, SmsClientService]
|
||||
: [
|
||||
{ provide: 'EMAIL_SERVICE', useValue: null },
|
||||
{ provide: 'SMS_SERVICE', useValue: null },
|
||||
EmailClientService,
|
||||
SmsClientService,
|
||||
]),
|
||||
],
|
||||
exports: [NotificationsService, EmailClientService, SmsClientService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
private readonly smsClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.enabled) return;
|
||||
this.smsClient
|
||||
.connect()
|
||||
.then(() => this.logger.log('Connected to SMS service'))
|
||||
@@ -19,18 +22,14 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async sendSms(dto: SendMessage) {
|
||||
this.smsClient.emit('send-sms', {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
});
|
||||
if (!this.enabled) return {};
|
||||
this.smsClient.emit('send-sms', { ...dto, appKey: 'EDR-PASSENGER-API' });
|
||||
return {};
|
||||
}
|
||||
|
||||
async sendBulkMessages(dto: BulkMessagesDto) {
|
||||
this.smsClient.emit('ozeking-bulk-sms', {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
});
|
||||
if (!this.enabled) return {};
|
||||
this.smsClient.emit('ozeking-bulk-sms', { ...dto, appKey: 'EDR-PASSENGER-API' });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto {
|
||||
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
coachIds?: string[];
|
||||
}
|
||||
|
||||
export class BulkSchedulesResponseDto {
|
||||
|
||||
@@ -44,6 +44,15 @@ export class SchedulesService {
|
||||
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
|
||||
// Assign coaches if provided
|
||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
|
||||
);
|
||||
}
|
||||
|
||||
scheduleCount++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => {
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@@ -30,6 +30,10 @@ export class TicketsController {
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@@ -77,14 +81,26 @@ export class TicketsController {
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
|
||||
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['validatorId'],
|
||||
properties: {
|
||||
validatorId: { type: 'string', example: 'agent-uuid' },
|
||||
gateId: { type: 'string', example: 'gate-01' },
|
||||
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'], description: 'Required for round-trip bookings' },
|
||||
},
|
||||
},
|
||||
})
|
||||
validate(
|
||||
@Param('bookingRef') ref: string,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string
|
||||
@Body('gateId') gateId?: string,
|
||||
@Body('leg') leg?: 'OUTBOUND' | 'RETURN',
|
||||
) {
|
||||
return this.service.validate(ref, validatorId, gateId);
|
||||
return this.service.validate(ref, validatorId, gateId, leg);
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@@ -106,7 +122,31 @@ export class TicketsController {
|
||||
@Post('validate/offline')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Batch import offline validations' })
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
validations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['bookingRef', 'validatorId', 'validatedAt'],
|
||||
properties: {
|
||||
bookingRef: { type: 'string' },
|
||||
validatorId: { type: 'string' },
|
||||
gateId: { type: 'string' },
|
||||
validatedAt: { type: 'string', format: 'date-time' },
|
||||
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
validateOfflineBatch(@Body() body: { validations: any[] }) {
|
||||
return this.service.validateOfflineBatch(body.validations);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface OfflineValidation {
|
||||
validatorId: string;
|
||||
gateId?: string;
|
||||
validatedAt: string;
|
||||
leg?: 'OUTBOUND' | 'RETURN';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -49,6 +50,10 @@ export class TicketsService {
|
||||
booking: {
|
||||
bookingRef: t.booking.bookingRef,
|
||||
status: t.booking.status,
|
||||
bookingType: t.booking.bookingType,
|
||||
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
|
||||
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
|
||||
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
|
||||
totalMinor: t.booking.totalMinor,
|
||||
currency: t.booking.currency,
|
||||
displayCurrency: t.booking.displayCurrency,
|
||||
@@ -213,22 +218,67 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async validate(bookingRef: string, validatorId: string, gateId?: string) {
|
||||
async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: 'OUTBOUND' | 'RETURN') {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
if (ticket.validatedAt) {
|
||||
|
||||
const isRoundTrip = booking.bookingType === 'ROUND_TRIP';
|
||||
// For one-way bookings use the original single-validation guard
|
||||
if (!isRoundTrip) {
|
||||
if (ticket.validatedAt) {
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' },
|
||||
});
|
||||
throw new BadRequestException('Ticket already validated');
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' },
|
||||
});
|
||||
throw new BadRequestException('Ticket already validated');
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
|
||||
|
||||
// Round-trip: track which leg is being boarded
|
||||
const resolvedLeg = leg ?? 'OUTBOUND';
|
||||
const now = new Date();
|
||||
const bookingData: Record<string, any> = {};
|
||||
|
||||
if (resolvedLeg === 'OUTBOUND') {
|
||||
if ((booking as any).outboundBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any,
|
||||
});
|
||||
throw new BadRequestException('Outbound leg already used');
|
||||
}
|
||||
bookingData.outboundBoardedAt = now;
|
||||
// Stamp the ticket's first validation
|
||||
if (!ticket.validatedAt) {
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
|
||||
}
|
||||
} else {
|
||||
if ((booking as any).returnBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any,
|
||||
});
|
||||
throw new BadRequestException('Return leg already used');
|
||||
}
|
||||
bookingData.returnBoardedAt = now;
|
||||
}
|
||||
|
||||
// Derive the new composite status
|
||||
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
|
||||
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
|
||||
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; // return pending/no-show
|
||||
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
|
||||
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any,
|
||||
});
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
async getValidationLogs(ticketId: string) {
|
||||
@@ -256,6 +306,8 @@ export class TicketsService {
|
||||
coachLabel: b.seats[0]?.seat.coach.number,
|
||||
qrPayload: b.ticket?.qrPayload,
|
||||
status: b.status,
|
||||
bookingType: b.bookingType,
|
||||
returnLegStatus: (b as any).returnLegStatus ?? null,
|
||||
validatedAt: b.ticket?.validatedAt,
|
||||
}));
|
||||
}
|
||||
@@ -265,11 +317,13 @@ export class TicketsService {
|
||||
const processedRefs = new Set<string>();
|
||||
|
||||
for (const v of validations) {
|
||||
if (processedRefs.has(v.bookingRef)) {
|
||||
const offlineLeg = v.leg;
|
||||
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
|
||||
if (processedRefs.has(dedupKey)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
processedRefs.add(v.bookingRef);
|
||||
processedRefs.add(dedupKey);
|
||||
|
||||
try {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
|
||||
@@ -286,11 +340,21 @@ export class TicketsService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ticket.validatedAt) {
|
||||
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP') {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For round-trip, check per-leg duplication
|
||||
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
|
||||
const alreadyUsed =
|
||||
offlineLeg === 'OUTBOUND' ? !!(booking as any).outboundBoardedAt : !!(booking as any).returnBoardedAt;
|
||||
if (alreadyUsed) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
|
||||
@@ -301,11 +365,24 @@ export class TicketsService {
|
||||
ticketId: ticket.id,
|
||||
validatorId: v.validatorId,
|
||||
gateId: v.gateId,
|
||||
leg: v.leg ?? null,
|
||||
status: 'APPROVED',
|
||||
validatedAt: new Date(v.validatedAt),
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
// update returnLegStatus for round-trip offline validations
|
||||
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
|
||||
const bookingData: Record<string, any> =
|
||||
offlineLeg === 'OUTBOUND' ? { outboundBoardedAt: new Date(v.validatedAt) } : { returnBoardedAt: new Date(v.validatedAt) };
|
||||
const outboundUsed = offlineLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
|
||||
const returnUsed = offlineLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
|
||||
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
}
|
||||
|
||||
results.success++;
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function BookingsPage() {
|
||||
},
|
||||
{
|
||||
key: 'bookingType',
|
||||
label: 'Class',
|
||||
label: 'Type',
|
||||
sortable: true,
|
||||
render: (booking: any) => booking.bookingType || 'ONE_WAY',
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function CoachesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Coach Types Queries
|
||||
@@ -252,12 +252,17 @@ export default function CoachesPage() {
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.item?.isCoachType) {
|
||||
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
|
||||
} else {
|
||||
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
|
||||
try {
|
||||
if (deleteConfirm.item?.isCoachType) {
|
||||
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
|
||||
} else {
|
||||
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, item: null });
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || 'Delete failed';
|
||||
setDeleteConfirm((prev) => ({ ...prev, error: msg }));
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, item: null });
|
||||
};
|
||||
|
||||
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
|
||||
@@ -536,6 +541,8 @@ export default function CoachesPage() {
|
||||
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
|
||||
confirmText="Delete"
|
||||
isDanger={true}
|
||||
isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
|
||||
error={deleteConfirm.error}
|
||||
warning={
|
||||
deleteConfirm.item?.isCoachType
|
||||
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
|
||||
|
||||
@@ -362,12 +362,16 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="code"
|
||||
className="input"
|
||||
value={generateRouteCode(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
defaultValue={editingRoute ? editingRoute.code : undefined}
|
||||
key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`}
|
||||
placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'}
|
||||
required
|
||||
placeholder="Select stations to generate"
|
||||
disabled={!!editingRoute}
|
||||
/>
|
||||
{!editingRoute && originStationId && destinationStationId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=code]') as HTMLInputElement); if (inp) inp.value = generateRouteCode(originStationId, destinationStationId); }}>{generateRouteCode(originStationId, destinationStationId)}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Route Name *</label>
|
||||
@@ -375,11 +379,16 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="name"
|
||||
className="input"
|
||||
value={generateRouteName(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
defaultValue={editingRoute ? editingRoute.name : undefined}
|
||||
key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`}
|
||||
placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'}
|
||||
required
|
||||
placeholder="Select stations to generate"
|
||||
/>
|
||||
{!editingRoute && originStationId && destinationStationId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=name]') as HTMLInputElement); if (inp) inp.value = generateRouteName(originStationId, destinationStationId); }}>{generateRouteName(originStationId, destinationStationId)}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -318,8 +318,8 @@ export default function SchedulesPage() {
|
||||
label: 'Train',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => (
|
||||
<div className="font-medium">
|
||||
{schedule.train?.name} ({schedule.train?.number})
|
||||
<div className="font-medium font-mono">
|
||||
{schedule.train?.number}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -548,8 +548,11 @@ export default function SeatsPage() {
|
||||
|
||||
{coachesWithSeats.map((coach: any, index: number) => {
|
||||
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
|
||||
const coachTypeName = coachData?.coachType?.type || 'Coach';
|
||||
const isBedCoach = coachTypeName.toLowerCase().includes('bed');
|
||||
const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach';
|
||||
const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || '';
|
||||
const isBedCoach = seatClassName.toLowerCase().includes('bed') ||
|
||||
coachTypeName.toLowerCase().includes('bed') ||
|
||||
(coach.seats || []).some((s: any) => s.bedPosition);
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
const isExpanded = expandedCoaches.has(coach.id);
|
||||
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
|
||||
|
||||
@@ -204,15 +204,31 @@ export default function TicketsPage() {
|
||||
key: 'boarded',
|
||||
label: 'Boarded',
|
||||
render: (ticket: any) => (
|
||||
ticket.boardedAt ? (
|
||||
ticket.validatedAt ? (
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<span className="text-sm">{formatDateTime(ticket.boardedAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Not boarded</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'returnLegStatus',
|
||||
label: 'Return Leg',
|
||||
render: (ticket: any) => {
|
||||
const status = ticket.booking?.returnLegStatus;
|
||||
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
|
||||
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
|
||||
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
|
||||
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
|
||||
};
|
||||
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
|
||||
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
@@ -444,10 +460,30 @@ export default function TicketsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTicket.boardedAt && (
|
||||
{selectedTicket.validatedAt && (
|
||||
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Boarded At</p>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p>
|
||||
<p className="text-sm text-muted-foreground">Validated At</p>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Leg Status</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Return Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ConfirmDialogProps {
|
||||
isLoading?: boolean;
|
||||
isDanger?: boolean;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
@@ -28,6 +29,7 @@ export default function ConfirmDialog({
|
||||
isLoading = false,
|
||||
isDanger = false,
|
||||
warning,
|
||||
error,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
@@ -47,6 +49,12 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 flex gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-800 dark:text-red-300 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
|
||||
{cancelText}
|
||||
|
||||
Reference in New Issue
Block a user