feat: implement KM-based maintenance scheduling

Add maintenance intervals configuration to track maintenance by kilometers
driven. When a maintenance is marked COMPLETED, automatically calculate and
schedule the next maintenance based on interval + current odometer reading.

Features:
- MaintenanceInterval entity: stores KM/day intervals per vehicle & type
- scheduleNextMaintenance(): creates next SCHEDULED item after completion
- nextDueKm field: tracks when next maintenance is due (in kilometers)
- getDueBoard() queries already support KM-based tracking

Maintenance now "marches forward" based on distance driven, not just dates.
Each vehicle type can have different intervals (e.g., oil every 10k km, tires 50k km).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-23 07:04:40 +00:00
parent b756ae2f1c
commit fa59ccfc95
5 changed files with 275 additions and 2 deletions

View File

@@ -0,0 +1,103 @@
import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey, TableUnique } from 'typeorm';
export class AddMaintenanceIntervals2800000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.dropTable('maintenance_intervals', true, true, true);
}
}

View File

@@ -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;
}

View File

@@ -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<MaintenanceInterval> {
constructor(
@InjectRepository(MaintenanceInterval)
private readonly intervalRepository: Repository<MaintenanceInterval>,
) {
super(intervalRepository);
}
async getByVehicleAndType(vehicleId: string, maintenanceType: MaintenanceType): Promise<MaintenanceInterval | null> {
return this.intervalRepository.findOne({
where: { vehicleId, maintenanceType, isActive: true },
});
}
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
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<MaintenanceInterval> {
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,
}),
);
}
}

View File

@@ -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,

View File

@@ -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<MaintenanceSchedule>,
@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<void> {
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);
}