diff --git a/src/modules/vehicle-management/entities/index.ts b/src/modules/vehicle-management/entities/index.ts index 4f36f7d..73aca38 100644 --- a/src/modules/vehicle-management/entities/index.ts +++ b/src/modules/vehicle-management/entities/index.ts @@ -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'; diff --git a/src/modules/vehicle-management/entities/vehicle-document.entity.ts b/src/modules/vehicle-management/entities/vehicle-document.entity.ts new file mode 100644 index 0000000..3c72e55 --- /dev/null +++ b/src/modules/vehicle-management/entities/vehicle-document.entity.ts @@ -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; +} diff --git a/src/modules/vehicle-management/entities/vehicle.entity.ts b/src/modules/vehicle-management/entities/vehicle.entity.ts index b4d8796..335ab97 100644 --- a/src/modules/vehicle-management/entities/vehicle.entity.ts +++ b/src/modules/vehicle-management/entities/vehicle.entity.ts @@ -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[]; } diff --git a/src/modules/vehicle-management/index.ts b/src/modules/vehicle-management/index.ts index 4fd0b25..8f75a49 100644 --- a/src/modules/vehicle-management/index.ts +++ b/src/modules/vehicle-management/index.ts @@ -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'; diff --git a/src/modules/vehicle-management/services/index.ts b/src/modules/vehicle-management/services/index.ts new file mode 100644 index 0000000..18ce569 --- /dev/null +++ b/src/modules/vehicle-management/services/index.ts @@ -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'; diff --git a/src/modules/vehicle-management/services/maintenance-reminder.service.ts b/src/modules/vehicle-management/services/maintenance-reminder.service.ts new file mode 100644 index 0000000..81b665d --- /dev/null +++ b/src/modules/vehicle-management/services/maintenance-reminder.service.ts @@ -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; + private vehicleRepository: Repository; + + 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 { + // 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 { + return this.reminderRepository.findOne({ + where: { id, tenantId }, + relations: ['vehicle'], + }); + } + + /** + * Find all reminders for a vehicle + */ + async findByVehicle( + tenantId: string, + vehicleId: string, + includeInactive = false + ): Promise { + const whereCondition: Record = { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + ): 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; + } +} diff --git a/src/modules/vehicle-management/services/maintenance-schedule.service.ts b/src/modules/vehicle-management/services/maintenance-schedule.service.ts new file mode 100644 index 0000000..efab223 --- /dev/null +++ b/src/modules/vehicle-management/services/maintenance-schedule.service.ts @@ -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 = { + '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; + private vehicleRepository: Repository; + private serviceOrderRepository: Repository; + + 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 { + 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 { + 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 { + 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(); + + 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(); + 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 { + 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 { + 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; + } +} diff --git a/src/modules/vehicle-management/services/vehicle-document.service.ts b/src/modules/vehicle-management/services/vehicle-document.service.ts new file mode 100644 index 0000000..42bbefd --- /dev/null +++ b/src/modules/vehicle-management/services/vehicle-document.service.ts @@ -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; + vehiclesWithExpiredDocs: number; + vehiclesWithExpiringSoonDocs: number; +} + +// ==================== +// Service +// ==================== + +export class VehicleDocumentService { + private documentRepository: Repository; + 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 { + // 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 { + return this.documentRepository.findOne({ + where: { + id, + tenantId, + isDeleted: false, + }, + relations: ['vehicle'], + }); + } + + /** + * Find all documents for a vehicle + */ + async findByVehicle( + tenantId: string, + vehicleId: string + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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.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; + } +}