import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere, In } 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 { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; import { InvoiceEventPayload } from "../billing/billing.service"; 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, ) { } /** * 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, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, }); 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, }, }); if (!record) { throw new NotFoundException(`First-mile record ${id} not found`); } 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); } 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); 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]); } } // Notify assigned driver on every explicit vehicle assignment or reassignment if (dto.vehicleId) { void this.notifyDriverAssignment(dto.vehicleId, existing); } // 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); const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); } 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 recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { where: { firstMileId: 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); } 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 { await this.findById(id); await this.firstMileRepository.softDelete(id); } async allocateContainers( firstMileId: string, allocations: Array<{ containerId: string; vehicleId: string }>, ) { const firstMile = await this.findById(firstMileId); if (!firstMile) { throw new NotFoundException(`First-mile record ${firstMileId} not found`); } const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { where: { firstMileId, 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(FirstMileContainerAllocation, { firstMileId, containerId: allocation.containerId, }); await manager.insert(FirstMileContainerAllocation, { firstMileId, containerId: allocation.containerId, vehicleId: allocation.vehicleId, containerType: "CONTAINER", quantity: 1, }); } }); const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); await Promise.all( [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)), ); await this.vehiclesService.releaseIfUnused( previousVehicleIds.filter((id) => !vehicleIds.has(id)), ); return { success: true, allocated: allocations.length, }; } }