feat(bookings): filter by container count, export per-type quantities

Container filters on the booking-requests list:
- "Container type" — bookings carrying that type.
- "Containers" — a count of BOXES (booking_container is one row per line with
  a quantity, so this sums quantity rather than counting rows), as an exact
  value or a range. It reads the container-type filter when one is set, so the
  one control answers both "10 containers in total" and "10 forty-footers".

Export gains a column per container type ("20FT containers", "40FT
containers"), plus the total "Containers" column and the two filters. Container
types are reference rows, not a constant, so `ExportDataset` gains an optional
`dynamicFields` resolver — DB-driven columns appended to the static list and
cached for the process, mirroring the existing `ExportFilterDef.optionsQuery`.
Adding a 45ft container type adds its column with no code change. The type id
is interpolated into raw SQL (ExportField.select has no parameter bag), so the
resolver drops any id that is not a uuid.

Also repoints the export's "Container VGM" column at the per-line sum. It was
projecting bookings.cargo_total_weight_vgm, which the portal wizard leaves at 0
for container freight — the same trap the tonnage fix addressed — so the column
read 0 for every portal-created container booking. Non-zero on dev data goes
from 54 to 170 of 208 container bookings.
This commit is contained in:
Nathnael
2026-08-28 10:02:01 +00:00
parent 339b8a8682
commit 74fb05207c
11 changed files with 310 additions and 13 deletions

View File

@@ -56,3 +56,45 @@ export function bookingContentMatchSql(alias = 'b'): string {
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)`;
}