mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 02:00:56 +00:00
185 lines
6.4 KiB
TypeScript
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;
|
|
}
|
|
}
|