Fare and route-coach, production checklist updates

This commit is contained in:
Stephanos A
2026-07-02 22:42:15 +03:00
parent 4aadf588d4
commit 200476dfd6
37 changed files with 1672 additions and 248 deletions

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, 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 { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Routes')
@@ -93,4 +93,35 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
@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); }
// ── Route Coach Template ───────────────────────────────────────────────────
@Get(':id/coaches')
@ApiOperation({ summary: 'Get the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Ordered coach template with coach and coach type details' })
@ApiResponse({ status: 404, description: 'Route not found' })
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
@Put(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Set the default coach lineup for this route',
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
})
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Updated coach template' })
@ApiResponse({ status: 400, description: 'Duplicate positions or inactive coach' })
@ApiResponse({ status: 404, description: 'Route or coach not found' })
setCoachTemplate(@Param('id') id: string, @Body() dto: SetRouteCoachTemplateDto) {
return this.service.setRouteCoachTemplate(id, dto);
}
@Delete(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Template cleared' })
@ApiResponse({ status: 404, description: 'Route not found' })
clearCoachTemplate(@Param('id') id: string) { return this.service.removeRouteCoachTemplate(id); }
}

View File

@@ -44,3 +44,14 @@ export class UpdateRouteDto {
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}
export class RouteCoachTemplateItemDto {
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() @Min(1) positionNumber: number;
}
export class SetRouteCoachTemplateDto {
@ApiProperty({ type: [RouteCoachTemplateItemDto], description: 'Ordered list of coaches for this route. Replaces the existing template.' })
@IsArray() @ValidateNested({ each: true }) @Type(() => RouteCoachTemplateItemDto)
coaches: RouteCoachTemplateItemDto[];
}

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@Injectable()
@@ -202,6 +202,44 @@ export class RoutesService {
});
}
async getRouteCoachTemplate(routeId: string) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
return this.prisma.routeCoachTemplate.findMany({
where: { routeId },
include: { coach: { include: { coachType: true } } },
orderBy: { positionNumber: 'asc' },
});
}
async setRouteCoachTemplate(routeId: string, dto: SetRouteCoachTemplateDto) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
const coachIds = dto.coaches.map(c => c.coachId);
const coaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
if (coaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
const inactive = coaches.find(c => c.status !== 'ACTIVE');
if (inactive) throw new BadRequestException(`Coach ${inactive.number} is not active`);
const positions = dto.coaches.map(c => c.positionNumber);
if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values');
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
await this.prisma.routeCoachTemplate.createMany({
data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })),
});
return this.getRouteCoachTemplate(routeId);
}
async removeRouteCoachTemplate(routeId: string) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
return { deleted: true, routeId };
}
// ── Used by SchedulesService ───────────────────────────────────────────────
/**

View File

@@ -119,7 +119,7 @@ export class BulkCreateSchedulesDto {
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule. Overrides the route coach template if provided.' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
}

View File

@@ -46,6 +46,8 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
// createSchedule already auto-applies the route coach template;
// only override if explicit coachIds are provided
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
@@ -177,6 +179,18 @@ export class SchedulesService {
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
// Auto-apply route coach template if one is defined
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
where: { routeId: dto.routeId },
orderBy: { positionNumber: 'asc' },
});
if (coachTemplates.length > 0) {
await this.assignCoaches(
schedule.id,
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
);
}
return this.getSchedule(schedule.id);
}
@@ -583,10 +597,10 @@ export class SchedulesService {
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
const data = coaches.map((c, idx) => ({
const data = coaches.map((c) => ({
scheduleId,
coachId: c.coachId,
positionNumber: idx + 1,
positionNumber: c.positionNumber,
isOperational: true,
}));