[SPRINT-4] feat: Fleet maintenance and document management services
- Add MaintenanceReminderService (420 lines, 11 methods) - Create, pause, resume, complete maintenance reminders - Find due and overdue reminders - Vehicle-specific statistics - Add MaintenanceScheduleService (677 lines, 8 methods) - Generate maintenance schedules - Cost estimation and calendar views - Fleet-wide statistics - Add VehicleDocumentService (448 lines, 11 methods) - Document lifecycle management - Expiration tracking and alerts - Fleet document status summary - Add VehicleDocument entity with status tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
74027be804
commit
71efafd139
@ -8,3 +8,4 @@ export * from './fleet.entity';
|
||||
export * from './vehicle-engine.entity';
|
||||
export * from './engine-catalog.entity';
|
||||
export * from './maintenance-reminder.entity';
|
||||
export * from './vehicle-document.entity';
|
||||
|
||||
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Vehicle Document Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents documents associated with vehicles such as
|
||||
* registrations, insurance policies, permits, and verifications.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Vehicle } from './vehicle.entity';
|
||||
|
||||
/**
|
||||
* Types of vehicle documents
|
||||
*/
|
||||
export enum DocumentType {
|
||||
REGISTRATION = 'registration', // Tarjeta de circulación
|
||||
INSURANCE = 'insurance', // Póliza de seguro
|
||||
PERMIT = 'permit', // Permiso de carga/transporte
|
||||
VERIFICATION = 'verification', // Verificación vehicular
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of vehicle documents
|
||||
*/
|
||||
export enum DocumentStatus {
|
||||
VALID = 'valid', // Document is current and valid
|
||||
EXPIRED = 'expired', // Document has expired
|
||||
EXPIRING_SOON = 'expiring_soon', // Document expires within 30 days
|
||||
PENDING = 'pending', // Document is pending approval/renewal
|
||||
}
|
||||
|
||||
@Entity({ name: 'vehicle_documents', schema: 'vehicle_management' })
|
||||
@Index('idx_vehicle_documents_tenant', ['tenantId'])
|
||||
@Index('idx_vehicle_documents_vehicle', ['vehicleId'])
|
||||
@Index('idx_vehicle_documents_type', ['documentType'])
|
||||
@Index('idx_vehicle_documents_expiration', ['expirationDate'])
|
||||
@Index('idx_vehicle_documents_status', ['status'])
|
||||
@Index('idx_vehicle_documents_vehicle_type', ['vehicleId', 'documentType'])
|
||||
export class VehicleDocument {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId: string;
|
||||
|
||||
@Column({
|
||||
name: 'document_type',
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
})
|
||||
documentType: DocumentType;
|
||||
|
||||
@Column({
|
||||
name: 'document_number',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
documentNumber?: string;
|
||||
|
||||
@Column({
|
||||
name: 'issued_by',
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
nullable: true,
|
||||
})
|
||||
issuedBy?: string;
|
||||
|
||||
@Column({
|
||||
name: 'issued_date',
|
||||
type: 'date',
|
||||
nullable: true,
|
||||
})
|
||||
issuedDate?: Date;
|
||||
|
||||
@Column({
|
||||
name: 'expiration_date',
|
||||
type: 'date',
|
||||
})
|
||||
expirationDate: Date;
|
||||
|
||||
@Column({
|
||||
name: 'file_url',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
fileUrl?: string;
|
||||
|
||||
@Column({
|
||||
name: 'file_name',
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
fileName?: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
notes?: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
default: DocumentStatus.VALID,
|
||||
})
|
||||
status: DocumentStatus;
|
||||
|
||||
@Column({
|
||||
name: 'is_deleted',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
isDeleted: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'deleted_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
})
|
||||
deletedAt?: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Vehicle, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle: Vehicle;
|
||||
}
|
||||
@ -19,6 +19,7 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Fleet } from './fleet.entity';
|
||||
import { VehicleEngine } from './vehicle-engine.entity';
|
||||
import { VehicleDocument } from './vehicle-document.entity';
|
||||
|
||||
export enum VehicleType {
|
||||
TRUCK = 'truck',
|
||||
@ -124,6 +125,6 @@ export class Vehicle {
|
||||
// @OneToMany(() => MaintenanceReminder, reminder => reminder.vehicle)
|
||||
// reminders: MaintenanceReminder[];
|
||||
|
||||
// @OneToMany(() => VehicleDocument, doc => doc.vehicle)
|
||||
// documents: VehicleDocument[];
|
||||
@OneToMany(() => VehicleDocument, doc => doc.vehicle)
|
||||
documents: VehicleDocument[];
|
||||
}
|
||||
|
||||
@ -9,10 +9,17 @@ export { Fleet } from './entities/fleet.entity';
|
||||
export { VehicleEngine } from './entities/vehicle-engine.entity';
|
||||
export { EngineCatalog } from './entities/engine-catalog.entity';
|
||||
export { MaintenanceReminder } from './entities/maintenance-reminder.entity';
|
||||
export { VehicleDocument, DocumentType, DocumentStatus } from './entities/vehicle-document.entity';
|
||||
|
||||
// Services
|
||||
export { VehicleService, CreateVehicleDto, UpdateVehicleDto, VehicleFilters } from './services/vehicle.service';
|
||||
export { FleetService, CreateFleetDto, UpdateFleetDto } from './services/fleet.service';
|
||||
export {
|
||||
VehicleDocumentService,
|
||||
CreateVehicleDocumentDto,
|
||||
UpdateVehicleDocumentDto,
|
||||
FleetDocumentSummary,
|
||||
} from './services/vehicle-document.service';
|
||||
|
||||
// Controllers
|
||||
export { createVehicleController } from './controllers/vehicle.controller';
|
||||
|
||||
42
src/modules/vehicle-management/services/index.ts
Normal file
42
src/modules/vehicle-management/services/index.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Vehicle Management Services Index
|
||||
* Mecanicas Diesel - ERP Suite
|
||||
*
|
||||
* Re-exports all services from the vehicle-management module.
|
||||
*/
|
||||
|
||||
// Vehicle Service
|
||||
export { VehicleService } from './vehicle.service';
|
||||
export type {
|
||||
CreateVehicleDto,
|
||||
UpdateVehicleDto,
|
||||
VehicleFilters,
|
||||
} from './vehicle.service';
|
||||
|
||||
// Fleet Service
|
||||
export { FleetService } from './fleet.service';
|
||||
export type {
|
||||
CreateFleetDto,
|
||||
UpdateFleetDto,
|
||||
} from './fleet.service';
|
||||
|
||||
// Maintenance Reminder Service
|
||||
export { MaintenanceReminderService } from './maintenance-reminder.service';
|
||||
export type {
|
||||
CreateMaintenanceReminderDto,
|
||||
UpdateMaintenanceReminderDto,
|
||||
CompleteServiceDto,
|
||||
DueRemindersOptions,
|
||||
} from './maintenance-reminder.service';
|
||||
|
||||
// Maintenance Schedule Service
|
||||
export { MaintenanceScheduleService } from './maintenance-schedule.service';
|
||||
export type {
|
||||
ScheduledMaintenance,
|
||||
MaintenanceCalendarEntry,
|
||||
UpcomingMaintenanceOptions,
|
||||
CostEstimate,
|
||||
MaintenanceTemplate,
|
||||
FleetStatistics,
|
||||
StatisticsOptions,
|
||||
} from './maintenance-schedule.service';
|
||||
@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Maintenance Reminder Service
|
||||
* Mecanicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for managing vehicle maintenance reminders.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource, LessThanOrEqual, MoreThanOrEqual, In } from 'typeorm';
|
||||
import {
|
||||
MaintenanceReminder,
|
||||
FrequencyType,
|
||||
ReminderStatus,
|
||||
} from '../entities/maintenance-reminder.entity';
|
||||
import { Vehicle } from '../entities/vehicle.entity';
|
||||
|
||||
// DTOs
|
||||
export interface CreateMaintenanceReminderDto {
|
||||
serviceType: string;
|
||||
serviceId?: string;
|
||||
frequencyType: FrequencyType;
|
||||
intervalDays?: number;
|
||||
intervalKm?: number;
|
||||
lastServiceDate?: Date;
|
||||
lastServiceKm?: number;
|
||||
nextDueDate?: Date;
|
||||
nextDueKm?: number;
|
||||
notifyDaysBefore?: number;
|
||||
notifyKmBefore?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMaintenanceReminderDto {
|
||||
serviceType?: string;
|
||||
serviceId?: string;
|
||||
frequencyType?: FrequencyType;
|
||||
intervalDays?: number;
|
||||
intervalKm?: number;
|
||||
lastServiceDate?: Date;
|
||||
lastServiceKm?: number;
|
||||
nextDueDate?: Date;
|
||||
nextDueKm?: number;
|
||||
notifyDaysBefore?: number;
|
||||
notifyKmBefore?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CompleteServiceDto {
|
||||
serviceDate: Date;
|
||||
serviceKm: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface DueRemindersOptions {
|
||||
daysAhead?: number;
|
||||
kmAhead?: number;
|
||||
vehicleId?: string;
|
||||
fleetId?: string;
|
||||
includeOverdue?: boolean;
|
||||
}
|
||||
|
||||
export class MaintenanceReminderService {
|
||||
private reminderRepository: Repository<MaintenanceReminder>;
|
||||
private vehicleRepository: Repository<Vehicle>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.reminderRepository = dataSource.getRepository(MaintenanceReminder);
|
||||
this.vehicleRepository = dataSource.getRepository(Vehicle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new maintenance reminder
|
||||
*/
|
||||
async create(
|
||||
tenantId: string,
|
||||
vehicleId: string,
|
||||
dto: CreateMaintenanceReminderDto
|
||||
): Promise<MaintenanceReminder> {
|
||||
// Validate vehicle exists
|
||||
const vehicle = await this.vehicleRepository.findOne({
|
||||
where: { id: vehicleId, tenantId },
|
||||
});
|
||||
|
||||
if (!vehicle) {
|
||||
throw new Error('Vehicle not found');
|
||||
}
|
||||
|
||||
// Validate frequency type requirements
|
||||
this.validateFrequencyTypeRequirements(dto.frequencyType, dto);
|
||||
|
||||
// Calculate next due date/km if not provided
|
||||
const nextDueDate = dto.nextDueDate || this.calculateNextDueDate(
|
||||
dto.lastServiceDate || new Date(),
|
||||
dto.intervalDays
|
||||
);
|
||||
|
||||
const nextDueKm = dto.nextDueKm || this.calculateNextDueKm(
|
||||
dto.lastServiceKm || vehicle.currentOdometer || 0,
|
||||
dto.intervalKm
|
||||
);
|
||||
|
||||
const reminder = this.reminderRepository.create({
|
||||
tenantId,
|
||||
vehicleId,
|
||||
serviceType: dto.serviceType,
|
||||
serviceId: dto.serviceId,
|
||||
frequencyType: dto.frequencyType,
|
||||
intervalDays: dto.intervalDays,
|
||||
intervalKm: dto.intervalKm,
|
||||
lastServiceDate: dto.lastServiceDate,
|
||||
lastServiceKm: dto.lastServiceKm,
|
||||
nextDueDate,
|
||||
nextDueKm,
|
||||
notifyDaysBefore: dto.notifyDaysBefore ?? 7,
|
||||
notifyKmBefore: dto.notifyKmBefore ?? 1000,
|
||||
notes: dto.notes,
|
||||
status: ReminderStatus.ACTIVE,
|
||||
});
|
||||
|
||||
return this.reminderRepository.save(reminder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reminder by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<MaintenanceReminder | null> {
|
||||
return this.reminderRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['vehicle'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all reminders for a vehicle
|
||||
*/
|
||||
async findByVehicle(
|
||||
tenantId: string,
|
||||
vehicleId: string,
|
||||
includeInactive = false
|
||||
): Promise<MaintenanceReminder[]> {
|
||||
const whereCondition: Record<string, unknown> = { tenantId, vehicleId };
|
||||
|
||||
if (!includeInactive) {
|
||||
whereCondition.status = In([ReminderStatus.ACTIVE, ReminderStatus.PAUSED]);
|
||||
}
|
||||
|
||||
return this.reminderRepository.find({
|
||||
where: whereCondition,
|
||||
order: { nextDueDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reminders due within specified days/km
|
||||
*/
|
||||
async findDueReminders(
|
||||
tenantId: string,
|
||||
options: DueRemindersOptions = {}
|
||||
): Promise<MaintenanceReminder[]> {
|
||||
const {
|
||||
daysAhead = 30,
|
||||
kmAhead,
|
||||
vehicleId,
|
||||
fleetId,
|
||||
includeOverdue = true,
|
||||
} = options;
|
||||
|
||||
const queryBuilder = this.reminderRepository
|
||||
.createQueryBuilder('reminder')
|
||||
.leftJoinAndSelect('reminder.vehicle', 'vehicle')
|
||||
.where('reminder.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('reminder.status = :status', { status: ReminderStatus.ACTIVE });
|
||||
|
||||
// Filter by due date
|
||||
const dueDate = new Date();
|
||||
dueDate.setDate(dueDate.getDate() + daysAhead);
|
||||
|
||||
if (includeOverdue) {
|
||||
queryBuilder.andWhere('reminder.next_due_date <= :dueDate', { dueDate });
|
||||
} else {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
queryBuilder.andWhere(
|
||||
'reminder.next_due_date BETWEEN :today AND :dueDate',
|
||||
{ today, dueDate }
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by vehicle
|
||||
if (vehicleId) {
|
||||
queryBuilder.andWhere('reminder.vehicle_id = :vehicleId', { vehicleId });
|
||||
}
|
||||
|
||||
// Filter by fleet
|
||||
if (fleetId) {
|
||||
queryBuilder.andWhere('vehicle.fleet_id = :fleetId', { fleetId });
|
||||
}
|
||||
|
||||
// Filter by km ahead (if vehicle has odometer)
|
||||
if (kmAhead) {
|
||||
queryBuilder.andWhere(
|
||||
'(reminder.next_due_km IS NULL OR reminder.next_due_km <= vehicle.current_odometer + :kmAhead)',
|
||||
{ kmAhead }
|
||||
);
|
||||
}
|
||||
|
||||
return queryBuilder
|
||||
.orderBy('reminder.next_due_date', 'ASC')
|
||||
.addOrderBy('reminder.next_due_km', 'ASC', 'NULLS LAST')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find overdue reminders
|
||||
*/
|
||||
async findOverdueReminders(tenantId: string): Promise<MaintenanceReminder[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return this.reminderRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
status: ReminderStatus.ACTIVE,
|
||||
nextDueDate: LessThanOrEqual(today),
|
||||
},
|
||||
relations: ['vehicle'],
|
||||
order: { nextDueDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update reminder
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateMaintenanceReminderDto
|
||||
): Promise<MaintenanceReminder | null> {
|
||||
const reminder = await this.findById(tenantId, id);
|
||||
if (!reminder) return null;
|
||||
|
||||
// Validate frequency type if changing
|
||||
if (dto.frequencyType) {
|
||||
this.validateFrequencyTypeRequirements(dto.frequencyType, {
|
||||
...reminder,
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
Object.assign(reminder, dto);
|
||||
return this.reminderRepository.save(reminder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause reminder
|
||||
*/
|
||||
async pause(tenantId: string, id: string): Promise<MaintenanceReminder | null> {
|
||||
const reminder = await this.findById(tenantId, id);
|
||||
if (!reminder) return null;
|
||||
|
||||
if (reminder.status !== ReminderStatus.ACTIVE) {
|
||||
throw new Error('Can only pause active reminders');
|
||||
}
|
||||
|
||||
reminder.status = ReminderStatus.PAUSED;
|
||||
return this.reminderRepository.save(reminder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume paused reminder
|
||||
*/
|
||||
async resume(tenantId: string, id: string): Promise<MaintenanceReminder | null> {
|
||||
const reminder = await this.findById(tenantId, id);
|
||||
if (!reminder) return null;
|
||||
|
||||
if (reminder.status !== ReminderStatus.PAUSED) {
|
||||
throw new Error('Can only resume paused reminders');
|
||||
}
|
||||
|
||||
reminder.status = ReminderStatus.ACTIVE;
|
||||
return this.reminderRepository.save(reminder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete reminder and calculate next due date/km
|
||||
*/
|
||||
async complete(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
serviceData: CompleteServiceDto
|
||||
): Promise<MaintenanceReminder | null> {
|
||||
const reminder = await this.findById(tenantId, id);
|
||||
if (!reminder) return null;
|
||||
|
||||
// Update last service info
|
||||
reminder.lastServiceDate = serviceData.serviceDate;
|
||||
reminder.lastServiceKm = serviceData.serviceKm;
|
||||
|
||||
if (serviceData.notes) {
|
||||
reminder.notes = serviceData.notes;
|
||||
}
|
||||
|
||||
// Calculate next due date and km
|
||||
reminder.nextDueDate = this.calculateNextDueDate(
|
||||
serviceData.serviceDate,
|
||||
reminder.intervalDays
|
||||
);
|
||||
reminder.nextDueKm = this.calculateNextDueKm(
|
||||
serviceData.serviceKm,
|
||||
reminder.intervalKm
|
||||
);
|
||||
|
||||
// Update vehicle odometer if service km is higher
|
||||
const vehicle = await this.vehicleRepository.findOne({
|
||||
where: { id: reminder.vehicleId, tenantId },
|
||||
});
|
||||
|
||||
if (vehicle && (!vehicle.currentOdometer || serviceData.serviceKm > vehicle.currentOdometer)) {
|
||||
vehicle.currentOdometer = serviceData.serviceKm;
|
||||
vehicle.odometerUpdatedAt = new Date();
|
||||
await this.vehicleRepository.save(vehicle);
|
||||
}
|
||||
|
||||
return this.reminderRepository.save(reminder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete reminder
|
||||
*/
|
||||
async delete(tenantId: string, id: string): Promise<boolean> {
|
||||
const reminder = await this.findById(tenantId, id);
|
||||
if (!reminder) return false;
|
||||
|
||||
reminder.status = ReminderStatus.COMPLETED;
|
||||
await this.reminderRepository.save(reminder);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reminder statistics for a vehicle
|
||||
*/
|
||||
async getVehicleStats(tenantId: string, vehicleId: string): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
paused: number;
|
||||
overdue: number;
|
||||
dueSoon: number;
|
||||
}> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const dueSoonDate = new Date();
|
||||
dueSoonDate.setDate(dueSoonDate.getDate() + 7);
|
||||
|
||||
const [total, active, paused, overdue, dueSoon] = await Promise.all([
|
||||
this.reminderRepository.count({ where: { tenantId, vehicleId } }),
|
||||
this.reminderRepository.count({
|
||||
where: { tenantId, vehicleId, status: ReminderStatus.ACTIVE },
|
||||
}),
|
||||
this.reminderRepository.count({
|
||||
where: { tenantId, vehicleId, status: ReminderStatus.PAUSED },
|
||||
}),
|
||||
this.reminderRepository.count({
|
||||
where: {
|
||||
tenantId,
|
||||
vehicleId,
|
||||
status: ReminderStatus.ACTIVE,
|
||||
nextDueDate: LessThanOrEqual(today),
|
||||
},
|
||||
}),
|
||||
this.reminderRepository.count({
|
||||
where: {
|
||||
tenantId,
|
||||
vehicleId,
|
||||
status: ReminderStatus.ACTIVE,
|
||||
nextDueDate: LessThanOrEqual(dueSoonDate),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { total, active, paused, overdue, dueSoon: dueSoon - overdue };
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private validateFrequencyTypeRequirements(
|
||||
frequencyType: FrequencyType,
|
||||
data: Partial<CreateMaintenanceReminderDto>
|
||||
): void {
|
||||
switch (frequencyType) {
|
||||
case FrequencyType.TIME:
|
||||
if (!data.intervalDays) {
|
||||
throw new Error('intervalDays is required for time-based frequency');
|
||||
}
|
||||
break;
|
||||
case FrequencyType.ODOMETER:
|
||||
if (!data.intervalKm) {
|
||||
throw new Error('intervalKm is required for odometer-based frequency');
|
||||
}
|
||||
break;
|
||||
case FrequencyType.BOTH:
|
||||
if (!data.intervalDays || !data.intervalKm) {
|
||||
throw new Error('Both intervalDays and intervalKm are required for combined frequency');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private calculateNextDueDate(lastDate: Date, intervalDays?: number): Date | undefined {
|
||||
if (!intervalDays) return undefined;
|
||||
|
||||
const nextDate = new Date(lastDate);
|
||||
nextDate.setDate(nextDate.getDate() + intervalDays);
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
private calculateNextDueKm(lastKm: number, intervalKm?: number): number | undefined {
|
||||
if (!intervalKm) return undefined;
|
||||
return lastKm + intervalKm;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,677 @@
|
||||
/**
|
||||
* Maintenance Schedule Service
|
||||
* Mecanicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for generating and managing maintenance schedules,
|
||||
* calendar views, cost estimation, and fleet-wide statistics.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource, Between, LessThanOrEqual, In } from 'typeorm';
|
||||
import {
|
||||
MaintenanceReminder,
|
||||
FrequencyType,
|
||||
ReminderStatus,
|
||||
} from '../entities/maintenance-reminder.entity';
|
||||
import { Vehicle, VehicleStatus } from '../entities/vehicle.entity';
|
||||
import { ServiceOrder, ServiceOrderStatus } from '../../service-management/entities/service-order.entity';
|
||||
|
||||
// Types
|
||||
export interface ScheduledMaintenance {
|
||||
reminderId: string;
|
||||
vehicleId: string;
|
||||
vehiclePlate: string;
|
||||
vehicleMake: string;
|
||||
vehicleModel: string;
|
||||
serviceType: string;
|
||||
dueDate?: Date;
|
||||
dueKm?: number;
|
||||
priority: 'overdue' | 'due_soon' | 'upcoming';
|
||||
estimatedCost?: number;
|
||||
}
|
||||
|
||||
export interface MaintenanceCalendarEntry {
|
||||
date: string;
|
||||
items: ScheduledMaintenance[];
|
||||
}
|
||||
|
||||
export interface UpcomingMaintenanceOptions {
|
||||
daysAhead?: number;
|
||||
vehicleIds?: string[];
|
||||
fleetId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
serviceType: string;
|
||||
estimatedCost: number;
|
||||
dueDate?: Date;
|
||||
dueKm?: number;
|
||||
}
|
||||
|
||||
export interface MaintenanceTemplate {
|
||||
serviceType: string;
|
||||
frequencyType: FrequencyType;
|
||||
intervalDays?: number;
|
||||
intervalKm?: number;
|
||||
estimatedCost?: number;
|
||||
notifyDaysBefore?: number;
|
||||
notifyKmBefore?: number;
|
||||
}
|
||||
|
||||
export interface FleetStatistics {
|
||||
totalReminders: number;
|
||||
activeReminders: number;
|
||||
overdueCount: number;
|
||||
dueSoonCount: number;
|
||||
completedThisMonth: number;
|
||||
estimatedCostThisMonth: number;
|
||||
byServiceType: { serviceType: string; count: number }[];
|
||||
byVehicle: { vehicleId: string; plate: string; overdueCount: number; dueSoonCount: number }[];
|
||||
}
|
||||
|
||||
export interface StatisticsOptions {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
fleetId?: string;
|
||||
vehicleIds?: string[];
|
||||
}
|
||||
|
||||
// Service cost estimates (could be moved to database)
|
||||
const SERVICE_COST_ESTIMATES: Record<string, number> = {
|
||||
'oil_change': 1500,
|
||||
'tire_rotation': 800,
|
||||
'brake_inspection': 500,
|
||||
'transmission_service': 3500,
|
||||
'coolant_flush': 1200,
|
||||
'air_filter': 400,
|
||||
'fuel_filter': 600,
|
||||
'battery_check': 200,
|
||||
'alignment': 1000,
|
||||
'general_inspection': 500,
|
||||
};
|
||||
|
||||
export class MaintenanceScheduleService {
|
||||
private reminderRepository: Repository<MaintenanceReminder>;
|
||||
private vehicleRepository: Repository<Vehicle>;
|
||||
private serviceOrderRepository: Repository<ServiceOrder>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.reminderRepository = dataSource.getRepository(MaintenanceReminder);
|
||||
this.vehicleRepository = dataSource.getRepository(Vehicle);
|
||||
this.serviceOrderRepository = dataSource.getRepository(ServiceOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate maintenance schedule for a vehicle
|
||||
*/
|
||||
async generateSchedule(
|
||||
tenantId: string,
|
||||
vehicleId: string,
|
||||
months: number = 12
|
||||
): Promise<ScheduledMaintenance[]> {
|
||||
const vehicle = await this.vehicleRepository.findOne({
|
||||
where: { id: vehicleId, tenantId },
|
||||
});
|
||||
|
||||
if (!vehicle) {
|
||||
throw new Error('Vehicle not found');
|
||||
}
|
||||
|
||||
const reminders = await this.reminderRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
vehicleId,
|
||||
status: ReminderStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setMonth(endDate.getMonth() + months);
|
||||
|
||||
const schedule: ScheduledMaintenance[] = [];
|
||||
|
||||
for (const reminder of reminders) {
|
||||
const items = this.projectMaintenanceOccurrences(
|
||||
reminder,
|
||||
vehicle,
|
||||
today,
|
||||
endDate
|
||||
);
|
||||
schedule.push(...items);
|
||||
}
|
||||
|
||||
// Sort by priority then by date
|
||||
return schedule.sort((a, b) => {
|
||||
const priorityOrder = { overdue: 0, due_soon: 1, upcoming: 2 };
|
||||
const aPriority = priorityOrder[a.priority];
|
||||
const bPriority = priorityOrder[b.priority];
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return aPriority - bPriority;
|
||||
}
|
||||
|
||||
if (a.dueDate && b.dueDate) {
|
||||
return a.dueDate.getTime() - b.dueDate.getTime();
|
||||
}
|
||||
|
||||
return (a.dueKm || 0) - (b.dueKm || 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming maintenance for all vehicles (or filtered)
|
||||
*/
|
||||
async getUpcomingMaintenance(
|
||||
tenantId: string,
|
||||
options: UpcomingMaintenanceOptions = {}
|
||||
): Promise<ScheduledMaintenance[]> {
|
||||
const {
|
||||
daysAhead = 30,
|
||||
vehicleIds,
|
||||
fleetId,
|
||||
limit = 50,
|
||||
} = options;
|
||||
|
||||
const queryBuilder = this.reminderRepository
|
||||
.createQueryBuilder('reminder')
|
||||
.leftJoinAndSelect('reminder.vehicle', 'vehicle')
|
||||
.where('reminder.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('reminder.status = :status', { status: ReminderStatus.ACTIVE });
|
||||
|
||||
// Filter by date range
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + daysAhead);
|
||||
queryBuilder.andWhere('reminder.next_due_date <= :futureDate', { futureDate });
|
||||
|
||||
// Filter by vehicles
|
||||
if (vehicleIds && vehicleIds.length > 0) {
|
||||
queryBuilder.andWhere('reminder.vehicle_id IN (:...vehicleIds)', { vehicleIds });
|
||||
}
|
||||
|
||||
// Filter by fleet
|
||||
if (fleetId) {
|
||||
queryBuilder.andWhere('vehicle.fleet_id = :fleetId', { fleetId });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('reminder.next_due_date', 'ASC')
|
||||
.limit(limit);
|
||||
|
||||
const reminders = await queryBuilder.getMany();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return reminders.map(reminder => ({
|
||||
reminderId: reminder.id,
|
||||
vehicleId: reminder.vehicleId,
|
||||
vehiclePlate: reminder.vehicle?.licensePlate || 'Unknown',
|
||||
vehicleMake: reminder.vehicle?.make || 'Unknown',
|
||||
vehicleModel: reminder.vehicle?.model || 'Unknown',
|
||||
serviceType: reminder.serviceType,
|
||||
dueDate: reminder.nextDueDate,
|
||||
dueKm: reminder.nextDueKm,
|
||||
priority: this.determinePriority(reminder.nextDueDate, today),
|
||||
estimatedCost: this.getEstimatedCost(reminder.serviceType),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintenance calendar view
|
||||
*/
|
||||
async getMaintenanceCalendar(
|
||||
tenantId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<MaintenanceCalendarEntry[]> {
|
||||
const reminders = await this.reminderRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
status: ReminderStatus.ACTIVE,
|
||||
nextDueDate: Between(startDate, endDate),
|
||||
},
|
||||
relations: ['vehicle'],
|
||||
order: { nextDueDate: 'ASC' },
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Group by date
|
||||
const calendarMap = new Map<string, ScheduledMaintenance[]>();
|
||||
|
||||
for (const reminder of reminders) {
|
||||
if (!reminder.nextDueDate) continue;
|
||||
|
||||
const dateKey = reminder.nextDueDate.toISOString().split('T')[0];
|
||||
|
||||
if (!calendarMap.has(dateKey)) {
|
||||
calendarMap.set(dateKey, []);
|
||||
}
|
||||
|
||||
calendarMap.get(dateKey)!.push({
|
||||
reminderId: reminder.id,
|
||||
vehicleId: reminder.vehicleId,
|
||||
vehiclePlate: reminder.vehicle?.licensePlate || 'Unknown',
|
||||
vehicleMake: reminder.vehicle?.make || 'Unknown',
|
||||
vehicleModel: reminder.vehicle?.model || 'Unknown',
|
||||
serviceType: reminder.serviceType,
|
||||
dueDate: reminder.nextDueDate,
|
||||
dueKm: reminder.nextDueKm,
|
||||
priority: this.determinePriority(reminder.nextDueDate, today),
|
||||
estimatedCost: this.getEstimatedCost(reminder.serviceType),
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(calendarMap.entries())
|
||||
.map(([date, items]) => ({ date, items }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate maintenance costs for a vehicle
|
||||
*/
|
||||
async estimateCosts(
|
||||
tenantId: string,
|
||||
vehicleId: string,
|
||||
months: number = 12
|
||||
): Promise<{
|
||||
total: number;
|
||||
byMonth: { month: string; cost: number }[];
|
||||
byService: CostEstimate[];
|
||||
}> {
|
||||
const schedule = await this.generateSchedule(tenantId, vehicleId, months);
|
||||
|
||||
let total = 0;
|
||||
const byMonth = new Map<string, number>();
|
||||
const byService: CostEstimate[] = [];
|
||||
|
||||
for (const item of schedule) {
|
||||
const cost = item.estimatedCost || 0;
|
||||
total += cost;
|
||||
|
||||
// Group by month
|
||||
if (item.dueDate) {
|
||||
const monthKey = `${item.dueDate.getFullYear()}-${String(item.dueDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
byMonth.set(monthKey, (byMonth.get(monthKey) || 0) + cost);
|
||||
}
|
||||
|
||||
// Add to service list
|
||||
byService.push({
|
||||
serviceType: item.serviceType,
|
||||
estimatedCost: cost,
|
||||
dueDate: item.dueDate,
|
||||
dueKm: item.dueKm,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
byMonth: Array.from(byMonth.entries())
|
||||
.map(([month, cost]) => ({ month, cost }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month)),
|
||||
byService,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next due date/km based on frequency type
|
||||
*/
|
||||
calculateNextDue(
|
||||
reminder: MaintenanceReminder,
|
||||
currentKm: number
|
||||
): { nextDueDate?: Date; nextDueKm?: number } {
|
||||
const result: { nextDueDate?: Date; nextDueKm?: number } = {};
|
||||
|
||||
const baseDate = reminder.lastServiceDate || new Date();
|
||||
const baseKm = reminder.lastServiceKm || currentKm;
|
||||
|
||||
switch (reminder.frequencyType) {
|
||||
case FrequencyType.TIME:
|
||||
if (reminder.intervalDays) {
|
||||
const nextDate = new Date(baseDate);
|
||||
nextDate.setDate(nextDate.getDate() + reminder.intervalDays);
|
||||
result.nextDueDate = nextDate;
|
||||
}
|
||||
break;
|
||||
|
||||
case FrequencyType.ODOMETER:
|
||||
if (reminder.intervalKm) {
|
||||
result.nextDueKm = baseKm + reminder.intervalKm;
|
||||
}
|
||||
break;
|
||||
|
||||
case FrequencyType.BOTH:
|
||||
if (reminder.intervalDays) {
|
||||
const nextDate = new Date(baseDate);
|
||||
nextDate.setDate(nextDate.getDate() + reminder.intervalDays);
|
||||
result.nextDueDate = nextDate;
|
||||
}
|
||||
if (reminder.intervalKm) {
|
||||
result.nextDueKm = baseKm + reminder.intervalKm;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintenance history for a vehicle
|
||||
*/
|
||||
async getMaintenanceHistory(
|
||||
tenantId: string,
|
||||
vehicleId: string
|
||||
): Promise<{
|
||||
completedOrders: ServiceOrder[];
|
||||
completedReminders: MaintenanceReminder[];
|
||||
}> {
|
||||
// Get completed service orders
|
||||
const completedOrders = await this.serviceOrderRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
vehicleId,
|
||||
status: In([ServiceOrderStatus.COMPLETED, ServiceOrderStatus.DELIVERED]),
|
||||
},
|
||||
order: { completedAt: 'DESC' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
// Get reminders with service history (those that have lastServiceDate)
|
||||
const completedReminders = await this.reminderRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
vehicleId,
|
||||
},
|
||||
order: { lastServiceDate: 'DESC' },
|
||||
});
|
||||
|
||||
// Filter to only those with service history
|
||||
const remindersWithHistory = completedReminders.filter(
|
||||
r => r.lastServiceDate !== null
|
||||
);
|
||||
|
||||
return {
|
||||
completedOrders,
|
||||
completedReminders: remindersWithHistory,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create maintenance plan from templates
|
||||
*/
|
||||
async createMaintenancePlan(
|
||||
tenantId: string,
|
||||
vehicleId: string,
|
||||
templates: MaintenanceTemplate[]
|
||||
): Promise<MaintenanceReminder[]> {
|
||||
const vehicle = await this.vehicleRepository.findOne({
|
||||
where: { id: vehicleId, tenantId },
|
||||
});
|
||||
|
||||
if (!vehicle) {
|
||||
throw new Error('Vehicle not found');
|
||||
}
|
||||
|
||||
const reminders: MaintenanceReminder[] = [];
|
||||
|
||||
for (const template of templates) {
|
||||
// Calculate initial next due
|
||||
const today = new Date();
|
||||
let nextDueDate: Date | undefined;
|
||||
let nextDueKm: number | undefined;
|
||||
|
||||
if (template.intervalDays) {
|
||||
nextDueDate = new Date(today);
|
||||
nextDueDate.setDate(nextDueDate.getDate() + template.intervalDays);
|
||||
}
|
||||
|
||||
if (template.intervalKm && vehicle.currentOdometer) {
|
||||
nextDueKm = vehicle.currentOdometer + template.intervalKm;
|
||||
}
|
||||
|
||||
const reminder = this.reminderRepository.create({
|
||||
tenantId,
|
||||
vehicleId,
|
||||
serviceType: template.serviceType,
|
||||
frequencyType: template.frequencyType,
|
||||
intervalDays: template.intervalDays,
|
||||
intervalKm: template.intervalKm,
|
||||
nextDueDate,
|
||||
nextDueKm,
|
||||
notifyDaysBefore: template.notifyDaysBefore ?? 7,
|
||||
notifyKmBefore: template.notifyKmBefore ?? 1000,
|
||||
status: ReminderStatus.ACTIVE,
|
||||
});
|
||||
|
||||
reminders.push(reminder);
|
||||
}
|
||||
|
||||
return this.reminderRepository.save(reminders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fleet-wide maintenance statistics
|
||||
*/
|
||||
async getStatistics(
|
||||
tenantId: string,
|
||||
options: StatisticsOptions = {}
|
||||
): Promise<FleetStatistics> {
|
||||
const { startDate, endDate, fleetId, vehicleIds } = options;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const dueSoonDate = new Date();
|
||||
dueSoonDate.setDate(dueSoonDate.getDate() + 7);
|
||||
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
|
||||
// Build base query for reminders
|
||||
let reminderQuery = this.reminderRepository
|
||||
.createQueryBuilder('reminder')
|
||||
.leftJoin('reminder.vehicle', 'vehicle')
|
||||
.where('reminder.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (fleetId) {
|
||||
reminderQuery = reminderQuery.andWhere('vehicle.fleet_id = :fleetId', { fleetId });
|
||||
}
|
||||
|
||||
if (vehicleIds && vehicleIds.length > 0) {
|
||||
reminderQuery = reminderQuery.andWhere('reminder.vehicle_id IN (:...vehicleIds)', { vehicleIds });
|
||||
}
|
||||
|
||||
// Count queries
|
||||
const [
|
||||
totalReminders,
|
||||
activeReminders,
|
||||
overdueCount,
|
||||
dueSoonCount,
|
||||
] = await Promise.all([
|
||||
reminderQuery.clone().getCount(),
|
||||
reminderQuery.clone()
|
||||
.andWhere('reminder.status = :status', { status: ReminderStatus.ACTIVE })
|
||||
.getCount(),
|
||||
reminderQuery.clone()
|
||||
.andWhere('reminder.status = :status', { status: ReminderStatus.ACTIVE })
|
||||
.andWhere('reminder.next_due_date < :today', { today })
|
||||
.getCount(),
|
||||
reminderQuery.clone()
|
||||
.andWhere('reminder.status = :status', { status: ReminderStatus.ACTIVE })
|
||||
.andWhere('reminder.next_due_date BETWEEN :today AND :dueSoonDate', { today, dueSoonDate })
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
// Count completed this month (orders completed)
|
||||
let completedQuery = this.serviceOrderRepository
|
||||
.createQueryBuilder('order')
|
||||
.where('order.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('order.status IN (:...statuses)', {
|
||||
statuses: [ServiceOrderStatus.COMPLETED, ServiceOrderStatus.DELIVERED],
|
||||
})
|
||||
.andWhere('order.completed_at BETWEEN :monthStart AND :monthEnd', { monthStart, monthEnd });
|
||||
|
||||
if (vehicleIds && vehicleIds.length > 0) {
|
||||
completedQuery = completedQuery.andWhere('order.vehicle_id IN (:...vehicleIds)', { vehicleIds });
|
||||
}
|
||||
|
||||
const completedThisMonth = await completedQuery.getCount();
|
||||
|
||||
// Estimated cost this month
|
||||
const upcomingThisMonth = await this.reminderRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
status: ReminderStatus.ACTIVE,
|
||||
nextDueDate: Between(monthStart, monthEnd),
|
||||
},
|
||||
});
|
||||
|
||||
const estimatedCostThisMonth = upcomingThisMonth.reduce(
|
||||
(sum, r) => sum + this.getEstimatedCost(r.serviceType),
|
||||
0
|
||||
);
|
||||
|
||||
// By service type
|
||||
const serviceTypeCounts = await reminderQuery.clone()
|
||||
.select('reminder.service_type', 'serviceType')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.andWhere('reminder.status = :status', { status: ReminderStatus.ACTIVE })
|
||||
.groupBy('reminder.service_type')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany();
|
||||
|
||||
// By vehicle (overdue and due soon)
|
||||
const vehicleStats = await this.reminderRepository
|
||||
.createQueryBuilder('reminder')
|
||||
.leftJoin('reminder.vehicle', 'vehicle')
|
||||
.select('reminder.vehicle_id', 'vehicleId')
|
||||
.addSelect('vehicle.license_plate', 'plate')
|
||||
.addSelect(
|
||||
`SUM(CASE WHEN reminder.next_due_date < :today THEN 1 ELSE 0 END)`,
|
||||
'overdueCount'
|
||||
)
|
||||
.addSelect(
|
||||
`SUM(CASE WHEN reminder.next_due_date BETWEEN :today AND :dueSoonDate THEN 1 ELSE 0 END)`,
|
||||
'dueSoonCount'
|
||||
)
|
||||
.where('reminder.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('reminder.status = :status', { status: ReminderStatus.ACTIVE })
|
||||
.setParameters({ today, dueSoonDate })
|
||||
.groupBy('reminder.vehicle_id')
|
||||
.addGroupBy('vehicle.license_plate')
|
||||
.having('SUM(CASE WHEN reminder.next_due_date < :today THEN 1 ELSE 0 END) > 0 OR SUM(CASE WHEN reminder.next_due_date BETWEEN :today AND :dueSoonDate THEN 1 ELSE 0 END) > 0')
|
||||
.orderBy('overdueCount', 'DESC')
|
||||
.limit(10)
|
||||
.getRawMany();
|
||||
|
||||
return {
|
||||
totalReminders,
|
||||
activeReminders,
|
||||
overdueCount,
|
||||
dueSoonCount,
|
||||
completedThisMonth,
|
||||
estimatedCostThisMonth,
|
||||
byServiceType: serviceTypeCounts.map(row => ({
|
||||
serviceType: row.serviceType,
|
||||
count: parseInt(row.count, 10),
|
||||
})),
|
||||
byVehicle: vehicleStats.map(row => ({
|
||||
vehicleId: row.vehicleId,
|
||||
plate: row.plate || 'Unknown',
|
||||
overdueCount: parseInt(row.overdueCount, 10),
|
||||
dueSoonCount: parseInt(row.dueSoonCount, 10),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private projectMaintenanceOccurrences(
|
||||
reminder: MaintenanceReminder,
|
||||
vehicle: Vehicle,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): ScheduledMaintenance[] {
|
||||
const items: ScheduledMaintenance[] = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let currentDate = reminder.nextDueDate ? new Date(reminder.nextDueDate) : null;
|
||||
let currentKm = reminder.nextDueKm;
|
||||
|
||||
// Project future occurrences
|
||||
let iterations = 0;
|
||||
const maxIterations = 24; // Prevent infinite loops
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
iterations++;
|
||||
|
||||
// Check if we have a valid date or km to track
|
||||
if (!currentDate && !currentKm) break;
|
||||
|
||||
// Stop if date exceeds end date
|
||||
if (currentDate && currentDate > endDate) break;
|
||||
|
||||
// Create schedule item
|
||||
const item: ScheduledMaintenance = {
|
||||
reminderId: reminder.id,
|
||||
vehicleId: vehicle.id,
|
||||
vehiclePlate: vehicle.licensePlate,
|
||||
vehicleMake: vehicle.make,
|
||||
vehicleModel: vehicle.model,
|
||||
serviceType: reminder.serviceType,
|
||||
dueDate: currentDate || undefined,
|
||||
dueKm: currentKm,
|
||||
priority: this.determinePriority(currentDate, today),
|
||||
estimatedCost: this.getEstimatedCost(reminder.serviceType),
|
||||
};
|
||||
|
||||
items.push(item);
|
||||
|
||||
// Calculate next occurrence
|
||||
if (currentDate && reminder.intervalDays) {
|
||||
currentDate = new Date(currentDate);
|
||||
currentDate.setDate(currentDate.getDate() + reminder.intervalDays);
|
||||
} else {
|
||||
currentDate = null;
|
||||
}
|
||||
|
||||
if (currentKm && reminder.intervalKm) {
|
||||
currentKm = currentKm + reminder.intervalKm;
|
||||
} else {
|
||||
currentKm = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private determinePriority(
|
||||
dueDate: Date | null | undefined,
|
||||
today: Date
|
||||
): 'overdue' | 'due_soon' | 'upcoming' {
|
||||
if (!dueDate) return 'upcoming';
|
||||
|
||||
const dueDateNormalized = new Date(dueDate);
|
||||
dueDateNormalized.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dueDateNormalized < today) {
|
||||
return 'overdue';
|
||||
}
|
||||
|
||||
const sevenDaysFromNow = new Date(today);
|
||||
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||
|
||||
if (dueDateNormalized <= sevenDaysFromNow) {
|
||||
return 'due_soon';
|
||||
}
|
||||
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
private getEstimatedCost(serviceType: string): number {
|
||||
const normalizedType = serviceType.toLowerCase().replace(/\s+/g, '_');
|
||||
return SERVICE_COST_ESTIMATES[normalizedType] || 500;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Vehicle Document Service
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for managing vehicle documents including
|
||||
* registrations, insurance policies, permits, and verifications.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource, LessThanOrEqual, MoreThan, In } from 'typeorm';
|
||||
import {
|
||||
VehicleDocument,
|
||||
DocumentType,
|
||||
DocumentStatus,
|
||||
} from '../entities/vehicle-document.entity';
|
||||
|
||||
// ====================
|
||||
// DTOs
|
||||
// ====================
|
||||
|
||||
export interface CreateVehicleDocumentDto {
|
||||
documentType: DocumentType;
|
||||
documentNumber?: string;
|
||||
issuedBy?: string;
|
||||
issuedDate?: Date;
|
||||
expirationDate: Date;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateVehicleDocumentDto {
|
||||
documentNumber?: string;
|
||||
issuedBy?: string;
|
||||
issuedDate?: Date;
|
||||
expirationDate?: Date;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface FleetDocumentSummary {
|
||||
totalDocuments: number;
|
||||
validDocuments: number;
|
||||
expiredDocuments: number;
|
||||
expiringSoonDocuments: number;
|
||||
pendingDocuments: number;
|
||||
byType: Record<DocumentType, {
|
||||
total: number;
|
||||
valid: number;
|
||||
expired: number;
|
||||
expiringSoon: number;
|
||||
}>;
|
||||
vehiclesWithExpiredDocs: number;
|
||||
vehiclesWithExpiringSoonDocs: number;
|
||||
}
|
||||
|
||||
// ====================
|
||||
// Service
|
||||
// ====================
|
||||
|
||||
export class VehicleDocumentService {
|
||||
private documentRepository: Repository<VehicleDocument>;
|
||||
private dataSource: DataSource;
|
||||
|
||||
// Days before expiration to mark as "expiring soon"
|
||||
private readonly EXPIRING_SOON_DAYS = 30;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.dataSource = dataSource;
|
||||
this.documentRepository = dataSource.getRepository(VehicleDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new vehicle document
|
||||
*/
|
||||
async create(
|
||||
tenantId: string,
|
||||
vehicleId: string,
|
||||
dto: CreateVehicleDocumentDto
|
||||
): Promise<VehicleDocument> {
|
||||
// Determine initial status based on expiration date
|
||||
const status = this.calculateStatus(dto.expirationDate);
|
||||
|
||||
const document = this.documentRepository.create({
|
||||
tenantId,
|
||||
vehicleId,
|
||||
documentType: dto.documentType,
|
||||
documentNumber: dto.documentNumber,
|
||||
issuedBy: dto.issuedBy,
|
||||
issuedDate: dto.issuedDate,
|
||||
expirationDate: dto.expirationDate,
|
||||
fileUrl: dto.fileUrl,
|
||||
fileName: dto.fileName,
|
||||
notes: dto.notes,
|
||||
status,
|
||||
isDeleted: false,
|
||||
});
|
||||
|
||||
return this.documentRepository.save(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find document by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<VehicleDocument | null> {
|
||||
return this.documentRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
isDeleted: false,
|
||||
},
|
||||
relations: ['vehicle'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all documents for a vehicle
|
||||
*/
|
||||
async findByVehicle(
|
||||
tenantId: string,
|
||||
vehicleId: string
|
||||
): Promise<VehicleDocument[]> {
|
||||
return this.documentRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
vehicleId,
|
||||
isDeleted: false,
|
||||
},
|
||||
order: {
|
||||
documentType: 'ASC',
|
||||
expirationDate: 'ASC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find documents by type for a vehicle
|
||||
*/
|
||||
async findByType(
|
||||
tenantId: string,
|
||||
vehicleId: string,
|
||||
documentType: DocumentType
|
||||
): Promise<VehicleDocument[]> {
|
||||
return this.documentRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
vehicleId,
|
||||
documentType,
|
||||
isDeleted: false,
|
||||
},
|
||||
order: {
|
||||
expirationDate: 'DESC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find documents expiring within X days
|
||||
*/
|
||||
async findExpiring(tenantId: string, days: number): Promise<VehicleDocument[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const futureDate = new Date(today);
|
||||
futureDate.setDate(futureDate.getDate() + days);
|
||||
|
||||
return this.documentRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
isDeleted: false,
|
||||
expirationDate: LessThanOrEqual(futureDate),
|
||||
status: In([DocumentStatus.VALID, DocumentStatus.EXPIRING_SOON]),
|
||||
},
|
||||
relations: ['vehicle'],
|
||||
order: {
|
||||
expirationDate: 'ASC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all expired documents
|
||||
*/
|
||||
async findExpired(tenantId: string): Promise<VehicleDocument[]> {
|
||||
return this.documentRepository.find({
|
||||
where: {
|
||||
tenantId,
|
||||
isDeleted: false,
|
||||
status: DocumentStatus.EXPIRED,
|
||||
},
|
||||
relations: ['vehicle'],
|
||||
order: {
|
||||
expirationDate: 'ASC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a document
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateVehicleDocumentDto
|
||||
): Promise<VehicleDocument | null> {
|
||||
const document = await this.findById(tenantId, id);
|
||||
if (!document) return null;
|
||||
|
||||
// Update fields
|
||||
if (dto.documentNumber !== undefined) {
|
||||
document.documentNumber = dto.documentNumber;
|
||||
}
|
||||
if (dto.issuedBy !== undefined) {
|
||||
document.issuedBy = dto.issuedBy;
|
||||
}
|
||||
if (dto.issuedDate !== undefined) {
|
||||
document.issuedDate = dto.issuedDate;
|
||||
}
|
||||
if (dto.expirationDate !== undefined) {
|
||||
document.expirationDate = dto.expirationDate;
|
||||
document.status = this.calculateStatus(dto.expirationDate);
|
||||
}
|
||||
if (dto.fileUrl !== undefined) {
|
||||
document.fileUrl = dto.fileUrl;
|
||||
}
|
||||
if (dto.fileName !== undefined) {
|
||||
document.fileName = dto.fileName;
|
||||
}
|
||||
if (dto.notes !== undefined) {
|
||||
document.notes = dto.notes;
|
||||
}
|
||||
|
||||
return this.documentRepository.save(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew a document with a new expiration date
|
||||
*/
|
||||
async renew(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
newExpirationDate: Date,
|
||||
newDocumentNumber?: string,
|
||||
newFileUrl?: string,
|
||||
newFileName?: string
|
||||
): Promise<VehicleDocument | null> {
|
||||
const document = await this.findById(tenantId, id);
|
||||
if (!document) return null;
|
||||
|
||||
document.expirationDate = newExpirationDate;
|
||||
document.status = this.calculateStatus(newExpirationDate);
|
||||
document.issuedDate = new Date();
|
||||
|
||||
if (newDocumentNumber) {
|
||||
document.documentNumber = newDocumentNumber;
|
||||
}
|
||||
if (newFileUrl) {
|
||||
document.fileUrl = newFileUrl;
|
||||
}
|
||||
if (newFileName) {
|
||||
document.fileName = newFileName;
|
||||
}
|
||||
|
||||
return this.documentRepository.save(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a document
|
||||
*/
|
||||
async delete(tenantId: string, id: string): Promise<boolean> {
|
||||
const document = await this.findById(tenantId, id);
|
||||
if (!document) return false;
|
||||
|
||||
document.isDeleted = true;
|
||||
document.deletedAt = new Date();
|
||||
await this.documentRepository.save(document);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fleet-wide document status summary
|
||||
*/
|
||||
async getFleetDocumentStatus(tenantId: string): Promise<FleetDocumentSummary> {
|
||||
// Get counts by status
|
||||
const statusCounts = await this.documentRepository
|
||||
.createQueryBuilder('doc')
|
||||
.select('doc.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('doc.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('doc.is_deleted = false')
|
||||
.groupBy('doc.status')
|
||||
.getRawMany();
|
||||
|
||||
// Get counts by type and status
|
||||
const typeCounts = await this.documentRepository
|
||||
.createQueryBuilder('doc')
|
||||
.select('doc.document_type', 'documentType')
|
||||
.addSelect('doc.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('doc.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('doc.is_deleted = false')
|
||||
.groupBy('doc.document_type')
|
||||
.addGroupBy('doc.status')
|
||||
.getRawMany();
|
||||
|
||||
// Get count of vehicles with expired documents
|
||||
const vehiclesWithExpired = await this.documentRepository
|
||||
.createQueryBuilder('doc')
|
||||
.select('COUNT(DISTINCT doc.vehicle_id)', 'count')
|
||||
.where('doc.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('doc.is_deleted = false')
|
||||
.andWhere('doc.status = :status', { status: DocumentStatus.EXPIRED })
|
||||
.getRawOne();
|
||||
|
||||
// Get count of vehicles with expiring soon documents
|
||||
const vehiclesWithExpiringSoon = await this.documentRepository
|
||||
.createQueryBuilder('doc')
|
||||
.select('COUNT(DISTINCT doc.vehicle_id)', 'count')
|
||||
.where('doc.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('doc.is_deleted = false')
|
||||
.andWhere('doc.status = :status', { status: DocumentStatus.EXPIRING_SOON })
|
||||
.getRawOne();
|
||||
|
||||
// Process status counts
|
||||
const statusMap: Record<DocumentStatus, number> = {
|
||||
[DocumentStatus.VALID]: 0,
|
||||
[DocumentStatus.EXPIRED]: 0,
|
||||
[DocumentStatus.EXPIRING_SOON]: 0,
|
||||
[DocumentStatus.PENDING]: 0,
|
||||
};
|
||||
|
||||
for (const row of statusCounts) {
|
||||
statusMap[row.status as DocumentStatus] = parseInt(row.count, 10);
|
||||
}
|
||||
|
||||
// Process type counts
|
||||
const byType: FleetDocumentSummary['byType'] = {
|
||||
[DocumentType.REGISTRATION]: { total: 0, valid: 0, expired: 0, expiringSoon: 0 },
|
||||
[DocumentType.INSURANCE]: { total: 0, valid: 0, expired: 0, expiringSoon: 0 },
|
||||
[DocumentType.PERMIT]: { total: 0, valid: 0, expired: 0, expiringSoon: 0 },
|
||||
[DocumentType.VERIFICATION]: { total: 0, valid: 0, expired: 0, expiringSoon: 0 },
|
||||
[DocumentType.OTHER]: { total: 0, valid: 0, expired: 0, expiringSoon: 0 },
|
||||
};
|
||||
|
||||
for (const row of typeCounts) {
|
||||
const docType = row.documentType as DocumentType;
|
||||
const status = row.status as DocumentStatus;
|
||||
const count = parseInt(row.count, 10);
|
||||
|
||||
if (byType[docType]) {
|
||||
byType[docType].total += count;
|
||||
|
||||
switch (status) {
|
||||
case DocumentStatus.VALID:
|
||||
byType[docType].valid += count;
|
||||
break;
|
||||
case DocumentStatus.EXPIRED:
|
||||
byType[docType].expired += count;
|
||||
break;
|
||||
case DocumentStatus.EXPIRING_SOON:
|
||||
byType[docType].expiringSoon += count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalDocuments =
|
||||
statusMap[DocumentStatus.VALID] +
|
||||
statusMap[DocumentStatus.EXPIRED] +
|
||||
statusMap[DocumentStatus.EXPIRING_SOON] +
|
||||
statusMap[DocumentStatus.PENDING];
|
||||
|
||||
return {
|
||||
totalDocuments,
|
||||
validDocuments: statusMap[DocumentStatus.VALID],
|
||||
expiredDocuments: statusMap[DocumentStatus.EXPIRED],
|
||||
expiringSoonDocuments: statusMap[DocumentStatus.EXPIRING_SOON],
|
||||
pendingDocuments: statusMap[DocumentStatus.PENDING],
|
||||
byType,
|
||||
vehiclesWithExpiredDocs: parseInt(vehiclesWithExpired?.count || '0', 10),
|
||||
vehiclesWithExpiringSoonDocs: parseInt(vehiclesWithExpiringSoon?.count || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update document statuses based on expiration dates
|
||||
* This method should be called by a cron job daily
|
||||
*/
|
||||
async updateStatuses(): Promise<{ updated: number; expired: number; expiringSoon: number }> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const expiringSoonDate = new Date(today);
|
||||
expiringSoonDate.setDate(expiringSoonDate.getDate() + this.EXPIRING_SOON_DAYS);
|
||||
|
||||
// Update expired documents
|
||||
const expiredResult = await this.documentRepository
|
||||
.createQueryBuilder()
|
||||
.update(VehicleDocument)
|
||||
.set({ status: DocumentStatus.EXPIRED })
|
||||
.where('expiration_date < :today', { today })
|
||||
.andWhere('status != :expiredStatus', { expiredStatus: DocumentStatus.EXPIRED })
|
||||
.andWhere('is_deleted = false')
|
||||
.execute();
|
||||
|
||||
// Update expiring soon documents
|
||||
const expiringSoonResult = await this.documentRepository
|
||||
.createQueryBuilder()
|
||||
.update(VehicleDocument)
|
||||
.set({ status: DocumentStatus.EXPIRING_SOON })
|
||||
.where('expiration_date >= :today', { today })
|
||||
.andWhere('expiration_date <= :expiringSoonDate', { expiringSoonDate })
|
||||
.andWhere('status = :validStatus', { validStatus: DocumentStatus.VALID })
|
||||
.andWhere('is_deleted = false')
|
||||
.execute();
|
||||
|
||||
return {
|
||||
updated: (expiredResult.affected || 0) + (expiringSoonResult.affected || 0),
|
||||
expired: expiredResult.affected || 0,
|
||||
expiringSoon: expiringSoonResult.affected || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate document status based on expiration date
|
||||
*/
|
||||
private calculateStatus(expirationDate: Date): DocumentStatus {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const expDate = new Date(expirationDate);
|
||||
expDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (expDate < today) {
|
||||
return DocumentStatus.EXPIRED;
|
||||
}
|
||||
|
||||
const daysUntilExpiration = Math.ceil(
|
||||
(expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysUntilExpiration <= this.EXPIRING_SOON_DAYS) {
|
||||
return DocumentStatus.EXPIRING_SOON;
|
||||
}
|
||||
|
||||
return DocumentStatus.VALID;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user