diff --git a/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts new file mode 100644 index 000000000..3013f0320 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts @@ -0,0 +1,103 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey, TableUnique } from 'typeorm'; + +export class AddMaintenanceIntervals2800000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'maintenance_intervals', + schema: 'freight', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { + name: 'vehicle_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'maintenance_type', + type: 'varchar', + isNullable: false, + }, + { + name: 'interval_km', + type: 'numeric', + precision: 14, + scale: 2, + isNullable: true, + }, + { + name: 'interval_days', + type: 'integer', + isNullable: true, + }, + { + name: 'description', + type: 'text', + isNullable: true, + }, + { + name: 'is_active', + type: 'boolean', + default: true, + }, + { + name: 'created_at', + type: 'timestamptz', + default: 'now()', + }, + { + name: 'updated_at', + type: 'timestamptz', + default: 'now()', + }, + { + name: 'deleted_at', + type: 'timestamptz', + isNullable: true, + }, + ], + }), + ); + + // Add indexes + await queryRunner.createIndex( + new Table({ name: 'maintenance_intervals', schema: 'freight' }), + new TableIndex({ + name: 'IDX_maintenance_intervals_vehicle_type', + columnNames: ['vehicle_id', 'maintenance_type'], + }), + ); + + // Add unique constraint + await queryRunner.createUniqueConstraint( + 'maintenance_intervals', + new TableUnique({ + name: 'UQ_maintenance_intervals_vehicle_type', + columnNames: ['vehicle_id', 'maintenance_type'], + }), + ); + + // Add foreign key + await queryRunner.createForeignKey( + 'maintenance_intervals', + new TableForeignKey({ + name: 'FK_maintenance_intervals_vehicle', + columnNames: ['vehicle_id'], + referencedSchema: 'freight', + referencedTableName: 'vehicles', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('maintenance_intervals', true, true, true); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts new file mode 100644 index 000000000..095123c09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index, Unique } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceType } from './maintenance-schedule.entity'; + +/** + * Maintenance interval configuration. Defines how often a vehicle/type needs maintenance. + * Each vehicle can have different intervals for different maintenance types (e.g., oil every 10k km, tires every 50k km). + */ +@Entity({ name: 'maintenance_intervals', schema: 'freight' }) +@Index(['vehicleId', 'maintenanceType']) +@Unique(['vehicleId', 'maintenanceType']) +export class MaintenanceInterval extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + /** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */ + @Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true }) + intervalKm?: number | null; + + /** Maintenance interval in days. E.g., 365 for annual inspection. */ + @Column({ name: 'interval_days', type: 'integer', nullable: true }) + intervalDays?: number | null; + + /** Human-readable description. E.g., "Oil and filter change". */ + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + /** Is this interval active? Can be disabled without deleting historical data. */ + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts new file mode 100644 index 000000000..eded86274 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository } from 'typeorm'; +import { MaintenanceInterval } from './entities/maintenance-interval.entity'; +import { MaintenanceType } from './entities/maintenance-schedule.entity'; + +@Injectable() +export class MaintenanceIntervalRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceInterval) + private readonly intervalRepository: Repository, + ) { + super(intervalRepository); + } + + async getByVehicleAndType(vehicleId: string, maintenanceType: MaintenanceType): Promise { + return this.intervalRepository.findOne({ + where: { vehicleId, maintenanceType, isActive: true }, + }); + } + + async getActiveIntervals(vehicleId: string): Promise { + return this.intervalRepository.find({ + where: { vehicleId, isActive: true }, + order: { maintenanceType: 'ASC' }, + }); + } + + async upsertInterval( + vehicleId: string, + maintenanceType: MaintenanceType, + intervalKm?: number | null, + intervalDays?: number | null, + description?: string | null, + ): Promise { + const existing = await this.getByVehicleAndType(vehicleId, maintenanceType); + + if (existing) { + await this.intervalRepository.update(existing.id, { + intervalKm: intervalKm ?? existing.intervalKm, + intervalDays: intervalDays ?? existing.intervalDays, + description: description ?? existing.description, + }); + const updated = await this.intervalRepository.findOneBy({ id: existing.id }); + return updated!; + } + + return this.intervalRepository.save( + this.intervalRepository.create({ + vehicleId, + maintenanceType, + intervalKm, + intervalDays, + description, + isActive: true, + }), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts index 5287948a7..b64b77bae 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -2,12 +2,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { MaintenanceInterval } from './entities/maintenance-interval.entity'; import { WorkOrder } from './entities/work-order.entity'; import { Part } from './entities/part.entity'; import { Warranty } from './entities/warranty.entity'; import { MaintenanceService } from './maintenance.service'; import { MaintenanceDepthService } from './maintenance-depth.service'; import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceIntervalRepository } from './maintenance-interval.repository'; import { WorkOrderRepository } from './work-order.repository'; import { PartRepository } from './part.repository'; import { WarrantyRepository } from './warranty.repository'; @@ -16,13 +18,21 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo @Module({ imports: [ - TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]), + TypeOrmModule.forFeature([ + MaintenanceSchedule, + MaintenanceCost, + MaintenanceInterval, + WorkOrder, + Part, + Warranty, + ]), NotificationInboxModule, ], providers: [ MaintenanceService, MaintenanceDepthService, MaintenanceRepository, + MaintenanceIntervalRepository, WorkOrderRepository, PartRepository, WarrantyRepository, diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index 7bf83647a..283ca5eea 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -4,7 +4,8 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, Repository } from 'typeorm'; import { MaintenanceRepository } from './maintenance.repository'; -import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +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'; @@ -16,6 +17,7 @@ export class MaintenanceService { constructor( private readonly maintenanceRepository: MaintenanceRepository, + private readonly intervalRepository: MaintenanceIntervalRepository, @InjectRepository(MaintenanceSchedule) private readonly scheduleRepository: Repository, @InjectRepository(MaintenanceCost) @@ -119,6 +121,11 @@ export class MaintenanceService { ) { // 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); @@ -128,6 +135,60 @@ export class MaintenanceService { 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); }