feat(bookings): surface cargo declared on the shipment request

A GENERAL + customs contract does not let the customer book directly: they
submit a shipment request, and initiateForShipmentRequest opens a BARE booking
from it — "the request itself carries the quantities; the instance carries
none". Between initiation and completeUnderContract the booking legitimately
holds no cargo, so the export reported 0 containers for a customer who had
declared, say, 2 x 20FT. 23 bookings on dev data are in that state.

Adds two columns and one filter reading booking_requests.requested_lines:
- "Requested cargo" — the declared lines as text ("2 x 20FT"), handling the
  bulk shape too (tons / item count), not only containers.
- "Requested containers" — the declared box count, with a matching min/max
  filter on the list and the export.

Deliberately a separate column rather than a fallback inside the real container
count: a declared 2 x 20FT is a request, not two boxes on a booking, and
merging them would overstate operational totals. The two compose instead —
Containers = 0 AND Requested containers >= 1 is exactly the set awaiting
completion after clearance.

requested_lines is free-form jsonb, so the container array is guarded by
jsonb_typeof before jsonb_array_elements; one malformed row would otherwise
500 the whole list.
This commit is contained in:
Nathnael
2026-08-28 11:23:52 +00:00
parent aa3100d161
commit df8e10e041
8 changed files with 162 additions and 0 deletions

View File

@@ -98,3 +98,51 @@ export function bookingContainerVgmSql(alias = 'b'): string {
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL)`;
}
/**
* Cargo the customer declared on the SHIPMENT REQUEST behind a booking, which
* is not the same fact as cargo on the booking itself.
*
* On a GENERAL + customs contract the customer cannot book directly: they
* submit a request (day + quantities), and `initiateForShipmentRequest` opens a
* BARE instance from it — "the request itself carries the quantities; the
* instance carries none". So between initiation and `completeUnderContract` the
* booking legitimately holds no cargo while the customer's declared quantities
* sit on `booking_requests.requested_lines`.
*
* Kept in its own column rather than folded into the real container count: a
* declared 2 × 20FT is a request, not two boxes on a booking, and merging the
* two would overstate operational totals.
*/
const REQUESTED_CONTAINER_LINES = `jsonb_array_elements(
CASE WHEN jsonb_typeof(br.requested_lines->'containers') = 'array'
THEN br.requested_lines->'containers'
ELSE '[]'::jsonb END)`;
/** Human-readable declared cargo: "2 × 20FT", "12 t", "40 items". */
export function bookingRequestedCargoSql(alias = 'b'): string {
return `(SELECT COALESCE(
(SELECT string_agg((l->>'quantity') || ' × ' || upper(l->>'containerSize'), ', '
ORDER BY l->>'containerSize')
FROM ${REQUESTED_CONTAINER_LINES} AS l),
NULLIF(br.requested_lines->'bulk'->>'cargoWeightTons', '') || ' t',
NULLIF(br.requested_lines->'bulk'->>'itemCount', '') || ' items')
FROM freight.booking_requests br
WHERE br.created_booking_id = ${alias}.id
AND br.deleted_at IS NULL
ORDER BY br.created_at DESC
LIMIT 1)`;
}
/**
* Boxes declared on the shipment request. Pairs with the real container count:
* `Containers = 0` AND `Requested containers >= 1` is exactly the set awaiting
* completion.
*/
export function bookingRequestedContainerCountSql(alias = 'b'): string {
return `(SELECT COALESCE(SUM((l->>'quantity')::int), 0)
FROM freight.booking_requests br
CROSS JOIN LATERAL ${REQUESTED_CONTAINER_LINES} AS l
WHERE br.created_booking_id = ${alias}.id
AND br.deleted_at IS NULL)`;
}