mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Coaches, seats, schedules, and pricing related updates
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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 { SchedulesService } from './schedules.service';
|
||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { TripStatus } from '@prisma/client';
|
||||
|
||||
@@ -10,14 +10,23 @@ import { TripStatus } from '@prisma/client';
|
||||
export class SchedulesController {
|
||||
constructor(private service: SchedulesService) {}
|
||||
|
||||
@Post('bulk-generate')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Bulk generate repetitive schedules',
|
||||
description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Schedules generated successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
|
||||
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
||||
return this.service.bulkGenerateSchedules(dto);
|
||||
}
|
||||
|
||||
@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.`,
|
||||
description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin 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' })
|
||||
@@ -40,13 +49,42 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
return this.service.listSchedules({ date, routeId, trainId, status });
|
||||
}
|
||||
|
||||
// Static routes before parameterised ones
|
||||
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
|
||||
|
||||
@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); }
|
||||
|
||||
@Post('segment-fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
|
||||
@ApiResponse({ status: 201, description: 'Segment fare rule created' })
|
||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||
|
||||
@Get('routes/:routeId/segment-fares')
|
||||
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
||||
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of segment fare rules' })
|
||||
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
||||
|
||||
@Patch('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Segment fare rule updated' })
|
||||
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||
|
||||
@Delete('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
|
||||
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
|
||||
|
||||
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@@ -56,12 +94,12 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a schedule' })
|
||||
@ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) {
|
||||
return this.service.updateSchedule(id, dto);
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||
return this.service.updateSchedulePartial(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@@ -84,8 +122,6 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
return this.service.deleteSchedule(id);
|
||||
}
|
||||
|
||||
// ── Stop Times ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/stops')
|
||||
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@@ -106,8 +142,6 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
// ── Fares ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@@ -151,8 +185,6 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
return this.service.syncFaresFromEngine(id);
|
||||
}
|
||||
|
||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
||||
|
||||
@Post(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -34,6 +34,13 @@ export class CreateScheduleDto {
|
||||
plannedTimes: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
|
||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -50,6 +57,17 @@ export class CreateFareRuleDto {
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
||||
}
|
||||
|
||||
export class CreateSegmentFareRuleDto {
|
||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||
@ApiProperty({ example: 1, description: 'Origin stop sequence number' }) @IsInt() @Min(1) originStopSequence: number;
|
||||
@ApiProperty({ example: 2, description: 'Destination stop sequence number' }) @IsInt() @Min(1) destinationStopSequence: number;
|
||||
@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;
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: 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;
|
||||
@@ -67,3 +85,21 @@ export class ListSchedulesDto {
|
||||
export class UpdateScheduleStatusDto {
|
||||
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
||||
}
|
||||
|
||||
export class BulkCreateSchedulesDto {
|
||||
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||
@ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Start date and time for first schedule' }) @IsDateString() startDateTime: string;
|
||||
@ApiProperty({ example: 12, description: 'Hours duration per schedule' }) @IsInt() @Min(1) durationHours: number;
|
||||
@ApiProperty({ example: 2, description: 'Repeat every X days' }) @IsInt() @Min(1) repeatEveryDays: number;
|
||||
@ApiProperty({ example: 30, description: 'Generate schedules for the next Y days' }) @IsInt() @Min(1) forNextDays: number;
|
||||
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class BulkSchedulesResponseDto {
|
||||
@ApiProperty() schedulesCreated: number;
|
||||
@ApiProperty() errors: string[];
|
||||
@ApiProperty() scheduleIds: string[];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
@@ -10,9 +10,55 @@ export class SchedulesService {
|
||||
private prisma: PrismaService,
|
||||
private routesService: RoutesService,
|
||||
private fareEngine: FareEngineService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
// ── Schedule CRUD ──────────────────────────────────────────────────────────
|
||||
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
||||
const startDate = new Date(dto.startDateTime);
|
||||
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
||||
const errors: string[] = [];
|
||||
const scheduleIds: string[] = [];
|
||||
|
||||
// Validate route and get stops for plannedTimes generation
|
||||
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');
|
||||
|
||||
let currentDate = new Date(startDate);
|
||||
let scheduleCount = 0;
|
||||
|
||||
while (currentDate < endDate) {
|
||||
try {
|
||||
const departureAt = new Date(currentDate);
|
||||
const arrivalAt = new Date(departureAt.getTime() + dto.durationHours * 60 * 60 * 1000);
|
||||
|
||||
const createDto: CreateScheduleDto = {
|
||||
trainId: dto.trainId,
|
||||
routeId: dto.routeId,
|
||||
departureAt: departureAt.toISOString(),
|
||||
arrivalAt: arrivalAt.toISOString(),
|
||||
plannedTimes: dto.plannedTimes || [],
|
||||
};
|
||||
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
scheduleCount++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// Move to next repetition
|
||||
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
schedulesCreated: scheduleCount,
|
||||
errors,
|
||||
scheduleIds,
|
||||
};
|
||||
}
|
||||
|
||||
async listSchedules(dto: ListSchedulesDto) {
|
||||
const where: any = {};
|
||||
@@ -58,28 +104,48 @@ export class SchedulesService {
|
||||
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');
|
||||
|
||||
// Check for duplicate schedule with same train, route, and date
|
||||
const depDate = new Date(dep);
|
||||
depDate.setHours(0, 0, 0, 0);
|
||||
const nextDay = new Date(depDate);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
const existingSchedule = await this.prisma.trainSchedule.findFirst({
|
||||
where: {
|
||||
trainId: dto.trainId,
|
||||
routeId: dto.routeId,
|
||||
departureAt: {
|
||||
gte: depDate,
|
||||
lt: nextDay,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
throw new BadRequestException(
|
||||
`A schedule for this train, route, and date already exists. Departure: ${new Date(existingSchedule.departureAt).toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-generate plannedTimes if not provided or empty
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
|
||||
if (index === 0) {
|
||||
// First stop - use departure time
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
// Last stop - use arrival time
|
||||
stopTime = arr;
|
||||
} else {
|
||||
// Intermediate stops - calculate based on distance proportion
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -113,7 +179,6 @@ export class SchedulesService {
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
});
|
||||
|
||||
// Copy route stops into TripStopTime with the provided planned times
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
@@ -130,7 +195,7 @@ export class SchedulesService {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
@@ -138,18 +203,16 @@ export class SchedulesService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Compute effective seat statuses from SeatHold + JourneySegment
|
||||
// (seat.status DB column is no longer written during booking)
|
||||
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||
const allSeatIds = schedule.coachAssignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
coachAssignments: schedule.coachAssignments.map(a => ({
|
||||
coachAssignments: schedule.coachAssignments.map((a: any) => ({
|
||||
...a,
|
||||
coach: {
|
||||
...a.coach,
|
||||
seats: a.coach.seats.map(s => ({
|
||||
seats: a.coach.seats.map((s: any) => ({
|
||||
...s,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
})),
|
||||
@@ -158,12 +221,6 @@ export class SchedulesService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes effective seat status for a schedule by checking active SeatHolds
|
||||
* and confirmed JourneySegments. The DB seat.status column is not written
|
||||
* during segment-based booking, so this overlay is required.
|
||||
* Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE
|
||||
*/
|
||||
private async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
@@ -204,7 +261,6 @@ export class SchedulesService {
|
||||
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' } } },
|
||||
@@ -213,7 +269,6 @@ export class SchedulesService {
|
||||
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');
|
||||
|
||||
// Derive origin and destination from first and last route stop
|
||||
const firstStop = route.stops[0];
|
||||
const lastStop = route.stops[route.stops.length - 1];
|
||||
|
||||
@@ -231,18 +286,16 @@ export class SchedulesService {
|
||||
},
|
||||
});
|
||||
|
||||
// Delete existing stop times and recreate
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
// Auto-generate plannedTimes if not provided
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
@@ -252,7 +305,7 @@ export class SchedulesService {
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -276,19 +329,53 @@ export class SchedulesService {
|
||||
async deleteSchedule(id: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Delete related records first (in dependency order)
|
||||
|
||||
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: { scheduleId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
const bookingIds = bookings.map(b => b.id);
|
||||
|
||||
if (bookingIds.length > 0) {
|
||||
const paymentIntents = await this.prisma.paymentIntent.findMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const paymentIntentIds = paymentIntents.map(pi => pi.id);
|
||||
|
||||
if (paymentIntentIds.length > 0) {
|
||||
await this.prisma.paymentRefund.deleteMany({
|
||||
where: { paymentIntentId: { in: paymentIntentIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.ticket.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingSeat.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingModification.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingCancellation.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.paymentIntent.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
|
||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
|
||||
|
||||
getStops(scheduleId: string) {
|
||||
return this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
@@ -314,8 +401,6 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fare Rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
createFareRule(dto: CreateFareRuleDto) {
|
||||
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
|
||||
return this.prisma.fareRule.create({
|
||||
@@ -329,6 +414,43 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
createSegmentFareRule(dto: any) {
|
||||
const { validFrom, validUntil, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
validFrom: new Date(validFrom),
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
}
|
||||
|
||||
getSegmentFares(routeId: string) {
|
||||
return this.prisma.segmentFareRule.findMany({
|
||||
where: { routeId },
|
||||
include: { seatClass: true, route: true },
|
||||
orderBy: [{ originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
deleteSegmentFareRule(id: string) {
|
||||
return this.prisma.segmentFareRule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
updateSegmentFareRule(id: string, dto: any) {
|
||||
const { validFrom, validUntil, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
validFrom: validFrom ? new Date(validFrom) : undefined,
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
}
|
||||
|
||||
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
||||
}
|
||||
@@ -337,10 +459,6 @@ export class SchedulesService {
|
||||
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate fares for all active seat classes on a schedule using the fare engine
|
||||
* and upsert them as FareRule records scoped to this schedule.
|
||||
*/
|
||||
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
|
||||
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
|
||||
const errors: string[] = [];
|
||||
@@ -352,7 +470,6 @@ export class SchedulesService {
|
||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
|
||||
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
|
||||
|
||||
// Expire any existing active rule for this schedule + seat class
|
||||
await this.prisma.fareRule.updateMany({
|
||||
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
|
||||
data: { validUntil: now },
|
||||
@@ -377,8 +494,6 @@ export class SchedulesService {
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
||||
|
||||
async assignCoaches(
|
||||
scheduleId: string,
|
||||
coaches: Array<{ coachId: string; positionNumber: number }>,
|
||||
@@ -386,7 +501,6 @@ export class SchedulesService {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Validate all coaches exist
|
||||
const coachIds = coaches.map(c => c.coachId);
|
||||
const existingCoaches = await this.prisma.coach.findMany({
|
||||
where: { id: { in: coachIds } },
|
||||
@@ -395,18 +509,16 @@ export class SchedulesService {
|
||||
throw new NotFoundException('One or more coaches not found');
|
||||
}
|
||||
|
||||
// Remove existing assignments
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
// Create new assignments
|
||||
await this.prisma.coachAssignment.createMany({
|
||||
data: coaches.map(c => ({
|
||||
scheduleId,
|
||||
coachId: c.coachId,
|
||||
positionNumber: c.positionNumber,
|
||||
isOperational: true,
|
||||
})),
|
||||
});
|
||||
const data = coaches.map((c, idx) => ({
|
||||
scheduleId,
|
||||
coachId: c.coachId,
|
||||
positionNumber: idx + 1,
|
||||
isOperational: true,
|
||||
}));
|
||||
|
||||
await this.prisma.coachAssignment.createMany({ data });
|
||||
|
||||
return { message: 'Coaches assigned successfully', count: coaches.length };
|
||||
}
|
||||
@@ -417,7 +529,6 @@ export class SchedulesService {
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
seatClass: true,
|
||||
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
||||
},
|
||||
},
|
||||
@@ -426,6 +537,41 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateSchedulePartial(id: string, dto: UpdateScheduleDto) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (dto.departureAt || dto.arrivalAt) {
|
||||
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
|
||||
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||
|
||||
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
|
||||
|
||||
updateData.departureAt = dep;
|
||||
updateData.arrivalAt = arr;
|
||||
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
||||
}
|
||||
|
||||
if (dto.status) {
|
||||
updateData.status = dto.status;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.prisma.trainSchedule.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.coaches && dto.coaches.length > 0) {
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
}
|
||||
|
||||
return this.getSchedule(id);
|
||||
}
|
||||
|
||||
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
||||
const assignment = await this.prisma.coachAssignment.findFirst({
|
||||
where: { scheduleId, coachId },
|
||||
@@ -435,4 +581,4 @@ export class SchedulesService {
|
||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||
return { message: 'Coach assignment removed' };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user