mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Refactor business logic for train,schedule,coach,seat and search modules
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
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 { RoutesService } from './routes.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Routes')
|
||||
@Controller('routes')
|
||||
export class RoutesController {
|
||||
constructor(private service: RoutesService) {}
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create a reusable route with its ordered stops',
|
||||
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
|
||||
Schedules reference this route via routeId and supply actual planned times per stop.
|
||||
Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Route created with stops' })
|
||||
@ApiResponse({ status: 409, description: 'Route code already exists or duplicate sequences' })
|
||||
@ApiResponse({ status: 400, description: 'Fewer than 2 stops or invalid station IDs' })
|
||||
createRoute(@Body() dto: CreateRouteDto) { return this.service.createRoute(dto); }
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all routes' })
|
||||
@ApiQuery({ name: 'activeOnly', required: false, type: Boolean, description: 'Filter to active routes only' })
|
||||
@ApiResponse({ status: 200, description: 'Array of routes with stop count' })
|
||||
listRoutes(@Query('activeOnly') activeOnly?: string) {
|
||||
return this.service.listRoutes(activeOnly === 'true');
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get route with all stops and station details' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Route with enriched stop list (station name, code, city)' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Route updated' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
|
||||
|
||||
// ── Route Stops ────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/stops')
|
||||
@ApiOperation({ summary: 'List all stops for a route ordered by sequence' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Ordered stop list with station details' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||
|
||||
@Post(':id/stops')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Add a stop to an existing route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Stop added' })
|
||||
@ApiResponse({ status: 409, description: 'Sequence already exists on this route' })
|
||||
@ApiResponse({ status: 404, description: 'Route or station not found' })
|
||||
addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
|
||||
|
||||
@Delete(':id/stops/:sequence')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number to remove' })
|
||||
@ApiResponse({ status: 200, description: 'Stop removed' })
|
||||
@ApiResponse({ status: 400, description: 'Cannot remove — route would have fewer than 2 stops' })
|
||||
@ApiResponse({ status: 404, description: 'Stop not found' })
|
||||
removeStop(@Param('id') id: string, @Param('sequence', ParseIntPipe) sequence: number) {
|
||||
return this.service.removeStop(id, sequence);
|
||||
}
|
||||
|
||||
// ── Schedules for a Route ──────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/schedules')
|
||||
@ApiOperation({ summary: 'List all train schedules that use this route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); }
|
||||
}
|
||||
44
apps/edr-passenger-api/src/modules/schedules/routes.dto.ts
Normal file
44
apps/edr-passenger-api/src/modules/schedules/routes.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { IsString, IsInt, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class RouteStopInputDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 120, description: 'Distance in km from previous stop' }) @IsOptional() @IsInt() distanceKm?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@ApiProperty({ example: 'ADD-DJI', description: 'Unique route code' }) @IsString() code: string;
|
||||
@ApiProperty({ example: 'Addis Ababa – Djibouti' }) @IsString() name: string;
|
||||
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiProperty({
|
||||
type: [RouteStopInputDto],
|
||||
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',
|
||||
example: [
|
||||
{ stationId: 'uuid-ADD', sequence: 1 },
|
||||
{ stationId: 'uuid-ADM', sequence: 2, distanceKm: 99 },
|
||||
{ stationId: 'uuid-AWS', sequence: 3, distanceKm: 120 },
|
||||
{ stationId: 'uuid-DDW', sequence: 4, distanceKm: 180 },
|
||||
{ stationId: 'uuid-AYS', sequence: 5, distanceKm: 95 },
|
||||
{ stationId: 'uuid-DJI', sequence: 6, distanceKm: 60 },
|
||||
],
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto)
|
||||
stops: RouteStopInputDto[];
|
||||
}
|
||||
|
||||
export class AddRouteStopDto {
|
||||
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 75 }) @IsOptional() @IsInt() distanceKm?: number;
|
||||
}
|
||||
|
||||
export class UpdateRouteDto {
|
||||
@ApiPropertyOptional({ example: 'Addis Ababa – Djibouti Express' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
}
|
||||
197
apps/edr-passenger-api/src/modules/schedules/routes.service.ts
Normal file
197
apps/edr-passenger-api/src/modules/schedules/routes.service.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RoutesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ── Route CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
async createRoute(dto: CreateRouteDto) {
|
||||
const existing = await this.prisma.route.findUnique({ where: { code: dto.code } });
|
||||
if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`);
|
||||
|
||||
if (dto.stops.length < 2) throw new BadRequestException('A route must have at least 2 stops');
|
||||
|
||||
const seqs = dto.stops.map(s => s.sequence);
|
||||
if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list');
|
||||
|
||||
const stationIds = [...new Set(dto.stops.map(s => s.stationId))];
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found');
|
||||
|
||||
return this.prisma.route.create({
|
||||
data: {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
effectiveFrom: new Date(dto.effectiveFrom),
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
|
||||
stops: {
|
||||
create: dto.stops.map(s => ({
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { stops: { include: { route: false }, orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
async listRoutes(activeOnly = false) {
|
||||
return this.prisma.route.findMany({
|
||||
where: activeOnly ? { active: true } : undefined,
|
||||
include: {
|
||||
stops: { orderBy: { sequence: 'asc' } },
|
||||
_count: { select: { stops: true } },
|
||||
},
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getRoute(id: string) {
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
stops: {
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
route: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
// Enrich stops with station details
|
||||
const stationIds = route.stops.map(s => s.stationId);
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
const stationMap = Object.fromEntries(stations.map(s => [s.id, s]));
|
||||
|
||||
return {
|
||||
...route,
|
||||
stops: route.stops.map(s => ({ ...s, station: stationMap[s.stationId] })),
|
||||
};
|
||||
}
|
||||
|
||||
async updateRoute(id: string, dto: UpdateRouteDto) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
return this.prisma.route.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
active: dto.active,
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
|
||||
},
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Route Stops ────────────────────────────────────────────────────────────
|
||||
|
||||
async addStop(routeId: string, dto: AddRouteStopDto) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } });
|
||||
if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`);
|
||||
|
||||
const existing = await this.prisma.routeStop.findUnique({
|
||||
where: { routeId_sequence: { routeId, sequence: dto.sequence } },
|
||||
});
|
||||
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
|
||||
|
||||
return this.prisma.routeStop.create({
|
||||
data: { routeId, stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm },
|
||||
});
|
||||
}
|
||||
|
||||
async removeStop(routeId: string, sequence: number) {
|
||||
const stop = await this.prisma.routeStop.findUnique({
|
||||
where: { routeId_sequence: { routeId, sequence } },
|
||||
});
|
||||
if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on route`);
|
||||
|
||||
const total = await this.prisma.routeStop.count({ where: { routeId } });
|
||||
if (total <= 2) throw new BadRequestException('A route must retain at least 2 stops');
|
||||
|
||||
await this.prisma.routeStop.delete({ where: { routeId_sequence: { routeId, sequence } } });
|
||||
return { deleted: true, sequence };
|
||||
}
|
||||
|
||||
async getStops(routeId: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
const stops = await this.prisma.routeStop.findMany({
|
||||
where: { routeId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
|
||||
const stationIds = stops.map(s => s.stationId);
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
const stationMap = Object.fromEntries(stations.map(s => [s.id, s]));
|
||||
|
||||
return stops.map(s => ({ ...s, station: stationMap[s.stationId] }));
|
||||
}
|
||||
|
||||
async getSchedulesForRoute(routeId: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
return this.prisma.trainSchedule.findMany({
|
||||
where: { routeId },
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
orderBy: { departureAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Used by SchedulesService ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Copies RouteStop definitions into TripStopTime rows for a schedule.
|
||||
* plannedTimes maps sequence → { arrivalAt?, departureAt? } for actual timing.
|
||||
*/
|
||||
async applyRouteToSchedule(
|
||||
routeId: string,
|
||||
scheduleId: string,
|
||||
plannedTimes: Record<number, { plannedArrivalAt?: string; plannedDepartureAt?: string }>,
|
||||
) {
|
||||
const stops = await this.prisma.routeStop.findMany({
|
||||
where: { routeId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
if (stops.length === 0) throw new BadRequestException('Route has no stops defined');
|
||||
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId } });
|
||||
|
||||
await this.prisma.tripStopTime.createMany({
|
||||
data: stops.map(s => {
|
||||
const times = plannedTimes[s.sequence] ?? {};
|
||||
return {
|
||||
scheduleId,
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
plannedArrivalAt: times.plannedArrivalAt ? new Date(times.plannedArrivalAt) : null,
|
||||
plannedDepartureAt: times.plannedDepartureAt ? new Date(times.plannedDepartureAt) : null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const intermediateCount = Math.max(0, stops.length - 2);
|
||||
await this.prisma.trainSchedule.update({
|
||||
where: { id: scheduleId },
|
||||
data: { stopsCount: intermediateCount },
|
||||
});
|
||||
|
||||
return this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
include: { station: true },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,100 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
|
||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { TripStatus } from '@prisma/client';
|
||||
|
||||
@ApiTags('Schedule')
|
||||
@Controller('schedule')
|
||||
@Controller('schedules')
|
||||
export class SchedulesController {
|
||||
constructor(private service: SchedulesService) {}
|
||||
@Post('trips') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create trip' })
|
||||
createTrip(@Body() dto: CreateTripDto) { return this.service.createTrip(dto); }
|
||||
@Get('trips/:id') @ApiOperation({ summary: 'Get trip details' })
|
||||
getTrip(@Param('id') id: string) { return this.service.getTrip(id); }
|
||||
@Patch('trips/:id/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update trip status' })
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateTripStatusDto) { return this.service.updateTripStatus(id, dto); }
|
||||
@Post('fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create fare rule' })
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create a train schedule from a route template',
|
||||
description: `Creates a schedule by referencing a Route (routeId).
|
||||
Stops are automatically copied from the route's RouteStop definitions.
|
||||
You supply the actual planned arrival/departure times per stop sequence.
|
||||
Origin and destination are derived from the first and last route stop — no need to specify them manually.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
|
||||
@ApiResponse({ status: 404, description: 'Train or route not found' })
|
||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List schedules with optional filters' })
|
||||
@ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
||||
@ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' })
|
||||
@ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' })
|
||||
@ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' })
|
||||
@ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' })
|
||||
listSchedules(
|
||||
@Query('date') date?: string,
|
||||
@Query('routeId') routeId?: string,
|
||||
@Query('trainId') trainId?: string,
|
||||
@Query('status') status?: TripStatus,
|
||||
) {
|
||||
return this.service.listSchedules({ date, routeId, trainId, status });
|
||||
}
|
||||
|
||||
// Static routes before parameterised ones
|
||||
@Post('fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
|
||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||
@Get('fares/:tripId') @ApiOperation({ summary: 'Get fare for trip and class' })
|
||||
getFare(@Param('tripId') tripId: string, @Query('class') cls: string) { return this.service.getFare(tripId, cls ?? 'ECONOMY'); }
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
||||
|
||||
@Patch(':id/status')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Status updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
|
||||
return this.service.updateScheduleStatus(id, dto);
|
||||
}
|
||||
|
||||
// ── Stop Times ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/stops')
|
||||
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||
|
||||
@Patch(':id/stops/:sequence')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update planned times or live status of a specific stop' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
||||
@ApiResponse({ status: 200, description: 'Stop updated' })
|
||||
@ApiResponse({ status: 404, description: 'Stop not found on schedule' })
|
||||
updateStop(
|
||||
@Param('id') id: string,
|
||||
@Param('sequence', ParseIntPipe) sequence: number,
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
// ── Fares ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get applicable fare for a schedule and seat class' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'class', required: false, description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed". Defaults to Economy Regular.' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule or default fare' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getFare(@Param('scheduleId') scheduleId: string, @Query('class') cls: string) {
|
||||
return this.service.getFare(scheduleId, cls ?? 'Economy Regular');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,68 @@
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ServiceClass } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { TripStatus, StopStatus } from '@prisma/client';
|
||||
|
||||
export class CreateTripDto {
|
||||
@ApiProperty() @IsString() serviceId: string;
|
||||
@ApiProperty() @IsString() originStationId: string;
|
||||
@ApiProperty() @IsString() destinationStationId: string;
|
||||
@ApiProperty({ example: '2026-05-11T08:30:00Z' }) @IsDateString() departureAt: string;
|
||||
@ApiProperty({ example: '2026-05-11T20:00:00Z' }) @IsDateString() arrivalAt: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() stopsCount?: number;
|
||||
export class PlannedStopTimeDto {
|
||||
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z', description: 'Planned arrival at this stop (omit for first stop)' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z', description: 'Planned departure from this stop (omit for last stop)' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
||||
}
|
||||
|
||||
export class CreateScheduleDto {
|
||||
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
||||
|
||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID — stops are copied from the route template. Origin and destination are derived from the first and last route stop.' })
|
||||
@IsString() routeId: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsDateString() departureAt: string;
|
||||
@ApiProperty({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsDateString() arrivalAt: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [PlannedStopTimeDto],
|
||||
description: 'Planned arrival/departure times per stop sequence. Must cover all stops defined on the route.',
|
||||
example: [
|
||||
{ sequence: 1, plannedDepartureAt: '2026-06-15T08:00:00Z' },
|
||||
{ sequence: 2, plannedArrivalAt: '2026-06-15T09:30:00Z', plannedDepartureAt: '2026-06-15T09:45:00Z' },
|
||||
{ sequence: 3, plannedArrivalAt: '2026-06-15T11:30:00Z', plannedDepartureAt: '2026-06-15T11:45:00Z' },
|
||||
{ sequence: 4, plannedArrivalAt: '2026-06-15T15:00:00Z', plannedDepartureAt: '2026-06-15T15:20:00Z' },
|
||||
{ sequence: 5, plannedArrivalAt: '2026-06-15T18:00:00Z', plannedDepartureAt: '2026-06-15T18:10:00Z' },
|
||||
{ sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' },
|
||||
],
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class UpdateStopTimeDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
||||
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
|
||||
}
|
||||
|
||||
export class CreateFareRuleDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() tripId?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() route?: string;
|
||||
@ApiProperty({ enum: ServiceClass, example: 'ECONOMY_REGULAR' }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
|
||||
@ApiProperty({ example: 45000 }) @IsInt() baseFareMinor: number;
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string;
|
||||
@ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI)' }) @IsOptional() @IsString() route?: string;
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
||||
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsDateString() validUntil?: string;
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
||||
}
|
||||
|
||||
export class UpdateTripStatusDto {
|
||||
@ApiProperty({ example: 'EN_ROUTE' }) @IsString() status: string;
|
||||
export class ListSchedulesDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
||||
@IsOptional() @IsDateString() date?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'route-uuid', description: 'Filter by route UUID' })
|
||||
@IsOptional() @IsString() routeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'train-uuid', description: 'Filter by train UUID' })
|
||||
@IsOptional() @IsString() trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED, description: 'Filter by schedule status' })
|
||||
@IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||
}
|
||||
|
||||
export class UpdateScheduleStatusDto {
|
||||
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { RoutesController } from './routes.controller';
|
||||
import { RoutesService } from './routes.service';
|
||||
|
||||
@Module({ controllers: [SchedulesController], providers: [SchedulesService] })
|
||||
@Module({
|
||||
controllers: [RoutesController, SchedulesController],
|
||||
providers: [RoutesService, SchedulesService],
|
||||
exports: [RoutesService, SchedulesService],
|
||||
})
|
||||
export class SchedulesModule {}
|
||||
|
||||
@@ -1,39 +1,174 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private routesService: RoutesService,
|
||||
) {}
|
||||
|
||||
async createTrip(dto: CreateTripDto) {
|
||||
const dep = new Date(dto.departureAt), arr = new Date(dto.arrivalAt);
|
||||
return this.prisma.trip.create({
|
||||
data: { serviceId: dto.serviceId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: dep, arrivalAt: arr, durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60000), stopsCount: dto.stopsCount ?? 0 },
|
||||
include: { service: true, originStation: true, destinationStation: true },
|
||||
// ── Schedule CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
async listSchedules(dto: ListSchedulesDto) {
|
||||
const where: any = {};
|
||||
|
||||
if (dto.date) {
|
||||
const date = new Date(dto.date);
|
||||
const nextDay = new Date(date.getTime() + 86_400_000);
|
||||
where.departureAt = { gte: date, lt: nextDay };
|
||||
}
|
||||
if (dto.routeId) where.routeId = dto.routeId;
|
||||
if (dto.trainId) where.trainId = dto.trainId;
|
||||
if (dto.status) where.status = dto.status;
|
||||
|
||||
return this.prisma.trainSchedule.findMany({
|
||||
where,
|
||||
include: {
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
_count: { select: { coachAssignments: true, bookings: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getTrip(id: string) {
|
||||
const trip = await this.prisma.trip.findUnique({ where: { id }, include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } });
|
||||
if (!trip) throw new NotFoundException('Trip not found');
|
||||
return trip;
|
||||
async createSchedule(dto: CreateScheduleDto) {
|
||||
const dep = new Date(dto.departureAt);
|
||||
const arr = new Date(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
// Validate route exists and has stops
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
|
||||
// Validate all route stop sequences are covered by plannedTimes
|
||||
const providedSeqs = new Set(dto.plannedTimes.map(t => t.sequence));
|
||||
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
|
||||
if (missingSeqs.length > 0) {
|
||||
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
||||
}
|
||||
|
||||
// Derive origin and destination from first and last route stop
|
||||
const firstStop = route.stops[0];
|
||||
const lastStop = route.stops[route.stops.length - 1];
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.create({
|
||||
data: {
|
||||
trainId: dto.trainId,
|
||||
routeId: dto.routeId,
|
||||
originStationId: firstStop.stationId,
|
||||
destinationStationId: lastStop.stationId,
|
||||
departureAt: dep,
|
||||
arrivalAt: arr,
|
||||
durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000),
|
||||
stopsCount: Math.max(0, route.stops.length - 2),
|
||||
},
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
});
|
||||
|
||||
// Copy route stops into TripStopTime with the provided planned times
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
dto.plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
|
||||
|
||||
return this.getSchedule(schedule.id);
|
||||
}
|
||||
|
||||
updateTripStatus(id: string, dto: UpdateTripStatusDto) { return this.prisma.trip.update({ where: { id }, data: { status: dto.status as any } }); }
|
||||
async getSchedule(id: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
return schedule;
|
||||
}
|
||||
|
||||
updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
|
||||
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
|
||||
}
|
||||
|
||||
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
|
||||
|
||||
getStops(scheduleId: string) {
|
||||
return this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
include: { station: true },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async updateStop(scheduleId: string, sequence: number, dto: UpdateStopTimeDto) {
|
||||
const stop = await this.prisma.tripStopTime.findUnique({
|
||||
where: { scheduleId_sequence: { scheduleId, sequence } },
|
||||
});
|
||||
if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on schedule`);
|
||||
|
||||
return this.prisma.tripStopTime.update({
|
||||
where: { scheduleId_sequence: { scheduleId, sequence } },
|
||||
data: {
|
||||
plannedArrivalAt: dto.plannedArrivalAt ? new Date(dto.plannedArrivalAt) : undefined,
|
||||
plannedDepartureAt: dto.plannedDepartureAt ? new Date(dto.plannedDepartureAt) : undefined,
|
||||
status: dto.status,
|
||||
},
|
||||
include: { station: true },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fare Rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
createFareRule(dto: CreateFareRuleDto) {
|
||||
return this.prisma.fareRule.create({ data: { ...dto, validFrom: new Date(dto.validFrom), validUntil: dto.validUntil ? new Date(dto.validUntil) : null } });
|
||||
const { validFrom, validUntil, scheduleId, ...rest } = dto;
|
||||
return this.prisma.fareRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
tripId: scheduleId,
|
||||
validFrom: new Date(validFrom),
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getFare(tripId: string, serviceClass: string) {
|
||||
const trip = await this.prisma.trip.findUnique({ where: { id: tripId }, include: { originStation: true, destinationStation: true } });
|
||||
if (!trip) throw new NotFoundException('Trip not found');
|
||||
const route = `${trip.originStation.code}-${trip.destinationStation.code}`;
|
||||
async getFare(scheduleId: string, seatClassName: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: { originStation: true, destinationStation: true },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const route = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: seatClassName } });
|
||||
const now = new Date();
|
||||
|
||||
const rule = await this.prisma.fareRule.findFirst({
|
||||
where: { serviceClass: serviceClass as any, validFrom: { lte: new Date() }, OR: [{ tripId }, { route }, { tripId: null, route: null }], AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: new Date() } }] }] },
|
||||
where: {
|
||||
seatClassId: seatClass?.id,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ tripId: scheduleId }, { route }, { tripId: null, route: null }],
|
||||
AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: now } }] }],
|
||||
},
|
||||
orderBy: { validFrom: 'desc' },
|
||||
});
|
||||
return rule ?? { baseFareMinor: 45000, currency: 'ETB', serviceClass };
|
||||
|
||||
return rule ?? { baseFareMinor: 45000, currency: 'ETB', seatClassName };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user