mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add seat conflict assertion for route bookings
This commit is contained in:
@@ -14,6 +14,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
|
|||||||
import { PaymentsService } from '../payments/payments.service';
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||||
|
import { JourneyDirection } from '../seats/seats.dto';
|
||||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
@@ -857,6 +858,14 @@ export class BookingsService {
|
|||||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||||
|
|
||||||
|
await this.seatsService.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
seatIds: requestedSeatIds,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
journeyDirection: JourneyDirection.ONE_WAY,
|
||||||
|
});
|
||||||
|
|
||||||
const [passengersData, iamContact] = await Promise.all([
|
const [passengersData, iamContact] = await Promise.all([
|
||||||
this.processPassengers(dto.passengers as any[]),
|
this.processPassengers(dto.passengers as any[]),
|
||||||
this.resolveIamContact(dto.passengerId),
|
this.resolveIamContact(dto.passengerId),
|
||||||
@@ -1059,6 +1068,21 @@ export class BookingsService {
|
|||||||
throw new NotFoundException('Origin or destination stops not found');
|
throw new NotFoundException('Origin or destination stops not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.seatsService.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
seatIds: holdObSeatIds,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
journeyDirection: JourneyDirection.OUTBOUND,
|
||||||
|
});
|
||||||
|
await this.seatsService.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.returnScheduleId,
|
||||||
|
seatIds: holdRetSeatIds,
|
||||||
|
originStationId: dto.returnOriginStationId,
|
||||||
|
destinationStationId: dto.returnDestinationStationId,
|
||||||
|
journeyDirection: JourneyDirection.RETURN,
|
||||||
|
});
|
||||||
|
|
||||||
const [passengersData, iamContact] = await Promise.all([
|
const [passengersData, iamContact] = await Promise.all([
|
||||||
this.processRoundTripPassengers(dto.passengers as any[]),
|
this.processRoundTripPassengers(dto.passengers as any[]),
|
||||||
this.resolveIamContact(dto.passengerId),
|
this.resolveIamContact(dto.passengerId),
|
||||||
@@ -1309,6 +1333,21 @@ export class BookingsService {
|
|||||||
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
|
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
|
||||||
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
|
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
|
||||||
|
|
||||||
|
await this.seatsService.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
seatIds: leg1SeatIds,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.transitStationId,
|
||||||
|
journeyDirection: JourneyDirection.ONE_WAY,
|
||||||
|
});
|
||||||
|
await this.seatsService.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.leg2ScheduleId,
|
||||||
|
seatIds: leg2SeatIds,
|
||||||
|
originStationId: dto.transitStationId,
|
||||||
|
destinationStationId: dto.leg2DestinationStationId,
|
||||||
|
journeyDirection: JourneyDirection.ONE_WAY,
|
||||||
|
});
|
||||||
|
|
||||||
const [passengersData, iamContact] = await Promise.all([
|
const [passengersData, iamContact] = await Promise.all([
|
||||||
this.processPassengers(dto.passengers as any[]),
|
this.processPassengers(dto.passengers as any[]),
|
||||||
this.resolveIamContact(dto.passengerId),
|
this.resolveIamContact(dto.passengerId),
|
||||||
@@ -1511,6 +1550,35 @@ export class BookingsService {
|
|||||||
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
|
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
|
||||||
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
|
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
|
||||||
|
|
||||||
|
await this.seatsService.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
seatIds: (dto.passengers as any[]).map(p => p.seatId),
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.transitStationId,
|
||||||
|
journeyDirection: JourneyDirection.OUTBOUND,
|
||||||
|
});
|
||||||
|
await this.seatsService.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.leg2ScheduleId,
|
||||||
|
seatIds: (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId),
|
||||||
|
originStationId: dto.transitStationId,
|
||||||
|
destinationStationId: dto.leg2DestinationStationId,
|
||||||
|
journeyDirection: JourneyDirection.OUTBOUND,
|
||||||
|
});
|
||||||
|
await this.seatsService.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.returnScheduleId,
|
||||||
|
seatIds: (dto.passengers as any[]).map(p => p.returnSeatId),
|
||||||
|
originStationId: dto.returnOriginStationId,
|
||||||
|
destinationStationId: dto.returnTransitStationId,
|
||||||
|
journeyDirection: JourneyDirection.RETURN,
|
||||||
|
});
|
||||||
|
await this.seatsService.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.returnLeg2ScheduleId,
|
||||||
|
seatIds: (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId),
|
||||||
|
originStationId: dto.returnTransitStationId,
|
||||||
|
destinationStationId: dto.returnLeg2DestinationStationId,
|
||||||
|
journeyDirection: JourneyDirection.RETURN,
|
||||||
|
});
|
||||||
|
|
||||||
const [passengersData, iamContact] = await Promise.all([
|
const [passengersData, iamContact] = await Promise.all([
|
||||||
this.processRoundTripPassengers(dto.passengers as any[]),
|
this.processRoundTripPassengers(dto.passengers as any[]),
|
||||||
this.resolveIamContact(dto.passengerId),
|
this.resolveIamContact(dto.passengerId),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SmsClientService } from '../notifications/sms-client.service';
|
|||||||
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
|
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
|
||||||
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
|
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
|
||||||
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
||||||
|
import { JourneyDirection } from '../seats/seats.dto';
|
||||||
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
||||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|||||||
@@ -2,16 +2,47 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { SeatsService } from './seats.service';
|
import { SeatsService } from './seats.service';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { ConflictException } from '@nestjs/common';
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import { SegmentsService } from '../segments/segments.service';
|
||||||
|
import { SystemConfigService } from '../system-config/system-config.service';
|
||||||
|
import { AuditService } from '../../common/audit.service';
|
||||||
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
|
import { JourneyDirection } from './seats.dto';
|
||||||
|
|
||||||
describe('SeatsService - Auto Assign', () => {
|
describe('SeatsService - Auto Assign', () => {
|
||||||
let service: SeatsService;
|
let service: SeatsService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
|
let segmentsService: SegmentsService;
|
||||||
|
|
||||||
const mockPrisma = {
|
const mockPrisma = {
|
||||||
seat: {
|
seat: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
updateMany: jest.fn(),
|
updateMany: jest.fn(),
|
||||||
},
|
},
|
||||||
|
tripStopTime: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
trainSchedule: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
seatBlock: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSegmentsService = {
|
||||||
|
getSeatAvailabilityMap: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSystemConfigService = {
|
||||||
|
getNumber: jest.fn().mockResolvedValue(30),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuditService = {
|
||||||
|
log: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSmsClientService = {
|
||||||
|
send: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -19,12 +50,67 @@ describe('SeatsService - Auto Assign', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
SeatsService,
|
SeatsService,
|
||||||
{ provide: PrismaService, useValue: mockPrisma },
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: SegmentsService, useValue: mockSegmentsService },
|
||||||
|
{ provide: SystemConfigService, useValue: mockSystemConfigService },
|
||||||
|
{ provide: AuditService, useValue: mockAuditService },
|
||||||
|
{ provide: SmsClientService, useValue: mockSmsClientService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<SeatsService>(SeatsService);
|
service = module.get<SeatsService>(SeatsService);
|
||||||
prisma = module.get<PrismaService>(PrismaService);
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
segmentsService = module.get<SegmentsService>(SegmentsService);
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
mockPrisma.trainSchedule.findUnique.mockResolvedValue({
|
||||||
|
originStationId: 'origin-station',
|
||||||
|
destinationStationId: 'destination-station',
|
||||||
|
});
|
||||||
|
mockPrisma.tripStopTime.findMany.mockResolvedValue([
|
||||||
|
{ stationId: 'origin-station', sequence: 0 },
|
||||||
|
{ stationId: 'destination-station', sequence: 1 },
|
||||||
|
]);
|
||||||
|
mockPrisma.seatBlock.findMany.mockResolvedValue([]);
|
||||||
|
mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertNoRouteSeatConflict', () => {
|
||||||
|
it('rejects overlapping origin-destination seat reuse on the same schedule', async () => {
|
||||||
|
mockPrisma.tripStopTime.findMany.mockResolvedValue([
|
||||||
|
{ stationId: 'seb', sequence: 1 },
|
||||||
|
{ stationId: 'ada', sequence: 2 },
|
||||||
|
{ stationId: 'dd', sequence: 3 },
|
||||||
|
]);
|
||||||
|
mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map([['seat-1', 'BOOKED']]));
|
||||||
|
mockPrisma.seat.findMany.mockResolvedValue([
|
||||||
|
{ id: 'seat-1', seatNumber: '12', coach: { number: 'C1' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: 'sched-1',
|
||||||
|
seatIds: ['seat-1'],
|
||||||
|
originStationId: 'seb',
|
||||||
|
destinationStationId: 'dd',
|
||||||
|
journeyDirection: JourneyDirection.ONE_WAY,
|
||||||
|
})).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the same seat number to be reused on non-overlapping legs', async () => {
|
||||||
|
mockPrisma.tripStopTime.findMany.mockResolvedValue([
|
||||||
|
{ stationId: 'seb', sequence: 1 },
|
||||||
|
{ stationId: 'ada', sequence: 2 },
|
||||||
|
{ stationId: 'dd', sequence: 3 },
|
||||||
|
]);
|
||||||
|
mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map());
|
||||||
|
|
||||||
|
await expect(service.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: 'sched-1',
|
||||||
|
seatIds: ['seat-1'],
|
||||||
|
originStationId: 'seb',
|
||||||
|
destinationStationId: 'ada',
|
||||||
|
journeyDirection: JourneyDirection.ONE_WAY,
|
||||||
|
})).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('autoAssignSeats', () => {
|
describe('autoAssignSeats', () => {
|
||||||
|
|||||||
@@ -210,6 +210,57 @@ export class SeatsService {
|
|||||||
// (availabilityByClass) use — so the seatmap and search results can never disagree
|
// (availabilityByClass) use — so the seatmap and search results can never disagree
|
||||||
// about seat availability again. Previously this method carried its own
|
// about seat availability again. Previously this method carried its own
|
||||||
// separately-written copy of the same hold/JourneySegment-overlap logic.
|
// separately-written copy of the same hold/JourneySegment-overlap logic.
|
||||||
|
async assertNoRouteSeatConflict(args: {
|
||||||
|
scheduleId: string;
|
||||||
|
seatIds: string[];
|
||||||
|
originStationId?: string;
|
||||||
|
destinationStationId?: string;
|
||||||
|
journeyDirection?: JourneyDirection;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { scheduleId, seatIds, originStationId, destinationStationId, journeyDirection = JourneyDirection.ONE_WAY } = args;
|
||||||
|
if (!seatIds.length) return;
|
||||||
|
|
||||||
|
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||||
|
where: { scheduleId },
|
||||||
|
select: { stationId: true, sequence: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let reqFrom = -Infinity;
|
||||||
|
let reqTo = Infinity;
|
||||||
|
if (originStationId && destinationStationId) {
|
||||||
|
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||||
|
const resolvedFrom = seqOf(originStationId);
|
||||||
|
const resolvedTo = seqOf(destinationStationId);
|
||||||
|
if (resolvedFrom !== undefined && resolvedTo !== undefined) {
|
||||||
|
reqFrom = resolvedFrom;
|
||||||
|
reqTo = resolvedTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = await this.segmentsService.getSeatAvailabilityMap(
|
||||||
|
scheduleId,
|
||||||
|
seatIds,
|
||||||
|
stopTimes,
|
||||||
|
reqFrom,
|
||||||
|
reqTo,
|
||||||
|
journeyDirection,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (availability.size === 0) return;
|
||||||
|
|
||||||
|
const seats = await this.prisma.seat.findMany({
|
||||||
|
where: { id: { in: seatIds } },
|
||||||
|
select: { id: true, seatNumber: true },
|
||||||
|
});
|
||||||
|
const byId = new Map(seats.map(seat => [seat.id, seat.seatNumber]));
|
||||||
|
const conflicts = seatIds.filter(id => availability.has(id));
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
const labels = conflicts.map(id => byId.get(id) ?? id).join(', ');
|
||||||
|
throw new ConflictException(`Seat(s) ${labels} are already assigned for this route on this schedule`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async resolveEffectiveStatuses(
|
async resolveEffectiveStatuses(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
seatIds: string[],
|
seatIds: string[],
|
||||||
@@ -425,6 +476,14 @@ export class SeatsService {
|
|||||||
throw new BadRequestException('Origin must come before destination');
|
throw new BadRequestException('Origin must come before destination');
|
||||||
|
|
||||||
const currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY;
|
const currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY;
|
||||||
|
await this.assertNoRouteSeatConflict({
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
seatIds,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
journeyDirection: currentDirection,
|
||||||
|
});
|
||||||
|
|
||||||
const activeHolds = await tx.seatHold.findMany({
|
const activeHolds = await tx.seatHold.findMany({
|
||||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||||
select: { seatIds: true, createdBy: true },
|
select: { seatIds: true, createdBy: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user