mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Boarding, payment methods, journey direction on seat hold, and more updates
This commit is contained in:
@@ -33,7 +33,7 @@ export class SeatsController {
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Get seat map filtered by coach type",
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches. Use journeyDirection to filter seat holds (OUTBOUND vs RETURN for round-trip bookings).`,
|
||||
})
|
||||
@ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
|
||||
@ApiQuery({
|
||||
@@ -42,6 +42,23 @@ export class SeatsController {
|
||||
description:
|
||||
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "journeyDirection",
|
||||
required: false,
|
||||
enum: ['ONE_WAY', 'OUTBOUND', 'RETURN'],
|
||||
description:
|
||||
"Journey direction for round-trip bookings. Filters seat holds to show only conflicting holds. Use OUTBOUND for outbound leg, RETURN for return leg. Defaults to ONE_WAY (shows all holds).",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "originStationId",
|
||||
required: false,
|
||||
description: "Origin station UUID for segment-specific seat availability",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "destinationStationId",
|
||||
required: false,
|
||||
description: "Destination station UUID for segment-specific seat availability",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description:
|
||||
@@ -50,8 +67,17 @@ export class SeatsController {
|
||||
getSeatMap(
|
||||
@Param("scheduleId") scheduleId: string,
|
||||
@Query("coachTypeId") coachTypeId?: string,
|
||||
@Query("journeyDirection") journeyDirection?: string,
|
||||
@Query("originStationId") originStationId?: string,
|
||||
@Query("destinationStationId") destinationStationId?: string,
|
||||
) {
|
||||
return this.service.getSeatMap(scheduleId, coachTypeId);
|
||||
return this.service.getSeatMap(
|
||||
scheduleId,
|
||||
coachTypeId,
|
||||
journeyDirection as any,
|
||||
originStationId,
|
||||
destinationStationId
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { IsString, IsArray, ValidateNested } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export enum JourneyDirection {
|
||||
ONE_WAY = 'ONE_WAY',
|
||||
OUTBOUND = 'OUTBOUND',
|
||||
RETURN = 'RETURN'
|
||||
}
|
||||
|
||||
export class PassengerSeatDto {
|
||||
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
|
||||
@IsString() passengerId: string;
|
||||
@@ -20,6 +26,15 @@ export class HoldSeatsDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: JourneyDirection,
|
||||
example: JourneyDirection.OUTBOUND,
|
||||
description: 'Journey direction for round-trip bookings. ONE_WAY for single journeys, OUTBOUND/RETURN for round-trip legs. Allows same seats to be held for different directions.'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(JourneyDirection)
|
||||
journeyDirection?: JourneyDirection;
|
||||
|
||||
@ApiProperty({
|
||||
type: [PassengerSeatDto],
|
||||
description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { HoldSeatsDto } from './seats.dto';
|
||||
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
@@ -13,7 +13,7 @@ export class SeatsService {
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string) {
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { originStationId: true, destinationStationId: true },
|
||||
@@ -37,7 +37,13 @@ export class SeatsService {
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
||||
scheduleId,
|
||||
allSeatIds,
|
||||
originStationId ?? schedule.originStationId,
|
||||
destinationStationId ?? schedule.destinationStationId,
|
||||
journeyDirection
|
||||
);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
@@ -179,6 +185,7 @@ export class SeatsService {
|
||||
seatIds: string[],
|
||||
originStationId?: string,
|
||||
destinationStationId?: string,
|
||||
journeyDirection?: JourneyDirection,
|
||||
): Promise<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
@@ -211,9 +218,13 @@ export class SeatsService {
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
|
||||
|
||||
for (const hold of activeHolds) {
|
||||
let holdFrom: number | undefined;
|
||||
let holdTo: number | undefined;
|
||||
let holdDirection = JourneyDirection.ONE_WAY;
|
||||
|
||||
try {
|
||||
if (hold.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(hold.createdBy);
|
||||
@@ -221,16 +232,26 @@ export class SeatsService {
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
holdFrom = seqOf(meta.originStationId);
|
||||
holdTo = seqOf(meta.destinationStationId);
|
||||
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const seatId of hold.seatIds) {
|
||||
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');
|
||||
}
|
||||
|
||||
// Check leg overlap
|
||||
const legsOverlap =
|
||||
reqFrom === undefined || reqTo === undefined ||
|
||||
holdFrom === undefined || holdTo === undefined ||
|
||||
(holdFrom < reqTo && reqFrom < holdTo);
|
||||
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
// Check direction conflict
|
||||
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
|
||||
if (!directionsConflict) continue;
|
||||
|
||||
statusMap.set(seatId, 'HELD');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +287,36 @@ export class SeatsService {
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two journey directions conflict (should not be allowed simultaneously)
|
||||
* For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
|
||||
*/
|
||||
private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
|
||||
// OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
|
||||
if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
|
||||
(current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
|
||||
if (current === existing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ONE_WAY conflicts with other ONE_WAY bookings only
|
||||
if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
|
||||
if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default: no conflict
|
||||
return false;
|
||||
}
|
||||
|
||||
async holdSeats(dto: HoldSeatsDto) {
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
@@ -307,19 +358,16 @@ export class SeatsService {
|
||||
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD');
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED');
|
||||
if (blocked.length > 0)
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId: dto.scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const seqOf = (stationId: string) =>
|
||||
stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||
|
||||
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||
const reqFrom = seqOf(dto.originStationId);
|
||||
const reqTo = seqOf(dto.destinationStationId);
|
||||
|
||||
@@ -328,57 +376,46 @@ export class SeatsService {
|
||||
if (reqFrom >= reqTo)
|
||||
throw new BadRequestException('Origin must come before destination');
|
||||
|
||||
// ── Check existing holds for overlap ────────────────────────────────────
|
||||
const currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
const activeHolds = await tx.seatHold.findMany({
|
||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = [];
|
||||
for (const h of activeHolds) {
|
||||
const rawSeatIds = h.seatIds as string[];
|
||||
let holdDirection = JourneyDirection.ONE_WAY;
|
||||
let holdFrom = 0, holdTo = Number.MAX_SAFE_INTEGER;
|
||||
let passengerIds: string[] = [];
|
||||
let legUnknown = true;
|
||||
|
||||
try {
|
||||
if (h.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(h.createdBy);
|
||||
const holdFrom = seqOf(meta.originStationId);
|
||||
const holdTo = seqOf(meta.destinationStationId);
|
||||
parsedHolds.push({
|
||||
seatIds: rawSeatIds,
|
||||
from: holdFrom ?? 0,
|
||||
to: holdTo ?? Number.MAX_SAFE_INTEGER,
|
||||
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||
legUnknown: holdFrom === undefined || holdTo === undefined,
|
||||
});
|
||||
} else {
|
||||
// Legacy plain-string createdBy — can't determine leg; block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
holdFrom = seqOf(meta.originStationId) ?? 0;
|
||||
holdTo = seqOf(meta.destinationStationId) ?? Number.MAX_SAFE_INTEGER;
|
||||
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
passengerIds = (meta.passengers ?? []).map((p: any) => p.passengerId);
|
||||
legUnknown = !meta.originStationId || !meta.destinationStationId;
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON — block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
for (const hold of parsedHolds) {
|
||||
const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to);
|
||||
if (!legsOverlap) continue;
|
||||
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
if (hold.seatIds.includes(seatId)) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seatId]} is already held for this leg`,
|
||||
);
|
||||
const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
|
||||
if (!directionsConflict) continue;
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
if (rawSeatIds.includes(seatId)) {
|
||||
throw new ConflictException(`Seat ${seatLabelById[seatId]} is already held for this leg`);
|
||||
}
|
||||
|
||||
if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(
|
||||
`Passenger already holds a seat on this journey leg`,
|
||||
);
|
||||
if (!legUnknown && passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(`Passenger already holds a seat on this journey leg`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check confirmed JourneySegments for overlap ──────────────────────────
|
||||
const bookedSegments = await tx.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -392,25 +429,19 @@ export class SeatsService {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
const segTo = seqOf(seg.arrivalStationId);
|
||||
// If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
|
||||
const overlaps = (segFrom === undefined || segTo === undefined)
|
||||
? true
|
||||
: segFrom < reqTo && reqFrom < segTo;
|
||||
const overlaps = (segFrom === undefined || segTo === undefined) ? true : segFrom < reqTo && reqFrom < segTo;
|
||||
if (overlaps) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||
);
|
||||
throw new ConflictException(`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`);
|
||||
}
|
||||
}
|
||||
|
||||
const holdMeta = {
|
||||
originStationId: dto.originStationId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
journeyDirection: currentDirection,
|
||||
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
||||
};
|
||||
|
||||
// Mark seats as HELD so the status check catches them immediately on any
|
||||
// subsequent hold attempt (avoids relying solely on the SeatHold table scan).
|
||||
await tx.seat.updateMany({
|
||||
where: { id: { in: seatIds } },
|
||||
data: { status: 'HELD' },
|
||||
@@ -418,10 +449,10 @@ export class SeatsService {
|
||||
|
||||
return tx.seatHold.create({
|
||||
data: {
|
||||
scheduleId: dto.scheduleId,
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerId: dto.passengers[0].passengerId,
|
||||
seatIds,
|
||||
createdBy: JSON.stringify(holdMeta),
|
||||
createdBy: JSON.stringify(holdMeta),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user