Added discrepancy management for duplicate seats

This commit is contained in:
Roba Boru
2026-07-17 10:20:35 +03:00
parent 4bc7d0ecf5
commit 610580e15e
8 changed files with 1068 additions and 2 deletions

View File

@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator';
export class GetDuplicateSeatsQuery {
@ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' })
@IsDateString()
date: string;
@ApiPropertyOptional({ description: 'Filter to a specific schedule ID' })
@IsOptional()
@IsUUID()
scheduleId?: string;
}
export class ResolveDuplicatesDto {
@ApiProperty({
description: 'BookingSeat IDs of the duplicate bookings to reassign',
type: [String],
example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'],
})
@IsArray()
@IsUUID(undefined, { each: true })
bookingSeatIds: string[];
@ApiProperty({
description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)',
type: [String],
example: ['uuid-coach-1', 'uuid-coach-2'],
})
@IsArray()
@IsUUID(undefined, { each: true })
coachIds: string[];
}

View File

@@ -17,9 +17,11 @@ import {
ApiParam,
ApiQuery,
ApiResponse,
ApiBody,
} from "@nestjs/swagger";
import { SeatsService } from "./seats.service";
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -306,4 +308,90 @@ This makes it clear which segment of the route each seat is held for, enabling s
) {
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
}
// ── Duplicate seat management (backoffice) ────────────────────────────────
@Get("duplicates")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: "List duplicate seat assignments by schedule date",
description:
"Returns all schedules on the given date that have bookings sharing " +
"the same seat, grouped by coach. Each coach entry includes the duplicate " +
"groups (with full booking info) and the list of currently available seats " +
"that can be used for reassignment.",
})
@ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" })
@ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" })
@ApiResponse({
status: 200,
description: "Duplicate seat report grouped by schedule → coach",
schema: {
example: {
date: "2026-07-17",
totalDuplicates: 1,
schedules: [{
scheduleId: "uuid",
departureAt: "2026-07-17T06:00:00.000Z",
origin: "Addis Ababa",
destination: "Dire Dawa",
coaches: [{
coachId: "uuid",
coachNumber: "C1",
coachTypeName: "SBC",
duplicates: [{
seatId: "uuid",
seatNumber: "12A",
leg: 1,
bookings: [
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" },
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" },
],
}],
availableSeats: [
{ seatId: "uuid", seatNumber: "14B" },
{ seatId: "uuid", seatNumber: "15A" },
],
}],
}],
},
},
})
getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) {
return this.service.getDuplicateSeats(query.date, query.scheduleId);
}
@Post("duplicates/resolve")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: "Auto-assign duplicate bookings to seats in selected coaches",
description:
"Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " +
"The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " +
"for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " +
"change immediately, then sends an SMS notification to the passenger. " +
"Coaches are searched in the order provided; seats within each coach are assigned by row then column.",
})
@ApiBody({ type: ResolveDuplicatesDto })
@ApiResponse({
status: 200,
description: "Resolution summary — resolved count, unresolved count, per-booking results",
schema: {
example: {
resolved: 2,
unresolved: 0,
results: [
{ bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" },
{ bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" },
],
},
},
})
@ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" })
@ApiResponse({ status: 404, description: "BookingSeat ID not found" })
resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) {
return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds);
}
}

View File

@@ -5,9 +5,10 @@ import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { AuditModule } from '../../common/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule],
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],

View File

@@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
@@ -17,6 +18,7 @@ export class SeatsService {
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
private auditService: AuditService,
private sms: SmsClientService,
) {}
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
@@ -960,4 +962,416 @@ export class SeatsService {
skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed
};
}
// ─────────────────────────────────────────────────────────────────────────
// Duplicate-seat management (backoffice)
// ─────────────────────────────────────────────────────────────────────────
async getDuplicateSeats(date: string, scheduleId?: string) {
const dayStart = new Date(`${date}T00:00:00.000Z`);
const dayEnd = new Date(`${date}T23:59:59.999Z`);
const schedules = await this.prisma.trainSchedule.findMany({
where: {
departureAt: { gte: dayStart, lte: dayEnd },
...(scheduleId ? { id: scheduleId } : {}),
},
orderBy: { departureAt: 'asc' },
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
coachAssignments: {
orderBy: { positionNumber: 'asc' },
include: {
coach: {
include: {
coachType: { select: { name: true } },
seats: {
orderBy: [{ row: 'asc' }, { col: 'asc' }],
select: { id: true, seatNumber: true, status: true, coachId: true },
},
},
},
},
},
},
});
const result = [];
for (const schedule of schedules) {
// All confirmed BookingSeat rows for this schedule
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: {
OR: [
{ scheduleId: schedule.id },
{ scheduleId: null, booking: { scheduleId: schedule.id } },
],
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
},
select: {
id: true, seatId: true, scheduleId: true, leg: true, passengerName: true,
seat: { select: { coachId: true } },
booking: {
select: {
id: true, bookingRef: true, scheduleId: true,
createdAt: true, contactPhone: true,
},
},
},
});
// Seats occupied by any confirmed journey on this schedule (source of truth)
const journeySegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId: schedule.id,
seatId: { not: null },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true },
});
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
// Group BookingSeat rows by (seatId::leg) to detect duplicates
type BS = (typeof bookingSeats)[number];
const groups = new Map<string, BS[]>();
for (const bs of bookingSeats) {
const key = `${bs.seatId}::${bs.leg}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(bs);
}
// All seats held by any confirmed BookingSeat — union of JourneySegment-based
// occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments
// are missing (e.g. created via enhanced-seats path without bookingId) are still
// excluded from the available list.
const bookedSeatIds = new Set<string>([
...occupiedIds,
...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined),
]);
const coachReports = [];
for (const assignment of schedule.coachAssignments) {
const coach = assignment.coach;
// Duplicate groups whose seat belongs to this coach
const duplicates = [];
for (const [key, group] of groups) {
if (group.length <= 1) continue;
if (group[0].seat.coachId !== coach.id) continue;
const [seatId] = key.split('::');
const seat = coach.seats.find(s => s.id === seatId);
duplicates.push({
seatId,
seatNumber: seat?.seatNumber ?? seatId,
leg: group[0].leg,
bookings: group.map(bs => ({
bookingSeatId: bs.id,
bookingId: bs.booking.id,
bookingRef: bs.booking.bookingRef,
passengerName: bs.passengerName,
contactPhone: bs.booking.contactPhone,
createdAt: bs.booking.createdAt,
})),
});
}
// Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat
// assignments, and all confirmed JourneySegment occupancies.
const availableSeats = coach.seats
.filter(s =>
(s.status as string) !== 'BLOCKED' &&
!s.seatNumber.startsWith('-') &&
!bookedSeatIds.has(s.id),
)
.map(s => ({ seatId: s.id, seatNumber: s.seatNumber }));
coachReports.push({
coachId: coach.id,
coachNumber: coach.number,
coachTypeName: coach.coachType.name,
duplicates,
availableSeats,
});
}
if (coachReports.some(c => c.duplicates.length > 0)) {
result.push({
scheduleId: schedule.id,
departureAt: schedule.departureAt,
origin: schedule.originStation.name,
destination: schedule.destinationStation.name,
coaches: coachReports,
});
}
}
const totalDuplicates = result.reduce(
(sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0),
0,
);
return { date, schedules: result, totalDuplicates };
}
async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) {
if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] };
// Load BookingSeat rows with full booking + schedule context
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: { id: { in: bookingSeatIds } },
select: {
id: true, seatId: true, leg: true, scheduleId: true,
seat: { select: { seatNumber: true } },
booking: {
select: {
id: true, bookingRef: true, scheduleId: true,
status: true, contactPhone: true, passengerId: true,
totalMinor: true, currency: true,
originStationId: true, destinationStationId: true,
schedule: {
select: {
originStationId: true,
destinationStationId: true,
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
departureAt: true,
},
},
},
},
},
});
if (bookingSeats.length !== bookingSeatIds.length) {
const found = new Set(bookingSeats.map(bs => bs.id));
const missing = bookingSeatIds.filter(id => !found.has(id));
throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`);
}
const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status));
if (invalid.length > 0) {
throw new BadRequestException(
`Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`,
);
}
// Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick)
const coachSeats = await this.prisma.seat.findMany({
where: {
coachId: { in: coachIds },
status: { not: 'BLOCKED' },
NOT: { seatNumber: { startsWith: '-' } },
},
select: { id: true, seatNumber: true, coachId: true, row: true, col: true },
orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }],
});
// Build occupied-seat sets per schedule from confirmed JourneySegments
const scheduleIds = [
...new Set(
bookingSeats
.map(bs => bs.scheduleId ?? bs.booking.scheduleId)
.filter((id): id is string => id !== null && id !== undefined),
),
];
const occupiedBySchedule = new Map<string, Set<string>>();
await Promise.all(
scheduleIds.map(async scheduleId => {
const segments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { not: null },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } },
},
select: { seatId: true },
});
occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!)));
}),
);
// Track seats assigned within this batch to prevent double-assignment
const assignedInBatch = new Set<string>();
const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = [];
const unresolved: { bookingRef: string; reason: string }[] = [];
for (const bs of bookingSeats) {
const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!;
const occupied = occupiedBySchedule.get(scheduleId) ?? new Set<string>();
// Pick the first available seat across the selected coaches
const newSeat = coachSeats.find(
seat =>
!occupied.has(seat.id) &&
!assignedInBatch.has(seat.id) &&
seat.id !== bs.seatId,
);
if (!newSeat) {
unresolved.push({
bookingRef: bs.booking.bookingRef,
reason: 'No available seat found in selected coaches',
});
this.logger.warn(
`Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`,
);
continue;
}
await this.prisma.$transaction(async tx => {
// 1. Change the seat on the booking and ticket.
await tx.bookingSeat.update({
where: { id: bs.id },
data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber },
});
await tx.ticket.updateMany({
where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg },
data: { seatId: newSeat.id },
});
// 2. Point the existing JourneySegments to the new seat.
// The Journey is already linked to this booking via bookingId;
// just update the seatId in its hop rows for this schedule.
const journey = await tx.journey.findFirst({
where: { bookingId: bs.booking.id },
select: { id: true },
});
if (!journey) {
// No Journey/JourneySegment for this booking (e.g. duplicate that was never
// processed by finalizePaymentSuccess). Create them now using the same logic,
// scoped to the booking's origin→destination leg so the seatmap shows BOOKED
// only for the correct range of stops.
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId },
orderBy: { sequence: 'asc' },
select: { stationId: true },
});
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
const fromIdx = originIdx >= 0 ? originIdx : 0;
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
const newJourney = await tx.journey.create({
data: {
passengerId: bs.booking.passengerId,
bookingId: bs.booking.id,
status: 'CONFIRMED',
totalMinor: bs.booking.totalMinor,
currency: bs.booking.currency,
} as any,
});
const segments = [];
for (let i = fromIdx; i < toIdx; i++) {
segments.push({
journeyId: newJourney.id,
scheduleId,
segmentOrder: i - fromIdx,
seatId: newSeat.id,
coachId: newSeat.coachId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
if (segments.length > 0) {
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
}
this.logger.log(
`No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`,
);
return;
}
const { count } = await tx.journeySegment.updateMany({
where: { journeyId: journey.id, scheduleId, seatId: bs.seatId },
data: { seatId: newSeat.id },
});
// Journey exists but had no segments (e.g. booking confirmed via a path
// that skipped JourneySegment creation). Create them now for the new seat
// so the seatmap reflects BOOKED.
if (count === 0) {
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId },
orderBy: { sequence: 'asc' },
select: { stationId: true },
});
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
const fromIdx = originIdx >= 0 ? originIdx : 0;
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
const segments = [];
for (let i = fromIdx; i < toIdx; i++) {
segments.push({
journeyId: journey.id,
scheduleId,
segmentOrder: i - fromIdx,
seatId: newSeat.id,
coachId: newSeat.coachId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
if (segments.length > 0) {
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
}
this.logger.log(
`Seat reassigned: ${bs.booking.bookingRef} ` +
`${bs.seat?.seatNumber ?? bs.seatId}${newSeat.seatNumber} ` +
`(0 existing segments — created ${segments.length} new hop(s))`,
);
} else {
this.logger.log(
`Seat reassigned: ${bs.booking.bookingRef} ` +
`${bs.seat?.seatNumber ?? bs.seatId}${newSeat.seatNumber} ` +
`(${count} segment hop(s) updated)`,
);
}
});
// Mark as taken so the next booking in this batch doesn't get the same seat
assignedInBatch.add(newSeat.id);
occupied.add(newSeat.id);
const oldSeatNumber = bs.seat?.seatNumber ?? '?';
const origin = bs.booking.schedule?.originStation?.name ?? '';
const dest = bs.booking.schedule?.destinationStation?.name ?? '';
if (bs.booking.contactPhone) {
const message =
`EDR: Your booking ${bs.booking.bookingRef} (${origin}${dest}): ` +
`your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` +
`We apologize for any inconvenience.`;
await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null);
}
this.logger.log(
`Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber}${newSeat.seatNumber}`,
);
results.push({
bookingRef: bs.booking.bookingRef,
oldSeatNumber,
newSeatNumber: newSeat.seatNumber,
contactPhone: bs.booking.contactPhone,
});
}
return {
resolved: results.length,
unresolved: unresolved.length,
results,
...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}),
};
}
}