mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
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.
149 lines
6.9 KiB
TypeScript
149 lines
6.9 KiB
TypeScript
/**
|
||
* What the customer said is IN the booking, per freight type — the list
|
||
* filter, the summary and the export all read this one expression so the
|
||
* column, the pill and the sheet can never disagree.
|
||
*
|
||
* BULK the commodity picked from the cargo tree (`cargo_types`), falling
|
||
* back to the free-text description for a bare group or a legacy row
|
||
* that has no commodity.
|
||
* CONTAINER the wizard asks for no description at all — VGM and contents are
|
||
* captured later in operations — so the closest thing to the
|
||
* customer's own words is the container lines they entered:
|
||
* "2 × 40FT, 1 × 20FT".
|
||
*
|
||
* Containers are checked FIRST: a container booking has no `cargo_type_id`
|
||
* (the API rejects one), so the order only matters for a mixed legacy row,
|
||
* where the physical lines are the better answer.
|
||
*/
|
||
export function bookingContentSql(alias = 'b'): string {
|
||
return `COALESCE(
|
||
NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', '
|
||
ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code)
|
||
FROM freight.booking_container bc
|
||
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
|
||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''),
|
||
NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt
|
||
WHERE cgt.id = ${alias}.cargo_type_id), ''),
|
||
NULLIF(${alias}.cargo_free_text, ''))`;
|
||
}
|
||
|
||
/**
|
||
* Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter
|
||
* matches every commodity beneath it — the same group→commodity drill-down the
|
||
* booking wizard offers, read back. Recursive because `cargo_types` is an
|
||
* arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels.
|
||
*/
|
||
export const CARGO_TYPE_SUBTREE_SQL = `(
|
||
WITH RECURSIVE sub AS (
|
||
SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId
|
||
UNION ALL
|
||
SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id
|
||
)
|
||
SELECT id FROM sub)`;
|
||
|
||
/**
|
||
* Contains-match over every part of the content a customer can type or pick:
|
||
* their own description, the commodity's name, and the container types on the
|
||
* booking. Bind `:cargoText` already wrapped in `%`.
|
||
*/
|
||
export function bookingContentMatchSql(alias = 'b'): string {
|
||
return `(${alias}.cargo_free_text ILIKE :cargoText
|
||
OR EXISTS (SELECT 1 FROM freight.cargo_types cgt
|
||
WHERE cgt.id = ${alias}.cargo_type_id
|
||
AND cgt.cargo_type_name ILIKE :cargoText)
|
||
OR EXISTS (SELECT 1 FROM freight.booking_container bc
|
||
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
|
||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL
|
||
AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`;
|
||
}
|
||
|
||
/**
|
||
* Containers on a booking, as a count of physical boxes — `booking_container`
|
||
* is one row PER LINE with a `quantity`, not one row per box, so this sums the
|
||
* quantity rather than counting rows.
|
||
*
|
||
* `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one
|
||
* number filter answer both "10 containers in total" and "10 forty-footers":
|
||
* the count filter reads the container-type filter when one is set, and counts
|
||
* every type when it is not.
|
||
*/
|
||
export function bookingContainerCountSql(alias = 'b', scopedToType = false): string {
|
||
return `(SELECT COALESCE(SUM(bc.quantity), 0)
|
||
FROM freight.booking_container bc
|
||
WHERE bc.booking_id = ${alias}.id
|
||
AND bc.deleted_at IS NULL${
|
||
scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : ''
|
||
})`;
|
||
}
|
||
|
||
/** Bookings carrying at least one line of `:containerTypeId`. */
|
||
export function bookingHasContainerTypeSql(alias = 'b'): string {
|
||
return `EXISTS (SELECT 1 FROM freight.booking_container bc
|
||
WHERE bc.booking_id = ${alias}.id
|
||
AND bc.deleted_at IS NULL
|
||
AND bc.container_type_id = :containerTypeId)`;
|
||
}
|
||
|
||
/**
|
||
* Container VGM on a booking, in tons — the sum of the per-line totals.
|
||
*
|
||
* NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for
|
||
* container freight (VGM is captured per container, later, in operations), so
|
||
* reading the booking-level column showed every portal container booking as
|
||
* weighing nothing. Same reason `bookingTonsSql` falls through to these lines.
|
||
*/
|
||
export function bookingContainerVgmSql(alias = 'b'): string {
|
||
return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0)
|
||
FROM freight.booking_container bc
|
||
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)`;
|
||
}
|