mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
753 lines
30 KiB
TypeScript
753 lines
30 KiB
TypeScript
import { Injectable, 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 } 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';
|
|
|
|
@Injectable()
|
|
export class SchedulesService {
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private routesService: RoutesService,
|
|
private fareEngine: FareEngineService,
|
|
private auditService: AuditService,
|
|
) { }
|
|
|
|
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 || [],
|
|
};
|
|
|
|
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)}`);
|
|
}
|
|
|
|
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
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' },
|
|
},
|
|
_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');
|
|
|
|
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) {
|
|
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(),
|
|
};
|
|
});
|
|
}
|
|
|
|
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);
|
|
|
|
// 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 })),
|
|
);
|
|
}
|
|
|
|
const result = await this.getSchedule(schedule.id);
|
|
await this.auditService.log({ action: 'CREATE', entityType: 'Schedule', entityId: schedule.id, newData: { trainId: dto.trainId, routeId: dto.routeId, departureAt: dep } });
|
|
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' } },
|
|
},
|
|
});
|
|
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) {
|
|
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(),
|
|
};
|
|
});
|
|
}
|
|
|
|
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: 'UPDATE', entityType: 'Schedule', entityId: id, newData: { trainId: dto.trainId, departureAt: dep } });
|
|
return result;
|
|
}
|
|
|
|
async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
|
|
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
|
|
}
|
|
|
|
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: 'DELETE', entityType: 'Schedule', entityId: id });
|
|
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`);
|
|
|
|
return 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 },
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
return 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 },
|
|
});
|
|
});
|
|
}
|
|
|
|
createFareRule(dto: CreateFareRuleDto) {
|
|
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
|
const result = this.prisma.fareRule.create({
|
|
data: {
|
|
...rest,
|
|
tripId: scheduleId,
|
|
nationality,
|
|
validFrom: parseEthiopianTime(validFrom),
|
|
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
|
|
},
|
|
include: { seatClass: true },
|
|
});
|
|
result.then(r => this.auditService.log({ action: 'CREATE', entityType: 'FareRule', entityId: r.id, newData: { seatClassId: r.seatClassId, baseFareMinor: r.baseFareMinor } }));
|
|
return result;
|
|
}
|
|
|
|
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;
|
|
return 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 },
|
|
});
|
|
}
|
|
|
|
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: 'DELETE', entityType: 'FareRule', entityId: id });
|
|
return { deleted: true, id };
|
|
}
|
|
|
|
createSegmentFareRule(dto: any) {
|
|
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
|
return this.prisma.segmentFareRule.create({
|
|
data: {
|
|
...rest,
|
|
validFrom: parseEthiopianTime(validFrom),
|
|
validUntil: validUntil ? parseEthiopianTime(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, passengerCategory, ...rest } = dto;
|
|
return this.prisma.segmentFareRule.update({
|
|
where: { id },
|
|
data: {
|
|
...rest,
|
|
validFrom: validFrom ? parseEthiopianTime(validFrom) : undefined,
|
|
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
|
|
},
|
|
include: { seatClass: true, route: true },
|
|
});
|
|
}
|
|
|
|
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)}`);
|
|
}
|
|
}
|
|
|
|
return { synced, errors };
|
|
}
|
|
|
|
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`);
|
|
|
|
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 });
|
|
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 = {};
|
|
|
|
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);
|
|
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 (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
|
|
|
|
if (Object.keys(updateData).length > 0) {
|
|
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
|
|
}
|
|
|
|
if (dto.coaches !== undefined) {
|
|
if (dto.coaches.length > 0) {
|
|
await this.assignCoaches(id, dto.coaches);
|
|
} else {
|
|
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
|
}
|
|
}
|
|
|
|
return this.getSchedule(id);
|
|
}
|
|
|
|
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 } });
|
|
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: 'CREATE', entityType: 'RouteFareRule', entityId: rule.id, newData: { routeId: dto.routeId, seatClassId: dto.seatClassId, baseFareMinor: dto.baseFareMinor } });
|
|
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');
|
|
return 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 },
|
|
});
|
|
}
|
|
|
|
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: 'DELETE', entityType: 'RouteFareRule', entityId: id });
|
|
return { deleted: true, id };
|
|
}
|
|
}
|