import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; 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 { CreateFirstMileDto } from "./dto/create-first-mile.dto"; import { UpdateFirstMileDto } from "./dto/update-first-mile.dto"; import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity"; import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; import { BillingService, 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; bookingId?: string; vehicleId?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: string; }; const SORTABLE_FIELDS: (keyof FirstMile)[] = [ "status", "advancedPayment", "remainingPayment", "createdAt", ]; @Injectable() export class FirstMileService { private readonly logger = new Logger(FirstMileService.name); constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly firstMileRepository: FirstMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly history: FleetHistoryService, private readonly billing: BillingService, ) { } /** Attach real invoice info so the UI shows an invoice link only when one * exists — not merely because distance was entered. Batched (no N+1). */ private async attachInvoices(records: FirstMile[]): Promise { const invoices = await this.billing.findBySourceIds( 'first_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 FirstMile & { 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 [junction, allocations] = await Promise.all([ this.dataSource.manager.count(FirstMileVehicleAssignment, { where: { firstMileId: recordId }, }), this.dataSource.manager.count(FirstMileContainerAllocation, { where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, }), ]); return junction > 0 || allocations > 0; } /** Human booking reference for a first-mile record, for the history timeline. */ private async resolveBookingRef(record: FirstMile): Promise { const loaded = (record as FirstMile & { booking?: { reference?: string } }) .booking?.reference; if (loaded) return loaded; if (!record.bookingId) return null; try { const b = await this.bookingsRepository.findById(record.bookingId); return (b as { reference?: string } | null)?.reference ?? 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 * unknown or the booking has not reached PAID status. */ async acceptBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId, { relations: { serviceType: true }, }); if (!booking) { return null; } return this.acceptEligibleBooking(booking); } async acceptBookingByReference( bookingReference: string, ): Promise { const [booking] = await this.bookingsRepository.findAll({ where: { reference: bookingReference }, relations: { serviceType: true }, take: 1, }); if (!booking) { throw new NotFoundException(`Booking ${bookingReference} not found`); } return this.acceptEligibleBooking(booking); } /** * Shared accept path: validates payment + first-mile eligibility, rejects an * already-assigned booking, then creates the first-mile record. Throws a * meaningful HTTP error instead of returning null so the client can surface * why an accept was refused. */ private async acceptEligibleBooking(booking: { id: string; reference?: string; paymentStatus?: string | null; tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): Promise { if (booking.paymentStatus !== "PAID") { return null; } if (!this.bookingRequestsFirstMile(booking)) { return null; } const existing = await this.findByBookingId(booking.id); if (existing) { return null; } return this.create({ bookingId: booking.id, advancedPayment: 0, }); } async findAll(filter: FirstMileListFilter = {}): Promise<{ data: FirstMile[]; 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 FirstMile) ? (filter.sortBy as keyof FirstMile) : "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.firstMileRepository.findAndCount({ where, relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: 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)), }, }; } @OnEvent("firstmile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { await this.firstMileRepository.update(payload.sourceId, { paid: true, } as any); this.logger.log( `Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`, ); } catch (err) { this.logger.error( `Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`, ); } } async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, }, vehicle: true, vehicleAssignments: { vehicle: true }, }, }); if (!record) { throw new NotFoundException(`First-mile record ${id} not found`); } await this.attachInvoices([record]); return record; } async create(dto: CreateFirstMileDto): Promise { const existing = await this.findByBookingId(dto.bookingId); if (existing) { return existing; } const record = await this.firstMileRepository.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, firstMileId: record.id, driverId: info.driverId, label: record.status, metadata: { mile: 'FIRST', bookingRef: await this.resolveBookingRef(record), vehiclePlate: info.plate, driverName: info.driverName, }, }); } return record; } private async findByBookingId(bookingId: string): Promise { const [records] = await this.firstMileRepository.findAndCount({ where: { bookingId }, relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, }, vehicle: true, }, take: 1, }); return records[0] ?? null; } private bookingRequestsFirstMile(booking: { tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { return Boolean( booking.tradeDirection === 'EXPORT' && (booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile), ); } async update(id: string, dto: UpdateFirstMileDto): 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 first-mile leg in transit', ); } } const dtoAny = dto as any; const updated = await this.firstMileRepository.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(`First-mile record ${id} not found`); } // Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); } if (existing.vehicleId) { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. const bookingRef = await this.resolveBookingRef(existing); if (existing.vehicleId) { const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId: existing.vehicleId, firstMileId: id, driverId: info.driverId, metadata: { mile: 'FIRST', 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, firstMileId: id, driverId: info.driverId, label: updated.status, metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName, }, }); } } // Notify assigned driver on every explicit vehicle assignment or reassignment if (dto.vehicleId) { void this.notifyDriverAssignment(dto.vehicleId, existing); } 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, firstMileId: id, vehicleId, driverId: info.driverId, fromValue: existing.status, toValue: dto.status, metadata: { mile: 'FIRST', bookingRef: await this.resolveBookingRef(existing), vehiclePlate: info.plate, driverName: info.driverName, }, }); } // Trip finished — release the vehicles it was holding if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); } return updated; } async updateStatus(id: string, status: FirstMileStatus): Promise { const existing = await this.findById(id); if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) { throw new BadRequestException( 'Assign a vehicle before marking this first-mile leg in transit', ); } } const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); } if (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, firstMileId: id, vehicleId, driverId: info.driverId, fromValue: existing.status, toValue: status, metadata: { mile: 'FIRST', bookingRef: await this.resolveBookingRef(existing), vehiclePlate: info.plate, driverName: info.driverName, }, }); } if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { 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: FirstMile): Promise { const [assignments, recordAllocations] = await Promise.all([ this.dataSource.manager.find(FirstMileVehicleAssignment, { where: { firstMileId: record.id }, }), this.dataSource.manager.find(FirstMileContainerAllocation, { where: { firstMileId: 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 first-mile pickup (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(FirstMileVehicleAssignment, { where: { firstMileId: 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(FirstMileVehicleAssignment, { firstMileId: id, vehicleId: In(removed), }); } for (const vehicleId of added) { await tx.insert(FirstMileVehicleAssignment, { firstMileId: id, vehicleId, containerNumber: desiredMap.get(vehicleId) ?? null, }); } for (const row of changed) { await tx.update( FirstMileVehicleAssignment, { firstMileId: id, vehicleId: row.vehicleId }, { containerNumber: desiredMap.get(row.vehicleId) ?? null }, ); } }); // Legacy primary vehicle = first of the set (null when cleared). await this.firstMileRepository.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, firstMileId: id, driverId: info.driverId, label: existing.status, metadata: { mile: 'FIRST', 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, firstMileId: id, driverId: info.driverId, metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } return this.findById(id); } /** * Record each truck's actual distance. The pickup 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('first_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( FirstMileVehicleAssignment, { firstMileId: id, vehicleId: d.vehicleId }, { distanceKm: d.distanceKm }, ); } const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); await this.firstMileRepository.update(id, { exactKm: total, ...(remainingPayment != null ? { remainingPayment } : {}), } as any); return this.findById(id); } private async notifyDriverAssignment(vehicleId: string, record: FirstMile): 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; } const booking = ( record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null; }; } ).booking; const driverName = `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim(); const message = `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : "") + (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ""); 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('first_mile', [id]); if (invoices.length) { throw new BadRequestException( 'Cannot delete a first-mile leg after its invoice is generated', ); } // Every vehicle this pickup holds — junction + legacy + container rows. const [assignments, allocations] = await Promise.all([ this.dataSource.manager.find(FirstMileVehicleAssignment, { where: { firstMileId: id }, }), this.dataSource.manager.find(FirstMileContainerAllocation, { where: { firstMileId: 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.firstMileRepository.softDelete(id); if (assignments.length) { await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id }); } // Free every vehicle no longer held by another active trip and audit 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, firstMileId: id, driverId: info.driverId, metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } } } }