mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
Merge branch 'alpha' into passenger/feat/iam
This commit is contained in:
@@ -216,6 +216,7 @@ export class BookingsService {
|
||||
...(matchedPassengers.length > 0
|
||||
? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }]
|
||||
: []),
|
||||
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -268,6 +269,7 @@ export class BookingsService {
|
||||
passenger: iam
|
||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||
: null,
|
||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
@@ -294,10 +296,23 @@ export class BookingsService {
|
||||
return this.createOneWayBooking(dto);
|
||||
}
|
||||
|
||||
private validateSeatIdsAgainstHold(holdId: string, holdSeatIds: string[], requestedSeatIds: string[]) {
|
||||
for (const seatId of requestedSeatIds) {
|
||||
if (!holdSeatIds.includes(seatId)) {
|
||||
throw new BadRequestException(
|
||||
`Seat ${seatId} is not part of hold ${holdId}. Use seat IDs returned from POST /seats/hold.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createOneWayBooking(dto: CreateBookingDto) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
||||
|
||||
|
||||
const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId);
|
||||
this.validateSeatIdsAgainstHold(dto.holdId, hold.seatIds, requestedSeatIds);
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||
@@ -367,6 +382,11 @@ export class BookingsService {
|
||||
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired');
|
||||
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
|
||||
|
||||
const holdObSeatIds = (dto.passengers as any[]).map((p: any) => p.seatId ?? p.outboundSeatId).filter(Boolean);
|
||||
const holdRetSeatIds = (dto.passengers as any[]).map((p: any) => p.returnSeatId).filter(Boolean);
|
||||
if (holdObSeatIds.length) this.validateSeatIdsAgainstHold(dto.holdId, outboundHold.seatIds, holdObSeatIds);
|
||||
if (holdRetSeatIds.length) this.validateSeatIdsAgainstHold(dto.returnHoldId!, returnHold.seatIds, holdRetSeatIds);
|
||||
|
||||
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
@@ -510,6 +530,11 @@ export class BookingsService {
|
||||
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired');
|
||||
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
|
||||
|
||||
const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId);
|
||||
const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId);
|
||||
this.validateSeatIdsAgainstHold(dto.holdId, leg1Hold.seatIds, leg1SeatIds);
|
||||
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, leg2Hold.seatIds, leg2SeatIds);
|
||||
|
||||
const [leg1Schedule, leg2Schedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
@@ -656,6 +681,11 @@ export class BookingsService {
|
||||
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired');
|
||||
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired');
|
||||
|
||||
this.validateSeatIdsAgainstHold(dto.holdId, obL1Hold.seatIds, (dto.passengers as any[]).map(p => p.seatId));
|
||||
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, obL2Hold.seatIds, (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId));
|
||||
this.validateSeatIdsAgainstHold(dto.returnHoldId!, retL1Hold.seatIds, (dto.passengers as any[]).map(p => p.returnSeatId));
|
||||
this.validateSeatIdsAgainstHold(dto.returnLeg2HoldId!, retL2Hold.seatIds, (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId));
|
||||
|
||||
// Load all 4 schedules
|
||||
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
@@ -847,7 +877,21 @@ export class BookingsService {
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
}
|
||||
|
||||
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
processedPassengers.push({
|
||||
...passenger,
|
||||
passengerName,
|
||||
dateOfBirth,
|
||||
category,
|
||||
verifaydaVerified,
|
||||
verifaydaData,
|
||||
nationality,
|
||||
// Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses
|
||||
// outboundSeatId/returnSeatId. Accept either form so both DTOs work.
|
||||
outboundSeatId: passenger.outboundSeatId ?? passenger.seatId,
|
||||
outboundLeg2SeatId: passenger.outboundLeg2SeatId ?? passenger.leg2SeatId,
|
||||
returnSeatId: passenger.returnSeatId,
|
||||
returnLeg2SeatId: passenger.returnLeg2SeatId,
|
||||
});
|
||||
}
|
||||
return processedPassengers;
|
||||
}
|
||||
@@ -1042,7 +1086,7 @@ export class BookingsService {
|
||||
await this.prisma.bookingModification.create({
|
||||
data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason },
|
||||
});
|
||||
await this.seatsService.releaseSeats(oldSeats);
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
await this.seatsService.confirmSeats(dto.newSeatIds);
|
||||
return { modified: true, bookingRef: dto.bookingRef };
|
||||
}
|
||||
@@ -1053,7 +1097,7 @@ export class BookingsService {
|
||||
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
|
||||
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
|
||||
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
|
||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
|
||||
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
|
||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
|
||||
@@ -1082,7 +1126,7 @@ export class BookingsService {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
|
||||
await this.prisma.booking.delete({ where: { id } });
|
||||
@@ -1118,7 +1162,7 @@ export class BookingsService {
|
||||
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
|
||||
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
|
||||
for (const b of expired) {
|
||||
await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId));
|
||||
await this.seatsService.releaseSeats(b.id);
|
||||
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,21 @@ function generateRef(): string {
|
||||
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
}
|
||||
|
||||
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)
|
||||
const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964'];
|
||||
|
||||
function generateEthiopianPhone(): string {
|
||||
const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)];
|
||||
const suffix = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
|
||||
return `+251${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
function generateGuestEmail(uniqueId: string): string {
|
||||
const domains = ['gmail.com', 'yahoo.com', 'ethionet.et', 'telecom.et'];
|
||||
const domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
return `guest.edr.${uniqueId}@${domain}`;
|
||||
}
|
||||
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
|
||||
@@ -189,11 +189,11 @@ export class PassengersService {
|
||||
|
||||
async getStats(passengerId: string) {
|
||||
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
|
||||
this.prisma.booking.count({ where: { passengerId, status: 'COMPLETED' } }),
|
||||
this.prisma.booking.aggregate({ where: { passengerId, status: 'COMPLETED' }, _sum: { totalMinor: true } }),
|
||||
this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }),
|
||||
this.prisma.booking.aggregate({ where: { passengerId, status: 'BOARDED' as any }, _sum: { totalMinor: true } }),
|
||||
this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }),
|
||||
]);
|
||||
const totalSpend = (totalSpendResult._sum.totalMinor ?? 0) / 100;
|
||||
const totalSpend = ((totalSpendResult._sum?.totalMinor ?? 0) as number) / 100;
|
||||
return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 };
|
||||
}
|
||||
|
||||
|
||||
@@ -447,7 +447,7 @@ export class PaymentsService {
|
||||
include: { seats: true },
|
||||
});
|
||||
if (booking) {
|
||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
await this.prisma.booking.update({
|
||||
where: { id: dto.bookingId },
|
||||
data: { status: "CANCELLED" },
|
||||
@@ -746,51 +746,125 @@ export class PaymentsService {
|
||||
private async createJourneySegments(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: booking.scheduleId },
|
||||
include: {
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!schedule) return;
|
||||
const b = booking as any;
|
||||
|
||||
const stopTimes = schedule.stopTimes;
|
||||
if (stopTimes.length < 2) return;
|
||||
// Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] }
|
||||
// BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2
|
||||
type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] };
|
||||
const legDefs: LegDef[] = [];
|
||||
|
||||
const originSequence = stopTimes.findIndex(
|
||||
(st) => st.stationId === schedule.originStationId,
|
||||
);
|
||||
const destSequence = stopTimes.findIndex(
|
||||
(st) => st.stationId === schedule.destinationStationId,
|
||||
);
|
||||
const seatsForLeg = (legNum: number) =>
|
||||
booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId);
|
||||
|
||||
if (
|
||||
originSequence < 0 ||
|
||||
destSequence < 0 ||
|
||||
originSequence >= destSequence
|
||||
)
|
||||
return;
|
||||
if (booking.bookingType === 'ONE_WAY') {
|
||||
legDefs.push({
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.destinationStationId,
|
||||
seatIds: booking.seats.map((s: any) => s.seatId),
|
||||
});
|
||||
} else if (booking.bookingType === 'ROUND_TRIP') {
|
||||
legDefs.push({
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.destinationStationId,
|
||||
seatIds: seatsForLeg(1),
|
||||
});
|
||||
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.returnScheduleId,
|
||||
originStationId: b.returnOriginStationId,
|
||||
destinationStationId: b.returnDestinationStationId,
|
||||
seatIds: seatsForLeg(2),
|
||||
});
|
||||
}
|
||||
} else if (booking.bookingType === 'TRANSIT') {
|
||||
legDefs.push({
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.leg2OriginStationId, // transit station
|
||||
seatIds: seatsForLeg(1),
|
||||
});
|
||||
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.leg2ScheduleId,
|
||||
originStationId: b.leg2OriginStationId,
|
||||
destinationStationId: b.leg2DestinationStationId,
|
||||
seatIds: seatsForLeg(2),
|
||||
});
|
||||
}
|
||||
} else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') {
|
||||
legDefs.push({
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.leg2OriginStationId,
|
||||
seatIds: seatsForLeg(1),
|
||||
});
|
||||
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.leg2ScheduleId,
|
||||
originStationId: b.leg2OriginStationId,
|
||||
destinationStationId: b.leg2DestinationStationId,
|
||||
seatIds: seatsForLeg(2),
|
||||
});
|
||||
}
|
||||
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.returnScheduleId,
|
||||
originStationId: b.returnOriginStationId,
|
||||
destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
|
||||
seatIds: seatsForLeg(3),
|
||||
});
|
||||
}
|
||||
if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.returnLeg2ScheduleId,
|
||||
originStationId: b.returnLeg2OriginStationId,
|
||||
destinationStationId: b.returnLeg2DestStationId,
|
||||
seatIds: seatsForLeg(4),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (legDefs.length === 0) return;
|
||||
|
||||
const journey = await this.prisma.journey.create({
|
||||
data: {
|
||||
passengerId: booking.passengerId,
|
||||
status: "CONFIRMED",
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
bookingId: booking.id,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
},
|
||||
});
|
||||
|
||||
const journeySegments = [];
|
||||
for (const bookingSeat of booking.seats) {
|
||||
for (let i = originSequence; i < destSequence; i++) {
|
||||
journeySegments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId: booking.scheduleId,
|
||||
segmentOrder: i,
|
||||
seatId: bookingSeat.seatId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
const journeySegments: any[] = [];
|
||||
let segmentOrder = 0;
|
||||
|
||||
for (const leg of legDefs) {
|
||||
if (leg.seatIds.length === 0) continue;
|
||||
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: leg.scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
|
||||
const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId);
|
||||
const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId);
|
||||
if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue;
|
||||
|
||||
for (const seatId of leg.seatIds) {
|
||||
for (let i = originIdx; i < destIdx; i++) {
|
||||
journeySegments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId: leg.scheduleId,
|
||||
segmentOrder: segmentOrder++,
|
||||
seatId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,23 @@ export class SearchService {
|
||||
|
||||
const outbound = [...direct, ...transit];
|
||||
|
||||
if (outbound.length === 0) {
|
||||
const alternativesOutbound = await this.searchAlternatives(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
return {
|
||||
journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
outbound: [],
|
||||
alternativeOutbound: alternativesOutbound,
|
||||
requestedDate: dto.date,
|
||||
};
|
||||
}
|
||||
|
||||
if (dto.journeyType === 'ROUND_TRIP') {
|
||||
const [returnDirect, returnTransit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
@@ -68,12 +85,85 @@ export class SearchService {
|
||||
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
|
||||
);
|
||||
|
||||
if (inbound.length === 0) {
|
||||
const alternativeInbound = await this.searchAlternatives(
|
||||
dto.destinationStationId,
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound };
|
||||
}
|
||||
|
||||
return { journeyType: 'ROUND_TRIP', outbound, inbound };
|
||||
}
|
||||
|
||||
return { journeyType: 'ONE_WAY', outbound };
|
||||
}
|
||||
|
||||
private async searchAlternatives(
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
|
||||
const now = new Date();
|
||||
const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000));
|
||||
const daysAfter = 14 - daysBefore;
|
||||
|
||||
const windowStart = new Date(requestedDate);
|
||||
windowStart.setDate(windowStart.getDate() - daysBefore);
|
||||
if (windowStart < now) windowStart.setTime(now.getTime());
|
||||
|
||||
const windowEnd = new Date(requestedDate);
|
||||
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound
|
||||
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
OR: [
|
||||
{ departureAt: { gte: windowStart, lt: requestedDate } },
|
||||
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
|
||||
],
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||
},
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
});
|
||||
|
||||
const results: any[] = [];
|
||||
for (const schedule of schedules) {
|
||||
const result = await this.buildScheduleResult(
|
||||
schedule,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
totalPassengers,
|
||||
nationality,
|
||||
);
|
||||
if (result) results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async searchSchedules(
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
@@ -85,12 +175,13 @@ export class SearchService {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
const now = new Date();
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: date, lt: nextDay },
|
||||
departureAt: { gte: date < now ? now : date, lt: nextDay },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export class SeatsService {
|
||||
private segmentsService: SegmentsService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachId?: string) {
|
||||
async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) {
|
||||
const assignments = await this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId, ...(coachId ? { coachId } : {}) },
|
||||
include: {
|
||||
@@ -25,16 +25,14 @@ export class SeatsService {
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`);
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId);
|
||||
|
||||
const response = {
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
const allSeats = a.coach.seats;
|
||||
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
||||
|
||||
|
||||
return {
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
@@ -68,43 +66,95 @@ export class SeatsService {
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
|
||||
return response;
|
||||
}
|
||||
|
||||
async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
originStationId?: string,
|
||||
destinationStationId?: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
|
||||
// Resolve the requested leg's sequence range once
|
||||
let reqFrom: number | undefined;
|
||||
let reqTo: number | undefined;
|
||||
let allStopTimes: { stationId: string; sequence: number }[] | null = null;
|
||||
|
||||
const getStopTimes = async () => {
|
||||
if (!allStopTimes) {
|
||||
allStopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
}
|
||||
return allStopTimes;
|
||||
};
|
||||
|
||||
if (originStationId && destinationStationId) {
|
||||
const stops = await getStopTimes();
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
reqFrom = seqOf(originStationId);
|
||||
reqTo = seqOf(destinationStationId);
|
||||
}
|
||||
|
||||
// ── Active holds ──────────────────────────────────────────────────────────
|
||||
const activeHolds = await this.prisma.seatHold.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
expiresAt: { gt: new Date() },
|
||||
seatIds: { hasSome: seatIds },
|
||||
},
|
||||
select: { seatIds: true },
|
||||
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
for (const hold of activeHolds) {
|
||||
let holdFrom: number | undefined;
|
||||
let holdTo: number | undefined;
|
||||
try {
|
||||
if (hold.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(hold.createdBy);
|
||||
const stops = await getStopTimes();
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
holdFrom = seqOf(meta.originStationId);
|
||||
holdTo = seqOf(meta.destinationStationId);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const seatId of hold.seatIds) {
|
||||
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
|
||||
if (!seatIds.includes(seatId)) continue;
|
||||
if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) {
|
||||
if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD');
|
||||
} else {
|
||||
statusMap.set(seatId, 'HELD');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Confirmed bookings via JourneySegment ─────────────────────────────────
|
||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId: { in: seatIds },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
select: { seatId: true, departureStationId: true, arrivalStationId: true },
|
||||
});
|
||||
for (const seg of bookedSegments) {
|
||||
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
||||
|
||||
if (reqFrom !== undefined && reqTo !== undefined) {
|
||||
const stops = await getStopTimes();
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
for (const seg of bookedSegments) {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
const segTo = seqOf(seg.arrivalStationId);
|
||||
if (segFrom !== undefined && segTo !== undefined) {
|
||||
if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED');
|
||||
} else {
|
||||
statusMap.set(seg.seatId, 'BOOKED');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const seg of bookedSegments) {
|
||||
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
||||
}
|
||||
}
|
||||
|
||||
return statusMap;
|
||||
@@ -154,6 +204,7 @@ export class SeatsService {
|
||||
if (reqFrom >= reqTo)
|
||||
throw new BadRequestException('Origin must come before destination');
|
||||
|
||||
// ── Check existing holds for overlap ────────────────────────────────────
|
||||
const activeHolds = await tx.seatHold.findMany({
|
||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
@@ -197,6 +248,29 @@ export class SeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check confirmed JourneySegments for overlap ──────────────────────────
|
||||
const bookedSegments = await tx.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: dto.scheduleId,
|
||||
seatId: { in: seatIds },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true, departureStationId: true, arrivalStationId: true },
|
||||
});
|
||||
|
||||
for (const seg of bookedSegments) {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
const segTo = seqOf(seg.arrivalStationId);
|
||||
if (segFrom !== undefined && segTo !== undefined) {
|
||||
if (segFrom < reqTo && reqFrom < segTo) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const holdMeta = {
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
@@ -347,16 +421,12 @@ export class SeatsService {
|
||||
return { released: true, holdId };
|
||||
}
|
||||
|
||||
async confirmSeats(seatIds: string[]) {
|
||||
// No-op
|
||||
}
|
||||
// Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy.
|
||||
async confirmSeats(_seatIds: string[]) {}
|
||||
|
||||
async releaseSeats(seatIds: string[]) {
|
||||
if (seatIds.length > 0) {
|
||||
await this.prisma.journeySegment.deleteMany({
|
||||
where: { seatId: { in: seatIds } },
|
||||
});
|
||||
}
|
||||
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
||||
async releaseSeats(bookingId: string) {
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId } });
|
||||
}
|
||||
|
||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
||||
@@ -424,7 +494,7 @@ export class SeatsService {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||
const [coachId, , row, col, seatNumber] = parts;
|
||||
if (!coachId || !row || !col || !seatNumber) {
|
||||
errors.push(`Line ${i + 2}: Missing required fields`);
|
||||
invalid++;
|
||||
@@ -448,7 +518,7 @@ export class SeatsService {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
const parts = lines[i].split(',');
|
||||
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||
const [coachId, , row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||
|
||||
await this.prisma.seat.upsert({
|
||||
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
|
||||
@@ -481,18 +551,8 @@ export class SeatsService {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { status: 'BLOCKED' },
|
||||
});
|
||||
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
reason,
|
||||
blockedBy: 'system',
|
||||
},
|
||||
});
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
|
||||
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
|
||||
|
||||
return { blocked: true, seatId, reason };
|
||||
}
|
||||
@@ -501,14 +561,8 @@ export class SeatsService {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: { seatId },
|
||||
});
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
|
||||
|
||||
return { unblocked: true, seatId };
|
||||
}
|
||||
@@ -518,11 +572,9 @@ export class SeatsService {
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
|
||||
|
||||
// Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space
|
||||
const negatedNumber = `-${seat.seatNumber}`;
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { seatNumber: negatedNumber },
|
||||
data: { seatNumber: `-${seat.seatNumber}` },
|
||||
});
|
||||
|
||||
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
|
||||
@@ -535,26 +587,15 @@ export class SeatsService {
|
||||
throw new BadRequestException('Seat is not removed');
|
||||
}
|
||||
|
||||
// Restore original seatNumber by removing the negative sign
|
||||
const originalNumber = seat.seatNumber.slice(1);
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { seatNumber: originalNumber },
|
||||
});
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } });
|
||||
|
||||
return { restored: true, seatId, seatNumber: originalNumber };
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expireHolds() {
|
||||
const now = new Date();
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
|
||||
if (expired.length === 0) return;
|
||||
|
||||
const expiredIds = expired.map(h => h.id);
|
||||
for (const hold of expired) {
|
||||
await this.releaseSeats(hold.seatIds);
|
||||
}
|
||||
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
|
||||
// Holds are temporary and don't create Journey rows — just delete expired ones.
|
||||
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ export class CreateStationDto {
|
||||
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string;
|
||||
@ApiProperty({ example: 9.0054 }) @IsNumber() lat: number;
|
||||
@ApiProperty({ example: 38.7636 }) @IsNumber() lng: number;
|
||||
@ApiPropertyOptional({ example: 9.0054 }) @IsOptional() @IsNumber() lat?: number;
|
||||
@ApiPropertyOptional({ example: 38.7636 }) @IsOptional() @IsNumber() lng?: number;
|
||||
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isOperational?: boolean;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,10 @@ export class StationsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateStationDto) {
|
||||
const station = await this.prisma.station.create({ data: dto });
|
||||
const { lat, lng, ...rest } = dto;
|
||||
const station = await this.prisma.station.create({
|
||||
data: { ...rest, ...(lat !== undefined && { lat }), ...(lng !== undefined && { lng }) } as any,
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
|
||||
@@ -50,7 +50,8 @@ export class TicketsService {
|
||||
booking: {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
},
|
||||
},
|
||||
@@ -90,6 +91,16 @@ export class TicketsService {
|
||||
schedule: t.booking.schedule,
|
||||
seat: t.booking.seats[0]?.seat,
|
||||
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,
|
||||
displayCurrency: t.booking.displayCurrency,
|
||||
displayTotalMinor: t.booking.displayTotalMinor,
|
||||
contactEmail: t.booking.contactEmail,
|
||||
contactPhone: t.booking.contactPhone,
|
||||
returnSchedule: (t.booking as any).returnSchedule ?? null,
|
||||
validatedAt: t.validatedAt,
|
||||
createdAt: t.issuedAt,
|
||||
};
|
||||
@@ -130,7 +141,7 @@ export class TicketsService {
|
||||
legs: legSummary,
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
const barcodePayload = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
|
||||
const ticket = await this.prisma.ticket.upsert({
|
||||
where: { bookingId },
|
||||
|
||||
Reference in New Issue
Block a user