Files
edr-platform/apps/edr-freight-api/src/common/mile-haulage.util.ts
hager 29c1a21b68 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>
2026-09-02 20:47:18 +00:00

103 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** The booking fields that decide who hauls the road legs. */
export interface MileHaulageRow {
tradeDirection: string | null;
/** `first_mile_pickup_address` — set when the customer asked EDR to collect. */
firstMile: string | null;
/** `last_mile_delivery_address` — set when the customer asked EDR to deliver. */
lastMile: string | null;
}
/**
* Whether the customer bought the EDR road leg that matters for their direction:
* delivery at the end of an import, collection at the start of an export. A
* DOMESTIC booking can use either, so either one counts.
*
* The address is the signal because it is the only per-booking record of the
* choice. `service_types.includes_first_mile` / `includes_last_mile` cannot be
* used — every service type ships with both set to true, so reading them would
* mean every booking uses EDR haulage and none could ever self-haul.
*/
export function usesEdrMileService(booking: MileHaulageRow): boolean {
const hasFirstMile = Boolean(booking.firstMile?.trim());
const hasLastMile = Boolean(booking.lastMile?.trim());
switch (booking.tradeDirection) {
case 'IMPORT':
return hasLastMile;
case 'EXPORT':
return hasFirstMile;
default:
return hasFirstMile || hasLastMile;
}
}
/**
* EDR haulage and a customer's own truck are alternatives, never both. Whichever
* side is being set up, it has to reject the other — a guard on only one side
* lets the two paths open on the same booking, each unaware of the other.
*/
export const SELF_HAUL_CONFLICT_MESSAGE =
'This booking is delivered by the customers 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, 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
* neither has no business in the first/last-mile queues at all.
*/
export const NO_MILE_SERVICE_MESSAGE =
'This booking did not select first/last mile delivery on its contract, so it cannot be assigned an EDR mile leg.';