mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
||||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||||
|
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
||||||
import { WorkOrder } from './entities/work-order.entity';
|
import { WorkOrder } from './entities/work-order.entity';
|
||||||
import { Part } from './entities/part.entity';
|
import { Part } from './entities/part.entity';
|
||||||
import { Warranty } from './entities/warranty.entity';
|
import { Warranty } from './entities/warranty.entity';
|
||||||
import { MaintenanceService } from './maintenance.service';
|
import { MaintenanceService } from './maintenance.service';
|
||||||
import { MaintenanceDepthService } from './maintenance-depth.service';
|
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||||
import { MaintenanceRepository } from './maintenance.repository';
|
import { MaintenanceRepository } from './maintenance.repository';
|
||||||
|
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
|
||||||
import { WorkOrderRepository } from './work-order.repository';
|
import { WorkOrderRepository } from './work-order.repository';
|
||||||
import { PartRepository } from './part.repository';
|
import { PartRepository } from './part.repository';
|
||||||
import { WarrantyRepository } from './warranty.repository';
|
import { WarrantyRepository } from './warranty.repository';
|
||||||
@@ -16,13 +18,21 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
|
TypeOrmModule.forFeature([
|
||||||
|
MaintenanceSchedule,
|
||||||
|
MaintenanceCost,
|
||||||
|
MaintenanceInterval,
|
||||||
|
WorkOrder,
|
||||||
|
Part,
|
||||||
|
Warranty,
|
||||||
|
]),
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
MaintenanceService,
|
MaintenanceService,
|
||||||
MaintenanceDepthService,
|
MaintenanceDepthService,
|
||||||
MaintenanceRepository,
|
MaintenanceRepository,
|
||||||
|
MaintenanceIntervalRepository,
|
||||||
WorkOrderRepository,
|
WorkOrderRepository,
|
||||||
PartRepository,
|
PartRepository,
|
||||||
WarrantyRepository,
|
WarrantyRepository,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { MaintenanceRepository } from './maintenance.repository';
|
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 { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||||
@@ -16,6 +17,7 @@ export class MaintenanceService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly maintenanceRepository: MaintenanceRepository,
|
private readonly maintenanceRepository: MaintenanceRepository,
|
||||||
|
private readonly intervalRepository: MaintenanceIntervalRepository,
|
||||||
@InjectRepository(MaintenanceSchedule)
|
@InjectRepository(MaintenanceSchedule)
|
||||||
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
||||||
@InjectRepository(MaintenanceCost)
|
@InjectRepository(MaintenanceCost)
|
||||||
@@ -119,6 +121,11 @@ export class MaintenanceService {
|
|||||||
) {
|
) {
|
||||||
// Maintenance finished/aborted → vehicle back in service.
|
// Maintenance finished/aborted → vehicle back in service.
|
||||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
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) {
|
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
||||||
// Maintenance started → keep the vehicle out of service.
|
// Maintenance started → keep the vehicle out of service.
|
||||||
await this.setVehicleMaintenanceState(updated.vehicleId, true);
|
await this.setVehicleMaintenanceState(updated.vehicleId, true);
|
||||||
@@ -128,6 +135,60 @@ export class MaintenanceService {
|
|||||||
return updated!;
|
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) {
|
async getUpcomingMaintenance(vehicleId: string) {
|
||||||
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
|
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user