mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
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.
225 lines
17 KiB
TypeScript
225 lines
17 KiB
TypeScript
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';
|
|
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 that the retired `bookings-list` report used to share.
|
|
* 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 = bookingTonsSql('b');
|
|
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);
|
|
},
|
|
};
|