Files
edr-platform/apps/edr-freight-api/src/modules/compliance/compliance.service.ts
natib21 998a6801ab fleet
2026-07-06 12:05:52 +00:00

185 lines
6.4 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { ComplianceRepository } from './compliance.repository';
import {
ComplianceRecord,
ComplianceStatus,
ComplianceType,
} from './entities/compliance-record.entity';
import {
CreateComplianceRecordDto,
UpdateComplianceRecordDto,
} from './dto/create-compliance-record.dto';
import { Vehicle } from '../vehicles/entities/vehicle.entity';
import { Driver } from '../drivers/entities/driver.entity';
const DUE_SOON_DAYS = 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export type AlertSeverity = 'OVERDUE' | 'DUE_SOON';
export interface ComplianceAlert {
vehicleId: string;
vehiclePlate?: string;
kind: string;
label: string;
expiryDate: string;
daysUntil: number;
severity: AlertSeverity;
}
@Injectable()
export class ComplianceService {
constructor(
private readonly complianceRepository: ComplianceRepository,
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
) {}
async create(dto: CreateComplianceRecordDto): Promise<ComplianceRecord> {
return this.complianceRepository.create({
...dto,
status: dto.status ?? this.deriveStatus(dto.expiryDate),
});
}
async findAll(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
return this.complianceRepository.findWithFilters(filter);
}
async findById(id: string): Promise<ComplianceRecord> {
const record = await this.complianceRepository.findById(id);
if (!record) {
throw new NotFoundException(`Compliance record ${id} not found`);
}
return record;
}
async update(id: string, dto: UpdateComplianceRecordDto): Promise<ComplianceRecord> {
await this.findById(id);
const nextExpiry = dto.expiryDate;
const updated = await this.complianceRepository.update(id, {
...dto,
// Re-derive status when expiry changes and the caller didn't set it explicitly.
status: dto.status ?? (nextExpiry ? this.deriveStatus(nextExpiry) : undefined),
});
return updated!;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.complianceRepository.softDelete(id);
}
/**
* Flat list of compliance items that are overdue or due within 30 days.
* Combines the compliance_records table with the vehicle expiry columns
* (insurance / registration / next inspection) and assigned-driver license
* expiry. `new Date()` is fine here — this is the NestJS API runtime.
*/
async getAlerts(): Promise<ComplianceAlert[]> {
const now = new Date();
const alerts: ComplianceAlert[] = [];
const vehicles = await this.vehicleRepo.find({ where: { deletedAt: IsNull() } });
const vehicleById = new Map(vehicles.map((v) => [v.id, v]));
const plateOf = (v?: Vehicle) => v?.plateNumber ?? v?.code ?? undefined;
// 1. Compliance records
const records = await this.complianceRepository.findWithFilters();
for (const record of records) {
const computed = this.computeSeverity(record.expiryDate, now);
if (!computed) continue;
const vehicle = vehicleById.get(record.vehicleId);
alerts.push({
vehicleId: record.vehicleId,
vehiclePlate: plateOf(vehicle),
kind: record.type,
label: record.documentNumber
? `${record.type} · ${record.documentNumber}`
: record.type,
expiryDate: record.expiryDate,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
// 2. Vehicle-level expiry columns
const vehicleFields: { field: keyof Vehicle; kind: string; label: string }[] = [
{ field: 'insuranceExpiry', kind: 'INSURANCE', label: 'Insurance' },
{ field: 'registrationExpiry', kind: 'REGISTRATION', label: 'Registration' },
{ field: 'nextInspectionDate', kind: 'INSPECTION', label: 'Inspection' },
];
for (const vehicle of vehicles) {
for (const { field, kind, label } of vehicleFields) {
const value = vehicle[field] as string | undefined;
if (!value) continue;
const computed = this.computeSeverity(value, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind,
label,
expiryDate: value,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
// 3. Assigned-driver license expiry
const driverIds = [
...new Set(vehicles.map((v) => v.assignedDriverId).filter((id): id is string => !!id)),
];
if (driverIds.length > 0) {
const drivers = await this.driverRepo.find({ where: { id: In(driverIds) } });
const driverById = new Map(drivers.map((d) => [d.id, d]));
for (const vehicle of vehicles) {
if (!vehicle.assignedDriverId) continue;
const driver = driverById.get(vehicle.assignedDriverId);
if (!driver?.licenseExpiryDate) continue;
const expiry =
driver.licenseExpiryDate instanceof Date
? driver.licenseExpiryDate.toISOString().slice(0, 10)
: String(driver.licenseExpiryDate);
const computed = this.computeSeverity(expiry, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind: 'DRIVER_LICENSE',
label: `Driver License · ${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
expiryDate: expiry,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
return alerts.sort((a, b) => a.daysUntil - b.daysUntil);
}
private computeSeverity(
expiryDate: string,
now: Date,
): { daysUntil: number; severity: AlertSeverity } | null {
const daysUntil = Math.ceil((new Date(expiryDate).getTime() - now.getTime()) / MS_PER_DAY);
if (daysUntil < 0) return { daysUntil, severity: 'OVERDUE' };
if (daysUntil <= DUE_SOON_DAYS) return { daysUntil, severity: 'DUE_SOON' };
return null;
}
private deriveStatus(expiryDate: string): ComplianceStatus {
const daysUntil = Math.ceil(
(new Date(expiryDate).getTime() - Date.now()) / MS_PER_DAY,
);
if (daysUntil < 0) return ComplianceStatus.EXPIRED;
if (daysUntil <= DUE_SOON_DAYS) return ComplianceStatus.EXPIRING;
return ComplianceStatus.VALID;
}
}