diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index ae0ada831..3ba1e4e75 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,19 +1,25 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { FindOptionsWhere } from "typeorm"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } 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 { 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'; +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; @@ -26,10 +32,10 @@ type FirstMileListFilter = { }; const SORTABLE_FIELDS: (keyof FirstMile)[] = [ - 'status', - 'advancedPayment', - 'remainingPayment', - 'createdAt', + "status", + "advancedPayment", + "remainingPayment", + "createdAt", ]; @Injectable() @@ -43,26 +49,28 @@ export class FirstMileService { 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 { + async acceptBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId, { relations: { serviceType: true }, }); if (!booking) { - throw new NotFoundException(`Booking ${bookingId} not found`); + return null; } return this.acceptEligibleBooking(booking); } - async acceptBookingByReference(bookingReference: string): Promise { + async acceptBookingByReference( + bookingReference: string, + ): Promise { const [booking] = await this.bookingsRepository.findAll({ where: { reference: bookingReference }, relations: { serviceType: true }, @@ -89,20 +97,20 @@ export class FirstMileService { tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; - }): Promise { + }): Promise { const label = booking.reference ?? booking.id; - if (booking.paymentStatus !== 'PAID') { - throw new BadRequestException(`Booking ${label} is not paid`); + if (booking.paymentStatus !== "PAID") { + return null; } if (!this.bookingRequestsFirstMile(booking)) { - throw new BadRequestException(`Booking ${label} does not require a first mile`); + return null; } const existing = await this.findByBookingId(booking.id); if (existing) { - throw new ConflictException(`Booking ${label} already has a first-mile assignment`); + return null; } return this.create({ @@ -118,8 +126,9 @@ export class FirstMileService { 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'; + : "createdAt"; + const sortOrder = + filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC"; const where: FindOptionsWhere = {}; if (filter.status) where.status = filter.status; @@ -129,7 +138,13 @@ export class FirstMileService { const [data, total] = await this.firstMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, order: { [sortBy]: sortOrder }, @@ -151,8 +166,12 @@ export class FirstMileService { @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})`); + 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)}`, @@ -163,7 +182,13 @@ export class FirstMileService { async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, }); @@ -183,7 +208,7 @@ export class FirstMileService { return this.firstMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'READY_TO_TRANSIT', + status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? null, @@ -197,7 +222,13 @@ export class FirstMileService { const [records] = await this.firstMileRepository.findAndCount({ where: { bookingId }, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, take: 1, @@ -213,9 +244,9 @@ export class FirstMileService { // Export bookings always need a first mile (pickup → origin yard); the // pickup address is captured at assignment time, not required upfront. return Boolean( - booking.tradeDirection === 'EXPORT' || - booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile, + booking.tradeDirection === "EXPORT" || + booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile, ); } @@ -226,9 +257,15 @@ export class FirstMileService { 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.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 } : {}), @@ -256,37 +293,63 @@ export class FirstMileService { return updated; } - private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { + 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`); + this.logger.warn( + `Vehicle ${vehicleId} has no assigned driver — skipping SMS`, + ); return; } - const driver = await this.driversService.findById(vehicle.assignedDriverId); + const driver = await this.driversService.findById( + vehicle.assignedDriverId, + ); if (!driver.phoneNumber) { - this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); + 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 booking = ( + record as FirstMile & { + booking?: { + reference?: string; + firstMilePickupAddress?: string | null; + originYard?: { label?: string } | null; + }; + } + ).booking; - const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + 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}.` : ''); + (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`); + 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)}`); + this.logger.error( + `Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`, + ); } } @@ -314,7 +377,7 @@ export class FirstMileService { firstMileId, containerId: allocation.containerId, vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', + containerType: "CONTAINER", quantity: 1, }); }