Files
edr-platform/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts

1162 lines
46 KiB
TypeScript

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';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { snapshot } from '../../common/audit-snapshot';
import { LiveService } from '../live/live.service';
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
const SCHEDULE_AUDIT_FIELDS = [
'trainId',
'routeId',
'originStationId',
'destinationStationId',
'departureAt',
'arrivalAt',
'durationMinutes',
'stopsCount',
'status',
] as const;
const FARE_RULE_AUDIT_FIELDS = [
'tripId',
'seatClassId',
'baseFareMinor',
'currency',
'nationality',
'validFrom',
'validUntil',
] as const;
const SEGMENT_FARE_AUDIT_FIELDS = [
'routeId',
'seatClassId',
'originStopSequence',
'destinationStopSequence',
'baseFareMinor',
'currency',
'validFrom',
'validUntil',
] as const;
const ROUTE_FARE_AUDIT_FIELDS = [
'routeId',
'seatClassId',
'passengerCategory',
'baseFareMinor',
'surchargeMinor',
'validFrom',
'validUntil',
] as const;
const STOP_TIME_AUDIT_FIELDS = [
'scheduleId',
'stationId',
'sequence',
'plannedArrivalAt',
'plannedDepartureAt',
'status',
] as const;
@Injectable()
export class SchedulesService {
private readonly logger = new Logger(SchedulesService.name);
constructor(
private prisma: PrismaService,
private routesService: RoutesService,
private fareEngine: FareEngineService,
private auditService: AuditService,
private liveService: LiveService,
) { }
/**
* 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);
const errors: string[] = [];
const scheduleIds: string[] = [];
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 || [],
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);
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
}
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
}
// One row for the whole sweep, not one per schedule — the operator performed a single
// action and the created ids are the interesting part.
await this.auditService.log({
action: AUDIT_ACTIONS.BULK_CREATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: dto.routeId,
newData: {
routeId: dto.routeId,
trainId: dto.trainId,
startDateTime: dto.startDateTime,
forNextDays: dto.forNextDays,
repeatEveryDays: dto.repeatEveryDays,
schedulesCreated: scheduleCount,
scheduleIds,
errorCount: errors.length,
},
});
return { schedulesCreated: scheduleCount, errors, scheduleIds };
}
async listSchedules(dto: ListSchedulesDto) {
const where: any = {};
if (dto.date) {
const date = parseEthiopianTime(dto.date);
const nextDay = startOfNextDayEAT(date);
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,
route: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: true },
orderBy: { positionNumber: 'asc' },
},
liveStatus: { select: { delayMinutes: true } },
_count: { select: { coachAssignments: true, bookings: true } },
},
orderBy: { departureAt: 'asc' },
});
}
async createSchedule(dto: CreateScheduleDto) {
// Parse dates in local Ethiopian time (EAT - UTC+3)
const dep = parseEthiopianTime(dto.departureAt);
const arr = parseEthiopianTime(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
// M-4: a new schedule cannot depart in the past — the backoffice form does not enforce this.
if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future');
const [train, route] = await Promise.all([
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
}),
]);
if (!train) throw new NotFoundException('Train not found');
if (!train.isActive) throw new BadRequestException('Train is not active');
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');
// Check for existing schedule on the same day (local Ethiopian time)
const depDate = startOfDayEAT(dep);
const nextDay = startOfNextDayEAT(dep);
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()}`,
);
}
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
plannedTimes = this.computePlannedTimes(route, dep, arr);
}
const providedSeqs = new Set((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(', ')}`);
}
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 },
});
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
// 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,
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.',
);
}
const result = await this.getSchedule(schedule.id);
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: schedule.id,
newData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
});
return result;
}
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' }] } } } },
orderBy: { positionNumber: 'asc' },
},
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
liveStatus: { select: { delayMinutes: true } },
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
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: any) => ({
...a,
coach: {
...a.coach,
seats: a.coach.seats.map((s: any) => ({
...s,
status: effectiveStatuses.get(s.id) ?? s.status,
})),
},
})),
};
}
private async resolveEffectiveStatuses(scheduleId: string, seatIds: string[]): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
const [activeHolds, bookedSegments] = await Promise.all([
this.prisma.seatHold.findMany({
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
select: { seatIds: true },
}),
this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true },
}),
]);
for (const hold of activeHolds)
for (const seatId of hold.seatIds)
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
for (const seg of bookedSegments)
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
return statusMap;
}
async updateSchedule(id: string, dto: CreateScheduleDto) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
// Parse dates in local Ethiopian time (EAT - UTC+3)
const dep = parseEthiopianTime(dto.departureAt);
const arr = parseEthiopianTime(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
const [train, route] = await Promise.all([
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
}),
]);
if (!train) throw new NotFoundException('Train not found');
if (!train.isActive) throw new BadRequestException('Train is not active');
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');
const firstStop = route.stops[0];
const lastStop = route.stops[route.stops.length - 1];
await this.prisma.trainSchedule.update({
where: { id },
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),
},
});
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
plannedTimes = this.computePlannedTimes(route, dep, arr);
}
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
const result = await this.getSchedule(id);
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: id,
oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS),
});
return result;
}
async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
const updated = await this.prisma.trainSchedule.update({
where: { id },
data: { status: dto.status },
});
// CANCELLED is the one transition an operator is asked to justify after the fact, so it
// gets its own verb; everything else is a plain status move.
await this.auditService.log({
action:
dto.status === 'CANCELLED' ? AUDIT_ACTIONS.CANCEL : AUDIT_ACTIONS.STATUS_CHANGE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: id,
oldData: { status: schedule.status },
newData: { status: updated.status, departureAt: updated.departureAt.toISOString() },
});
return updated;
}
async deleteSchedule(id: string, cascade = false) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id },
include: {
_count: { select: { bookings: true } },
train: true,
originStation: true,
destinationStation: true
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!cascade) {
const constraints = [];
if ((schedule as any)._count.bookings > 0) {
constraints.push({
entityName: 'booking',
count: (schedule as any)._count.bookings,
action: 'cancel' as const
});
}
if (constraints.length > 0) {
const scheduleName = `${schedule.train.number} (${schedule.originStation.name}${schedule.destinationStation.name})`;
throw new DeleteOperationException('Schedule', scheduleName, constraints);
}
}
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: id } });
await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } });
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
// Delete bookings and all their children
const bookings = await this.prisma.booking.findMany({
where: { OR: [{ scheduleId: id }, { returnScheduleId: id }] },
select: { id: true },
});
if (bookings.length > 0) {
const bookingIds = bookings.map(b => b.id);
// Leaf tables first
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (tickets.length > 0) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (foodOrders.length > 0) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (paymentIntents.length > 0) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
}
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.agentBooking.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.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
}
// Delete travel packages that reference this schedule
const packagesToDelete = await this.prisma.travelPackage.findMany({
where: { OR: [{ outboundScheduleId: id }, { returnScheduleId: id }] },
select: { id: true },
});
if (packagesToDelete.length > 0) {
const packageIds = packagesToDelete.map(p => p.id);
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
}
await this.prisma.trainSchedule.delete({ where: { id } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: id,
oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
newData: { cascade, bookingsAffected: (schedule as any)._count?.bookings ?? 0 },
});
return { deleted: true, id };
}
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`);
const updated = await this.prisma.tripStopTime.update({
where: { scheduleId_sequence: { scheduleId, sequence } },
data: {
plannedArrivalAt: dto.plannedArrivalAt ? parseEthiopianTime(dto.plannedArrivalAt) : undefined,
plannedDepartureAt: dto.plannedDepartureAt ? parseEthiopianTime(dto.plannedDepartureAt) : undefined,
status: dto.status,
},
include: { station: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: scheduleId,
oldData: snapshot(stop, STOP_TIME_AUDIT_FIELDS),
newData: snapshot(updated, STOP_TIME_AUDIT_FIELDS),
});
return updated;
}
/**
* Shifts stored planned times additively rather than reusing updateSchedulePartial's
* recompute-from-route-interpolation path — that path also guards `departureAt must be in the
* future`, which a delay report for an already-departed/EN_ROUTE train would legitimately
* fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived
* directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the
* stored values here is the entire fix — neither of those needs to change.
*/
async applyDelay(scheduleId: string, dto: ApplyDelayDto) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
const stopWhere: any = { scheduleId };
if (dto.fromSequence != null) {
stopWhere.sequence = { gte: dto.fromSequence };
} else {
// Default: only stops the train hasn't reached yet — a delay report must not retroactively
// move a stop that's already BOARDED/COMPLETED.
stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] };
}
const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere });
const shiftMs = dto.delayMinutes * 60_000;
const includesOrigin = stopsToShift.some((s) => s.sequence === 1);
await this.prisma.$transaction(async (tx) => {
for (const stop of stopsToShift) {
await tx.tripStopTime.update({
where: { id: stop.id },
data: {
plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined,
plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined,
},
});
}
// Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window
// queries and the displayed departure time, so they must move too (both together, so
// durationMinutes stays correct).
if (includesOrigin) {
await tx.trainSchedule.update({
where: { id: scheduleId },
data: {
departureAt: new Date(schedule.departureAt.getTime() + shiftMs),
arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs),
},
});
}
});
const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } });
const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes);
await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes });
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: scheduleId,
oldData: {
delayMinutes: currentLive?.delayMinutes ?? 0,
departureAt: schedule.departureAt.toISOString(),
},
newData: {
delayMinutes: dto.delayMinutes,
fromSequence: dto.fromSequence,
accumulatedDelayMinutes,
stopsShifted: stopsToShift.length,
},
});
return this.getSchedule(scheduleId);
}
async upsertScheduleFare(
scheduleId: string,
seatClassId: string,
dto: { baseFareMinor: number; validFrom?: string; validUntil?: string },
) {
const [schedule, seatClass] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }),
this.prisma.seatClass.findUnique({ where: { id: seatClassId } }),
]);
if (!schedule) throw new NotFoundException('Schedule not found');
if (!seatClass) throw new NotFoundException('Seat class not found');
const now = new Date();
const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now;
const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null;
const superseded = await this.prisma.fareRule.findFirst({
where: { tripId: scheduleId, seatClassId, validUntil: null },
orderBy: { validFrom: 'desc' },
});
const created = await this.prisma.$transaction(async (tx) => {
await tx.fareRule.updateMany({
where: { tripId: scheduleId, seatClassId, validUntil: null },
data: { validUntil: now },
});
return tx.fareRule.create({
data: { tripId: scheduleId, seatClassId, baseFareMinor: dto.baseFareMinor, currency: 'ETB', validFrom, validUntil },
include: { seatClass: true },
});
});
// A schedule fare is versioned rather than edited, so the audit row pairs the rule that was
// closed off with the one that replaced it — otherwise the price change is invisible.
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.ScheduleFare,
entityId: created.id,
oldData: snapshot(superseded, FARE_RULE_AUDIT_FIELDS),
newData: {
...snapshot(created, FARE_RULE_AUDIT_FIELDS),
scheduleId,
seatClassName: seatClass.name,
},
});
return created;
}
async createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
const rule = await this.prisma.fareRule.create({
data: {
...rest,
tripId: scheduleId,
nationality,
validFrom: parseEthiopianTime(validFrom),
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
},
include: { seatClass: true },
});
// Awaited, not a floating .then(): an unhandled rejection there could outlive the response,
// and the row could land after the caller had already moved on.
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.FareRule,
entityId: rule.id,
newData: snapshot(rule, FARE_RULE_AUDIT_FIELDS),
});
return rule;
}
async updateFareRule(id: string, dto: Partial<CreateFareRuleDto>) {
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Fare rule not found');
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
const updated = await this.prisma.fareRule.update({
where: { id },
data: {
...rest,
...(scheduleId !== undefined && { tripId: scheduleId }),
...(nationality !== undefined && { nationality }),
...(validFrom && { validFrom: parseEthiopianTime(validFrom) }),
...(validUntil !== undefined && { validUntil: validUntil ? parseEthiopianTime(validUntil) : null }),
},
include: { seatClass: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.FareRule,
entityId: id,
oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS),
newData: snapshot(updated, FARE_RULE_AUDIT_FIELDS),
});
return updated;
}
async deleteFareRule(id: string) {
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Fare rule not found');
await this.prisma.fareRule.delete({ where: { id } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.FareRule,
entityId: id,
oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS),
});
return { deleted: true, id };
}
async createSegmentFareRule(dto: any) {
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
const rule = await this.prisma.segmentFareRule.create({
data: {
...rest,
validFrom: parseEthiopianTime(validFrom),
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
},
include: { seatClass: true, route: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.SegmentFareRule,
entityId: rule.id,
newData: snapshot(rule, SEGMENT_FARE_AUDIT_FIELDS),
});
return rule;
}
getSegmentFares(routeId: string) {
return this.prisma.segmentFareRule.findMany({
where: { routeId },
include: { seatClass: true, route: true },
orderBy: [{ originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
});
}
async deleteSegmentFareRule(id: string) {
const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Segment fare rule not found');
const deleted = await this.prisma.segmentFareRule.delete({ where: { id } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.SegmentFareRule,
entityId: id,
oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS),
});
return deleted;
}
async updateSegmentFareRule(id: string, dto: any) {
const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Segment fare rule not found');
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
const updated = await this.prisma.segmentFareRule.update({
where: { id },
data: {
...rest,
validFrom: validFrom ? parseEthiopianTime(validFrom) : undefined,
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
},
include: { seatClass: true, route: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.SegmentFareRule,
entityId: id,
oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS),
newData: snapshot(updated, SEGMENT_FARE_AUDIT_FIELDS),
});
return updated;
}
async getFareRules(scheduleId?: string) {
const where: any = {};
if (scheduleId) where.tripId = scheduleId;
return this.prisma.fareRule.findMany({
where,
include: { seatClass: true },
orderBy: { createdAt: 'desc' },
});
}
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
}
async getAllFaresFromEngine(scheduleId: string, nationality?: string) {
try {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
} catch (error) {
throw new BadRequestException(
error instanceof Error ? error.message : 'Failed to calculate fares for schedule',
);
}
}
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
const errors: string[] = [];
let synced = 0;
const now = new Date();
for (const fare of results as any[]) {
try {
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
await this.prisma.fareRule.updateMany({
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
data: { validUntil: now },
});
await this.prisma.fareRule.create({
data: {
tripId: scheduleId,
seatClassId: seatClass.id,
baseFareMinor: fare.totalMinor,
currency: 'ETB',
validFrom: now,
validUntil: null,
},
});
synced++;
} catch (err) {
errors.push(`${fare.seatClassName}: ${err instanceof Error ? err.message : String(err)}`);
}
}
// One row for the sweep: the operator pressed sync once, and every fare it rewrote is
// reconstructable from the FareRule versions it created.
await this.auditService.log({
action: AUDIT_ACTIONS.SYNC,
entityType: AUDIT_ENTITIES.ScheduleFare,
entityId: scheduleId,
newData: { scheduleId, synced, errorCount: errors.length },
});
return { synced, errors };
}
async recalculateStopTimes(scheduleId: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { route: { include: { stops: { orderBy: { sequence: 'asc' } } } } },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId || !schedule.route) throw new BadRequestException('Schedule has no associated route');
const plannedTimes = computePlannedStopTimes(
schedule.route,
new Date(schedule.departureAt),
new Date(schedule.arrivalAt),
);
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap);
await this.auditService.log({
action: AUDIT_ACTIONS.BULK_UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: scheduleId,
newData: {
recalculatedStopTimes: true,
routeId: schedule.routeId,
stopCount: plannedTimes.length,
},
});
return { recalculated: true, scheduleId, stopCount: plannedTimes.length };
}
async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
const coachIds = coaches.map(c => c.coachId);
const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
const inactiveCoach = existingCoaches.find(c => c.status !== 'ACTIVE');
if (inactiveCoach) throw new BadRequestException(`Coach ${inactiveCoach.number} is not active`);
const previous = await this.prisma.coachAssignment.findMany({
where: { scheduleId },
orderBy: { positionNumber: 'asc' },
select: { coachId: true, positionNumber: true },
});
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
const data = coaches.map((c) => ({
scheduleId,
coachId: c.coachId,
positionNumber: c.positionNumber,
isOperational: true,
}));
await this.prisma.coachAssignment.createMany({ data });
// Assignment is a wholesale replacement, so both compositions go on one row rather than a
// delete row per coach followed by a create row per coach.
await this.auditService.log({
action: AUDIT_ACTIONS.ASSIGN,
entityType: AUDIT_ENTITIES.CoachAssignment,
entityId: scheduleId,
oldData: { scheduleId, coaches: previous },
newData: {
scheduleId,
coaches: coaches.map((c) => ({ coachId: c.coachId, positionNumber: c.positionNumber })),
},
});
return { message: 'Coaches assigned successfully', count: coaches.length };
}
async getAssignedCoaches(scheduleId: string) {
return this.prisma.coachAssignment.findMany({
where: { scheduleId },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
orderBy: { positionNumber: 'asc' },
});
}
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 = {};
let dep: Date | undefined;
let arr: Date | undefined;
if (dto.departureAt || dto.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);
}
if (dto.status) updateData.status = dto.status;
if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
if (Object.keys(updateData).length > 0) {
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) {
// Logs its own ASSIGN row; this method only audits the schedule's own fields, so the
// two rows describe two facts rather than double-reporting one.
await this.assignCoaches(id, dto.coaches);
} else {
const cleared = await this.prisma.coachAssignment.findMany({
where: { scheduleId: id },
select: { coachId: true, positionNumber: true },
});
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
await this.auditService.log({
action: AUDIT_ACTIONS.UNASSIGN,
entityType: AUDIT_ENTITIES.CoachAssignment,
entityId: id,
oldData: { scheduleId: id, coaches: cleared },
newData: { scheduleId: id, coaches: [] },
});
}
}
const result = await this.getSchedule(id);
if (Object.keys(updateData).length > 0) {
const statusChanged = dto.status !== undefined && dto.status !== schedule.status;
await this.auditService.log({
action: statusChanged
? dto.status === 'CANCELLED'
? AUDIT_ACTIONS.CANCEL
: AUDIT_ACTIONS.STATUS_CHANGE
: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Schedule,
entityId: id,
oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS),
});
}
return result;
}
async removeCoachAssignment(scheduleId: string, coachId: string) {
const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } });
if (!assignment) throw new NotFoundException('Coach assignment not found');
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
await this.auditService.log({
action: AUDIT_ACTIONS.UNASSIGN,
entityType: AUDIT_ENTITIES.CoachAssignment,
entityId: assignment.id,
oldData: { scheduleId, coachId, positionNumber: assignment.positionNumber },
});
return { message: 'Coach assignment removed' };
}
// ── Route Fare Rule Overrides ──────────────────────────────────────────────
listRouteFareRules(routeId: string) {
return this.prisma.routeFareRule.findMany({
where: { routeId },
include: { seatClass: true, route: true },
orderBy: { createdAt: 'desc' },
});
}
async createRouteFareRule(dto: {
routeId: string;
seatClassId: string;
passengerCategory?: string;
baseFareMinor: number;
validFrom: string;
validUntil?: string;
}) {
const [route, seatClass] = await Promise.all([
this.prisma.route.findUnique({ where: { id: dto.routeId } }),
this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }),
]);
if (!route) throw new NotFoundException('Route not found');
if (!seatClass) throw new NotFoundException('Seat class not found');
const rule = await this.prisma.routeFareRule.create({
data: {
routeId: dto.routeId,
seatClassId: dto.seatClassId,
passengerCategory: (dto.passengerCategory as any) ?? 'ADULT',
baseFareMinor: dto.baseFareMinor,
validFrom: parseEthiopianTime(dto.validFrom),
validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null,
},
include: { seatClass: true, route: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.RouteFareRule,
entityId: rule.id,
newData: {
...snapshot(rule, ROUTE_FARE_AUDIT_FIELDS),
routeCode: route.code,
seatClassName: seatClass.name,
},
});
return rule;
}
async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
const updated = await this.prisma.routeFareRule.update({
where: { id },
data: {
...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
...(dto.surchargeMinor !== undefined && { surchargeMinor: dto.surchargeMinor }),
...(dto.validFrom && { validFrom: parseEthiopianTime(dto.validFrom) }),
...(dto.validUntil !== undefined && { validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null }),
},
include: { seatClass: true, route: true },
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.RouteFareRule,
entityId: id,
oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS),
newData: snapshot(updated, ROUTE_FARE_AUDIT_FIELDS),
});
return updated;
}
async deleteRouteFareRule(id: string) {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
await this.prisma.routeFareRule.delete({ where: { id } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.RouteFareRule,
entityId: id,
oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS),
});
return { deleted: true, id };
}
}