Files
edr-platform/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
natib21 c643d30624 fix
2026-07-03 15:47:12 +00:00

239 lines
8.5 KiB
TypeScript

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<Vehicle>,
private readonly history: FleetHistoryService,
) {}
async create(dto: CreateVehicleDto): Promise<Vehicle> {
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<Vehicle[]> {
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<Vehicle> {
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<Vehicle> {
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<void> {
// 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<void> {
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<FirstMileStatus>('RECEIVED_TO_PORT') },
}),
manager.count(LastMile, {
where: { vehicleId, status: Not<LastMileStatus>('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<void> {
await this.findById(id);
await this.vehicleRepo.softDelete(id);
}
}