mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user