diff --git a/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts b/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts new file mode 100644 index 000000000..7c95199b1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Multi-truck customer (self-haul) assignment. Replaces the single + * booking.customer_truck_* fields with a per-booking list of trucks, each + * carrying 1–2 containers and tracking its own arrival. The legacy + * booking.customer_truck_* columns are kept as a synced booking-level flag + * (any truck assigned / all trucks arrived) so the warehouse exit-gate and + * delivery-approval logic keep working. + */ +export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface { + name = 'AddCustomerTruckAssignments1950000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + plate_number varchar(32) NOT NULL, + driver_name varchar(120) NOT NULL, + truck_type varchar(60) NOT NULL, + assigned_at timestamptz NOT NULL DEFAULT now(), + arrived_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`, + ); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customer_truck_containers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE, + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + container_number varchar(64) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`, + ); + // One container number can be loaded onto exactly one truck per booking. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number" + ON freight.customer_truck_containers (booking_id, container_number) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c5574377a..795040eeb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -61,6 +61,8 @@ import { } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; +import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { CustomerTruckService } from './customer-truck.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { @@ -83,6 +85,7 @@ export class BookingsController { private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, private readonly bookingClearanceService: BookingClearanceService, + private readonly customerTruckService: CustomerTruckService, ) {} @Post() @@ -309,6 +312,47 @@ export class BookingsController { res.send(buffer); } + @Get(':id/customer-trucks') + @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) + async listCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.listTrucks(id); + } + + @Post(':id/customer-trucks') + @ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' }) + async addCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AddCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addTruck(id, dto); + } + + @Delete(':id/customer-trucks/:assignmentId') + @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) + async removeCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.removeTruck(id, assignmentId); + } + @Get(':id/tracking') @ApiOperation({ summary: "Shipment tracking timeline for a booking", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 92790c273..8f750af34 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -33,6 +33,10 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; +import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; +import { CustomerTruckService } from './customer-truck.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; @@ -55,6 +59,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingReviewNote, BookingContractSignature, BookingContainerAllocation, + CustomerTruckAssignment, + CustomerTruckContainer, ]), BillingModule, forwardRef(() => FirstMileModule), @@ -91,12 +97,15 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, + CustomerTruckAssignmentsRepository, + CustomerTruckService, ], exports: [ BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService, + CustomerTruckService, ], }) export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e3d882baa..1f2c1a09e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -145,7 +145,28 @@ export class BookingsService { throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); } - const html = this.buildCustomerTruckFreightOrderHtml(booking); + const trucks: Array<{ + plateNumber: string; + driverName: string; + truckType: string; + arrivedAt: string | null; + containers: string | null; + }> = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.arrived_at AS "arrivedAt", + string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers" + FROM freight.customer_truck_assignments a + LEFT JOIN freight.customer_truck_containers c + ON c.assignment_id = a.id AND c.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at + ORDER BY a.assigned_at`, + [bookingId], + ); + + const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); const buffer = await this.contractPdfService.htmlToPdfBuffer(html); return { filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, @@ -190,37 +211,85 @@ export class BookingsService { return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } - private buildCustomerTruckFreightOrderHtml(booking: Booking): string { + private buildCustomerTruckFreightOrderHtml( + booking: Booking, + trucks: Array<{ + plateNumber: string; + driverName: string; + truckType: string; + arrivedAt: string | null; + containers: string | null; + }>, + ): string { const assignedAt = booking.customerTruckAssignedAt ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') : '-'; - const rows: Array<[string, string | null | undefined]> = [ + const bookingRows: Array<[string, string | null | undefined]> = [ ['Booking Reference', booking.reference], ['Client Name', booking.company?.name], ['Client ID', booking.companyId], ['Trade Direction', booking.tradeDirection], ['Freight Type', booking.freightType], - ['Truck Plate Number', booking.customerTruckPlateNumber], - ['Driver Name', booking.customerTruckDriverName], - ['Truck Type', booking.customerTruckType], - ['Container Number to Load', booking.customerTruckContainerNumber], ['Assigned At', assignedAt], ['Booking Status', booking.status], ]; - const rowHtml = rows + const bookingRowHtml = bookingRows .map(([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`) .join(''); + + // Fall back to the legacy single-truck booking columns when there are no + // multi-truck rows (bookings assigned before the multi-truck feature). + const truckList = + trucks.length > 0 + ? trucks + : booking.customerTruckPlateNumber + ? [ + { + plateNumber: booking.customerTruckPlateNumber, + driverName: booking.customerTruckDriverName ?? '', + truckType: booking.customerTruckType ?? '', + arrivedAt: booking.customerTruckArrivedAt + ? String(booking.customerTruckArrivedAt) + : null, + containers: booking.customerTruckContainerNumber ?? null, + }, + ] + : []; + + const truckBlocks = truckList + .map((t, i) => { + const rows: Array<[string, string | null | undefined]> = [ + ['Truck Plate Number', t.plateNumber], + ['Driver Name', t.driverName], + ['Truck Type', t.truckType], + ['Containers Loaded', t.containers], + [ + 'Arrival', + t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival', + ], + ]; + const html = rows + .map( + ([label, value]) => + `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`, + ) + .join(''); + return `

Truck ${i + 1}

${html}
`; + }) + .join(''); + const copy = (watermark: string) => `
${this.escapeHtml(watermark)}

Freight Order

-

Customer external truck assignment

+

Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}

${this.escapeHtml(booking.reference)}
- ${rowHtml}
+ ${bookingRowHtml}
+ ${truckBlocks}
Customer / Carrier Signature
Port Operations Verification
@@ -238,11 +307,13 @@ export class BookingsService { .watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; } header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; } h1 { margin: 0; font-size: 28px; letter-spacing: 0; } + h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; } p { margin: 4px 0 0; color: #64748b; } strong { font-size: 16px; color: #0a9f6a; } - table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; } + table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; } th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; } th { width: 34%; background: #f1f5f9; } + .truck { page-break-inside: avoid; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; } .signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; } diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts new file mode 100644 index 000000000..45a09a6a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; + +@Injectable() +export class CustomerTruckAssignmentsRepository extends BaseRepository { + constructor( + @InjectRepository(CustomerTruckAssignment) + private readonly repo: Repository, + ) { + super(repo); + } + + /** All trucks assigned to a booking, oldest first, with their containers. */ + findByBookingId(bookingId: string): Promise { + return this.repo.find({ + where: { bookingId }, + relations: { containers: true }, + order: { assignedAt: 'ASC' }, + }); + } + + findByIdWithContainers(id: string): Promise { + return this.repo.findOne({ where: { id }, relations: { containers: true } }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts new file mode 100644 index 000000000..5ea1a898c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -0,0 +1,228 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager, IsNull } from 'typeorm'; + +import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; +import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; + +interface BookingGuardRow { + tradeDirection: 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 { + constructor( + private readonly dataSource: DataSource, + private readonly assignments: CustomerTruckAssignmentsRepository, + ) {} + + 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); + + const requested = dto.containerNumbers.map((n) => n.trim().toUpperCase()); + 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 alreadyAssigned = await this.assignedContainerNumbers(bookingId); + for (const n of requested) { + if (alreadyAssigned.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + + 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(), + }), + ); + 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); + } + + /** + * 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; + + await m + .getRepository(CustomerTruckAssignment) + .update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); + + await this.syncBookingArrival(bookingId, 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; + await m + .getRepository(CustomerTruckAssignment) + .update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); + await this.syncBookingArrival(bookingId, m); + } + + /** + * 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", + 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 { + const hasFirstMile = Boolean(booking.firstMile?.trim()); + const hasLastMile = Boolean(booking.lastMile?.trim()); + const usesMileService = + booking.tradeDirection === 'IMPORT' + ? hasLastMile + : booking.tradeDirection === 'EXPORT' + ? hasFirstMile + : hasFirstMile || hasLastMile; + if (usesMileService) { + throw new BadRequestException( + 'Customer truck assignment is only allowed when first/last mile delivery is not selected', + ); + } + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException( + 'Booking must be paid before assigning an external customer truck', + ); + } + } + + 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_containers 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()); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts new file mode 100644 index 000000000..17458dafa --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -0,0 +1,45 @@ +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsIn, + IsNotEmpty, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +/** + * Add one external customer truck to a booking, carrying 1–2 container numbers. + * Each container must be one of the booking's containers and not already loaded + * onto another truck (enforced in the service + a partial unique index). + */ +export class AddCustomerTruckDto { + @IsString() + @IsNotEmpty() + @MaxLength(32) + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(120) + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts new file mode 100644 index 000000000..83b70a135 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Booking } from './booking.entity'; +import { CustomerTruckContainer } from './customer-truck-container.entity'; + +/** + * One external (self-haul) truck a customer assigns to a booking that has no + * EDR first/last-mile leg. Each truck carries 1–2 containers and tracks its own + * arrival at the terminal/warehouse. + */ +@Entity({ schema: 'freight', name: 'customer_truck_assignments' }) +@Index(['bookingId']) +export class CustomerTruckAssignment extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'plate_number', type: 'varchar', length: 32 }) + plateNumber!: string; + + @Column({ name: 'driver_name', type: 'varchar', length: 120 }) + driverName!: string; + + @Column({ name: 'truck_type', type: 'varchar', length: 60 }) + truckType!: string; + + @Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'now()' }) + assignedAt!: Date; + + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + @OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true }) + containers?: CustomerTruckContainer[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts new file mode 100644 index 000000000..110e31671 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { CustomerTruckAssignment } from './customer-truck-assignment.entity'; + +/** + * A container number loaded onto a customer truck. A container may be loaded + * onto exactly one truck per booking (enforced by a partial unique index on + * booking_id + container_number). + */ +@Entity({ schema: 'freight', name: 'customer_truck_containers' }) +@Index(['assignmentId']) +export class CustomerTruckContainer extends BaseEntity { + @Column({ name: 'assignment_id', type: 'uuid' }) + assignmentId!: string; + + @ManyToOne(() => CustomerTruckAssignment, (a) => a.containers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assignment_id' }) + assignment?: CustomerTruckAssignment; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @Column({ name: 'container_number', type: 'varchar', length: 64 }) + containerNumber!: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index ac6b71c89..9a9f8caa4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2068,6 +2068,26 @@ export class WarehouseInventoryService { notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), }); if (!isTruckLeaving && item.bookingId) { + // Per-truck arrival: mark the customer truck carrying THIS item's + // container as arrived (matched via the physical container number). + if (item.containerId) { + await manager.query( + `UPDATE freight.customer_truck_assignments a + SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW() + FROM freight.customer_truck_containers c + JOIN freight.containers cont ON cont.container_number = c.container_number + WHERE c.assignment_id = a.id + AND c.deleted_at IS NULL + AND c.booking_id = $1 + AND cont.id = $2 + AND a.arrived_at IS NULL + AND a.deleted_at IS NULL`, + [item.bookingId, item.containerId], + ); + } + // Booking-level flag stamped on the FIRST truck arrival. The import + // handover is signed ONCE (before the first truck leaves), even though + // trucks pick up per-container — COALESCE keeps the first timestamp. await manager.query( `UPDATE freight.bookings SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), @@ -2147,6 +2167,44 @@ export class WarehouseInventoryService { } await this.invoices.assertClearanceAllowed(id); + // Import self-haul: the exit paper names the pickup truck + all containers it + // carries, so gate staff can verify the goods leaving on that truck. + let truck: { + plateNumber: string; + driverName: string; + truckType: string; + containerNumbers: string; + truckWeightTons: string | number | null; + } | null = null; + if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers", + COALESCE(( + SELECT SUM(bcu.vgm_tons) + FROM freight.customer_truck_containers cc + JOIN freight.booking_container_units bcu + ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + AND bc.booking_id = c.booking_id + WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL + ), 0) AS "truckWeightTons" + FROM freight.customer_truck_containers c + JOIN freight.customer_truck_assignments a + ON a.id = c.assignment_id AND a.deleted_at IS NULL + JOIN freight.customer_truck_containers c2 + ON c2.assignment_id = a.id AND c2.deleted_at IS NULL + WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id + LIMIT 1`, + [row.bookingId, row.containerNumber], + ); + truck = truckRow ?? null; + } + const bookingReference = row?.bookingReference || 'N/A'; const reference = row?.releaseOrderReference || @@ -2170,6 +2228,11 @@ export class WarehouseInventoryService { inventoryStatus: row?.status ?? null, clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', exitInspectionSummary: this.extractExitInspectionNote(row?.notes), + truckPlateNumber: truck?.plateNumber ?? null, + truckDriverName: truck?.driverName ?? null, + truckType: truck?.truckType ?? null, + truckContainers: truck?.containerNumbers ?? null, + truckWeightKg: truck ? Number(truck.truckWeightTons ?? 0) * 1000 : null, }); return { @@ -3099,6 +3162,11 @@ export class WarehouseInventoryService { inventoryStatus: string | null; clearanceStatus: string; exitInspectionSummary?: string | null; + truckPlateNumber?: string | null; + truckDriverName?: string | null; + truckType?: string | null; + truckContainers?: string | null; + truckWeightKg?: number | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -3123,12 +3191,26 @@ export class WarehouseInventoryService { ['Container Number', data.containerNumber], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Declared Weight', `${data.weight.toLocaleString()} kg`], + [ + data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight', + `${(data.truckPlateNumber && data.truckWeightKg + ? data.truckWeightKg + : data.weight + ).toLocaleString()} kg`, + ], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], ['Inventory Status', data.inventoryStatus], ['Clearance Status', data.clearanceStatus], + ...(data.truckPlateNumber + ? ([ + ['Pickup Truck Plate', data.truckPlateNumber], + ['Truck Driver', data.truckDriverName], + ['Truck Type', data.truckType], + ['Containers Loaded on Truck', data.truckContainers], + ] as [string, string | null][]) + : []), ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), ]; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index ee8abe106..fc3a291e6 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -106,6 +106,9 @@ export const URL_CONSTANTS = { CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`, CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`, CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`, + CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`, + CUSTOMER_TRUCK: (id: string, assignmentId: string) => + `/api/bookings/${id}/customer-trucks/${assignmentId}`, }, CONTRACTS: { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index c6ec6bf5d..ad7f6b72b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -1,15 +1,30 @@ -import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core"; -import { useMutation } from "@tanstack/react-query"; +import { + ActionIcon, + Alert, + Badge, + Button, + Divider, + Group, + Loader, + MultiSelect, + Select, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { Download, Lock, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react"; import { useState } from "react"; +import toast from "react-hot-toast"; import { api } from "@/services/api"; +import { customerTrucksService } from "@/services/customer-trucks.service"; import { CardTitle, SectionCard } from "./layout"; const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; -const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/; const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); @@ -22,6 +37,13 @@ const downloadBlob = (blob: Blob, filename: string) => { URL.revokeObjectURL(url); }; +const errorMessage = (error: unknown, fallback: string) => { + const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data; + if (Array.isArray(data?.message)) return data.message.join(", "); + if (data?.message) return data.message; + return error instanceof Error ? error.message : fallback; +}; + export function CustomerTruckAssignmentCard({ booking, onAssigned, @@ -29,48 +51,81 @@ export function CustomerTruckAssignmentCard({ booking: Freight.IBooking; onAssigned: () => void; }) { - const assigned = Boolean(booking.customerTruckAssignedAt); - const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? ""); - const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? ""); - const [truckType, setTruckType] = useState(booking.customerTruckType ?? ""); - const [containerNumberToLoad, setContainerNumberToLoad] = useState( - booking.customerTruckContainerNumber ?? "", - ); + const queryClient = useQueryClient(); + const trucksKey = ["customer-trucks", booking.id]; + + const { data: trucks = [], isLoading } = useQuery({ + queryKey: trucksKey, + queryFn: () => customerTrucksService.list(booking.id), + }); + + const [plateNumber, setPlateNumber] = useState(""); + const [driverName, setDriverName] = useState(""); + const [truckType, setTruckType] = useState(""); + const [containers, setContainers] = useState([]); const [error, setError] = useState(null); - // Physical container numbers on this booking — the customer picks which one to - // load onto the truck instead of typing it. Falls back to free entry when the - // booking has no container numbers recorded. - const containerOptions = booking.containerNumbers ?? []; + // Container numbers on the booking that aren't already loaded onto a truck. + const assignedNumbers = new Set( + trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)), + ); + const availableContainers = (booking.containerNumbers ?? []).filter( + (n) => !assignedNumbers.has(n), + ); - const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions()); - const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); - - const submit = async () => { - const payload = { - truckPlateNumber: truckPlateNumber.trim().toUpperCase(), - driverName: driverName.trim(), - truckType: truckType.trim(), - containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(), - }; - if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) { - setError("All truck assignment fields are required."); - return; - } - if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) { - setError("Container number must match ISO format, e.g. ABCD1234567."); - return; - } + const resetForm = () => { + setPlateNumber(""); + setDriverName(""); + setTruckType(""); + setContainers([]); setError(null); - await assignMutation.mutateAsync({ id: booking.id, payload }); - onAssigned(); }; + const addMutation = useMutation({ + mutationFn: () => + customerTrucksService.add(booking.id, { + truckPlateNumber: plateNumber.trim().toUpperCase(), + driverName: driverName.trim(), + truckType: truckType.trim(), + containerNumbers: containers, + }), + onSuccess: (list) => { + queryClient.setQueryData(trucksKey, list); + resetForm(); + onAssigned(); + toast.success("Truck added"); + }, + onError: (e) => setError(errorMessage(e, "Could not add truck")), + }); + + const removeMutation = useMutation({ + mutationFn: (assignmentId: string) => customerTrucksService.remove(booking.id, assignmentId), + onSuccess: (list) => { + queryClient.setQueryData(trucksKey, list); + onAssigned(); + }, + onError: (e) => toast.error(errorMessage(e, "Could not remove truck")), + }); + + const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); const downloadFreightOrder = async () => { const blob = await downloadMutation.mutateAsync({ id: booking.id }); downloadBlob(blob, `freight-order-${booking.reference}.pdf`); }; + const submitAdd = () => { + if (!plateNumber.trim() || !driverName.trim() || !truckType.trim()) { + setError("Plate number, driver name and truck type are required."); + return; + } + if (containers.length < 1 || containers.length > 2) { + setError("Select 1 or 2 container numbers for this truck."); + return; + } + setError(null); + addMutation.mutate(); + }; + return ( @@ -79,78 +134,133 @@ export function CustomerTruckAssignmentCard({ External Truck Assignment - {assigned && ( - - - - Truck Assigned - - + {trucks.length > 0 && ( + + {trucks.length} truck{trucks.length !== 1 ? "s" : ""} + )} + {/* Assigned trucks */} + {isLoading ? ( + + + + ) : ( + trucks.map((t) => ( + + + + + {t.plateNumber} + + {t.arrivedAt ? ( + }> + Arrived + + ) : ( + }> + Awaiting arrival + + )} + + + {t.driverName} · {t.truckType} + + + {(t.containers ?? []).map((c) => ( + + {c.containerNumber} + + ))} + + + {!t.arrivedAt && ( + removeMutation.mutate(t.id)} + loading={removeMutation.isPending} + > + + + )} + + )) + )} + {error && ( {error} )} - {assignMutation.isError && ( - - {assignMutation.error instanceof Error - ? assignMutation.error.message - : "Truck assignment failed."} - + + {/* Add-truck form */} + {availableContainers.length > 0 ? ( + <> + + + setPlateNumber(e.currentTarget.value.toUpperCase())} + /> + setDriverName(e.currentTarget.value)} + /> + setTruckType(value ?? "")} - disabled={assigned} - /> - {containerOptions.length > 0 ? ( -