Coaches, seats, schedules, and pricing related updates

This commit is contained in:
Stephanos A
2026-06-07 17:41:31 +03:00
parent af14535e08
commit bb10e7fdf2
55 changed files with 5119 additions and 2930 deletions

View File

@@ -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({

View File

@@ -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[];
}

View File

@@ -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' };
}
}
}