mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
Join truck_types via vehicles.truck_type_id (normalized legacy vehicle_type only as fallback) so type renames can't unmatch detention rules and FK-less vehicles keep billing.
314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
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<Vehicle>,
|
|
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<void> {
|
|
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<Vehicle> {
|
|
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<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`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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<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);
|
|
}
|
|
}
|