|
|
|
|
@@ -224,7 +224,7 @@ export class SeatsService {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [availability, persistedSeats] = await Promise.all([
|
|
|
|
|
const [availability, persistedSeats, scheduleBlocks] = await Promise.all([
|
|
|
|
|
this.segmentsService.getSeatAvailabilityMap(
|
|
|
|
|
scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY,
|
|
|
|
|
),
|
|
|
|
|
@@ -232,16 +232,23 @@ export class SeatsService {
|
|
|
|
|
where: { id: { in: seatIds } },
|
|
|
|
|
select: { id: true, status: true },
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.seatBlock.findMany({
|
|
|
|
|
where: { seatId: { in: seatIds }, scheduleId },
|
|
|
|
|
select: { seatId: true },
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const persistedStatus = new Map(persistedSeats.map(s => [s.id, s.status]));
|
|
|
|
|
const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId));
|
|
|
|
|
|
|
|
|
|
for (const seatId of seatIds) {
|
|
|
|
|
const persisted = persistedStatus.get(seatId);
|
|
|
|
|
// BLOCKED and UNDER_MAINTENANCE are cross-schedule flags set by admins —
|
|
|
|
|
// always honour them regardless of hold/booking state.
|
|
|
|
|
// Global BLOCKED/UNDER_MAINTENANCE (no scheduleId) — always honour
|
|
|
|
|
if ((persisted as string) === 'BLOCKED' || (persisted as string) === 'UNDER_MAINTENANCE') {
|
|
|
|
|
statusMap.set(seatId, persisted!);
|
|
|
|
|
} else if (scheduleBlockedIds.has(seatId)) {
|
|
|
|
|
// Schedule-scoped block — only blocked for this schedule
|
|
|
|
|
statusMap.set(seatId, 'BLOCKED');
|
|
|
|
|
} else {
|
|
|
|
|
statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE');
|
|
|
|
|
}
|
|
|
|
|
@@ -291,15 +298,12 @@ export class SeatsService {
|
|
|
|
|
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only the raw BLOCKED status (seat pulled out of service — a genuine
|
|
|
|
|
// cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked
|
|
|
|
|
// against this raw column: the same physical Seat row is reused across every
|
|
|
|
|
// recurring date a coach runs, and Seat.status only resets to AVAILABLE via a
|
|
|
|
|
// trip-completion event that isn't guaranteed to fire, so a stale BOOKED value
|
|
|
|
|
// here would wrongly block a seat that's actually free for this schedule/leg.
|
|
|
|
|
// The schedule- and leg-scoped SeatHold/JourneySegment checks below are the
|
|
|
|
|
// authoritative source for whether a seat is actually taken.
|
|
|
|
|
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
|
|
|
|
// Only the raw BLOCKED/UNDER_MAINTENANCE status (seat pulled out of service —
|
|
|
|
|
// a genuine cross-schedule flag) is checked here. Seat.status is never written
|
|
|
|
|
// for holds/bookings because coaches are reused across schedules; the
|
|
|
|
|
// schedule-scoped SeatHold/JourneySegment checks below are the authoritative
|
|
|
|
|
// source for whether a seat is taken on this specific schedule/leg.
|
|
|
|
|
const blocked = seats.filter(s => s.status === 'BLOCKED' || (s.status as string) === 'UNDER_MAINTENANCE');
|
|
|
|
|
if (blocked.length > 0)
|
|
|
|
|
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
|
|
|
|
|
|
|
|
|
@@ -400,11 +404,6 @@ export class SeatsService {
|
|
|
|
|
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await tx.seat.updateMany({
|
|
|
|
|
where: { id: { in: seatIds } },
|
|
|
|
|
data: { status: 'HELD' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return tx.seatHold.create({
|
|
|
|
|
data: {
|
|
|
|
|
scheduleId: dto.scheduleId,
|
|
|
|
|
@@ -545,13 +544,7 @@ export class SeatsService {
|
|
|
|
|
async releaseHold(holdId: string) {
|
|
|
|
|
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
|
|
|
|
if (!hold) throw new NotFoundException('Hold not found');
|
|
|
|
|
await this.prisma.$transaction([
|
|
|
|
|
this.prisma.seat.updateMany({
|
|
|
|
|
where: { id: { in: hold.seatIds as string[] }, status: 'HELD' },
|
|
|
|
|
data: { status: 'AVAILABLE' },
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.seatHold.delete({ where: { id: holdId } }),
|
|
|
|
|
]);
|
|
|
|
|
await this.prisma.seatHold.delete({ where: { id: holdId } });
|
|
|
|
|
return { released: true, holdId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -657,21 +650,41 @@ export class SeatsService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
|
|
|
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
|
|
|
|
where: { id: scheduleId },
|
|
|
|
|
select: { originStationId: true, destinationStationId: true },
|
|
|
|
|
});
|
|
|
|
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
|
|
|
|
|
|
|
|
|
const seats = await this.prisma.seat.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
coach: { assignments: { some: { scheduleId } } },
|
|
|
|
|
status: 'AVAILABLE',
|
|
|
|
|
seatNumber: { not: '' },
|
|
|
|
|
NOT: { seatNumber: { startsWith: '-' } },
|
|
|
|
|
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }],
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (seats.length < count) {
|
|
|
|
|
throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`);
|
|
|
|
|
const allSeatIds = seats.map(s => s.id);
|
|
|
|
|
const stopTimes = await this.prisma.tripStopTime.findMany({
|
|
|
|
|
where: { scheduleId },
|
|
|
|
|
select: { stationId: true, sequence: true },
|
|
|
|
|
});
|
|
|
|
|
const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence;
|
|
|
|
|
const reqFrom = seqOf(schedule.originStationId) ?? 0;
|
|
|
|
|
const reqTo = seqOf(schedule.destinationStationId) ?? stopTimes.length;
|
|
|
|
|
|
|
|
|
|
const unavailable = await this.segmentsService.getSeatAvailabilityMap(
|
|
|
|
|
scheduleId, allSeatIds, stopTimes, reqFrom, reqTo,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const availableSeats = seats.filter(s => !unavailable.has(s.id));
|
|
|
|
|
|
|
|
|
|
if (availableSeats.length < count) {
|
|
|
|
|
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const assigned = this.findContiguousSeats(seats, count);
|
|
|
|
|
const assigned = this.findContiguousSeats(availableSeats, count);
|
|
|
|
|
return assigned.map((s) => s.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -774,24 +787,34 @@ export class SeatsService {
|
|
|
|
|
return { imported, errors: errors.slice(0, 10) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async blockSeat(seatId: string, reason: string) {
|
|
|
|
|
async blockSeat(seatId: string, reason: string, scheduleId?: string) {
|
|
|
|
|
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.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason } });
|
|
|
|
|
return { blocked: true, seatId, reason };
|
|
|
|
|
// Schedule-scoped block: only affects this schedule, not all schedules
|
|
|
|
|
// Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules
|
|
|
|
|
if (scheduleId) {
|
|
|
|
|
await this.prisma.seatBlock.create({ data: { seatId, scheduleId, reason, blockedBy: 'system' } });
|
|
|
|
|
} else {
|
|
|
|
|
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
|
|
|
|
|
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
|
|
|
|
|
}
|
|
|
|
|
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason, scheduleId } });
|
|
|
|
|
return { blocked: true, seatId, reason, scheduleId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async unblockSeat(seatId: string) {
|
|
|
|
|
async unblockSeat(seatId: string, scheduleId?: string) {
|
|
|
|
|
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.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE' } });
|
|
|
|
|
return { unblocked: true, seatId };
|
|
|
|
|
if (scheduleId) {
|
|
|
|
|
await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId } });
|
|
|
|
|
} else {
|
|
|
|
|
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
|
|
|
|
|
await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId: null } });
|
|
|
|
|
}
|
|
|
|
|
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE', scheduleId } });
|
|
|
|
|
return { unblocked: true, seatId, scheduleId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async setMaintenance(seatId: string, reason: string) {
|
|
|
|
|
@@ -929,22 +952,12 @@ export class SeatsService {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (releasedSeatIds.size > 0) {
|
|
|
|
|
await this.prisma.seat.updateMany({
|
|
|
|
|
where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' },
|
|
|
|
|
// heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an
|
|
|
|
|
// AVAILABLE seat is stale data that any future code reading heldUntil directly
|
|
|
|
|
// (instead of re-deriving availability live) would misinterpret.
|
|
|
|
|
data: { status: 'AVAILABLE', heldUntil: null },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
expiredHolds: expired.length,
|
|
|
|
|
releasedSeatIds: Array.from(releasedSeatIds),
|
|
|
|
|
skippedSeatIds: Array.from(skippedSeatIds),
|
|
|
|
|
skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|