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, UpsertMaintenanceIntervalDto, } 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 { // Status BEFORE the write: completing an already-COMPLETED schedule again // must not auto-create a second "next" schedule. const before = await this.scheduleRepository.findOneBy({ id }); 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); // First transition into COMPLETED with an odometer → auto-schedule next. if ( dto.status === MaintenanceStatus.COMPLETED && before?.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!; } /** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */ async upsertInterval(dto: UpsertMaintenanceIntervalDto) { return this.intervalRepository.upsertInterval( dto.vehicleId, dto.maintenanceType, dto.serviceItem ?? null, dto.intervalKm ?? null, dto.intervalDays ?? null, dto.description ?? null, ); } async getIntervals(vehicleId: string) { return this.intervalRepository.getActiveIntervals(vehicleId); } async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> { await this.intervalRepository.deactivate(id); return { id, deactivated: true }; } /** * Auto-schedule the next service after a completion: matched on the * completed schedule's (type, serviceItem) interval; one SCHEDULED row * carrying BOTH thresholds when the interval defines km and days — * whichever is crossed first makes it due. */ private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise { try { const interval = await this.intervalRepository.getByVehicleAndType( completed.vehicleId, completed.maintenanceType as MaintenanceType, completed.serviceItem, ); if (!interval) return; // No interval defined, skip auto-scheduling const now = new Date(); const completedKm = Number(completed.odometerReading ?? 0); const intervalKm = Number(interval.intervalKm ?? 0); const intervalDays = Number(interval.intervalDays ?? 0); if (intervalKm <= 0 && intervalDays <= 0) return; const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined; const nextDueDate = intervalDays > 0 ? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000) : undefined; const label = interval.serviceItem ? `${interval.serviceItem}: ` : ''; const due = [ nextDueKm != null ? `${nextDueKm} km` : null, nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null, ] .filter(Boolean) .join(' / '); await this.scheduleRepository.save( this.scheduleRepository.create({ vehicleId: completed.vehicleId, maintenanceType: completed.maintenanceType, serviceItem: completed.serviceItem ?? interval.serviceItem ?? null, description: `${label}${interval.description || completed.description} (next due: ${due})`, scheduledDate: now, nextDueKm, nextDueDate, status: MaintenanceStatus.SCHEDULED, }), ); } 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; } }