From d6f730fa3e442240b6cb85bf54e7f23a8f05a7bd Mon Sep 17 00:00:00 2001 From: hager Date: Wed, 2 Sep 2026 12:21:29 +0000 Subject: [PATCH 1/2] fix(empty-return-requests): show the queue to the staff who run returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu item was gated on the brand-new empty_return_requests:view key, which on the dev DB is held by one position (Operations Chief) — so the Director and Dispatcher who already work Container Returns two rows above it never saw the entry at all. Viewing the queue now accepts that key OR warehouse_inventory:view, the key the rest of the Imports menu runs on, in the sidebar, the route guard and the API's read routes alike. Approving and rejecting stay on the review key on their own, and the buttons come up disabled (with the reason) for a viewer who lacks it rather than firing into a 403. The dedicated keys are still there to hand out per position whenever that call gets made. Co-Authored-By: Claude Opus 5 (1M context) --- .../backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 0a3765d01..faec5dc2a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx @@ -18,6 +18,8 @@ import { } from "@mantine/core"; import { DataTable, type ColumnDef } from "@edr/ui-common"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { PageContainer, PageHeader } from "@/components/page"; import ListControls from "@/components/common/ListControls"; import { extractErrorMessage } from "@/components/warehouses/options"; From 29c1a21b6812cdf9a0dca5b38b3961e89da12d65 Mon Sep 17 00:00:00 2001 From: hager Date: Wed, 2 Sep 2026 20:45:58 +0000 Subject: [PATCH 2/2] feat(bookings): make customer self-haul assignment work end to end The customer truck card on the booking's Logistics tab is now usable for every cargo type, with an Excel template and bulk upload that match what the API enforces. Excel template and bulk upload - The template is built per booking. Container bookings get the booking's own containers with their sizes on a reference sheet and sample rows paired 20ft+20ft; bulk (PER_TON) bookings get a Planned Tons column; counted cargo (PER_ITEM: machinery, RoRo vehicles) gets Planned Quantity. - Parsing validates the whole file before anything is posted: plate, driver, truck type, ISO container numbers, containers on the booking, duplicates across rows, containers already on a truck, and the capacity rule (one 40ft alone, or two 20ft). Errors quote the Excel row number. - The bulk DTO reuses AddCustomerTruckDto instead of a drifted copy that lacked plannedTons/plannedQuantity, so bulk cargo can be uploaded at all. - A partial failure is reported per row in the modal instead of closing it as if every truck had been created. Capacity rule in the form - The container picker shows sizes and stops offering a second container once a 40ft is picked, or a 40ft once a 20ft is picked. - PER_ITEM cargo commits by item count; tonnage becomes optional. Shared vocabulary - CUSTOMER_TRUCK_TYPES and ISO_CONTAINER_NUMBER move to @edr/types so the API validators, the dropdown and the template read one list. When assignment is open - The card always renders and states why assignment is closed (EDR haulage, unpaid, train not arrived, cargo already loaded) rather than vanishing. - Self-haul is blocked only once EDR has committed to the road leg: an approved last-mile request or an existing last-mile leg. A delivery address whose request is still awaiting confirmation, submitted or rejected no longer blocks the customer from bringing their own truck. Collection on exports has no approval step and still blocks as before. Both the multi-truck service and the legacy single-truck path read the same SQL fragment (edrHaulsThisBooking), and the portal applies the same rule with a notice that assigning a truck makes the pending request unapprovable. The last-mile side already refuses to approve a booking carrying a customer truck, so the two paths stay mutually exclusive. Verified: EXPLAIN on the new SQL against edr_dev, type-check clean for freight-api and portal, 12 util specs pass (5 new). Backoffice type-check fails only in pre-existing user-management files. Co-Authored-By: Claude Fable 5.1 --- .../src/common/mile-haulage.util.spec.ts | 75 ++- .../src/common/mile-haulage.util.ts | 55 +- .../modules/bookings/bookings.controller.ts | 3 +- .../src/modules/bookings/bookings.service.ts | 34 +- .../bookings/customer-truck.service.ts | 43 +- .../bookings/dto/add-customer-truck.dto.ts | 4 +- .../bookings/dto/bulk-customer-truck.dto.ts | 67 +- .../dto/customer-truck-assignment.dto.ts | 16 +- .../bookings/dto/depart-customer-truck.dto.ts | 3 +- .../bookings/dto/load-customer-truck.dto.ts | 3 +- .../empty-return-requests.controller.ts | 19 +- apps/edr-freight-web/backoffice/src/App.tsx | 5 +- .../components/layout/sidebar-sections.tsx | 8 +- .../warehouses/EmptyReturnRequestsPage.tsx | 22 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 90 ++- .../components/BulkTruckUploadModal.tsx | 257 ++++--- .../CustomerTruckAssignmentCard.tsx | 170 +++-- .../src/utils/truck-assignment-template.ts | 625 +++++++++++++++--- packages/types/src/freight/index.ts | 30 +- 19 files changed, 1197 insertions(+), 332 deletions(-) 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 {