mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
fleet
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ComplianceService } from './compliance.service';
|
||||
import {
|
||||
CreateComplianceRecordDto,
|
||||
UpdateComplianceRecordDto,
|
||||
} from './dto/create-compliance-record.dto';
|
||||
import { ComplianceType } from './entities/compliance-record.entity';
|
||||
|
||||
@ApiTags('Vehicle Compliance')
|
||||
@Controller('compliance')
|
||||
export class ComplianceController {
|
||||
constructor(private readonly complianceService: ComplianceService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a compliance record' })
|
||||
create(@Body() dto: CreateComplianceRecordDto) {
|
||||
return this.complianceService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List compliance records' })
|
||||
findAll(
|
||||
@Query('vehicleId') vehicleId?: string,
|
||||
@Query('type') type?: ComplianceType,
|
||||
) {
|
||||
return this.complianceService.findAll({ vehicleId, type });
|
||||
}
|
||||
|
||||
@Get('alerts')
|
||||
@ApiOperation({ summary: 'List overdue / due-soon compliance & expiry alerts' })
|
||||
getAlerts() {
|
||||
return this.complianceService.getAlerts();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a compliance record by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.complianceService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a compliance record' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) {
|
||||
return this.complianceService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Soft-delete a compliance record' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.complianceService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ComplianceRecord } from './entities/compliance-record.entity';
|
||||
import { Vehicle } from '../vehicles/entities/vehicle.entity';
|
||||
import { Driver } from '../drivers/entities/driver.entity';
|
||||
import { ComplianceService } from './compliance.service';
|
||||
import { ComplianceRepository } from './compliance.repository';
|
||||
import { ComplianceController } from './compliance.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ComplianceRecord, Vehicle, Driver])],
|
||||
providers: [ComplianceService, ComplianceRepository],
|
||||
controllers: [ComplianceController],
|
||||
exports: [ComplianceService],
|
||||
})
|
||||
export class ComplianceModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { ComplianceRecord, ComplianceType } from './entities/compliance-record.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ComplianceRepository extends BaseRepository<ComplianceRecord> {
|
||||
constructor(
|
||||
@InjectRepository(ComplianceRecord)
|
||||
private readonly complianceRepository: Repository<ComplianceRecord>,
|
||||
) {
|
||||
super(complianceRepository);
|
||||
}
|
||||
|
||||
async findWithFilters(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
|
||||
const where: FindOptionsWhere<ComplianceRecord> = {};
|
||||
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
|
||||
if (filter.type) where.type = filter.type;
|
||||
|
||||
return this.complianceRepository.find({
|
||||
where,
|
||||
order: { expiryDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IsUUID, IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ComplianceType, ComplianceStatus } from '../entities/compliance-record.entity';
|
||||
|
||||
export class CreateComplianceRecordDto {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
@IsEnum(ComplianceType)
|
||||
type!: ComplianceType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
documentNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
issuedDate?: string;
|
||||
|
||||
@IsDateString()
|
||||
expiryDate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ComplianceStatus)
|
||||
status?: ComplianceStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateComplianceRecordDto {
|
||||
@IsOptional()
|
||||
@IsEnum(ComplianceType)
|
||||
type?: ComplianceType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
documentNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
issuedDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiryDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ComplianceStatus)
|
||||
status?: ComplianceStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
|
||||
export enum ComplianceType {
|
||||
INSPECTION = 'INSPECTION',
|
||||
INSURANCE = 'INSURANCE',
|
||||
ROADWORTHINESS = 'ROADWORTHINESS',
|
||||
PERMIT = 'PERMIT',
|
||||
TAX = 'TAX',
|
||||
}
|
||||
|
||||
export enum ComplianceStatus {
|
||||
VALID = 'VALID',
|
||||
EXPIRING = 'EXPIRING',
|
||||
EXPIRED = 'EXPIRED',
|
||||
}
|
||||
|
||||
@Entity({ name: 'compliance_records', schema: 'freight' })
|
||||
@Index(['vehicleId', 'expiryDate'])
|
||||
export class ComplianceRecord extends BaseEntity {
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId!: string;
|
||||
|
||||
@ManyToOne(() => Vehicle, { eager: false, nullable: false })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle!: Vehicle;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar' })
|
||||
type!: ComplianceType;
|
||||
|
||||
@Column({ name: 'document_number', type: 'varchar', nullable: true })
|
||||
documentNumber?: string;
|
||||
|
||||
@Column({ name: 'issued_date', type: 'date', nullable: true })
|
||||
issuedDate?: string;
|
||||
|
||||
@Column({ name: 'expiry_date', type: 'date' })
|
||||
expiryDate!: string;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', default: ComplianceStatus.VALID })
|
||||
status!: ComplianceStatus;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user