Commiting stop based booking

This commit is contained in:
Muluhabt
2026-07-23 20:11:11 +03:00
parent 8bba882710
commit 64e1130f7d
14 changed files with 354 additions and 147 deletions

View File

@@ -42,7 +42,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
@Patch(':id')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' })
@ApiResponse({ status: 404, description: 'Route not found' })

View File

@@ -5,8 +5,9 @@ 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.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 120.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Ignored for sequence 1 (origin, no predecessor). Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number;
}
export class CreateRouteDto {
@@ -14,8 +15,9 @@ export class CreateRouteDto {
@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;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string | null;
@ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route (defaults to 30 if omitted)' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiProperty({
type: [RouteStopInputDto],
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',
@@ -35,15 +37,17 @@ export class CreateRouteDto {
export class AddRouteStopDto {
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 75.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: 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;
@ApiPropertyOptional({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsOptional() @IsDateString() effectiveFrom?: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z', description: 'Send null to clear (open-ended route)' }) @IsOptional() @IsDateString() effectiveUntil?: string | null;
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
import { parseEthiopianTime } from '../../common/utils/timezone.utils';
@Injectable()
export class RoutesService {
@@ -10,6 +11,33 @@ export class RoutesService {
// ── Route CRUD ─────────────────────────────────────────────────────────────
/**
* distanceKm is CUMULATIVE distance from the route origin, not distance from the previous
* stop (that's what travelMinutesToStop is for) — fare pricing computes a segment's distance
* as destStop.distanceKm - originStop.distanceKm, so a route with equal or decreasing values
* across stops silently produces zero/negative segment distances, which the fare engine
* rejects (caught and swallowed by search into a bare "N/A" instead of a visible error). Catch
* the mistake here instead, with a message that names the exact stops involved.
*/
private validateStopDistances(stops: { sequence: number; stationId: string; distanceKm?: number | null }[]): void {
const sorted = [...stops].sort((a, b) => a.sequence - b.sequence);
let prevDistance = sorted[0]?.distanceKm ?? 0;
for (let i = 1; i < sorted.length; i++) {
const stop = sorted[i];
if (stop.distanceKm == null) {
throw new BadRequestException(
`Stop ${stop.sequence} is missing distanceKm (cumulative distance in km from the route origin). This is required for fare pricing.`,
);
}
if (stop.distanceKm <= prevDistance) {
throw new BadRequestException(
`Stop ${stop.sequence}'s distanceKm (${stop.distanceKm}) must be greater than stop ${sorted[i - 1].sequence}'s distanceKm (${prevDistance}) — distanceKm is cumulative distance from the route origin, not distance from the previous stop. Equal or decreasing values make fare pricing between these stops fail silently.`,
);
}
prevDistance = stop.distanceKm;
}
}
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`);
@@ -19,6 +47,8 @@ export class RoutesService {
const seqs = dto.stops.map(s => s.sequence);
if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list');
this.validateStopDistances(dto.stops);
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');
@@ -29,14 +59,16 @@ export class RoutesService {
name: dto.name,
description: dto.description,
active: dto.active ?? true,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
effectiveFrom: parseEthiopianTime(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null,
stops: {
create: dto.stops.map(s => ({
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
travelMinutesToStop: s.travelMinutesToStop ?? null,
})),
},
},
@@ -86,13 +118,20 @@ export class RoutesService {
const route = await this.prisma.route.findUnique({ where: { id } });
if (!route) throw new NotFoundException('Route not found');
if (dto.stops && dto.stops.length >= 2) this.validateStopDistances(dto.stops);
await this.prisma.route.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
...(dto.effectiveFrom ? { effectiveFrom: parseEthiopianTime(dto.effectiveFrom) } : {}),
// effectiveUntil is nullable (open-ended route) — distinguish "field not sent" (leave
// untouched) from "explicitly cleared" (null → set to null), not just truthy/falsy.
...(dto.effectiveUntil !== undefined
? { effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null }
: {}),
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
},
});
@@ -106,6 +145,7 @@ export class RoutesService {
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
travelMinutesToStop: s.travelMinutesToStop ?? null,
})),
});
}
@@ -218,6 +258,9 @@ export class RoutesService {
});
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } });
this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]);
return this.prisma.routeStop.create({
data: {
routeId,
@@ -225,6 +268,7 @@ export class RoutesService {
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
travelMinutesToStop: dto.travelMinutesToStop ?? null,
},
});
}

View File

@@ -52,6 +52,10 @@ export class CreateScheduleDto {
})
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
@ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
}
export class UpdateScheduleDto {

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
@@ -9,6 +9,8 @@ import { AuditService } from '../../common/audit.service';
@Injectable()
export class SchedulesService {
private readonly logger = new Logger(SchedulesService.name);
constructor(
private prisma: PrismaService,
private routesService: RoutesService,
@@ -16,6 +18,44 @@ export class SchedulesService {
private auditService: AuditService,
) { }
/**
* Computes each stop's planned arrival/departure time by walking the route in sequence
* order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the
* previous stop). Falls back to distance-proportional interpolation over `distanceKm` for
* any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed
* overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays
* authoritative even if per-stop estimates drift.
*/
private computePlannedTimes(
route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] },
dep: Date,
arr: Date,
) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
let cursor = dep;
return route.stops.map((stop, index) => {
if (index === 0) {
cursor = dep;
} else if (index === route.stops.length - 1) {
cursor = arr;
} else if (stop.travelMinutesToStop != null) {
cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000);
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
cursor = new Date(dep.getTime() + totalDuration * progress);
this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(),
};
});
}
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
const startDate = parseEthiopianTime(dto.startDateTime);
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
@@ -43,20 +83,14 @@ export class SchedulesService {
departureAt: departureAt.toISOString(),
arrivalAt: arrivalAt.toISOString(),
plannedTimes: dto.plannedTimes || [],
coachIds: dto.coachIds,
};
// createSchedule applies coachIds if given, else auto-applies the route coach template,
// and rejects the day outright (caught below) if it would end up with zero coaches.
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,
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
);
}
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
@@ -135,26 +169,7 @@ export class SchedulesService {
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) {
stopTime = arr;
} else {
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(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
plannedTimes = this.computePlannedTimes(route, dep, arr);
}
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
@@ -183,15 +198,34 @@ 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) {
// Explicit coachIds (from the schedule form's Coaches step) override the route's coach
// template; otherwise auto-apply the template if one is defined.
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
);
} else {
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 })),
);
}
}
// A schedule with zero coaches has zero seats and is silently invisible to search (and
// unbookable) with no indication why — block creation instead of leaving a dead schedule.
const assignedCoachCount = await this.prisma.coachAssignment.count({ where: { scheduleId: schedule.id } });
if (assignedCoachCount === 0) {
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: schedule.id } });
await this.prisma.trainSchedule.delete({ where: { id: schedule.id } });
throw new BadRequestException(
'A schedule must have at least one coach assigned to be bookable. Add coaches in the Coaches step, or set a Route Coach Template on this route so new schedules auto-assign coaches.',
);
}
@@ -306,26 +340,7 @@ export class SchedulesService {
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) {
stopTime = arr;
} else {
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(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
plannedTimes = this.computePlannedTimes(route, dep, arr);
}
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
@@ -655,11 +670,14 @@ export class SchedulesService {
if (!schedule) throw new NotFoundException('Schedule not found');
const updateData: any = {};
let dep: Date | undefined;
let arr: Date | undefined;
if (dto.departureAt || dto.arrivalAt) {
const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future');
updateData.departureAt = dep;
updateData.arrivalAt = arr;
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
@@ -672,6 +690,22 @@ export class SchedulesService {
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
}
// departureAt/arrivalAt changed — the per-stop TripStopTime rows were computed against the
// OLD times and are now stale (same interpolation createSchedule/updateSchedule use). Left
// unfixed, check-in cutoff enforcement and search silently keep using outdated per-stop
// arrival/departure estimates for every intermediate stop.
if (dep && arr && schedule.routeId) {
const route = await this.prisma.route.findUnique({
where: { id: schedule.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (route && route.stops.length >= 2) {
const plannedTimes = this.computePlannedTimes(route, dep, arr);
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
}
}
if (dto.coaches !== undefined) {
if (dto.coaches.length > 0) {
await this.assignCoaches(id, dto.coaches);