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 { BillingService, 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, private readonly billing: BillingService, ) {} /** Attach real invoice info (number/status) to records so the UI can show an * invoice link only when one actually exists — NOT merely because distance * was entered. Batched to avoid N+1. */ private async attachInvoices(records: LastMile[]): Promise { const invoices = await this.billing.findBySourceIds( 'last_mile', records.map((r) => r.id), ); const byId = new Map(); for (const inv of invoices) { if (!byId.has(inv.sourceId)) { byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); } } for (const r of records) { (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } } /** 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: { containerType: true, units: true } }, vehicle: true, vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, }); await this.attachInvoices(data); 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: { containerType: true, units: true } }, vehicle: true, vehicleAssignments: { vehicle: true }, }, }); if (!record) { throw new NotFoundException(`Last-mile record ${id} not found`); } await this.attachInvoices([record]); 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("last_mile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { // Invoice paid → the delivery is complete. Route through update() so it // also frees the trucks + records history (same as "Mark Delivered"). await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto); this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`); } catch (err) { this.logger.error( `Failed to deliver last-mile ${payload.sourceId} on payment: ${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 — junction assignments, the legacy * direct vehicle, and container allocations — unless still used by another * active trip. */ private async releaseVehicles(record: LastMile): Promise { const [assignments, recordAllocations] = await Promise.all([ this.dataSource.manager.find(LastMileVehicleAssignment, { where: { lastMileId: record.id }, }), this.dataSource.manager.find(LastMileContainerAllocation, { where: { lastMileId: record.id }, }), ]); const vehicleIds = [ ...new Set( [ ...assignments.map((a) => a.vehicleId), ...recordAllocations.map((a) => a.vehicleId), record.vehicleId ?? null, ].filter((id): id is string => Boolean(id)), ), ]; 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, inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, ): Promise { const existing = await this.findById(id); // Dedupe by vehicleId, keeping the container number; preserve order. const desiredMap = new Map(); for (const inp of inputs) { if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); } const desired = [...desiredMap.keys()]; const desiredSet = new Set(desired); const manager = this.dataSource.manager; const current = await manager.find(LastMileVehicleAssignment, { where: { lastMileId: id }, }); const junctionSet = new Set(current.map((a) => a.vehicleId)); // Fold the legacy vehicleId into the release set — a vehicle assigned via the // old single-vehicle path has no junction row but must still be freed. const releaseIds = [...new Set( current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []), )]; const added = desired.filter((v) => !junctionSet.has(v)); const removed = releaseIds.filter((v) => !desiredSet.has(v)); // Vehicles that stay but whose container number changed. const changed = current.filter( (a) => desiredMap.has(a.vehicleId) && (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), ); 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, containerNumber: desiredMap.get(vehicleId) ?? null, }); } for (const row of changed) { await tx.update( LastMileVehicleAssignment, { lastMileId: id, vehicleId: row.vehicleId }, { containerNumber: desiredMap.get(row.vehicleId) ?? null }, ); } }); // 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); } /** * Record each truck's actual distance. The delivery total (exact_km) is their * sum and drives billing; `remainingPayment` (total km × rate) is recomputed * client-side. Does NOT generate an invoice — that's a separate explicit step. */ async setDistances( id: string, distances: Array<{ vehicleId: string; distanceKm: number }>, remainingPayment?: number, ): Promise { await this.findById(id); // Distances are locked once the invoice exists. const invoices = await this.billing.findBySourceIds('last_mile', [id]); if (invoices.length) { throw new BadRequestException( 'Distances cannot be changed after the invoice is generated', ); } for (const d of distances) { await this.dataSource.manager.update( LastMileVehicleAssignment, { lastMileId: id, vehicleId: d.vehicleId }, { distanceKm: d.distanceKm }, ); } const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); await this.lastMileRepository.update(id, { exactKm: total, ...(remainingPayment != null ? { remainingPayment } : {}), } as any); 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 { const existing = await this.findById(id); // Can't delete once billed. const invoices = await this.billing.findBySourceIds('last_mile', [id]); if (invoices.length) { throw new BadRequestException( 'Cannot delete a last-mile delivery after its invoice is generated', ); } // Every vehicle this delivery holds — junction + legacy + container rows. const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { where: { lastMileId: id }, }); const allocations = await this.dataSource.manager.find(LastMileContainerAllocation, { where: { lastMileId: id }, }); const vehicleIds = [ ...new Set( [ ...assignments.map((a) => a.vehicleId), ...allocations.map((a) => a.vehicleId), existing.vehicleId ?? null, ].filter((v): v is string => Boolean(v)), ), ]; await this.lastMileRepository.softDelete(id); if (assignments.length) { await this.dataSource.manager.softDelete(LastMileVehicleAssignment, { lastMileId: id }); } // Free every vehicle no longer held by another active trip (releaseIfUnused // ignores this now soft-deleted record) and audit the release. if (vehicleIds.length) { await this.vehiclesService.releaseIfUnused(vehicleIds); const bookingRef = await this.resolveBookingRef(existing); for (const vehicleId of vehicleIds) { 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 }, }); } } } }