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