[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:
Adrian Flores Cortes 2026-02-03 01:35:51 -06:00
parent 74027be804
commit 71efafd139
8 changed files with 1746 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View 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';

View File

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

View File

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

View File

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