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

@@ -8,6 +8,8 @@ import {
bookingContainerVgmSql,
bookingContentSql,
bookingHasContainerTypeSql,
bookingRequestedCargoSql,
bookingRequestedContainerCountSql,
} from '../../bookings/booking-content.sql';
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Booking } from '../../bookings/entities/booking.entity';
@@ -36,6 +38,7 @@ const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
/** What the customer described as the booking's contents — see the helper. */
const CONTENT = bookingContentSql('b');
const CONTAINER_COUNT = bookingContainerCountSql('b');
const REQUESTED_COUNT = bookingRequestedContainerCountSql('b');
const STATUS_OPTIONS = [
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
@@ -210,6 +213,9 @@ export const bookingsDataset: ExportDataset = {
{ key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' },
// Boxes, not lines: booking_container is one row per LINE with a quantity.
{ key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT },
// Declared on the shipment request, not yet on the booking — see the helper.
{ key: 'requestedCargo', label: 'Requested cargo', type: 'string', group: 'cargo', select: bookingRequestedCargoSql('b') },
{ key: 'requestedContainers', label: 'Requested containers', type: 'number', group: 'cargo', select: `${REQUESTED_COUNT}::int`, sortExpr: REQUESTED_COUNT },
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
// The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that
@@ -296,6 +302,8 @@ export const bookingsDataset: ExportDataset = {
{ key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions },
{ key: 'containersMin', label: 'Containers (min)', type: 'text' },
{ key: 'containersMax', label: 'Containers (max)', type: 'text' },
{ key: 'requestedContainersMin', label: 'Requested containers (min)', type: 'text' },
{ key: 'requestedContainersMax', label: 'Requested containers (max)', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
@@ -329,6 +337,10 @@ export const bookingsDataset: ExportDataset = {
const max = num(params.containersMax);
if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min });
if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max });
const reqMin = num(params.requestedContainersMin);
const reqMax = num(params.requestedContainersMax);
if (Number.isFinite(reqMin)) qb.andWhere(`${REQUESTED_COUNT} >= :requestedContainersMin`, { requestedContainersMin: reqMin });
if (Number.isFinite(reqMax)) qb.andWhere(`${REQUESTED_COUNT} <= :requestedContainersMax`, { requestedContainersMax: reqMax });
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
if (params.search) {