mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
@@ -46,7 +47,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get('by-device')
|
||||
@ApiOperation({
|
||||
@IsPublic()
|
||||
@ApiOperation({
|
||||
summary: 'Get bookings by device ID',
|
||||
description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.'
|
||||
})
|
||||
@@ -100,7 +102,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post('guest')
|
||||
@ApiOperation({
|
||||
@IsPublic()
|
||||
@ApiOperation({
|
||||
summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)',
|
||||
description: `Creates a booking without requiring login. Supports all four booking types.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
|
||||
import { FleetService } from './fleet.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Fleet')
|
||||
@@ -317,6 +317,39 @@ export class FleetController {
|
||||
return this.service.removeAssignment(id);
|
||||
}
|
||||
|
||||
@Post('seatmap/generate')
|
||||
@ApiOperation({
|
||||
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
|
||||
description: `Generates a structured seat map for bed coaches without persisting anything.
|
||||
|
||||
**ECONOMY_BED**: 6 beds per room — Left(Lower/Middle/Upper) + Right(Lower/Middle/Upper)
|
||||
|
||||
**VIP_BED**: 4 beds per room — Left(Lower/Upper) + Right(Lower/Upper)
|
||||
|
||||
Use this to preview the full flat seat list before creating coaches.`,
|
||||
})
|
||||
@ApiBody({ type: GenerateSeatMapDto })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Generated seat map preview',
|
||||
schema: {
|
||||
example: {
|
||||
coachCount: 1, roomsPerCoach: 2, roomType: 'ECONOMY_BED', bedsPerRoom: 6, totalBeds: 12,
|
||||
seats: [
|
||||
{ seat_id: 'C1-C1-R1-S1', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'LOWER', sequence_number: 1, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S2', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'MIDDLE', sequence_number: 2, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S3', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'UPPER', sequence_number: 3, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S4', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'LOWER', sequence_number: 4, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S5', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'MIDDLE', sequence_number: 5, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S6', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'UPPER', sequence_number: 6, status: 'AVAILABLE' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
generateSeatMap(@Body() dto: GenerateSeatMapDto) {
|
||||
return this.service.generateSeatMapPreview(dto);
|
||||
}
|
||||
|
||||
@Get('analytics')
|
||||
@ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
|
||||
@ApiResponse({ status: 200, description: 'Occupancy statistics' })
|
||||
|
||||
@@ -12,9 +12,20 @@ export class CreateTrainDto {
|
||||
export class CreateCoachDto {
|
||||
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
|
||||
@ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string;
|
||||
@ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number;
|
||||
@ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string;
|
||||
@ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number;
|
||||
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
|
||||
@ApiPropertyOptional({
|
||||
enum: ['ECONOMY_BED', 'VIP_BED'],
|
||||
description: 'Bed coach category. Set to generate bed/sleeper compartments instead of regular seats. Overrides name-based detection.',
|
||||
example: 'VIP_BED',
|
||||
})
|
||||
@IsOptional() @IsString() bedCategory?: 'ECONOMY_BED' | 'VIP_BED';
|
||||
@ApiPropertyOptional({
|
||||
example: 4,
|
||||
description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.',
|
||||
})
|
||||
@IsOptional() @IsInt() bedsPerRoom?: number;
|
||||
}
|
||||
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
|
||||
@@ -67,3 +78,14 @@ export class UpdateClassDto {
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class GenerateSeatMapDto {
|
||||
@ApiProperty({ example: 2, description: 'Number of coaches' })
|
||||
@IsInt() coachCount: number;
|
||||
|
||||
@ApiProperty({ example: 9, description: 'Number of rooms (compartments) per coach' })
|
||||
@IsInt() roomsPerCoach: number;
|
||||
|
||||
@ApiProperty({ enum: ['ECONOMY_BED', 'VIP_BED'], example: 'ECONOMY_BED', description: 'ECONOMY_BED = 6 beds/room (L/M/U × Left/Right), VIP_BED = 4 beds/room (L/U × Left/Right)' })
|
||||
@IsString() roomType: 'ECONOMY_BED' | 'VIP_BED';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
import { SeatKind } from '@prisma/client';
|
||||
|
||||
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
|
||||
@@ -33,41 +33,97 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] {
|
||||
type BedCategory = 'ECONOMY_BED' | 'VIP_BED' | null;
|
||||
|
||||
// Default beds per room for each category when not explicitly configured
|
||||
const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
|
||||
VIP_BED: 4,
|
||||
ECONOMY_BED: 6,
|
||||
};
|
||||
|
||||
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
|
||||
function detectBedCategory(coachTypeName: string): BedCategory {
|
||||
const name = coachTypeName.toLowerCase();
|
||||
const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette');
|
||||
if (!isBed) return null;
|
||||
if (name.includes('vip')) return 'VIP_BED';
|
||||
return 'ECONOMY_BED';
|
||||
}
|
||||
|
||||
// Resolves bed type names per side from beds-per-side count:
|
||||
// 2/side → ['LOWER','UPPER'] (VIP style)
|
||||
// 3/side → ['LOWER','MIDDLE','UPPER'] (Economy style)
|
||||
function resolveBedTypes(bedsPerSide: number): string[] {
|
||||
if (bedsPerSide === 1) return ['LOWER'];
|
||||
if (bedsPerSide === 2) return ['LOWER', 'UPPER'];
|
||||
if (bedsPerSide === 3) return ['LOWER', 'MIDDLE', 'UPPER'];
|
||||
return Array.from({ length: bedsPerSide }, (_, i) => {
|
||||
if (i === 0) return 'LOWER';
|
||||
if (i === bedsPerSide - 1) return 'UPPER';
|
||||
return 'MIDDLE';
|
||||
});
|
||||
}
|
||||
|
||||
// Generates the flat seat/bed list for a bed coach.
|
||||
// Row = room number; col = position-relative label (L1, L2 … R1, R2 …).
|
||||
function buildBedSeats(
|
||||
coachId: string,
|
||||
capacity: number,
|
||||
bedsPerRoom: number,
|
||||
): SeatRow[] {
|
||||
const bedsPerSide = bedsPerRoom / 2;
|
||||
const bedTypeNames = resolveBedTypes(bedsPerSide);
|
||||
const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [
|
||||
...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })),
|
||||
...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })),
|
||||
];
|
||||
|
||||
const roomCount = Math.ceil(capacity / bedsPerRoom);
|
||||
const seats: SeatRow[] = [];
|
||||
let seatNumber = 1;
|
||||
|
||||
for (let room = 1; room <= roomCount; room++) {
|
||||
const posCount: Record<string, number> = {};
|
||||
for (let slot = 0; slot < bedsPerRoom && seats.length < capacity; slot++) {
|
||||
const { position, bedType } = layout[slot];
|
||||
posCount[position] = (posCount[position] ?? 0) + 1;
|
||||
const col = `${position[0]}${posCount[position]}`;
|
||||
seats.push({
|
||||
coachId,
|
||||
row: room,
|
||||
col,
|
||||
seatNumber: `${seatNumber}`,
|
||||
kind: SeatKind.STANDARD,
|
||||
bedPosition: bedType.toLowerCase(),
|
||||
isWindow: false,
|
||||
isAisle: false,
|
||||
});
|
||||
seatNumber++;
|
||||
}
|
||||
}
|
||||
return seats;
|
||||
}
|
||||
|
||||
function buildRegularSeats(coachId: string, arrangement: string, capacity: number): SeatRow[] {
|
||||
const cols = seatCols(arrangement);
|
||||
const groups = parseArrangement(arrangement);
|
||||
const seats: SeatRow[] = [];
|
||||
let row = 1;
|
||||
let seatNumber = 1;
|
||||
let seatIndex = 0;
|
||||
const isBedCoach = seatClass?.toLowerCase().includes('bed');
|
||||
const totalCols = cols.length;
|
||||
|
||||
while (seatIndex < capacity) {
|
||||
for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) {
|
||||
const col = cols[ci];
|
||||
let bedPosition = null;
|
||||
|
||||
// Set bedPosition for bed coaches based on ROW cycling (not seat number)
|
||||
if (isBedCoach) {
|
||||
if (totalCols === 3) {
|
||||
// Economy bed (3-row cycle): upper, middle, lower
|
||||
if (row % 3 === 1) bedPosition = 'upper';
|
||||
else if (row % 3 === 2) bedPosition = 'middle';
|
||||
else bedPosition = 'lower';
|
||||
} else if (totalCols === 2) {
|
||||
// VIP bed (2-row cycle): upper, lower
|
||||
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
|
||||
}
|
||||
}
|
||||
|
||||
seats.push({
|
||||
coachId,
|
||||
row,
|
||||
col,
|
||||
seatNumber: `${seatNumber}`,
|
||||
kind: SeatKind.STANDARD,
|
||||
bedPosition,
|
||||
bedPosition: null,
|
||||
isWindow: isWindowCol(ci, groups),
|
||||
isAisle: isAisleCol(ci, groups),
|
||||
});
|
||||
seatNumber++;
|
||||
seatIndex++;
|
||||
@@ -84,6 +140,8 @@ type SeatRow = {
|
||||
seatNumber: string;
|
||||
kind: SeatKind;
|
||||
bedPosition?: string | null;
|
||||
isWindow?: boolean;
|
||||
isAisle?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -332,8 +390,20 @@ export class FleetService {
|
||||
});
|
||||
|
||||
if (dto.capacity > 0) {
|
||||
const seatClass = coach.coachType?.name || '';
|
||||
const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass);
|
||||
// dto.bedCategory takes priority; fall back to name-based detection
|
||||
const bedCategory: BedCategory = dto.bedCategory ?? detectBedCategory(coach.coachType?.name || '');
|
||||
let seats: SeatRow[];
|
||||
|
||||
if (bedCategory) {
|
||||
const bedsPerRoom = dto.bedsPerRoom ?? DEFAULT_BEDS_PER_ROOM[bedCategory];
|
||||
if (bedsPerRoom < 2 || bedsPerRoom % 2 !== 0) {
|
||||
throw new BadRequestException('bedsPerRoom must be an even number ≥ 2');
|
||||
}
|
||||
seats = buildBedSeats(coach.id, dto.capacity, bedsPerRoom);
|
||||
} else {
|
||||
seats = buildRegularSeats(coach.id, dto.arrangement, dto.capacity);
|
||||
}
|
||||
|
||||
await this.prisma.seat.createMany({ data: seats });
|
||||
}
|
||||
|
||||
@@ -425,6 +495,50 @@ export class FleetService {
|
||||
return this.prisma.coachAssignment.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async generateSeatMapPreview(dto: GenerateSeatMapDto) {
|
||||
const { coachCount, roomsPerCoach, roomType } = dto;
|
||||
const bedsPerRoom = DEFAULT_BEDS_PER_ROOM[roomType];
|
||||
const bedTypeNames = resolveBedTypes(bedsPerRoom / 2);
|
||||
const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [
|
||||
...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })),
|
||||
...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })),
|
||||
];
|
||||
const seats: object[] = [];
|
||||
let globalSeq = 1;
|
||||
|
||||
for (let c = 1; c <= coachCount; c++) {
|
||||
const coachLabel = `C${c}`;
|
||||
for (let r = 1; r <= roomsPerCoach; r++) {
|
||||
const roomLabel = `R${r}`;
|
||||
const posCount: Record<string, number> = {};
|
||||
for (let s = 0; s < bedsPerRoom; s++) {
|
||||
const { position, bedType } = layout[s];
|
||||
posCount[position] = (posCount[position] ?? 0) + 1;
|
||||
seats.push({
|
||||
seat_id: `${coachLabel}-${roomLabel}-S${globalSeq}`,
|
||||
coach_id: coachLabel,
|
||||
room_id: `${coachLabel}-${roomLabel}`,
|
||||
category: roomType,
|
||||
position,
|
||||
col: `${position[0]}${posCount[position]}`,
|
||||
bed_type: bedType,
|
||||
sequence_number: globalSeq,
|
||||
status: 'AVAILABLE',
|
||||
});
|
||||
globalSeq++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
coachCount,
|
||||
roomsPerCoach,
|
||||
roomType,
|
||||
bedsPerRoom,
|
||||
totalBeds: seats.length,
|
||||
seats,
|
||||
};
|
||||
}
|
||||
|
||||
async getAnalytics() {
|
||||
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
|
||||
this.prisma.train.count(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { SkipThrottle, Throttle } from "@nestjs/throttler";
|
||||
import { Response } from "express";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
@@ -64,6 +65,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Post("initiate")
|
||||
@IsPublic()
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment with nationality-based payment methods",
|
||||
description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`,
|
||||
@@ -73,12 +75,14 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("intents/:bookingId")
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||
getIntent(@Param("bookingId") bookingId: string) {
|
||||
return this.service.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Get("waafi/return")
|
||||
@IsPublic()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " +
|
||||
@@ -119,6 +123,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("methods")
|
||||
@IsPublic()
|
||||
@ApiOperation({
|
||||
summary: "List payment systems supported by the platform",
|
||||
description:
|
||||
@@ -131,6 +136,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@IsPublic()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
|
||||
@@ -835,7 +835,7 @@ export class PaymentsService {
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const journeySegments: any[] = [];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -23,6 +24,7 @@ export class SchedulesController {
|
||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||
|
||||
@Get()
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List schedules with optional filters' })
|
||||
@ApiQuery({ name: 'date', required: false })
|
||||
@ApiQuery({ name: 'routeId', required: false })
|
||||
@@ -67,6 +69,7 @@ export class SchedulesController {
|
||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||
|
||||
@Get('routes/:routeId/segment-fares')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
||||
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
||||
@@ -86,6 +89,7 @@ export class SchedulesController {
|
||||
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
|
||||
|
||||
@Get(':id')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Get schedule detail' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
||||
@@ -113,6 +117,7 @@ export class SchedulesController {
|
||||
deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); }
|
||||
|
||||
@Get(':id/stops')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List all stops for a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
|
||||
@ApiTags('Search')
|
||||
@Controller('search')
|
||||
@IsPublic()
|
||||
export class SearchController {
|
||||
constructor(private service: SearchService) {}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SeatClassesService } from './seat-classes.service';
|
||||
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -10,11 +11,13 @@ export class SeatClassesController {
|
||||
constructor(private service: SeatClassesService) {}
|
||||
|
||||
@Get()
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List all seat classes' })
|
||||
@ApiResponse({ status: 200, description: 'Returns all seat classes with their coaches' })
|
||||
listSeatClasses() { return this.service.listSeatClasses(); }
|
||||
|
||||
@Get(':id')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Get a seat class by ID' })
|
||||
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Returns seat class with its coaches' })
|
||||
|
||||
@@ -1,36 +1,64 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { SeatsService } from './seats.service';
|
||||
import { HoldSeatsDto } from './seats.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Patch,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto } from "./seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { IamGuard } from "../../common/iam-adapter";
|
||||
|
||||
@ApiTags('Seats')
|
||||
@Controller('seats')
|
||||
@ApiTags("Seats")
|
||||
@Controller("seats")
|
||||
export class SeatsController {
|
||||
constructor(private service: SeatsService) {}
|
||||
|
||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||
@Get('seatmap/:scheduleId')
|
||||
@ApiOperation({
|
||||
summary: 'Get seat map with real-time availability by class',
|
||||
description: `Returns seat map for a schedule with availability by seat class:
|
||||
- Economy Regular
|
||||
- Economy Bed
|
||||
- VIP Bed
|
||||
|
||||
Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
|
||||
@Get("seatmap/:scheduleId")
|
||||
@ApiOperation({
|
||||
summary: "Get seat map filtered by coach type",
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
|
||||
})
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' })
|
||||
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); }
|
||||
@ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
|
||||
@ApiQuery({
|
||||
name: "coachTypeId",
|
||||
required: false,
|
||||
description:
|
||||
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description:
|
||||
"List of coaches of the given type with their seats and availability",
|
||||
})
|
||||
getSeatMap(
|
||||
@Param("scheduleId") scheduleId: string,
|
||||
@Query("coachTypeId") coachTypeId?: string,
|
||||
) {
|
||||
return this.service.getSeatMap(scheduleId, coachTypeId);
|
||||
}
|
||||
|
||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||
@Get('holds')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@Get("holds")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: 'List active seat holds with full leg context',
|
||||
summary: "List active seat holds with full leg context",
|
||||
description: `Returns all non-expired holds enriched with:
|
||||
- **schedule**: train number, departure/arrival, full route origin→destination
|
||||
- **leg**: the specific origin→destination this hold covers (station name, code, stop sequence)
|
||||
@@ -39,25 +67,45 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
|
||||
|
||||
This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`,
|
||||
})
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter by TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'passengerId', required: false, description: 'Filter by Passenger UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Active holds with schedule, leg, and seat details' })
|
||||
@ApiQuery({
|
||||
name: "scheduleId",
|
||||
required: false,
|
||||
description: "Filter by TrainSchedule UUID",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "passengerId",
|
||||
required: false,
|
||||
description: "Filter by Passenger UUID",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Active holds with schedule, leg, and seat details",
|
||||
})
|
||||
getHolds(
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
@Query('passengerId') passengerId?: string,
|
||||
) { return this.service.getHolds(scheduleId, passengerId); }
|
||||
@Query("scheduleId") scheduleId?: string,
|
||||
@Query("passengerId") passengerId?: string,
|
||||
) {
|
||||
return this.service.getHolds(scheduleId, passengerId);
|
||||
}
|
||||
|
||||
@Get('holds/:holdId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Get a single hold with full leg context' })
|
||||
@ApiParam({ name: 'holdId', description: 'SeatHold UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Hold with schedule, leg, and seat details' })
|
||||
@ApiResponse({ status: 404, description: 'Hold not found' })
|
||||
getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); }
|
||||
@Get("holds/:holdId")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Get a single hold with full leg context" })
|
||||
@ApiParam({ name: "holdId", description: "SeatHold UUID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Hold with schedule, leg, and seat details",
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Hold not found" })
|
||||
getHold(@Param("holdId") holdId: string) {
|
||||
return this.service.getHold(holdId);
|
||||
}
|
||||
|
||||
@Post('hold')
|
||||
@ApiOperation({
|
||||
summary: 'Hold seats for 15 minutes before booking (Public - Guest booking supported)',
|
||||
@Post("hold")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Hold seats for 15 minutes before booking (Public - Guest booking supported)",
|
||||
description: `Temporarily reserves seats for a passenger to complete booking.
|
||||
|
||||
**Features:**
|
||||
@@ -65,74 +113,107 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
- Auto-release after expiry
|
||||
- Prevents double booking
|
||||
- Required before creating booking
|
||||
- **Public endpoint** - No authentication required (supports guest booking)`
|
||||
- **Public endpoint** - No authentication required (supports guest booking)`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Seats held successfully with holdId' })
|
||||
@ApiResponse({ status: 409, description: 'One or more seats unavailable' })
|
||||
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); }
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Seats held successfully with holdId",
|
||||
})
|
||||
@ApiResponse({ status: 409, description: "One or more seats unavailable" })
|
||||
holdSeats(@Body() dto: HoldSeatsDto) {
|
||||
return this.service.holdSeats(dto);
|
||||
}
|
||||
|
||||
@Delete('hold/:holdId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Release a seat hold' })
|
||||
@ApiParam({ name: 'holdId', description: 'Hold UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Hold released' })
|
||||
@ApiResponse({ status: 404, description: 'Hold not found' })
|
||||
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
|
||||
@Delete("hold/:holdId")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Release a seat hold" })
|
||||
@ApiParam({ name: "holdId", description: "Hold UUID" })
|
||||
@ApiResponse({ status: 200, description: "Hold released" })
|
||||
@ApiResponse({ status: 404, description: "Hold not found" })
|
||||
releaseHold(@Param("holdId") holdId: string) {
|
||||
return this.service.releaseHold(holdId);
|
||||
}
|
||||
|
||||
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
||||
@Post(':seatId/block')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat blocked' })
|
||||
blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) {
|
||||
@Post(":seatId/block")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({ status: 200, description: "Seat blocked" })
|
||||
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
|
||||
return this.service.blockSeat(seatId, body.reason);
|
||||
}
|
||||
|
||||
@Delete(':seatId/block')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Unblock a seat' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat unblocked' })
|
||||
unblockSeat(@Param('seatId') seatId: string) {
|
||||
@Delete(":seatId/block")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Unblock a seat" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({ status: 200, description: "Seat unblocked" })
|
||||
unblockSeat(@Param("seatId") seatId: string) {
|
||||
return this.service.unblockSeat(seatId);
|
||||
}
|
||||
|
||||
// ── Remove Seat ────────────────────────────────────────────────────────────
|
||||
@Patch(':seatId/remove')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' })
|
||||
@ApiResponse({ status: 404, description: 'Seat not found' })
|
||||
removeSeat(@Param('seatId') seatId: string) {
|
||||
@Patch(":seatId/remove")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Remove a seat by marking with negative seatNumber",
|
||||
})
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Seat removed (seatNumber negated), shows as empty space",
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Seat not found" })
|
||||
removeSeat(@Param("seatId") seatId: string) {
|
||||
return this.service.removeSeat(seatId);
|
||||
}
|
||||
|
||||
@Patch(':seatId/undo-remove')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' })
|
||||
@ApiResponse({ status: 404, description: 'Seat not found' })
|
||||
@ApiResponse({ status: 400, description: 'Seat is not removed' })
|
||||
undoRemoveSeat(@Param('seatId') seatId: string) {
|
||||
@Patch(":seatId/undo-remove")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Undo seat removal by restoring original seatNumber",
|
||||
})
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Seat restored (negative seatNumber removed)",
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Seat not found" })
|
||||
@ApiResponse({ status: 400, description: "Seat is not removed" })
|
||||
undoRemoveSeat(@Param("seatId") seatId: string) {
|
||||
return this.service.undoRemoveSeat(seatId);
|
||||
}
|
||||
|
||||
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
|
||||
async exportCSV(@Param('scheduleId') scheduleId: string) {
|
||||
@Get("export/csv/:scheduleId")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Export seats as CSV" })
|
||||
async exportCSV(@Param("scheduleId") scheduleId: string) {
|
||||
const csv = await this.service.exportSeatsCSV(scheduleId);
|
||||
return { csv, filename: `seats-${scheduleId}.csv` };
|
||||
}
|
||||
|
||||
@Post('import/preview') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Preview CSV import' })
|
||||
@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: { scheduleId: string; csv: string; commit: boolean }) {
|
||||
@Post("import/commit")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Commit CSV import" })
|
||||
importCSV(
|
||||
@Body() body: { scheduleId: string; csv: string; commit: boolean },
|
||||
) {
|
||||
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,18 @@ export class SeatsService {
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) {
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { originStationId: true, destinationStationId: true },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const assignments = await this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId, ...(coachId ? { coachId } : {}) },
|
||||
where: {
|
||||
scheduleId,
|
||||
...(coachTypeId ? { coach: { coachTypeId } } : {}),
|
||||
},
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
@@ -28,48 +37,110 @@ export class SeatsService {
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId);
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
const allSeats = a.coach.seats;
|
||||
const allSeats = a.coach.seats;
|
||||
const coachTypeName = a.coach.coachType?.name ?? '';
|
||||
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
||||
const isBedCoach = this.isBedCoach(coachTypeName);
|
||||
// Compute actual beds-per-room from first room to correctly identify VIP (4) vs Economy (6)
|
||||
const bedsPerRoom = isBedCoach
|
||||
? allSeats.filter((s: any) => s.row === (allSeats[0] as any)?.row).length
|
||||
: 0;
|
||||
const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null;
|
||||
|
||||
return {
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
coachNumber: a.coach.number,
|
||||
label: a.coach.number,
|
||||
mode: a.coach.status,
|
||||
name: `Coach ${a.coach.number}`,
|
||||
seatClasses: seatClassNames,
|
||||
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
|
||||
const mappedSeats = allSeats.map((s: any) => ({
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
label: s.seatNumber,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
isWindow: s.isWindow,
|
||||
isAisle: s.isAisle,
|
||||
// Bed-specific fields
|
||||
...(isBedCoach ? {
|
||||
room_id: `${a.coach.id}-R${s.row}`,
|
||||
category: bedCategory,
|
||||
position: this.colToPosition(s.col),
|
||||
bed_type: this.bedPositionToType(s.bedPosition),
|
||||
bedPosition: s.bedPosition,
|
||||
} : {
|
||||
bedPosition: s.bedPosition,
|
||||
}),
|
||||
}));
|
||||
|
||||
const base = {
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
coachNumber: a.coach.number,
|
||||
label: a.coach.number,
|
||||
mode: a.coach.status,
|
||||
name: `Coach ${a.coach.number}`,
|
||||
coachTypeName,
|
||||
isBedCoach,
|
||||
bedCategory,
|
||||
seatClasses: seatClassNames,
|
||||
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
|
||||
positionNumber: a.positionNumber,
|
||||
seatArrangement: a.coach.arrangement,
|
||||
totalSeats: a.coach.capacity,
|
||||
seats: allSeats.map((s) => ({
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
number: s.seatNumber,
|
||||
label: s.seatNumber,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
isWindow: s.isWindow,
|
||||
isAisle: s.isAisle,
|
||||
bedPosition: s.bedPosition,
|
||||
coach: {
|
||||
id: a.coach.id,
|
||||
coachNumber: a.coach.number,
|
||||
label: a.coach.number,
|
||||
},
|
||||
})),
|
||||
totalSeats: a.coach.capacity,
|
||||
};
|
||||
|
||||
if (isBedCoach) {
|
||||
// Group seats into rooms; row = room number
|
||||
const roomMap = new Map<number, any[]>();
|
||||
for (const seat of mappedSeats) {
|
||||
if (!roomMap.has(seat.row)) roomMap.set(seat.row, []);
|
||||
roomMap.get(seat.row)!.push(seat);
|
||||
}
|
||||
const rooms = Array.from(roomMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([roomNumber, beds]) => ({
|
||||
room_id: `${a.coach.id}-R${roomNumber}`,
|
||||
roomNumber,
|
||||
category: bedCategory,
|
||||
totalBeds: beds.length,
|
||||
beds,
|
||||
}));
|
||||
return { ...base, rooms, seats: mappedSeats };
|
||||
}
|
||||
|
||||
return { ...base, seats: mappedSeats };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private isBedCoach(coachTypeName: string): boolean {
|
||||
const n = coachTypeName.toLowerCase();
|
||||
return n.includes('bed') || n.includes('sleeper') || n.includes('couchette');
|
||||
}
|
||||
|
||||
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
|
||||
const n = coachTypeName.toLowerCase();
|
||||
// Explicit VIP name check first
|
||||
if (n.includes('vip')) return 'VIP_BED';
|
||||
// Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy
|
||||
if (bedsPerRoom === 4) return 'VIP_BED';
|
||||
return 'ECONOMY_BED';
|
||||
}
|
||||
|
||||
// col format: L1, L2, L3, R1, R2, R3
|
||||
private colToPosition(col: string): 'LEFT' | 'RIGHT' {
|
||||
return col?.startsWith('R') ? 'RIGHT' : 'LEFT';
|
||||
}
|
||||
|
||||
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
|
||||
if (!bedPosition) return null;
|
||||
const map: Record<string, 'LOWER' | 'MIDDLE' | 'UPPER'> = {
|
||||
lower: 'LOWER', middle: 'MIDDLE', upper: 'UPPER',
|
||||
};
|
||||
return map[bedPosition.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
@@ -446,7 +517,7 @@ export class SeatsService {
|
||||
|
||||
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
||||
async releaseSeats(bookingId: string) {
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId } });
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
|
||||
}
|
||||
|
||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { StationsService } from './stations.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -10,7 +11,8 @@ export class StationsController {
|
||||
constructor(private service: StationsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
@IsPublic()
|
||||
@ApiOperation({
|
||||
summary: 'List all stations with country information',
|
||||
description: 'Returns all stations on the Ethio-Djibouti Railway with country codes (ET for Ethiopia, DJ for Djibouti)'
|
||||
})
|
||||
@@ -48,7 +50,8 @@ export class StationsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
@IsPublic()
|
||||
@ApiOperation({
|
||||
summary: 'Get station details by ID',
|
||||
description: 'Returns station information including name, code, country, coordinates, and facilities'
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user