mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
239 lines
8.5 KiB
TypeScript
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);
|
|
}
|
|
}
|