import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity'; import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity'; import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; import { FleetHistoryService } from '../fleet-history/fleet-history.service'; import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class VehiclesService { constructor( @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, private readonly history: FleetHistoryService, ) {} async create(dto: CreateVehicleDto): Promise { const existing = await this.vehicleRepo.findOne({ where: { plateNumber: dto.plateNumber }, }); if (existing) { throw new ConflictException( `Vehicle with plate number ${dto.plateNumber} already exists`, ); } const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; const vehicle = this.vehicleRepo.create({ ...dto, registrationNumber, }); const saved = await this.vehicleRepo.save(vehicle); await this.history.record({ eventType: FleetEventType.VEHICLE_REGISTERED, vehicleId: saved.id, label: saved.plateNumber ?? saved.code ?? null, toValue: saved.availability ?? null, }); if (saved.assignedDriverId) { await this.history.record({ eventType: FleetEventType.DRIVER_ASSIGNED, vehicleId: saved.id, driverId: saved.assignedDriverId, label: saved.assignedDriverName ?? null, metadata: { vehiclePlate: saved.plateNumber ?? saved.code ?? null, driverName: saved.assignedDriverName ?? null, }, }); } return saved; } async findAll(query: { search?: string; status?: VehicleStatus | string; availability?: VehicleAvailability | string; page?: number; limit?: number; sortBy?: string; sortOrder?: 'ASC' | 'DESC'; } = {}): Promise { let qb = this.vehicleRepo.createQueryBuilder('v'); if (query.search) { qb = qb.where( '(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search OR v.code ILIKE :search OR v.trailerPlateNo ILIKE :search)', { search: `%${query.search}%` }, ); } if (query.status) { qb = qb.andWhere('v.status = :status', { status: query.status }); } if (query.availability) { qb = qb.andWhere('v.availability = :availability', { availability: query.availability }); } const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( query.sortBy ?? '', ) ? query.sortBy : 'createdAt'; const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase(); return qb .orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC') .getMany(); } async findById(id: string): Promise { const vehicle = await this.vehicleRepo.findOne({ where: { id } }); if (!vehicle) { throw new NotFoundException(`Vehicle ${id} not found`); } return vehicle; } async update(id: string, dto: UpdateVehicleDto): Promise { const vehicle = await this.findById(id); if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) { const existing = await this.vehicleRepo.findOne({ where: { plateNumber: dto.plateNumber }, }); if (existing) { throw new ConflictException( `Vehicle with plate number ${dto.plateNumber} already exists`, ); } } const prev = { assignedDriverId: vehicle.assignedDriverId, assignedDriverName: vehicle.assignedDriverName, status: vehicle.status, availability: vehicle.availability, }; Object.assign(vehicle, dto); const saved = await this.vehicleRepo.save(vehicle); // Driver (re)assignment — emit an unassign for the old driver and/or an // assign for the new one so both drivers' timelines and the vehicle's line up. if ( dto.assignedDriverId !== undefined && dto.assignedDriverId !== prev.assignedDriverId ) { const vehiclePlate = saved.plateNumber ?? saved.code ?? null; if (prev.assignedDriverId) { await this.history.record({ eventType: FleetEventType.DRIVER_UNASSIGNED, vehicleId: id, driverId: prev.assignedDriverId, label: prev.assignedDriverName ?? null, metadata: { vehiclePlate, driverName: prev.assignedDriverName ?? null }, }); } if (saved.assignedDriverId) { await this.history.record({ eventType: FleetEventType.DRIVER_ASSIGNED, vehicleId: id, driverId: saved.assignedDriverId, label: saved.assignedDriverName ?? null, metadata: { vehiclePlate, driverName: saved.assignedDriverName ?? null }, }); } } if (dto.status !== undefined && dto.status !== prev.status) { await this.history.record({ eventType: FleetEventType.VEHICLE_STATUS_CHANGED, vehicleId: id, fromValue: prev.status ?? null, toValue: saved.status ?? null, }); } if (dto.availability !== undefined && dto.availability !== prev.availability) { await this.history.record({ eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, vehicleId: id, fromValue: prev.availability ?? null, toValue: saved.availability ?? null, }); } return saved; } async setAvailability(id: string, availability: VehicleAvailability): Promise { // Read the current value so the audit event records an accurate from→to and // we skip logging no-op writes (setAvailability is called in release loops). const vehicle = await this.vehicleRepo.findOne({ where: { id } }); const previous = vehicle?.availability; await this.vehicleRepo.update(id, { availability }); if (previous !== availability) { await this.history.record({ eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, vehicleId: id, fromValue: previous ?? null, toValue: availability, }); } } /** * Set vehicles back to FREE, but only when no active (non-completed) * first/last-mile record or container allocation still references them. * First-mile trips ending in RECEIVED_TO_PORT and last-mile trips ending * in DELIVERED no longer hold the vehicle. */ async releaseIfUnused(vehicleIds: string[]): Promise { const manager = this.vehicleRepo.manager; for (const vehicleId of [...new Set(vehicleIds)]) { const [fmRecords, lmRecords, fmAllocations, lmAllocations, bookingAllocations] = await Promise.all([ manager.count(FirstMile, { where: { vehicleId, status: Not('RECEIVED_TO_PORT') }, }), manager.count(LastMile, { where: { vehicleId, status: Not('DELIVERED') }, }), manager .createQueryBuilder(FirstMileContainerAllocation, 'alloc') .innerJoin(FirstMile, 'fm', 'fm.id = alloc.firstMileId') .where('alloc.vehicleId = :vehicleId', { vehicleId }) .andWhere('fm.status != :done', { done: 'RECEIVED_TO_PORT' }) .andWhere('fm.deletedAt IS NULL') .getCount(), manager .createQueryBuilder(LastMileContainerAllocation, 'alloc') .innerJoin(LastMile, 'lm', 'lm.id = alloc.lastMileId') .where('alloc.vehicleId = :vehicleId', { vehicleId }) .andWhere('lm.status != :done', { done: 'DELIVERED' }) .andWhere('lm.deletedAt IS NULL') .getCount(), manager.count(BookingContainerAllocation, { where: { vehicleId } }), ]); if (fmRecords + lmRecords + fmAllocations + lmAllocations + bookingAllocations === 0) { await this.setAvailability(vehicleId, VehicleAvailability.FREE); } } } async remove(id: string): Promise { await this.findById(id); await this.vehicleRepo.softDelete(id); } }