import { BadRequestException, ConflictException, Injectable, NotFoundException, } 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 { TruckType } from '../truck-types/entities/truck-type.entity'; import { TruckTypesService } from '../truck-types/truck-types.service'; 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, private readonly truckTypes: TruckTypesService, ) {} /** * A trailer plate only exists on a configuration that pulls a trailer — a * rigid truck (Casoni) has none. Checked against the RESULTING record, not * just the patch, so switching an articulated truck to a rigid type cannot * leave its old trailer plate stranded on the row. */ private assertTrailerPlateAllowed( truckType: TruckType, trailerPlateNo?: string | null, ): void { if (!truckType.hasTrailer && trailerPlateNo) { throw new BadRequestException( `${truckType.name} has no trailer — remove the trailer plate number`, ); } } /** * A driver holds one truck at a time — reassignment requires detaching them * from their current truck first. * ponytail: app-level guard only (race window); add a partial unique index on * assigned_driver_id if concurrent fleet edits ever become real. */ private async assertDriverUnassigned(driverId: string, exceptVehicleId?: string): Promise { const holder = await this.vehicleRepo.findOne({ where: exceptVehicleId ? { assignedDriverId: driverId, id: Not(exceptVehicleId) } : { assignedDriverId: driverId }, }); if (holder) { throw new ConflictException( `This driver is already assigned to truck ${holder.plateNumber ?? holder.code ?? holder.id} — detach the driver from that truck first`, ); } } 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`, ); } if (dto.assignedDriverId) { await this.assertDriverUnassigned(dto.assignedDriverId); } const truckType = await this.truckTypes.findById(dto.truckTypeId); this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo); const registrationNumber = `REG-${truckType.code}-${Date.now()}`; const vehicle = this.vehicleRepo.create({ ...dto, registrationNumber, // Denormalised for truck-detention billing, which groups on this column. vehicleType: truckType.code, // Capacity belongs to the type; an explicit value still wins for one-offs. capacity: dto.capacity ?? truckType.capacityTons ?? undefined, }); 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`, ); } } if (dto.assignedDriverId && dto.assignedDriverId !== vehicle.assignedDriverId) { await this.assertDriverUnassigned(dto.assignedDriverId, id); } // Re-resolve the truck type whenever the type OR the trailer plate moves — // either edit can produce a rigid truck holding a trailer plate. const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId; let nextTruckType: TruckType | null = null; if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) { nextTruckType = await this.truckTypes.findById(nextTruckTypeId); const nextTrailerPlate = dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo; this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate); } const prev = { assignedDriverId: vehicle.assignedDriverId, assignedDriverName: vehicle.assignedDriverName, status: vehicle.status, availability: vehicle.availability, }; Object.assign(vehicle, dto); // After the patch is applied, so the denormalised billing code always // reflects the type the vehicle actually ends up on. if (nextTruckType) { vehicle.vehicleType = nextTruckType.code; } 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); } }