Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-17 08:27:25 +00:00
16 changed files with 1227 additions and 469 deletions

View File

@@ -0,0 +1,28 @@
/**
* SQL CTE resolving the bookings riding a train schedule, as `sched_bookings
* (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`.
*
* A booking reaches a train through WAGON ALLOCATION
* (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations),
* which is what the allocation UI writes. `train_schedule_bookings` is only ever
* written by the demo seeders, so both sources are unioned: real allocations work
* and the seeded scenarios keep working.
*
* Shared so the warehouse loading queue and the train dispatch guard agree on
* exactly which bookings are on a train — if they drift, a train can be
* dispatched leaving cargo the warehouse still thinks it should load.
*/
export const SCHEDULE_BOOKINGS_CTE = `
sched_bookings AS (
SELECT ts.id AS schedule_id, wba.booking_id
FROM freight.train_schedules ts
JOIN freight.train_set_wagons tsw
ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL
JOIN freight.wagon_booking_allocations wba
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
WHERE ts.deleted_at IS NULL
UNION
SELECT tsb.train_schedule_id, tsb.booking_id
FROM freight.train_schedule_bookings tsb
WHERE tsb.deleted_at IS NULL
)`;

View File

@@ -1034,16 +1034,16 @@ export class BillingService {
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
referenceId: invoice.sourceId,
intentId: result.intentId,
providerTxnId: result.providerTxnId,
paidAt: (result.paidAt ?? new Date()).toISOString(),
});
}
// if (!result.immediateSuccess) {
// await this.payment.handlePaymentEvent({
// eventType: "payment.succeeded",
// eventId: `demo-${result.intentId}`,
// referenceId: invoice.sourceId,
// intentId: result.intentId,
// providerTxnId: result.providerTxnId,
// paidAt: (result.paidAt ?? new Date()).toISOString(),
// });
// }
if (result.immediateSuccess) {
await this.settleByPaymentId(

View File

@@ -19,6 +19,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import {
DataSource,
EntityManager,
@@ -2029,6 +2030,58 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
/**
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
* loaded onto the wagons allocated to the booking, so anything still in the
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
* booking has warehouse inventory that never made it onto a wagon (received /
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
* or drop the booking's wagon allocation so it rides a later train.
*
* Import/domestic are untouched: their cargo isn't loaded out of an origin
* warehouse, so warehouse inventory says nothing about what's aboard.
*
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
* wagon before the goods arrive is normal planning; they simply aren't aboard.
*/
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
await this.dataSource.query(
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
[scheduleId],
);
if (!route) return;
const direction = deriveTradeDirection(
{ country: route.originCountry },
{ country: route.destinationCountry },
);
if (direction !== 'EXPORT') return;
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
`WITH ${SCHEDULE_BOOKINGS_CTE}
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
FROM sched_bookings sb
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = $1
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
[scheduleId],
);
if (rows.length) {
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
throw new BadRequestException(
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
);
}
}
async dispatchSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -2038,6 +2091,8 @@ export class TrainSchedulingService {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
await this.assertImportDjiboutiMayDepart(schedule);
// Export only: don't leave received cargo behind in the warehouse.
await this.assertAllocatedCargoLoaded(scheduleId);
// A locomotive may sit on many future schedules, but it can only pull one train
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);

View File

@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Company } from '../companies/entities/company.entity';
@@ -1031,6 +1032,8 @@ export class WarehouseInventoryService {
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
continue;
}
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads
// onto a train without one. Import GRN handling is left untouched.
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
@@ -1040,6 +1043,9 @@ export class WarehouseInventoryService {
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: new Date(),
...(booking.tradeDirection === 'EXPORT'
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
: {}),
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
});
result.processedCount += 1;
@@ -1060,6 +1066,14 @@ export class WarehouseInventoryService {
/** Unload a single arrived booking into a chosen (or default) location. */
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
// a train without one. Import GRN handling is left untouched.
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const isExport = bookingRow?.tradeDirection === 'EXPORT';
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
@@ -1080,6 +1094,10 @@ export class WarehouseInventoryService {
zoneId: location.zoneId,
status: 'RECEIVED',
arrivedAt,
// Export only, and keep an already-issued GRN rather than reissuing.
...(isExport && !existing[0].grnNumber
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
@@ -1094,6 +1112,9 @@ export class WarehouseInventoryService {
weight: 0,
status: 'RECEIVED',
arrivedAt,
...(isExport
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);
@@ -1542,11 +1563,19 @@ export class WarehouseInventoryService {
// their already-allocated wagons. Reuses the single-item load() machinery.
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
/**
* Export flow this queue serves: booked -> paid -> received at the warehouse
* (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the
* booking. Which bookings ride a train comes from the shared CTE.
*/
private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
async loadableTrains(): Promise<LoadableTrainRow[]> {
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT ts.id AS "scheduleId",
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
@@ -1554,15 +1583,15 @@ export class WarehouseInventoryService {
dy.country AS "destinationCountry",
ts.status AS "status",
ts.scheduled_departure_date AS "departureTime",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
(SELECT count(*) FROM sched_bookings sb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = ts.id
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM sched_bookings sb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = ts.id
AND inv.status = 'LOADED') AS "loadedCount"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
@@ -1570,11 +1599,11 @@ export class WarehouseInventoryService {
WHERE ts.deleted_at IS NULL
AND ts.status = ANY($1)
AND EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb2
SELECT 1 FROM sched_bookings sb2
JOIN freight.warehouse_inventory inv2
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL
WHERE sb2.schedule_id = ts.id
AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
@@ -1599,22 +1628,28 @@ export class WarehouseInventoryService {
*/
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
`SELECT inv.id AS "id",
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
SELECT inv.id AS "id",
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
-- receive() stamps the GRN onto the row and mirrors it into the
-- note; prefer the column and fall back for legacy/seeded rows.
COALESCE(
inv.grn_number,
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) AS "grnNumber",
inv.inspection_status AS "inspectionStatus",
inv.status AS "status",
wl.wagon_id AS "wagonId",
wl.wagon_number AS "wagonNumber",
wl.sequence_no AS "sequenceNo"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
FROM sched_bookings sb
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
@@ -1631,15 +1666,19 @@ export class WarehouseInventoryService {
ORDER BY tsw.sequence_no ASC NULLS LAST
LIMIT 1
) wl ON true
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
WHERE sb.schedule_id = $1
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
[scheduleId],
);
return rows.map((r) => ({
...r,
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
// Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
// The row only exists once the goods were received, so requiring a GRN and
// an allocated wagon completes the chain.
loadable:
r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
}));
}
@@ -1692,6 +1731,9 @@ export class WarehouseInventoryService {
if (!item) { skip('Not assigned to this train'); continue; }
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
// Export: the GRN is raised when the goods arrive at the warehouse, and
// nothing rides a train without one.
if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
try {

View File

@@ -322,7 +322,7 @@ Payment providers send notifications to:
- \`POST /payments/webhooks/card\` (International)
## Support
- **Email:** support@edr-platform.com
- **Email:** edr_@edrsc.com
- **Documentation:** https://docs.edr-platform.com
- **Status Page:** https://status.edr-platform.com
`,

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 } : {}),
};
}
}

View File

@@ -1,6 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { SeatStatus } from '@prisma/client';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
@@ -254,438 +253,6 @@ export class TasksService {
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: detect and resolve duplicate seat assignments.
//
// Root cause: a stale RabbitMQ message, delivered after system recovery,
// re-confirmed a cancelled booking whose seat had already been assigned to
// a new booking — leaving two CONFIRMED bookings holding the same seat on
// the same schedule.
//
// Resolution (FCFS):
// • Earliest confirmed booking keeps the original seat.
// • All later duplicates are reassigned to the next free seat within the
// SAME coach type (same coach preferred; any coach of same type as
// fallback).
// • If no seat is available in that coach type the booking is flagged for
// manual intervention and logged as unresolved.
//
// Idempotent: after reassignment the BookingSeat/JourneySegment rows no
// longer share the same (seatId, scheduleId) key, so the next tick finds
// nothing to do for the same pair.
//
// Scope: only schedules departing in the last 24 h or in the future, to
// keep the per-tick DB scan bounded.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
async resolveDuplicateSeatAssignments() {
const BATCH_SIZE = 20;
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Fetch all BookingSeat rows for CONFIRMED bookings on upcoming/recent schedules.
const confirmedSeats = await this.prisma.bookingSeat.findMany({
where: {
booking: {
status: 'CONFIRMED',
schedule: { departureAt: { gte: since } },
},
},
include: {
booking: {
select: {
id: true,
bookingRef: true,
scheduleId: true,
createdAt: true,
contactPhone: true,
schedule: {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
},
},
},
seat: {
include: {
coach: {
include: { coachType: { select: { id: true, name: true } } },
},
},
},
},
});
// Group by (seatId, scheduleId). BookingSeat.scheduleId is per-leg for
// round-trips; fall back to Booking.scheduleId for single-leg bookings.
const groups = new Map<string, typeof confirmedSeats>();
for (const bs of confirmedSeats) {
if (!bs.seatId) continue;
const scheduleId = bs.scheduleId ?? bs.booking.scheduleId;
if (!scheduleId) continue;
const key = `${bs.seatId}:${scheduleId}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(bs);
}
const duplicateGroups = [...groups.values()]
.filter(g => g.length > 1)
.slice(0, BATCH_SIZE);
if (duplicateGroups.length === 0) return;
this.logger.warn(`Seat dedup: ${duplicateGroups.length} duplicate seat group(s) detected`);
// Track seats newly assigned within this run to prevent double-assignment.
const newlyAssigned = new Map<string, Set<string>>(); // scheduleId → Set<seatId>
let resolved = 0;
let unresolved = 0;
for (const group of duplicateGroups) {
// FCFS: earliest confirmed booking keeps the seat.
const sorted = [...group].sort(
(a, b) =>
new Date(a.booking.createdAt as Date).getTime() -
new Date(b.booking.createdAt as Date).getTime(),
);
const [keeper, ...duplicates] = sorted;
for (const dup of duplicates) {
const scheduleId = (dup.scheduleId ?? dup.booking.scheduleId)!;
const coachTypeId = dup.seat?.coach?.coachTypeId;
const oldCoachId = dup.seat?.coachId;
if (!coachTypeId) {
this.logger.error(
`Seat dedup: missing coachTypeId for BookingSeat ${dup.id}, booking ${dup.booking.bookingRef}`,
);
unresolved++;
continue;
}
if (!newlyAssigned.has(scheduleId)) newlyAssigned.set(scheduleId, new Set());
const takenThisRun = newlyAssigned.get(scheduleId)!;
// All seats already taken: confirmed bookings + those assigned this tick.
const occupiedIds = new Set([
...confirmedSeats
.filter(bs => (bs.scheduleId ?? bs.booking.scheduleId) === scheduleId && bs.seatId)
.map(bs => bs.seatId as string),
...takenThisRun,
]);
try {
const newSeat = await this.findReplacementSeat(scheduleId, coachTypeId, oldCoachId, occupiedIds);
if (!newSeat) {
this.logger.warn(
`Seat dedup: no available seat for booking ${dup.booking.bookingRef} ` +
`(schedule ${scheduleId}, coachType ${coachTypeId}) — manual intervention required`,
);
unresolved++;
continue;
}
await this.prisma.$transaction(async (tx) => {
// 1. Update BookingSeat to the new seat.
await tx.bookingSeat.update({
where: { id: dup.id },
data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber },
});
// 2. Update JourneySegment — look up journeyId first to avoid a
// nested-relation filter in updateMany (not supported in all Prisma versions).
const journey = await tx.journey.findUnique({
where: { bookingId: dup.booking.id } as any,
select: { id: true },
});
if (journey) {
await tx.journeySegment.updateMany({
where: { journeyId: journey.id, seatId: dup.seatId!, scheduleId },
data: { seatId: newSeat.id, coachId: newSeat.coachId },
});
}
// 3. Update Ticket seat reference (QR payload regeneration is out of scope
// here; the backoffice can trigger that separately if required).
await tx.ticket.updateMany({
where: { bookingId: dup.booking.id, seatId: dup.seatId! },
data: { seatId: newSeat.id },
});
});
takenThisRun.add(newSeat.id);
const oldLabel = dup.seat?.seatNumber ?? dup.seatId ?? '?';
const newCoach = (newSeat as any).coach;
const coachTypeName = newCoach?.coachType?.name ?? '';
const coachNumber = newCoach?.number ?? '';
const origin = dup.booking.schedule?.originStation?.name ?? '';
const dest = dup.booking.schedule?.destinationStation?.name ?? '';
if (dup.booking.contactPhone) {
const message =
`EDR: Your booking ${dup.booking.bookingRef} (${origin}${dest}): ` +
`your seat has been changed from ${oldLabel} to seat ${newSeat.seatNumber} ` +
`in coach ${coachNumber} (${coachTypeName}). ` +
`We apologize for the inconvenience.`;
await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null);
}
this.logger.log(
`Seat dedup resolved: booking ${dup.booking.bookingRef} ` +
`seat ${oldLabel}${newSeat.seatNumber} (coach ${coachNumber}, ${coachTypeName}), ` +
`keeper: ${keeper.booking.bookingRef}`,
);
resolved++;
} catch (err) {
this.logger.error(
`Seat dedup error for booking ${dup.booking.bookingRef}: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
unresolved++;
}
}
}
this.logger.log(`Seat dedup run: ${resolved} resolved, ${unresolved} unresolved`);
}
private async findReplacementSeat(
scheduleId: string,
coachTypeId: string,
preferredCoachId: string | undefined,
occupiedIds: Set<string>,
) {
const includeCoach = {
coach: { include: { coachType: { select: { id: true, name: true } } } },
};
const baseWhere = (coachId?: string) => ({
...(coachId ? { coachId } : {}),
seatNumber: { not: '' },
id: { notIn: [...occupiedIds] },
coach: { coachTypeId, assignments: { some: { scheduleId } } },
NOT: [
{ seatNumber: { startsWith: '-' } },
{ status: SeatStatus.BLOCKED },
],
});
// 1. Prefer the exact same coach.
if (preferredCoachId) {
const seat = await this.prisma.seat.findFirst({
where: baseWhere(preferredCoachId),
include: includeCoach,
orderBy: [{ row: 'asc' }, { col: 'asc' }],
});
if (seat) return seat;
}
// 2. Any coach of the same coach type assigned to this schedule.
return this.prisma.seat.findFirst({
where: baseWhere(),
include: includeCoach,
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
});
}
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: detect and resolve duplicate seat assignments caused by
// RabbitMQ-recovered events re-confirming already-cancelled bookings.
//
// Detection: group confirmed BookingSeat rows by (scheduleId, seatId, leg).
// Any group with >1 row means multiple bookings share the same physical seat.
//
// Resolution (FCFS): the booking created first keeps the seat; all later
// bookings are reassigned to an available seat in:
// 1. Same coach + same coach type (preferred)
// 2. Same coach type, any coach (fallback)
// 3. No seat available → logged, needs manual intervention
//
// Idempotency: once a duplicate's BookingSeat is updated to a new seatId it
// no longer appears in the duplicate group on the next tick — naturally safe
// to re-run without any extra flag.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
async deduplicateSeatAssignments() {
this.logger.log('Seat dedup cron started');
// Scan at most 500 confirmed seat rows per run to stay lightweight.
const confirmedSeats = await this.prisma.bookingSeat.findMany({
where: { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
select: {
id: true,
seatId: true,
scheduleId: true,
leg: true,
passengerName: true,
booking: {
select: {
id: true,
bookingRef: true,
scheduleId: true,
createdAt: true,
contactPhone: true,
},
},
seat: {
select: {
id: true,
seatNumber: true,
coachId: true,
coach: {
select: {
id: true,
number: true,
coachTypeId: true,
coachType: { select: { id: true, name: true } },
},
},
},
},
},
take: 500,
});
// Group by (effectiveScheduleId :: seatId :: leg)
type BsRow = (typeof confirmedSeats)[number];
const groups = new Map<string, BsRow[]>();
for (const bs of confirmedSeats) {
const schedId = bs.scheduleId ?? bs.booking.scheduleId;
if (!schedId) continue;
const key = `${schedId}::${bs.seatId}::${bs.leg}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(bs);
}
const duplicateGroups = [...groups.values()].filter(g => g.length > 1);
if (duplicateGroups.length === 0) return;
this.logger.warn(`Seat dedup: ${duplicateGroups.length} conflict(s) detected`);
// Build taken-seat sets keyed by (scheduleId::leg) — used when finding
// a replacement seat so we don't assign an already-occupied seat.
const takenByScheduleLeg = new Map<string, Set<string>>();
for (const bs of confirmedSeats) {
const schedId = bs.scheduleId ?? bs.booking.scheduleId;
if (!schedId) continue;
const key = `${schedId}::${bs.leg}`;
if (!takenByScheduleLeg.has(key)) takenByScheduleLeg.set(key, new Set());
takenByScheduleLeg.get(key)!.add(bs.seatId);
}
let resolved = 0;
let unresolved = 0;
for (const group of duplicateGroups) {
// FCFS: earliest booking keeps the seat
group.sort((a, b) =>
new Date(a.booking.createdAt).getTime() - new Date(b.booking.createdAt).getTime(),
);
const [winner, ...duplicates] = group;
const schedId = winner.scheduleId ?? winner.booking.scheduleId;
const coachTypeId = winner.seat.coach.coachTypeId;
const origCoachId = winner.seat.coachId;
const taken = takenByScheduleLeg.get(`${schedId}::${winner.leg}`) ?? new Set<string>();
for (const dup of duplicates) {
try {
// 1st choice: same coach + same coach type
const newSeat =
(await this.prisma.seat.findFirst({
where: {
id: { notIn: [...taken] },
status: { not: SeatStatus.BLOCKED },
coachId: origCoachId,
coach: {
coachTypeId,
assignments: { some: { scheduleId: schedId } },
},
},
select: {
id: true, seatNumber: true, coachId: true,
coach: { select: { number: true, coachType: { select: { name: true } } } },
},
})) ??
// 2nd choice: any coach within same coach type
(await this.prisma.seat.findFirst({
where: {
id: { notIn: [...taken] },
status: { not: SeatStatus.BLOCKED },
coach: {
coachTypeId,
assignments: { some: { scheduleId: schedId } },
},
},
select: {
id: true, seatNumber: true, coachId: true,
coach: { select: { number: true, coachType: { select: { name: true } } } },
},
}));
if (!newSeat) {
this.logger.warn(
`Seat dedup: no available seat in coach type for ` +
`booking ${dup.booking.bookingRef} (${dup.passengerName}) — manual intervention required`,
);
unresolved++;
continue;
}
// Atomically update BookingSeat + Ticket + JourneySegment
await this.prisma.$transaction(async (tx) => {
await tx.bookingSeat.update({
where: { id: dup.id },
data: { seatId: newSeat!.id, seatLabelSnapshot: newSeat!.seatNumber },
});
await tx.ticket.updateMany({
where: { bookingId: dup.booking.id, seatId: dup.seatId, leg: dup.leg },
data: { seatId: newSeat!.id },
});
await tx.journeySegment.updateMany({
where: {
journey: { bookingId: dup.booking.id },
seatId: dup.seatId,
scheduleId: schedId,
},
data: { seatId: newSeat!.id, coachId: newSeat!.coachId },
});
});
// Claim the new seat so subsequent duplicates in this run don't use it
taken.add(newSeat.id);
const message =
`EDR: Your seat for booking ${dup.booking.bookingRef} has been updated ` +
`due to a system correction. ` +
`New seat: ${newSeat.seatNumber}, Coach: ${newSeat.coach.number} ` +
`(${newSeat.coach.coachType.name}). We apologize for the inconvenience.`;
if (dup.booking.contactPhone) {
await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null);
}
this.logger.log(
`Seat dedup: booking ${dup.booking.bookingRef} (${dup.passengerName}) ` +
`seat ${dup.seat.seatNumber}${newSeat.seatNumber} (coach ${newSeat.coach.number})`,
);
resolved++;
} catch (err) {
this.logger.error(
`Seat dedup error for ${dup.booking.bookingRef}: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
unresolved++;
}
}
}
this.logger.log(
`Seat dedup complete: ${resolved} reassigned, ${unresolved} unresolved ` +
`across ${duplicateGroups.length} conflict(s)`,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,519 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
import { seatsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
// ── Types ─────────────────────────────────────────────────────────────────
interface DuplicateBooking {
bookingSeatId: string;
bookingId: string;
bookingRef: string;
passengerName: string;
contactPhone: string | null;
createdAt: string;
}
interface DuplicateSeatGroup {
seatId: string;
seatNumber: string;
leg: number;
bookings: DuplicateBooking[];
}
interface AvailableSeat {
seatId: string;
seatNumber: string;
}
interface CoachReport {
coachId: string;
coachNumber: string;
coachTypeName: string;
duplicates: DuplicateSeatGroup[];
availableSeats: AvailableSeat[];
}
interface ScheduleReport {
scheduleId: string;
origin: string;
destination: string;
departureAt: string;
coaches: CoachReport[];
}
interface DuplicatesResponse {
date: string;
schedules: ScheduleReport[];
totalDuplicates: number;
}
// ── Helpers ───────────────────────────────────────────────────────────────
function today() {
return new Date().toISOString().slice(0, 10);
}
function scheduleDuplicateCount(s: ScheduleReport) {
return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0);
}
// ── Resolve modal ─────────────────────────────────────────────────────────
interface ResolveModalProps {
schedule: ScheduleReport;
coach: CoachReport;
onClose: () => void;
onSuccess: () => void;
}
function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) {
// Default: pre-select all-but-first passenger in every duplicate group
const defaultSelected = new Set<string>(
coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)),
);
const [selectedSeats, setSelectedSeats] = useState<Set<string>>(defaultSelected);
// Coaches that have at least one available seat (pre-select all)
const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0);
const [selectedCoachIds, setSelectedCoachIds] = useState<Set<string>>(
new Set(coachesWithSeats.map(c => c.coachId)),
);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
seatsApi.resolveDuplicates(data),
onSuccess: (res) => {
setSuccessMsg(
`${res.resolved ?? 0} passenger(s) successfully reassigned.` +
(res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''),
);
setErrorMsg(null);
onSuccess();
},
onError: (err: any) => {
setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.');
},
});
function toggleBookingSeat(id: string) {
setSelectedSeats(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleCoach(id: string) {
setSelectedCoachIds(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function handleAssign() {
setErrorMsg(null);
if (selectedSeats.size === 0) {
setErrorMsg('Select at least one passenger to reassign.');
return;
}
if (selectedCoachIds.size === 0) {
setErrorMsg('Select at least one coach to source the replacement seat from.');
return;
}
mutation.mutate({
bookingSeatIds: [...selectedSeats],
coachIds: [...selectedCoachIds],
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Resolve Duplicates {coach.coachNumber}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{schedule.origin} {schedule.destination} · {formatDateTime(schedule.departureAt)}
</p>
</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
{/* Duplicate seat groups */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Duplicate seat assignments
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat.
</p>
{coach.duplicates.map(group => (
<div
key={`${group.seatId}-${group.leg}`}
className="rounded-lg border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-950/30 p-4"
>
<div className="flex items-center gap-2 mb-3">
<AlertCircle className="w-4 h-4 text-orange-500 shrink-0" />
<span className="text-sm font-medium text-orange-800 dark:text-orange-300">
Seat {group.seatNumber} {group.bookings.length} passengers assigned
</span>
</div>
<div className="space-y-2">
{group.bookings.map((b, idx) => {
const checked = selectedSeats.has(b.bookingSeatId);
return (
<label
key={b.bookingSeatId}
className="flex items-start gap-3 cursor-pointer rounded-lg px-3 py-2 hover:bg-orange-100 dark:hover:bg-orange-900/30 transition-colors"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleBookingSeat(b.bookingSeatId)}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{b.passengerName || '—'}
</span>
<span className="text-xs font-mono bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 px-1.5 py-0.5 rounded">
{b.bookingRef}
</span>
{idx === 0 && (
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-1.5 py-0.5 rounded">
earliest
</span>
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{b.contactPhone ?? 'No phone'} · Booked {formatDateTime(b.createdAt)}
</div>
</div>
</label>
);
})}
</div>
</div>
))}
</div>
{/* Coach selection */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Reassign to seats in
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
The system picks the first available seat in the selected coaches.
</p>
<div className="space-y-2">
{coachesWithSeats.length === 0 ? (
<p className="text-sm text-red-500">No coaches have available seats on this schedule.</p>
) : (
coachesWithSeats.map(c => (
<label
key={c.coachId}
className="flex items-center gap-3 cursor-pointer rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<input
type="checkbox"
checked={selectedCoachIds.has(c.coachId)}
onChange={() => toggleCoach(c.coachId)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{c.coachNumber}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
{c.coachTypeName}
</span>
</div>
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
{c.availableSeats.length} available
</span>
</label>
))
)}
</div>
</div>
{/* Feedback */}
{errorMsg && (
<div className="flex items-start gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-sm text-red-700 dark:text-red-400">{errorMsg}</p>
</div>
)}
{successMsg && (
<div className="flex items-start gap-2 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-4 py-3">
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
<p className="text-sm text-green-700 dark:text-green-400">{successMsg}</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{successMsg ? 'Close' : 'Cancel'}
</button>
{!successMsg && (
<button
onClick={handleAssign}
disabled={mutation.isPending || selectedSeats.size === 0 || selectedCoachIds.size === 0}
className="flex items-center gap-2 px-5 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Assign {selectedSeats.size > 0 ? `${selectedSeats.size} passenger${selectedSeats.size > 1 ? 's' : ''}` : ''}
</button>
)}
</div>
</div>
</div>
);
}
// ── Coach card ────────────────────────────────────────────────────────────
interface CoachCardProps {
coach: CoachReport;
schedule: ScheduleReport;
onResolve: () => void;
}
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
const [expanded, setExpanded] = useState(false);
const hasDuplicates = coach.duplicates.length > 0;
return (
<div className={`rounded-xl border ${hasDuplicates ? 'border-orange-200 dark:border-orange-800' : 'border-gray-200 dark:border-gray-700'} bg-white dark:bg-gray-900 overflow-hidden`}>
{/* Card header */}
<div className="flex items-center gap-4 px-5 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-900 dark:text-white">{coach.coachNumber}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{coach.coachTypeName}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
<span>{coach.availableSeats.length} available seats</span>
{hasDuplicates && (
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400 font-medium">
<AlertCircle className="w-3.5 h-3.5" />
{coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{hasDuplicates && (
<button
onClick={onResolve}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors"
>
Resolve
</button>
)}
{hasDuplicates && (
<button
onClick={() => setExpanded(v => !v)}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 transition-colors"
title={expanded ? 'Collapse' : 'View passengers'}
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
)}
</div>
</div>
{/* Expanded passenger list */}
{expanded && hasDuplicates && (
<div className="border-t border-gray-100 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
{coach.duplicates.map(group => (
<div key={`${group.seatId}-${group.leg}`} className="px-5 py-3">
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400 mb-2">
Seat {group.seatNumber} {group.bookings.length} passengers
</p>
<div className="space-y-2">
{group.bookings.map((b, idx) => (
<div key={b.bookingSeatId} className="flex items-center gap-3 text-sm">
<span className="w-5 h-5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs flex items-center justify-center font-medium shrink-0">
{idx + 1}
</span>
<div className="flex-1 min-w-0">
<span className="font-medium text-gray-900 dark:text-white">{b.passengerName || '—'}</span>
<span className="ml-2 text-xs font-mono text-gray-500 dark:text-gray-400">{b.bookingRef}</span>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{b.contactPhone ?? '—'}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────
export default function DiscrepancyPage() {
const [date, setDate] = useState(today());
const [searchDate, setSearchDate] = useState('');
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
const { data, isLoading, isError, refetch } = useQuery<DuplicatesResponse>({
queryKey: ['seat-duplicates', searchDate],
queryFn: () => seatsApi.getDuplicates(searchDate),
enabled: !!searchDate,
});
function handleSearch() {
if (date) setSearchDate(date);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') handleSearch();
}
return (
<div className="p-6 space-y-6 max-w-5xl mx-auto">
{/* Page header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-950/40">
<Layers className="w-6 h-6 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seat Discrepancy</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Detect and resolve duplicate seat assignments by schedule date
</p>
</div>
</div>
{/* Date picker */}
<div className="flex items-center gap-3">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
onKeyDown={handleKeyDown}
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleSearch}
disabled={!date || isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
Search
</button>
</div>
{/* Error */}
{isError && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
<AlertCircle className="w-4 h-4 shrink-0" />
Failed to load duplicate seat data. Please try again.
</div>
)}
{/* Summary banner */}
{data && (
<div className={`rounded-xl border px-5 py-4 flex items-center gap-3 ${
data.totalDuplicates > 0
? 'bg-orange-50 dark:bg-orange-950/20 border-orange-200 dark:border-orange-800'
: 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
}`}>
{data.totalDuplicates > 0 ? (
<AlertCircle className="w-5 h-5 text-orange-500 shrink-0" />
) : (
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
)}
<span className={`text-sm font-medium ${
data.totalDuplicates > 0
? 'text-orange-800 dark:text-orange-300'
: 'text-green-800 dark:text-green-300'
}`}>
{data.totalDuplicates > 0
? `${data.totalDuplicates} duplicate seat assignment${data.totalDuplicates > 1 ? 's' : ''} found across ${data.schedules.length} schedule${data.schedules.length > 1 ? 's' : ''} on ${data.date}`
: `No duplicate seat assignments found on ${data.date}`}
</span>
</div>
)}
{/* Results per schedule */}
{data?.schedules.map(schedule => (
<div key={schedule.scheduleId} className="space-y-3">
{/* Schedule header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
{schedule.origin} {schedule.destination}
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Coach cards grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{schedule.coaches.map(coach => (
<CoachCard
key={coach.coachId}
coach={coach}
schedule={schedule}
onResolve={() => setResolveTarget({ schedule, coach })}
/>
))}
</div>
</div>
))}
{/* Empty state when searched but no results */}
{data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<CheckCircle2 className="w-12 h-12 text-green-400 mb-3" />
<p className="text-gray-500 dark:text-gray-400">All seats are correctly assigned for {data.date}</p>
</div>
)}
{/* Resolve modal */}
{resolveTarget && (
<ResolveModal
schedule={resolveTarget.schedule}
coach={resolveTarget.coach}
onClose={() => setResolveTarget(null)}
onSuccess={() => {
refetch();
// Keep modal open to show success message; user closes manually
}}
/>
)}
</div>
);
}

View File

@@ -365,7 +365,7 @@ export default function LoginPage() {
Back-office · v1.0
</span>
<span className="text-xs text-gray-400 dark:text-gray-600">
Need help? <a href="mailto:support@edr.com" className="text-[rgb(20,113,76)] hover:underline">support@edr.com</a>
Need help? <a href="mailto:edr_@edrsc.com" className="text-[rgb(20,113,76)] hover:underline">edr_@edrsc.com</a>
</span>
</div>
</div>

View File

@@ -94,11 +94,11 @@ export default function SettingsPage() {
</div>
<div>
<label className="label">Support Email</label>
<input type="email" className="input" defaultValue="support@edr-platform.com" />
<input type="email" className="input" defaultValue="edr_@edrsc.com" />
</div>
<div>
<label className="label">Support Phone</label>
<input type="tel" className="input" defaultValue="+251911234567" />
<input type="tel" className="input" defaultValue="+2519546" />
</div>
<div>
<label className="label">Default Currency</label>

View File

@@ -37,6 +37,7 @@ import {
Banknote,
Activity,
Smartphone,
Layers,
} from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
@@ -64,7 +65,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view },
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
{ name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage },
]
},
{

View File

@@ -159,6 +159,10 @@ export const seatsApi = {
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`),
getDuplicates: (date: string, scheduleId?: string) =>
apiClient.get<any>(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`),
resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
apiClient.post<any>('/seats/duplicates/resolve', data),
};
// Payments API