From df8e10e0417259ddcbf89699ce2c0d273a07fd01 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 11:23:52 +0000 Subject: [PATCH] feat(bookings): surface cargo declared on the shipment request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../bookings/booking-content.sql.spec.ts | 38 +++++++++++++++ .../modules/bookings/booking-content.sql.ts | 48 +++++++++++++++++++ .../modules/bookings/bookings.repository.ts | 17 +++++++ .../src/modules/bookings/bookings.service.ts | 4 ++ .../bookings/dto/filter-booking.dto.ts | 13 +++++ .../exports/datasets/bookings.dataset.ts | 12 +++++ .../pages/bookings/BookingRequestsPage.tsx | 19 ++++++++ .../src/services/bookings.service.ts | 11 +++++ 8 files changed, 162 insertions(+) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts index 8580c7774..0668b9eba 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts index 813e833fc..744a6bbd8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts @@ -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)`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index a2d2fb53f..5e0e3c994 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -26,6 +26,7 @@ import { bookingContainerCountSql, bookingContentMatchSql, bookingHasContainerTypeSql, + bookingRequestedContainerCountSql, } from './booking-content.sql'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { @@ -79,6 +80,9 @@ export interface BookingListFilterOptions { containerTypeId?: string; containersMin?: number; containersMax?: number; + /** Bounds on containers declared on the shipment request behind the booking. */ + requestedContainersMin?: number; + requestedContainersMax?: number; freightType?: string; bookingType?: string; tradeDirection?: string; @@ -1225,6 +1229,19 @@ export class BookingsRepository extends BaseRepository { }); } } + // Declared on the shipment request, not on the booking. Pairs with the + // count above: containers 0..0 AND requested >= 1 is the set awaiting + // completion after clearance. + if (options.requestedContainersMin != null) { + qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, { + requestedContainersMin: options.requestedContainersMin, + }); + } + if (options.requestedContainersMax != null) { + qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, { + requestedContainersMax: options.requestedContainersMax, + }); + } if (omit !== 'freightType' && options.freightType) { qb.andWhere('booking.freight_type = :freightType', { freightType: options.freightType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index bbadfda55..71107650f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1849,6 +1849,8 @@ export class BookingsService { containerTypeId: filter.containerTypeId, containersMin: filter.containersMin, containersMax: filter.containersMax, + requestedContainersMin: filter.requestedContainersMin, + requestedContainersMax: filter.requestedContainersMax, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, @@ -2080,6 +2082,8 @@ export class BookingsService { containerTypeId: filter.containerTypeId, containersMin: filter.containersMin, containersMax: filter.containersMax, + requestedContainersMin: filter.requestedContainersMin, + requestedContainersMax: filter.requestedContainersMax, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 3eb579b86..a6138e77b 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -103,6 +103,19 @@ export class FilterBookingDto { @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) containersMax?: number; + @ApiPropertyOptional({ + description: + 'Minimum containers declared on the shipment request behind the booking', + }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + requestedContainersMin?: number; + + @ApiPropertyOptional({ description: 'Maximum requested containers — see requestedContainersMin' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + requestedContainersMax?: number; + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) @IsOptional() @IsIn([...FREIGHT_TYPES]) diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index 1ed7883c9..69ff22927 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -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) { diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 28edee8b8..a277d0570 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -251,6 +251,25 @@ export default function BookingRequestsPage() { ? { containersMin: v.v[0], containersMax: v.v[1] } : { containersMin: v.v[0], containersMax: v.v[0] }, }, + { + // Declared on the shipment request, not yet on the booking. Pair it + // with Containers = 0 to find the set awaiting completion. + key: "requestedContainers", + label: "Requested containers", + type: "number", + secondary: true, + operators: ["is", "between"], + toParams: (v) => + v.op === "between" + ? { + requestedContainersMin: v.v[0], + requestedContainersMax: v.v[1], + } + : { + requestedContainersMin: v.v[0], + requestedContainersMax: v.v[0], + }, + }, { key: "serviceTypeId", label: "Service", diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 3acd3bd05..4f2feeea3 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -82,6 +82,9 @@ export interface BookingListFilter { /** Container count bounds — of containerTypeId when set, else of all types. */ containersMin?: string; containersMax?: string; + /** Bounds on containers declared on the shipment request behind the booking. */ + requestedContainersMin?: string; + requestedContainersMax?: string; /** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */ bookingType?: string; /** 'true' → customs bookings, 'false' → self-clearance (non-customs). */ @@ -213,6 +216,10 @@ export const bookingsService = { if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId; if (filter.containersMin) params.containersMin = filter.containersMin; if (filter.containersMax) params.containersMax = filter.containersMax; + if (filter.requestedContainersMin) + params.requestedContainersMin = filter.requestedContainersMin; + if (filter.requestedContainersMax) + params.requestedContainersMax = filter.requestedContainersMax; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) @@ -257,6 +264,10 @@ export const bookingsService = { if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId; if (filter.containersMin) params.containersMin = filter.containersMin; if (filter.containersMax) params.containersMax = filter.containersMax; + if (filter.requestedContainersMin) + params.requestedContainersMin = filter.requestedContainersMin; + if (filter.requestedContainersMax) + params.requestedContainersMax = filter.requestedContainersMax; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency)