This commit is contained in:
natib21
2026-07-03 15:21:23 +00:00
parent e881e8de84
commit 0e6ebda6f6
15 changed files with 661 additions and 7 deletions

View File

@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@FleetView()
export class DriversController {
constructor(private readonly driversService: DriversService) {}
constructor(
private readonly driversService: DriversService,
private readonly fleetHistory: FleetHistoryService,
) {}
@Post()
@FleetManage()
@@ -55,6 +59,12 @@ export class DriversController {
return this.driversService.findById(id);
}
@Get(':id/history')
@ApiOperation({ summary: 'Get driver assignment & activity history' })
history(@Param('id', ParseUUIDPipe) id: string) {
return this.fleetHistory.getDriverHistory(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a driver' })

View File

@@ -4,12 +4,15 @@ import { Repository } from 'typeorm';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
@Injectable()
export class DriversService {
constructor(
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
private readonly history: FleetHistoryService,
) {}
async create(dto: CreateDriverDto): Promise<Driver> {
@@ -51,7 +54,16 @@ export class DriversService {
}
const driver = this.driverRepo.create(dto);
return this.driverRepo.save(driver);
const saved = await this.driverRepo.save(driver);
await this.history.record({
eventType: FleetEventType.DRIVER_REGISTERED,
driverId: saved.id,
label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null,
toValue: saved.status ?? null,
});
return saved;
}
async findAll(query: {

View File

@@ -14,6 +14,8 @@ import { FirstMileContainerAllocation } from "./entities/first-mile-container-al
import { FirstMileRepository } from "./first-mile.repository";
import { OnEvent } from "@nestjs/event-emitter";
import { InvoiceEventPayload } from "../billing/billing.service";
import { FleetHistoryService } from "../fleet-history/fleet-history.service";
import { FleetEventType } from "../fleet-history/entities/fleet-event.entity";
type FirstMileListFilter = {
status?: FirstMileStatus;
@@ -43,8 +45,21 @@ export class FirstMileService {
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
private readonly history: FleetHistoryService,
) { }
/** Resolve the driver currently assigned to a vehicle, for stamping mile
* events onto that driver's timeline. Best-effort — never throws. */
private async resolveDriverId(vehicleId?: string | null): Promise<string | null> {
if (!vehicleId) return null;
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
return vehicle.assignedDriverId ?? null;
} catch {
return null;
}
}
/**
* Look up a booking by its human-readable reference and confirm it has been
* paid before any first-mile work proceeds. Throws if the reference is
@@ -210,6 +225,14 @@ export class FirstMileService {
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
firstMileId: record.id,
driverId: await this.resolveDriverId(dto.vehicleId),
label: record.status,
metadata: { mile: 'FIRST' },
});
}
return record;
@@ -278,6 +301,26 @@ export class FirstMileService {
if (existing.vehicleId) {
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
}
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
if (existing.vehicleId) {
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId: existing.vehicleId,
firstMileId: id,
driverId: await this.resolveDriverId(existing.vehicleId),
metadata: { mile: 'FIRST' },
});
}
if (dto.vehicleId) {
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
firstMileId: id,
driverId: await this.resolveDriverId(dto.vehicleId),
label: updated.status,
metadata: { mile: 'FIRST' },
});
}
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
@@ -285,6 +328,19 @@ export class FirstMileService {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
if (dto.status !== undefined && dto.status !== existing.status) {
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
await this.history.record({
eventType: FleetEventType.MILE_STATUS_CHANGED,
firstMileId: id,
vehicleId,
driverId: await this.resolveDriverId(vehicleId),
fromValue: existing.status,
toValue: dto.status,
metadata: { mile: 'FIRST' },
});
}
// Trip finished — release the vehicles it was holding
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
@@ -301,6 +357,19 @@ export class FirstMileService {
throw new NotFoundException(`First-mile record ${id} not found`);
}
if (status !== existing.status) {
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
await this.history.record({
eventType: FleetEventType.MILE_STATUS_CHANGED,
firstMileId: id,
vehicleId,
driverId: await this.resolveDriverId(vehicleId),
fromValue: existing.status,
toValue: status,
metadata: { mile: 'FIRST' },
});
}
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
}

View File

@@ -0,0 +1,55 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
/**
* Append-only audit log for fleet activity. One row per transition. Queried by
* `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may
* carry both so a driver↔vehicle assignment or a mile assignment shows on both.
* `createdAt` (from BaseEntity) is the event time.
*/
export enum FleetEventType {
DRIVER_REGISTERED = 'DRIVER_REGISTERED',
VEHICLE_REGISTERED = 'VEHICLE_REGISTERED',
DRIVER_ASSIGNED = 'DRIVER_ASSIGNED',
DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED',
VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED',
VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED',
MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED',
MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED',
MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED',
}
@Entity({ name: 'fleet_events', schema: 'freight' })
export class FleetEvent extends BaseEntity {
@Column({ name: 'event_type', type: 'varchar' })
eventType!: FleetEventType;
@Index()
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@Index()
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string | null;
@Column({ name: 'first_mile_id', type: 'uuid', nullable: true })
firstMileId?: string | null;
@Column({ name: 'last_mile_id', type: 'uuid', nullable: true })
lastMileId?: string | null;
/** Previous value for a transition (e.g. old status/availability). */
@Column({ name: 'from_value', type: 'varchar', nullable: true })
fromValue?: string | null;
/** New value for a transition (e.g. new status/availability). */
@Column({ name: 'to_value', type: 'varchar', nullable: true })
toValue?: string | null;
/** Human-readable summary token (driver name, plate, booking ref, mile). */
@Column({ name: 'label', type: 'varchar', nullable: true })
label?: string | null;
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: Record<string, unknown> | null;
}

View File

@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FleetEvent } from './entities/fleet-event.entity';
import { FleetHistoryService } from './fleet-history.service';
/**
* Global so any fleet-touching service (vehicles, drivers, first/last-mile) can
* inject FleetHistoryService to append audit events without each module having
* to import this one.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([FleetEvent])],
providers: [FleetHistoryService],
exports: [FleetHistoryService],
})
export class FleetHistoryModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FleetEvent, FleetEventType } from './entities/fleet-event.entity';
export interface FleetEventInput {
eventType: FleetEventType;
vehicleId?: string | null;
driverId?: string | null;
firstMileId?: string | null;
lastMileId?: string | null;
fromValue?: string | null;
toValue?: string | null;
label?: string | null;
metadata?: Record<string, unknown> | null;
}
@Injectable()
export class FleetHistoryService {
private readonly logger = new Logger(FleetHistoryService.name);
constructor(
@InjectRepository(FleetEvent)
private readonly eventRepo: Repository<FleetEvent>,
) {}
/**
* Append an audit event. Best-effort: recording history must never break the
* business operation that triggered it, so failures are logged and swallowed.
*/
async record(input: FleetEventInput): Promise<void> {
try {
await this.eventRepo.save(this.eventRepo.create(input));
} catch (err) {
this.logger.error(
`Failed to record fleet event ${input.eventType}: ${String(err)}`,
);
}
}
getVehicleHistory(vehicleId: string): Promise<FleetEvent[]> {
return this.eventRepo.find({
where: { vehicleId },
order: { createdAt: 'DESC' },
});
}
getDriverHistory(driverId: string): Promise<FleetEvent[]> {
return this.eventRepo.find({
where: { driverId },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -12,6 +12,8 @@ import { LastMileContainerAllocation } from './entities/last-mile-container-allo
import { LastMileRepository } from './last-mile.repository';
import { InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
type LastMileListFilter = {
status?: LastMileStatus;
@@ -41,8 +43,21 @@ export class LastMileService {
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
private readonly dataSource: DataSource,
private readonly history: FleetHistoryService,
) {}
/** Resolve the driver currently assigned to a vehicle, for stamping mile
* events onto that driver's timeline. Best-effort — never throws. */
private async resolveDriverId(vehicleId?: string | null): Promise<string | null> {
if (!vehicleId) return null;
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
return vehicle.assignedDriverId ?? null;
} catch {
return null;
}
}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
@@ -132,7 +147,7 @@ export class LastMileService {
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
return this.lastMileRepository.create({
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
advancedPayment: dto.advancedPayment ?? 0,
@@ -142,6 +157,19 @@ export class LastMileService {
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
});
if (dto.vehicleId) {
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
lastMileId: record.id,
driverId: await this.resolveDriverId(dto.vehicleId),
label: record.status,
metadata: { mile: 'LAST' },
});
}
return record;
}
@OnEvent("lastmile.invoice.paid")
@@ -180,6 +208,42 @@ export class LastMileService {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
if (existing.vehicleId) {
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId: existing.vehicleId,
lastMileId: id,
driverId: await this.resolveDriverId(existing.vehicleId),
metadata: { mile: 'LAST' },
});
}
if (dto.vehicleId) {
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
lastMileId: id,
driverId: await this.resolveDriverId(dto.vehicleId),
label: updated.status,
metadata: { mile: 'LAST' },
});
}
}
if (dto.status !== undefined && dto.status !== existing.status) {
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
await this.history.record({
eventType: FleetEventType.MILE_STATUS_CHANGED,
lastMileId: id,
vehicleId,
driverId: await this.resolveDriverId(vehicleId),
fromValue: existing.status,
toValue: dto.status,
metadata: { mile: 'LAST' },
});
}
return updated;
}

View File

@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
import { VehiclesService } from './vehicles.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@FleetView()
export class VehiclesController {
constructor(private readonly vehiclesService: VehiclesService) {}
constructor(
private readonly vehiclesService: VehiclesService,
private readonly fleetHistory: FleetHistoryService,
) {}
@Post()
@FleetManage()
@@ -57,6 +61,12 @@ export class VehiclesController {
return this.vehiclesService.findById(id);
}
@Get(':id/history')
@ApiOperation({ summary: 'Get vehicle assignment, status & mile history' })
history(@Param('id', ParseUUIDPipe) id: string) {
return this.fleetHistory.getVehicleHistory(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a vehicle' })

View File

@@ -9,12 +9,15 @@ import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-
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> {
@@ -34,7 +37,24 @@ export class VehiclesService {
registrationNumber,
});
return this.vehicleRepo.save(vehicle);
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,
});
}
return saved;
}
async findAll(query: {
@@ -97,12 +117,73 @@ export class VehiclesService {
}
}
const prev = {
assignedDriverId: vehicle.assignedDriverId,
assignedDriverName: vehicle.assignedDriverName,
status: vehicle.status,
availability: vehicle.availability,
};
Object.assign(vehicle, dto);
return this.vehicleRepo.save(vehicle);
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
) {
if (prev.assignedDriverId) {
await this.history.record({
eventType: FleetEventType.DRIVER_UNASSIGNED,
vehicleId: id,
driverId: prev.assignedDriverId,
label: prev.assignedDriverName ?? null,
});
}
if (saved.assignedDriverId) {
await this.history.record({
eventType: FleetEventType.DRIVER_ASSIGNED,
vehicleId: id,
driverId: saved.assignedDriverId,
label: 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,
});
}
}
/**