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

@@ -5,6 +5,8 @@ import {
bookingContentMatchSql,
bookingContentSql,
bookingHasContainerTypeSql,
bookingRequestedCargoSql,
bookingRequestedContainerCountSql,
} from './booking-content.sql';
describe('bookingContentSql', () => {
@@ -106,3 +108,39 @@ describe('bookingContainerVgmSql', () => {
expect(sql).toContain('bc.deleted_at IS NULL');
});
});
describe('requested (shipment-request) cargo', () => {
const cargo = bookingRequestedCargoSql('b');
const count = bookingRequestedContainerCountSql('b');
it('reads the request, never the booking or its container lines', () => {
for (const sql of [cargo, count]) {
expect(sql).toContain('freight.booking_requests br');
expect(sql).toContain('br.created_booking_id = b.id');
expect(sql).not.toContain('freight.booking_container');
}
});
// requested_lines is a free-form jsonb column; jsonb_array_elements throws on
// a non-array, which would 500 the whole list for one malformed row.
it('survives a requested_lines with no container array', () => {
for (const sql of [cargo, count]) {
expect(sql).toContain("jsonb_typeof(br.requested_lines->'containers') = 'array'");
expect(sql).toContain("ELSE '[]'::jsonb");
}
});
it('renders the bulk shape too, not only containers', () => {
expect(cargo).toContain("'bulk'->>'cargoWeightTons'");
expect(cargo).toContain("'bulk'->>'itemCount'");
});
it('counts 0 rather than NULL when no request exists', () => {
expect(count).toContain("COALESCE(SUM((l->>'quantity')::int), 0)");
});
it('ignores soft-deleted requests', () => {
expect(cargo).toContain('br.deleted_at IS NULL');
expect(count).toContain('br.deleted_at IS NULL');
});
});