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 <noreply@anthropic.com>
This commit is contained in:
hager
2026-09-02 20:45:58 +00:00
parent d6f730fa3e
commit 29c1a21b68
19 changed files with 1197 additions and 332 deletions

View File

@@ -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 12 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 (12 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 {