From 78c6f8be0a960b851d42bc607590c148cc45cd7f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 09:17:35 +0000 Subject: [PATCH 1/5] feat(bookings): show the invoice number in Pricing & payment The booking's freight invoice is looked up through the existing invoice list endpoint (source=booking, sourceId matched by search), newest first so a re-issue supersedes the old number. --- .../bookings/BookingPricingSummary.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx index 814fb9a95..0cf206350 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx @@ -1,12 +1,32 @@ import { Banknote, Receipt } from "lucide-react"; import { Divider, Group, Paper, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import type { BookingDetail } from "@/types/booking"; import { SectionCard } from "./detail/SectionCard"; import { detailStyles } from "./detail/booking-detail.styles"; export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { + // The booking's own freight invoice: source `booking`, sourceId = booking id + // (which `search` matches). Newest first — a re-issue supersedes the old one. + const invoiceQuery = useQuery( + api.invoices.list.queryOptions({ + input: { + filter: { + page: 1, + pageSize: 1, + sources: "booking", + search: booking.id, + sortBy: "createdAt", + sortOrder: "DESC", + }, + }, + }), + ); + const invoiceNumber = invoiceQuery.data?.items[0]?.invoiceNumber ?? null; + const computed = Number(booking.totalAmount); // The booking price is computed from the contract and is NOT staff-editable. // A historical `adjustedTotalAmount` (from before adjustments were removed) @@ -49,6 +69,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { + {invoiceNumber && } {booking.pnrCode && } {lineItems.length > 0 && ( From a6b2519136b2dfe90fe0702337ce3b87db14f0a4 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 09:17:41 +0000 Subject: [PATCH 2/5] feat(billing): filter invoices and manual payments by invoice type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a free-form `types` CSV filter to the invoice list DTO and query (same treatment as `paymentMethods` — each billing source mints its own type string, so an IsIn would drop real values), carries it into the invoices export dataset, and surfaces a Type column plus filter pill on both the Invoices and Manual Payments tables. --- .../src/modules/billing/billing.service.ts | 5 ++++ .../billing/dto/filter-invoice.dto.spec.ts | 2 ++ .../modules/billing/dto/filter-invoice.dto.ts | 12 +++++++++ .../exports/datasets/invoices.dataset.ts | 3 +++ .../src/pages/invoices/InvoicesPage.tsx | 14 +++++++++- .../src/pages/invoices/UsdPaymentsPage.tsx | 20 +++++++++++++- .../backoffice/src/types/invoice.ts | 27 +++++++++++++++++++ 7 files changed, 81 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index cf41998ba..1dd4c5656 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -116,6 +116,8 @@ export interface InvoiceListFilters { status?: Freight.InvoiceStatus; statuses?: Freight.InvoiceStatus[]; sources?: string[]; + /** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */ + types?: string[]; eimsStatuses?: string[]; /** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */ paymentMethods?: string[]; @@ -306,6 +308,9 @@ export class BillingService { sources: filter.sources, }); } + if (filter.types?.length) { + qb.andWhere("invoice.type IN (:...types)", { types: filter.types }); + } if (filter.eimsStatuses?.length) { qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { eimsStatuses: filter.eimsStatuses, diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts index 55e6b19d2..45c7acb51 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts @@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => { search: "INV-2026", statuses: "PENDING,OVERDUE", sources: "booking,warehouse", + types: "PREPAID,WAGON_CANCEL_FEE", eimsStatuses: "NOT_SUBMITTED", currency: "etb", issuedFrom: "2026-08-01T00:00:00.000Z", @@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => { expect(errors).toEqual([]); expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]); expect(dto.sources).toEqual(["booking", "warehouse"]); + expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]); expect(dto.currency).toBe("ETB"); expect(dto.minAmount).toBe(100); expect(dto.hasBalance).toBe(true); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index a98ad06c1..fa00fb521 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -89,6 +89,18 @@ export class FilterInvoiceDto { @IsIn(Object.values(Freight.InvoiceSource), { each: true }) sources?: Freight.InvoiceSource[]; + /** + * What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form + * like `paymentMethods`: every billing source mints its own `type` string, so + * an `IsIn` here would silently drop a real value. + */ + @ApiPropertyOptional({ isArray: true, example: ["PREPAID"] }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsString({ each: true }) + types?: string[]; + /** MoR filing state — Finance's "what still needs registering" cut. */ @ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 56ab3b74e..d4d635f6f 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = { // on-screen filter actually carries into the export. { key: 'status', label: 'Status (single)', type: 'text' }, { key: 'sources', label: 'Source', type: 'multiselect' }, + { key: 'types', label: 'Type', type: 'multiselect' }, { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, { key: 'paymentMethods', label: 'Payment method', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ @@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = { if (params.status) qb.andWhere('i.status = :status', { status: params.status }); const sources = params.sources as string[] | null; if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); + const types = params.types as string[] | null; + if (types?.length) qb.andWhere('i.type IN (:...types)', { types }); const eimsStatuses = params.eimsStatuses as string[] | null; if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses }); const paymentMethods = params.paymentMethods as string[] | null; diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 8b28fd71a..2b319de30 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -19,8 +19,10 @@ import { ExportButton } from "@/components/export/ExportButton"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; import { + INVOICE_TYPE_OPTIONS, PAYMENT_METHOD_OPTIONS, invoicePaymentMethod, + invoiceTypeLabel, paymentMethodLabel, type Invoice, type InvoiceListFilter, @@ -55,6 +57,7 @@ const EIMS_STATUS_OPTIONS = [ const INVOICE_FILTER_DEFS: FilterDef[] = [ { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS }, + { key: "types", label: "Type", type: "enum", options: INVOICE_TYPE_OPTIONS }, { key: "currency", label: "Currency", @@ -261,6 +264,15 @@ export default function InvoicesPanel() { size: 220, cell: ({ row }) => , }, + { + id: "type", + header: "Type", + cell: ({ row }) => ( + + {invoiceTypeLabel(row.original.type)} + + ), + }, { id: "status", header: "Status", @@ -389,7 +401,7 @@ export default function InvoicesPanel() { - + ( + + {invoiceTypeLabel(row.original.type)} + + ), + }, { id: "status", header: "Status", @@ -523,7 +541,7 @@ export default function UsdPaymentsPanel({ - + PAYMENT_METHOD_LABELS.get(method) ?? method; + +/** + * What an invoice bills for. Every billing source mints its own `type` string, + * so this list is the known vocabulary, not a closed enum — render an unknown + * value rather than treating it as invalid. + */ +export const INVOICE_TYPE_OPTIONS: { value: string; label: string }[] = [ + { value: "PREPAID", label: "Prepaid freight" }, + { value: "WAGON_CANCEL_FEE", label: "Wagon cancellation fee" }, + { value: "GL_FINAL", label: "General contract final" }, + { value: "ADDITIONAL_CHARGE", label: "Additional charge" }, + { value: "PORT_CHARGES", label: "Port charges" }, + { value: "MISCELLANEOUS", label: "Miscellaneous" }, + { value: "DELIVERY_FEE", label: "Delivery fee" }, + { value: "LAST_MILE_ADVANCE", label: "Last-mile advance" }, + { value: "SHIPPING_LINE_CREDIT", label: "Shipping line credit" }, + { value: "STORAGE_FEE", label: "Storage fee" }, + { value: "DEMURRAGE", label: "Demurrage" }, + { value: "MIXED_WAREHOUSE_FEES", label: "Mixed warehouse fees" }, +]; + +/** Label for an invoice `type`, falling back to the humanised raw value. */ +export const invoiceTypeLabel = (type: string): string => + INVOICE_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? + type.replace(/_/g, " "); From 0e9cfbce66802f426b271140f58c33507b595119 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 09:20:46 +0000 Subject: [PATCH 3/5] fix(reports): count container tonnage in exports and reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every SQL tonnage in the export datasets and report definitions used `COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)`. COALESCE falls through on NULL, never on 0 — and the portal booking wizard stores `cargo_total_weight_vgm = 0` for container freight on purpose, because VGM is captured per container line, not as a booking-level figure. So every portal-created container booking reported as weighing nothing. The backoffice wizard does store a booking-level total, so the same table holds both shapes and the numbers looked erratic rather than uniformly zero. Extract the resolver the TypeScript side already has three copies of (bookingCargoTons, cargoTonsAndItems, totalVgmTons) into one SQL helper: NULLIF both booking-level columns, then fall back to SUM(booking_container.total_vgm_tons). Applied to the bookings and train-schedules export datasets, the cargo-summary, contract-utilization and booking-status-breakdown reports, and the intercity booking list. On dev data this recovers 116 of 154 zero-weight container bookings and raises live booking tonnage from 42,973 t to 61,424 t. --- .../modules/bookings/booking-tons.sql.spec.ts | 30 +++++++++++++++++++ .../src/modules/bookings/booking-tons.sql.ts | 26 ++++++++++++++++ .../exports/datasets/bookings.dataset.ts | 9 +++--- .../datasets/train-schedules.dataset.ts | 3 +- .../booking-status-breakdown.report.ts | 3 +- .../definitions/cargo-summary.report.ts | 3 +- .../contract-utilization.report.ts | 3 +- .../train-scheduling/intercity.service.ts | 3 +- 8 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts new file mode 100644 index 000000000..07067c3ed --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts @@ -0,0 +1,30 @@ +import { bookingTonsSql } from './booking-tons.sql'; + +describe('bookingTonsSql', () => { + const sql = bookingTonsSql('b'); + + // The regression this exists for: a plain COALESCE stops at the portal's + // literal 0 for container bookings and reports them as weighing nothing. + it('treats a stored 0 as "no figure" on both booking-level columns', () => { + expect(sql).toContain('NULLIF(b.bulk_total_weight_tons, 0)'); + expect(sql).toContain('NULLIF(b.cargo_total_weight_vgm, 0)'); + }); + + it('falls back to the per-line container VGM, excluding soft-deleted lines', () => { + expect(sql).toContain('SUM(bc.total_vgm_tons)'); + expect(sql).toContain('freight.booking_container bc'); + expect(sql).toContain('bc.booking_id = b.id'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); + + it('never returns NULL, so callers may SUM it directly', () => { + expect(sql.trimEnd().endsWith('0)')).toBe(true); + }); + + it('rewrites every reference when embedded under another alias', () => { + const aliased = bookingTonsSql('bk'); + expect(aliased).not.toMatch(/\bb\./); + expect(aliased).toContain('bk.cargo_total_weight_vgm'); + expect(aliased).toContain('bc.booking_id = bk.id'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts new file mode 100644 index 000000000..f3a8590a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts @@ -0,0 +1,26 @@ +/** + * SQL mirror of `bookingCargoTons()` (train-scheduling/train-capacity.util.ts). + * + * Three storage conventions share `bookings.cargo_total_weight_vgm`: + * - BULK PER_TON — the column holds tons. + * - BULK PER_ITEM — the column holds an ITEM COUNT; the tons are in + * `bulk_total_weight_tons`. + * - CONTAINER — the portal wizard captures VGM per line, not per booking, + * and sends 0 (portal NewBookingPage: "containers carry NO weight at the + * wizard"). The tons live in `booking_container.total_vgm_tons`. The + * backoffice wizard does store a booking-level total, so both shapes exist + * in the same table. + * + * Hence NULLIF on both columns: a plain + * `COALESCE(bulk_total_weight_tons, cargo_total_weight_vgm)` stops at the + * portal's 0 — COALESCE falls through on NULL, never on 0 — and every + * portal-created container booking reads as 0 tons in exports and reports. + */ +export function bookingTonsSql(alias = 'b'): string { + return `COALESCE( + NULLIF(${alias}.bulk_total_weight_tons, 0), + NULLIF(${alias}.cargo_total_weight_vgm, 0), + (SELECT SUM(bc.total_vgm_tons) FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), + 0)`; +} 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 c9f2a7d21..eed069764 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,5 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; @@ -14,11 +15,11 @@ import { ExportDataset } from '../export.types'; /** * Domain semantics that the retired `bookings-list` report used to share. - * Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm` - * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently - * overrides `total_amount`. Getting either wrong misreports money or weight. + * Tonnage is `bookingTonsSql` — the one resolver for the three ways a booking + * stores its weight. `adjusted_total_amount` silently overrides `total_amount`. + * Getting either wrong misreports money or weight. */ -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; const STATUS_OPTIONS = [ diff --git a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts index bb31591da..d380470de 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts @@ -1,4 +1,5 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Route } from '../../routes/entities/route.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; @@ -86,7 +87,7 @@ export const trainSchedulesDataset: ExportDataset = { }, { key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true, - select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8 + select: `(SELECT ROUND(COALESCE(SUM(${bookingTonsSql('b')}), 0))::float8 FROM freight.bookings b WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, }, diff --git a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts index 449c47181..c70fcb13f 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts @@ -1,6 +1,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { BookingStatus } from '@edr/types'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; @@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types'; // One resolver behind "Booking per status, per port/train/date/cargo/contract // type" — the same breakdown Operation, Marketing, Global Logistics and the // Operation Report each ask for verbatim. Embed once, reuse everywhere. -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({ diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts index ee3063ee1..2df862f3c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts @@ -1,9 +1,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { ReportContext, ReportDefinition } from '../report.types'; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts index 747a14891..64598fa87 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -1,10 +1,11 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Company } from '../../companies/entities/company.entity'; import { Contract } from '../../contracts/entities/contract.entity'; import { ReportContext, ReportDefinition } from '../report.types'; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; function baseQuery(ctx: ReportContext): SelectQueryBuilder { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 4e2724f49..9dc75335f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -7,6 +7,7 @@ import { import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { bookingTonsSql } from '../bookings/booking-tons.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -58,7 +59,7 @@ export class IntercityService { b.reference AS "reference", b.status AS "status", b.freight_type AS "freightType", - b.cargo_total_weight_vgm AS "weightTons", + ${bookingTonsSql('b')} AS "weightTons", b.loaded_at AS "loadedAt", b.arrived_at AS "arrivedAt", company.name AS "customer", From 339b8a8682226fc2307f9c9e455e760d4248b2bb Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 09:42:56 +0000 Subject: [PATCH 4/5] feat(bookings): filter and export booking content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The booking-requests list could filter by freight type but not by what is actually in the booking, and the export's only cargo column showed the commodity name — blank for every container booking, which stores no commodity at all. Adds one resolver, `bookingContentSql`, that answers "what did the customer say is in this booking" per freight type: the container lines they entered ("2 × 40FT, 1 × 20FT") for container freight, since the wizard asks them for no description; the commodity they picked from the cargo tree for bulk, falling back to their free-text description. List filters: - "Content" — a single select flattening the cargo tree the same way the booking wizard presents it (group, then each commodity as "Bulk → Wheat"). Picking a GROUP matches its whole subtree via a recursive walk, so "Bulk" returns all 44 bulk bookings rather than the 0 that carry the group id itself. This makes the existing, previously unexposed `cargoTypeId` param group-aware. - "Content contains" — a contains-search over the description, the commodity name and the container types, so container bookings are reachable by "40FT" even though they carry no words of the customer's own. Both apply through `applyListFilters`, so the list, its summary tiles and its facets agree, and both are declared on the bookings export dataset — the export button already forwards the page's filters verbatim. Export fields: "Content" (default), plus "Cargo description" as its own column. The old `cargo` column is unchanged and still selectable, relabelled "Cargo (commodity)"; it loses only its default tick, so saved presets that name it keep working. --- .../bookings/booking-content.sql.spec.ts | 64 +++++++++++++++++++ .../modules/bookings/booking-content.sql.ts | 58 +++++++++++++++++ .../modules/bookings/bookings.repository.ts | 17 ++++- .../src/modules/bookings/bookings.service.ts | 2 + .../bookings/dto/filter-booking.dto.ts | 16 ++++- .../exports/datasets/bookings.dataset.ts | 50 ++++++++++++++- .../pages/bookings/BookingRequestsPage.tsx | 40 +++++++++++- .../src/services/bookings.service.ts | 8 +++ 8 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts 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) From 74fb05207c08f79d811b6ad7f50ac7d684b3eea7 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 10:02:01 +0000 Subject: [PATCH 5/5] feat(bookings): filter by container count, export per-type quantities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../bookings/booking-content.sql.spec.ts | 44 +++++++++++ .../modules/bookings/booking-content.sql.ts | 42 +++++++++++ .../modules/bookings/bookings.repository.ts | 29 ++++++++ .../src/modules/bookings/bookings.service.ts | 6 ++ .../bookings/dto/filter-booking.dto.ts | 22 ++++++ .../exports/datasets/bookings.dataset.ts | 74 ++++++++++++++++++- .../src/modules/exports/export-filter.util.ts | 22 ++++++ .../src/modules/exports/export.types.ts | 9 +++ .../src/modules/exports/exports.controller.ts | 26 ++++--- .../pages/bookings/BookingRequestsPage.tsx | 38 +++++++++- .../src/services/bookings.service.ts | 11 +++ 11 files changed, 310 insertions(+), 13 deletions(-) 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 39e6da2f2..8580c7774 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 @@ -1,7 +1,10 @@ import { CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, + bookingContainerVgmSql, bookingContentMatchSql, bookingContentSql, + bookingHasContainerTypeSql, } from './booking-content.sql'; describe('bookingContentSql', () => { @@ -62,3 +65,44 @@ describe('bookingContentMatchSql', () => { expect(sql.trimEnd().endsWith(')')).toBe(true); }); }); + +describe('bookingContainerCountSql', () => { + // booking_container is one row per LINE carrying a quantity, so counting rows + // would report a 54-container booking as 1. + it('sums the line quantities rather than counting lines', () => { + expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)'); + expect(bookingContainerCountSql('b')).not.toContain('COUNT('); + }); + + it('counts every type by default and one type when scoped', () => { + expect(bookingContainerCountSql('b')).not.toContain('container_type_id'); + expect(bookingContainerCountSql('b', true)).toContain( + 'bc.container_type_id = :containerTypeId', + ); + }); + + it('is 0, never NULL, so a bound comparison still decides', () => { + expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)'); + }); + + it('ignores soft-deleted lines', () => { + expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL'); + expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL'); + }); + + it('rewrites the booking reference under another alias', () => { + expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id'); + expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id'); + }); +}); + +describe('bookingContainerVgmSql', () => { + // The whole point: b.cargo_total_weight_vgm is 0 for portal container + // bookings, so the weight has to come off the lines. + it('reads the lines, never the booking-level column', () => { + const sql = bookingContainerVgmSql('b'); + expect(sql).toContain('SUM(bc.total_vgm_tons)'); + expect(sql).not.toContain('cargo_total_weight_vgm'); + expect(sql).toContain('bc.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 0896e6be0..813e833fc 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 @@ -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)`; +} 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 f0ca9cfa5..a2d2fb53f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -23,7 +23,9 @@ import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, bookingContentMatchSql, + bookingHasContainerTypeSql, } from './booking-content.sql'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { @@ -73,6 +75,10 @@ export interface BookingListFilterOptions { cargoTypeId?: string; /** Contains-search over content: description, commodity name, container types. */ cargoText?: string; + /** Bookings carrying this container type; also scopes the container count. */ + containerTypeId?: string; + containersMin?: number; + containersMax?: number; freightType?: string; bookingType?: string; tradeDirection?: string; @@ -1196,6 +1202,29 @@ export class BookingsRepository extends BaseRepository { cargoText: `%${options.cargoText}%`, }); } + if (options.containerTypeId) { + qb.andWhere(bookingHasContainerTypeSql('booking'), { + containerTypeId: options.containerTypeId, + }); + } + // One count filter, two questions: with a container type picked it counts + // that type, without one it counts every box on the booking. + if (options.containersMin != null || options.containersMax != null) { + const count = bookingContainerCountSql( + 'booking', + Boolean(options.containerTypeId), + ); + if (options.containersMin != null) { + qb.andWhere(`${count} >= :containersMin`, { + containersMin: options.containersMin, + }); + } + if (options.containersMax != null) { + qb.andWhere(`${count} <= :containersMax`, { + containersMax: options.containersMax, + }); + } + } 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 8d54e5e86..bbadfda55 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1846,6 +1846,9 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, cargoText: filter.cargoText, + containerTypeId: filter.containerTypeId, + containersMin: filter.containersMin, + containersMax: filter.containersMax, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, @@ -2074,6 +2077,9 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, cargoText: filter.cargoText, + containerTypeId: filter.containerTypeId, + containersMin: filter.containersMin, + containersMax: filter.containersMax, 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 bfa80b453..3eb579b86 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 @@ -81,6 +81,28 @@ export class FilterBookingDto { ) cargoText?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Bookings carrying this container type. Also scopes containersMin/Max to it.', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiPropertyOptional({ + description: + 'Minimum container count — of containerTypeId when set, else of all types', + }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + containersMin?: number; + + @ApiPropertyOptional({ description: 'Maximum container count — see containersMin' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + containersMax?: 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 ab3ba6118..1ed7883c9 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 @@ -3,8 +3,11 @@ import { DataSource } from 'typeorm'; import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, bookingContentMatchSql, + bookingContainerVgmSql, bookingContentSql, + bookingHasContainerTypeSql, } from '../../bookings/booking-content.sql'; import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; @@ -19,7 +22,7 @@ import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line 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'; +import { ExportDataset, ExportField } from '../export.types'; /** * Domain semantics that the retired `bookings-list` report used to share. @@ -32,6 +35,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 STATUS_OPTIONS = [ 'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED', @@ -67,6 +71,53 @@ async function cargoTypeOptions(ds: DataSource): Promise { `) as Promise; } +/** Container types are 2 rows that change about never. */ +async function containerTypeOptions(ds: DataSource): Promise { + return ds.query(` + SELECT id AS value, COALESCE(label, code) AS label + FROM freight.container_types + WHERE deleted_at IS NULL AND is_active + ORDER BY display_order, code + `) as Promise; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * One column per container type ("20FT", "40FT", …), each the box count of + * that type on the booking. Resolved from `container_types` rather than + * hardcoded, so adding a 45ft adds its column without a deploy of this file. + * + * The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL + * string with no parameter bag — so ids that are not uuids are dropped rather + * than spliced. They come from our own table; the guard is for the day someone + * changes that column's type. + */ +async function containerTypeFields(ds: DataSource): Promise { + const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(` + SELECT id, code, label + FROM freight.container_types + WHERE deleted_at IS NULL AND is_active + ORDER BY display_order, code + `); + return rows + .filter((r) => UUID_RE.test(r.id)) + .map((r) => { + const name = r.label || r.code; + return { + key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`, + label: `${name} containers`, + type: 'number' as const, + group: 'cargo', + select: `(SELECT COALESCE(SUM(bc.quantity), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = b.id + AND bc.deleted_at IS NULL + AND bc.container_type_id = '${r.id}')::int`, + }; + }); +} + export const bookingsDataset: ExportDataset = { key: 'bookings', title: 'Bookings', @@ -157,9 +208,13 @@ export const bookingsDataset: ExportDataset = { { 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' }, + // 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 }, { 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' }, + // The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that + // column at 0 for container freight, so it read 0 for every such booking. + { key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') }, { key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' }, { key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' }, { key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' }, @@ -216,6 +271,8 @@ export const bookingsDataset: ExportDataset = { { key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' }, ], + dynamicFields: containerTypeFields, + filters: [ { key: 'created', label: 'Created', type: 'daterange' }, { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, @@ -236,6 +293,9 @@ export const bookingsDataset: ExportDataset = { ] }, { key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions }, { key: 'cargoText', label: 'Content contains', type: 'text' }, + { key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions }, + { key: 'containersMin', label: 'Containers (min)', type: 'text' }, + { key: 'containersMax', label: 'Containers (max)', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search reference or customer', type: 'text' }, ], @@ -259,6 +319,16 @@ export const bookingsDataset: ExportDataset = { // 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.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId }); + // With a container type picked the count is of THAT type, else of every box. + const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId)); + // coerceFilterParams yields null (not undefined) for an unset filter, and + // Number(null) is 0 — which would silently apply ">= 0" to every export. + const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v)); + const min = num(params.containersMin); + 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 }); 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-api/src/modules/exports/export-filter.util.ts b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts index c302d9d3b..40c8e6980 100644 --- a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts +++ b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; +import type { ExportField } from './export.types'; + const DAY_MS = 24 * 60 * 60 * 1000; export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; @@ -64,6 +66,26 @@ export function coerceFilterParams( */ const optionsCache = new Map(); +/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */ +const fieldsCache = new Map(); + +/** + * A dataset's full field list: its static fields plus whatever `dynamicFields` + * resolves from the DB. Every read of `dataset.fields` goes through this, so + * the catalog and the download agree on which keys exist. + */ +export async function resolveDatasetFields( + dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise }, + ds: DataSource, +): Promise { + if (!dataset.dynamicFields) return dataset.fields; + const cached = fieldsCache.get(dataset.key); + if (cached) return cached; + const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))]; + fieldsCache.set(dataset.key, resolved); + return resolved; +} + export async function resolveFilterOptions( filters: ExportFilterDef[], ds: DataSource, diff --git a/apps/edr-freight-api/src/modules/exports/export.types.ts b/apps/edr-freight-api/src/modules/exports/export.types.ts index db12f0211..71abdad88 100644 --- a/apps/edr-freight-api/src/modules/exports/export.types.ts +++ b/apps/edr-freight-api/src/modules/exports/export.types.ts @@ -103,6 +103,15 @@ export interface ExportDataset { alwaysJoin?: string[]; groups: ExportGroup[]; fields: ExportField[]; + /** + * Extra fields resolved from reference data and appended to `fields` — one + * column per row of some small, rarely-changing table (a column per container + * type, say). Cached for the process, like `ExportFilterDef.optionsQuery`. + * + * The SQL these build is interpolated, not bound, so a resolver MUST validate + * anything it splices in; see `bookingsDataset` for the uuid guard. + */ + dynamicFields?: (ds: DataSource) => Promise; filters: ExportFilterDef[]; /** Must name a field whose `sortExpr` references only the base alias. */ defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts index aea35a9bc..46c6ebad2 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { resolveFilterOptions } from './export-filter.util'; +import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util'; import { EXPORT_MIME, formatRowCap, @@ -31,13 +31,16 @@ const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP }; * Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of * the schema — they never leave the server. */ -const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({ +const toCatalogEntry = ( + dataset: ExportDataset, + fields: ExportField[], +): ExportCatalogEntry => ({ key: dataset.key, title: dataset.title, description: dataset.description, group: dataset.group, groups: dataset.groups, - fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({ + fields: fields.map(({ key, label, type, group, default: isDefault }) => ({ key, label, type, @@ -72,7 +75,7 @@ export class ExportsController { const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission)); return Promise.all( allowed.map(async (d) => ({ - ...toCatalogEntry(d), + ...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)), filters: await resolveFilterOptions(d.filters, this.dataSource), })), ); @@ -102,7 +105,10 @@ export class ExportsController { const dataset = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const format = resolveExportFormat(query.format); - const fields = this.resolveFields(dataset, query.fields); + const fields = ExportsController.pickFields( + await resolveDatasetFields(dataset, this.dataSource), + query.fields, + ); const rows = await this.runner.run(dataset, fields, query, directions, { cap: formatRowCap(format), @@ -134,15 +140,15 @@ export class ExportsController { * DEFAULT set, not everything — a booking export has ~70 fields and dumping * all of them on an unparameterised call is nobody's intent. */ - private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] { + private static pickFields(all: ExportField[], raw: string | undefined): ExportField[] { if (raw?.trim()) { - const picked = pickByKey(dataset.fields, raw); + const picked = pickByKey(all, raw); // pickByKey falls back to everything when nothing matched; for a dataset // the safer read of "all keys unknown" is still the default set. - if (picked.length !== dataset.fields.length) return picked; + if (picked.length !== all.length) return picked; } - const defaults = dataset.fields.filter((f) => f.default); - return defaults.length ? defaults : dataset.fields; + const defaults = all.filter((f) => f.default); + return defaults.length ? defaults : all; } private resolve(key: string, user: TCurrentUser): ExportDataset { 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 9a7010b05..28edee8b8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -166,6 +166,15 @@ export default function BookingRequestsPage() { // already mounted just works, and every filter — direction included — // auto-pins its own pill the moment it has a value (FilterBar's `secondary` // split), so a deep link can never land behind "More filters" unseen. + // Container types, flattened out of the reference data's size groups. + const containerTypeOptions = useMemo( + () => + (refData?.containers ?? []).flatMap((group) => + group.types.map((t) => ({ value: t.id, label: t.name || t.code })), + ), + [refData], + ); + const bookingFilterDefs: FilterDef[] = useMemo( () => [ { @@ -221,6 +230,27 @@ export default function BookingRequestsPage() { secondary: true, placeholder: "Commodity, description or container type", }, + { + key: "containerTypeId", + label: "Container type", + type: "enum", + multiple: false, + options: containerTypeOptions, + secondary: true, + }, + { + // Counts boxes. Scoped to the container-type filter when one is set, so + // this one control answers "10 containers" and "10 forty-footers" both. + key: "containers", + label: "Containers", + type: "number", + secondary: true, + operators: ["is", "between"], + toParams: (v) => + v.op === "between" + ? { containersMin: v.v[0], containersMax: v.v[1] } + : { containersMin: v.v[0], containersMax: v.v[0] }, + }, { key: "serviceTypeId", label: "Service", @@ -282,7 +312,13 @@ export default function BookingRequestsPage() { toParams: dateRangeParams("scheduledFrom", "scheduledTo"), }, ], - [filterOptions, yardOptions, serviceTypeOptions, cargoTypeOptions], + [ + filterOptions, + yardOptions, + serviceTypeOptions, + cargoTypeOptions, + containerTypeOptions, + ], ); 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 c97d14b68..3acd3bd05 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -77,6 +77,11 @@ export interface BookingListFilter { cargoTypeId?: string; /** Contains-search over content: cargo description, commodity, container types. */ cargoText?: string; + /** Bookings carrying this container type; also scopes containersMin/Max to it. */ + containerTypeId?: string; + /** Container count bounds — of containerTypeId when set, else of all types. */ + containersMin?: string; + containersMax?: string; /** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */ bookingType?: string; /** 'true' → customs bookings, 'false' → self-clearance (non-customs). */ @@ -205,6 +210,9 @@ export const bookingsService = { if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId; if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId; if (filter.cargoText) params.cargoText = filter.cargoText; + if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId; + if (filter.containersMin) params.containersMin = filter.containersMin; + if (filter.containersMax) params.containersMax = filter.containersMax; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) @@ -246,6 +254,9 @@ export const bookingsService = { if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId; if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId; if (filter.cargoText) params.cargoText = filter.cargoText; + if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId; + if (filter.containersMin) params.containersMin = filter.containersMin; + if (filter.containersMax) params.containersMax = filter.containersMax; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency)