mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
Adds a parallel export system the reports module can also draw on. A dataset describes a table's exportable fields — including related-entity detail the list page never shows — and the engine assembles a query from whichever fields the caller picked. GET /exports catalog (metadata only; select/requires never ship) GET /exports/:key/count exact row count + per-format caps GET /exports/:key/download csv | xlsx | pdf Two invariants carry the design: - Every lazy join is a LEFT join, and ExportJoin has no 'kind' field to make anything else expressible. An inner join added because a checkbox was ticked would change the rowset, so two exports of the same filters would disagree on their row count. - Because of that, the count cannot depend on field selection, so /count runs base + alwaysJoin only and is exact rather than an estimate. Verified: count and the delivered file both report 223 rows. One-to-many relations (a booking's containers) aggregate in a correlated subquery rather than joining, so a row can never multiply. Export rides each dataset's existing view permission — no new permission keys and no seeder change. Sensitive columns are simply never declared as fields: raw gateway payloads, signature blobs, error dumps, raw jsonb snapshots, internal user UUIDs and review notes are all absent by construction. bookings ships 77 fields across 10 groups. scripts/validate-export-datasets.ts EXPLAINs every dataset's widest query, its count query, and each field on its own against the real database — the per-field pass is what catches a field referencing a join it forgot to declare, which otherwise only fails when that one field is picked alone.
224 lines
17 KiB
TypeScript
224 lines
17 KiB
TypeScript
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
|
import { Booking } from '../../bookings/entities/booking.entity';
|
|
import { Company } from '../../companies/entities/company.entity';
|
|
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
|
import { Contract } from '../../contracts/entities/contract.entity';
|
|
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
|
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
|
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
|
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 { ExportDataset } from '../export.types';
|
|
|
|
/**
|
|
* Domain semantics shared with `reports/definitions/bookings-list.report.ts`.
|
|
* 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.
|
|
*/
|
|
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
|
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
|
|
|
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, ' ') }));
|
|
|
|
export const bookingsDataset: ExportDataset = {
|
|
key: 'bookings',
|
|
title: 'Bookings',
|
|
description: 'Every booking, with customer, route, cargo, contract and payment detail',
|
|
group: 'Commercial',
|
|
permission: FREIGHT_PERMS.bookings.view,
|
|
base: { entity: Booking, alias: 'b' },
|
|
|
|
// Every join is a LEFT join (see ExportJoin) — ticking a field must never
|
|
// change which rows come back.
|
|
joins: [
|
|
{ alias: 'c', entity: Company, on: 'c.id = b.company_id' },
|
|
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = b.company_profile_id' },
|
|
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = b.shipping_line_company_id' },
|
|
{ alias: 'o', entity: Yard, on: 'o.id = b.origin_yard_id' },
|
|
{ alias: 'd', entity: Yard, on: 'd.id = b.destination_yard_id' },
|
|
{ alias: 'cty', entity: CargoType, on: 'cty.id = b.cargo_type_id' },
|
|
{ alias: 'st', entity: ServiceType, on: 'st.id = b.service_type_id' },
|
|
{ alias: 'sl', entity: ShippingLine, on: 'sl.id = b.shipping_line_id' },
|
|
{ alias: 'ct', entity: Contract, on: 'ct.id = b.contract_id' },
|
|
{ alias: 't', entity: Train, on: 't.id = b.train_id' },
|
|
// Transitive: the contract's own customer, reachable only once `ct` is in.
|
|
{ alias: 'ctc', entity: Company, on: 'ctc.id = ct.company_id', requires: ['ct'] },
|
|
],
|
|
// `search` matches the customer name, so `c` is always present — which is
|
|
// also why the count query joins it.
|
|
alwaysJoin: ['c'],
|
|
|
|
groups: [
|
|
{ id: 'booking', label: 'Booking' },
|
|
{ id: 'customer', label: 'Customer' },
|
|
{ id: 'route', label: 'Route' },
|
|
{ id: 'cargo', label: 'Cargo' },
|
|
{ id: 'payment', label: 'Payment' },
|
|
{ id: 'scheduling', label: 'Scheduling' },
|
|
{ id: 'contract', label: 'Contract' },
|
|
{ id: 'firstMile', label: 'First mile' },
|
|
{ id: 'lastMile', label: 'Last mile' },
|
|
{ id: 'clearance', label: 'Clearance' },
|
|
],
|
|
|
|
fields: [
|
|
// ---- Booking -------------------------------------------------------
|
|
{ key: 'reference', label: 'Reference', type: 'string', group: 'booking', default: true, select: 'b.reference', sortExpr: 'b.reference' },
|
|
{ key: 'status', label: 'Status', type: 'string', group: 'booking', default: true, select: 'b.status', sortExpr: 'b.status' },
|
|
{ key: 'bookingType', label: 'Booking type', type: 'string', group: 'booking', select: 'b.booking_type' },
|
|
{ key: 'contractKind', label: 'Contract kind', type: 'string', group: 'booking', select: 'b.contract_kind' },
|
|
{ key: 'createdAt', label: 'Created', type: 'datetime', group: 'booking', default: true, select: `to_char(b.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.created_at' },
|
|
{ key: 'updatedAt', label: 'Updated', type: 'datetime', group: 'booking', select: `to_char(b.updated_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.updated_at' },
|
|
{ key: 'expiresAt', label: 'Expires', type: 'date', group: 'booking', select: `to_char(b.expires_at, 'YYYY-MM-DD')` },
|
|
{ key: 'createdByRole', label: 'Created by role', type: 'string', group: 'booking', select: 'b.created_by_role' },
|
|
{ key: 'isSplit', label: 'Split booking', type: 'boolean', group: 'booking', select: 'b.is_split' },
|
|
{ key: 'priorityScore', label: 'Priority score', type: 'number', group: 'booking', select: 'b.priority_score', sortExpr: 'b.priority_score' },
|
|
{ key: 'versionNumber', label: 'Version', type: 'number', group: 'booking', select: 'b.version_number' },
|
|
{ key: 'pnrCode', label: 'PNR code', type: 'string', group: 'booking', select: 'b.pnr_code' },
|
|
|
|
// ---- Customer (the "more than the UI shows" payload) ----------------
|
|
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
|
|
{ key: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' },
|
|
{ key: 'customerKind', label: 'Customer kind', type: 'string', group: 'customer', requires: ['c'], select: 'c.kind' },
|
|
{ key: 'customerStatus', label: 'Customer status', type: 'string', group: 'customer', requires: ['c'], select: 'c.status' },
|
|
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
|
|
{ key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' },
|
|
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
|
|
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
|
|
{ key: 'customerContact', label: 'Contact person', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_name' },
|
|
{ key: 'customerContactPhone', label: 'Contact phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_phone' },
|
|
{ key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' },
|
|
{ key: 'customerCountry', label: 'Customer country', type: 'string', group: 'customer', requires: ['c'], select: 'c.country' },
|
|
{ key: 'customerRegion', label: 'Customer region', type: 'string', group: 'customer', requires: ['c'], select: 'c.region' },
|
|
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
|
|
{ key: 'customerProfileType', label: 'Profile type', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.type' },
|
|
{ key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'b.is_government' },
|
|
{ key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'b.government_institution' },
|
|
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' },
|
|
|
|
// ---- Route ----------------------------------------------------------
|
|
{ key: 'origin', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['o'], select: 'o.label' },
|
|
{ key: 'originCode', label: 'Origin code', type: 'string', group: 'route', requires: ['o'], select: 'o.code' },
|
|
{ key: 'destination', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['d'], select: 'd.label' },
|
|
{ key: 'destinationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['d'], select: 'd.code' },
|
|
{ key: 'tradeDirection', label: 'Direction', type: 'string', group: 'route', default: true, select: 'b.trade_direction', sortExpr: 'b.trade_direction' },
|
|
{ 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)' },
|
|
{ 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' },
|
|
{ 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' },
|
|
{ key: 'shippingLine', label: 'Shipping line', type: 'string', group: 'cargo', requires: ['sl'], select: 'sl.label' },
|
|
{
|
|
// One-to-many, so it aggregates in a correlated subquery rather than a
|
|
// join — a join here would multiply rows and break the count contract.
|
|
key: 'containerNumbers', label: 'Container numbers', type: 'string', group: 'cargo',
|
|
select: `(SELECT string_agg(bc.container_number, ' | ' ORDER BY bc.container_number)
|
|
FROM freight.booking_container bc
|
|
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL)`,
|
|
},
|
|
|
|
// ---- Payment ----------------------------------------------------------
|
|
{ key: 'amount', label: 'Amount', type: 'money', group: 'payment', default: true, select: `ROUND(${REVENUE}, 2)::float8`, sortExpr: REVENUE },
|
|
{ key: 'totalAmount', label: 'Total amount (pre-adjustment)', type: 'money', group: 'payment', select: 'b.total_amount::float8' },
|
|
{ key: 'adjustedTotalAmount', label: 'Adjusted total', type: 'money', group: 'payment', select: 'b.adjusted_total_amount::float8' },
|
|
{ key: 'adjustmentReason', label: 'Adjustment reason', type: 'string', group: 'payment', select: 'b.adjustment_reason' },
|
|
{ key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', default: true, select: 'b.payment_status', sortExpr: 'b.payment_status' },
|
|
{ key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'payment', select: 'b.payment_currency' },
|
|
{ key: 'paymentDeadline', label: 'Payment deadline', type: 'datetime', group: 'payment', select: `to_char(b.payment_deadline, 'YYYY-MM-DD HH24:MI')` },
|
|
|
|
// ---- Scheduling --------------------------------------------------------
|
|
{ key: 'scheduledDate', label: 'Scheduled date', type: 'date', group: 'scheduling', default: true, select: `to_char(b.scheduled_date, 'YYYY-MM-DD')`, sortExpr: 'b.scheduled_date' },
|
|
{ key: 'schedulingStatus', label: 'Scheduling status', type: 'string', group: 'scheduling', select: 'b.scheduling_status' },
|
|
{ key: 'wagonsRequired', label: 'Wagons required', type: 'number', group: 'scheduling', select: 'b.wagons_required' },
|
|
{ key: 'trainCode', label: 'Train', type: 'string', group: 'scheduling', requires: ['t'], select: 't.code' },
|
|
{ key: 'loadedAt', label: 'Loaded at', type: 'datetime', group: 'scheduling', select: `to_char(b.loaded_at, 'YYYY-MM-DD HH24:MI')` },
|
|
{ key: 'arrivedAt', label: 'Arrived at', type: 'datetime', group: 'scheduling', select: `to_char(b.arrived_at, 'YYYY-MM-DD HH24:MI')` },
|
|
|
|
// ---- Contract ----------------------------------------------------------
|
|
{ key: 'contractReference', label: 'Contract reference', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.reference' },
|
|
{ key: 'contractStatus', label: 'Contract status', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.status' },
|
|
{ key: 'contractCustomer', label: 'Contract customer', type: 'string', group: 'contract', requires: ['ctc'], select: 'ctc.name' },
|
|
{ key: 'contractType', label: 'Contract type', type: 'string', group: 'contract', select: 'b.contract_type' },
|
|
{ key: 'contractValidFrom', label: 'Contract valid from', type: 'date', group: 'contract', select: `to_char(b.contract_valid_from, 'YYYY-MM-DD')` },
|
|
{ key: 'contractValidUntil', label: 'Contract valid until', type: 'date', group: 'contract', select: `to_char(b.contract_valid_until, 'YYYY-MM-DD')` },
|
|
{ key: 'fullyExecutedAt', label: 'Fully executed at', type: 'datetime', group: 'contract', select: `to_char(b.fully_executed_at, 'YYYY-MM-DD HH24:MI')` },
|
|
|
|
// ---- First / last mile ---------------------------------------------------
|
|
{ key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'firstMile', select: 'b.first_mile_pickup_address' },
|
|
{ key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'lastMile', select: 'b.last_mile_delivery_address' },
|
|
{ key: 'customerTruckPlate', label: 'Customer truck plate', type: 'string', group: 'lastMile', select: 'b.customer_truck_plate_number' },
|
|
{ key: 'customerTruckDriver', label: 'Customer truck driver', type: 'string', group: 'lastMile', select: 'b.customer_truck_driver_name' },
|
|
{ key: 'exportHandoverMode', label: 'Handover mode', type: 'string', group: 'lastMile', select: 'b.export_handover_mode' },
|
|
|
|
// ---- Clearance -------------------------------------------------------------
|
|
{ key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'b.customs_clearing_enabled' },
|
|
{ key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'b.customs_clearing_agent' },
|
|
{ key: 'clearancePhase', label: 'Clearance phase', type: 'string', group: 'clearance', select: 'b.clearance_current_phase' },
|
|
{ key: 'dutyRequired', label: 'Duty required', type: 'boolean', group: 'clearance', select: 'b.duty_required' },
|
|
{ key: 'vesselArrivalDate', label: 'Vessel arrival', type: 'date', group: 'clearance', select: `to_char(b.vessel_arrival_date, 'YYYY-MM-DD')` },
|
|
{ key: 'doCollectedDate', label: 'DO collected', type: 'date', group: 'clearance', select: `to_char(b.do_collected_date, 'YYYY-MM-DD')` },
|
|
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
|
|
],
|
|
|
|
filters: [
|
|
{ key: 'created', label: 'Created', type: 'daterange' },
|
|
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
|
{
|
|
key: 'tradeDirection', label: 'Direction', type: 'select',
|
|
options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })),
|
|
},
|
|
{
|
|
key: 'freightType', label: 'Freight type', type: 'select',
|
|
options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })),
|
|
},
|
|
{ key: 'paymentStatus', label: 'Payment status', type: 'select', options: [
|
|
{ value: 'PENDING', label: 'Pending' },
|
|
{ value: 'PNR_GENERATED', label: 'PNR generated' },
|
|
{ value: 'VERIFICATION_IN_PROGRESS', label: 'Verification in progress' },
|
|
{ value: 'PAID', label: 'Paid' },
|
|
{ value: 'FAILED', label: 'Failed' },
|
|
] },
|
|
{ key: 'companyId', label: 'Customer', type: 'text' },
|
|
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
|
],
|
|
|
|
defaultSort: { key: 'createdAt', dir: 'DESC' },
|
|
|
|
scope(ctx, qb) {
|
|
const { params, directions } = ctx;
|
|
// andWhere, not where: `where()` resets any condition already on the
|
|
// builder, so scope() would silently drop anything a caller added first.
|
|
qb.andWhere('b.deleted_at IS NULL');
|
|
|
|
if (params.createdFrom) qb.andWhere('b.created_at >= :createdFrom', { createdFrom: params.createdFrom });
|
|
if (params.createdTo) qb.andWhere('b.created_at < :createdTo', { createdTo: params.createdTo });
|
|
|
|
const statuses = params.statuses as string[] | null;
|
|
if (statuses?.length) qb.andWhere('b.status IN (:...statuses)', { statuses });
|
|
|
|
if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
|
|
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
|
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) {
|
|
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
|
|
}
|
|
|
|
// Trade-direction ACL. Without this the export returns rows the user's own
|
|
// list page would not show them.
|
|
applyDirectionScope(qb, 'b.trade_direction', directions);
|
|
},
|
|
};
|