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 new file mode 100644 index 000000000..39e6da2f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts @@ -0,0 +1,64 @@ +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContentMatchSql, + bookingContentSql, +} from './booking-content.sql'; + +describe('bookingContentSql', () => { + const sql = bookingContentSql('b'); + + it('prefers the container lines, since container bookings carry no description', () => { + expect(sql.indexOf('freight.booking_container')).toBeLessThan( + sql.indexOf('freight.cargo_types'), + ); + expect(sql).toContain('freight.container_types'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); + + it('falls back to commodity, then to the free-text description', () => { + expect(sql.indexOf('cgt.cargo_type_name')).toBeLessThan( + sql.indexOf('b.cargo_free_text'), + ); + }); + + // An empty string is not a missing value to COALESCE — without NULLIF a blank + // description would win over the commodity behind it. + it('treats an empty string as absent at every level', () => { + expect(sql.match(/NULLIF/g)).toHaveLength(3); + }); + + it('rewrites every reference when embedded under another alias', () => { + expect(bookingContentSql('bk')).not.toMatch(/\bb\.(cargo|id)/); + }); +}); + +describe('CARGO_TYPE_SUBTREE_SQL', () => { + // The filter offers groups, not just leaves, so picking "Bulk" has to reach + // commodities at any depth beneath it — two levels today, more tomorrow. + it('walks the tree recursively rather than one level of children', () => { + expect(CARGO_TYPE_SUBTREE_SQL).toContain('WITH RECURSIVE'); + expect(CARGO_TYPE_SUBTREE_SQL).toContain('c.parent_group_id = sub.id'); + }); + + it('includes the picked node itself, so a leaf still matches exactly', () => { + expect(CARGO_TYPE_SUBTREE_SQL).toContain('WHERE id = :cargoTypeId'); + }); +}); + +describe('bookingContentMatchSql', () => { + const sql = bookingContentMatchSql('b'); + + it('searches all three places content can live', () => { + expect(sql).toContain('b.cargo_free_text ILIKE :cargoText'); + expect(sql).toContain('cgt.cargo_type_name ILIKE :cargoText'); + expect(sql).toContain('cnt.code ILIKE :cargoText'); + }); + + // Anything but OR would make the text box match nothing for whole freight + // types — a container booking has no commodity, a bulk one has no container. + it('ORs them, and stays one parenthesised term for andWhere', () => { + expect(sql).not.toContain(' AND :cargoText'); + expect(sql.startsWith('(')).toBe(true); + expect(sql.trimEnd().endsWith(')')).toBe(true); + }); +}); 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 new file mode 100644 index 000000000..0896e6be0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts @@ -0,0 +1,58 @@ +/** + * 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)))`; +} 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 885486031..f0ca9cfa5 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -21,6 +21,10 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContentMatchSql, +} from './booking-content.sql'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -65,7 +69,10 @@ export interface BookingListFilterOptions { contractId?: string; contractType?: string; serviceTypeId?: string; + /** Cargo type OR cargo group — a group matches every commodity beneath it. */ cargoTypeId?: string; + /** Contains-search over content: description, commodity name, container types. */ + cargoText?: string; freightType?: string; bookingType?: string; tradeDirection?: string; @@ -1176,11 +1183,19 @@ export class BookingsRepository extends BaseRepository { serviceTypeId: options.serviceTypeId, }); } + // A group is selectable in the filter, not just a leaf commodity, so this + // matches the whole subtree — picking "Bulk" must return every commodity + // under it, the same drill-down the booking wizard offers, read back. if (options.cargoTypeId) { - qb.andWhere('booking.cargo_type_id = :cargoTypeId', { + qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: options.cargoTypeId, }); } + if (options.cargoText) { + qb.andWhere(bookingContentMatchSql('booking'), { + cargoText: `%${options.cargoText}%`, + }); + } 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 b6047633a..8d54e5e86 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1845,6 +1845,7 @@ export class BookingsService { contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, + cargoText: filter.cargoText, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, @@ -2072,6 +2073,7 @@ export class BookingsService { contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, + cargoText: filter.cargoText, 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 a404c005e..bfa80b453 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 @@ -62,11 +62,25 @@ export class FilterBookingDto { @IsUUID() serviceTypeId?: string; - @ApiPropertyOptional({ format: 'uuid' }) + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Cargo type OR cargo group — a group matches every commodity beneath it', + }) @IsOptional() @IsUUID() cargoTypeId?: string; + @ApiPropertyOptional({ + description: + 'Contains-search over booking content: cargo description, commodity name, container types', + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + cargoText?: string; + @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 eed069764..ab3ba6118 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 @@ -1,4 +1,11 @@ +import { DataSource } from 'typeorm'; + import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContentMatchSql, + bookingContentSql, +} from '../../bookings/booking-content.sql'; import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Company } from '../../companies/entities/company.entity'; @@ -11,6 +18,7 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { Train } from '../../trains/entities/train.entity'; import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportFilterOption } from '../export-filter.util'; import { ExportDataset } from '../export.types'; /** @@ -22,12 +30,43 @@ import { ExportDataset } from '../export.types'; const TONS = bookingTonsSql('b'); 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 STATUS_OPTIONS = [ 'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED', 'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT', 'ARRIVED', 'DELIVERED', 'COMPLETED', ].map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); +/** + * Cargo tree flattened for a single select: groups and every commodity beneath + * them, each labelled by its full path ("Bulk → Wheat") the way the booking + * wizard shows a deep leaf. Picking a group row filters its whole subtree. + * + * Recursive because `cargo_types` is arbitrary-depth, not two levels. + */ +async function cargoTypeOptions(ds: DataSource): Promise { + return ds.query(` + WITH RECURSIVE t AS ( + SELECT id, display_order, 0 AS depth, + ARRAY[display_order]::int[] AS ord, + ARRAY[cargo_type_name]::text[] AS path + FROM freight.cargo_types + WHERE parent_group_id IS NULL AND deleted_at IS NULL AND is_active + UNION ALL + SELECT c.id, c.display_order, t.depth + 1, + t.ord || c.display_order, + t.path || c.cargo_type_name + FROM freight.cargo_types c + JOIN t ON c.parent_group_id = t.id + WHERE c.deleted_at IS NULL AND c.is_active + ) + SELECT id AS value, array_to_string(path, ' → ') AS label + FROM t ORDER BY ord, path + `) as Promise; +} + export const bookingsDataset: ExportDataset = { key: 'bookings', title: 'Bookings', @@ -113,7 +152,11 @@ export const bookingsDataset: ExportDataset = { { key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' }, // ---- Cargo ----------------------------------------------------------- - { key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, + // What the customer said is in the booking. `cargo` below is the narrower + // commodity-only view, kept for saved presets that already tick it. + { key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT }, + { key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, + { key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' }, { 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 }, { key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' }, @@ -191,6 +234,8 @@ export const bookingsDataset: ExportDataset = { { value: 'PAID', label: 'Paid' }, { value: 'FAILED', label: 'Failed' }, ] }, + { key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions }, + { key: 'cargoText', label: 'Content contains', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search reference or customer', type: 'text' }, ], @@ -211,6 +256,9 @@ export const bookingsDataset: ExportDataset = { if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection }); if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + // Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL). + if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId }); + if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` }); 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 758f8a7bb..9a7010b05 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -140,6 +140,25 @@ export default function BookingRequestsPage() { [refData], ); + // Content options mirror the booking wizard's cargo picker: the group itself + // — which the server expands to every commodity beneath it — then each + // commodity, labelled by its full path so a generically-named leaf still + // reads unambiguously. A group with no descendants is emitted by the + // reference-data tree as its own single child; drop that duplicate. + const cargoTypeOptions = useMemo( + () => + (refData?.cargo_type ?? []).flatMap((group) => [ + { value: group.id, label: group.name }, + ...(group.children ?? []) + .filter((child) => child.id !== group.id) + .map((child) => ({ + value: child.id, + label: `${group.name} → ${child.name}`, + })), + ]), + [refData], + ); + // Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) — // the header's document-review alarm opens exactly the undecided requests // it is counting down for. No sync effect needed any more: controls.values @@ -183,6 +202,25 @@ export default function BookingRequestsPage() { multiple: false, options: FREIGHT_TYPE_OPTIONS, }, + { + // Cargo group or commodity. The group row matches its whole subtree + // server-side, so "Bulk" returns every bulk commodity under it. + key: "cargoTypeId", + label: "Content", + type: "enum", + multiple: false, + options: cargoTypeOptions, + }, + { + // Containers carry no customer-written description, so this is also how + // they are reached: it matches container types ("40FT") as well as the + // commodity name and the bulk cargo description. + key: "cargoText", + label: "Content contains", + type: "text", + secondary: true, + placeholder: "Commodity, description or container type", + }, { key: "serviceTypeId", label: "Service", @@ -244,7 +282,7 @@ export default function BookingRequestsPage() { toParams: dateRangeParams("scheduledFrom", "scheduledTo"), }, ], - [filterOptions, yardOptions, serviceTypeOptions], + [filterOptions, yardOptions, serviceTypeOptions, cargoTypeOptions], ); const controls = useFilters(bookingFilterDefs, { 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 28c700529..c97d14b68 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -73,6 +73,10 @@ export interface BookingListFilter { freightType?: string; /** Service type (rule-engine service_types.id). */ serviceTypeId?: string; + /** Cargo type OR cargo group — a group matches every commodity beneath it. */ + cargoTypeId?: string; + /** Contains-search over content: cargo description, commodity, container types. */ + cargoText?: string; /** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */ bookingType?: string; /** 'true' → customs bookings, 'false' → self-clearance (non-customs). */ @@ -199,6 +203,8 @@ export const bookingsService = { if (filter.companyId) params.companyId = filter.companyId; if (filter.freightType) params.freightType = filter.freightType; if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId; + if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId; + if (filter.cargoText) params.cargoText = filter.cargoText; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) @@ -238,6 +244,8 @@ export const bookingsService = { if (filter.contractId) params.contractId = filter.contractId; if (filter.freightType) params.freightType = filter.freightType; if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId; + if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId; + if (filter.cargoText) params.cargoText = filter.cargoText; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency)