import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager, IsNull } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { EDR_HAULAGE_CONFLICT_MESSAGE, usesEdrMileService, } from '../../common/mile-haulage.util'; import { assertBulkTonnageRemains, assertTruckCountWithinContainers, assertTruckLoad, bookingContainerSizes, remainingBulkTons, } from '../../common/truck-load.util'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; interface BookingGuardRow { tradeDirection: string | null; freightType: string | null; firstMile: string | null; lastMile: string | null; paymentStatus: string | null; status: string | null; } /** * Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg * can have several customer trucks, each carrying 1–2 of its containers and * tracking its own arrival. The legacy booking.customer_truck_* columns are kept * as a booking-level flag (any truck assigned / all arrived) so the warehouse * exit-gate + delivery-approval logic keep working unchanged. */ @Injectable() export class CustomerTruckService { private readonly logger = new Logger(CustomerTruckService.name); constructor( private readonly dataSource: DataSource, private readonly assignments: CustomerTruckAssignmentsRepository, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, ) {} listTrucks(bookingId: string): Promise { return this.assignments.findByBookingId(bookingId); } async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise { const booking = await this.loadBookingGuard(bookingId); this.assertSelfHaulPaid(booking); this.assertAssignmentWindow(booking); // Bulk bookings have no containers — the truck hauls loose tonnage and is // weighed out on departure (gross_weight_kg). Container bookings assign the // 1–2 specific containers each truck carries. const isBulk = booking.freightType === 'BULK'; const requested = isBulk ? [] : (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); // Container capacity is size-based: a 40ft container fills the truck (max 1); // two 20ft containers fit (max 2), no size mixing. #trucks <= #containers // follows naturally since each container is assigned to exactly one truck. if (!isBulk && requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } // Bulk is capped by tonnage, not container count: trucks may be added until // the booking's declared weight has been hauled away. Container bookings are // capped below by #trucks <= #containers. if (isBulk) { const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId); assertBulkTonnageRemains(totalTons, remainingTons); // Assignment-time drawdown: planned tonnage across live trucks (weighed // net once departed, planned before) may not exceed the declared total. if (totalTons > 0) { const [p]: Array<{ planned: string | null }> = await this.dataSource.query( `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned FROM freight.customer_truck_assignments a WHERE a.booking_id = $1 AND a.deleted_at IS NULL`, [bookingId], ); const alreadyPlanned = Number(p?.planned ?? 0); const requestedTons = Number(dto.plannedTons ?? 0); if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) { throw new BadRequestException( `Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`, ); } } } if (requested.length) { const bookingNumbers = await this.bookingContainerNumbers(bookingId); const existingTrucks = await this.dataSource .getRepository(CustomerTruckAssignment) .count({ where: { bookingId } }); assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length); assertTruckLoad({ containers: requested, bookingContainers: bookingNumbers, sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), assignedElsewhere: await this.assignedContainerNumbers(bookingId), }); } await this.dataSource.transaction(async (manager) => { const assignment = await manager.getRepository(CustomerTruckAssignment).save( manager.getRepository(CustomerTruckAssignment).create({ bookingId, plateNumber: dto.truckPlateNumber.trim().toUpperCase(), driverName: dto.driverName.trim(), truckType: dto.truckType.trim(), plannedTons: isBulk ? (dto.plannedTons ?? null) : null, plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null, }), ); await manager.getRepository(CustomerTruckContainer).save( requested.map((containerNumber) => manager.getRepository(CustomerTruckContainer).create({ assignmentId: assignment.id, bookingId, containerNumber, }), ), ); // Booking-level flag: first truck marks the booking as truck-assigned. await manager.query( `UPDATE freight.bookings SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()), status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END, updated_at = NOW() WHERE id = $1`, [bookingId], ); }); return this.listTrucks(bookingId); } async removeTruck(bookingId: string, assignmentId: string): Promise { const assignment = await this.assignments.findByIdWithContainers(assignmentId); if (!assignment || assignment.bookingId !== bookingId) { throw new NotFoundException('Truck assignment not found for this booking'); } if (assignment.arrivedAt) { throw new ConflictException('Cannot remove a truck that has already arrived'); } await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId); const remaining = await manager .getRepository(CustomerTruckAssignment) .count({ where: { bookingId } }); if (remaining === 0) { // No trucks left — clear the booking-level flag and revert the status. await manager.query( `UPDATE freight.bookings SET customer_truck_assigned_at = NULL, status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END, updated_at = NOW() WHERE id = $1`, [bookingId], ); } }); return this.listTrucks(bookingId); } /** * Edit a truck assignment — plate/driver/type and the containers it carries. * Allowed only until the truck has arrived (same guard as removal). Container * rules mirror {@link addTruck}: 1–2 of the booking's containers, none already * on another truck, and a 40ft container fills the truck (max 1). */ async updateTruck( bookingId: string, assignmentId: string, dto: AddCustomerTruckDto, ): Promise { const booking = await this.loadBookingGuard(bookingId); this.assertSelfHaulPaid(booking); const assignment = await this.assignments.findByIdWithContainers(assignmentId); if (!assignment || assignment.bookingId !== bookingId) { throw new NotFoundException('Truck assignment not found for this booking'); } if (assignment.arrivedAt) { throw new ConflictException('Cannot edit a truck that has already arrived'); } // Bulk trucks carry loose tonnage, not containers — planned tonnage is // editable instead, capped by what the other trucks haven't claimed. const isBulk = booking.freightType === 'BULK'; const requested = isBulk ? [] : (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); if (!isBulk && requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } if (!isBulk) { assertTruckLoad({ containers: requested, bookingContainers: await this.bookingContainerNumbers(bookingId), sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), // Exclude THIS truck's own containers so re-saving the same set is allowed. assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), }); } else if (dto.plannedTons != null) { const { totalTons } = await remainingBulkTons(this.dataSource, bookingId); if (totalTons > 0) { const [p]: Array<{ planned: string | null }> = await this.dataSource.query( `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned FROM freight.customer_truck_assignments a WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`, [bookingId, assignmentId], ); const others = Number(p?.planned ?? 0); if (others + Number(dto.plannedTons) > totalTons + 0.001) { throw new BadRequestException( `Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`, ); } } } await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { plateNumber: dto.truckPlateNumber.trim().toUpperCase(), driverName: dto.driverName.trim(), truckType: dto.truckType.trim(), ...(isBulk ? { plannedTons: dto.plannedTons ?? null, plannedQuantity: dto.plannedQuantity ?? null, } : {}), }); await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); await manager.getRepository(CustomerTruckContainer).save( requested.map((containerNumber) => manager.getRepository(CustomerTruckContainer).create({ assignmentId, bookingId, containerNumber, }), ), ); }); return this.listTrucks(bookingId); } /** * Register an IMPORT self-haul truck leaving the port: the containers it * actually loaded (replacing any provisional list) and its weighed gross. * Export bookings have no truck departure — trucks only deliver (receive). */ async departTruck( bookingId: string, assignmentId: string, dto: DepartCustomerTruckDto, ): Promise { const booking = await this.loadBookingGuard(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException( 'Truck departure/weighing applies to import self-haul only (export trucks only deliver)', ); } const assignment = await this.assignments.findByIdWithContainers(assignmentId); if (!assignment || assignment.bookingId !== bookingId) { throw new NotFoundException('Truck assignment not found for this booking'); } // Once filled, the departure record is uneditable. if (assignment.departedAt) { throw new ConflictException('This truck has already departed — its exit record is locked'); } const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); if (requested.length) { const bookingNumbers = await this.bookingContainerNumbers(bookingId); for (const n of requested) { if (!bookingNumbers.includes(n)) { throw new BadRequestException(`Container ${n} is not one of this booking's containers`); } } const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); for (const n of requested) { if (elsewhere.includes(n)) { throw new ConflictException(`Container ${n} is already loaded onto another truck`); } } } await this.dataSource.transaction(async (manager) => { if (requested.length) { // Replace the truck's containers with what was actually loaded. await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); await manager.getRepository(CustomerTruckContainer).save( requested.map((containerNumber) => manager.getRepository(CustomerTruckContainer).create({ assignmentId, bookingId, containerNumber, }), ), ); } await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { grossWeightKg: dto.grossWeightKg, departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(), arrivedAt: assignment.arrivedAt ?? new Date(), }); }); return this.listTrucks(bookingId); } /** Booking container numbers not yet loaded onto any truck. */ async getLoadableContainers(bookingId: string): Promise { const [all, assigned] = await Promise.all([ this.bookingContainerNumbers(bookingId), this.assignedContainerNumbers(bookingId), ]); const taken = new Set(assigned); return all.filter((n) => !taken.has(n)); } /** * Truck_dispatch (load): assign the selected containers to a truck after it has * arrived, and set a provisional gross weight from their VGM. The truck is * weighed for real on departure. Locked once the truck has left. */ async loadTruck( bookingId: string, assignmentId: string, dto: { containerNumbers: string[] }, ): Promise { await this.loadBookingGuard(bookingId); const assignment = await this.assignments.findByIdWithContainers(assignmentId); if (!assignment || assignment.bookingId !== bookingId) { throw new NotFoundException('Truck assignment not found for this booking'); } if (assignment.departedAt) { throw new ConflictException('This truck has already left — its load is locked'); } // Loading a truck at the warehouse implies it is physically present, so a // truck that is still only assigned (not yet marked arrived) is auto-arrived // here rather than blocking the operator — the real gross is weighed on // departure anyway. const needsArrival = !assignment.arrivedAt; const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); if (!requested.length) { throw new BadRequestException('Select at least one container to load onto the truck'); } // Capacity is size-based: a truck carries at most 2 containers, and a 40ft // container fills the truck (max 1) — mirror the addTruck/updateTruck rule. assertTruckLoad({ containers: requested, bookingContainers: await this.bookingContainerNumbers(bookingId), sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), }); const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); // Operator loading the truck: stamp loaded_at so these containers move to // the LOADED stage (customer assignment alone leaves loaded_at null). const loadedAt = new Date(); await manager.getRepository(CustomerTruckContainer).save( requested.map((containerNumber) => manager.getRepository(CustomerTruckContainer).create({ assignmentId, bookingId, containerNumber, loadedAt, }), ), ); // Provisional gross (tonnes) from the loaded containers' VGM — overridden // by the weighed gross on departure. (Column is *_kg but holds tonnes.) // Auto-stamp arrival if the truck was still only assigned. await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { grossWeightKg: grossTons, ...(needsArrival ? { arrivedAt: new Date() } : {}), }); if (needsArrival) { await manager.query( `UPDATE freight.bookings SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), updated_at = NOW() WHERE id = $1`, [bookingId], ); } }); return this.listTrucks(bookingId); } /** Summed VGM (tonnes) of the given containers — provisional truck gross. */ private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise { const [row]: Array<{ tons: string }> = await this.dataSource.query( `SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.container_number = ANY($2::varchar[]) AND bcu.deleted_at IS NULL`, [bookingId, numbers], ); return Number(row?.tons ?? 0); } /** * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse * receive flow. When every truck on the booking has arrived, the booking-level * customer_truck_arrived_at flag is stamped (used by the delivery-approval * gate). No-op when the container is not on any customer truck. */ async markArrivedByContainer( bookingId: string, containerNumber: string, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; const cn = containerNumber.trim().toUpperCase(); const container = await m.getRepository(CustomerTruckContainer).findOne({ where: { bookingId, containerNumber: cn }, }); if (!container) return; const assignment = await m .getRepository(CustomerTruckAssignment) .findOne({ where: { id: container.assignmentId } }); const justArrived = Boolean(assignment) && !assignment?.arrivedAt; await m .getRepository(CustomerTruckAssignment) .update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); await this.syncBookingArrival(bookingId, m); if (justArrived && assignment) { await this.notifyTruckArrival(bookingId, assignment.plateNumber, m); } } /** Mark every truck on the booking arrived (fallback when no container is known). */ async markAllArrived(bookingId: string, manager?: EntityManager): Promise { const m = manager ?? this.dataSource.manager; const justArrived = await m .getRepository(CustomerTruckAssignment) .find({ where: { bookingId, arrivedAt: IsNull() } }); await m .getRepository(CustomerTruckAssignment) .update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); await this.syncBookingArrival(bookingId, m); for (const truck of justArrived) { await this.notifyTruckArrival(bookingId, truck.plateNumber, m); } } /** * Best-effort truck-arrival notification to the booking's company across every * channel: in-app (portal inbox) + SMS + email. Never throws — a missing * provider or contact must not break the arrival flow. */ private async notifyTruckArrival( bookingId: string, plateNumber: string | null, m: EntityManager, ): Promise { try { const [booking]: Array<{ companyId: string | null; reference: string | null }> = await m.query( `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); if (!booking?.companyId) return; const ref = booking.reference ?? bookingId; const truck = plateNumber ? `Truck ${plateNumber}` : 'A customer truck'; const body = `${truck} has arrived at the terminal for booking ${ref}.`; await this.inbox.notify({ recipients: { companyId: booking.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.BOOKING_STATUS, title: 'Truck arrived', body, link: `/bookings/${bookingId}`, data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' }, }); await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); } catch (err) { this.logger.warn( `Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`, ); } } /** * Stamp the booking-level arrival flag on the FIRST truck arrival. The import * handover is signed once, before the first truck leaves, even though trucks * pick up per-container — so the flag fires on the first arrival (COALESCE * keeps it), not once all trucks have arrived. */ private async syncBookingArrival(bookingId: string, m: EntityManager): Promise { await m.query( `UPDATE freight.bookings SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), updated_at = NOW() WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`, [bookingId], ); } private async loadBookingGuard(bookingId: string): Promise { const [row]: BookingGuardRow[] = await this.dataSource.query( `SELECT trade_direction AS "tradeDirection", freight_type AS "freightType", first_mile_pickup_address AS "firstMile", last_mile_delivery_address AS "lastMile", payment_status AS "paymentStatus", status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); if (!row) throw new NotFoundException(`Booking ${bookingId} not found`); return row; } private assertSelfHaulPaid(booking: BookingGuardRow): void { // Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two // halves of this rule cannot drift apart — they did, and a booking ended up // with a customer truck and an EDR leg at once. if (usesEdrMileService(booking)) { throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE); } if (booking.paymentStatus !== 'PAID') { throw new BadRequestException( 'Booking must be paid before assigning an external customer truck', ); } } /** * Assignment window by direction: * - IMPORT: pickup trucks are assigned only AFTER the train has arrived. * - EXPORT / DOMESTIC: delivery trucks are assigned only BEFORE the cargo is * loaded onto the train (booking still PAID / TRUCK_ASSIGNED). Once loaded * (IN_TRANSIT and beyond) assignment is closed. */ private assertAssignmentWindow(booking: BookingGuardRow): void { const status = booking.status ?? ''; if (booking.tradeDirection === 'IMPORT') { if (status !== 'ARRIVED') { throw new BadRequestException( 'Import pickup trucks can only be assigned after the train has arrived', ); } return; } if (!['PAID', 'TRUCK_ASSIGNED'].includes(status)) { throw new BadRequestException( 'Export delivery trucks can only be assigned before the cargo is loaded onto the train', ); } } private async bookingContainerNumbers(bookingId: string): Promise { const rows: Array<{ containerNumber: string }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, [bookingId], ); return rows.map((r) => r.containerNumber.trim().toUpperCase()); } private async assignedContainerNumbers(bookingId: string): Promise { const rows: Array<{ containerNumber: string }> = await this.dataSource.query( `SELECT container_number AS "containerNumber" FROM freight.customer_truck_containers WHERE booking_id = $1 AND deleted_at IS NULL`, [bookingId], ); return rows.map((r) => r.containerNumber.trim().toUpperCase()); } private async assignedContainerNumbersExcept( bookingId: string, exceptAssignmentId: string, ): Promise { const rows: Array<{ containerNumber: string }> = await this.dataSource.query( `SELECT container_number AS "containerNumber" FROM freight.customer_truck_containers WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`, [bookingId, exceptAssignmentId], ); return rows.map((r) => r.containerNumber.trim().toUpperCase()); } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ async addBulkTrucks( bookingId: string, dtos: AddCustomerTruckDto[], ): Promise<{ success: number; failed: number; errors: Array<{ row: number; truck: string; reason: string }>; }> { const errors: Array<{ row: number; truck: string; reason: string }> = []; let successCount = 0; for (let i = 0; i < dtos.length; i++) { try { await this.addTruck(bookingId, dtos[i]); successCount++; } catch (err: any) { errors.push({ row: i + 2, // Row 1 is header truck: dtos[i].truckPlateNumber, reason: err.message || 'Unknown error', }); } } return { success: successCount, failed: errors.length, errors, }; } }