From 0e9cfbce66802f426b271140f58c33507b595119 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 09:20:46 +0000 Subject: [PATCH] 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",