diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts index 8c3310321..c060130cd 100644 --- a/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts +++ b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts @@ -1,4 +1,4 @@ -import { usesEdrMileService } from './mile-haulage.util'; +import { edrHaulsThisBooking, usesEdrMileService } from './mile-haulage.util'; /** * The road legs are chosen on the contract and copied onto the booking. EDR @@ -26,27 +26,76 @@ describe('usesEdrMileService', () => { }); it('an export that chose collection uses EDR haulage', () => { - expect( - usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })), - ).toBe(true); + expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe( + true, + ); }); it('ignores the delivery address on an export — delivery is the import leg', () => { - expect( - usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })), - ).toBe(false); + expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' }))).toBe( + false, + ); }); it('a domestic booking counts either leg', () => { - expect( - usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })), - ).toBe(true); - expect( - usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })), - ).toBe(true); + expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe( + true, + ); + expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' }))).toBe( + true, + ); }); it('treats a whitespace-only address as no choice', () => { expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false); }); }); + +/** + * Self-haul is closed only once EDR has committed to the leg. Delivery chosen + * on the contract is a request the chief still has to approve; collection has + * no approval step. + */ +describe('edrHaulsThisBooking', () => { + const booking = (over: Partial[0]> = {}) => ({ + tradeDirection: 'IMPORT', + firstMile: null, + lastMile: null, + lastMileCommitted: false, + ...over, + }); + + it('an import whose last-mile request is not yet approved may still self-haul', () => { + expect(edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(false); + }); + + it('an import whose last-mile request was approved is hauled by EDR', () => { + expect( + edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa', lastMileCommitted: true })), + ).toBe(true); + }); + + it('an import that chose no delivery self-hauls, whatever the leg tables say', () => { + expect(edrHaulsThisBooking(booking({ lastMileCommitted: true }))).toBe(false); + }); + + it('an export that chose collection is hauled by EDR — no approval step on that leg', () => { + expect(edrHaulsThisBooking(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe( + true, + ); + }); + + it('a domestic booking is blocked by collection, or by an approved delivery', () => { + expect(edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe( + true, + ); + expect( + edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })), + ).toBe(false); + expect( + edrHaulsThisBooking( + booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa', lastMileCommitted: true }), + ), + ).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.ts b/apps/edr-freight-api/src/common/mile-haulage.util.ts index 1ca83d06e..c9382d45c 100644 --- a/apps/edr-freight-api/src/common/mile-haulage.util.ts +++ b/apps/edr-freight-api/src/common/mile-haulage.util.ts @@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE = 'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.'; export const EDR_HAULAGE_CONFLICT_MESSAGE = - 'Customer truck assignment is only allowed when first/last mile delivery is not selected'; + 'Customer truck assignment is only allowed when first/last mile delivery is not selected, or when the EDR last-mile request has not been approved'; + +/** The booking fields that decide whether the customer may still bring their own truck. */ +export interface MileCommitmentRow extends MileHaulageRow { + /** + * EDR has actually committed to the delivery leg: the booking's last-mile + * request was approved, or a `freight.last_mile` leg row exists for it. + * Selecting delivery on the contract is only a request — see + * `edrHaulsThisBooking`. + */ + lastMileCommitted: boolean; +} + +/** + * SQL for `MileCommitmentRow.lastMileCommitted`, to be selected alongside the + * booking row aliased `b`. Both services that gate self-haul read the same + * fragment so the rule cannot drift between them. + */ +export const LAST_MILE_COMMITTED_SQL = `( + EXISTS (SELECT 1 + FROM freight.last_mile lm + WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL) + OR EXISTS (SELECT 1 + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = b.id + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED') +)`; + +/** + * Whether EDR is hauling this booking's road leg, such that the customer may + * NOT assign their own truck. Stricter than `usesEdrMileService` on the + * delivery side: choosing last-mile delivery on the contract opens a request + * that the Truck & Machinery chief still has to approve, and until that + * approval the customer is free to self-haul instead. Collection (the export + * leg) has no approval step, so the contract choice alone decides it. + * + * `usesEdrMileService` keeps answering the other question — whether the booking + * belongs in the EDR mile queues at all — and the queue side still refuses a + * booking that already carries a customer truck, so the two paths remain + * mutually exclusive whichever acts first. + */ +export function edrHaulsThisBooking(booking: MileCommitmentRow): boolean { + const hasFirstMile = Boolean(booking.firstMile?.trim()); + const lastMileApproved = Boolean(booking.lastMile?.trim()) && booking.lastMileCommitted; + switch (booking.tradeDirection) { + case 'IMPORT': + return lastMileApproved; + case 'EXPORT': + return hasFirstMile; + default: + return hasFirstMile || lastMileApproved; + } +} /** * The road legs are chosen on the contract. A booking whose contract bought 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 9655a9603..bcb461a14 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -86,6 +86,7 @@ import { import { ContractViewDto } from "./dto/contract-view.dto"; import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto"; import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto"; +import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto"; import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto"; import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto"; import { CustomerTruckService } from "./customer-truck.service"; @@ -848,7 +849,7 @@ export class BookingsController { }) async bulkAddCustomerTrucks( @Param("id", ParseUUIDPipe) id: string, - @Body() payload: { trucks: AddCustomerTruckDto[] }, + @Body() payload: BulkCustomerTrucksDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); 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 9b213d675..d777c6414 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -29,6 +29,11 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; +import { + EDR_HAULAGE_CONFLICT_MESSAGE, + LAST_MILE_COMMITTED_SQL, + edrHaulsThisBooking, +} from '../../common/mile-haulage.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -174,18 +179,23 @@ export class BookingsService { dto: CustomerTruckAssignmentDto, ): Promise { const booking = await this.findById(bookingId); - const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim()); - const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.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', - ); + // Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg + // closes self-haul only once it has been approved. + const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query( + `SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted" + FROM freight.bookings b + WHERE b.id = $1`, + [bookingId], + ); + if ( + edrHaulsThisBooking({ + tradeDirection: booking.tradeDirection ?? null, + firstMile: booking.firstMilePickupAddress ?? null, + lastMile: booking.lastMileDeliveryAddress ?? null, + lastMileCommitted: Boolean(commitment?.lastMileCommitted), + }) + ) { + throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE); } if (booking.customerTruckAssignedAt) { throw new ConflictException('Customer truck assignment is already submitted and locked'); 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 index d8b94efad..88df3d80e 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -9,12 +9,17 @@ import { DataSource, EntityManager, IsNull } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import type { + BulkTruckUploadError, + BulkTruckUploadResult, +} from './dto/bulk-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, + LAST_MILE_COMMITTED_SQL, + edrHaulsThisBooking, } from '../../common/mile-haulage.util'; import { assertBulkTonnageRemains, @@ -36,6 +41,8 @@ interface BookingGuardRow { paymentStatus: string | null; status: string | null; trainScheduleStatus: string | null; + /** See `MileCommitmentRow` — an approved EDR last-mile leg closes self-haul. */ + lastMileCommitted: boolean; } /** @@ -547,7 +554,8 @@ export class CustomerTruckService { ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL ORDER BY ts.updated_at DESC - LIMIT 1) AS "trainScheduleStatus" + LIMIT 1) AS "trainScheduleStatus", + ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted" FROM freight.bookings b WHERE b.id = $1 AND b.deleted_at IS NULL`, [bookingId], @@ -557,10 +565,12 @@ export class CustomerTruckService { } 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)) { + // Mirrors the EDR side (LastMileService.assertEdrHaulsThisBooking) 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. A last-mile leg only + // blocks self-haul once it is approved; until then the customer may still + // bring their own truck, and doing so makes the pending request unapprovable. + if (edrHaulsThisBooking(booking)) { throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE); } if (booking.paymentStatus !== 'PAID') { @@ -631,26 +641,29 @@ export class CustomerTruckService { /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + /** + * Add trucks one at a time, keeping the good ones. Partial success is the + * right shape here: one mistyped plate in a twenty-row spreadsheet should not + * discard the other nineteen trucks. Every row still goes through `addTruck`, + * so no guard is skipped. + */ 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 }> = []; + ): Promise { + const errors: BulkTruckUploadError[] = []; let successCount = 0; for (let i = 0; i < dtos.length; i++) { try { await this.addTruck(bookingId, dtos[i]); successCount++; - } catch (err: any) { + } catch (err) { errors.push({ - row: i + 2, // Row 1 is header + index: i, + row: i + 2, // Row 1 is the header truck: dtos[i].truckPlateNumber, - reason: err.message || 'Unknown error', + reason: err instanceof Error ? err.message : 'Unknown error', }); } } 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 index 9816b3405..1085f954a 100644 --- 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 @@ -12,7 +12,7 @@ import { Min, } from 'class-validator'; -import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; +import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types'; /** * Add one external customer truck to a booking. @@ -41,7 +41,7 @@ export class AddCustomerTruckDto { @IsArray() @ArrayMaxSize(2) @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts index 5e03c7bc4..e3249e684 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -1,48 +1,41 @@ -import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; -import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; +import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; -export class BulkCustomerTruckRow { - @IsString() - @IsNotEmpty() - truckPlateNumber!: string; - - @IsString() - @IsNotEmpty() - driverName!: string; - - @IsString() - @IsNotEmpty() - @IsIn(CUSTOMER_TRUCK_TYPES) - truckType!: string; - - @IsOptional() - @IsArray() - @ArrayMaxSize(2) - @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { - each: true, - message: 'each container must be ISO format (e.g. ABCD1234567)', - }) - containerNumbers?: (string | null)[]; -} +import { AddCustomerTruckDto } from './add-customer-truck.dto'; +/** + * Bulk self-haul truck assignment, parsed from the customer's Excel upload in + * the browser and posted as JSON (the house pattern — the API never receives an + * .xlsx for import). + * + * Rows reuse `AddCustomerTruckDto` verbatim rather than redeclaring the fields: + * the earlier copy drifted, missing `plannedTons` / `plannedQuantity`, so bulk + * cargo could not be uploaded at all. + */ export class BulkCustomerTrucksDto { @IsArray() + @ArrayMinSize(1) @ArrayMaxSize(100) - trucks!: BulkCustomerTruckRow[]; + @ValidateNested({ each: true }) + @Type(() => AddCustomerTruckDto) + trucks!: AddCustomerTruckDto[]; +} + +export interface BulkTruckUploadError { + /** + * Position in the submitted array. The client knows which spreadsheet line it + * read each entry from, so it maps this back to the row number the customer + * actually sees. + */ + index: number; + /** 1-based row assuming a single header line — a fallback for non-Excel callers. */ + row: number; + truck: string; + reason: string; } export interface BulkTruckUploadResult { success: number; failed: number; - errors: Array<{ - row: number; - truck: string; - reason: string; - }>; - created: Array<{ - truckPlateNumber: string; - driverName: string; - containers: number; - }>; + errors: BulkTruckUploadError[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts index 9daf7523e..e4655a739 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts @@ -1,12 +1,12 @@ import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types'; -export const CUSTOMER_TRUCK_TYPES = [ - 'Flatbed', - 'Container Chassis', - 'Lowboy', - 'Box Truck', - 'Tipper', -] as const; +/** + * Re-exported for the DTOs that already import it from here. The list itself + * lives in `@edr/types` so the portal's dropdown and its Excel template read the + * same values this validator enforces. + */ +export { CUSTOMER_TRUCK_TYPES }; export class CustomerTruckAssignmentDto { @IsString() @@ -27,7 +27,7 @@ export class CustomerTruckAssignmentDto { @IsString() @IsNotEmpty() @MaxLength(16) - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567', }) containerNumberToLoad!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts index 31ab1b5bd..d3e73267d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts @@ -8,6 +8,7 @@ import { Matches, Min, } from 'class-validator'; +import { ISO_CONTAINER_NUMBER } from '@edr/types'; /** * Register an import self-haul truck leaving the port: the containers it actually @@ -20,7 +21,7 @@ export class DepartCustomerTruckDto { @IsArray() @ArrayMaxSize(2) @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts index 1386e5bd4..35a4f7e0e 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts @@ -1,4 +1,5 @@ import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; +import { ISO_CONTAINER_NUMBER } from '@edr/types'; /** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */ export class LoadCustomerTruckDto { @@ -7,7 +8,7 @@ export class LoadCustomerTruckDto { // A truck carries at most 2 containers (two 20ft, or one 40ft). @ArrayMaxSize(2) @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts index 1451f86c7..06687253b 100644 --- a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts @@ -16,6 +16,17 @@ import { import { EmptyReturnRequestsService } from './empty-return-requests.service'; import type { EmptyReturnRequestStatus } from './entities/empty-return-request.entity'; +/** + * Reading the queue is OR'd with the warehouse-inventory key the rest of the + * Imports menu uses, so the staff who already run container returns can open + * it while the dedicated key is still being handed out. Approving and + * rejecting stay on the review key alone — that one is a commercial decision. + */ +const CAN_VIEW = [ + FREIGHT_PERMS.emptyReturnRequests.view, + FREIGHT_PERMS.warehouseInventory.view, +]; + @ApiTags('empty-return-requests') @ApiBearerAuth() @Controller('empty-return-requests') @@ -23,7 +34,7 @@ export class EmptyReturnRequestsController { constructor(private readonly service: EmptyReturnRequestsService) {} @Get() - @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.view) + @BookingStaff(CAN_VIEW) @ApiOperation({ summary: 'Empty container return requests queue' }) findAll(@Query('status') status?: string, @Query('bookingId') bookingId?: string) { return this.service.findAll({ @@ -42,7 +53,7 @@ export class EmptyReturnRequestsController { } @Get('eligibility/:bookingId') - @MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view) + @MixedAudience(CAN_VIEW) @ApiOperation({ summary: 'Whether a booking may request an empty return, its free containers, and the price per container', @@ -55,14 +66,14 @@ export class EmptyReturnRequestsController { } @Get('by-booking/:bookingId') - @MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view) + @MixedAudience(CAN_VIEW) @ApiOperation({ summary: "A booking's empty return requests, newest first" }) findForBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.service.findForBooking(bookingId); } @Get(':id') - @MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view) + @MixedAudience(CAN_VIEW) @ApiOperation({ summary: 'Get an empty return request by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { return this.service.findById(id, this.portalUserId(user)); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ff03bd5da..f95fe7cca 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -714,7 +714,10 @@ const App = () => { path="empty-return-requests" element={ diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 8e118afea..1180518fc 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -372,7 +372,13 @@ export const buildSidebarSections = ( label: "Empty Return Requests", href: "/dashboard/empty-return-requests", icon: , - permission: FREIGHT_PERMS.emptyReturnRequests.view, + // Visible to whoever already runs container returns, OR'd with + // the queue's own key — so the dedicated permission can be handed + // out per position later without the menu disappearing now. + permission: [ + FREIGHT_PERMS.emptyReturnRequests.view, + FREIGHT_PERMS.warehouseInventory.view, + ], }, { label: "Register Full Containers", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx index faec5dc2a..3c2b487df 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx @@ -57,6 +57,11 @@ const money = (amount: number | null | undefined, currency: string | null | unde export default function EmptyReturnRequestsPage() { const { toast } = useToast(); const qc = useQueryClient(); + const { user } = useAuth(); + // Anyone who runs container returns can watch the queue; pricing and + // approving is its own permission, so show the buttons disabled rather than + // letting them fire into a 403. + const canReview = hasPermission(user, FREIGHT_PERMS.emptyReturnRequests.review); const [statusFilter, setStatusFilter] = useState(null); const [approving, setApproving] = useState(null); const [rejecting, setRejecting] = useState(null); @@ -206,10 +211,23 @@ export default function EmptyReturnRequestsPage() { cell: ({ row }) => row.original.status === "SUBMITTED" ? ( - - diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 85340b788..44a09450c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -17,6 +17,7 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { useFileViewer } from "@/hooks/useFileViewer"; import { bookingsService } from "@/services/bookings.service"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import type { Freight } from "@edr/types"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; @@ -182,23 +183,70 @@ export function ReadonlyBookingView({ // backend flag counts only unsigned SELF_HAUL handovers, so no status // heuristics are needed here. const canApproveDelivery = Boolean(booking.handoverAwaitingSignature); + // Delivery chosen on the contract only opens a last-mile request; EDR is + // committed to that leg once the request is approved (or a leg record + // exists). Until then the customer may still bring their own truck. Same + // rule as the API's `edrHaulsThisBooking`. Collection (the export leg) has + // no approval step, so the address alone decides it. + const hasLastMileChoice = + booking.tradeDirection !== "EXPORT" && !!booking.lastMileDeliveryAddress; + const lastMileRequests = useQuery({ + queryKey: ["booking-last-mile-requests", booking.id], + queryFn: () => lastMileRequestsService.listForBooking(booking.id), + enabled: hasLastMileChoice, + }); + const mileSummary = useQuery({ + queryKey: ["booking-mile-summary", booking.id], + queryFn: () => bookingsService.mileSummary(booking.id), + enabled: hasLastMileChoice, + }); + const lastMileCommitted = + hasLastMileChoice && + ((lastMileRequests.data ?? []).some((r) => r.status === "APPROVED") || + !!mileSummary.data?.lastMile); + const lastMileCheckPending = + hasLastMileChoice && (lastMileRequests.isPending || mileSummary.isPending); const usesCustomerTruck = booking.tradeDirection === "IMPORT" - ? !booking.lastMileDeliveryAddress + ? !lastMileCommitted : booking.tradeDirection === "EXPORT" ? !booking.firstMilePickupAddress - : !booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress; - const canAssignCustomerTruck = - booking.paymentStatus === "PAID" && - usesCustomerTruck && - (booking.tradeDirection === "IMPORT" - ? // Import self-haul: pickup trucks are assigned only after the train has - // arrived at the destination. - (status === "ARRIVED" || booking.trainScheduleStatus === "ARRIVED") - : // Export / domestic self-haul: delivery trucks are assigned only before - // the cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded - // (IN_TRANSIT and beyond) assignment is closed. - ["PAID", "TRUCK_ASSIGNED"].includes(status)); + : !booking.firstMilePickupAddress && !lastMileCommitted; + // Why truck assignment is closed, or null when it is open. The card renders + // either way — a customer who opens Logistics and finds nothing there cannot + // tell a missing feature from a stage they have not reached yet. + const truckBlockedReason = (() => { + if (lastMileCheckPending) { + return "Checking whether EDR last-mile delivery has been approved for this booking…"; + } + if (!usesCustomerTruck) { + return booking.tradeDirection !== "EXPORT" && lastMileCommitted + ? "EDR last-mile delivery for this booking has been approved, so no customer truck is needed." + : "EDR is handling first-mile pickup for this booking, so no customer truck is needed."; + } + if (booking.paymentStatus !== "PAID") { + return "Truck assignment opens once payment for this booking is confirmed."; + } + if (booking.tradeDirection === "IMPORT") { + // Import self-haul: pickup trucks are assigned only after the train has + // arrived at the destination. + return status === "ARRIVED" || booking.trainScheduleStatus === "ARRIVED" + ? null + : "Pickup trucks can be assigned once the train arrives at the destination."; + } + // Export / domestic self-haul: delivery trucks are assigned only before the + // cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded + // (IN_TRANSIT and beyond) assignment is closed. + return ["PAID", "TRUCK_ASSIGNED"].includes(status) + ? null + : "The cargo is already loaded onto the train — truck assignment is closed."; + })(); + // The customer asked for EDR delivery and can still self-haul instead — say + // so, because assigning a truck here makes that pending request unapprovable. + const truckNotice = + !truckBlockedReason && hasLastMileChoice && !lastMileCommitted + ? "You requested EDR last-mile delivery for this booking, and it has not been approved yet. Assigning your own truck here replaces that request — it can no longer be approved once a truck is on the booking." + : null; const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; // Customs (Path B) bookings are created AND rebooked by Global Logistics, not @@ -464,14 +512,16 @@ export function ReadonlyBookingView({ - + {/* Truck assignment leads; where the cargo currently sits is + supporting detail beneath it. */} + {})} + /> - {canAssignCustomerTruck && ( - {})} - /> - )} + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx index eec8183af..4fa315fdf 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx @@ -1,131 +1,237 @@ -import { useState } from "react"; -import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core"; -import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react"; -import { useMutation } from "@tanstack/react-query"; +import { useMemo, useRef, useState } from "react"; +import { + Alert, + Badge, + Button, + FileButton, + Group, + List, + Modal, + Stack, + Table, + Text, +} from "@mantine/core"; +import { AlertCircle, AlertTriangle, CheckCircle, Download, Upload } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Freight } from "@edr/types"; import { client } from "@/utils/api"; import { URL_CONSTANTS } from "@/constants/URLS"; -import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template"; +import { + downloadTruckAssignmentTemplate, + parseTruckAssignmentFile, + truckTemplateContext, + type ParsedTruckFile, +} from "@/utils/truck-assignment-template"; + +import type { BookingDetail } from "../booking-detail-types"; + +interface BulkTruckUploadResponse { + success: number; + failed: number; + errors: Array<{ index: number; row: number; truck: string; reason: string }>; +} interface BulkTruckUploadModalProps { opened: boolean; onClose: () => void; - bookingId: string; + booking: BookingDetail; + trucks: Freight.ICustomerTruck[]; onSuccess?: () => void; } +/** Long error lists are unreadable in a modal — show the first few and count the rest. */ +const MAX_LISTED_ERRORS = 8; + export function BulkTruckUploadModal({ opened, onClose, - bookingId, + booking, + trucks, onSuccess, }: BulkTruckUploadModalProps) { - const [file, setFile] = useState(null); - const [parsed, setParsed] = useState< - Array<{ - truckPlateNumber: string; - driverName: string; - truckType: string; - containerNumbers?: string[]; - }> - >([]); - const [parseError, setParseError] = useState(null); + const queryClient = useQueryClient(); + // `resetRef` lets the customer re-pick a corrected file with the same name — + // without it the input's onChange never fires the second time. + const resetFile = useRef<() => void>(null); + + const [fileName, setFileName] = useState(null); + const [parsed, setParsed] = useState({ rows: [], errors: [], rowNumbers: [] }); + const [result, setResult] = useState(null); + + const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]); + + const reset = () => { + resetFile.current?.(); + setFileName(null); + setParsed({ rows: [], errors: [], rowNumbers: [] }); + setResult(null); + }; const uploadMutation = useMutation({ mutationFn: async () => { - const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), { - trucks: parsed, - }); - return data; + const { data } = await client.post( + URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(booking.id), + { trucks: parsed.rows }, + ); + return ("data" in data ? data.data : data) as BulkTruckUploadResponse; }, - onSuccess: () => { + onSuccess: (response) => { + void queryClient.invalidateQueries({ queryKey: ["customer-trucks", booking.id] }); onSuccess?.(); - setFile(null); - setParsed([]); - onClose(); + // Only a clean run closes. A partial failure has to be shown, or the + // customer walks away believing all their trucks were created. + if (response.failed === 0) { + reset(); + onClose(); + return; + } + setResult(response); }, }); - const handleFileSelect = async (selectedFile: File | null) => { - if (!selectedFile) { - setFile(null); - setParsed([]); - setParseError(null); + const handleFile = async (selected: File | null) => { + setResult(null); + if (!selected) { + reset(); return; } - + setFileName(selected.name); try { - setParseError(null); - const trucks = await parseTruckAssignmentFile(selectedFile); - setFile(selectedFile); - setParsed(trucks); - } catch (err: any) { - setParseError(err.message || "Failed to parse Excel file"); - setFile(null); - setParsed([]); + setParsed(await parseTruckAssignmentFile(selected, ctx)); + } catch (err) { + setParsed({ + rows: [], + errors: [err instanceof Error ? err.message : "Could not read the file."], + rowNumbers: [], + }); } }; - const handleDownloadTemplate = () => { - generateTruckAssignmentTemplate("truck-assignments.xlsx"); - }; + const { rows, errors, rowNumbers } = parsed; + const listedErrors = errors.slice(0, MAX_LISTED_ERRORS); + const hiddenErrors = errors.length - listedErrors.length; + + /** Map a server error back to the spreadsheet row the customer actually sees. */ + const excelRowFor = (index: number, fallback: number) => rowNumbers[index] ?? fallback; + + const columnLabel = + ctx.shape === "CONTAINER" + ? "Containers" + : ctx.shape === "PER_ITEM" + ? `Quantity (${ctx.itemNoun})` + : "Planned tons"; return ( { + reset(); + onClose(); + }} + title="Bulk upload truck assignments" size="lg" centered > } color="blue"> - Download template, fill with truck data, upload Excel file to bulk-create truck assignments. + Download the template for this booking, fill in one row per truck, then upload it. + {ctx.shape === "CONTAINER" + ? " It lists this booking's containers and their sizes." + : ctx.remainingTons != null + ? ` ${ctx.remainingTons} t are still to be hauled.` + : ""} + + {(props) => ( + + )} + - } - /> - - {parseError && ( - } color="red" title="Parse Error"> - {parseError} + {errors.length > 0 && ( + } + color="red" + title="Import failed — fix the file and upload it again" + > + + {listedErrors.map((message) => ( + {message} + ))} + + {hiddenErrors > 0 && ( + + …and {hiddenErrors} more. + + )} )} - {parsed.length > 0 && ( + {result && result.failed > 0 && ( + } + color="orange" + title={`${result.success} truck(s) added, ${result.failed} rejected`} + > + + + + Row + Plate + Reason + + + + {result.errors.map((e) => ( + + {excelRowFor(e.index, e.row)} + {e.truck} + + {e.reason} + + + ))} + +
+
+ )} + + {rows.length > 0 && ( <>
- Preview ({parsed.length} trucks) + Preview ({rows.length} truck{rows.length !== 1 ? "s" : ""}) - Plate Number - Driver Name - Truck Type - Containers + Row + Plate number + Driver + Truck type + {columnLabel} - {parsed.map((truck, idx) => ( - + {rows.map((truck, idx) => ( + + + + {rowNumbers[idx]} + + {truck.truckPlateNumber} @@ -136,18 +242,21 @@ export function BulkTruckUploadModal({ {truck.truckType} - {truck.containerNumbers?.length ? ( + {ctx.shape === "CONTAINER" ? ( - {truck.containerNumbers.map((c) => ( + {(truck.containerNumbers ?? []).map((c) => ( {c} ))} - ) : ( - - — + ) : ctx.shape === "PER_ITEM" ? ( + + {truck.plannedQuantity} + {truck.plannedTons ? ` · ${truck.plannedTons} t` : ""} + ) : ( + {truck.plannedTons} t )} @@ -158,14 +267,14 @@ export function BulkTruckUploadModal({ - Ready to upload {parsed.length} truck(s) + Ready to upload {rows.length} truck{rows.length !== 1 ? "s" : ""} 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 9bf49cb2a..839b537dd 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 @@ -16,9 +16,20 @@ import { TextInput, } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { Freight } from "@edr/types"; -import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react"; -import { useState } from "react"; +import { CUSTOMER_TRUCK_TYPES, type Freight } from "@edr/types"; +import { + AlertTriangle, + CheckCircle2, + Clock, + Download, + Lock, + Pencil, + Plus, + Trash2, + Truck, + Upload, +} from "lucide-react"; +import { useMemo, useState } from "react"; import toast from "react-hot-toast"; import { api } from "@/services/api"; @@ -26,9 +37,14 @@ import { customerTrucksService } from "@/services/customer-trucks.service"; import { CardTitle, SectionCard } from "./layout"; import { BulkTruckUploadModal } from "./BulkTruckUploadModal"; -import { generateTruckAssignmentTemplate } from "@/utils/truck-assignment-template"; +import type { BookingDetail } from "../booking-detail-types"; +import { + downloadTruckAssignmentTemplate, + isTwentyFoot, + truckTemplateContext, +} from "@/utils/truck-assignment-template"; -const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; +const TRUCK_TYPES = [...CUSTOMER_TRUCK_TYPES]; // Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate // copies (Port Operations, Gate Security & Carrier) are always printed. @@ -64,9 +80,19 @@ const errorMessage = (error: unknown, fallback: string) => { export function CustomerTruckAssignmentCard({ booking, onAssigned, + blockedReason, + notice, }: { - booking: Freight.IBooking; + booking: BookingDetail; onAssigned: () => void; + /** + * Why assignment is closed right now, if it is. The card still renders — and + * still lists any trucks already assigned — with this shown in place of the + * form, rather than the whole card vanishing from the Logistics tab. + */ + blockedReason?: string | null; + /** Something the customer should know before assigning — shown above the form when it is open. */ + notice?: string | null; }) { const queryClient = useQueryClient(); const trucksKey = ["customer-trucks", booking.id]; @@ -86,6 +112,12 @@ export function CustomerTruckAssignmentCard({ const [error, setError] = useState(null); const [bulkModalOpen, setBulkModalOpen] = useState(false); + // What this booking hauls: containers, loose tonnage (PER_TON), or counted + // items (PER_ITEM — machinery, RoRo vehicles). Drives the form, the Excel + // template and its parser from one place. + const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]); + const isPerItem = ctx.shape === "PER_ITEM"; + // 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)), @@ -94,9 +126,20 @@ export function CustomerTruckAssignmentCard({ const editingOwn = new Set( (trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber), ); - const availableContainers = (booking.containerNumbers ?? []).filter( + const containerSizes = new Map(ctx.containers.map((c) => [c.number, c.size])); + const unassignedContainers = (booking.containerNumbers ?? []).filter( (n) => !assignedNumbers.has(n) || editingOwn.has(n), ); + // A 40ft fills the truck. Once one is picked, or two 20ft are, nothing more + // may be added — the API rejects it either way, so don't offer the choice. + const pickedHas40ft = containers.some((n) => !isTwentyFoot(containerSizes.get(n) ?? "")); + const availableContainers = unassignedContainers.filter((n) => { + if (containers.includes(n)) return true; + if (pickedHas40ft) return false; + // Something is already picked and it's 20ft — only another 20ft may join it. + if (containers.length > 0) return isTwentyFoot(containerSizes.get(n) ?? ""); + return true; + }); // Containers on the booking not yet assigned to any truck (independent of edit). const pendingAssignmentCount = (booking.containerNumbers ?? []).filter( (n) => !assignedNumbers.has(n), @@ -115,16 +158,12 @@ export function CustomerTruckAssignmentCard({ }; const startEdit = (t: Freight.ICustomerTruck) => { - const planned = t as Freight.ICustomerTruck & { - plannedTons?: number | string | null; - plannedQuantity?: number | null; - }; setPlateNumber(t.plateNumber ?? ""); setDriverName(t.driverName ?? ""); setTruckType(t.truckType ?? ""); setContainers((t.containers ?? []).map((c) => c.containerNumber)); - setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : ""); - setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : ""); + setPlannedTons(t.plannedTons != null ? Number(t.plannedTons) : ""); + setPlannedQty(t.plannedQuantity != null ? Number(t.plannedQuantity) : ""); setEditingId(t.id); setError(null); }; @@ -187,7 +226,13 @@ export function CustomerTruckAssignmentCard({ setError("Select 1 or 2 container numbers for this truck."); return; } - if (isBulk && plannedTons === "") { + // Counted cargo (machinery, RoRo vehicles) is committed by item count — + // tonnage is often unknown until the weighbridge, so it stays optional. + if (isBulk && isPerItem && plannedQty === "") { + setError(`Enter how many ${ctx.itemNoun} this truck will carry.`); + return; + } + if (isBulk && !isPerItem && plannedTons === "") { setError("Enter the tonnes this truck will haul."); return; } @@ -204,25 +249,27 @@ export function CustomerTruckAssignmentCard({ External Truck Assignment - - - - - {pendingAssignmentCount > 0 && ( + {!blockedReason && ( + + + + + )} + {!blockedReason && pendingAssignmentCount > 0 && ( {pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment @@ -306,6 +353,19 @@ export function CustomerTruckAssignmentCard({ )} + {/* Assignment is closed for now — say why, and keep the trucks above + visible rather than hiding the whole card. */} + {blockedReason ? ( + }> + {blockedReason} + + ) : ( + <> + {notice && ( + }> + {notice} + + )} {!isBulk && ( Truck capacity: assign either 1 x 40ft container or up to{' '} @@ -344,34 +404,39 @@ export function CustomerTruckAssignmentCard({ description="20ft: up to 2 per truck · 40ft: 1 per truck" required placeholder="Select container numbers" - data={availableContainers} + data={availableContainers.map((n) => { + const size = containerSizes.get(n); + return { value: n, label: size ? `${n} · ${size}` : n }; + })} value={containers} onChange={setContainers} - maxValues={2} + maxValues={pickedHas40ft ? 1 : 2} searchable nothingFoundMessage="No unassigned containers" /> )} {isBulk && ( { const total = Number(booking.cargoTotalWeightVgm) || 0; const assigned = trucks .filter((t) => t.id !== editingId) - .reduce((s, t) => { - const x = t as Freight.ICustomerTruck & { - netWeightTons?: number | string | null; - plannedTons?: number | string | null; - }; - return s + (Number(x.netWeightTons ?? x.plannedTons) || 0); - }, 0); + .reduce( + (s, t) => + s + + (Number( + (t as Freight.ICustomerTruck & { netWeightTons?: number | string | null }) + .netWeightTons ?? t.plannedTons, + ) || 0), + 0, + ); const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000); return total > 0 ? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining` : "Tonnage this truck hauls"; })()} - required + required={!isPerItem} min={0} value={plannedTons} onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))} @@ -379,9 +444,17 @@ export function CustomerTruckAssignmentCard({ )} {isBulk && ( setPlannedQty(v === "" ? "" : Number(v))} /> @@ -410,6 +483,8 @@ export function CustomerTruckAssignmentCard({ ) )} + + )} {trucks.length > 0 && ( @@ -464,7 +539,8 @@ export function CustomerTruckAssignmentCard({ setBulkModalOpen(false)} - bookingId={booking.id} + booking={booking} + trucks={trucks} onSuccess={() => { queryClient.invalidateQueries({ queryKey: trucksKey }); onAssigned(); diff --git a/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts index 7a0fef1cc..b3b712c6c 100644 --- a/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts +++ b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts @@ -1,108 +1,551 @@ -import * as XLSX from 'xlsx'; +import * as XLSX from "xlsx"; +import { + CUSTOMER_TRUCK_TYPES, + ISO_CONTAINER_NUMBER, + type Freight, +} from "@edr/types"; -export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void { - const data = [ - { - 'Truck Plate Number': '3-12345/67890', - 'Driver Name': 'John Doe', - 'Truck Type': 'Flatbed', - 'Container 1': 'MAEU1234567', - 'Container 2': 'HLXU7654321', - }, - { - 'Truck Plate Number': '3-98765/43210', - 'Driver Name': 'Jane Smith', - 'Truck Type': 'Flatbed', - 'Container 1': 'COSCO1111111', - 'Container 2': '', - }, - ]; +import type { BookingDetail } from "@/pages/bookings/BookingDetailPage/booking-detail-types"; - const instructions = [ - ['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'], - [], - ['Column', 'Required', 'Notes'], - ['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'], - ['Driver Name', 'Yes', 'Full name of truck driver'], - ['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'], - ['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'], - ['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'], - [], - ['CONTAINER RULES'], - ['- A 40ft container fills one truck (max 1 per truck)'], - ['- Two 20ft containers fit on one truck (max 2 per truck)'], - ['- No size mixing on same truck'], - ['- Containers must be from the booking'], - [], - ['Example Data Below →'], - ]; +/** + * Bulk self-haul truck assignment via Excel. + * + * The sheet a customer gets depends on what they booked. A container booking + * names the containers each truck carries; a bulk booking has no containers at + * all — the truck hauls loose tonnage — and a break-bulk/RoRo booking is counted + * in items (machinery units, vehicles), not tons. Emitting one fixed set of + * columns for all three is what made this unusable for anything but containers. + * + * Parsing follows the house pattern in + * `pages/contracts/new-shipment-form/container-excel.ts`: read the sheet as a + * grid, match headers fuzzily, report 1-based Excel row numbers, and return + * all-or-nothing so a half-valid file never posts. + */ - const wb = XLSX.utils.book_new(); +/** Two 20ft containers fit a truck bed; one 40ft fills it. Mirrors the API's `MAX_CONTAINERS_PER_TRUCK`. */ +const MAX_CONTAINERS_PER_TRUCK = 2; - // Instructions sheet - const wsInstructions = XLSX.utils.aoa_to_sheet(instructions); - wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }]; - XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions'); +export type TruckTemplateShape = "CONTAINER" | "PER_TON" | "PER_ITEM"; - // Data template sheet - const wsData = XLSX.utils.json_to_sheet(data, { - header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'], - }); - wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }]; - XLSX.utils.book_append_sheet(wb, wsData, 'Trucks'); - - XLSX.writeFile(wb, filename); +export interface TruckTemplateContainer { + number: string; + /** "20ft" / "40ft", or "" when the booking line never recorded one. */ + size: string; + /** Already riding another truck on this booking. */ + assigned: boolean; } -export function parseTruckAssignmentFile( +export interface TruckTemplateContext { + shape: TruckTemplateShape; + reference: string; + /** Human label for the cargo, e.g. "Machinery (MACHINERY)" or "Containerised cargo". */ + cargoLabel: string; + /** Unit noun for PER_ITEM cargo — "machinery units", "vehicles", "items". */ + itemNoun: string; + containers: TruckTemplateContainer[]; + /** Bulk only: tonnage still to be hauled, when the booking declares a total. */ + remainingTons: number | null; +} + +/** A 40ft (or an unrecorded size, treated as one) fills the bed and travels alone. */ +export function isTwentyFoot(size: string): boolean { + return size.includes("20"); +} + +/** + * PER_ITEM cargo is counted in pieces, and the piece has a name the customer + * recognises. RoRo bookings ride in as TRUCK / AUTOMOBILE / CARS. + */ +function itemNounFor(code: string | undefined, name: string | undefined): string { + switch ((code ?? "").toUpperCase()) { + case "TRUCK": + case "AUTOMOBILE": + case "CARS": + case "RORO": + return "vehicles"; + case "MACHINERY": + return "machinery units"; + default: + return name ? `${name.trim().toLowerCase()} items` : "items"; + } +} + +/** + * Everything the template and the parser need about one booking. Derived from + * data the detail endpoint already returns — no extra request. + */ +export function truckTemplateContext( + booking: BookingDetail, + trucks: Freight.ICustomerTruck[] = [], +): TruckTemplateContext { + const assigned = new Set( + trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)), + ); + + // Container size lives on the booking_container LINE; the physical numbers + // live on its units. Walk both to get number → size. + const containers: TruckTemplateContainer[] = []; + for (const line of booking.bookingContainers ?? []) { + const size = + line.containerSize?.trim() || + (line.containerType?.sizeFt ? `${line.containerType.sizeFt}ft` : ""); + for (const unit of line.units ?? []) { + if (!unit.containerNumber) continue; + containers.push({ + number: unit.containerNumber, + size, + assigned: assigned.has(unit.containerNumber), + }); + } + } + // Fall back to the flat list when the line relation didn't come through — the + // numbers are still usable, we just can't police sizes client-side. + if (containers.length === 0) { + for (const number of booking.containerNumbers ?? []) { + containers.push({ number, size: "", assigned: assigned.has(number) }); + } + } + + const isContainer = String(booking.freightType) === "CONTAINER"; + const unit = booking.cargoType?.unitOfMeasure; + const shape: TruckTemplateShape = isContainer + ? "CONTAINER" + : unit === "PER_ITEM" + ? "PER_ITEM" + : // PER_TON, NUMBER_OF_WAGONS and unset all haul tonnage by truck. + "PER_TON"; + + const cargoLabel = isContainer + ? "Containerised cargo" + : booking.cargoType?.cargoTypeName + ? `${booking.cargoType.cargoTypeName.trim()}${booking.cargoType.code ? ` (${booking.cargoType.code})` : ""}` + : "Bulk cargo"; + + // Planned tonnage already committed to live trucks draws the total down; the + // API applies the same rule on every add. + const totalTons = Number(booking.cargoTotalWeightVgm ?? 0); + const committed = trucks.reduce((sum, t) => sum + Number(t.plannedTons ?? 0), 0); + const remainingTons = + !isContainer && totalTons > 0 + ? Math.max(0, Math.round((totalTons - committed) * 1000) / 1000) + : null; + + return { + shape, + reference: booking.reference ?? "", + cargoLabel, + itemNoun: itemNounFor(booking.cargoType?.code, booking.cargoType?.cargoTypeName), + containers, + remainingTons, + }; +} + +const PLATE_HEADER = "Truck Plate Number"; +const DRIVER_HEADER = "Driver Name"; +const TYPE_HEADER = "Truck Type"; +const CONTAINER_1_HEADER = "Container 1"; +const CONTAINER_2_HEADER = "Container 2"; +const TONS_HEADER = "Planned Tons"; +const QUANTITY_HEADER = "Planned Quantity"; + +function headersFor(shape: TruckTemplateShape): string[] { + const base = [PLATE_HEADER, DRIVER_HEADER, TYPE_HEADER]; + if (shape === "CONTAINER") return [...base, CONTAINER_1_HEADER, CONTAINER_2_HEADER]; + if (shape === "PER_ITEM") return [...base, QUANTITY_HEADER, TONS_HEADER]; + return [...base, TONS_HEADER]; +} + +/** Sample rows built from the booking's own data, so the customer edits rather than invents. */ +function sampleRowsFor(ctx: TruckTemplateContext): Array> { + const type = CUSTOMER_TRUCK_TYPES[0]; + + if (ctx.shape === "CONTAINER") { + const free = ctx.containers.filter((c) => !c.assigned); + if (free.length === 0) { + return [["3-12345 ET", "Abebe Kebede", type, "", ""]]; + } + const rows: Array> = []; + let i = 0; + let plate = 12345; + while (i < free.length) { + const first = free[i]; + // Pair only two 20ft; a 40ft (or an unknown size) takes the truck alone. + const second = + isTwentyFoot(first.size) && free[i + 1] && isTwentyFoot(free[i + 1].size) + ? free[i + 1] + : null; + rows.push([ + `3-${plate++} ET`, + "Abebe Kebede", + type, + first.number, + second?.number ?? "", + ]); + i += second ? 2 : 1; + if (rows.length >= 5) break; + } + return rows; + } + + const tons = ctx.remainingTons && ctx.remainingTons > 0 ? Math.min(30, ctx.remainingTons) : 30; + if (ctx.shape === "PER_ITEM") { + return [["3-12345 ET", "Abebe Kebede", type, 2, tons]]; + } + return [["3-12345 ET", "Abebe Kebede", type, tons]]; +} + +function instructionsFor(ctx: TruckTemplateContext): Array> { + const rows: Array> = [ + ["TRUCK ASSIGNMENT — BULK UPLOAD"], + [], + ["Booking", ctx.reference], + ["Cargo", ctx.cargoLabel], + [], + ["Fill in the 'Trucks' sheet. One row per truck. Do not rename the headers."], + [], + ["Column", "Required", "Notes"], + [PLATE_HEADER, "Yes", "The truck's plate, as written on the vehicle."], + [DRIVER_HEADER, "Yes", "Full name of the driver."], + [TYPE_HEADER, "Yes", `One of: ${CUSTOMER_TRUCK_TYPES.join(", ")}`], + ]; + + if (ctx.shape === "CONTAINER") { + rows.push( + [CONTAINER_1_HEADER, "Yes", "A container number from this booking — see the 'Containers' sheet."], + [CONTAINER_2_HEADER, "No", "Only when pairing two 20ft containers on one truck."], + [], + ["TRUCK CAPACITY"], + ["One 40ft container fills a truck and travels alone."], + ["Two 20ft containers may share one truck."], + ["Never mix a 40ft and a 20ft on the same truck."], + ["Each container may be assigned to exactly one truck."], + ); + } else if (ctx.shape === "PER_ITEM") { + rows.push( + [QUANTITY_HEADER, "Yes", `Whole number of ${ctx.itemNoun} on this truck.`], + [TONS_HEADER, "No", "Weight in tonnes, if known."], + [], + ["This cargo is counted in items, not containers — leave containers out entirely."], + ); + } else { + rows.push( + [TONS_HEADER, "Yes", "Tonnes this truck will haul. Decimals allowed."], + [], + ["This is bulk cargo — the truck hauls loose tonnage and is weighed on exit."], + ); + } + + if (ctx.remainingTons != null) { + rows.push([], ["Tonnage still to be hauled", `${ctx.remainingTons} t`]); + } + + return rows; +} + +/** Build and download the template for this booking. */ +export function downloadTruckAssignmentTemplate( + ctx: TruckTemplateContext, + filename?: string, +): void { + const workbook = XLSX.utils.book_new(); + + const instructions = XLSX.utils.aoa_to_sheet(instructionsFor(ctx)); + instructions["!cols"] = [{ wch: 24 }, { wch: 12 }, { wch: 62 }]; + XLSX.utils.book_append_sheet(workbook, instructions, "Instructions"); + + const headers = headersFor(ctx.shape); + const trucks = XLSX.utils.aoa_to_sheet([headers, ...sampleRowsFor(ctx)]); + trucks["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) })); + XLSX.utils.book_append_sheet(workbook, trucks, "Trucks"); + + if (ctx.shape === "CONTAINER") { + const reference = XLSX.utils.aoa_to_sheet([ + ["Container Number", "Size", "Status"], + ...ctx.containers.map((c) => [ + c.number, + c.size || "unknown", + c.assigned ? "Already on a truck" : "Available", + ]), + ]); + reference["!cols"] = [{ wch: 20 }, { wch: 10 }, { wch: 20 }]; + XLSX.utils.book_append_sheet(workbook, reference, "Containers"); + } + + XLSX.writeFile( + workbook, + filename ?? `truck-assignments-${ctx.reference || "booking"}.xlsx`, + ); +} + +/** Normalised header key — tolerates case, spaces, punctuation and stray units. */ +function headerKey(raw: string): string { + return String(raw ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +/** + * Map each template column to the index it occupies in the uploaded sheet. + * Matching is by normalised substring so "Planned Tons (t)" still resolves; + * the two container columns are matched on their digit so "Container 1" can + * never be captured by the looser "container" test. + */ +function resolveColumns(headerRow: string[]): Record { + const keys = headerRow.map(headerKey); + const find = (...candidates: string[]): number => { + for (const candidate of candidates) { + const index = keys.findIndex((k) => k.includes(candidate)); + if (index >= 0) return index; + } + return -1; + }; + return { + plate: find("plate", "truckplate"), + driver: find("driver"), + type: find("trucktype", "type"), + container1: find("container1", "containerone"), + container2: find("container2", "containertwo"), + tons: find("plannedtons", "tons", "weight"), + quantity: find("plannedquantity", "quantity", "count"), + }; +} + +const TRUCK_TYPE_LIST = CUSTOMER_TRUCK_TYPES.join(", "); + +/** Case-insensitive match onto the canonical spelling the API expects. */ +function canonicalTruckType(raw: string): string | null { + const needle = raw.trim().toLowerCase(); + return CUSTOMER_TRUCK_TYPES.find((t) => t.toLowerCase() === needle) ?? null; +} + +export interface ParsedTruckFile { + rows: Freight.AddCustomerTruckPayload[]; + errors: string[]; + /** Excel row number each parsed row came from, positionally aligned with `rows`. */ + rowNumbers: number[]; +} + +/** + * Parse an uploaded sheet against this booking. Returns every problem at once — + * a customer fixing a 30-row file should not discover the mistakes one upload at + * a time — and returns no rows at all unless the whole file is clean. + */ +export async function parseTruckAssignmentFile( file: File, -): Promise< - Array<{ - truckPlateNumber: string; - driverName: string; - truckType: string; - containerNumbers?: string[]; - }> -> { - return new Promise((resolve, reject) => { - const reader = new FileReader(); + ctx: TruckTemplateContext, +): Promise { + const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" }); + const sheet = + workbook.Sheets["Trucks"] ?? + Object.values(workbook.Sheets).find((s) => s !== workbook.Sheets["Instructions"]) ?? + Object.values(workbook.Sheets)[0]; - reader.onload = (e) => { - try { - const data = e.target?.result as ArrayBuffer; - const wb = XLSX.read(data, { type: 'array' }); - const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0]; + if (!sheet) { + return { rows: [], errors: ["The file has no sheets."], rowNumbers: [] }; + } - if (!wsData) { - reject(new Error('No data sheet found in Excel file')); - return; - } + const grid = XLSX.utils.sheet_to_json(sheet, { + header: 1, + raw: false, + defval: "", + }); - const jsonData = XLSX.utils.sheet_to_json(wsData) as Array>; + // The header is the first row that names the plate column — customers add + // titles and notes above the table. + const headerIndex = grid.findIndex((row) => + row.some((cell) => headerKey(cell).includes("plate")), + ); + if (headerIndex < 0) { + return { + rows: [], + errors: [ + `Could not find the "${PLATE_HEADER}" column. Use the downloaded template and keep its headers.`, + ], + rowNumbers: [], + }; + } - const trucks = jsonData.map((row) => { - const containers = [ - row['Container 1'], - row['Container 2'], - ] - .filter((c) => c && c.trim()) - .map((c) => c.trim().toUpperCase()); + const columns = resolveColumns(grid[headerIndex]); + const errors: string[] = []; + const rows: Freight.AddCustomerTruckPayload[] = []; + const rowNumbers: number[] = []; - return { - truckPlateNumber: row['Truck Plate Number']?.trim() || '', - driverName: row['Driver Name']?.trim() || '', - truckType: row['Truck Type']?.trim() || '', - containerNumbers: containers.length > 0 ? containers : undefined, - }; - }); + const containerSizes = new Map(ctx.containers.map((c) => [c.number, c.size])); + const alreadyAssigned = new Set( + ctx.containers.filter((c) => c.assigned).map((c) => c.number), + ); + const plateSeen = new Map(); + const containerSeen = new Map(); + let tonsInFile = 0; - resolve(trucks); - } catch (error) { - reject(error); - } + for (let i = headerIndex + 1; i < grid.length; i++) { + const row = grid[i]; + const rowNo = i + 1; // 1-based, as shown in Excel + const cell = (key: string): string => { + const index = columns[key]; + return index >= 0 ? String(row[index] ?? "").trim() : ""; }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsArrayBuffer(file); - }); + const plate = cell("plate"); + const driver = cell("driver"); + const rawType = cell("type"); + + // A trailing blank row is normal, not an error. + if (!plate && !driver && !rawType && row.every((c) => !String(c ?? "").trim())) { + continue; + } + + let rowOk = true; + if (!plate) { + errors.push(`Row ${rowNo}: truck plate number is required.`); + rowOk = false; + } + if (!driver) { + errors.push(`Row ${rowNo}: driver name is required.`); + rowOk = false; + } + + const truckType = canonicalTruckType(rawType); + if (!truckType) { + errors.push( + `Row ${rowNo}: truck type "${rawType || "—"}" is not accepted. Use one of: ${TRUCK_TYPE_LIST}.`, + ); + rowOk = false; + } + + const plateKey = plate.toUpperCase(); + if (plate) { + const seenAt = plateSeen.get(plateKey); + if (seenAt) { + errors.push(`Row ${rowNo}: plate ${plate} already appears on row ${seenAt}.`); + rowOk = false; + } else { + plateSeen.set(plateKey, rowNo); + } + } + + const payload: Freight.AddCustomerTruckPayload = { + truckPlateNumber: plateKey, + driverName: driver, + truckType: truckType ?? "", + }; + + if (ctx.shape === "CONTAINER") { + const numbers = [cell("container1"), cell("container2")] + .map((n) => n.trim().toUpperCase()) + .filter(Boolean); + + if (numbers.length === 0) { + errors.push(`Row ${rowNo}: at least one container number is required.`); + rowOk = false; + } + if (numbers.length > MAX_CONTAINERS_PER_TRUCK) { + errors.push(`Row ${rowNo}: a truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers.`); + rowOk = false; + } + if (numbers.length === 2 && numbers[0] === numbers[1]) { + errors.push(`Row ${rowNo}: container ${numbers[0]} is listed twice on the same truck.`); + rowOk = false; + } + + for (const number of numbers) { + if (!ISO_CONTAINER_NUMBER.test(number)) { + errors.push( + `Row ${rowNo}: container "${number}" is not a valid ISO number (four letters then seven digits, e.g. ABCD1234567).`, + ); + rowOk = false; + continue; + } + if (ctx.containers.length > 0 && !containerSizes.has(number)) { + errors.push(`Row ${rowNo}: container ${number} is not on this booking.`); + rowOk = false; + continue; + } + if (alreadyAssigned.has(number)) { + errors.push(`Row ${rowNo}: container ${number} is already loaded onto another truck.`); + rowOk = false; + continue; + } + const seenAt = containerSeen.get(number); + if (seenAt) { + errors.push(`Row ${rowNo}: container ${number} is already used on row ${seenAt}.`); + rowOk = false; + continue; + } + containerSeen.set(number, rowNo); + } + + // Pairing is allowed only when both are explicitly 20ft — a 40ft, or a + // container whose size was never recorded, fills the bed on its own. Same + // rule the API enforces in `assertTruckLoad`. + if (numbers.length === 2) { + const sizes = numbers.map((n) => containerSizes.get(n) ?? ""); + if (sizes.some((size) => !isTwentyFoot(size))) { + errors.push( + `Row ${rowNo}: a truck carries either one 40ft container or two 20ft containers — ${numbers + .map((n, idx) => `${n} (${sizes[idx] || "size unknown"})`) + .join(" and ")} cannot share one.`, + ); + rowOk = false; + } + } + + payload.containerNumbers = numbers; + } else { + const tonsRaw = cell("tons"); + const tons = Number(tonsRaw); + const quantityRaw = cell("quantity"); + const quantity = Number(quantityRaw); + + if (ctx.shape === "PER_ITEM") { + if (!quantityRaw || !Number.isInteger(quantity) || quantity <= 0) { + errors.push( + `Row ${rowNo}: planned quantity "${quantityRaw || "—"}" must be a whole number of ${ctx.itemNoun} greater than 0.`, + ); + rowOk = false; + } else { + payload.plannedQuantity = quantity; + } + if (tonsRaw) { + if (Number.isNaN(tons) || tons <= 0) { + errors.push(`Row ${rowNo}: planned tons "${tonsRaw}" must be a number greater than 0.`); + rowOk = false; + } else { + payload.plannedTons = tons; + tonsInFile += tons; + } + } + } else { + if (!tonsRaw || Number.isNaN(tons) || tons <= 0) { + errors.push( + `Row ${rowNo}: planned tons "${tonsRaw || "—"}" must be a number greater than 0.`, + ); + rowOk = false; + } else { + payload.plannedTons = tons; + tonsInFile += tons; + } + } + } + + if (rowOk) { + rows.push(payload); + rowNumbers.push(rowNo); + } + } + + if ( + ctx.remainingTons != null && + tonsInFile > ctx.remainingTons + 0.001 // numeric(14,3) — tolerate float drift + ) { + errors.push( + `The file plans ${Math.round(tonsInFile * 1000) / 1000} t but only ${ctx.remainingTons} t are left to haul on this booking.`, + ); + } + + if (rows.length === 0 && errors.length === 0) { + errors.push("The sheet has no truck rows below the header."); + } + + return errors.length > 0 + ? { rows: [], errors, rowNumbers: [] } + : { rows, errors: [], rowNumbers }; } diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 6e4f7c6da..c0ba79edd 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -648,6 +648,25 @@ export interface ICustomerTruckContainer { containerNumber: string; } +/** + * The truck-type vocabulary a customer picks from when self-hauling. The API + * validates against this exact list (`@IsIn`), the portal's dropdown renders it, + * and the bulk-upload template documents it — all three read this constant so a + * value the customer can type can never be one the API rejects. + */ +export const CUSTOMER_TRUCK_TYPES = [ + "Flatbed", + "Container Chassis", + "Lowboy", + "Box Truck", + "Tipper", +] as const; + +export type CustomerTruckType = (typeof CUSTOMER_TRUCK_TYPES)[number]; + +/** ISO 6346 container number: four letters then seven digits, e.g. ABCD1234567. */ +export const ISO_CONTAINER_NUMBER = /^[A-Z]{4}\d{7}$/; + /** A customer self-haul truck on a booking, carrying 1–2 containers. */ export interface ICustomerTruck { id: string; @@ -659,6 +678,10 @@ export interface ICustomerTruck { arrivedAt?: string | null; departedAt?: string | null; containers?: ICustomerTruckContainer[]; + /** Bulk: planned tonnage this truck hauls. `numeric` — serialises as a string. */ + plannedTons?: number | string | null; + /** Bulk PER_ITEM: planned item/piece count on this truck. */ + plannedQuantity?: number | null; } /** Payload to add a customer self-haul truck (1–2 container numbers). */ @@ -666,7 +689,12 @@ export interface AddCustomerTruckPayload { truckPlateNumber: string; driverName: string; truckType: string; - containerNumbers: string[]; + /** Container bookings only — bulk trucks haul loose tonnage instead. */ + containerNumbers?: string[]; + /** Bulk: planned tonnage, drawn down against the booking's declared VGM. */ + plannedTons?: number; + /** Bulk PER_ITEM: planned item/piece count. */ + plannedQuantity?: number; } export interface IBooking extends BaseEntity {