import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, Repository } from 'typeorm'; import { MaintenanceRepository } from './maintenance.repository'; import { MaintenanceIntervalRepository } from './maintenance-interval.repository'; import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; @Injectable() export class MaintenanceService { private readonly logger = new Logger(MaintenanceService.name); constructor( private readonly maintenanceRepository: MaintenanceRepository, private readonly intervalRepository: MaintenanceIntervalRepository, @InjectRepository(MaintenanceSchedule) private readonly scheduleRepository: Repository, @InjectRepository(MaintenanceCost) private readonly costRepository: Repository, // Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we // reach it through the global DataSource rather than @InjectRepository. private readonly dataSource: DataSource, private readonly inbox: NotificationInboxService, ) {} /** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */ async getDueBoard() { return this.maintenanceRepository.getDueBoard(); } /** * Daily check: a vehicle's driven km (latest fuel-up odometer reading, since * that's the only place mileage is actually recorded) or its due date has * reached a SCHEDULED item's threshold → alert backoffice once. */ @Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' }) async sendDueAlerts(): Promise { try { const due = await this.maintenanceRepository.getUnnotifiedDue(); for (const item of due) { const reason = item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm ? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)` : `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`; await this.inbox.notify({ recipients: { allBackoffice: true }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: `Maintenance due — ${item.plateNumber}`, body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`, link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`, data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' }, }); await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() }); } } catch (err) { this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack); } } /** * Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle * under maintenance is taken out of service (MAINTENANCE + BUSY); once the * maintenance completes or is cancelled it returns to service (ACTIVE + FREE). * Only the vehicle's status/availability columns are written here. The * assignment-side reject (first-mile/last-mile refusing MAINTENANCE vehicles) * lives in those excluded mile modules, not here. */ private async setVehicleMaintenanceState( vehicleId: string, underMaintenance: boolean, ): Promise { await this.dataSource.getRepository(Vehicle).update(vehicleId, { status: underMaintenance ? VehicleStatus.MAINTENANCE : VehicleStatus.ACTIVE, availability: underMaintenance ? VehicleAvailability.BUSY : VehicleAvailability.FREE, }); } async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise { const schedule = this.scheduleRepository.create({ ...dto, scheduledDate: new Date(dto.scheduledDate), nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined, }); const saved = await this.scheduleRepository.save(schedule); // Scheduling maintenance takes the vehicle out of the available pool. await this.setVehicleMaintenanceState(saved.vehicleId, true); return saved; } async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise { const cost = this.costRepository.create({ ...dto, incurredDate: new Date(dto.incurredDate), }); return this.costRepository.save(cost); } async updateMaintenanceSchedule( id: string, dto: UpdateMaintenanceScheduleDto, ): Promise { await this.scheduleRepository.update(id, { ...dto, completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, }); const updated = await this.scheduleRepository.findOneBy({ id }); // Keep the vehicle's status/availability in step with the schedule status. if (updated && dto.status) { if ( dto.status === MaintenanceStatus.COMPLETED || dto.status === MaintenanceStatus.CANCELLED ) { // Maintenance finished/aborted → vehicle back in service. await this.setVehicleMaintenanceState(updated.vehicleId, false); // If completed, schedule the next maintenance based on interval if (dto.status === MaintenanceStatus.COMPLETED && updated.odometerReading != null) { await this.scheduleNextMaintenance(updated); } } else if (dto.status === MaintenanceStatus.IN_PROGRESS) { // Maintenance started → keep the vehicle out of service. await this.setVehicleMaintenanceState(updated.vehicleId, true); } } return updated!; } private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise { try { // Get maintenance interval for this type const interval = await this.intervalRepository.getByVehicleAndType( completed.vehicleId, completed.maintenanceType as MaintenanceType, ); if (!interval) return; // No interval defined, skip auto-scheduling const now = new Date(); const completedKm = Number(completed.odometerReading ?? 0); // Calculate next due based on KM interval if (interval.intervalKm && interval.intervalKm > 0) { const nextDueKm = completedKm + Number(interval.intervalKm); // Create next scheduled maintenance const nextSchedule = this.scheduleRepository.create({ vehicleId: completed.vehicleId, maintenanceType: completed.maintenanceType, description: `${interval.description || completed.description} (Next interval: ${nextDueKm} km)`, scheduledDate: now, nextDueKm, status: MaintenanceStatus.SCHEDULED, }); await this.scheduleRepository.save(nextSchedule); } // Calculate next due based on date interval if (interval.intervalDays && interval.intervalDays > 0) { const nextDueDate = new Date(now.getTime() + interval.intervalDays * 24 * 60 * 60 * 1000); // If no KM-based next maintenance was created, use date-based if (!interval.intervalKm) { const nextSchedule = this.scheduleRepository.create({ vehicleId: completed.vehicleId, maintenanceType: completed.maintenanceType, description: completed.description, scheduledDate: now, nextDueDate, status: MaintenanceStatus.SCHEDULED, }); await this.scheduleRepository.save(nextSchedule); } } } catch (err) { this.logger.error( `Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`, (err as Error).stack, ); } } async getUpcomingMaintenance(vehicleId: string) { return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); } async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) { const endDate = new Date(); const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); } async getFleetMaintenanceStats(monthsBack: number = 12) { const endDate = new Date(); const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); const costs = await this.costRepository .createQueryBuilder('cost') .where('cost.incurredDate BETWEEN :startDate AND :endDate', { startDate, endDate }) .getMany(); const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); return { totalCost, numberOfMaintenanceItems: costs.length, averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, costByType: this.groupCostsByType(costs), }; } async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) { const endDate = new Date(); const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); return { vehicleId, totalCost, numberOfMaintenanceItems: costs.length, averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, costByType: this.groupCostsByType(costs), }; } private groupCostsByType(costs: MaintenanceCost[]) { const grouped: Record = {}; costs.forEach((c) => { if (!grouped[c.costType]) grouped[c.costType] = 0; grouped[c.costType] += Number(c.costAmount); }); return grouped; } }