import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; 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; bookingId?: string; vehicleId?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: string; }; const SORTABLE_FIELDS: (keyof LastMile)[] = [ 'status', 'advancedPayment', 'remainingPayment', 'createdAt', ]; @Injectable() export class LastMileService { private readonly logger = new Logger(LastMileService.name); constructor( private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly dataSource: DataSource, private readonly history: FleetHistoryService, ) {} /** Resolve a vehicle's driver + human labels, for stamping mile events onto * the driver's timeline and naming the vehicle. Best-effort — never throws. */ private async vehicleInfo( vehicleId?: string | null, ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { if (!vehicleId) return { driverId: null, plate: null, driverName: null }; try { const v = await this.vehiclesService.findById(vehicleId); return { driverId: v.assignedDriverId ?? null, plate: v.plateNumber ?? v.code ?? null, driverName: v.assignedDriverName ?? null, }; } catch { return { driverId: null, plate: null, driverName: null }; } } /** A leg counts as having a vehicle if it has a direct assignment or at least * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ private async hasAssignedVehicle( recordId: string, directVehicleId?: string | null, ): Promise { if (directVehicleId) return true; const count = await this.dataSource.manager.count(LastMileContainerAllocation, { where: { lastMileId: recordId, vehicleId: Not(IsNull()) }, }); return count > 0; } /** Human booking reference for a last-mile record, for the history timeline. * Uses the already-loaded relation when present, else looks it up. */ private async resolveBookingRef( record: LastMile, ): Promise { const loaded = (record as LastMile & { booking?: { reference?: string } }) .booking?.reference; if (loaded) return loaded; if (!record.bookingId) return null; try { const booking = await this.bookingsRepository.findById(record.bookingId); return (booking as { reference?: string } | null)?.reference ?? null; } catch { return null; } } async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); if (!booking) { return null; } if (booking.paymentStatus !== 'PAID') { return null; } return this.create({ bookingId: booking.id, advancedPayment: 0, }); } async acceptBookingByReference(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); if (!booking) { return null; } if (booking.paymentStatus !== 'PAID') { return null; } return this.create({ bookingId: booking.id, advancedPayment: 0, }); } async findAll(filter: LastMileListFilter = {}): Promise<{ data: LastMile[]; meta: { total: number; page: number; pageSize: number; totalPages: number }; }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 50; const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof LastMile) ? (filter.sortBy as keyof LastMile) : 'createdAt'; const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; const where: FindOptionsWhere = {}; if (filter.status) where.status = filter.status; if (filter.bookingId) where.bookingId = filter.bookingId; if (filter.vehicleId) where.vehicleId = filter.vehicleId; const [data, total] = await this.lastMileRepository.findAndCount({ where, relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true }, vehicle: true, vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, }); return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)), }, }; } async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true }, vehicle: true, vehicleAssignments: { vehicle: true }, }, }); if (!record) { throw new NotFoundException(`Last-mile record ${id} not found`); } return record; } async create(dto: CreateLastMileDto): Promise { const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, lastMileId: record.id, driverId: info.driverId, label: record.status, metadata: { mile: 'LAST', bookingRef: await this.resolveBookingRef(record), vehiclePlate: info.plate, driverName: info.driverName, }, }); } return record; } @OnEvent("lastmile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { await this.lastMileRepository.update(payload.sourceId, { paid: true } as any); this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); } catch (err) { this.logger.error( `Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`, ); } } async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle // assigned in this same request). if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { const vehicleId = dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; if (!(await this.hasAssignedVehicle(id, vehicleId))) { throw new BadRequestException( 'Assign a vehicle before marking this last-mile leg in transit', ); } } const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), ...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}), ...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}), ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), } as any); if (!updated) { throw new NotFoundException(`Last-mile record ${id} not found`); } // Notify assigned driver on every explicit vehicle assignment or reassignment if (dto.vehicleId) { void this.notifyDriverAssignment(dto.vehicleId, existing); } // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { // Keep vehicle availability in sync: new vehicle goes BUSY, replaced one // is freed if no other active trip still holds it. if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); } if (existing.vehicleId) { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } if (existing.vehicleId) { const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId: existing.vehicleId, lastMileId: id, driverId: info.driverId, metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName, }, }); } if (dto.vehicleId) { const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, lastMileId: id, driverId: info.driverId, label: updated.status, metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName, }, }); } } if (dto.status !== undefined && dto.status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, lastMileId: id, vehicleId, driverId: info.driverId, fromValue: existing.status, toValue: dto.status, metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName, }, }); } // Delivery finished — free the vehicles this trip was holding. if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') { await this.releaseVehicles(updated); } return updated; } /** * Free every vehicle held by this record (direct assignment + container * allocations), unless still in use by another active trip. */ private async releaseVehicles(record: LastMile): Promise { const recordAllocations = await this.dataSource.manager.find( LastMileContainerAllocation, { where: { lastMileId: record.id } }, ); const vehicleIds = recordAllocations .map((a) => a.vehicleId) .filter((id): id is string => Boolean(id)); if (record.vehicleId) { vehicleIds.push(record.vehicleId); } await this.vehiclesService.releaseIfUnused(vehicleIds); } /** * Replace the full set of vehicles serving a last-mile delivery (multi-truck). * Diffs against the current junction rows, syncing availability + audit history * for each added/removed vehicle. The first vehicle is mirrored onto the legacy * `vehicleId` column for back-compat with single-vehicle readers. */ async setVehicles(id: string, vehicleIds: string[]): Promise { const existing = await this.findById(id); const desired = [...new Set(vehicleIds.filter(Boolean))]; const manager = this.dataSource.manager; const current = await manager.find(LastMileVehicleAssignment, { where: { lastMileId: id }, }); const currentIds = current.map((a) => a.vehicleId); const currentSet = new Set(currentIds); const desiredSet = new Set(desired); const added = desired.filter((v) => !currentSet.has(v)); const removed = currentIds.filter((v) => !desiredSet.has(v)); await this.dataSource.transaction(async (tx) => { if (removed.length) { await tx.delete(LastMileVehicleAssignment, { lastMileId: id, vehicleId: In(removed), }); } for (const vehicleId of added) { await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId }); } }); // Legacy primary vehicle = first of the set (null when cleared). await this.lastMileRepository.update(id, { vehicleId: desired[0] ?? null } as any); const bookingRef = await this.resolveBookingRef(existing); for (const vehicleId of added) { await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY); void this.notifyDriverAssignment(vehicleId, existing); const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId, lastMileId: id, driverId: info.driverId, label: existing.status, metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } for (const vehicleId of removed) { await this.vehiclesService.releaseIfUnused([vehicleId]); const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId, lastMileId: id, driverId: info.driverId, metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } return this.findById(id); } private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); if (!vehicle.assignedDriverId) { this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`); return; } const driver = await this.driversService.findById(vehicle.assignedDriverId); if (!driver.phoneNumber) { this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); return; } type BookingWithYards = { reference?: string; lastMileDeliveryAddress?: string | null; destinationYard?: { label?: string } | null; }; const booking = (record as LastMile & { booking?: BookingWithYards }).booking; const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); const message = `Dear ${driverName}, you have been assigned to a last-mile delivery. ` + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + (booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') + (booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : ''); void this.smsClient.sendSms({ to: driver.phoneNumber, message, }); this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } } async remove(id: string): Promise { await this.findById(id); await this.lastMileRepository.softDelete(id); } async allocateContainers( lastMileId: string, allocations: Array<{ containerId: string; vehicleId: string }>, ) { const lastMile = await this.findById(lastMileId); if (!lastMile) { throw new NotFoundException(`Last-mile record ${lastMileId} not found`); } // Capture the vehicles currently on these containers so a reallocation can // be diffed into assigned/released history events below. const previousAllocations = await this.dataSource.manager.find( LastMileContainerAllocation, { where: { lastMileId, containerId: In(allocations.map((a) => a.containerId)), }, }, ); const previousVehicleIds = previousAllocations .map((a) => a.vehicleId) .filter((id): id is string => Boolean(id)); await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(LastMileContainerAllocation, { lastMileId, containerId: allocation.containerId, }); await manager.insert(LastMileContainerAllocation, { lastMileId, containerId: allocation.containerId, vehicleId: allocation.vehicleId, containerType: 'CONTAINER', quantity: 1, }); } }); // Keep vehicle availability in sync: newly-allocated cars go BUSY, cars no // longer on any of these containers are freed if unused elsewhere. const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); await Promise.all( [...vehicleIds].map((id) => this.vehiclesService.setAvailability(id, VehicleAvailability.BUSY), ), ); await this.vehiclesService.releaseIfUnused( previousVehicleIds.filter((id) => !vehicleIds.has(id)), ); // History: one event per vehicle actually added or removed by this // multi-car (re)allocation, so reassignments show on every timeline. const prevSet = new Set(previousVehicleIds); const bookingRef = await this.resolveBookingRef(lastMile); for (const vehicleId of vehicleIds) { if (prevSet.has(vehicleId)) continue; const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId, lastMileId, driverId: info.driverId, label: lastMile.status, metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } for (const vehicleId of previousVehicleIds) { if (vehicleIds.has(vehicleId)) continue; const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId, lastMileId, driverId: info.driverId, metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } return { success: true, allocated: allocations.length, }; } }