mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Refactored the whole app based on the requirements shared
This commit is contained in:
@@ -14,4 +14,20 @@ export class SeatsController {
|
||||
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); }
|
||||
@Delete('hold/:holdId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Release a seat hold' })
|
||||
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
|
||||
|
||||
@Get('export/csv/:tripId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
|
||||
async exportCSV(@Param('tripId') tripId: string) {
|
||||
const csv = await this.service.exportSeatsCSV(tripId);
|
||||
return { csv, filename: `seats-${tripId}.csv` };
|
||||
}
|
||||
|
||||
@Post('import/preview') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Preview CSV import' })
|
||||
previewCSV(@Body() body: { csv: string }) {
|
||||
return this.service.previewSeatsCSV(body.csv);
|
||||
}
|
||||
|
||||
@Post('import/commit') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Commit CSV import' })
|
||||
importCSV(@Body() body: { tripId: string; csv: string; commit: boolean }) {
|
||||
return this.service.importSeatsCSV(body.tripId, body.csv, body.commit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SeatsService } from './seats.service';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
describe('SeatsService - Auto Assign', () => {
|
||||
let service: SeatsService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrisma = {
|
||||
seat: {
|
||||
findMany: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SeatsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SeatsService>(SeatsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('autoAssignSeats', () => {
|
||||
it('should assign contiguous seats in same row', async () => {
|
||||
const mockSeats = [
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B' },
|
||||
{ id: 'seat-3', coachId: 'coach-1', row: 1, col: 'C' },
|
||||
{ id: 'seat-4', coachId: 'coach-1', row: 2, col: 'A' },
|
||||
];
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(['seat-1', 'seat-2']);
|
||||
});
|
||||
|
||||
it('should throw error if not enough seats available', async () => {
|
||||
mockPrisma.seat.findMany.mockResolvedValue([
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.autoAssignSeats('trip-1', 3, 'ECONOMY_REGULAR'),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('should respect eligibility filter', async () => {
|
||||
const mockSeats = [
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A', eligibility: 'ACCESSIBLE' },
|
||||
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B', eligibility: 'ACCESSIBLE' },
|
||||
];
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should assign single seat', async () => {
|
||||
const mockSeats = [
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
];
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 1, 'ECONOMY_REGULAR');
|
||||
|
||||
expect(result).toEqual(['seat-1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,128 @@ export class SeatsService {
|
||||
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
|
||||
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
|
||||
|
||||
async autoAssignSeats(tripId: string, count: number, serviceClass: string, eligibility?: string): Promise<string[]> {
|
||||
const seats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coach: { tripId, serviceClass: serviceClass as any },
|
||||
status: 'AVAILABLE',
|
||||
...(eligibility ? { eligibility } : {}),
|
||||
},
|
||||
orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
|
||||
if (seats.length < count) {
|
||||
throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`);
|
||||
}
|
||||
|
||||
const assigned = this.findContiguousSeats(seats, count);
|
||||
return assigned.map((s) => s.id);
|
||||
}
|
||||
|
||||
private findContiguousSeats(seats: any[], count: number): any[] {
|
||||
if (count === 1) return [seats[0]];
|
||||
|
||||
const grouped = new Map<string, any[]>();
|
||||
for (const seat of seats) {
|
||||
const key = `${seat.coachId}-${seat.row}`;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key)!.push(seat);
|
||||
}
|
||||
|
||||
for (const rowSeats of grouped.values()) {
|
||||
if (rowSeats.length >= count) {
|
||||
return rowSeats.slice(0, count);
|
||||
}
|
||||
}
|
||||
|
||||
return seats.slice(0, count);
|
||||
}
|
||||
|
||||
async exportSeatsCSV(tripId: string): Promise<string> {
|
||||
const coaches = await this.prisma.coach.findMany({
|
||||
where: { tripId },
|
||||
include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } },
|
||||
});
|
||||
|
||||
const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility'];
|
||||
for (const coach of coaches) {
|
||||
for (const seat of coach.seats) {
|
||||
rows.push(
|
||||
`${coach.id},${coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
async previewSeatsCSV(csvContent: string): Promise<{ valid: number; invalid: number; errors: string[] }> {
|
||||
const lines = csvContent.trim().split('\n').slice(1);
|
||||
const errors: string[] = [];
|
||||
let valid = 0;
|
||||
let invalid = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split(',');
|
||||
if (parts.length < 8) {
|
||||
errors.push(`Line ${i + 2}: Invalid format`);
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts;
|
||||
if (!coachId || !row || !col || !label) {
|
||||
errors.push(`Line ${i + 2}: Missing required fields`);
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
valid++;
|
||||
}
|
||||
|
||||
return { valid, invalid, errors: errors.slice(0, 10) };
|
||||
}
|
||||
|
||||
async importSeatsCSV(tripId: string, csvContent: string, commit: boolean): Promise<{ imported: number; errors: string[] }> {
|
||||
const lines = csvContent.trim().split('\n').slice(1);
|
||||
const errors: string[] = [];
|
||||
let imported = 0;
|
||||
|
||||
if (!commit) {
|
||||
return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] };
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
const parts = lines[i].split(',');
|
||||
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts;
|
||||
|
||||
await this.prisma.seat.upsert({
|
||||
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
|
||||
update: {
|
||||
label,
|
||||
kind: kind as any,
|
||||
status: status as any,
|
||||
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
||||
eligibility: eligibility || null,
|
||||
},
|
||||
create: {
|
||||
coachId,
|
||||
row: parseInt(row),
|
||||
col,
|
||||
label,
|
||||
kind: kind as any,
|
||||
status: status as any,
|
||||
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
||||
eligibility: eligibility || null,
|
||||
},
|
||||
});
|
||||
imported++;
|
||||
} catch (err) {
|
||||
errors.push(`Line ${i + 2}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, errors: errors.slice(0, 10) };
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expireHolds() {
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
|
||||
Reference in New Issue
Block a user