mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
UAT findings resolutions and enhancements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
|
||||
@@ -9,7 +9,11 @@ export class PassengerInputDto {
|
||||
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string;
|
||||
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
|
||||
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string;
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' })
|
||||
@IsDateString()
|
||||
@Transform(({ value }) => value)
|
||||
@MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' })
|
||||
dateOfBirth: string;
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
@ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string;
|
||||
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
|
||||
@@ -38,9 +42,12 @@ export class RoundTripPassengerDto {
|
||||
|
||||
@ApiProperty({
|
||||
example: '1990-05-15',
|
||||
description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)'
|
||||
})
|
||||
@IsDateString() dateOfBirth: string;
|
||||
description: 'Date of birth (YYYY-MM-DD). Must not be a future date.'
|
||||
})
|
||||
@IsDateString()
|
||||
@Transform(({ value }) => value)
|
||||
@MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' })
|
||||
dateOfBirth: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'NATIONAL_ID',
|
||||
|
||||
@@ -206,6 +206,13 @@ export class FleetController {
|
||||
return this.service.listCoaches(dto);
|
||||
}
|
||||
|
||||
@Get('coaches/utilization')
|
||||
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' })
|
||||
@ApiResponse({ status: 200, description: 'Coach utilization data' })
|
||||
getCoachUtilization() {
|
||||
return this.service.getCoachUtilization();
|
||||
}
|
||||
|
||||
@Get('coaches/:id')
|
||||
@ApiOperation({ summary: 'Get single coach with seat layout' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateTrainDto {
|
||||
@@ -7,7 +8,7 @@ export class CreateTrainDto {
|
||||
@ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
|
||||
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
|
||||
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @IsBoolean() isActive?: boolean;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
|
||||
export class CreateCoachDto {
|
||||
|
||||
@@ -593,6 +593,58 @@ export class FleetService {
|
||||
};
|
||||
}
|
||||
|
||||
async getCoachUtilization() {
|
||||
const coaches = await this.prisma.coach.findMany({
|
||||
include: {
|
||||
coachType: true,
|
||||
seats: { select: { id: true, status: true } },
|
||||
assignments: {
|
||||
include: {
|
||||
schedule: {
|
||||
select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { schedule: { departureAt: 'desc' } },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
|
||||
return coaches.map((coach) => {
|
||||
const totalSeats = coach.seats.length;
|
||||
const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length;
|
||||
const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length;
|
||||
const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length;
|
||||
const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').length;
|
||||
const totalAssignments = coach.assignments.length;
|
||||
const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0);
|
||||
const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0;
|
||||
|
||||
return {
|
||||
id: coach.id,
|
||||
number: coach.number,
|
||||
sequence: coach.sequence,
|
||||
coachType: coach.coachType?.name,
|
||||
status: coach.status,
|
||||
totalSeats,
|
||||
availableSeats,
|
||||
bookedSeats,
|
||||
blockedSeats,
|
||||
maintenanceSeats,
|
||||
utilizationRate,
|
||||
totalAssignments,
|
||||
totalBookings,
|
||||
recentSchedules: coach.assignments.slice(0, 5).map((a) => ({
|
||||
scheduleId: a.scheduleId,
|
||||
departureAt: a.schedule.departureAt,
|
||||
scheduleStatus: a.schedule.status,
|
||||
bookings: (a.schedule as any)._count?.bookings ?? 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getAnalytics() {
|
||||
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
|
||||
this.prisma.train.count(),
|
||||
|
||||
@@ -184,6 +184,27 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
return this.service.unblockSeat(seatId);
|
||||
}
|
||||
|
||||
// ── Maintenance ───────────────────────────────────────────────────────────
|
||||
@Post(":seatId/maintenance")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({ status: 200, description: "Seat set to under maintenance" })
|
||||
setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
|
||||
return this.service.setMaintenance(seatId, body.reason);
|
||||
}
|
||||
|
||||
@Delete(":seatId/maintenance")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Clear seat maintenance status" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({ status: 200, description: "Seat cleared from maintenance" })
|
||||
clearMaintenance(@Param("seatId") seatId: string) {
|
||||
return this.service.clearMaintenance(seatId);
|
||||
}
|
||||
|
||||
// ── Remove Seat ────────────────────────────────────────────────────────────
|
||||
@Patch(":seatId/remove")
|
||||
@UseGuards(IamGuard)
|
||||
|
||||
@@ -742,6 +742,23 @@ export class SeatsService {
|
||||
return { unblocked: true, seatId };
|
||||
}
|
||||
|
||||
async setMaintenance(seatId: string, reason: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance');
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } });
|
||||
await this.prisma.seatBlock.create({ data: { seatId, reason: `MAINTENANCE: ${reason}`, blockedBy: 'system' } });
|
||||
return { maintenance: true, seatId, reason };
|
||||
}
|
||||
|
||||
async clearMaintenance(seatId: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' as any } });
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
|
||||
return { maintenance: false, seatId };
|
||||
}
|
||||
|
||||
async removeSeat(seatId: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
Reference in New Issue
Block a user