diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 4ec11e082..397845cb3 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -51,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { OperationsReportingModule } from "./modules/operations-reporting/operations-reporting.module"; import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; @@ -101,6 +102,7 @@ import { RoutesModule } from "./modules/routes/routes.module"; import { WarehousesModule } from "./modules/warehouses/warehouses.module"; import { OverviewModule } from "./modules/overview/overview.module"; import { ReportsModule } from "./modules/reports/reports.module"; +import { ExportsModule } from "./modules/exports/exports.module"; import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; @@ -219,6 +221,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + OperationsReportingModule, PaymentSettingsModule, StampSettingsModule, LogoSettingsModule, @@ -239,6 +242,7 @@ if (!process.env.APPLICATION_NAME) { WarehousesModule, OverviewModule, ReportsModule, + ExportsModule, UserTradeAccessModule, VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/common/dto/id-list.transform.spec.ts b/apps/edr-freight-api/src/common/dto/id-list.transform.spec.ts new file mode 100644 index 000000000..a98fce97c --- /dev/null +++ b/apps/edr-freight-api/src/common/dto/id-list.transform.spec.ts @@ -0,0 +1,57 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; + +import { FilterBookingDto } from '../../modules/bookings/dto/filter-booking.dto'; +import { ListTrainSchedulesQueryDto } from '../../modules/train-scheduling/dto/list-train-schedules-query.dto'; + +/** + * The route filters carry one id, `a,b`, or a repeated param, and the + * repositories then branch on `?.length` before emitting `IN (:...ids)`. + * Two things have to hold or that breaks at runtime, not compile time: + * the value must always arrive as an array (a bare string would make + * `.length` count characters), and an absent/blank param must arrive as + * `undefined`, never `[]` — TypeORM turns `[]` into the syntax error `IN ()`. + */ +// Real-shaped v4s: the variant nibble must be 8/9/a/b, so `1111…` is NOT a +// valid UUID and would fail `@IsUUID` for reasons that have nothing to do +// with the list transform under test. +const A = '0a5d4b1e-1b2c-4d3e-8f90-1234567890ab'; +const B = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + +const parse = (cls: new () => T, query: Record): T => + plainToInstance(cls, query); + +describe('route id-list query params', () => { + it('accepts a single id, still as an array', () => { + const dto = parse(FilterBookingDto, { originYardId: A }); + expect(dto.originYardId).toEqual([A]); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('splits a comma-separated list', () => { + const dto = parse(FilterBookingDto, { originYardId: `${A}, ${B}` }); + expect(dto.originYardId).toEqual([A, B]); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('accepts the repeated-param form', () => { + const dto = parse(ListTrainSchedulesQueryDto, { destinationStationId: [A, B] }); + expect(dto.destinationStationId).toEqual([A, B]); + expect(validateSync(dto)).toHaveLength(0); + }); + + it.each([undefined, '', ','])('yields undefined, never [], for %p', (raw) => { + expect(parse(FilterBookingDto, { originYardId: raw }).originYardId).toBeUndefined(); + }); + + it('leaves the two ends independent — one side set, the other absent', () => { + const dto = parse(FilterBookingDto, { originYardId: A }); + expect(dto.originYardId).toEqual([A]); + expect(dto.destinationYardId).toBeUndefined(); + }); + + it('still rejects a non-uuid inside the list', () => { + const dto = parse(FilterBookingDto, { originYardId: `${A},not-a-uuid` }); + expect(validateSync(dto)).not.toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-api/src/common/dto/id-list.transform.ts b/apps/edr-freight-api/src/common/dto/id-list.transform.ts new file mode 100644 index 000000000..3bfe94a77 --- /dev/null +++ b/apps/edr-freight-api/src/common/dto/id-list.transform.ts @@ -0,0 +1,29 @@ +import { Transform } from 'class-transformer'; + +/** + * A query param that carries one id, a comma-separated list (`a,b,c`), or the + * same key repeated — and always lands on the DTO as a `string[]`. + * + * Two details matter: + * + * - It yields `undefined`, never `[]`, when nothing usable is left. `@IsOptional` + * then short-circuits, and — more importantly — a repository that does + * `if (ids?.length)` can never be handed an empty array, which TypeORM turns + * into the syntax error `IN ()`. + * - It is backwards compatible with the single-value form these params used to + * take, so existing deep links and saved views keep working unchanged. + * + * Pair it with `@IsUUID(undefined, { each: true })` (or the relevant `each` + * validator) — this only reshapes the value, it does not validate it. + */ +export const IdListParam = () => + Transform(({ value }: { value: unknown }) => { + const raw = Array.isArray(value) ? value : [value]; + const ids = raw + .flatMap((entry) => + entry === undefined || entry === null ? [] : String(entry).split(','), + ) + .map((s) => s.trim()) + .filter(Boolean); + return ids.length ? ids : undefined; + }); diff --git a/apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts b/apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts new file mode 100644 index 000000000..574fa0ae6 --- /dev/null +++ b/apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts @@ -0,0 +1,32 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; + +import { PaginationQueryDto } from './pagination-query.dto'; +import { ListWagonsQueryDto } from '../../modules/wagons/dto/list-wagons-query.dto'; +import { normalizePagination } from '../utils/pagination.util'; + +/** + * The page-size ceiling is stated in three places that must agree: `@Max` on + * PaginationQueryDto, the same `@Max` repeated on ListWagonsQueryDto (which + * doesn't extend it), and `MAX_PAGE_SIZE` in pagination.util. A fourth copy + * lives outside this package — `MAX_PAGE_SIZE` in @edr/ui-common's data-table + * footer, which is what actually asks for the number. Drift between any of + * them shows up as a 400 on the largest rows-per-page option, so pin them. + */ +const errorsFor = (cls: any, pageSize: unknown) => + validateSync(plainToInstance(cls, { pageSize }), { whitelist: false }); + +describe('page size ceiling', () => { + it.each([PaginationQueryDto, ListWagonsQueryDto])('accepts 500 on %p', (cls) => { + expect(errorsFor(cls, 500)).toHaveLength(0); + }); + + it.each([PaginationQueryDto, ListWagonsQueryDto])('rejects 501 on %p', (cls) => { + expect(errorsFor(cls, 501)).not.toHaveLength(0); + }); + + it('does not truncate 500 in the service-side clamp', () => { + expect(normalizePagination({ page: 1, pageSize: 500 }).take).toBe(500); + expect(normalizePagination({ page: 1, pageSize: 501 }).take).toBe(500); + }); +}); diff --git a/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts index e705b019d..be04dadba 100644 --- a/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts +++ b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts @@ -19,12 +19,18 @@ export class PaginationQueryDto { @Min(1) page?: number; - @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + /** + * Ceiling is 500, matching `MAX_PAGE_SIZE` in `common/utils/pagination.util.ts` + * and the backoffice table footer's largest option. The three have to agree: + * a lower value here turns the footer's top preset into a 400, a higher one + * lets a request through that the util then silently truncates. + */ + @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 500 }) @IsOptional() @Transform(({ value }) => parseInt(String(value), 10) || 20) @IsInt() @Min(1) - @Max(100) + @Max(500) pageSize?: number; @ApiPropertyOptional({ diff --git a/apps/edr-freight-api/src/common/utils/pagination.util.ts b/apps/edr-freight-api/src/common/utils/pagination.util.ts index ca4ed35a1..c7097dde7 100644 --- a/apps/edr-freight-api/src/common/utils/pagination.util.ts +++ b/apps/edr-freight-api/src/common/utils/pagination.util.ts @@ -20,7 +20,12 @@ export interface NormalizedPage { } const DEFAULT_PAGE_SIZE = 20; -const MAX_PAGE_SIZE = 100; +/** + * Must stay in step with `@Max` on `PaginationQueryDto.pageSize` and with + * `MAX_PAGE_SIZE` in the backoffice's data-table footer — the DTO rejects, + * this clamps, and the footer is what actually asks for the number. + */ +const MAX_PAGE_SIZE = 500; /** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */ export function normalizePagination( diff --git a/apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts b/apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts new file mode 100644 index 000000000..140a7ad6a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`. + * + * `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`: + * a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously. + * `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck + * batch (webhook never arrived) can be found and reconciled by conversation id. + */ +export class EimsBulkRegistration3580000000000 implements MigrationInterface { + name = "EimsBulkRegistration3580000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ADD COLUMN IF NOT EXISTS in_flight_conversation_id text + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + DROP COLUMN IF EXISTS in_flight_conversation_id + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_bulk_conversation_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts b/apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts new file mode 100644 index 000000000..1813a7b3a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts @@ -0,0 +1,114 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Reference data for the operations reporting suite (turnaround, delay, + * trainset, TEU, cargo volume). + * + * Two new tables and two new columns: + * + * - `operations_standards` — single-row settings table, same shape as + * `logo_settings` / `exchange_settings`. Holds the railway's standard times + * and charged-tonnage factors. Editable in the backoffice because the + * business calls the corridor standard "flexible". + * - `operations_targets` — the planned side of every "Plan / Operated / + * Implement Rate" table in the spec. One row per period × metric × + * dimension value. + * - `yard_distances.standard_hours` — the per-corridor standard transit time + * (Negad→GMP 21h, →Adama 20h, →Modjo 20.5h, →Sebeta 22h). Null falls back to + * `operations_standards.default_leg_standard_hours`. + * - `cargo_types.full_trainset_wagons` — wagons in a full trainset of this + * cargo (37 for vehicles, 22 for sand). Null falls back to + * `operations_standards.default_full_trainset_wagons`. + * + * The seed row is inserted only when the table is empty, so re-running this + * never overwrites values an operator has since edited. + */ +export class OperationsReporting3580000000000 implements MigrationInterface { + name = "OperationsReporting3580000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.operations_standards ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + station_standard_hours_ethiopia numeric(6,2) NOT NULL DEFAULT 10, + station_standard_hours_djibouti numeric(6,2) NOT NULL DEFAULT 13, + cycle_standard_hours_container numeric(6,2) NOT NULL DEFAULT 65, + cycle_standard_hours_bulk_dmp numeric(6,2) NOT NULL DEFAULT 88, + cycle_standard_hours_bulk_nagad numeric(6,2) NOT NULL DEFAULT 96, + cycle_standard_hours_bulk_bcc numeric(6,2) NOT NULL DEFAULT 96, + default_leg_standard_hours numeric(6,2) NOT NULL DEFAULT 21, + delay_tolerance_minutes integer NOT NULL DEFAULT 30, + charged_tons_full_20ft numeric(8,2) NOT NULL DEFAULT 20, + charged_tons_full_40ft numeric(8,2) NOT NULL DEFAULT 40, + charged_tons_empty_20ft numeric(8,2) NOT NULL DEFAULT 2.24, + charged_tons_empty_40ft numeric(8,2) NOT NULL DEFAULT 3.88, + charged_tons_per_wagon_general numeric(8,2) NOT NULL DEFAULT 70, + charged_tons_per_wagon_perishable numeric(8,2) NOT NULL DEFAULT 38, + default_full_trainset_wagons integer NOT NULL DEFAULT 50, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Column defaults carry every value — the seed only needs the row to exist. + await queryRunner.query(` + INSERT INTO freight.operations_standards (id) + SELECT gen_random_uuid() + WHERE NOT EXISTS (SELECT 1 FROM freight.operations_standards WHERE deleted_at IS NULL); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.operations_targets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + period_type varchar(10) NOT NULL, + period_start date NOT NULL, + metric varchar(20) NOT NULL, + dimension varchar(20) NOT NULL, + dimension_key varchar(60) NOT NULL, + planned_value numeric(14,3) NOT NULL, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Partial unique index rather than a table constraint, so a soft-deleted + // target can be re-created — same choice as yard_distances. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot + ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key) + WHERE deleted_at IS NULL; + `); + + // The reports look targets up by period and metric, never by id. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_operations_targets_lookup + ON freight.operations_targets (metric, period_type, period_start) + WHERE deleted_at IS NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.yard_distances + ADD COLUMN IF NOT EXISTS standard_hours numeric(6,2); + `); + + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS full_trainset_wagons integer; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS full_trainset_wagons;`, + ); + await queryRunner.query( + `ALTER TABLE freight.yard_distances DROP COLUMN IF EXISTS standard_hours;`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_targets;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_standards;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts b/apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts new file mode 100644 index 000000000..c77087044 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A station's plan is per station AND per cargo type, not per station. + * + * The OCC monthly report plans "Nagad–Mojo multimodal container 122,010 t" and + * "Nagad–Mojo fertilizer 18,000 t" as separate lines against the same station, + * which the single `dimension_key` column cannot express: a station-keyed target + * would apply the whole station's plan to each of its cargo types. + * + * `cargo_category` is nullable, so `cargo_category` and `container_class` + * targets are unaffected — they leave it null and stay keyed on + * `dimension_key` alone. The uniqueness index moves to include it, since + * (station, category) is now the slot. + */ +export class OperationsTargetCargoCategory3590000000000 implements MigrationInterface { + name = "OperationsTargetCargoCategory3590000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.operations_targets + ADD COLUMN IF NOT EXISTS cargo_category varchar(60); + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`); + + // COALESCE rather than a plain column list: a partial unique index treats + // NULLs as distinct, which would let the same category target be entered + // twice over. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot + ON freight.operations_targets ( + period_type, period_start, metric, dimension, dimension_key, + COALESCE(cargo_category, '') + ) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot + ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key) + WHERE deleted_at IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.operations_targets DROP COLUMN IF EXISTS cargo_category; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts new file mode 100644 index 000000000..2525c1b3d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */ +export class AdditionalCharge3620000000000 implements MigrationInterface { + name = 'AdditionalCharge3620000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "freight"."additional_charge" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "booking_id" uuid NOT NULL, + "reason" text NOT NULL, + "status" character varying(20) NOT NULL DEFAULT 'DRAFT', + "amount" numeric(14,2) NOT NULL, + "currency" character varying(8) NOT NULL, + "file_record_id" uuid, + "invoice_id" uuid, + "payment_reference" character varying(64), + "created_by_staff_id" uuid, + "sent_by_staff_id" uuid, + "sent_at" timestamptz, + "paid_at" timestamptz, + "cancelled_by_staff_id" uuid, + "cancelled_at" timestamptz, + "cancel_reason" text, + CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"), + CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id") + REFERENCES "freight"."bookings"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking" + ON "freight"."additional_charge" ("booking_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`); + } +} 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 d64361bf8..247a1dad5 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -13,6 +13,7 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { AdditionalCharge } from "../bookings/entities/additional-charge.entity"; // Entity-only import (no module edge): portal reads resolve shipping-line // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; @@ -46,6 +47,29 @@ export interface PayInvoiceOptions { failureUrl?: string; } +/** + * What an invoice's `sourceId` actually points at, resolved for display. + * + * `source` alone ("warehouse", "booking", …) says which subsystem raised the + * invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every + * source except a shipping-line credit hangs off a booking — directly + * (booking/clearance) or through the warehouse/first-mile/last-mile record — + * so the booking reference is the one label that identifies almost any row. + */ +export interface InvoiceSourceRef { + /** Booking behind the invoice, when there is one. Null for shipping-line credits. */ + bookingId: string | null; + bookingReference: string | null; + tradeDirection: string | null; + /** Warehouse-sourced rows: the goods-received note the fees were raised against. */ + grnNumber: string | null; + /** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */ + shippingLineName: string | null; +} + +/** Row shape of the backoffice invoice list: the entity plus its resolved source. */ +export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null }; + /** Booking context attached to a finance offline-USD invoice row. */ export interface OfflineUsdBookingInfo { id: string; @@ -236,8 +260,32 @@ export class BillingService { qb.andWhere("invoice.status = :status", { status: filter.status }); } if (filter.search) { + // Searches what the row actually shows: its number, who it bills, and + // the source record behind it (booking reference, GRN, shipping line). + // The raw `sourceId` stays matchable so a pasted UUID still resolves. + // Requires the `company` alias — every caller of this joins it. qb.andWhere( - "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + `(invoice.invoiceNumber ILIKE :search + OR invoice.sourceId ILIKE :search + OR company.name ILIKE :search + OR EXISTS ( + SELECT 1 FROM freight.bookings b + LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id + LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id + LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id + WHERE b.reference ILIKE :search + AND (b.id::text = invoice.source_id + OR wi.id::text = invoice.source_id + OR fm.id::text = invoice.source_id + OR lm.id::text = invoice.source_id)) + OR EXISTS ( + SELECT 1 FROM freight.warehouse_inventory wi2 + WHERE wi2.id::text = invoice.source_id + AND wi2.grn_number ILIKE :search) + OR EXISTS ( + SELECT 1 FROM freight.shipping_line_companies slc + WHERE slc.id::text = invoice.source_id + AND slc.name ILIKE :search))`, { search: `%${filter.search}%` }, ); } @@ -261,7 +309,7 @@ export class BillingService { /** Per-user trade-direction scope, applied via the source booking. */ tradeDirections?: string[]; } = {}, - ): Promise<{ items: Invoice[]; total: number }> { + ): Promise<{ items: InvoiceListRow[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; @@ -277,7 +325,92 @@ export class BillingService { this.applyInvoiceFilters(qb, filter); const [items, total] = await qb.getManyAndCount(); - return { items: await this.attachShippingLineCompanies(items), total }; + const withLines = await this.attachShippingLineCompanies(items); + return { items: await this.attachSourceRefs(withLines), total }; + } + + /** + * Resolve each row's `sourceId` to the record it points at, in one query for + * the whole page. `sourceId` is a bare varchar pointer with no FK and no + * relation to eager-load, and which table it addresses depends on `source` — + * so this walks every candidate table at once and lands on the booking + * through whichever one matched. + * + * `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence + * the shape guard before every cast — an unguarded `::uuid` throws on those. + */ + private async attachSourceRefs( + invoices: T[], + ): Promise<(T & { sourceRef: InvoiceSourceRef | null })[]> { + const sourceIds = [ + ...new Set(invoices.map((i) => i.sourceId).filter(Boolean)), + ]; + if (!sourceIds.length) { + return invoices.map((invoice) => ({ ...invoice, sourceRef: null })); + } + + const rows: { + sourceId: string; + bookingId: string | null; + bookingReference: string | null; + tradeDirection: string | null; + grnNumber: string | null; + shippingLineName: string | null; + }[] = await this.dataSource.query( + `SELECT s.source_id AS "sourceId", + b.id::text AS "bookingId", + b.reference AS "bookingReference", + b.trade_direction AS "tradeDirection", + wi.grn_number AS "grnNumber", + slc.name AS "shippingLineName" + FROM unnest($1::text[]) AS s(source_id) + LEFT JOIN freight.warehouse_inventory wi + ON wi.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND wi.deleted_at IS NULL + LEFT JOIN freight.first_mile fm + ON fm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND fm.deleted_at IS NULL + LEFT JOIN freight.last_mile lm + ON lm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND lm.deleted_at IS NULL + LEFT JOIN freight.bookings b + ON b.id = COALESCE(wi.booking_id, fm.booking_id, lm.booking_id, + CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND b.deleted_at IS NULL + LEFT JOIN freight.shipping_line_companies slc + ON slc.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND slc.deleted_at IS NULL`, + [sourceIds], + ); + + const bySourceId = new Map(rows.map((r) => [r.sourceId, r])); + return invoices.map((invoice) => { + const row = bySourceId.get(invoice.sourceId); + const sourceRef: InvoiceSourceRef | null = row + ? { + bookingId: row.bookingId, + bookingReference: row.bookingReference, + tradeDirection: row.tradeDirection, + grnNumber: row.grnNumber, + shippingLineName: row.shippingLineName, + } + : null; + // Nothing resolved (an EIMS self-test row, a deleted record) → null, + // and the UI falls back to the plain source label. + const resolved = + sourceRef && + (sourceRef.bookingId || + sourceRef.grnNumber || + sourceRef.shippingLineName) + ? sourceRef + : null; + return { ...invoice, sourceRef: resolved }; + }); } /** @@ -336,6 +469,9 @@ export class BillingService { const qb = this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice") + // Joined, not selected: `applyInvoiceFilters` searches the customer name, + // so the alias has to exist even though the summary only sums money. + .leftJoin("invoice.company", "company") .select("invoice.currency", "currency") .addSelect("SUM(invoice.paidAmount)", "collected") .groupBy("invoice.currency"); @@ -1823,6 +1959,14 @@ export class BillingService { .getRepository(Booking) .update({ id: invoice.sourceId }, { pnrCode: billReference }); } + // Same reference, for an ad-hoc additional charge — its own column, since + // an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking + // can carry many of these at once. + if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) { + await this.dataSource + .getRepository(AdditionalCharge) + .update({ id: invoice.sourceId }, { paymentReference: billReference }); + } // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept for local demos only. diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 43b6e3271..66bf0f452 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -203,4 +203,12 @@ export class Invoice extends BaseEntity { @ManyToOne(() => Invoice) @JoinColumn({ name: "related_invoice_id" }) relatedInvoice?: Invoice | null; + + /** + * Which `POST /v1/bulkRegister` batch this invoice was submitted in, if any — MoR's own + * conversation id, not one we generate. Lets a stuck batch (webhook never arrived) be found and + * reconciled. Null for every invoice filed through single `/v1/register`. + */ + @Column({ name: "eims_bulk_conversation_id", type: "text", nullable: true }) + eimsBulkConversationId?: string | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts new file mode 100644 index 000000000..9a7e652b0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { AdditionalCharge } from './entities/additional-charge.entity'; + +@Injectable() +export class AdditionalChargeRepository extends BaseRepository { + constructor(@InjectRepository(AdditionalCharge) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts new file mode 100644 index 000000000..bb5836d1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -0,0 +1,281 @@ +import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource, EntityManager } from 'typeorm'; +import { Freight, NotificationAudience, NotificationType } from '@edr/types'; + +import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FilesService } from '../files/files.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsService } from './bookings.service'; +import { BookingsRepository } from './bookings.repository'; +import { AdditionalChargeRepository } from './additional-charge.repository'; +import { AdditionalCharge } from './entities/additional-charge.entity'; +import { CreateAdditionalChargeDto } from './dto/additional-charge.dto'; + +const FILE_RESOURCE = 'additional_charges'; + +/** + * Ad-hoc extra charges finance raises against a booking, independent of + * `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS + * row per booking). Any number per booking, free-text reason. DRAFT until + * sent; sending issues the payable invoice and notifies the customer + * (in-app + SMS + email). Settles via `additional_charge.invoice.paid`, + * same event-driven pattern as every other invoice source. + */ +@Injectable() +export class AdditionalChargeService { + private readonly logger = new Logger(AdditionalChargeService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly repository: AdditionalChargeRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + private readonly billing: BillingService, + private readonly bookingsService: BookingsService, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private async findOwned(bookingId: string, chargeId: string): Promise { + const charge = await this.repository.findById(chargeId); + if (!charge || charge.bookingId !== bookingId) { + throw new NotFoundException('Additional charge not found'); + } + return charge; + } + + async list(bookingId: string): Promise { + const rows = await this.repository.findAll({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + return this.toDtoList(rows); + } + + async create( + bookingId: string, + dto: CreateAdditionalChargeDto, + staffId: string, + file?: Express.Multer.File, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + const shouldSend = dto.action === 'send'; + + const chargeId = await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(AdditionalCharge); + let saved = await repo.save( + repo.create({ + bookingId, + reason: dto.reason.trim(), + amount: dto.amount.toFixed(2), + currency: dto.currency.trim().toUpperCase(), + status: 'DRAFT', + createdByStaffId: staffId, + }), + ); + + if (file) { + const record = await this.filesService.upload({ + resourceId: saved.id, + resource: FILE_RESOURCE, + code: FILE_RESOURCE, + file, + uploadedByUserId: staffId, + }); + await repo.update(saved.id, { fileRecordId: record.id }); + } + + if (shouldSend) { + saved = await this.issueInvoice(manager, saved.id, booking, staffId); + } + return saved.id; + }); + + if (shouldSend) await this.notifyCustomerSent(chargeId); + return this.list(bookingId); + } + + async send(bookingId: string, chargeId: string, staffId: string): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT') { + throw new ConflictException('Only a draft charge can be sent.'); + } + const booking = await this.bookingsService.findById(bookingId); + + await this.dataSource.transaction((manager) => + this.issueInvoice(manager, charge.id, booking, staffId), + ); + await this.notifyCustomerSent(charge.id); + return this.list(bookingId); + } + + /** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */ + private async issueInvoice( + manager: EntityManager, + chargeId: string, + booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null }, + staffId: string, + ): Promise { + const repo = manager.getRepository(AdditionalCharge); + const charge = await repo.findOneByOrFail({ id: chargeId }); + + const invoice = await this.billing.generateInvoice( + { + source: Freight.InvoiceSource.AdditionalCharge, + sourceId: charge.id, + type: 'ADDITIONAL_CHARGE', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: charge.currency, + lines: [ + { + chargeType: 'ADDITIONAL_CHARGE', + description: `${charge.reason} — ${booking.reference ?? booking.id}`, + amount: Number(charge.amount), + }, + ], + }, + manager, + ); + + await repo.update(charge.id, { + status: 'SENT', + invoiceId: invoice.id, + sentByStaffId: staffId, + sentAt: new Date(), + }); + this.logger.log( + `Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`, + ); + return repo.findOneByOrFail({ id: charge.id }); + } + + private async notifyCustomerSent(chargeId: string): Promise { + try { + const charge = await this.repository.findById(chargeId); + if (!charge) return; + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'New charge on your booking', + body, + link: `/bookings/${charge.bookingId}`, + data: { + bookingId: charge.bookingId, + chargeId: charge.id, + amount: Number(charge.amount), + currency: charge.currency, + }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`); + } + } + + async cancel( + bookingId: string, + chargeId: string, + staffId: string, + reason?: string, + ): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT' && charge.status !== 'SENT') { + throw new ConflictException('Only a draft or unpaid charge can be cancelled.'); + } + if (charge.status === 'SENT' && charge.invoiceId) { + await this.billing.cancelInvoice(charge.invoiceId); + } + await this.repository.update(charge.id, { + status: 'CANCELLED', + cancelledByStaffId: staffId, + cancelledAt: new Date(), + cancelReason: reason ?? null, + }); + return this.list(bookingId); + } + + /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ + @OnEvent('additional_charge.invoice.paid') + async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise { + const charge = await this.repository.findById(payload.sourceId); + if (!charge || charge.status === 'PAID') return; + await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() }); + + try { + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Charge payment received', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + await this.inbox.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Additional charge paid', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + } catch (err) { + this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`); + } + } + + private async toDtoList(rows: AdditionalCharge[]): Promise { + if (!rows.length) return []; + + const filesByCharge = await this.filesService.findByResourceIdsGrouped( + rows.map((r) => r.id), + FILE_RESOURCE, + ); + const names = await this.bookingsRepository.resolveStaffNames( + rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]), + ); + + const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id)); + const invoices = invoiceIds.length + ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) + : []; + const invoiceById = new Map(invoices.map((i) => [i.id, i])); + + return rows.map((r) => { + const file = filesByCharge.get(r.id)?.[0]; + return { + id: r.id, + bookingId: r.bookingId, + reason: r.reason, + status: r.status, + amount: Number(r.amount), + currency: r.currency, + file: file ? { id: file.id, name: file.name, url: file.url } : null, + invoiceId: r.invoiceId ?? null, + invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null, + paymentReference: r.paymentReference ?? null, + createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null, + createdAt: r.createdAt.toISOString(), + sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null, + sentAt: r.sentAt?.toISOString() ?? null, + paidAt: r.paidAt?.toISOString() ?? null, + cancelledAt: r.cancelledAt?.toISOString() ?? null, + cancelReason: r.cancelReason ?? null, + }; + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index bec8f8b29..98bc2332e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -42,10 +42,16 @@ import type { Response } from "express"; import { BookingClearanceChargeService } from './booking-clearance-charge.service'; import { BookingPayablesService } from './booking-payables.service'; import { ClearanceEventService } from './clearance-event.service'; +<<<<<<< HEAD import { BillClearanceChargeDto, RejectClearanceChargeDto, } from './dto/clearance-charge.dto'; +======= +import { BillClearanceChargeDto } from './dto/clearance-charge.dto'; +import { AdditionalChargeService } from './additional-charge.service'; +import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto'; +>>>>>>> 82c795999efce5e4422dd332551cecab2592764d import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingTransitionService } from './booking-transition.service'; @@ -181,6 +187,7 @@ export class BookingsController { private readonly clearanceChargeService: BookingClearanceChargeService, private readonly bookingPayablesService: BookingPayablesService, private readonly clearanceEventService: ClearanceEventService, + private readonly additionalChargeService: AdditionalChargeService, ) {} @Post() @@ -1278,6 +1285,64 @@ export class BookingsController { ); } + // ── Additional charges (ad-hoc finance billing) ──────────────────────────── + + @Get(":id/additional-charges") + @MixedAudience(FREIGHT_PERMS.additionalCharges.view) + @ApiOperation({ summary: "Ad-hoc extra charges finance has raised against this booking" }) + async getAdditionalCharges( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view); + if (!isStaff) { + const booking = await this.bookingsService.findById(id); + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const charges = await this.additionalChargeService.list(id); + // A charge finance hasn't sent yet isn't the customer's to see. + return isStaff ? charges : charges.filter((c) => c.status !== "DRAFT"); + } + + @Post(":id/additional-charges") + @BookingStaff(FREIGHT_PERMS.additionalCharges.create) + @UseInterceptors(FileInterceptor("file")) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Finance raises a new additional charge — draft, or send to the customer immediately", + }) + createAdditionalCharge( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Body() dto: CreateAdditionalChargeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.additionalChargeService.create(id, dto, resolveAuthUserId(user), file); + } + + @Post(":id/additional-charges/:chargeId/send") + @BookingStaff(FREIGHT_PERMS.additionalCharges.send) + @ApiOperation({ summary: "Issue the draft charge's payable invoice and notify the customer" }) + sendAdditionalCharge( + @Param("id", ParseUUIDPipe) id: string, + @Param("chargeId", ParseUUIDPipe) chargeId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.additionalChargeService.send(id, chargeId, resolveAuthUserId(user)); + } + + @Post(":id/additional-charges/:chargeId/cancel") + @BookingStaff(FREIGHT_PERMS.additionalCharges.cancel) + @ApiOperation({ summary: "Withdraw a draft or unpaid additional charge" }) + cancelAdditionalCharge( + @Param("id", ParseUUIDPipe) id: string, + @Param("chargeId", ParseUUIDPipe) chargeId: string, + @Body() dto: CancelAdditionalChargeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.additionalChargeService.cancel(id, chargeId, resolveAuthUserId(user), dto.reason); + } + @Post(":id/clearance/output-documents") @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 442ace419..e119af52e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -38,6 +38,9 @@ import { BookingsService } from './bookings.service'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview } from './entities/booking-document-review.entity'; import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity'; +import { AdditionalCharge } from './entities/additional-charge.entity'; +import { AdditionalChargeRepository } from './additional-charge.repository'; +import { AdditionalChargeService } from './additional-charge.service'; import { BookingClearanceChargeService } from './booking-clearance-charge.service'; import { BookingPayablesService } from './booking-payables.service'; import { BookingClearanceEvent } from './entities/booking-clearance-event.entity'; @@ -83,6 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ConsolidationApproval, BookingClearanceCharge, BookingClearanceEvent, + AdditionalCharge, ]), BillingModule, DocumentsModule, @@ -121,6 +125,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingClearanceChargeService, BookingPayablesService, ClearanceEventService, + AdditionalChargeRepository, + AdditionalChargeService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, 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 3ad75242d..efd6ed217 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -79,8 +79,10 @@ export interface BookingListFilterOptions { createdTo?: string; scheduledFrom?: string; scheduledTo?: string; - originYardId?: string; - destinationYardId?: string; + /** Any of these origin yards (OR). ANDed with `destinationYardId`. */ + originYardId?: string[]; + /** Any of these destination yards (OR). ANDed with `originYardId`. */ + destinationYardId?: string[]; isGovernment?: 'true' | 'false'; /** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */ customerKind?: 'SHIPPING_LINE' | 'CUSTOMER'; @@ -1151,14 +1153,17 @@ export class BookingsRepository extends BaseRepository { scheduledTo: options.scheduledTo, }); } - if (options.originYardId) { - qb.andWhere('booking.origin_yard_id = :originYardId', { - originYardId: options.originYardId, + // Each end is its own OR-list, and the two ends AND together — so + // "leaving Nagad or DMP" and "leaving Nagad, arriving Gelan" are both + // expressible. `?.length` guards the empty array: `IN ()` is a syntax error. + if (options.originYardId?.length) { + qb.andWhere('booking.origin_yard_id IN (:...originYardIds)', { + originYardIds: options.originYardId, }); } - if (options.destinationYardId) { - qb.andWhere('booking.destination_yard_id = :destinationYardId', { - destinationYardId: options.destinationYardId, + if (options.destinationYardId?.length) { + qb.andWhere('booking.destination_yard_id IN (:...destinationYardIds)', { + destinationYardIds: options.destinationYardId, }); } if (options.isGovernment === 'true') { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts new file mode 100644 index 000000000..eb4d095bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator'; + +export class CreateAdditionalChargeDto { + @ApiProperty({ example: 'Re-weighing fee at Mojo dry port' }) + @IsString() + @Length(1, 2000) + reason!: string; + + @ApiProperty({ example: 4500 }) + @Type(() => Number) + @IsNumber() + @IsPositive() + amount!: number; + + @ApiProperty({ example: 'ETB' }) + @IsString() + @Length(3, 8) + currency!: string; + + /** 'send' issues the invoice + notifies the customer immediately; omit/'draft' just saves it. */ + @ApiPropertyOptional({ enum: ['draft', 'send'], default: 'draft' }) + @IsOptional() + @IsIn(['draft', 'send']) + action?: 'draft' | 'send'; +} + +export class CancelAdditionalChargeDto { + @ApiPropertyOptional({ example: 'Raised in error' }) + @IsOptional() + @IsString() + @Length(1, 2000) + reason?: string; +} 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 4ffde3d0d..a404c005e 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 @@ -9,6 +9,7 @@ import { TRADE_DIRECTIONS, } from './create-booking.dto'; import { PAYMENT_STATUSES } from '../entities/booking.entity'; +import { IdListParam } from '../../../common/dto/id-list.transform'; export class FilterBookingDto { @ApiPropertyOptional({ enum: BOOKING_STATUSES }) @@ -96,15 +97,23 @@ export class FilterBookingDto { @IsDateString() scheduledTo?: string; - @ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' }) + @ApiPropertyOptional({ + description: + 'Filter by origin yard — one id or a comma-separated list; a booking matches if it leaves ANY of them.', + }) @IsOptional() - @IsUUID() - originYardId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + originYardId?: string[]; - @ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' }) + @ApiPropertyOptional({ + description: + 'Filter by destination yard — one id or a comma-separated list; a booking matches if it arrives at ANY of them. Combined with originYardId by AND.', + }) @IsOptional() - @IsUUID() - destinationYardId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + destinationYardId?: string[]; @ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts new file mode 100644 index 000000000..11ea0bdb1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts @@ -0,0 +1,74 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; + +export const ADDITIONAL_CHARGE_STATUSES = [ + 'DRAFT', + 'SENT', + 'PAID', + 'CANCELLED', +] as const; +export type AdditionalChargeStatus = (typeof ADDITIONAL_CHARGE_STATUSES)[number]; + +/** + * An ad-hoc extra charge finance raises against a booking — free-text reason, + * any number per booking (unlike `BookingClearanceCharge`, which caps at one + * per type). DRAFT until finance sends it; sending issues the payable invoice + * and notifies the customer (in-app + SMS + email). PAID via the standard + * `additional_charge.invoice.paid` settlement event. + */ +@Entity({ schema: 'freight', name: 'additional_charge' }) +@Index(['bookingId']) +export class AdditionalCharge extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'reason', type: 'text' }) + reason!: string; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: AdditionalChargeStatus; + + @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 }) + amount!: string; + + @Column({ name: 'currency', type: 'varchar', length: 8 }) + currency!: string; + + /** The supporting attachment (FileRecord), if any. */ + @Column({ name: 'file_record_id', type: 'uuid', nullable: true }) + fileRecordId?: string | null; + + /** The payable invoice issued for this charge (null until SENT). */ + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + /** CBE bill reference / PNR the customer pays against, once issued. */ + @Column({ name: 'payment_reference', type: 'varchar', length: 64, nullable: true }) + paymentReference?: string | null; + + @Column({ name: 'created_by_staff_id', type: 'uuid', nullable: true }) + createdByStaffId?: string | null; + + @Column({ name: 'sent_by_staff_id', type: 'uuid', nullable: true }) + sentByStaffId?: string | null; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt?: Date | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt?: Date | null; + + @Column({ name: 'cancelled_by_staff_id', type: 'uuid', nullable: true }) + cancelledByStaffId?: string | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date | null; + + @Column({ name: 'cancel_reason', type: 'text', nullable: true }) + cancelReason?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 309079010..e289674b2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -48,8 +48,10 @@ export interface ContractListFilterOptions { hasClearanceDocuments?: boolean; createdFrom?: string; createdTo?: string; - originYardId?: string; - destinationYardId?: string; + /** Any of these origin yards (OR). ANDed with `destinationYardId`. */ + originYardId?: string[]; + /** Any of these destination yards (OR). ANDed with `originYardId`. */ + destinationYardId?: string[]; } @Injectable() @@ -494,20 +496,25 @@ export class ContractsRepository extends BaseRepository { // Routes are one-to-many (a contract can list several lanes), so origin // and destination each need their own EXISTS — a plain join would // duplicate the contract row per matching route. - if (omit !== 'originYardId' && options.originYardId) { + // Each end is an OR-list, the two ends AND together. Note this still means + // "has a route from one of these origins" AND "has a route to one of these + // destinations" — not necessarily the SAME route, which is what the two + // separate EXISTS have always meant and what the filter bar's two + // independent pickers describe. + if (omit !== 'originYardId' && options.originYardId?.length) { qb.andWhere( 'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' + 'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' + - 'AND cr_o.origin_yard_id = :originYardId)', - { originYardId: options.originYardId }, + 'AND cr_o.origin_yard_id IN (:...originYardIds))', + { originYardIds: options.originYardId }, ); } - if (omit !== 'destinationYardId' && options.destinationYardId) { + if (omit !== 'destinationYardId' && options.destinationYardId?.length) { qb.andWhere( 'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' + 'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' + - 'AND cr_d.destination_yard_id = :destinationYardId)', - { destinationYardId: options.destinationYardId }, + 'AND cr_d.destination_yard_id IN (:...destinationYardIds))', + { destinationYardIds: options.destinationYardId }, ); } } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts index a03d52afb..1d513b2c0 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts @@ -3,6 +3,7 @@ import { Transform } from 'class-transformer'; import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity'; +import { IdListParam } from '../../../common/dto/id-list.transform'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; @@ -62,20 +63,22 @@ export class FilterContractDto { paymentCurrency?: string; @ApiPropertyOptional({ - format: 'uuid', - description: 'Only contracts with a route starting at this yard.', + description: + 'Only contracts with a route starting at one of these yards — a single id or a comma-separated list.', }) @IsOptional() - @IsUUID() - originYardId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + originYardId?: string[]; @ApiPropertyOptional({ - format: 'uuid', - description: 'Only contracts with a route ending at this yard.', + description: + 'Only contracts with a route ending at one of these yards — a single id or a comma-separated list. ANDed with originYardId.', }) @IsOptional() - @IsUUID() - destinationYardId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + destinationYardId?: string[]; @ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/eims/dto/bulk-register-eims-invoice.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/bulk-register-eims-invoice.dto.ts new file mode 100644 index 000000000..2509275e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/bulk-register-eims-invoice.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ArrayMinSize, IsArray, IsUUID } from "class-validator"; + +/** `POST invoices/eims/bulk-register` body — see `EimsBulkRegistrationService.registerBulk`. */ +export class BulkRegisterEimsInvoiceDto { + @ApiProperty({ type: [String], description: "Invoice IDs to register with MoR EIMS in one batch." }) + @IsArray() + @ArrayMinSize(1) + @IsUUID("4", { each: true }) + invoiceIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts new file mode 100644 index 000000000..459ee8f71 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts @@ -0,0 +1,359 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { Invoice } from "../billing/entities/invoice.entity"; +import { NotificationsService } from "../notifications/notifications.service"; +import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsBulkRegistrationService } from "./eims-bulk-registration.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { EimsApiException } from "./eims.errors"; +import { EimsInvoiceStatus } from "./eims-registration.types"; +import { buildEimsSeller } from "./eims-invoice-context"; + +const SYSTEM_NUMBER = "B0360154BA"; +const INVOICE_A = "11111111-1111-4111-8111-111111111111"; +const INVOICE_B = "22222222-2222-4222-8222-222222222222"; +const CONVERSATION_ID = "2345678901-1735900502800-c04f8dd6-e6e2-4198-b871-c6e504fc14f5"; + +const invoiceRow = (over: Partial = {}): Invoice => + ({ + id: INVOICE_A, + invoiceNumber: "INV-20260807-00001", + currency: "ETB", + companyId: "company-1", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "10000.00", + eimsStatus: EimsInvoiceStatus.NotSubmitted, + eimsIrn: null, + eimsDocumentType: "INV", + eimsBulkConversationId: null, + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + ...over, + }) as unknown as Invoice; + +const LINES = (id: string) => [ + { + invoiceId: id, + chargeType: "RAIL_FREIGHT", + description: "Addis to Djibouti", + quantity: "1.00", + unitRate: "10000.00", + amount: "10000.00", + }, +]; + +/** In-memory stand-in covering the query/manager surface this service actually calls. */ +class FakeDb { + invoices = new Map(); + state: EimsSystemState; + companyContact: { phone: string | null; email: string | null } | null = null; + + constructor(invoices: Invoice[], state: Partial = {}) { + for (const inv of invoices) this.invoices.set(inv.id, inv); + this.state = { + id: "state-1", + systemNumber: SYSTEM_NUMBER, + nextInvoiceCounter: 1, + nextDocumentNumber: 1, + previousIrn: null, + inFlightInvoiceId: null, + inFlightCounter: null, + inFlightDocumentNumber: null, + inFlightConversationId: null, + blockedReason: null, + ...state, + } as EimsSystemState; + } + + private matches(entity: Invoice | EimsSystemState, where: Record): boolean { + return Object.entries(where).every(([key, value]) => (entity as never)[key] === value); + } + + private queryBuilder(entityCtor: unknown) { + let where: Record = {}; + const builder = { + setLock: () => builder, + where: (_clause: string, params: Record) => { + where = { ...where, ...this.normalizeParams(params) }; + return builder; + }, + andWhere: (_clause: string, params: Record) => { + where = { ...where, ...this.normalizeParams(params) }; + return builder; + }, + getOne: async () => this.find(entityCtor, where)[0] ?? null, + getMany: async () => this.find(entityCtor, where), + }; + return builder; + } + + private normalizeParams(params: Record): Record { + // Test-only mapping from the SQL param names used in the service's own queries to entity fields. + const map: Record = { + invoiceId: "id", + systemNumber: "systemNumber", + id: "eimsBulkConversationId", + }; + const out: Record = {}; + for (const [k, v] of Object.entries(params)) out[map[k] ?? k] = v; + return out; + } + + private find(entityCtor: unknown, where: Record): Array { + const isState = entityCtor === EimsSystemState; + const pool: Array = isState ? [this.state] : [...this.invoices.values()]; + return pool.filter((e) => this.matches(e, where)); + } + + private manager = { + createQueryBuilder: (entityCtor: unknown) => this.queryBuilder(entityCtor), + query: async () => [], + findOne: async (entityCtor: unknown, options: { where: Record }) => + this.find(entityCtor, options.where)[0] ?? null, + update: async (entityCtor: unknown, idOrWhere: string | Record, patch: Record) => { + const targets = + typeof idOrWhere === "string" + ? this.find(entityCtor, { id: idOrWhere }) + : this.find(entityCtor, idOrWhere); + for (const t of targets) Object.assign(t, patch); + return { affected: targets.length }; + }, + getRepository: (entityCtor: unknown) => ({ + findOne: async (options: { where: { id: string } }) => this.find(entityCtor, { id: options.where.id })[0] ?? null, + }), + }; + + asDataSource(): DataSource { + return { + manager: this.manager, + // Routed by SQL text: the lines lookup and sendCompanyChannels' contact lookup share this + // one entry point in the real DataSource. + query: async (sql: string) => { + if (sql.includes("invoice_lines")) { + return [...this.invoices.keys()].flatMap((id) => LINES(id)); + } + return this.companyContact ? [this.companyContact] : []; + }, + transaction: async (body: (m: unknown) => Promise) => body(this.manager), + getRepository: () => ({ + find: async (options: { where: { id: { value: string[] } } }) => { + const ids = options.where.id.value ?? []; + return ids.map((id: string) => this.invoices.get(id)).filter(Boolean); + }, + createQueryBuilder: (alias: string) => { + void alias; + return this.queryBuilder(Invoice); + }, + count: async (options: { where: Record }) => this.find(Invoice, options.where).length, + }), + } as unknown as DataSource; + } +} + +const build = (db: FakeDb, postSigned: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) => + new EimsBulkRegistrationService( + db.asDataSource(), + { get: () => eimsConfig({ invoice: eimsInvoiceConfig() }) } as unknown as ConfigService, + { postSigned } as unknown as EimsClientService, + { getSessionContext: async () => ({ systemNumber: SYSTEM_NUMBER, systemType: "SYS" }) } as unknown as EimsAuthService, + { directSend } as unknown as NotificationsService, + { getSellerDetails: (c: unknown) => buildEimsSeller(c as never) } as unknown as EimsSellerCacheService, + ); + +const accepted = (conversationId = CONVERSATION_ID) => ({ conversationId, status: 202 }); + +describe("EimsBulkRegistrationService.registerBulk", () => { + it("reserves sequential counters, sends one signed array, and claims MoR's real conversation id", async () => { + const db = new FakeDb( + [invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })], + { nextInvoiceCounter: 5, nextDocumentNumber: 5, previousIrn: "prev-irn" }, + ); + const postSigned = jest.fn().mockResolvedValue(accepted()); + + const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]); + + expect(result).toEqual({ conversationId: CONVERSATION_ID, accepted: [INVOICE_A, INVOICE_B], alreadyRegistered: [] }); + const [, request] = postSigned.mock.calls[0]; + expect(request).toHaveLength(2); + expect(request[0].SourceSystem.InvoiceCounter).toBe(5); + expect(request[0].DocumentDetails.DocumentNumber).toBe("5"); + expect(request[0].ReferenceDetails.PreviousIrn).toBe("prev-irn"); + expect(request[1].SourceSystem.InvoiceCounter).toBe(6); + // Only the first item in a bulk batch chains — the rest have no IRN to reference yet. + expect(request[1].ReferenceDetails.PreviousIrn).toBe(""); + + expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID }); + expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID }); + expect(db.state.nextInvoiceCounter).toBe(7); + expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID); + }); + + it("skips an already-registered invoice, without consuming a counter for it", async () => { + const db = new FakeDb([ + invoiceRow({ eimsIrn: "already-irn", eimsStatus: EimsInvoiceStatus.Registered }), + invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" }), + ]); + const postSigned = jest.fn().mockResolvedValue(accepted()); + + const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]); + + expect(result.alreadyRegistered).toEqual([INVOICE_A]); + expect(result.accepted).toEqual([INVOICE_B]); + const [, request] = postSigned.mock.calls[0]; + expect(request).toHaveLength(1); + }); + + it("refuses the whole batch — no reservation, no HTTP call — when a DEB note has no registered original", async () => { + const db = new FakeDb([ + invoiceRow({ eimsDocumentType: "DEB", relatedInvoice: { eimsIrn: null, invoiceNumber: "INV-orig" } as never }), + ]); + const postSigned = jest.fn(); + + await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(BadRequestException); + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state.inFlightConversationId).toBeNull(); + }); + + it("refuses when a single-invoice submission is already in flight", async () => { + const db = new FakeDb([invoiceRow()], { inFlightInvoiceId: "some-other-invoice" }); + await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException); + }); + + it("refuses when another bulk batch is already in flight", async () => { + const db = new FakeDb([invoiceRow()], { inFlightConversationId: "other-conversation" }); + await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException); + }); + + it("a deterministic rejection rolls back the whole block and clears the in-flight marker", async () => { + const db = new FakeDb( + [invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })], + { nextInvoiceCounter: 5, nextDocumentNumber: 5 }, + ); + const postSigned = jest.fn().mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "bad", 400)); + + await expect(build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B])).rejects.toBeInstanceOf(EimsApiException); + + expect(db.state.nextInvoiceCounter).toBe(5); + expect(db.state.nextDocumentNumber).toBe(5); + expect(db.state.inFlightConversationId).toBeNull(); + expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Failed); + expect(db.invoices.get(INVOICE_A)?.eimsBulkConversationId).toBeNull(); + }); + + it("an ambiguous failure blocks the system number and leaves counters consumed", async () => { + const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 5, nextDocumentNumber: 5 }); + const postSigned = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "timed out")); + + await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(EimsApiException); + + expect(db.state.nextInvoiceCounter).toBe(6); + expect(db.state.blockedReason).toMatch(/never acknowledged/); + expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Unknown); + }); + + it("refuses an empty invoice list", async () => { + const db = new FakeDb([invoiceRow()]); + await expect(build(db, jest.fn()).registerBulk([])).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe("EimsBulkRegistrationService.handleBulkCallback", () => { + const submittingRow = (over: Partial) => + invoiceRow({ + eimsStatus: EimsInvoiceStatus.Submitting, + eimsBulkConversationId: CONVERSATION_ID, + ...over, + }); + + it("settles a mixed success/error callback, advancing previousIrn to the last accepted item", async () => { + const db = new FakeDb( + [ + submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }), + submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }), + ], + { inFlightConversationId: CONVERSATION_ID }, + ); + + const results = await build(db, jest.fn()).handleBulkCallback([ + { irn: "irn-a", status: "A", documentNumber: "5" }, + { ruleError: [{ portion: "DocumentDetails", errorMessage: ["bad date"] }], status: "ERROR", docNo: "6" }, + { conversionId: CONVERSATION_ID }, + ]); + + expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "irn-a" }); + expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed }); + expect(db.state.previousIrn).toBe("irn-a"); + expect(db.state.inFlightConversationId).toBeNull(); + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ invoiceId: INVOICE_A, success: true, irn: "irn-a" }), + expect.objectContaining({ invoiceId: INVOICE_B, success: false }), + ]), + ); + }); + + it("ignores a callback for an unknown or already-settled conversation", async () => { + const db = new FakeDb([invoiceRow()]); + const results = await build(db, jest.fn()).handleBulkCallback([ + { irn: "irn-x", status: "A", documentNumber: "1" }, + { conversionId: "no-such-conversation" }, + ]); + expect(results).toEqual([]); + }); + + it("does not clear the in-flight marker while another invoice in the batch is still submitting", async () => { + const db = new FakeDb( + [ + submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }), + submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }), + ], + { inFlightConversationId: CONVERSATION_ID }, + ); + + // Callback only reports on one of the two invoices in this batch. + await build(db, jest.fn()).handleBulkCallback([ + { irn: "irn-a", status: "A", documentNumber: "5" }, + { conversionId: CONVERSATION_ID }, + ]); + + expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID); + }); + + it("reports the current state without re-settling an invoice that already resolved", async () => { + const db = new FakeDb( + [ + invoiceRow({ + eimsStatus: EimsInvoiceStatus.Registered, + eimsIrn: "irn-a", + eimsDocumentNumber: "1", + eimsBulkConversationId: CONVERSATION_ID, + }), + ], + { inFlightConversationId: CONVERSATION_ID }, + ); + + const results = await build(db, jest.fn()).handleBulkCallback([ + { irn: "irn-a", status: "A", documentNumber: "1" }, + { conversionId: CONVERSATION_ID }, + ]); + + expect(results).toEqual([ + expect.objectContaining({ invoiceId: INVOICE_A, success: true, message: expect.stringContaining("Already settled") }), + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts new file mode 100644 index 000000000..4638d3555 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts @@ -0,0 +1,518 @@ +import { randomUUID } from "node:crypto"; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; +import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js"; + +import { EimsConfig } from "../../config/eims.config"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper"; +import { sendCompanyChannels } from "../notifications/notify-company.util"; +import { NotificationsService } from "../notifications/notifications.service"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; +import { + EimsBulkCallbackItem, + EimsBulkRegisterAcceptedResponse, + EimsBulkRegisterItemResult, + EimsBulkRegisterRequest, + EimsInvoiceError, + EimsInvoiceStatus, +} from "./eims-registration.types"; + +const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]); + +interface BulkReservation { + stateId: string; + invoice: Invoice & { lines: EimsMapperLine[] }; + documentType: EimsDocumentType; + relatedDocument: string | null; + invoiceCounter: number; + documentNumber: string; + previousIrn: string; +} + +/** + * Registers many invoices with MoR EIMS in one call — `POST /v1/bulkRegister`. + * + * Fundamentally different shape from `EimsInvoiceRegistrationService.registerInvoiceWithEims`: + * that endpoint answers synchronously (an IRN or a rejection, in the HTTP response itself). Bulk + * does not — it returns only `{conversationId, status:202}` immediately, and the real per-invoice + * results (a mix of accepted/rejected in one array, per the collection's own examples) arrive later + * as a POST to a webhook MoR was configured with out of band. That means this service has two + * halves that don't share a call stack: `registerBulk` reserves and submits; `handleBulkCallback` + * — invoked by `EimsWebhookController`, whenever MoR gets around to it — settles. + * + * Reservation follows the same durable-reservation doctrine as the single-invoice service (counters + * consumed and the holder recorded, committed, before the HTTP call leaves the process), extended + * to a contiguous block of N counters instead of one. The "something is in flight" marker is + * `EimsSystemState.inFlightConversationId`, not `inFlightInvoiceId` — a whole batch is outstanding, + * not one invoice — and the two markers block each other: a single registration cannot start while + * a bulk batch is pending, and vice versa, because they share the same counter sequence. + * + * The conversation id is not known until MoR's 202 response arrives, so reservation stamps a + * locally-generated placeholder token first (same "commit the reservation before the network call" + * reasoning as the single flow), then swaps it for MoR's real conversation id right after — the only + * value the webhook callback can actually use to find this batch again. + * + * Not live-testable from this sandbox (no route to MoR's real gateway) — signing the whole array as + * one envelope, the way single `/v1/register` was confirmed live to need despite the collection's + * raw example showing no envelope, is the reasonable extension of that confirmed behavior, not a + * blind guess, but it has not itself been exercised against the real gateway. + */ +@Injectable() +export class EimsBulkRegistrationService { + private readonly logger = new Logger(EimsBulkRegistrationService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly client: EimsClientService, + private readonly auth: EimsAuthService, + private readonly notifications: NotificationsService, + private readonly sellerCache: EimsSellerCacheService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * Reserve counters for every eligible invoice and submit them as one `/v1/bulkRegister` call. + * An invoice that already has an IRN is silently skipped (idempotent, matching single register); + * everything else must pass the same DEB/CRE precondition single register checks, or the whole + * call is refused before anything is reserved. + */ + async registerBulk( + invoiceIds: string[], + ): Promise<{ conversationId: string | null; accepted: string[]; alreadyRegistered: string[] }> { + const cfg = this.cfg; + assertEimsInvoiceConfig(cfg); + + const ids = [...new Set(invoiceIds)]; + if (ids.length === 0) { + throw new BadRequestException({ code: "EIMS_BULK_EMPTY", message: "No invoice ids given" }); + } + + const invoices = await this.loadInvoicesForMapping(ids); + const alreadyRegistered = invoices.filter((inv) => inv.eimsIrn).map((inv) => inv.id); + const pending = invoices.filter((inv) => !inv.eimsIrn); + + // Same DEB/CRE precondition as single register, checked for every pending invoice before any + // counter is touched: a bad member must fail the whole batch, not surface mid-submission. + const prepared = pending.map((invoice) => { + const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV"; + let relatedDocument: string | null = null; + if (documentType !== "INV") { + if (!invoice.relatedInvoice) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_REQUIRED", + message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`, + }); + } + if (!invoice.relatedInvoice.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_NOT_REGISTERED", + message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`, + }); + } + relatedDocument = invoice.relatedInvoice.eimsIrn; + } + return { invoice, documentType, relatedDocument }; + }); + + if (prepared.length === 0) { + return { conversationId: null, accepted: [], alreadyRegistered }; + } + + const session = await this.auth.getSessionContext(); + const placeholder = `local:${randomUUID()}`; + const reservations = await this.reserveBulk(prepared, session.systemNumber, placeholder); + + let conversationId: string; + try { + const requests: EimsBulkRegisterRequest = reservations.map((r) => + toEimsInvoice( + r.invoice, + this.sellerCache.getSellerDetails(cfg), + buildEimsContext(cfg, { + documentNumber: r.documentNumber, + invoiceCounter: r.invoiceCounter, + previousIrn: r.previousIrn, + session, + documentType: r.documentType, + reason: r.invoice.eimsReason, + relatedDocument: r.relatedDocument, + }), + ), + ); + const response = await this.client.postSigned( + "/v1/bulkRegister", + requests, + ); + if (!response?.conversationId) { + throw new EimsApiException( + "SCHEMA_VALIDATION", + "EIMS bulkRegister returned no conversationId", + response?.status, + ); + } + conversationId = response.conversationId; + } catch (err) { + await this.settleBulkFailure(reservations, err); + throw err; + } + + await this.claimConversationId(placeholder, conversationId); + this.logger.log( + `Bulk-registered ${reservations.length} invoice(s) with EIMS (conversation ${conversationId}), awaiting callback`, + ); + return { conversationId, accepted: reservations.map((r) => r.invoice.id), alreadyRegistered }; + } + + /** + * Settle a batch's callback, whenever MoR gets around to sending it. Called by + * `EimsWebhookController` with the raw parsed array body — no auth on that route (MoR calls it, + * not a logged-in user), so the only thing standing between this and a forged callback is the + * conversation id itself: an item is only ever applied to an invoice actually holding that exact + * id, and an unknown id is logged and ignored rather than touching anything. + */ + async handleBulkCallback(items: EimsBulkCallbackItem[]): Promise { + const settlements = items.filter( + (item): item is Exclude => + "irn" in item || "ruleError" in item, + ); + + const conversationId = this.markerFrom(items); + const invoices = await this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .where("invoice.eims_bulk_conversation_id = :id", { id: conversationId }) + .getMany(); + + if (invoices.length === 0) { + this.logger.warn( + `EIMS bulk callback for an unknown or already-settled conversation — ignored (${settlements.length} item(s))`, + ); + return []; + } + + const byDocumentNumber = new Map(invoices.map((inv) => [inv.eimsDocumentNumber, inv])); + // Process in invoiceCounter order so `previousIrn` ends up as the last-accepted item's IRN — + // the same "advance the chain" semantics as single register's settleSuccess. + const ordered = [...settlements].sort((a, b) => { + const invA = byDocumentNumber.get("documentNumber" in a ? a.documentNumber : a.docNo); + const invB = byDocumentNumber.get("documentNumber" in b ? b.documentNumber : b.docNo); + return (invA?.eimsInvoiceCounter ?? 0) - (invB?.eimsInvoiceCounter ?? 0); + }); + + const results: EimsBulkRegisterItemResult[] = []; + for (const item of ordered) { + const docNumber = "documentNumber" in item ? item.documentNumber : item.docNo; + const invoice = byDocumentNumber.get(docNumber); + if (!invoice) { + this.logger.warn(`EIMS bulk callback item for unknown document number ${docNumber} — ignored`); + continue; + } + if (invoice.eimsStatus !== EimsInvoiceStatus.Submitting) { + // Already settled — a duplicate callback delivery. Report the current state, touch nothing. + results.push({ + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + success: invoice.eimsStatus === EimsInvoiceStatus.Registered, + message: `Already settled (${invoice.eimsStatus})`, + irn: invoice.eimsIrn ?? undefined, + }); + continue; + } + + if ("irn" in item) { + await this.settleBulkItemSuccess(invoice, item.irn, conversationId, item.signedQR); + results.push({ + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + success: true, + message: `Registered with EIMS (IRN ${item.irn})`, + irn: item.irn, + }); + } else { + const message = item.ruleError.flatMap((e) => e.errorMessage).join("; ") || "EIMS bulk rule validation error"; + await this.settleBulkItemFailure(invoice, message); + results.push({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, success: false, message }); + } + } + + // Clear the batch's in-flight marker only once nothing submitted under this conversation is + // still waiting — a partial/incremental callback (not expected per the collection's docs, but + // not ruled out either) must not prematurely unblock the system number. + const stillPending = await this.dataSource + .getRepository(Invoice) + .count({ where: { eimsBulkConversationId: conversationId, eimsStatus: EimsInvoiceStatus.Submitting } }); + if (stillPending === 0) { + await this.dataSource.manager.update( + EimsSystemState, + { inFlightConversationId: conversationId }, + { inFlightConversationId: null }, + ); + this.logger.log(`EIMS bulk conversation ${conversationId} fully settled (${results.length} item(s))`); + } + + return results; + } + + // ── transactions ───────────────────────────────────────────────────────────────────────────── + + /** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */ + private async reserveBulk( + prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>, + systemNumber: string, + placeholder: string, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const state = await this.lockSystemState(manager, systemNumber); + + if (state.blockedReason) { + throw new ConflictException({ + code: "EIMS_SYSTEM_BLOCKED", + message: `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. Resolve the affected invoice before registering anything else.`, + }); + } + if (state.inFlightInvoiceId) { + throw new ConflictException({ + code: "EIMS_SUBMISSION_IN_FLIGHT", + message: `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`, + }); + } + if (state.inFlightConversationId) { + throw new ConflictException({ + code: "EIMS_BULK_IN_FLIGHT", + message: `A bulk submission (conversation ${state.inFlightConversationId}) is already in flight on system ${systemNumber}. Wait for its callback, or resolve it if the process was interrupted.`, + }); + } + + let counter = Number(state.nextInvoiceCounter); + let docNumber = Number(state.nextDocumentNumber); + let previousIrn = state.previousIrn ?? ""; + const reservations: BulkReservation[] = []; + + // Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking + // on the opposite lock order. + for (const { invoice, documentType, relatedDocument } of prepared) { + const locked = await this.lockInvoice(manager, invoice.id); + const thisCounter = counter++; + const thisDocNumber = String(docNumber++); + const thisPreviousIrn = reservations.length === 0 ? previousIrn : ""; + + await manager.update(Invoice, invoice.id, { + eimsStatus: EimsInvoiceStatus.Submitting, + eimsInvoiceCounter: thisCounter, + eimsDocumentNumber: thisDocNumber, + eimsSubmittedAt: new Date(), + eimsLastError: null, + eimsBulkConversationId: placeholder, + } as QueryDeepPartialEntity); + + reservations.push({ + stateId: state.id, + invoice: Object.assign(locked, { lines: invoice.lines }), + documentType, + relatedDocument, + invoiceCounter: thisCounter, + documentNumber: thisDocNumber, + previousIrn: thisPreviousIrn, + }); + } + + await manager.update(EimsSystemState, state.id, { + nextInvoiceCounter: counter, + nextDocumentNumber: docNumber, + inFlightConversationId: placeholder, + }); + + return reservations; + }); + } + + /** Swap the local placeholder for MoR's real conversation id, on both the state row and every invoice. */ + private async claimConversationId(placeholder: string, conversationId: string): Promise { + await this.dataSource.transaction(async (manager) => { + await manager.update(EimsSystemState, { inFlightConversationId: placeholder }, { inFlightConversationId: conversationId }); + await manager.update(Invoice, { eimsBulkConversationId: placeholder }, { eimsBulkConversationId: conversationId }); + }); + } + + /** + * TX2b for the whole batch — the same determinism doctrine as single register's settleFailure, + * applied once since `/v1/bulkRegister` either accepts the whole array (202) or fails as one HTTP + * call; there is no per-item answer yet at this point, only after the callback. + */ + private async settleBulkFailure(reservations: BulkReservation[], err: unknown): Promise { + const api = err instanceof EimsApiException ? err : null; + const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true; + const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; + const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL"; + const lastError: EimsInvoiceError = { + kind: api?.kind ?? localKind, + message: (err as Error)?.message ?? "unknown error", + httpStatus: api?.httpStatus, + details: api?.details, + at: new Date().toISOString(), + }; + const first = reservations[0]; + + await this.dataSource.transaction(async (manager) => { + for (const r of reservations) { + await manager.update(Invoice, r.invoice.id, { + eimsStatus: status, + eimsLastError: lastError, + ...(deterministic ? { eimsBulkConversationId: null } : {}), + } as QueryDeepPartialEntity); + } + await manager.update( + EimsSystemState, + first.stateId, + deterministic + ? { + // The whole block returns: MoR never counted a refused batch against either sequence. + nextInvoiceCounter: first.invoiceCounter, + nextDocumentNumber: Number(first.documentNumber), + inFlightConversationId: null, + } + : { + blockedReason: + `A bulk submission of ${reservations.length} invoice(s) (starting counter ${first.invoiceCounter}) ` + + `was sent but never acknowledged (${lastError.kind}). No further document can be filed until it is resolved.`, + }, + ); + }); + + this.logger.error(`EIMS bulk submission ${status}: ${lastError.message}`); + } + + /** One callback item accepted. */ + private async settleBulkItemSuccess( + invoice: Invoice, + irn: string, + conversationId: string, + signedQR?: string, + ): Promise { + await this.dataSource.transaction(async (manager) => { + await this.lockInvoice(manager, invoice.id); + await manager.update(Invoice, invoice.id, { + eimsStatus: EimsInvoiceStatus.Registered, + eimsIrn: irn, + eimsSignedQr: signedQR ?? null, + eimsLastError: null, + }); + // Looked up by conversation id, not system number — this batch's state row is whichever one + // is holding this conversation, which is exactly what `inFlightConversationId` already tracks. + await manager.update(EimsSystemState, { inFlightConversationId: conversationId }, { previousIrn: irn }); + }); + this.logger.log(`Invoice ${invoice.invoiceNumber} registered with EIMS via bulk (IRN ${irn})`); + + if (invoice.companyId) { + try { + await sendCompanyChannels( + this.dataSource, + this.notifications, + invoice.companyId, + `Invoice ${invoice.invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`, + ); + } catch (err) { + this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`); + } + } + } + + /** + * One callback item rejected. Unlike single register's settleFailure, the counter/document + * number are not returned — MoR's own bulk processing already advanced the whole array's + * allocation regardless of this item's individual outcome, so there is nothing local to roll back. + */ + private async settleBulkItemFailure(invoice: Invoice, message: string): Promise { + const lastError: EimsInvoiceError = { kind: "RULE_VALIDATION", message, at: new Date().toISOString() }; + await this.dataSource.manager.update(Invoice, invoice.id, { + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: lastError, + } as QueryDeepPartialEntity); + this.logger.error(`Invoice ${invoice.invoiceNumber} EIMS bulk registration FAILED: ${message}`); + } + + // ── internals ──────────────────────────────────────────────────────────────────────────────── + + private markerFrom(items: EimsBulkCallbackItem[]): string { + const marker = items.find((i) => "conversationId" in i || "conversionId" in i) as + | { conversationId?: string; conversionId?: string } + | undefined; + return marker?.conversationId ?? marker?.conversionId ?? ""; + } + + + private async lockInvoice(manager: EntityManager, invoiceId: string): Promise { + const invoice = await manager + .createQueryBuilder(Invoice, "invoice") + .setLock("pessimistic_write") + .where("invoice.id = :invoiceId", { invoiceId }) + .getOne(); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + return invoice; + } + + private async lockSystemState(manager: EntityManager, systemNumber: string): Promise { + const select = () => + manager + .createQueryBuilder(EimsSystemState, "state") + .setLock("pessimistic_write") + .where("state.system_number = :systemNumber", { systemNumber }) + .getOne(); + + const existing = await select(); + if (existing) return existing; + + await manager.query( + `INSERT INTO freight.eims_system_state (system_number) VALUES ($1) ON CONFLICT (system_number) DO NOTHING`, + [systemNumber], + ); + const created = await select(); + if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`); + return created; + } + + private async loadInvoicesForMapping(invoiceIds: string[]): Promise> { + const invoices = await this.dataSource.getRepository(Invoice).find({ + where: { id: In(invoiceIds) }, + relations: { company: true, companyProfile: true, relatedInvoice: true }, + }); + const found = new Set(invoices.map((inv) => inv.id)); + const missing = invoiceIds.filter((id) => !found.has(id)); + if (missing.length > 0) { + throw new NotFoundException(`Invoice(s) not found: ${missing.join(", ")}`); + } + + const lines: Array = await this.dataSource.query( + `SELECT invoice_id AS "invoiceId", charge_type AS "chargeType", description, quantity, + unit_rate AS "unitRate", amount, currency, metadata + FROM freight.invoice_lines + WHERE invoice_id = ANY($1) AND deleted_at IS NULL + ORDER BY created_at ASC`, + [invoiceIds], + ); + const linesByInvoice = new Map(); + for (const line of lines) { + const { invoiceId, ...rest } = line; + if (!linesByInvoice.has(invoiceId)) linesByInvoice.set(invoiceId, []); + linesByInvoice.get(invoiceId)!.push(rest); + } + + // Preserve the caller's given order — reservation and result ordering both depend on it. + return invoiceIds.map((id) => { + const invoice = invoices.find((inv) => inv.id === id)!; + return Object.assign(invoice, { lines: linesByInvoice.get(id) ?? [] }); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index 11eb214dc..db28eeafe 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -6,10 +6,12 @@ import { BookingStaff } from "../../common/booking-guards"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { sendPdf } from "../billing/billing.controller"; import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto"; +import { BulkRegisterEimsInvoiceDto } from "./dto/bulk-register-eims-invoice.dto"; import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto"; +import { EimsBulkRegistrationService } from "./eims-bulk-registration.service"; import { EimsCancellationService } from "./eims-cancellation.service"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsReceiptService } from "./eims-receipt.service"; @@ -39,6 +41,7 @@ import { EimsReceiptService } from "./eims-receipt.service"; export class EimsInvoiceController { constructor( private readonly registration: EimsInvoiceRegistrationService, + private readonly bulkRegistration: EimsBulkRegistrationService, private readonly cancellation: EimsCancellationService, private readonly receipts: EimsReceiptService, ) {} @@ -53,6 +56,18 @@ export class EimsInvoiceController { return this.registration.registerInvoiceWithEims(id); } + @Post("eims/bulk-register") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ + summary: + "Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR " + + "accepted the batch (a conversation id), not the per-invoice outcome. Real results (IRN or " + + "rejection per invoice) arrive later via MoR's own callback; poll GET :id/eims/status.", + }) + bulkRegister(@Body() dto: BulkRegisterEimsInvoiceDto) { + return this.bulkRegistration.registerBulk(dto.invoiceIds); + } + @Post(":id/eims/verify") @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) @ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" }) diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts index 60941825c..7cc58c0a9 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -1,3 +1,4 @@ +import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; import { EimsErrorResponse } from "./eims.types"; /** @@ -129,6 +130,62 @@ export interface EimsBulkCancelItemResult { message: string; } +/** + * `POST /v1/bulkRegister` — same array-of-full-documents shape as single register (`EimsInvoiceRequest` + * from `eims-invoice.mapper.ts`), one element per invoice, sent as one signed envelope. + */ +export type EimsBulkRegisterRequest = EimsInvoiceRequest[]; + +/** + * Immediate response to `bulkRegister` — unlike single register, this is not the result, just an + * acknowledgement. The real per-invoice outcomes arrive later via `EimsBulkCallbackItem`s pushed to + * a webhook MoR was configured with out of band (see `EimsBulkRegistrationService`). + */ +export interface EimsBulkRegisterAcceptedResponse { + conversationId: string; + status: number; +} + +/** A settled item in the async callback — `irn` present means MoR accepted this document. */ +export interface EimsBulkCallbackSuccessItem { + irn: string; + status: string; + documentNumber: string; + signedQR?: string; + signedInvoice?: string; +} + +/** A rejected item in the async callback — `docNo` echoes what we submitted as `DocumentNumber`. */ +export interface EimsBulkCallbackErrorItem { + ruleError: Array<{ portion: string; errorMessage: string[] }>; + status: string; + docNo: string; +} + +/** + * The callback array's last element, per the collection's own examples — never a settlement result, + * just the batch id echoed back. Spelled two different ways across the collection's own docs + * ("conversationId" on the initial 202, "conversionId" in the callback examples); accept both. + */ +export interface EimsBulkCallbackMarker { + conversationId?: string; + conversionId?: string; +} + +export type EimsBulkCallbackItem = + | EimsBulkCallbackSuccessItem + | EimsBulkCallbackErrorItem + | EimsBulkCallbackMarker; + +/** One invoice's outcome once a bulk batch's callback has been processed. */ +export interface EimsBulkRegisterItemResult { + invoiceId: string; + invoiceNumber: string; + success: boolean; + message: string; + irn?: string; +} + /** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */ export interface EimsInvoiceError { kind: string; diff --git a/apps/edr-freight-api/src/modules/eims/eims-webhook.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-webhook.controller.ts new file mode 100644 index 000000000..e2b3ab49e --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-webhook.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, HttpCode, Post } from "@nestjs/common"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; +import { EimsBulkCallbackItem } from "./eims-registration.types"; +import { EimsBulkRegistrationService } from "./eims-bulk-registration.service"; + +/** + * MoR's own callback for `POST /v1/bulkRegister`, not a route a person calls. Public — MoR has no + * JWT to send — so the conversation id embedded in the payload is the only thing standing between + * this and a forged callback: `EimsBulkRegistrationService.handleBulkCallback` only ever touches + * invoices actually holding that exact id, and an unrecognised one is logged and ignored. See the + * "Callback Mechanism" section of the collection's own docs for the payload shape. + */ +@ApiExcludeController() +@Controller("eims/webhook") +export class EimsWebhookController { + constructor(private readonly bulk: EimsBulkRegistrationService) {} + + @Public() + @Post("bulk-register") + @HttpCode(200) + bulkRegisterCallback(@Body() items: EimsBulkCallbackItem[]) { + return this.bulk.handleBulkCallback(items); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 5d55a7ee8..739bf7089 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -9,6 +9,7 @@ import { NotificationInboxModule } from "../notification-inbox/notification-inbo import { NotificationsModule } from "../notifications/notifications.module"; import { EimsAuthService } from "./eims-auth.service"; import { EimsAutoSubmitService } from "./eims-auto-submit.service"; +import { EimsBulkRegistrationService } from "./eims-bulk-registration.service"; import { EimsCancellationService } from "./eims-cancellation.service"; import { EimsClientService } from "./eims-client.service"; import { EimsCredentialsProvider } from "./eims-credentials.provider"; @@ -17,6 +18,7 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv import { EimsReceiptService } from "./eims-receipt.service"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSignerService } from "./eims-signer.service"; +import { EimsWebhookController } from "./eims-webhook.controller"; import { EimsReceipt } from "./entities/eims-receipt.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -40,13 +42,14 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; // so this stays a plain one-directional import, not a new cycle. CompaniesModule, ], - controllers: [EimsInvoiceController], + controllers: [EimsInvoiceController, EimsWebhookController], providers: [ EimsCredentialsProvider, EimsSignerService, EimsAuthService, EimsClientService, EimsInvoiceRegistrationService, + EimsBulkRegistrationService, EimsAutoSubmitService, EimsCancellationService, EimsReceiptService, @@ -56,6 +59,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAuthService, EimsClientService, EimsInvoiceRegistrationService, + EimsBulkRegistrationService, EimsCancellationService, EimsReceiptService, ], diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts index 328aa8d0f..67b5d74d4 100644 --- a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -54,4 +54,11 @@ export class EimsSystemState extends BaseEntity { */ @Column({ name: "blocked_reason", type: "text", nullable: true }) blockedReason?: string | null; + + /** + * Bulk equivalent of `in_flight_invoice_id` — a whole batch, not one invoice, is outstanding + * while MoR processes `POST /v1/bulkRegister` asynchronously. See `EimsBulkRegistrationService`. + */ + @Column({ name: "in_flight_conversation_id", type: "text", nullable: true }) + inFlightConversationId?: string | null; } 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 new file mode 100644 index 000000000..1e2cc9098 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -0,0 +1,223 @@ +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); + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts new file mode 100644 index 000000000..51e99614a --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts @@ -0,0 +1,161 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; +import { Contract } from '../../contracts/entities/contract.entity'; +import { ServiceType } from '../../rule-engine/entities/service-type.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportDataset } from '../export.types'; + +export const contractsDataset: ExportDataset = { + key: 'contracts', + title: 'Contracts', + description: 'Contracts with customer, terms, routes, approval and clearance detail', + group: 'Commercial', + permission: FREIGHT_PERMS.contracts.view, + base: { entity: Contract, alias: 'ct' }, + + joins: [ + { alias: 'c', entity: Company, on: 'c.id = ct.company_id' }, + { alias: 'cp', entity: CompanyProfile, on: 'cp.id = ct.company_profile_id' }, + { alias: 'st', entity: ServiceType, on: 'st.id = ct.service_type_id' }, + { alias: 'ren', entity: Contract, on: 'ren.id = ct.renewal_of_id' }, + ], + // `search` matches the customer name. + alwaysJoin: ['c'], + + groups: [ + { id: 'contract', label: 'Contract' }, + { id: 'customer', label: 'Customer' }, + { id: 'terms', label: 'Terms' }, + { id: 'routes', label: 'Routes & cargo' }, + { id: 'approval', label: 'Approval' }, + { id: 'clearance', label: 'Clearance' }, + ], + + fields: [ + { key: 'reference', label: 'Reference', type: 'string', group: 'contract', default: true, select: 'ct.reference', sortExpr: 'ct.reference' }, + { key: 'status', label: 'Status', type: 'string', group: 'contract', default: true, select: 'ct.status', sortExpr: 'ct.status' }, + { key: 'contractKind', label: 'Kind', type: 'string', group: 'contract', default: true, select: 'ct.contract_kind' }, + { key: 'contractType', label: 'Type', type: 'string', group: 'contract', select: 'ct.contract_type' }, + { key: 'createdAt', label: 'Created', type: 'date', group: 'contract', default: true, select: `to_char(ct.created_at, 'YYYY-MM-DD')`, sortExpr: 'ct.created_at' }, + { key: 'submittedAt', label: 'Submitted', type: 'datetime', group: 'contract', select: `to_char(ct.submitted_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'versionNumber', label: 'Version', type: 'number', group: 'contract', select: 'ct.version_number' }, + { key: 'renewalOf', label: 'Renewal of', type: 'string', group: 'contract', requires: ['ren'], select: 'ren.reference' }, + { key: 'statusBeforeSuspension', label: 'Status before suspension', type: 'string', group: 'contract', select: 'ct.status_before_suspension' }, + + { key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' }, + { key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' }, + { 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: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' }, + { key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' }, + { key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'ct.is_government' }, + { key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'ct.government_institution' }, + + { key: 'tradeDirection', label: 'Direction', type: 'string', group: 'terms', default: true, select: 'ct.trade_direction', sortExpr: 'ct.trade_direction' }, + { key: 'freightType', label: 'Freight type', type: 'string', group: 'terms', default: true, select: 'ct.freight_type' }, + { key: 'serviceType', label: 'Service type', type: 'string', group: 'terms', requires: ['st'], select: 'st.service_name' }, + { key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'terms', select: 'ct.payment_currency' }, + { key: 'validFrom', label: 'Valid from', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, sortExpr: 'ct.contract_valid_from' }, + { key: 'validUntil', label: 'Valid until', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_until, 'YYYY-MM-DD')` }, + { key: 'validityDays', label: 'Validity (days)', type: 'number', group: 'terms', select: 'ct.contract_validity_days' }, + { key: 'expiresAt', label: 'Expires', type: 'date', group: 'terms', select: `to_char(ct.expires_at, 'YYYY-MM-DD')` }, + { key: 'estimatedShipmentDate', label: 'Est. shipment date', type: 'date', group: 'terms', select: `to_char(ct.estimated_shipment_date, 'YYYY-MM-DD')` }, + { key: 'equipmentReturn', label: 'Equipment return', type: 'string', group: 'terms', select: 'ct.equipment_return' }, + { key: 'pricingDisplayMode', label: 'Pricing display mode', type: 'string', group: 'terms', select: 'ct.pricing_display_mode' }, + + { + key: 'routes', label: 'Routes', type: 'string', group: 'routes', + select: `(SELECT string_agg(o.label || ' -> ' || d.label, ' | ' ORDER BY cr.sort_order) + FROM freight.contract_routes cr + JOIN freight.yards o ON o.id = cr.origin_yard_id + JOIN freight.yards d ON d.id = cr.destination_yard_id + WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`, + }, + { + key: 'routeCount', label: 'Route count', type: 'number', group: 'routes', + select: `(SELECT COUNT(*)::int FROM freight.contract_routes cr + WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`, + }, + { + key: 'bookingCount', label: 'Bookings', type: 'number', group: 'routes', + select: `(SELECT COUNT(*)::int FROM freight.bookings b + WHERE b.contract_id = ct.id AND b.deleted_at IS NULL)`, + }, + { key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'routes', select: 'ct.is_hazardous' }, + { key: 'hazardClass', label: 'Hazard class', type: 'string', group: 'routes', select: 'ct.hazard_class' }, + { key: 'unNumber', label: 'UN number', type: 'string', group: 'routes', select: 'ct.un_number' }, + { key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'routes', select: 'ct.is_reefer' }, + + { key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'approval', select: `to_char(ct.approved_by_staff_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'signedByDirectorAt', label: 'Director signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_director_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'signedByCeoAt', label: 'CEO signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_ceo_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'customerSignedAt', label: 'Customer signed', type: 'datetime', group: 'approval', select: `to_char(ct.customer_signed_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'fullyExecutedAt', label: 'Fully executed', type: 'datetime', group: 'approval', default: true, select: `to_char(ct.fully_executed_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'lockedAt', label: 'Locked at', type: 'datetime', group: 'approval', select: `to_char(ct.locked_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'contractGeneratedAt', label: 'Document generated', type: 'datetime', group: 'approval', select: `to_char(ct.contract_generated_at, 'YYYY-MM-DD HH24:MI')` }, + + { key: 'clearanceStatus', label: 'Clearance status', type: 'string', group: 'clearance', select: 'ct.clearance_status' }, + { key: 'clearanceCycleNumber', label: 'Clearance cycle', type: 'number', group: 'clearance', select: 'ct.clearance_cycle_number' }, + { key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'ct.customs_clearing_enabled' }, + { key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'ct.customs_clearing_agent' }, + { key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'clearance', select: 'ct.first_mile_pickup_address' }, + { key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'clearance', select: 'ct.last_mile_delivery_address' }, + ], + + filters: [ + { key: 'created', label: 'Created', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect' }, + { key: 'contractKind', label: 'Kind', type: 'text' }, + { 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: 'paymentCurrency', label: 'Currency', type: 'select', options: [ + { value: 'ETB', label: 'ETB' }, + { value: 'USD', label: 'USD' }, + ] }, + { key: 'serviceTypeId', label: 'Service type', type: 'text' }, + // Routes are one-to-many on contract_routes, so these filter via EXISTS + // rather than a column comparison. + { key: 'originYardId', label: 'Origin', type: 'text' }, + { key: 'destinationYardId', label: 'Destination', type: 'text' }, + { 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; + qb.andWhere('ct.deleted_at IS NULL'); + if (params.createdFrom) qb.andWhere('ct.created_at >= :createdFrom', { createdFrom: params.createdFrom }); + if (params.createdTo) qb.andWhere('ct.created_at < :createdTo', { createdTo: params.createdTo }); + const statuses = params.statuses as string[] | null; + if (statuses?.length) qb.andWhere('ct.status IN (:...statuses)', { statuses }); + if (params.contractKind) qb.andWhere('ct.contract_kind = :contractKind', { contractKind: params.contractKind }); + if (params.tradeDirection) qb.andWhere('ct.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection }); + if (params.freightType) qb.andWhere('ct.freight_type = :freightType', { freightType: params.freightType }); + if (params.paymentCurrency) qb.andWhere('ct.payment_currency = :paymentCurrency', { paymentCurrency: params.paymentCurrency }); + if (params.serviceTypeId) qb.andWhere('ct.service_type_id = :serviceTypeId', { serviceTypeId: params.serviceTypeId }); + if (params.originYardId) { + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.contract_routes cr + WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL + AND cr.origin_yard_id = :originYardId)`, + { originYardId: params.originYardId }, + ); + } + if (params.destinationYardId) { + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.contract_routes cr2 + WHERE cr2.contract_id = ct.id AND cr2.deleted_at IS NULL + AND cr2.destination_yard_id = :destinationYardId)`, + { destinationYardId: params.destinationYardId }, + ); + } + if (params.companyId) qb.andWhere('ct.company_id = :companyId', { companyId: params.companyId }); + if (params.search) { + qb.andWhere('(ct.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); + } + applyDirectionScope(qb, 'ct.trade_direction', directions); + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts new file mode 100644 index 000000000..7a01ae048 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -0,0 +1,137 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Company } from '../../companies/entities/company.entity'; +import { ExportDataset } from '../export.types'; + +/** + * ONE ROW PER COMPANY. A company has many `company_profiles`, so every + * profile-derived field aggregates in a subquery rather than joining — a join + * would multiply rows and make the file disagree with the count endpoint. + * If per-profile rows are ever wanted, that is a separate `company-profiles` + * dataset, not a flag on this one. + */ +export const customersDataset: ExportDataset = { + key: 'customers', + title: 'Customers', + description: 'Companies with registration, contact, address and activity detail', + group: 'Commercial', + permission: FREIGHT_PERMS.customers.view, + base: { entity: Company, alias: 'c' }, + joins: [], + + groups: [ + { id: 'identity', label: 'Identity' }, + { id: 'registration', label: 'Registration' }, + { id: 'contact', label: 'Contact' }, + { id: 'address', label: 'Address' }, + { id: 'profiles', label: 'Profiles' }, + { id: 'activity', label: 'Activity' }, + ], + + fields: [ + { key: 'name', label: 'Company', type: 'string', group: 'identity', default: true, select: 'c.name', sortExpr: 'c.name' }, + { key: 'type', label: 'Type', type: 'string', group: 'identity', default: true, select: 'c.type' }, + { key: 'kind', label: 'Kind', type: 'string', group: 'identity', default: true, select: 'c.kind' }, + { key: 'status', label: 'Status', type: 'string', group: 'identity', default: true, select: 'c.status', sortExpr: 'c.status' }, + { key: 'statusDescription', label: 'Status note', type: 'string', group: 'identity', select: 'c.status_description' }, + { key: 'nationality', label: 'Nationality', type: 'string', group: 'identity', select: 'c.nationality' }, + + // date_registered / renewal_date / renewed_* are varchar in the schema, + // not dates — exported verbatim rather than pushed through to_char. + { key: 'tin', label: 'TIN', type: 'string', group: 'registration', default: true, select: 'c.tin' }, + { key: 'vatNumber', label: 'VAT number', type: 'string', group: 'registration', select: 'c.vat_number' }, + { key: 'fanNumber', label: 'FAN number', type: 'string', group: 'registration', select: 'c.fan_number' }, + { key: 'licenceNumber', label: 'Licence number', type: 'string', group: 'registration', select: 'c.licence_number' }, + { key: 'dateRegistered', label: 'Date registered', type: 'string', group: 'registration', select: 'c.date_registered' }, + { key: 'renewalDate', label: 'Renewal date', type: 'string', group: 'registration', select: 'c.renewal_date' }, + { key: 'renewedFrom', label: 'Renewed from', type: 'string', group: 'registration', select: 'c.renewed_from' }, + { key: 'renewedTo', label: 'Renewed to', type: 'string', group: 'registration', select: 'c.renewed_to' }, + { key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'registration', select: `to_char(c.approved_at, 'YYYY-MM-DD HH24:MI')` }, + + { key: 'phone', label: 'Phone', type: 'string', group: 'contact', default: true, select: 'c.phone' }, + { key: 'email', label: 'Email', type: 'string', group: 'contact', default: true, select: 'c.email' }, + { key: 'etradePhone', label: 'eTrade phone', type: 'string', group: 'contact', select: 'c.etrade_phone' }, + { key: 'website', label: 'Website', type: 'string', group: 'contact', select: 'c.website' }, + { key: 'contactPersonName', label: 'Contact person', type: 'string', group: 'contact', select: 'c.contact_person_name' }, + { key: 'contactPersonPhone', label: 'Contact phone', type: 'string', group: 'contact', select: 'c.contact_person_phone' }, + + { key: 'country', label: 'Country', type: 'string', group: 'address', select: 'c.country' }, + { key: 'region', label: 'Region', type: 'string', group: 'address', select: 'c.region' }, + { key: 'zone', label: 'Zone', type: 'string', group: 'address', select: 'c.zone' }, + { key: 'woreda', label: 'Woreda', type: 'string', group: 'address', select: 'c.woreda' }, + { key: 'kebele', label: 'Kebele', type: 'string', group: 'address', select: 'c.kebele' }, + { key: 'houseNo', label: 'House no.', type: 'string', group: 'address', select: 'c.house_no' }, + { key: 'address', label: 'Address', type: 'string', group: 'address', select: 'c.address' }, + + { + key: 'profileCount', label: 'Profile count', type: 'number', group: 'profiles', default: true, + select: `(SELECT COUNT(*)::int FROM freight.company_profiles cp + WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`, + }, + { + key: 'profileTypes', label: 'Profile types', type: 'string', group: 'profiles', + select: `(SELECT string_agg(DISTINCT cp.type, ' | ') FROM freight.company_profiles cp + WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`, + }, + { + key: 'profileReferences', label: 'Profile references', type: 'string', group: 'profiles', + select: `(SELECT string_agg(cp.reference, ' | ' ORDER BY cp.reference) FROM freight.company_profiles cp + WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`, + }, + { + key: 'profileStatuses', label: 'Profile statuses', type: 'string', group: 'profiles', + select: `(SELECT string_agg(DISTINCT cp.status, ' | ') FROM freight.company_profiles cp + WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`, + }, + + { key: 'createdAt', label: 'Registered on', type: 'date', group: 'activity', default: true, select: `to_char(c.created_at, 'YYYY-MM-DD')`, sortExpr: 'c.created_at' }, + { + key: 'bookingCount', label: 'Bookings', type: 'number', group: 'activity', + select: `(SELECT COUNT(*)::int FROM freight.bookings b + WHERE b.company_id = c.id AND b.deleted_at IS NULL)`, + }, + { + key: 'contractCount', label: 'Contracts', type: 'number', group: 'activity', + select: `(SELECT COUNT(*)::int FROM freight.contracts ct + WHERE ct.company_id = c.id AND ct.deleted_at IS NULL)`, + }, + { + key: 'invoicedTotal', label: 'Invoiced total', type: 'money', group: 'activity', + select: `(SELECT ROUND(COALESCE(SUM(i.total_amount), 0), 2)::float8 FROM freight.invoices i + WHERE i.company_id = c.id AND i.deleted_at IS NULL)`, + }, + { + key: 'outstandingBalance', label: 'Outstanding balance', type: 'money', group: 'activity', + select: `(SELECT ROUND(COALESCE(SUM(i.balance_amount), 0), 2)::float8 FROM freight.invoices i + WHERE i.company_id = c.id AND i.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'created', label: 'Registered', type: 'daterange' }, + { key: 'type', label: 'Type', type: 'text' }, + { key: 'kind', label: 'Kind', type: 'select', options: [ + { value: 'commercial', label: 'Commercial' }, + { value: 'government', label: 'Government' }, + ] }, + { key: 'status', label: 'Status', type: 'text' }, + { key: 'search', label: 'Search name, TIN or email', type: 'text' }, + ], + + defaultSort: { key: 'name', dir: 'ASC' }, + + scope(ctx, qb) { + const { params } = ctx; + qb.andWhere('c.deleted_at IS NULL'); + if (params.createdFrom) qb.andWhere('c.created_at >= :createdFrom', { createdFrom: params.createdFrom }); + if (params.createdTo) qb.andWhere('c.created_at < :createdTo', { createdTo: params.createdTo }); + if (params.type) qb.andWhere('c.type = :type', { type: params.type }); + if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind }); + if (params.status) qb.andWhere('c.status = :status', { status: params.status }); + if (params.search) { + qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', { + search: `%${params.search as string}%`, + }); + } + // Companies carry no trade direction — nothing to scope. Intentional. + }, +}; 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 new file mode 100644 index 000000000..5eb0986b5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -0,0 +1,130 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Invoice } from '../../billing/entities/invoice.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; +import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportDataset } from '../export.types'; + +/** + * Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a + * signature blob) and `eims_last_error` (a raw error dump). The + * human-meaningful status/IRN/document-number fields are kept. + */ +export const invoicesDataset: ExportDataset = { + key: 'invoices', + title: 'Invoices', + description: 'Invoices with customer, amounts, payment status and EIMS state', + group: 'Finance', + permission: FREIGHT_PERMS.invoices.view, + base: { entity: Invoice, alias: 'i' }, + + joins: [ + { alias: 'c', entity: Company, on: 'c.id = i.company_id' }, + { alias: 'cp', entity: CompanyProfile, on: 'cp.id = i.company_profile_id' }, + // No relation object on the entity for this FK — the service hydrates it + // with a second query. In a dataset it is just a join by column. + { alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' }, + { alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' }, + ], + alwaysJoin: ['c'], + + groups: [ + { id: 'invoice', label: 'Invoice' }, + { id: 'customer', label: 'Customer' }, + { id: 'amounts', label: 'Amounts' }, + { id: 'payment', label: 'Payment' }, + { id: 'lines', label: 'Lines' }, + { id: 'eims', label: 'EIMS' }, + ], + + fields: [ + { key: 'invoiceNumber', label: 'Invoice no.', type: 'string', group: 'invoice', default: true, select: 'i.invoice_number', sortExpr: 'i.invoice_number' }, + { key: 'status', label: 'Status', type: 'string', group: 'invoice', default: true, select: 'i.status', sortExpr: 'i.status' }, + { key: 'type', label: 'Type', type: 'string', group: 'invoice', select: 'i.type' }, + { key: 'source', label: 'Source', type: 'string', group: 'invoice', default: true, select: 'i.source' }, + { key: 'sourceId', label: 'Source reference', type: 'string', group: 'invoice', select: 'i.source_id' }, + { key: 'issuedAt', label: 'Issued', type: 'date', group: 'invoice', default: true, select: `to_char(i.issued_at, 'YYYY-MM-DD')`, sortExpr: 'i.issued_at' }, + { key: 'dueAt', label: 'Due', type: 'date', group: 'invoice', default: true, select: `to_char(i.due_at, 'YYYY-MM-DD')`, sortExpr: 'i.due_at' }, + { key: 'createdAt', label: 'Created', type: 'date', group: 'invoice', select: `to_char(i.created_at, 'YYYY-MM-DD')`, sortExpr: 'i.created_at' }, + { key: 'relatedInvoice', label: 'Related invoice', type: 'string', group: 'invoice', requires: ['rel'], select: 'rel.invoice_number' }, + + { key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' }, + { 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: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' }, + { key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' }, + { key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' }, + + { key: 'subtotalAmount', label: 'Subtotal', type: 'money', group: 'amounts', select: 'i.subtotal_amount::float8' }, + { key: 'taxAmount', label: 'Tax', type: 'money', group: 'amounts', select: 'i.tax_amount::float8' }, + { key: 'totalAmount', label: 'Total', type: 'money', group: 'amounts', default: true, select: 'i.total_amount::float8', sortExpr: 'i.total_amount' }, + { key: 'paidAmount', label: 'Paid', type: 'money', group: 'amounts', default: true, select: 'i.paid_amount::float8' }, + { key: 'balanceAmount', label: 'Balance', type: 'money', group: 'amounts', default: true, select: 'i.balance_amount::float8', sortExpr: 'i.balance_amount' }, + { key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' }, + + { key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` }, + { + key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment', + select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now() + THEN EXTRACT(DAY FROM now() - i.due_at)::int ELSE 0 END`, + }, + + { + key: 'lineCount', label: 'Line count', type: 'number', group: 'lines', + select: `(SELECT COUNT(*)::int FROM freight.invoice_lines il + WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`, + }, + { + key: 'lineCharges', label: 'Charges', type: 'string', group: 'lines', + select: `(SELECT string_agg(il.charge_type || ': ' || ROUND(il.amount, 2), ' | ' ORDER BY il.charge_type) + FROM freight.invoice_lines il + WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`, + }, + + { key: 'eimsStatus', label: 'EIMS status', type: 'string', group: 'eims', select: 'i.eims_status' }, + { key: 'eimsIrn', label: 'EIMS IRN', type: 'string', group: 'eims', select: 'i.eims_irn' }, + { key: 'eimsDocumentNumber', label: 'EIMS document no.', type: 'string', group: 'eims', select: 'i.eims_document_number' }, + { key: 'eimsDocumentType', label: 'EIMS document type', type: 'string', group: 'eims', select: 'i.eims_document_type' }, + { key: 'eimsSubmittedAt', label: 'EIMS submitted', type: 'datetime', group: 'eims', select: `to_char(i.eims_submitted_at, 'YYYY-MM-DD HH24:MI')` }, + // eims_ack_date is varchar in the schema, not a timestamp. + { key: 'eimsAckDate', label: 'EIMS acknowledged', type: 'string', group: 'eims', select: 'i.eims_ack_date' }, + { key: 'eimsCancelledAt', label: 'EIMS cancelled', type: 'datetime', group: 'eims', select: `to_char(i.eims_cancelled_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'eimsCancellationReasonCode', label: 'EIMS cancellation reason', type: 'string', group: 'eims', select: 'i.eims_cancellation_reason_code' }, + ], + + filters: [ + { key: 'issued', label: 'Issued', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect' }, + // The invoices list page sends a single `status`; accept both so its + // on-screen filter actually carries into the export. + { key: 'status', label: 'Status (single)', type: 'text' }, + { key: 'currency', label: 'Currency', type: 'select', options: [ + { value: 'ETB', label: 'ETB' }, + { value: 'USD', label: 'USD' }, + ] }, + { key: 'companyId', label: 'Customer', type: 'text' }, + { key: 'search', label: 'Search invoice no. or customer', type: 'text' }, + ], + + defaultSort: { key: 'issuedAt', dir: 'DESC' }, + + scope(ctx, qb) { + const { params, directions } = ctx; + qb.andWhere('i.deleted_at IS NULL'); + if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom }); + if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo }); + const statuses = params.statuses as string[] | null; + if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses }); + if (params.status) qb.andWhere('i.status = :status', { status: params.status }); + if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency }); + if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId }); + if (params.search) { + qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); + } + // ACL: invoices.source_id is a varchar pointer at the originating booking. + applyBookingRefDirectionScope(qb, 'i.source_id', directions); + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/locomotives.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/locomotives.dataset.ts new file mode 100644 index 000000000..5ec734489 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/locomotives.dataset.ts @@ -0,0 +1,79 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ExportDataset } from '../export.types'; + +const STATUS_OPTIONS = ['AVAILABLE', 'IN_USE', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((v) => ({ + value: v, + label: v.replace(/_/g, ' '), +})); + +export const locomotivesDataset: ExportDataset = { + key: 'locomotives', + title: 'Locomotives', + description: 'Locomotive fleet with capacity, traction specs and current location', + group: 'Fleet', + permission: FREIGHT_PERMS.locomotives.view, + base: { entity: Locomotive, alias: 'l' }, + + joins: [{ alias: 'y', entity: Yard, on: 'y.id = l.current_yard_id' }], + + groups: [ + { id: 'identity', label: 'Locomotive' }, + { id: 'capacity', label: 'Capacity & traction' }, + { id: 'location', label: 'Status & location' }, + { id: 'assignment', label: 'Assignment' }, + ], + + fields: [ + { key: 'code', label: 'Code', type: 'string', group: 'identity', default: true, select: 'l.code', sortExpr: 'l.code' }, + { key: 'name', label: 'Name', type: 'string', group: 'identity', default: true, select: 'l.name' }, + { key: 'locomotiveType', label: 'Type', type: 'string', group: 'identity', default: true, select: 'l.locomotive_type' }, + { key: 'createdAt', label: 'Added', type: 'date', group: 'identity', select: `to_char(l.created_at, 'YYYY-MM-DD')`, sortExpr: 'l.created_at' }, + + { key: 'maxPullWeightTons', label: 'Max pull weight (t)', type: 'tons', group: 'capacity', default: true, select: 'l.max_pull_weight_tons::float8', sortExpr: 'l.max_pull_weight_tons' }, + { key: 'maxTrainLengthMeters', label: 'Max train length (m)', type: 'number', group: 'capacity', default: true, select: 'l.max_train_length_meters::float8' }, + { key: 'overageToleranceTons', label: 'Overage tolerance (t)', type: 'tons', group: 'capacity', select: 'l.overage_tolerance_tons::float8' }, + { key: 'overageToleranceMeters', label: 'Overage tolerance (m)', type: 'number', group: 'capacity', select: 'l.overage_tolerance_meters::float8' }, + { key: 'powerKw', label: 'Power (kW)', type: 'number', group: 'capacity', select: 'l.power_kw::float8' }, + { key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number', group: 'capacity', select: 'l.traction_force_kn::float8' }, + { key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number', group: 'capacity', select: 'l.max_speed_kmh::float8' }, + + { key: 'status', label: 'Status', type: 'string', group: 'location', default: true, select: 'l.status', sortExpr: 'l.status' }, + { key: 'availableFrom', label: 'Available from', type: 'date', group: 'location', select: `to_char(l.available_from, 'YYYY-MM-DD')` }, + { key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' }, + { key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' }, + { key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' }, + + { + // One-to-many -> aggregate in a subquery, never a join. + key: 'assignedTrains', label: 'Assigned trains', type: 'string', group: 'assignment', + select: `(SELECT string_agg(DISTINCT tr.code, ' | ') + FROM freight.train_set_locomotives tsl + JOIN freight.train_sets ts ON ts.id = tsl.train_set_id AND ts.deleted_at IS NULL + JOIN freight.trains tr ON tr.id = ts.train_id AND tr.deleted_at IS NULL + WHERE tsl.locomotive_id = l.id AND tsl.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'status', label: 'Status', type: 'select', options: STATUS_OPTIONS }, + { key: 'locomotiveType', label: 'Type', type: 'text' }, + { key: 'currentYardId', label: 'Current yard', type: 'text' }, + { key: 'search', label: 'Search code or name', type: 'text' }, + ], + + defaultSort: { key: 'code', dir: 'ASC' }, + + scope(ctx, qb) { + const { params } = ctx; + qb.andWhere('l.deleted_at IS NULL'); + if (params.status) qb.andWhere('l.status = :status', { status: params.status }); + if (params.locomotiveType) qb.andWhere('l.locomotive_type = :locomotiveType', { locomotiveType: params.locomotiveType }); + if (params.currentYardId) qb.andWhere('l.current_yard_id = :currentYardId', { currentYardId: params.currentYardId }); + if (params.search) { + qb.andWhere('(l.code ILIKE :search OR l.name ILIKE :search)', { search: `%${params.search as string}%` }); + } + // Locomotives carry no trade direction — nothing to scope. Intentional. + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts new file mode 100644 index 000000000..59739823c --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts @@ -0,0 +1,113 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { PaymentEntity } from '../../payment/entities/payment.entity'; +import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportDataset } from '../export.types'; + +/** + * `freight.payments` breaks two conventions this codebase otherwise holds to, + * both verified against the live schema: + * + * 1. It does NOT extend @edr/api-common's BaseEntity — there is no + * `updated_at` and no `deleted_at`. A soft-delete guard here is a 42703, + * which is why `scope()` below applies the ACL only. + * 2. The failure columns are `failer_code` / `failer_message`, not `failure_*`. + * + * Raw gateway payloads (`raw_initiation`, `client_action`) are deliberately + * not exposed as fields. + */ +export const paymentsDataset: ExportDataset = { + key: 'payments', + title: 'Payments', + description: 'Payment transactions with method, status, and the booking and customer they belong to', + group: 'Finance', + permission: FREIGHT_PERMS.payments.view, + base: { entity: PaymentEntity, alias: 'p' }, + + joins: [ + // ref_id is a varchar pointer at the booking, so the cast is required. + { alias: 'bk', entity: Booking, on: 'bk.id::text = p.ref_id' }, + { alias: 'c', entity: Company, on: 'c.id = bk.company_id', requires: ['bk'] }, + ], + + groups: [ + { id: 'payment', label: 'Payment' }, + { id: 'amounts', label: 'Amounts' }, + { id: 'gateway', label: 'Gateway' }, + { id: 'booking', label: 'Booking' }, + { id: 'customer', label: 'Customer' }, + ], + + fields: [ + { key: 'merchantOrderId', label: 'Order ID', type: 'string', group: 'payment', default: true, select: 'p.merchant_order_id', sortExpr: 'p.merchant_order_id' }, + { key: 'status', label: 'Status', type: 'string', group: 'payment', default: true, select: 'p.status', sortExpr: 'p.status' }, + { key: 'method', label: 'Method', type: 'string', group: 'payment', default: true, select: 'p.method', sortExpr: 'p.method' }, + { key: 'type', label: 'Type', type: 'string', group: 'payment', select: 'p.type' }, + { key: 'referenceType', label: 'Reference type', type: 'string', group: 'payment', select: 'p.reference_type' }, + { key: 'createdAt', label: 'Created', type: 'datetime', group: 'payment', default: true, select: `to_char(p.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.created_at' }, + { key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', default: true, select: `to_char(p.paid_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.paid_at' }, + { key: 'refundedAt', label: 'Refunded at', type: 'datetime', group: 'payment', select: `to_char(p.refunded_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'expiresAt', label: 'Expires at', type: 'datetime', group: 'payment', select: `to_char(p.expires_at, 'YYYY-MM-DD HH24:MI')` }, + + { key: 'amount', label: 'Amount', type: 'money', group: 'amounts', default: true, select: 'p.amount::float8', sortExpr: 'p.amount' }, + { key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'p.currency' }, + { + // payment_refunds stores MINOR units (amount_minor), unlike payments.amount + // which is major. Divide, or a 50.00 refund exports as 5000. + key: 'refundedTotal', label: 'Refunded total', type: 'money', group: 'amounts', + select: `(SELECT ROUND(COALESCE(SUM(pr.amount_minor), 0) / 100.0, 2)::float8 + FROM freight.payment_refunds pr WHERE pr.payment_id = p.id)`, + }, + + { key: 'transactionId', label: 'Transaction ID', type: 'string', group: 'gateway', select: 'p.transaction_id' }, + { key: 'failerCode', label: 'Failure code', type: 'string', group: 'gateway', select: 'p.failer_code' }, + { key: 'failureMessage', label: 'Failure message', type: 'string', group: 'gateway', select: 'p.failer_message' }, + { key: 'reason', label: 'Reason', type: 'string', group: 'gateway', select: 'p.reason' }, + + { key: 'bookingReference', label: 'Booking', type: 'string', group: 'booking', default: true, requires: ['bk'], select: 'bk.reference' }, + { key: 'bookingStatus', label: 'Booking status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.status' }, + { key: 'bookingPaymentStatus', label: 'Booking payment status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.payment_status' }, + { key: 'bookingTradeDirection', label: 'Direction', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.trade_direction' }, + { key: 'bookingFreightType', label: 'Freight type', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.freight_type' }, + { key: 'bookingPnrCode', label: 'PNR code', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.pnr_code' }, + + { key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name' }, + { key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' }, + { 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' }, + ], + + filters: [ + { key: 'created', label: 'Created', type: 'daterange' }, + { key: 'status', label: 'Status', type: 'select', options: [ + 'action-required', 'processing', 'success', 'failed', 'canceled', 'refunded', + ].map((v) => ({ value: v, label: v })) }, + { key: 'method', label: 'Method', type: 'select', options: [ + 'telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill', + ].map((v) => ({ value: v, label: v })) }, + { key: 'currency', label: 'Currency', type: 'select', options: [ + { value: 'ETB', label: 'ETB' }, + { value: 'USD', label: 'USD' }, + ] }, + { key: 'search', label: 'Search order or transaction ID', type: 'text' }, + ], + + defaultSort: { key: 'createdAt', dir: 'DESC' }, + + scope(ctx, qb) { + const { params, directions } = ctx; + // No `p.deleted_at IS NULL` — this table has no soft-delete column. + if (params.createdFrom) qb.andWhere('p.created_at >= :createdFrom', { createdFrom: params.createdFrom }); + if (params.createdTo) qb.andWhere('p.created_at < :createdTo', { createdTo: params.createdTo }); + if (params.status) qb.andWhere('p.status = :status', { status: params.status }); + if (params.method) qb.andWhere('p.method = :method', { method: params.method }); + if (params.currency) qb.andWhere('p.currency = :currency', { currency: params.currency }); + if (params.search) { + qb.andWhere('(p.merchant_order_id ILIKE :search OR p.transaction_id ILIKE :search)', { + search: `%${params.search as string}%`, + }); + } + applyBookingRefDirectionScope(qb, 'p.ref_id', directions); + }, +}; 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 new file mode 100644 index 000000000..bb31591da --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts @@ -0,0 +1,138 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +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'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportDataset } from '../export.types'; + +/** + * `freightType` is NOT a column on train_schedules — it is derived from the + * bookings aboard (the list service does this with an EXISTS subquery). Exposed + * here as an aggregate over those bookings, never as `sch.freight_type`. + */ +export const trainSchedulesDataset: ExportDataset = { + key: 'train-schedules', + title: 'Train schedules', + description: 'Scheduled trains with route, timings, booking window and load', + group: 'Operations', + permission: FREIGHT_PERMS.trainScheduling.view, + base: { entity: TrainSchedule, alias: 'sch' }, + + joins: [ + { alias: 'rt', entity: Route, on: 'rt.id = sch.route_id' }, + { alias: 'os', entity: Yard, on: 'os.id = sch.origin_station_id' }, + { alias: 'ds', entity: Yard, on: 'ds.id = sch.destination_station_id' }, + { alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = sch.shipping_line_company_id' }, + ], + + groups: [ + { id: 'schedule', label: 'Schedule' }, + { id: 'route', label: 'Route' }, + { id: 'timings', label: 'Timings' }, + { id: 'window', label: 'Booking window' }, + { id: 'load', label: 'Load' }, + ], + + fields: [ + { key: 'reference', label: 'Reference', type: 'string', group: 'schedule', default: true, select: 'sch.reference', sortExpr: 'sch.reference' }, + { key: 'status', label: 'Status', type: 'string', group: 'schedule', default: true, select: 'sch.status', sortExpr: 'sch.status' }, + { key: 'trainNumber', label: 'Train number', type: 'string', group: 'schedule', default: true, select: 'sch.train_number' }, + { key: 'voyageNumber', label: 'Voyage number', type: 'string', group: 'schedule', select: 'sch.voyage_number' }, + { key: 'direction', label: 'Direction', type: 'string', group: 'schedule', default: true, select: 'sch.direction' }, + { key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'schedule', requires: ['slc'], select: 'slc.name' }, + { key: 'bookingCycleNo', label: 'Booking cycle', type: 'number', group: 'schedule', select: 'sch.booking_cycle_no' }, + { key: 'reverseWagonOrder', label: 'Reverse wagon order', type: 'boolean', group: 'schedule', select: 'sch.reverse_wagon_order' }, + { key: 'createdAt', label: 'Created', type: 'date', group: 'schedule', select: `to_char(sch.created_at, 'YYYY-MM-DD')`, sortExpr: 'sch.created_at' }, + + { key: 'originStation', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['os'], select: 'os.label' }, + { key: 'originStationCode', label: 'Origin code', type: 'string', group: 'route', requires: ['os'], select: 'os.code' }, + { key: 'destinationStation', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['ds'], select: 'ds.label' }, + { key: 'destinationStationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['ds'], select: 'ds.code' }, + { key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' }, + + { key: 'scheduledDeparture', label: 'Scheduled departure', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'sch.scheduled_departure_date' }, + { key: 'scheduledArrival', label: 'Scheduled arrival', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI')` }, + { key: 'actualDeparture', label: 'Actual departure', type: 'datetime', group: 'timings', select: `to_char(sch.actual_departure_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'actualArrival', label: 'Actual arrival', type: 'datetime', group: 'timings', select: `to_char(sch.actual_arrival_at, 'YYYY-MM-DD HH24:MI')` }, + { + key: 'departureDelayHours', label: 'Departure delay (h)', type: 'number', group: 'timings', + select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_departure_at - sch.scheduled_departure_date)) / 3600.0, 2)::float8`, + }, + { + key: 'transitHours', label: 'Transit time (h)', type: 'number', group: 'timings', + select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_arrival_at - sch.actual_departure_at)) / 3600.0, 2)::float8`, + }, + + { key: 'bookingWindowStatus', label: 'Window status', type: 'string', group: 'window', select: 'sch.booking_window_status' }, + { key: 'windowPhase', label: 'Window phase', type: 'string', group: 'window', select: 'sch.window_phase' }, + { key: 'windowOpensAt', label: 'Window opens', type: 'datetime', group: 'window', select: `to_char(sch.window_opens_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'windowClosesAt', label: 'Window closes', type: 'datetime', group: 'window', select: `to_char(sch.window_closes_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'docReviewEndsAt', label: 'Doc review ends', type: 'datetime', group: 'window', select: `to_char(sch.doc_review_ends_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'paymentPhaseEndsAt', label: 'Payment phase ends', type: 'datetime', group: 'window', select: `to_char(sch.payment_phase_ends_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'windowRuleCustom', label: 'Custom window rules', type: 'boolean', group: 'window', select: 'sch.window_rule_custom' }, + + { key: 'maxWagons', label: 'Max wagons', type: 'number', group: 'load', select: 'sch.max_wagons' }, + { + key: 'bookingCount', label: 'Bookings', type: 'number', group: 'load', default: true, + select: `(SELECT COUNT(*)::int FROM freight.bookings b + WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, + }, + { + // Derived, not a column — see the file header. + key: 'freightTypes', label: 'Freight types', type: 'string', group: 'load', default: true, + select: `(SELECT string_agg(DISTINCT b.freight_type, ' | ') FROM freight.bookings b + WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, + }, + { + 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 + FROM freight.bookings b + WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, + }, + { + key: 'assignedWagonCount', label: 'Wagons assigned', type: 'number', group: 'load', + select: `(SELECT COUNT(*)::int FROM freight.wagons w + WHERE w.current_train_schedule_id = sch.id AND w.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'departure', label: 'Departure', type: 'daterange' }, + { key: 'status', label: 'Status', type: 'text' }, + { key: 'direction', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) }, + // freightType is derived from the bookings aboard, so it filters via + // EXISTS — the same shape the list service's scheduleFreightTypeFilter uses. + { key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) }, + { key: 'originStationId', label: 'Origin', type: 'text' }, + { key: 'destinationStationId', label: 'Destination', type: 'text' }, + { key: 'search', label: 'Search reference or train number', type: 'text' }, + ], + + defaultSort: { key: 'scheduledDeparture', dir: 'DESC' }, + + scope(ctx, qb) { + const { params, directions } = ctx; + qb.andWhere('sch.deleted_at IS NULL'); + if (params.departureFrom) qb.andWhere('sch.scheduled_departure_date >= :departureFrom', { departureFrom: params.departureFrom }); + if (params.departureTo) qb.andWhere('sch.scheduled_departure_date < :departureTo', { departureTo: params.departureTo }); + if (params.status) qb.andWhere('sch.status = :status', { status: params.status }); + if (params.direction) qb.andWhere('sch.direction = :direction', { direction: params.direction }); + if (params.freightType) { + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.bookings fb + WHERE fb.train_schedule_id = sch.id AND fb.deleted_at IS NULL + AND fb.freight_type = :freightType)`, + { freightType: params.freightType }, + ); + } + if (params.originStationId) qb.andWhere('sch.origin_station_id = :originStationId', { originStationId: params.originStationId }); + if (params.destinationStationId) qb.andWhere('sch.destination_station_id = :destinationStationId', { destinationStationId: params.destinationStationId }); + if (params.search) { + qb.andWhere('(sch.reference ILIKE :search OR sch.train_number ILIKE :search)', { search: `%${params.search as string}%` }); + } + // Schedules carry their own `direction` column, so scope on that directly + // rather than through the bookings aboard. + applyDirectionScope(qb, 'sch.direction', directions); + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/trains.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/trains.dataset.ts new file mode 100644 index 000000000..4122a245a --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/trains.dataset.ts @@ -0,0 +1,90 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Route } from '../../routes/entities/route.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Train } from '../../trains/entities/train.entity'; +import { ExportDataset } from '../export.types'; + +/** + * The list endpoint (`trains.service.ts findAll`) loads NO relations, so the + * UI shows raw FK uuids where names belong. This dataset resolves them, which + * makes it the clearest "the export shows more than the screen" case in the set. + */ +export const trainsDataset: ExportDataset = { + key: 'trains', + title: 'Trains', + description: 'Trains with route, stations, capacity and current composition', + group: 'Fleet', + permission: FREIGHT_PERMS.trains.view, + base: { entity: Train, alias: 't' }, + + joins: [ + { alias: 'y', entity: Yard, on: 'y.id = t.current_yard_id' }, + { alias: 'rt', entity: Route, on: 'rt.id = t.route_id' }, + { alias: 'os', entity: Yard, on: 'os.id = t.origin_station_id' }, + { alias: 'ds', entity: Yard, on: 'ds.id = t.destination_station_id' }, + ], + + groups: [ + { id: 'train', label: 'Train' }, + { id: 'route', label: 'Route & stations' }, + { id: 'capacity', label: 'Capacity' }, + { id: 'composition', label: 'Composition' }, + ], + + fields: [ + { key: 'code', label: 'Code', type: 'string', group: 'train', default: true, select: 't.code', sortExpr: 't.code' }, + { key: 'trainNumber', label: 'Train number', type: 'string', group: 'train', default: true, select: 't.train_number' }, + { key: 'trainName', label: 'Train name', type: 'string', group: 'train', default: true, select: 't.train_name' }, + { key: 'status', label: 'Status', type: 'string', group: 'train', default: true, select: 't.status', sortExpr: 't.status' }, + { key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'train', select: 't.import_train_number' }, + { key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'train', select: 't.export_train_number' }, + { key: 'locomotiveNumber', label: 'Locomotive number', type: 'string', group: 'train', select: 't.locomotive_number' }, + { key: 'notes', label: 'Notes', type: 'string', group: 'train', select: 't.notes' }, + { key: 'remarks', label: 'Remarks', type: 'string', group: 'train', select: 't.remarks' }, + { key: 'createdAt', label: 'Added', type: 'date', group: 'train', select: `to_char(t.created_at, 'YYYY-MM-DD')`, sortExpr: 't.created_at' }, + + { key: 'currentYard', label: 'Current yard', type: 'string', group: 'route', default: true, requires: ['y'], select: 'y.label' }, + { key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'route', requires: ['y'], select: 'y.code' }, + { key: 'routeDirection', label: 'Route direction', type: 'string', group: 'route', requires: ['rt'], select: 'rt.direction' }, + { key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' }, + { key: 'originStation', label: 'Origin station', type: 'string', group: 'route', requires: ['os'], select: 'os.label' }, + { key: 'destinationStation', label: 'Destination station', type: 'string', group: 'route', requires: ['ds'], select: 'ds.label' }, + { key: 'departureTime', label: 'Departure time', type: 'string', group: 'route', select: 't.departure_time::text' }, + { key: 'arrivalTime', label: 'Arrival time', type: 'string', group: 'route', select: 't.arrival_time::text' }, + + { key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'capacity', default: true, select: 't.capacity_tons::float8', sortExpr: 't.capacity_tons' }, + + { + key: 'wagonCount', label: 'Wagons attached', type: 'number', group: 'composition', default: true, + select: `(SELECT COUNT(*)::int FROM freight.wagons w + WHERE w.train_id = t.id AND w.deleted_at IS NULL)`, + }, + { + key: 'wagonNumbers', label: 'Wagon numbers', type: 'string', group: 'composition', + select: `(SELECT string_agg(w.wagon_number, ' | ' ORDER BY w.sequence_number NULLS LAST, w.wagon_number) + FROM freight.wagons w + WHERE w.train_id = t.id AND w.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'status', label: 'Status', type: 'text' }, + { key: 'currentYardId', label: 'Current yard', type: 'text' }, + { key: 'search', label: 'Search code, number or name', type: 'text' }, + ], + + defaultSort: { key: 'code', dir: 'ASC' }, + + scope(ctx, qb) { + const { params } = ctx; + qb.andWhere('t.deleted_at IS NULL'); + if (params.status) qb.andWhere('t.status = :status', { status: params.status }); + if (params.currentYardId) qb.andWhere('t.current_yard_id = :currentYardId', { currentYardId: params.currentYardId }); + if (params.search) { + qb.andWhere('(t.code ILIKE :search OR t.train_number ILIKE :search OR t.train_name ILIKE :search)', { + search: `%${params.search as string}%`, + }); + } + // Trains carry no trade direction — nothing to scope. Intentional. + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/wagons.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/wagons.dataset.ts new file mode 100644 index 000000000..cce85bb88 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/wagons.dataset.ts @@ -0,0 +1,110 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { Train } from '../../trains/entities/train.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { ExportDataset } from '../export.types'; + +/** + * Two traps this dataset works around: + * + * 1. Tare weight, payload and length live on WAGON_TYPE, not wagon — which is + * why they carry `requires: ['wt']` and their sortExpr points at `wt`. + * 2. `lastMaintenanceAt` / `lastAvailableAt` come from a grouped query over + * wagon_status_logs in the service (`attachStatusDates`). Ported here as + * correlated subqueries so they compose with everything else and cost + * nothing when unticked. Note the log columns are `from_status`/`to_status`, + * not `status`. + */ +export const wagonsDataset: ExportDataset = { + key: 'wagons', + title: 'Wagons', + description: 'Wagon fleet with type specs, location, train assignment and status history', + group: 'Fleet', + permission: FREIGHT_PERMS.wagons.view, + base: { entity: Wagon, alias: 'w' }, + + joins: [ + { alias: 'wt', entity: WagonType, on: 'wt.id = w.wagon_type_id' }, + { alias: 'y', entity: Yard, on: 'y.id = w.current_yard_id' }, + { alias: 't', entity: Train, on: 't.id = w.train_id' }, + { alias: 'sch', entity: TrainSchedule, on: 'sch.id = w.current_train_schedule_id' }, + ], + + groups: [ + { id: 'wagon', label: 'Wagon' }, + { id: 'type', label: 'Type & specs' }, + { id: 'location', label: 'Location' }, + { id: 'assignment', label: 'Assignment' }, + { id: 'history', label: 'Status history' }, + ], + + fields: [ + { key: 'wagonNumber', label: 'Wagon number', type: 'string', group: 'wagon', default: true, select: 'w.wagon_number', sortExpr: 'w.wagon_number' }, + { key: 'status', label: 'Status', type: 'string', group: 'wagon', default: true, select: 'w.status', sortExpr: 'w.status' }, + { key: 'sequenceNumber', label: 'Sequence no.', type: 'number', group: 'wagon', select: 'w.sequence_number' }, + { key: 'notes', label: 'Notes', type: 'string', group: 'wagon', select: 'w.notes' }, + { key: 'createdAt', label: 'Added', type: 'date', group: 'wagon', select: `to_char(w.created_at, 'YYYY-MM-DD')`, sortExpr: 'w.created_at' }, + + { key: 'wagonType', label: 'Type', type: 'string', group: 'type', default: true, requires: ['wt'], select: 'wt.name', sortExpr: 'wt.name' }, + { key: 'wagonTypeCode', label: 'Type code', type: 'string', group: 'type', requires: ['wt'], select: 'wt.code' }, + { key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'type', default: true, requires: ['wt'], select: 'wt.capacity_tons::float8', sortExpr: 'wt.capacity_tons' }, + { key: 'tareWeightTons', label: 'Tare weight (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.tare_weight_tons::float8' }, + { key: 'lengthMeters', label: 'Length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.length_meters::float8' }, + { key: 'equatedLengthM', label: 'Equated length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.equated_length_m::float8' }, + { key: 'maxContainerGrossT', label: 'Max container gross (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.max_container_gross_t::float8' }, + { key: 'supportsContainer', label: 'Supports container', type: 'boolean', group: 'type', requires: ['wt'], select: 'wt.supports_container' }, + { key: 'supportedLoadTypes', label: 'Supported load types', type: 'string', group: 'type', requires: ['wt'], select: 'wt.supported_load_types::text' }, + + { key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' }, + { key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' }, + { key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' }, + + { key: 'trainCode', label: 'Train', type: 'string', group: 'assignment', default: true, requires: ['t'], select: 't.code' }, + { key: 'trainNumber', label: 'Train number', type: 'string', group: 'assignment', requires: ['t'], select: 't.train_number' }, + { key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'assignment', select: 'w.export_train_number' }, + { key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'assignment', select: 'w.import_train_number' }, + { key: 'scheduleReference', label: 'Current schedule', type: 'string', group: 'assignment', requires: ['sch'], select: 'sch.reference' }, + { key: 'scheduleDeparture', label: 'Schedule departure', type: 'date', group: 'assignment', requires: ['sch'], select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD')` }, + + { + key: 'lastMaintenanceAt', label: 'Last maintenance', type: 'datetime', group: 'history', + select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI') + FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.to_status = 'MAINTENANCE' AND l.deleted_at IS NULL)`, + }, + { + key: 'lastAvailableAt', label: 'Last available', type: 'datetime', group: 'history', + select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI') + FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.to_status = 'AVAILABLE' AND l.deleted_at IS NULL)`, + }, + { + key: 'statusChangeCount', label: 'Status changes', type: 'number', group: 'history', + select: `(SELECT COUNT(*)::int FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'status', label: 'Status', type: 'text' }, + { key: 'wagonTypeId', label: 'Wagon type', type: 'text' }, + { key: 'currentYardId', label: 'Current yard', type: 'text' }, + { key: 'trainId', label: 'Train', type: 'text' }, + { key: 'search', label: 'Search wagon number', type: 'text' }, + ], + + defaultSort: { key: 'wagonNumber', dir: 'ASC' }, + + scope(ctx, qb) { + const { params } = ctx; + qb.andWhere('w.deleted_at IS NULL'); + if (params.status) qb.andWhere('w.status = :status', { status: params.status }); + if (params.wagonTypeId) qb.andWhere('w.wagon_type_id = :wagonTypeId', { wagonTypeId: params.wagonTypeId }); + if (params.currentYardId) qb.andWhere('w.current_yard_id = :currentYardId', { currentYardId: params.currentYardId }); + if (params.trainId) qb.andWhere('w.train_id = :trainId', { trainId: params.trainId }); + if (params.search) qb.andWhere('w.wagon_number ILIKE :search', { search: `%${params.search as string}%` }); + // Wagons carry no trade direction — nothing to scope. Intentional. + }, +}; 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 new file mode 100644 index 000000000..c302d9d3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts @@ -0,0 +1,81 @@ +import { DataSource } from 'typeorm'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; + +export interface ExportFilterOption { + value: string; + label: string; +} + +export interface ExportFilterDef { + key: string; + label: string; + type: ExportFilterType; + /** Static choices. Mutually exclusive with `optionsQuery`. */ + options?: ExportFilterOption[]; + /** Reference-data choices resolved from the DB and cached for the process. */ + optionsQuery?: (ds: DataSource) => Promise; +} + +/** Raw query-string bag. Per-registry filter keys, so `forbidNonWhitelisted` can't police it. */ +export type RawFilterQuery = Record; + +/** + * Coerce raw query strings into typed filter params per a filter declaration + * list. Unknown keys are dropped rather than rejected. + * + * Shared by the report runner and the export runner so the `daterange` + * handling in particular cannot drift between them: `To` is pushed forward a + * day because callers mean an INCLUSIVE end date while the SQL bound is + * exclusive (`created_at < :dateTo`). + */ +export function coerceFilterParams( + filters: ExportFilterDef[], + raw: RawFilterQuery, +): Record { + const params: Record = {}; + for (const filter of filters) { + if (filter.type === 'daterange') { + const from = raw[`${filter.key}From`]; + const to = raw[`${filter.key}To`]; + params[`${filter.key}From`] = from ? new Date(from).toISOString() : null; + params[`${filter.key}To`] = to + ? new Date(new Date(to).getTime() + DAY_MS).toISOString() + : null; + } else if (filter.type === 'multiselect') { + const csv = raw[filter.key]; + const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; + params[filter.key] = items.length ? items : null; + } else { + params[filter.key] = raw[filter.key]?.trim() || null; + } + } + return params; +} + +/** + * Process-lifetime cache for `optionsQuery` results — small, rarely-changing + * reference lists (23 stations, 18 cargo types) hit on every catalog load. + * + * ponytail: keyed by filter key alone, so two registries sharing a filter key + * share one option list. Key by `${registry}:${filterKey}` if that ever bites. + */ +const optionsCache = new Map(); + +export async function resolveFilterOptions( + filters: ExportFilterDef[], + ds: DataSource, +): Promise { + return Promise.all( + filters.map(async (filter) => { + if (!filter.optionsQuery) return filter; + const cached = optionsCache.get(filter.key); + if (cached) return { ...filter, options: cached, optionsQuery: undefined }; + const options = await filter.optionsQuery(ds); + optionsCache.set(filter.key, options); + return { ...filter, options, optionsQuery: undefined }; + }), + ); +} diff --git a/apps/edr-freight-api/src/modules/exports/export-query.builder.spec.ts b/apps/edr-freight-api/src/modules/exports/export-query.builder.spec.ts new file mode 100644 index 000000000..af6dd923d --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-query.builder.spec.ts @@ -0,0 +1,92 @@ +import { resolveJoins } from './export-query.builder'; +import { ExportDataset, ExportField } from './export.types'; + +const field = (key: string, requires?: string[]): ExportField => ({ + key, + label: key, + type: 'string', + group: 'g', + select: `x.${key}`, + requires, +}); + +/** Entities are never dereferenced by resolveJoins — only the alias graph matters. */ +const entity = {} as ExportDataset['joins'][number]['entity']; + +const dataset = ( + joins: ExportDataset['joins'], + alwaysJoin?: string[], +): ExportDataset => + ({ + key: 'test', + joins, + alwaysJoin, + fields: [], + }) as unknown as ExportDataset; + +describe('resolveJoins', () => { + it('pulls in only the joins the selected fields ask for', () => { + const ds = dataset([ + { alias: 'a', entity, on: 'a.id = b.a_id' }, + { alias: 'z', entity, on: 'z.id = b.z_id' }, + ]); + expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']); + }); + + it('selecting nothing still applies alwaysJoin — the count query relies on this', () => { + const ds = dataset( + [ + { alias: 'a', entity, on: 'a.id = b.a_id' }, + { alias: 'z', entity, on: 'z.id = b.z_id' }, + ], + ['a'], + ); + expect(resolveJoins(ds, []).map((j) => j.alias)).toEqual(['a']); + }); + + it('resolves a transitive dependency, dependency first', () => { + const ds = dataset([ + { alias: 'ct', entity, on: 'ct.id = b.contract_id' }, + { alias: 'ctc', entity, on: 'ctc.id = ct.company_id', requires: ['ct'] }, + ]); + expect(resolveJoins(ds, [field('x', ['ctc'])]).map((j) => j.alias)).toEqual(['ct', 'ctc']); + }); + + it('resolves a multi-hop chain in order', () => { + const ds = dataset([ + { alias: 'a', entity, on: 'a.id = b.a_id' }, + { alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] }, + { alias: 'cc', entity, on: 'cc.id = bb.c_id', requires: ['bb'] }, + ]); + expect(resolveJoins(ds, [field('x', ['cc'])]).map((j) => j.alias)).toEqual(['a', 'bb', 'cc']); + }); + + it('emits a shared join once, not per field that needs it', () => { + const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]); + const joins = resolveJoins(ds, [field('one', ['a']), field('two', ['a'])]); + expect(joins.map((j) => j.alias)).toEqual(['a']); + }); + + it('does not duplicate a join already pulled in by alwaysJoin', () => { + const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }], ['a']); + expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']); + }); + + it('throws on a cycle rather than looping forever', () => { + const ds = dataset([ + { alias: 'a', entity, on: 'a.id = bb.a_id', requires: ['bb'] }, + { alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] }, + ]); + expect(() => resolveJoins(ds, [field('x', ['a'])])).toThrow(/join cycle/); + }); + + it('throws on an undeclared alias — a typo must fail loudly, not silently 42P01', () => { + const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]); + expect(() => resolveJoins(ds, [field('x', ['ghost'])])).toThrow(/unknown join alias "ghost"/); + }); + + it('a field with no requires pulls in no joins at all', () => { + const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]); + expect(resolveJoins(ds, [field('plain')])).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exports/export-query.builder.ts b/apps/edr-freight-api/src/modules/exports/export-query.builder.ts new file mode 100644 index 000000000..6f4c091d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-query.builder.ts @@ -0,0 +1,101 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ExportContext, ExportDataset, ExportField, ExportJoin } from './export.types'; + +/** + * Sort expression fallback: the SELECT alias TypeORM emitted, quoted. TypeORM + * double-quotes `addSelect` aliases (preserving case), so ordering by the bare + * key lets Postgres fold it to lowercase and 42703 on any camelCase alias. + */ +export const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`; + +/** + * Selected fields -> the joins they need, transitively, dependencies first. + * DFS post-order over `requires`, memoized. Deterministic: `alwaysJoin` first, + * then fields in the dataset's own declaration order. + */ +export function resolveJoins(dataset: ExportDataset, fields: ExportField[]): ExportJoin[] { + const byAlias = new Map(dataset.joins.map((j) => [j.alias, j])); + const out: ExportJoin[] = []; + const done = new Set(); + const onStack = new Set(); + + const visit = (alias: string): void => { + if (done.has(alias)) return; + if (onStack.has(alias)) { + throw new Error(`export "${dataset.key}": join cycle at alias "${alias}"`); + } + const join = byAlias.get(alias); + if (!join) { + throw new Error(`export "${dataset.key}": unknown join alias "${alias}"`); + } + onStack.add(alias); + for (const dep of join.requires ?? []) visit(dep); + onStack.delete(alias); + done.add(alias); + out.push(join); + }; + + for (const alias of dataset.alwaysJoin ?? []) visit(alias); + for (const field of fields) for (const alias of field.requires ?? []) visit(alias); + return out; +} + +/** The download query: base + only the joins the selected fields need. */ +export function buildExportQuery( + dataset: ExportDataset, + fields: ExportField[], + ctx: ExportContext, +): SelectQueryBuilder { + const qb = ctx.ds.createQueryBuilder().from(dataset.base.entity, dataset.base.alias); + for (const join of resolveJoins(dataset, fields)) { + qb.leftJoin(join.entity, join.alias, join.on); + } + for (const field of fields) qb.addSelect(field.select, field.key); + dataset.scope(ctx, qb); + return qb; +} + +/** + * The count query: same base, same `scope()`, same WHERE — but no field joins + * and no selects. Exact rather than an estimate, because every lazy join is a + * left join to a to-one side and so cannot change the row count. + */ +export function buildExportCountQuery( + dataset: ExportDataset, + ctx: ExportContext, +): SelectQueryBuilder { + const qb = ctx.ds + .createQueryBuilder() + .select('COUNT(*)::int', 'total') + .from(dataset.base.entity, dataset.base.alias); + for (const join of resolveJoins(dataset, [])) { + qb.leftJoin(join.entity, join.alias, join.on); + } + dataset.scope(ctx, qb); + return qb; +} + +/** + * Resolve a requested sort against the SELECTED fields. Restricting to selected + * fields means a sort can never pull in a join the projection didn't already + * need — which is what keeps the count query's join set correct. + */ +export function resolveExportSort( + dataset: ExportDataset, + fields: ExportField[], + sortBy?: string, + sortOrder?: string, +): { expr: string; dir: 'ASC' | 'DESC' } | null { + const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const requested = sortBy && fields.find((f) => f.key === sortBy && f.sortExpr); + if (requested) return { expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir }; + + if (!dataset.defaultSort) return null; + const fallback = fields.find((f) => f.key === dataset.defaultSort!.key); + if (!fallback) return null; + return { + expr: fallback.sortExpr ?? aliasSortExpr(fallback.key), + dir: dataset.defaultSort.dir, + }; +} diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts new file mode 100644 index 000000000..1b473b12a --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts @@ -0,0 +1,87 @@ +import { + EXPORT_MIME, + formatRowCap, + pickByKey, + resolveExportFormat, + resolveRowLimit, +} from './export-request.util'; +import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service'; + +describe('resolveExportFormat', () => { + it('only \'pdf\' exports as pdf', () => { + expect(resolveExportFormat('pdf')).toBe('pdf'); + }); + + it('\'csv\' exports as csv', () => { + expect(resolveExportFormat('csv')).toBe('csv'); + }); + + it.each([undefined, 'xlsx', 'doc', ''])('%p falls back to xlsx', (raw) => { + expect(resolveExportFormat(raw)).toBe('xlsx'); + }); +}); + +describe('formatRowCap', () => { + it('is the format\'s hard ceiling and is not caller-controllable', () => { + expect(formatRowCap('xlsx')).toBe(XLSX_ROW_CAP); + expect(formatRowCap('csv')).toBe(CSV_ROW_CAP); + expect(formatRowCap('pdf')).toBe(PDF_ROW_CAP); + }); +}); + +describe('resolveRowLimit', () => { + it('no limit means "everything, up to the cap"', () => { + expect(resolveRowLimit('xlsx', undefined)).toBeUndefined(); + }); + + it('an explicit limit is the caller asking to be truncated — kept as-is', () => { + // Distinct from the cap: 100 here must yield 100 rows, not a 400, even + // when the unfiltered result is far larger. + expect(resolveRowLimit('pdf', '100')).toBe(100); + }); + + it('an explicit limit over the format cap is clamped down', () => { + expect(resolveRowLimit('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP); + expect(resolveRowLimit('csv', String(CSV_ROW_CAP + 1))).toBe(CSV_ROW_CAP); + }); + + it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p means no limit', (raw) => { + expect(resolveRowLimit('xlsx', raw)).toBeUndefined(); + }); +}); + +describe('EXPORT_MIME', () => { + it('every format has a content type and a matching extension', () => { + expect(EXPORT_MIME.csv.ext).toBe('csv'); + expect(EXPORT_MIME.xlsx.ext).toBe('xlsx'); + expect(EXPORT_MIME.pdf.type).toBe('application/pdf'); + }); +}); + +describe('pickByKey', () => { + const columns = [ + { key: 'a', label: 'A', type: 'string' as const }, + { key: 'b', label: 'B', type: 'number' as const }, + { key: 'c', label: 'C', type: 'money' as const }, + ]; + + it('missing fields returns every column', () => { + expect(pickByKey(columns, undefined)).toEqual(columns); + }); + + it('empty fields string returns every column', () => { + expect(pickByKey(columns, '')).toEqual(columns); + }); + + it('a known subset filters to just those, in the source\'s own order', () => { + expect(pickByKey(columns, 'c,a')).toEqual([columns[0], columns[2]]); + }); + + it('unknown keys are dropped, not passed through', () => { + expect(pickByKey(columns, 'a,ghost')).toEqual([columns[0]]); + }); + + it('all-unknown keys falls back to everything instead of a blank sheet', () => { + expect(pickByKey(columns, 'ghost,also-ghost')).toEqual(columns); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.ts new file mode 100644 index 000000000..39f1f2e8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.ts @@ -0,0 +1,58 @@ +import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service'; + +export type ExportFormat = 'xlsx' | 'csv' | 'pdf'; + +/** Anything but the literal 'pdf' or 'csv' exports as xlsx. */ +export function resolveExportFormat(raw: string | undefined): ExportFormat { + if (raw === 'pdf') return 'pdf'; + if (raw === 'csv') return 'csv'; + return 'xlsx'; +} + +/** + * The format's hard ceiling. Not caller-controllable: exceeding it is an error, + * because a silently short file is worse than a clear failure. + */ +export function formatRowCap(format: ExportFormat): number { + return format === 'pdf' ? PDF_ROW_CAP : format === 'csv' ? CSV_ROW_CAP : XLSX_ROW_CAP; +} + +/** + * The caller's deliberate "just the first N rows", clamped to the format cap. + * `undefined` means "everything, up to the cap". + * + * This is a DIFFERENT thing from the cap and must not share a number with it. + * Conflating them (as this code did originally) makes the dialog's + * "Records: First 100" option fail outright on any export with more than 100 + * rows — the user explicitly asked to be truncated, so truncating is the + * correct answer, not a 400. + */ +export function resolveRowLimit( + format: ExportFormat, + rawLimit: string | undefined, +): number | undefined { + const requested = Number(rawLimit); + return requested > 0 ? Math.min(requested, formatRowCap(format)) : undefined; +} + +/** Content type + file extension per format, for the download response headers. */ +export const EXPORT_MIME: Record = { + xlsx: { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ext: 'xlsx', + }, + csv: { type: 'text/csv; charset=utf-8', ext: 'csv' }, + pdf: { type: 'application/pdf', ext: 'pdf' }, +}; + +/** + * Caller's requested subset, whitelisted against what they're allowed to have. + * Missing, empty, or all-unknown `raw` falls back to every entry rather than + * shipping a blank sheet. Generic over `{ key }` so it serves both a report's + * `columns` and a dataset's `fields`. + */ +export function pickByKey(all: T[], raw: string | undefined): T[] { + const requested = raw?.split(',').filter(Boolean); + const filtered = requested?.length ? all.filter((c) => requested.includes(c.key)) : all; + return filtered.length ? filtered : all; +} diff --git a/apps/edr-freight-api/src/modules/exports/export-runner.service.ts b/apps/edr-freight-api/src/modules/exports/export-runner.service.ts new file mode 100644 index 000000000..816aeb38d --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export-runner.service.ts @@ -0,0 +1,69 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { coerceFilterParams, RawFilterQuery } from './export-filter.util'; +import { + buildExportCountQuery, + buildExportQuery, + resolveExportSort, +} from './export-query.builder'; +import { ExportDataset, ExportField } from './export.types'; + +@Injectable() +export class ExportRunnerService { + constructor(@InjectDataSource() private readonly ds: DataSource) {} + + private context(dataset: ExportDataset, raw: RawFilterQuery, directions: string[] | null) { + return { ds: this.ds, params: coerceFilterParams(dataset.filters, raw), directions }; + } + + /** + * Exact row count for the current filters. Exact rather than estimated + * because lazy joins are all left joins to to-one sides, so the count cannot + * depend on which fields the caller picked. + */ + async count( + dataset: ExportDataset, + raw: RawFilterQuery, + directions: string[] | null, + ): Promise { + const qb = buildExportCountQuery(dataset, this.context(dataset, raw, directions)); + const row = await qb.getRawOne<{ total: number }>(); + return Number(row?.total ?? 0); + } + + /** + * Matching rows. + * + * `limit` is the caller's deliberate "first N" truncation — honoured + * silently, because they asked for it. `cap` is the format's hard ceiling — + * exceeding it throws, because a silently short file is worse than a clear + * error: nothing downstream reveals that rows are missing. + */ + async run( + dataset: ExportDataset, + fields: ExportField[], + raw: RawFilterQuery, + directions: string[] | null, + { cap, limit }: { cap: number; limit?: number }, + ): Promise[]> { + const ctx = this.context(dataset, raw, directions); + const qb = buildExportQuery(dataset, fields, ctx); + + const sort = resolveExportSort(dataset, fields, raw.sortBy, raw.sortOrder); + if (sort) qb.orderBy(sort.expr, sort.dir); + + const ceiling = limit ?? cap; + // ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are + // exactly that many rows" from "there are more". + const items = await qb.limit(ceiling + 1).getRawMany(); + if (items.length <= ceiling) return items; + + // Asked to be truncated -> truncate. Hit the hard cap -> say so. + if (limit !== undefined) return items.slice(0, limit); + throw new BadRequestException( + `This export has more than ${cap.toLocaleString()} rows, the limit for this format. Narrow the filters, or export a smaller number of rows.`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/exports/export.registry.ts b/apps/edr-freight-api/src/modules/exports/export.registry.ts new file mode 100644 index 000000000..1b7f24c13 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export.registry.ts @@ -0,0 +1,33 @@ +import { bookingsDataset } from './datasets/bookings.dataset'; +import { contractsDataset } from './datasets/contracts.dataset'; +import { customersDataset } from './datasets/customers.dataset'; +import { invoicesDataset } from './datasets/invoices.dataset'; +import { locomotivesDataset } from './datasets/locomotives.dataset'; +import { paymentsDataset } from './datasets/payments.dataset'; +import { trainSchedulesDataset } from './datasets/train-schedules.dataset'; +import { trainsDataset } from './datasets/trains.dataset'; +import { wagonsDataset } from './datasets/wagons.dataset'; +import { ExportDataset } from './export.types'; + +/** + * Every exportable dataset. + * + * Adding one = a new file under `datasets/` + an entry here. No frontend edit, + * no route, no permission seed — the dialog is driven entirely by the catalog + * this registry serves, and a dataset reuses its module's existing `view` key. + */ +export const DATASETS: ExportDataset[] = [ + bookingsDataset, + contractsDataset, + customersDataset, + invoicesDataset, + paymentsDataset, + trainSchedulesDataset, + locomotivesDataset, + trainsDataset, + wagonsDataset, +]; + +const BY_KEY = new Map(DATASETS.map((d) => [d.key, d])); + +export const getDataset = (key: string): ExportDataset | undefined => BY_KEY.get(key); diff --git a/apps/edr-freight-api/src/modules/exports/export.types.ts b/apps/edr-freight-api/src/modules/exports/export.types.ts new file mode 100644 index 000000000..db12f0211 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/export.types.ts @@ -0,0 +1,135 @@ +import { + DataSource, + EntityTarget, + ObjectLiteral, + ObjectType, + SelectQueryBuilder, +} from 'typeorm'; + +import { ExportFilterDef } from './export-filter.util'; +import { ExportFieldType } from './tabular-export.service'; + +export type { ExportFieldType }; + +/** + * A lazily-applied relation. + * + * There is deliberately no `kind: 'inner' | 'left'` here — every join is + * emitted as a LEFT JOIN, and the type makes anything else unrepresentable. + * An inner join added only because someone ticked a checkbox would silently + * change the rowset (ticking "Customer TIN" would drop every booking with a + * null company_id), so two exports of the same filters would disagree on their + * row count. Anything that genuinely must narrow rows belongs in `scope()`, + * where it is unconditional and visible. + * + * The payoff: because a left join to a to-one side can neither add nor remove + * rows, the row count is independent of which fields are selected — which is + * what lets the count endpoint be exact rather than an estimate. + */ +export interface ExportJoin { + /** Alias used by field `select` expressions and by `requires`. */ + alias: string; + /** Entity class. Narrower than `EntityTarget` to match TypeORM's join overload. */ + entity: ObjectType; + /** ON condition; may reference the base alias and any alias in `requires`. */ + on: string; + /** Other join aliases this join's ON clause depends on. Resolved transitively. */ + requires?: string[]; +} + +/** + * One exportable column. + * + * `select` must yield exactly ONE value per base row. To surface a one-to-many + * relation (a company's profiles, a booking's containers), aggregate inside a + * correlated subquery — `(SELECT string_agg(...) FROM ... WHERE ... = base.id)` + * — rather than adding a join, which would multiply rows and break the count. + * + * Sensitive columns are simply never declared: raw gateway payloads + * (payments.raw_initiation, client_action), signature/crypto blobs + * (invoices.eims_signed_qr), internal error dumps (eims_last_error), raw jsonb + * snapshots (pricing_breakdown, document_snapshot, financial_terms, + * attributes, business_license_files), bare internal user UUIDs, and internal + * review/rejection notes. Fields are opt-in, so omission is the whole + * enforcement mechanism. + */ +export interface ExportField { + /** Response key, sheet header id, and the picker's checkbox id. */ + key: string; + label: string; + type: ExportFieldType; + /** Scalar SQL projected as `key`. */ + select: string; + /** Join aliases `select` references. Omit for base-table-only fields. */ + requires?: string[]; + /** Picker group id; must exist in the dataset's `groups`. */ + group: string; + /** Pre-ticked when the dialog opens with no preset. */ + default?: boolean; + /** ORDER BY expression. Presence makes the field sortable. */ + sortExpr?: string; +} + +export interface ExportGroup { + id: string; + label: string; +} + +export interface ExportContext { + ds: DataSource; + /** Filter values, already coerced by `coerceFilterParams`. */ + params: Record; + /** Trade-scope directions. `null` = unrestricted, `[]` = show nothing. */ + directions: string[] | null; +} + +export interface ExportDataset { + key: string; + title: string; + description: string; + group: 'Commercial' | 'Operations' | 'Finance' | 'Fleet'; + /** + * Permission to export this dataset. Reuses the module's existing `view` + * key — if you may see these rows on their list page, you may export them. + * The export never returns a row the list endpoint would not. + */ + permission: string; + base: { entity: EntityTarget; alias: string }; + joins: ExportJoin[]; + /** + * Aliases applied unconditionally because `scope()` references them. This is + * the only reason a join is eager, and the count query applies exactly these. + */ + alwaysJoin?: string[]; + groups: ExportGroup[]; + fields: ExportField[]; + filters: ExportFilterDef[]; + /** Must name a field whose `sortExpr` references only the base alias. */ + defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; + /** + * Base WHERE (soft-delete guard), filter application, and the trade-direction + * ACL. Runs identically for the count and download queries, so the row count + * the dialog shows is exactly what lands in the file. + * + * A dataset whose table carries a trade direction MUST apply it here, or the + * export leaks rows the user cannot see on the list page. + */ + scope(ctx: ExportContext, qb: SelectQueryBuilder): void; +} + +/** + * What `GET /exports` serves. `select` / `requires` / `sortExpr` are raw SQL + * and a map of the schema — they never leave the server. + */ +export interface ExportCatalogEntry { + key: string; + title: string; + description: string; + group: ExportDataset['group']; + groups: ExportGroup[]; + fields: Pick[]; + filters: ExportFilterDef[]; + formats: ('csv' | 'xlsx' | 'pdf')[]; + caps: { csv: number; xlsx: number; pdf: number }; + 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 new file mode 100644 index 000000000..89ca7419a --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -0,0 +1,156 @@ +import { Controller, Get, NotFoundException, Param, Query, Res, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { CurrentUser } from '@edr/api-common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { Response } from 'express'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +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 { + EXPORT_MIME, + formatRowCap, + pickByKey, + resolveExportFormat, + resolveRowLimit, +} from './export-request.util'; +import { ExportRunnerService } from './export-runner.service'; +import { DATASETS, getDataset } from './export.registry'; +import { ExportCatalogEntry, ExportDataset, ExportField } from './export.types'; +import { CSV_ROW_CAP, PDF_ROW_CAP, TabularExportService, XLSX_ROW_CAP } from './tabular-export.service'; + +/** Raw query bag — filter keys are per-dataset, so DTO whitelisting can't police it. */ +type RawExportQuery = Record; + +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 => ({ + 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 }) => ({ + key, + label, + type, + group, + default: isDefault, + })), + filters: dataset.filters, + formats: ['csv', 'xlsx', 'pdf'], + caps: CAPS, + defaultSort: dataset.defaultSort, +}); + +/** + * Generic table export. One dataset per major table, each describing far more + * fields than its list page shows — including related-entity detail. + */ +@ApiTags('Exports') +@ApiBearerAuth() +@Controller('exports') +@UseGuards(JwtGuard) +export class ExportsController { + constructor( + private readonly runner: ExportRunnerService, + private readonly writer: TabularExportService, + private readonly userTradeAccessService: UserTradeAccessService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + @Get() + @ApiOperation({ summary: 'List datasets the caller has permission to export' }) + async catalog(@CurrentUser() user: TCurrentUser): Promise { + const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission)); + return Promise.all( + allowed.map(async (d) => ({ + ...toCatalogEntry(d), + filters: await resolveFilterOptions(d.filters, this.dataSource), + })), + ); + } + + @Get(':key/count') + @ApiOperation({ summary: 'Exact row count for the given filters, plus the per-format caps' }) + async count( + @Param('key') key: string, + @Query() query: RawExportQuery, + @CurrentUser() user: TCurrentUser, + ): Promise<{ total: number; caps: typeof CAPS }> { + const dataset = this.resolve(key, user); + const directions = await this.userTradeAccessService.resolveAllowedDirections(user); + const total = await this.runner.count(dataset, query, directions); + return { total, caps: CAPS }; + } + + @Get(':key/download') + @ApiOperation({ summary: 'Export a dataset to csv, xlsx or pdf' }) + async download( + @Param('key') key: string, + @Query() query: RawExportQuery & { format?: string; fields?: string; limit?: string }, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + 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 rows = await this.runner.run(dataset, fields, query, directions, { + cap: formatRowCap(format), + limit: resolveRowLimit(format, query.limit), + }); + const doc = { + title: dataset.title, + description: dataset.description, + label: `export:${dataset.key}`, + columns: fields.map(({ key: k, label, type }) => ({ key: k, label, type })), + rows, + }; + const buffer = + format === 'pdf' + ? await this.writer.toPdf(doc) + : format === 'csv' + ? await this.writer.toCsv(doc) + : await this.writer.toXlsx(doc); + + const mime = EXPORT_MIME[format]; + const stamp = new Date().toISOString().slice(0, 10); + res.setHeader('Content-Disposition', `attachment; filename="${dataset.key}-${stamp}.${mime.ext}"`); + res.setHeader('Content-Type', mime.type); + res.send(buffer); + } + + /** + * Requested fields, whitelisted against the dataset. No `fields=` means the + * 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[] { + if (raw?.trim()) { + const picked = pickByKey(dataset.fields, 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; + } + const defaults = dataset.fields.filter((f) => f.default); + return defaults.length ? defaults : dataset.fields; + } + + private resolve(key: string, user: TCurrentUser): ExportDataset { + const dataset = getDataset(key); + if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`); + // Export rides the dataset's own list-page view permission: if you may see + // these rows, you may export them. + assertFreightPermission(user, dataset.permission); + return dataset; + } +} diff --git a/apps/edr-freight-api/src/modules/exports/exports.module.ts b/apps/edr-freight-api/src/modules/exports/exports.module.ts new file mode 100644 index 000000000..e3c64bd4c --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/exports.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; + +import { DocumentsModule } from '../billing/documents/documents.module'; +import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; +import { ExportRunnerService } from './export-runner.service'; +import { ExportsController } from './exports.controller'; +import { TabularExportService } from './tabular-export.service'; + +/** + * Generic table export: a dataset registry describing far more fields than each + * list page shows (related-entity detail included), plus the shared tabular + * writer (csv / xlsx / pdf) the reports module also writes through. + * + * `TabularExportService` is exported so ReportsModule can reuse it without + * pulling in the dataset machinery. + */ +@Module({ + imports: [DocumentsModule, UserTradeAccessModule], + controllers: [ExportsController], + providers: [TabularExportService, ExportRunnerService], + exports: [TabularExportService], +}) +export class ExportsModule {} diff --git a/apps/edr-freight-api/src/modules/exports/tabular-export.service.spec.ts b/apps/edr-freight-api/src/modules/exports/tabular-export.service.spec.ts new file mode 100644 index 000000000..7f262b48e --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/tabular-export.service.spec.ts @@ -0,0 +1,65 @@ +import { PdfRenderService } from '../billing/documents/pdf-render.service'; +import { TabularDoc, TabularExportService } from './tabular-export.service'; + +/** The PDF path is puppeteer-backed; these specs only cover the sheet writers. */ +const service = new TabularExportService(null as unknown as PdfRenderService); + +const doc: TabularDoc = { + title: 'Bookings', + description: 'every booking', + label: 'test', + columns: [ + { key: 'ref', label: 'Reference', type: 'string' }, + { key: 'customer', label: 'Customer', type: 'string' }, + { key: 'amount', label: 'Amount', type: 'money' }, + { key: 'gov', label: 'Government', type: 'boolean' }, + ], + rows: [ + { ref: 'BK-1', customer: 'Acme, Inc.', amount: 1234.5, gov: true }, + { ref: 'BK-2', customer: 'Quote "Q" Ltd', amount: null, gov: false }, + ], + kpis: [{ label: 'Bookings', value: 2 }], +}; + +describe('TabularExportService.toCsv', () => { + it('quotes a value containing the delimiter — the reason we do not hand-roll join(",")', async () => { + const csv = (await service.toCsv(doc)).toString('utf8'); + expect(csv).toContain('"Acme, Inc."'); + }); + + it('escapes embedded double quotes by doubling them', async () => { + const csv = (await service.toCsv(doc)).toString('utf8'); + expect(csv).toContain('"Quote ""Q"" Ltd"'); + }); + + it('starts at the header row — no KPI preamble, so the file parses as a plain table', async () => { + const csv = (await service.toCsv(doc)).toString('utf8'); + expect(csv.split('\n')[0]).toBe('Reference,Customer,Amount,Government'); + expect(csv).not.toContain('Bookings: 2'); + }); + + it('emits one line per row plus the header', async () => { + const csv = (await service.toCsv(doc)).toString('utf8'); + expect(csv.trim().split('\n').filter(Boolean)).toHaveLength(3); + }); + + it('only the selected columns are written, in the order given', async () => { + const csv = ( + await service.toCsv({ ...doc, columns: [doc.columns[2], doc.columns[0]] }) + ).toString('utf8'); + expect(csv.split('\n')[0]).toBe('Amount,Reference'); + }); +}); + +describe('TabularExportService.toXlsx', () => { + it('writes a real xlsx (a zip, so it starts with the PK magic bytes)', async () => { + const buffer = await service.toXlsx(doc); + expect(buffer.subarray(0, 2).toString('utf8')).toBe('PK'); + expect(buffer.length).toBeGreaterThan(1000); + }); + + it('a title longer than Excel\'s 31-char sheet-name limit does not throw', async () => { + const longTitle = 'A'.repeat(60); + await expect(service.toXlsx({ ...doc, title: longTitle })).resolves.toBeInstanceOf(Buffer); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exports/tabular-export.service.ts b/apps/edr-freight-api/src/modules/exports/tabular-export.service.ts new file mode 100644 index 000000000..05d40f360 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/tabular-export.service.ts @@ -0,0 +1,180 @@ +import { Injectable } from '@nestjs/common'; +import ExcelJS from 'exceljs'; + +import { PdfRenderService } from '../billing/documents/pdf-render.service'; +import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; + +// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming +// WorkbookWriter if an export ever needs to outgrow XLSX_ROW_CAP. +export const XLSX_ROW_CAP = 50_000; +// ponytail: CSV is buffered through the same Workbook as xlsx, so it shares the +// cap. Switch to qb.stream() + res.write() if a dataset needs more than this. +export const CSV_ROW_CAP = 50_000; +// ponytail: HTML→PDF render cost grows with row count; larger exports must +// use XLSX or CSV instead. +export const PDF_ROW_CAP = 5_000; + +/** + * Value types a tabular export understands. A superset of `ReportColumn['type']` + * so a report's own columns are assignable here unchanged. + */ +export type ExportFieldType = + | 'string' + | 'number' + | 'money' + | 'tons' + | 'percent' + | 'date' + | 'datetime' + | 'boolean'; + +/** The minimum a column must describe to be written to a sheet. */ +export interface ExportColumnLike { + key: string; + label: string; + type: ExportFieldType; +} + +/** Headline figures printed above the table. xlsx/pdf only — never in CSV. */ +export interface ExportKpiLike { + label: string; + value: number; + unit?: string; +} + +/** + * One tabular document, independent of where the rows came from. A report and a + * dataset export both reduce to this, which is what lets them share one writer. + */ +export interface TabularDoc { + /** Sheet name (truncated to Excel's 31-char limit) and the PDF's

. */ + title: string; + description?: string; + /** Log label handed to PdfRenderService, e.g. "report:bookings-list". */ + label: string; + columns: ExportColumnLike[]; + rows: Record[]; + kpis?: ExportKpiLike[]; +} + +const NUMBER_FORMAT: Partial> = { + money: '#,##0.00', + tons: '#,##0.0', + percent: '0"%"', + number: '#,##0', +}; + +function formatCell(value: unknown, type: ExportFieldType): string { + if (value === null || value === undefined) return ''; + if (type === 'money' || type === 'number') { + return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 }); + } + if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`; + if (type === 'percent') return `${value}%`; + if (type === 'boolean') return value ? 'Yes' : 'No'; + return String(value); +} + +@Injectable() +export class TabularExportService { + constructor(private readonly pdfRender: PdfRenderService) {} + + async toXlsx(doc: TabularDoc): Promise { + const workbook = this.buildWorkbook(doc, { includeKpis: true }); + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } + + /** + * CSV via ExcelJS's own writer, off the same Workbook xlsx builds — it already + * handles quoting, embedded commas and embedded newlines. Hand-rolling + * `row.join(',')` breaks on the first customer name containing a comma. + * + * KPIs are deliberately omitted: a preamble row plus a blank row before the + * header stops the file parsing as a plain table, and CSV's whole point here + * is being machine-readable. + */ + async toCsv(doc: TabularDoc): Promise { + const workbook = this.buildWorkbook(doc, { includeKpis: false }); + const buffer = await workbook.csv.writeBuffer(); + return Buffer.from(buffer); + } + + async toPdf(doc: TabularDoc): Promise { + const html = this.buildHtml(doc); + return this.pdfRender.htmlToPdfBuffer(html, { + label: doc.label, + landscape: true, + // Without this, a box with no Chromium silently degrades to + // genericFallbackPdf — a ~900-character text dump instead of a table. + // buildTabularFallbackPdf parses exactly the markup buildHtml emits. + fallback: buildTabularFallbackPdf, + }); + } + + private buildWorkbook(doc: TabularDoc, opts: { includeKpis: boolean }): ExcelJS.Workbook { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(doc.title.slice(0, 31)); + const { columns, rows, kpis } = doc; + + if (opts.includeKpis && kpis?.length) { + sheet.addRow( + kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`), + ); + sheet.addRow([]); + } + + const headerRow = sheet.addRow(columns.map((c) => c.label)); + headerRow.font = { bold: true }; + + for (const row of rows) { + sheet.addRow(columns.map((c) => row[c.key] ?? null)); + } + + columns.forEach((col, i) => { + const format = NUMBER_FORMAT[col.type]; + const excelCol = sheet.getColumn(i + 1); + excelCol.width = Math.max(col.label.length + 2, 12); + if (format) excelCol.numFmt = format; + }); + + return workbook; + } + + private buildHtml(doc: TabularDoc): string { + const { title, description, columns, rows, kpis } = doc; + const esc = (v: unknown) => + String(v ?? '').replace(/&/g, '&').replace(//g, '>'); + + const kpiHtml = kpis?.length + ? `
${kpis + .map( + (k) => + `
${esc(k.label)}
${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}
`, + ) + .join('')}
` + : ''; + + const head = columns.map((c) => `${esc(c.label)}`).join(''); + const body = rows + .map( + (row) => + `${columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, + ) + .join(''); + + return ` +

${esc(title)}

+

${esc(description ?? '')}

+ ${kpiHtml} + ${head}${body}
+ `; + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts new file mode 100644 index 000000000..2d8f5beb0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts @@ -0,0 +1,74 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsDateString, + IsIn, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +import { + TARGET_DIMENSIONS, + TARGET_METRICS, + TARGET_PERIOD_TYPES, + TargetDimension, + TargetMetric, + TargetPeriodType, +} from '../entities/operations-target.entity'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +export class CreateOperationsTargetDto { + @ApiProperty({ enum: TARGET_PERIOD_TYPES }) + @IsIn(TARGET_PERIOD_TYPES as unknown as string[]) + periodType!: TargetPeriodType; + + @ApiProperty({ + example: '2026-08-01', + description: 'Any date inside the bucket — normalised to the bucket start on write.', + }) + @IsDateString() + periodStart!: string; + + @ApiProperty({ enum: TARGET_METRICS }) + @IsIn(TARGET_METRICS as unknown as string[]) + metric!: TargetMetric; + + @ApiProperty({ enum: TARGET_DIMENSIONS }) + @IsIn(TARGET_DIMENSIONS as unknown as string[]) + dimension!: TargetDimension; + + @ApiProperty({ + example: 'CONTAINER_IMPORT_MULTIMODAL', + description: 'Category key, container-class key or yard code — not a display label.', + }) + @IsString() + @MaxLength(60) + dimensionKey!: string; + + @ApiProperty({ example: 1200 }) + @Transform(toNumber) + @IsNumber() + @Min(0) + plannedValue!: number; + + @ApiPropertyOptional({ + description: + 'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.', + example: 'CONTAINER_IMPORT_MULTIMODAL', + }) + @IsOptional() + @IsString() + @MaxLength(60) + cargoCategory?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/list-operations-targets-query.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/list-operations-targets-query.dto.ts new file mode 100644 index 000000000..f76f9fa34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/list-operations-targets-query.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; +import { + TARGET_DIMENSIONS, + TARGET_METRICS, + TARGET_PERIOD_TYPES, + TargetDimension, + TargetMetric, + TargetPeriodType, +} from '../entities/operations-target.entity'; + +export class ListOperationsTargetsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: TARGET_PERIOD_TYPES }) + @IsOptional() + @IsIn(TARGET_PERIOD_TYPES as unknown as string[]) + periodType?: TargetPeriodType; + + @ApiPropertyOptional({ enum: TARGET_METRICS }) + @IsOptional() + @IsIn(TARGET_METRICS as unknown as string[]) + metric?: TargetMetric; + + @ApiPropertyOptional({ enum: TARGET_DIMENSIONS }) + @IsOptional() + @IsIn(TARGET_DIMENSIONS as unknown as string[]) + dimension?: TargetDimension; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts new file mode 100644 index 000000000..cbc686a22 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts @@ -0,0 +1,118 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +/** + * Every field optional — the backoffice form PATCHes only what changed. A + * standard of zero is rejected: it would make every implement-rate division + * blow up or read as infinite achievement. + */ +export class UpdateOperationsStandardsDto { + @ApiPropertyOptional({ example: 10 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + stationStandardHoursEthiopia?: number; + + @ApiPropertyOptional({ example: 13 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + stationStandardHoursDjibouti?: number; + + @ApiPropertyOptional({ example: 65 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + cycleStandardHoursContainer?: number; + + @ApiPropertyOptional({ example: 88 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + cycleStandardHoursBulkDmp?: number; + + @ApiPropertyOptional({ example: 96 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + cycleStandardHoursBulkNagad?: number; + + @ApiPropertyOptional({ example: 96 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + cycleStandardHoursBulkBcc?: number; + + @ApiPropertyOptional({ example: 21 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + defaultLegStandardHours?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Transform(toNumber) + @IsInt() + @Min(0) + delayToleranceMinutes?: number; + + @ApiPropertyOptional({ example: 20 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsFull20ft?: number; + + @ApiPropertyOptional({ example: 40 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsFull40ft?: number; + + @ApiPropertyOptional({ example: 2.24 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsEmpty20ft?: number; + + @ApiPropertyOptional({ example: 3.88 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsEmpty40ft?: number; + + @ApiPropertyOptional({ example: 70 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsPerWagonGeneral?: number; + + @ApiPropertyOptional({ example: 38 }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + chargedTonsPerWagonPerishable?: number; + + @ApiPropertyOptional({ example: 50 }) + @IsOptional() + @Transform(toNumber) + @IsInt() + @Min(1) + defaultFullTrainsetWagons?: number; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-target.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-target.dto.ts new file mode 100644 index 000000000..9be45cefb --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-target.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateOperationsTargetDto } from './create-operations-target.dto'; + +export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts new file mode 100644 index 000000000..d5a31725f --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts @@ -0,0 +1,185 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity } from 'typeorm'; + +/** + * numeric comes back from pg as a string. Every value here is arithmetic in a + * report expression, so convert on read rather than making each caller do it. + */ +const asNumber = { + to: (value: number) => value, + from: (value: string | null) => (value === null ? null : Number(value)), +}; + +/** + * Single-row table holding the railway's operating standards — the numbers the + * operations reports measure actual performance against. Same single-row shape + * as `logo_settings` and `exchange_settings`; the app never inserts a second row. + * + * These live in the database rather than in a constants file because the + * business treats them as tunable (the corridor standard is explicitly + * described as "flexible"), and a planner must be able to change one without a + * deployment. + */ +@Entity({ schema: 'freight', name: 'operations_standards' }) +export class OperationsStandard extends BaseEntity { + /** Standard time a train may stand at an Ethiopian station, in hours. */ + @Column({ + name: 'station_standard_hours_ethiopia', + type: 'numeric', + precision: 6, + scale: 2, + default: 10, + transformer: asNumber, + }) + stationStandardHoursEthiopia!: number; + + /** Standard time a train may stand at a Djibouti station, in hours. */ + @Column({ + name: 'station_standard_hours_djibouti', + type: 'numeric', + precision: 6, + scale: 2, + default: 13, + transformer: asNumber, + }) + stationStandardHoursDjibouti!: number; + + /** Container turn-around cycle: 10 + 21 + 13 + 21. */ + @Column({ + name: 'cycle_standard_hours_container', + type: 'numeric', + precision: 6, + scale: 2, + default: 65, + transformer: asNumber, + }) + cycleStandardHoursContainer!: number; + + /** Bulk cycle via DMP: 13 + 21 + 33 + 21. */ + @Column({ + name: 'cycle_standard_hours_bulk_dmp', + type: 'numeric', + precision: 6, + scale: 2, + default: 88, + transformer: asNumber, + }) + cycleStandardHoursBulkDmp!: number; + + /** Bulk cycle via Negad freight yard: 13 + 21 + 41 + 21. */ + @Column({ + name: 'cycle_standard_hours_bulk_nagad', + type: 'numeric', + precision: 6, + scale: 2, + default: 96, + transformer: asNumber, + }) + cycleStandardHoursBulkNagad!: number; + + /** Bulk cycle via BCC: 13 + 21 + 41 + 21. */ + @Column({ + name: 'cycle_standard_hours_bulk_bcc', + type: 'numeric', + precision: 6, + scale: 2, + default: 96, + transformer: asNumber, + }) + cycleStandardHoursBulkBcc!: number; + + /** + * Standard running time for one corridor leg, used when the yard pair has no + * `yard_distances.standard_hours` of its own. + */ + @Column({ + name: 'default_leg_standard_hours', + type: 'numeric', + precision: 6, + scale: 2, + default: 21, + transformer: asNumber, + }) + defaultLegStandardHours!: number; + + /** Grace on top of the leg standard before a train counts as delayed. */ + @Column({ name: 'delay_tolerance_minutes', type: 'int', default: 30 }) + delayToleranceMinutes!: number; + + /** Charged tonnage per laden 20ft container. */ + @Column({ + name: 'charged_tons_full_20ft', + type: 'numeric', + precision: 8, + scale: 2, + default: 20, + transformer: asNumber, + }) + chargedTonsFull20ft!: number; + + /** Charged tonnage per laden 40ft container. */ + @Column({ + name: 'charged_tons_full_40ft', + type: 'numeric', + precision: 8, + scale: 2, + default: 40, + transformer: asNumber, + }) + chargedTonsFull40ft!: number; + + /** Charged tonnage per empty 20ft container. */ + @Column({ + name: 'charged_tons_empty_20ft', + type: 'numeric', + precision: 8, + scale: 2, + default: 2.24, + transformer: asNumber, + }) + chargedTonsEmpty20ft!: number; + + /** Charged tonnage per empty 40ft container. */ + @Column({ + name: 'charged_tons_empty_40ft', + type: 'numeric', + precision: 8, + scale: 2, + default: 3.88, + transformer: asNumber, + }) + chargedTonsEmpty40ft!: number; + + /** Charged tonnage per wagon of steel, fertilizer, rice, sugar, livestock. */ + @Column({ + name: 'charged_tons_per_wagon_general', + type: 'numeric', + precision: 8, + scale: 2, + default: 70, + transformer: asNumber, + }) + chargedTonsPerWagonGeneral!: number; + + /** Charged tonnage per wagon of vegetables, milk, meat and other perishables. */ + @Column({ + name: 'charged_tons_per_wagon_perishable', + type: 'numeric', + precision: 8, + scale: 2, + default: 38, + transformer: asNumber, + }) + chargedTonsPerWagonPerishable!: number; + + /** + * Wagons in a full trainset when the cargo type has no + * `cargo_types.full_trainset_wagons` of its own. + */ + @Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 }) + defaultFullTrainsetWagons!: number; + + /** IAM user id of the last operator to change a standard. */ + @Column({ name: 'updated_by_id', type: 'uuid', nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts new file mode 100644 index 000000000..aec11ae24 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts @@ -0,0 +1,95 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** Planning buckets the reports offer. Mirrors the reports' period filter. */ +export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const; +export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number]; + +/** What is being planned. */ +export const TARGET_METRICS = ['TEU', 'TRAINSET', 'VOLUME_TONS'] as const; +export type TargetMetric = (typeof TARGET_METRICS)[number]; + +/** Which axis `dimensionKey` names. */ +export const TARGET_DIMENSIONS = ['cargo_category', 'station', 'container_class'] as const; +export type TargetDimension = (typeof TARGET_DIMENSIONS)[number]; + +/** + * How each stored code reads on screen. The columns are enums the reports match + * on, so the stored values must stay exactly as they are — these exist for the + * admin grid, which otherwise shows `VOLUME_TONS` and `cargo_category` verbatim. + */ +export const TARGET_METRIC_LABELS: Record = { + TEU: 'TEU', + TRAINSET: 'Trainsets', + VOLUME_TONS: 'Volume (tons)', +}; + +export const TARGET_DIMENSION_LABELS: Record = { + cargo_category: 'Cargo category', + station: 'Station', + container_class: 'Container class', +}; + +export const TARGET_PERIOD_LABELS: Record = { + week: 'Weekly', + month: 'Monthly', + quarter: 'Quarterly', + year: 'Yearly', +}; + +/** + * The planned side of every "Plan / Operated / Implement Rate" table in the + * operations reporting spec. One row is one planned number: a period, a metric, + * and the dimension value it applies to. + * + * `dimensionKey` holds a category key (not a label) — the same keys + * `operations-classification.ts` emits, so a report can join on it directly. + * + * Uniqueness on the five-column slot is a partial index in the database + * (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a soft-deleted + * target can be re-created — the same choice `yard_distances` makes. + */ +@Entity({ schema: 'freight', name: 'operations_targets' }) +@Index(['metric', 'periodType', 'periodStart']) +export class OperationsTarget extends BaseEntity { + @Column({ name: 'period_type', type: 'varchar', length: 10 }) + periodType!: TargetPeriodType; + + /** First day of the bucket, normalised on write (Monday, 1st, quarter start). */ + @Column({ name: 'period_start', type: 'date' }) + periodStart!: string; + + @Column({ name: 'metric', type: 'varchar', length: 20 }) + metric!: TargetMetric; + + @Column({ name: 'dimension', type: 'varchar', length: 20 }) + dimension!: TargetDimension; + + /** Category key, container-class key, or yard code — never a display label. */ + @Column({ name: 'dimension_key', type: 'varchar', length: 60 }) + dimensionKey!: string; + + @Column({ + name: 'planned_value', + type: 'numeric', + precision: 14, + scale: 3, + transformer: { + to: (value: number) => value, + from: (value: string | null) => (value === null ? null : Number(value)), + }, + }) + plannedValue!: number; + + /** + * Only for `station` targets, where the plan is per station AND per cargo + * type — the OCC report plans Nagad–Mojo container and Nagad–Mojo fertilizer + * separately. Null on `cargo_category` and `container_class` targets, whose + * `dimensionKey` already carries the category. + */ + @Column({ name: 'cargo_category', type: 'varchar', length: 60, nullable: true }) + cargoCategory?: string | null; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts new file mode 100644 index 000000000..180c12271 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts @@ -0,0 +1,26 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { OperationsStandard } from './entities/operations-standard.entity'; +import { OperationsTarget } from './entities/operations-target.entity'; +import { OperationsStandardsController } from './operations-standards.controller'; +import { OperationsStandardsService } from './operations-standards.service'; +import { OperationsTargetsController } from './operations-targets.controller'; +import { OperationsTargetsService } from './operations-targets.service'; + +/** + * Reference data behind the operations reports: the railway's operating + * standards (one settings row) and the planned targets the reports compare + * actuals against. + * + * Global because the reports module reads the standards row on every run and + * has no other reason to import this. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])], + controllers: [OperationsStandardsController, OperationsTargetsController], + providers: [OperationsStandardsService, OperationsTargetsService], + exports: [OperationsStandardsService, OperationsTargetsService], +}) +export class OperationsReportingModule {} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.controller.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.controller.ts new file mode 100644 index 000000000..773767623 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Patch } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto'; +import { OperationsStandardsService } from './operations-standards.service'; + +@ApiTags('operations-standards') +@ApiBearerAuth() +@Controller('operations-standards') +export class OperationsStandardsController { + constructor(private readonly service: OperationsStandardsService) {} + + @Get() + @BookingStaff([FREIGHT_PERMS.settings.operationsStandards.view, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: 'Standard times and charged-tonnage factors used by the operations reports' }) + get() { + return this.service.get(); + } + + @Patch() + @BookingStaff([FREIGHT_PERMS.settings.operationsStandards.manage, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: 'Change one or more operating standards' }) + update(@Body() dto: UpdateOperationsStandardsDto, @CurrentUser() user: TCurrentUser) { + return this.service.update(dto, user?.id ?? null); + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.service.ts new file mode 100644 index 000000000..ea33ee83a --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-standards.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; + +import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto'; +import { OperationsStandard } from './entities/operations-standard.entity'; + +/** + * Owns the single `operations_standards` row — the times and tonnage factors + * every operations report measures actual performance against. + * + * The migration seeds the row, but `get()` creates it on demand as well: a + * report that cannot read a standard would have to fall back to a hardcoded + * number, which is exactly what putting these in the database was meant to + * avoid. + */ +@Injectable() +export class OperationsStandardsService { + constructor( + @InjectRepository(OperationsStandard) + private readonly repository: Repository, + ) {} + + async get(): Promise { + const existing = await this.repository.findOne({ + where: { deletedAt: IsNull() }, + order: { createdAt: 'ASC' }, + }); + if (existing) return existing; + + // Every column has a database default, so an empty insert is the seed row. + return this.repository.save(this.repository.create({})); + } + + async update( + dto: UpdateOperationsStandardsDto, + userId: string | null, + ): Promise { + const current = await this.get(); + await this.repository.update(current.id, { ...dto, updatedById: userId }); + return this.get(); + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.controller.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.controller.ts new file mode 100644 index 000000000..3396f643d --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + RuleEngineCreate, + RuleEngineDelete, + RuleEngineUpdate, + RuleEngineView, +} from '../../common/rule-engine-guards'; +import { CreateOperationsTargetDto } from './dto/create-operations-target.dto'; +import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto'; +import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto'; +import { OperationsTargetsService } from './operations-targets.service'; + +@ApiTags('operations-targets') +@Controller('operations-targets') +@ApiBearerAuth() +export class OperationsTargetsController { + constructor(private readonly service: OperationsTargetsService) {} + + @Get() + @RuleEngineView('operations-targets') + @ApiOperation({ summary: 'List planned operational targets' }) + findAll(@Query() query: ListOperationsTargetsQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + @RuleEngineView('operations-targets') + @ApiOperation({ summary: 'Get a target by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineCreate('operations-targets') + @ApiOperation({ summary: 'Create a planned target' }) + create(@Body() dto: CreateOperationsTargetDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineUpdate('operations-targets') + @ApiOperation({ summary: 'Update a planned target' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateOperationsTargetDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineDelete('operations-targets') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a planned target' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts new file mode 100644 index 000000000..c33054aa5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts @@ -0,0 +1,218 @@ +import { PaginatedResponse } from '@edr/types'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, IsNull, Repository } from 'typeorm'; + +import { paginateQuery } from '../../common/utils/pagination.util'; +import { CreateOperationsTargetDto } from './dto/create-operations-target.dto'; +import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto'; +import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto'; +import { + OperationsTarget, + TARGET_DIMENSION_LABELS, + TARGET_METRIC_LABELS, + TARGET_PERIOD_LABELS, + TargetPeriodType, +} from './entities/operations-target.entity'; +import { + CARGO_CATEGORIES, + CONTAINER_CLASSES, +} from '../reports/operations-classification'; + +/** + * Normalises any date inside a bucket to the bucket's first day, matching + * Postgres `date_trunc` — which is what the reports group by. Week starts + * Monday, the same as `date_trunc('week', …)` and ISO week numbering. + * + * Done in UTC throughout: the stored column is a bare `date`, and running the + * arithmetic in local time would shift a 1st-of-month target into the previous + * month for anyone east of Greenwich. + */ +export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string { + const d = new Date(`${value.slice(0, 10)}T00:00:00Z`); + switch (periodType) { + case 'week': { + // getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in. + const offset = (d.getUTCDay() + 6) % 7; + d.setUTCDate(d.getUTCDate() - offset); + break; + } + case 'month': + d.setUTCDate(1); + break; + case 'quarter': + d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1); + break; + case 'year': + d.setUTCMonth(0, 1); + break; + } + return d.toISOString().slice(0, 10); +} + +/** + * Flat row shape for the backoffice config grid: the stored codes stay put — + * the reports join on them — and readable twins ride alongside, the same way + * `YardDistancesService` adds `fromYardLabel`. + */ +export type OperationsTargetRow = OperationsTarget & { + metricLabel: string; + dimensionLabel: string; + periodLabel: string; + appliesToLabel: string; + cargoCategoryLabel: string; +}; + +/** + * Resolved per dimension, not from one merged map: `CONTAINER_EXPORT` is in + * both vocabularies and reads differently in each ("Export container" as a + * cargo category, "Full export container" as a container class). Merging them + * silently gave every cargo-category row the container-class wording. + */ +const LABELS_BY_DIMENSION: Record> = { + cargo_category: new Map(CARGO_CATEGORIES.map((o) => [o.value, o.label])), + container_class: new Map(CONTAINER_CLASSES.map((o) => [o.value, o.label])), +}; + +const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category; + +@Injectable() +export class OperationsTargetsService { + constructor( + @InjectRepository(OperationsTarget) + private readonly repository: Repository, + ) {} + + /** Yard code → label, for station targets. Reference data, read per list. */ + private async yardLabels(): Promise> { + const rows = await this.repository.manager.query>( + `SELECT code, label FROM freight.yards WHERE deleted_at IS NULL`, + ); + return new Map(rows.map((r) => [r.code, r.label])); + } + + private toRow(target: OperationsTarget, yards: Map): OperationsTargetRow { + const appliesToLabel = + target.dimension === 'station' + ? (yards.get(target.dimensionKey) ?? target.dimensionKey) + : (LABELS_BY_DIMENSION[target.dimension]?.get(target.dimensionKey) ?? + target.dimensionKey); + + return Object.assign(target, { + metricLabel: TARGET_METRIC_LABELS[target.metric] ?? target.metric, + dimensionLabel: TARGET_DIMENSION_LABELS[target.dimension] ?? target.dimension, + periodLabel: TARGET_PERIOD_LABELS[target.periodType] ?? target.periodType, + appliesToLabel, + // Only station targets carry one, and it is always a cargo category. + cargoCategoryLabel: target.cargoCategory + ? (CARGO_CATEGORY_LABELS.get(target.cargoCategory) ?? target.cargoCategory) + : '', + }); + } + + async findAll( + query: ListOperationsTargetsQueryDto, + ): Promise> { + const sortable: Record = { + periodStart: 'target.period_start', + metric: 'target.metric', + dimension: 'target.dimension', + dimensionKey: 'target.dimension_key', + plannedValue: 'target.planned_value', + createdAt: 'target.created_at', + }; + const sortBy = sortable[query.sortBy ?? ''] ?? sortable.periodStart; + + const qb = this.repository + .createQueryBuilder('target') + .orderBy(sortBy, query.sortOrder ?? 'DESC') + .addOrderBy('target.dimension_key', 'ASC'); + + if (query.periodType) qb.andWhere('target.period_type = :pt', { pt: query.periodType }); + if (query.metric) qb.andWhere('target.metric = :m', { m: query.metric }); + if (query.dimension) qb.andWhere('target.dimension = :d', { d: query.dimension }); + if (query.search) { + qb.andWhere( + new Brackets((w) => + w + .where('target.dimension_key ILIKE :s', { s: `%${query.search}%` }) + .orWhere('target.note ILIKE :s', { s: `%${query.search}%` }), + ), + ); + } + + const [page, yards] = await Promise.all([paginateQuery(qb, query), this.yardLabels()]); + return { ...page, items: page.items.map((t) => this.toRow(t, yards)) }; + } + + async findById(id: string): Promise { + const found = await this.repository.findOne({ where: { id } }); + if (!found) throw new NotFoundException(`Operations target ${id} not found`); + return found; + } + + async create(dto: CreateOperationsTargetDto): Promise { + const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart); + const cargoCategory = dto.cargoCategory ?? null; + await this.assertSlotFree({ ...dto, periodStart, cargoCategory }); + return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory })); + } + + async update(id: string, dto: UpdateOperationsTargetDto): Promise { + const current = await this.findById(id); + const periodType = dto.periodType ?? current.periodType; + const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart); + const next = { + periodType, + periodStart, + metric: dto.metric ?? current.metric, + dimension: dto.dimension ?? current.dimension, + dimensionKey: dto.dimensionKey ?? current.dimensionKey, + cargoCategory: + dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null, + }; + await this.assertSlotFree(next, id); + + await this.repository.update(id, { + ...next, + ...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}), + ...(dto.note !== undefined ? { note: dto.note } : {}), + }); + return this.findById(id); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + /** + * One planned number per (period, metric, dimension value). The database + * enforces this too — the check is here to turn a 23505 into a message that + * says which slot is taken. + */ + private async assertSlotFree( + slot: Pick< + OperationsTarget, + 'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory' + >, + ignoreId?: string, + ): Promise { + const existing = await this.repository.findOne({ + where: { + periodType: slot.periodType, + periodStart: slot.periodStart, + metric: slot.metric, + dimension: slot.dimension, + dimensionKey: slot.dimensionKey, + cargoCategory: slot.cargoCategory ?? IsNull(), + deletedAt: IsNull(), + }, + }); + if (existing && existing.id !== ignoreId) { + throw new ConflictException( + `A ${slot.metric} target for ${slot.dimensionKey} in the ${slot.periodType} starting ${slot.periodStart} already exists`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts new file mode 100644 index 000000000..dd111d22a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts @@ -0,0 +1,173 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + ACTUAL_TONS_EXPR, + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_FILTER, + CATEGORY_LABEL_OF, + COUNTRY_FILTER, + LOADED_WAGONS_EXPR, + OPS_DATE, + OPERATIONS_FILTERS, + TEU_EXPR, + allocationLedgerQb, + applyCategoryFilter, + PLAN_GRANULARITY_NOTE, + implementRateExpr, + plannedRowsParams, + plannedRowsSql, +} from '../operations-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; + +/** The two sides of the line. Anything else is ignored rather than interpolated. */ +const COUNTRIES = ['Ethiopia', 'Djibouti']; + +const countryOf = (params: Record): string | null => { + const value = String(params.country ?? ''); + return COUNTRIES.includes(value) ? value : null; +}; + +/** + * Which end of the corridor this report calls "the station". + * + * With a country chosen it is that country's end — the Ethiopian view lists + * GMP, Modjo, Adama and the rest; the Djibouti view lists DMP, DCT and Nagad, + * which is the second format the spec asks for. With no country chosen it is + * the destination, so the report still reads sensibly. + * + * The country is whitelisted above before it reaches the SQL: it arrives as a + * filter value, and a CASE expression cannot take a bound parameter here + * because the same expression has to appear verbatim in the GROUP BY. + */ +const stationExpr = (params: Record, column: string): string => { + const country = countryOf(params); + if (!country) return `dy.${column}`; + return `CASE WHEN oy.country = '${country}' THEN oy.${column} ELSE dy.${column} END`; +}; + +/** The other end of the same corridor — the spec's "origination" column. */ +const originationExpr = (params: Record, column: string): string => { + const country = countryOf(params); + if (!country) return `oy.${column}`; + return `CASE WHEN oy.country = '${country}' THEN dy.${column} ELSE oy.${column} END`; +}; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx); + applyCategoryFilter(qb, ctx.params); + + const country = countryOf(ctx.params); + // Only corridors that touch the chosen side have a station on it. + if (country) { + qb.andWhere('(oy.country = :sideCountry OR dy.country = :sideCountry)', { + sideCountry: country, + }); + } + return qb; +} + +export const cargoVolumeByStationReport: ReportDefinition = { + key: 'cargo-volume-by-station', + title: 'Cargo Volume by Station', + description: + 'Tonnage by station and cargo type against plan. Choose a country to switch between ' + + 'the Ethiopian view (GMP, Modjo, Dire Dawa, Adama, Sebeta) and the Djibouti view ' + + '(DMP, DCT, Nagad), which changes which end of the corridor counts as the station and ' + + 'which counts as the origination. Plan comes from Operational targets, keyed on the ' + + 'station’s yard code.' + + PLAN_GRANULARITY_NOTE, + group: 'Operations', + filters: [PERIOD_FILTER, COUNTRY_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'station', label: 'Station', type: 'string', sortable: true }, + { key: 'origination', label: 'Origination', type: 'string' }, + { key: 'category', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, + { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + { key: 'teu', label: 'TEU', type: 'number' }, + { key: 'wagons', label: 'Wagons', type: 'number' }, + { key: 'trains', label: 'Trains', type: 'number' }, + ], + defaultSort: { key: 'operated', dir: 'DESC' }, + chart: { type: 'bar', x: 'station', y: ['operated'] }, + query(ctx) { + const { params } = ctx; + const bucket = periodTruncExprOn(OPS_DATE, params); + const stationCode = stationExpr(params, 'code'); + + const operated = baseQuery(ctx) + .select(periodExprOn(OPS_DATE, params), 'period') + .addSelect(stationCode, 'station_code') + .addSelect(`COALESCE(${stationExpr(params, 'label')}, ${stationCode}, '?')`, 'station') + .addSelect( + `COALESCE(${originationExpr(params, 'label')}, ${originationExpr(params, 'code')}, '?')`, + 'origination', + ) + .addSelect(CARGO_CATEGORY_EXPR, 'category_key') + .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated') + .addSelect(TEU_EXPR, 'teu') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .groupBy(bucket) + .addGroupBy(stationCode) + .addGroupBy(stationExpr(params, 'label')) + .addGroupBy(originationExpr(params, 'label')) + .addGroupBy(originationExpr(params, 'code')) + .addGroupBy(CARGO_CATEGORY_EXPR); + + // A station plan is keyed on station AND cargo type, so the join needs + // both. Full outer, so a station-and-cargo line that was planned and never + // ran still reports its miss — the OCC report is full of those. + const combined = ` + SELECT COALESCE(o.period, p.period) AS period, + COALESCE(o.station_code, p.plan_key) AS station_code, + COALESCE(o.station, + (SELECT y2.label FROM freight.yards y2 + WHERE y2.code = p.plan_key AND y2.deleted_at IS NULL LIMIT 1), + p.plan_key) AS station, + COALESCE(o.origination, '—') AS origination, + COALESCE(o.category_key, p.plan_category) AS category_key, + COALESCE(o.operated, 0) AS operated, + COALESCE(o.teu, 0) AS teu, + COALESCE(o.wagons, 0) AS wagons, + COALESCE(o.trains, 0) AS trains, + p.plan_value AS plan + FROM (${operated.getQuery()}) o + FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p + ON p.period = o.period + AND p.plan_key = o.station_code + AND p.plan_category = o.category_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) }) + .select('r.period', 'period') + .addSelect('r.station', 'station') + .addSelect('r.origination', 'origination') + .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') + .addSelect('r.category_key', 'categoryKey') + .addSelect('r.operated::float8', 'operated') + .addSelect('r.plan::float8', 'plan') + .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') + .addSelect('r.teu::int', 'teu') + .addSelect('r.wagons::int', 'wagons') + .addSelect('r.trains::int', 'trains'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual') + .addSelect(`COUNT(DISTINCT ${stationExpr(ctx.params, 'code')})::int`, 'stations') + .addSelect(TEU_EXPR, 'teu') + .getRawOne<{ actual: number; stations: number; teu: number }>(); + + return [ + { label: 'Volume', value: Number(row?.actual ?? 0), unit: 't' }, + { label: 'Stations', value: Number(row?.stations ?? 0) }, + { label: 'TEU', value: Number(row?.teu ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts new file mode 100644 index 000000000..68c255c11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts @@ -0,0 +1,109 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + ACTUAL_TONS_EXPR, + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_FILTER, + CATEGORY_LABEL_OF, + CHARGED_TONS_EXPR, + LOADED_WAGONS_EXPR, + OPS_DATE, + OPERATIONS_FILTERS, + TEU_EXPR, + allocationLedgerQb, + applyCategoryFilter, + PLAN_GRANULARITY_NOTE, + implementRateExpr, + plannedRowsParams, + plannedRowsSql, +} from '../operations-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx); + applyCategoryFilter(qb, ctx.params); + return qb; +} + +export const cargoVolumePerformanceReport: ReportDefinition = { + key: 'cargo-volume-performance', + title: 'Cargo Volume Performance', + description: + 'Tonnage moved per cargo category against plan. Operated is the actual loaded weight ' + + 'from the marshalling record; charged volume is the standard weight capacity the same ' + + 'cargo is billed on. Plan comes from Operational targets and is measured against the ' + + 'actual, not the charged, tonnage.' + + PLAN_GRANULARITY_NOTE, + group: 'Operations', + filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'category', label: 'Cargo category', type: 'string', sortable: true }, + { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, + { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number', sortable: true }, + { key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, + { key: 'trains', label: 'Trains', type: 'number' }, + ], + defaultSort: { key: 'operated', dir: 'DESC' }, + chart: { type: 'bar', x: 'category', y: ['operated'] }, + query(ctx) { + const bucket = periodTruncExprOn(OPS_DATE, ctx.params); + const operated = baseQuery(ctx) + .select(periodExprOn(OPS_DATE, ctx.params), 'period') + .addSelect(CARGO_CATEGORY_EXPR, 'category_key') + .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated') + .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged_tons') + .addSelect(TEU_EXPR, 'teu') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .groupBy(bucket) + .addGroupBy(CARGO_CATEGORY_EXPR); + + // Full outer join so a planned cargo category that moved nothing still + // reports its miss instead of disappearing from the table. + const combined = ` + SELECT COALESCE(o.period, p.period) AS period, + COALESCE(o.category_key, p.plan_key) AS category_key, + COALESCE(o.operated, 0) AS operated, + COALESCE(o.charged_tons, 0) AS charged_tons, + COALESCE(o.teu, 0) AS teu, + COALESCE(o.wagons, 0) AS wagons, + COALESCE(o.trains, 0) AS trains, + p.plan_value AS plan + FROM (${operated.getQuery()}) o + FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) p + ON p.period = o.period AND p.plan_key = o.category_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .select('r.period', 'period') + .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') + .addSelect('r.category_key', 'categoryKey') + .addSelect('r.operated::float8', 'operated') + .addSelect('r.plan::float8', 'plan') + .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') + .addSelect('r.charged_tons::float8', 'chargedTons') + .addSelect('r.teu::int', 'teu') + .addSelect('r.wagons::int', 'wagons') + .addSelect('r.trains::int', 'trains'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual') + .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged') + .addSelect(TEU_EXPR, 'teu') + .getRawOne<{ actual: number; charged: number; teu: number }>(); + + return [ + { label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' }, + { label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' }, + { label: 'TEU', value: Number(row?.teu ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts new file mode 100644 index 000000000..6da342e17 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts @@ -0,0 +1,110 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + ACTUAL_TONS_EXPR, + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_FILTER, + CARGO_CATEGORY_LABEL_EXPR, + CHARGED_TONS_EXPR, + LOADED_WAGONS_EXPR, + OPERATIONS_FILTERS, + SCHEDULE_EMPTY_WAGONS, + SCHEDULE_KM_EXPR, + TEU_EXPR, + allocationLedgerQb, + applyCategoryFilter, +} from '../operations-classification'; + +/** + * Distance and empty-wagon count belong to the departure, so they are constant + * within a group that includes `ts.id` — MAX() satisfies Postgres without + * dragging a scalar subselect through the GROUP BY. + */ +const ROUTE_KM = `MAX(${SCHEDULE_KM_EXPR})`; +const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`; + +/** + * Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no + * configured distance. A missing distance is not a zero distance, and zeroing + * it would understate the corridor's work without anyone noticing. + */ +const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${ROUTE_KM}, 1)::float8`; +const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${ROUTE_KM}, 1)::float8`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx); + applyCategoryFilter(qb, ctx.params); + return qb; +} + +export const chargedVsActualVolumeReport: ReportDefinition = { + key: 'charged-vs-actual-volume', + title: 'Charged and Actual Volumes', + description: + 'Charged versus actual volume per train and cargo type, with Ton/Km and Vehicle-Km. ' + + 'Charged volume is the standard weight capacity — 20 and 40 tons per laden container, ' + + '2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for perishables — ' + + 'all editable in Operating standards. Actual volume is what the marshalling recorded. ' + + 'Vehicle-Km counts the empty wagons on that train, so it repeats across the train’s ' + + 'cargo types rather than being split between them.', + group: 'Operations', + filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' }, + { key: 'station', label: 'Station', type: 'string' }, + { key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR }, + { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, + { key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number' }, + { key: 'wagons', label: 'Loaded wagons', type: 'number' }, + { key: 'emptyWagons', label: 'Empty wagons', type: 'number' }, + { key: 'distanceKm', label: 'Distance (km)', type: 'number' }, + { key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true }, + { key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' }, + ], + defaultSort: { key: 'departedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select("COALESCE(ts.train_number, '—')", 'trainNumber') + .addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt') + .addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station') + .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') + .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons') + .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons') + .addSelect(TEU_EXPR, 'teu') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + .addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons') + .addSelect(`${ROUTE_KM}::float8`, 'distanceKm') + .addSelect(TON_KM, 'tonKm') + .addSelect(VEHICLE_KM, 'vehicleKm') + .groupBy('ts.id') + .addGroupBy('ts.train_number') + .addGroupBy('ts.actual_departure_at') + .addGroupBy('ts.scheduled_departure_date') + .addGroupBy('oy.label') + .addGroupBy('oy.code') + .addGroupBy('dy.label') + .addGroupBy('dy.code') + .addGroupBy(CARGO_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged') + .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual') + .addSelect( + `COUNT(DISTINCT ts.id) FILTER (WHERE ${SCHEDULE_KM_EXPR} IS NULL)::int`, + 'unmeasuredTrains', + ) + .getRawOne<{ charged: number; actual: number; unmeasuredTrains: number }>(); + + return [ + { label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' }, + { label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' }, + // Always shown, even at zero: a corridor with no configured distance + // silently drops out of Ton/Km, and that must be visible. + { label: 'Trains without a configured distance', value: Number(row?.unmeasuredTrains ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/payment-classification.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/payment-classification.report.ts new file mode 100644 index 000000000..4a77521b5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/payment-classification.report.ts @@ -0,0 +1,76 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + PAID_SHARE, + PAYMENT_CLASSES, + PAYMENT_CLASS_EXPR, + PAYMENT_CLASS_LABEL_EXPR, + PERIOD_FILTER, + REVENUE_FILTERS, + REVENUE_SUM, + currencyOf, + periodExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = revenueLedgerQb(ctx); + const classes = ctx.params.classes as string[] | null; + if (classes?.length) { + qb.andWhere(`${PAYMENT_CLASS_EXPR} IN (:...classes)`, { classes }); + } + return qb; +} + +export const paymentClassificationReport: ReportDefinition = { + key: 'payment-classification', + title: 'Payment Classification', + description: + 'What customers actually paid for, per period: rail transport, customs clearance, ' + + 'first/last mile, overweight, cancellation, demurrage, storage, loading and unloading, ' + + 'and additional charges. Note there is no dedicated loading/unloading charge type in ' + + 'the system — handling and double-handling fees stand in for it.', + group: 'Finance', + filters: [ + PERIOD_FILTER, + ...REVENUE_FILTERS, + { key: 'classes', label: 'Payment class', type: 'multiselect', options: PAYMENT_CLASSES }, + ], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'paymentClass', label: 'Payment class', type: 'string', sortable: true }, + { key: 'billed', label: 'Billed', type: 'money', sortable: true }, + { key: 'settled', label: 'Settled', type: 'money', sortable: true }, + { key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true }, + { key: 'lines', label: 'Lines', type: 'number' }, + ], + defaultSort: { key: 'billed', dir: 'DESC' }, + chart: { type: 'bar', x: 'paymentClass', y: ['billed'] }, + query(ctx) { + const period = periodExpr(ctx.params); + return baseQuery(ctx) + .select(period, 'period') + .addSelect(PAYMENT_CLASS_LABEL_EXPR, 'paymentClass') + .addSelect('ROUND(SUM(il.amount))::float8', 'billed') + .addSelect(`ROUND(SUM(${PAID_SHARE}))::float8`, 'settled') + .addSelect(`ROUND(SUM(il.amount) - SUM(${PAID_SHARE}))::float8`, 'outstanding') + .addSelect('COUNT(*)::int', 'lines') + .groupBy(period) + .addGroupBy(PAYMENT_CLASS_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'billed') + .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'settled') + .getRawOne<{ billed: number; settled: number }>(); + const currency = currencyOf(ctx.params); + const billed = Number(row?.billed ?? 0); + const settled = Number(row?.settled ?? 0); + return [ + { label: 'Billed', value: billed, unit: currency }, + { label: 'Settled', value: settled, unit: currency }, + { label: 'Outstanding', value: Math.round(billed - settled), unit: currency }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts new file mode 100644 index 000000000..018a68faf --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts @@ -0,0 +1,114 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types'; +import { + PAYER_EXPR, + REVENUE_DATE, + REVENUE_FILTERS, + currencyOf, + invoiceLedgerQb, +} from '../revenue-classification'; + +export const LEDGER_SIDES: ReportFilterOption[] = [ + { value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' }, + { value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' }, + { value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' }, + { value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' }, + { value: 'SETTLED', label: 'Settled' }, +]; + +/** + * Which side of the ledger an invoice sits on. + * + * Receivable = EDR delivered and is owed money — the shipping-line credit + * arrangement, plus any invoice still carrying a balance. + * Payable = the customer paid for something EDR did not deliver, so the money + * is a refund liability rather than revenue: cancellation fees, and prepaid + * invoices whose booking died. + */ +const SIDE_EXPR = `CASE + WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT' + THEN 'RECEIVABLE_CREDIT' + WHEN i.type = 'WAGON_CANCEL_FEE' THEN 'PAYABLE_CANCELLATION' + WHEN i.paid_amount > 0 AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED') + THEN 'PAYABLE_UNDELIVERED' + WHEN i.balance_amount > 0 THEN 'RECEIVABLE_OPEN' + ELSE 'SETTLED' +END`; + +const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label])); +const SIDE_LABEL_EXPR = `CASE ${SIDE_EXPR} + ${[...LABELS].map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`).join('\n ')} +END`; + +/** Money at stake on this row: what is owed, or what may have to be given back. */ +const EXPOSURE = `CASE + WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount + ELSE i.balance_amount +END`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = invoiceLedgerQb(ctx); + const sides = ctx.params.sides as string[] | null; + if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides }); + return qb; +} + +export const receivablesPayablesReport: ReportDefinition = { + key: 'receivables-payables', + title: 'Receivables and Payables', + description: + 'Splits customer money two ways: receivable, where EDR delivered and is owed — ' + + 'including shipping-line credit services — and payable, where the customer paid but ' + + 'the service was not delivered, such as cancellation fees and prepayments against ' + + 'dead bookings. Payable amounts are a refund liability, not revenue.', + group: 'Finance', + filters: [ + ...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'), + { key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES }, + ], + columns: [ + { key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR }, + { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, + { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { key: 'bookingRef', label: 'Booking', type: 'string' }, + { key: 'bookingStatus', label: 'Booking status', type: 'string' }, + { key: 'customer', label: 'Payer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, + { key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' }, + { key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, + { key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE }, + ], + defaultSort: { key: 'exposure', dir: 'DESC' }, + chart: { type: 'bar', x: 'side', y: ['exposure'] }, + query(ctx) { + return baseQuery(ctx) + .select(SIDE_LABEL_EXPR, 'side') + .addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') + .addSelect('i.invoice_number', 'invoiceNumber') + .addSelect("COALESCE(b.reference, '—')", 'bookingRef') + .addSelect("COALESCE(b.status, '—')", 'bookingStatus') + .addSelect(PAYER_EXPR, 'customer') + .addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') + .addSelect('ROUND(i.paid_amount, 2)::float8', 'paid') + .addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select( + `ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`, + 'receivable', + ) + .addSelect( + `ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`, + 'payable', + ) + .addSelect('COUNT(*)::int', 'invoices') + .getRawOne<{ receivable: number; payable: number; invoices: number }>(); + const currency = currencyOf(ctx.params); + return [ + { label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency }, + { label: 'Payable', value: Number(row?.payable ?? 0), unit: currency }, + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-anomalies.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-anomalies.report.ts new file mode 100644 index 000000000..cf2d7735e --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-anomalies.report.ts @@ -0,0 +1,102 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CATEGORY_LABEL_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + growthPctExpr, + periodExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +const REVENUE = 'SUM(il.amount)'; + +const THRESHOLDS = [ + { value: '10', label: '±10%' }, + { value: '25', label: '±25%' }, + { value: '50', label: '±50%' }, +]; + +const thresholdOf = (params: Record): number => { + const raw = Number(params.threshold); + return THRESHOLDS.some((t) => Number(t.value) === raw) ? raw : 25; +}; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueAnomaliesReport: ReportDefinition = { + key: 'revenue-anomalies', + title: 'Revenue Anomalies', + description: + 'Periods where a revenue category moved more than the chosen threshold against the ' + + 'previous period. Pull-based on purpose: this surfaces the spikes and drops for review ' + + 'rather than paging anyone, so the thresholds can be tuned against real numbers first.', + group: 'Finance', + filters: [ + PERIOD_FILTER, + { key: 'threshold', label: 'Threshold', type: 'select', options: THRESHOLDS }, + ...REVENUE_FILTERS, + ], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'category', label: 'Revenue category', type: 'string', sortable: true }, + { key: 'direction', label: 'Movement', type: 'string' }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'priorRevenue', label: 'Prior period', type: 'money' }, + { key: 'growthPct', label: 'Change', type: 'percent', sortable: true }, + ], + defaultSort: { key: 'period', dir: 'DESC' }, + query(ctx) { + const period = periodExpr(ctx.params); + // ORDER BY the same expression this query GROUPs BY — the to_char label, not + // the inner date_trunc. Ordering by the unwrapped timestamp raises + // "column i.issued_at must appear in the GROUP BY clause". The label formats + // are zero-padded, so lexicographic order is chronological order. + const prior = `lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`; + const change = growthPctExpr(REVENUE, prior); + + const inner = baseQuery(ctx) + .select(period, 'period') + .addSelect(CATEGORY_LABEL_EXPR, 'category') + .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') + .addSelect(`ROUND(${REVENUE})::float8`, 'revenue') + .addSelect(`ROUND(COALESCE(${prior}, 0))::float8`, 'priorRevenue') + .addSelect(change, 'growthPct') + .addSelect( + `CASE WHEN ${REVENUE} >= COALESCE(${prior}, 0) THEN 'Spike' ELSE 'Drop' END`, + 'direction', + ) + .groupBy(period) + .addGroupBy(REVENUE_CATEGORY_EXPR); + + // The threshold cannot live in WHERE or HAVING — both are evaluated before + // window functions, and `growthPct` is one. Wrapping is the only place the + // comparison is legal. A period with no predecessor yields NULL, and + // `ABS(NULL) >= n` is NULL, so those rows drop out without an extra guard. + return ctx.ds + .createQueryBuilder() + .select('a.*') + .from(`(${inner.getQuery()})`, 'a') + .setParameters(inner.getParameters()) + .where('ABS(a."growthPct") >= :threshold', { threshold: thresholdOf(ctx.params) }); + }, + + async summary(ctx) { + const [sql, params] = revenueAnomaliesReport.query(ctx).getQueryAndParameters(); + const rows: Array<{ spikes: number; drops: number }> = await ctx.ds.query( + `SELECT COUNT(*) FILTER (WHERE a.direction = 'Spike')::int AS spikes, + COUNT(*) FILTER (WHERE a.direction = 'Drop')::int AS drops + FROM (${sql}) a`, + params, + ); + return [ + { label: 'Spikes', value: Number(rows[0]?.spikes ?? 0) }, + { label: 'Drops', value: Number(rows[0]?.drops ?? 0) }, + { label: 'Threshold', value: thresholdOf(ctx.params), unit: '%' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts new file mode 100644 index 000000000..0a46f15ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts @@ -0,0 +1,121 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + AVG_PER_UNIT_EXPR, + CATEGORY_LABEL_EXPR, + CONTAINERS_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + TEU_EXPR, + TONS_EXPR, + UNIT_LABEL_EXPR, + currencyOf, + growthPctExpr, + periodExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +const REVENUE = 'SUM(il.amount)'; + +/** + * Previous period's revenue for the same category. + * + * Postgres evaluates window functions after GROUP BY, so `lag(SUM(...))` is + * legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the + * ORDER BY must repeat their grouping expressions verbatim: ordering by the + * inner `date_trunc` when the group key is the `to_char` wrapper fails, and + * ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window + * clause, silently producing an unordered partition. + */ +const priorRevenue = (period: string): string => + `lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`; + +const growthPct = (period: string): string => growthPctExpr(REVENUE, priorRevenue(period)); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueByCategoryReport: ReportDefinition = { + key: 'revenue-by-category', + title: 'Revenue by Category', + description: + 'Billed revenue in the twelve rail revenue categories, per period, with volume and ' + + 'period-over-period growth. Growth compares against the previous period inside the ' + + 'selected date range, so the earliest period always reads zero. ' + + 'Multimodal means a named sea carrier is on the booking.', + group: 'Finance', + filters: [PERIOD_FILTER, ...REVENUE_FILTERS], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'category', label: 'Revenue category', type: 'string', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'priorRevenue', label: 'Prior period', type: 'money' }, + { key: 'growthPct', label: 'Growth', type: 'percent' }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number', sortable: true }, + { key: 'containers', label: 'Containers', type: 'number' }, + { key: 'avgPerUnit', label: 'Avg revenue/unit', type: 'money' }, + { key: 'unit', label: 'Unit', type: 'string' }, + { key: 'lines', label: 'Lines', type: 'number' }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + chart: { type: 'bar', x: 'category', y: ['revenue'] }, + /** + * Row click opens the transaction list for exactly this bucket. + * + * `period` carries into `period_value` (the bucket, e.g. "2026-08"), NOT into + * `period` — that filter is the granularity, and handing it a date string + * would silently reset it to monthly. The granularity itself rides along + * from the filters already applied. + * + * `categoryKey` rather than `category`: the visible column holds the business + * label, and the target filters on the key. + */ + drill: { + to: 'revenue-transactions', + carry: { period: 'period_value', categoryKey: 'categoryKey' }, + }, + query(ctx) { + const period = periodExpr(ctx.params); + return baseQuery(ctx) + .select(period, 'period') + .addSelect(CATEGORY_LABEL_EXPR, 'category') + .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') + .addSelect(`ROUND(${REVENUE})::float8`, 'revenue') + .addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue') + .addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct') + .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') + .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') + .addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers') + .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit') + .addSelect(UNIT_LABEL_EXPR, 'unit') + .addSelect('COUNT(*)::int', 'lines') + .groupBy(period) + .addGroupBy(REVENUE_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'revenue') + .addSelect( + `ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${REVENUE_CATEGORY_EXPR} = 'UNCLASSIFIED'), 0))::float8`, + 'unclassified', + ) + .addSelect(`COUNT(DISTINCT ${REVENUE_CATEGORY_EXPR})::int`, 'categories') + // Invoice-level balance is deliberately NOT summed here — the ledger is + // at line grain, so a 3-line invoice would count its balance three times. + // Outstanding lives on `revenue-reconciliation`, at invoice grain. + .getRawOne<{ revenue: number; unclassified: number; categories: number }>(); + + const currency = currencyOf(ctx.params); + return [ + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency }, + { label: 'Categories', value: Number(row?.categories ?? 0) }, + // Always shown, even at zero: an audit report must never quietly drop money. + { label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-period.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-period.report.ts new file mode 100644 index 000000000..541b9ef01 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-period.report.ts @@ -0,0 +1,112 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + TEU_EXPR, + TONS_EXPR, + currencyOf, + growthPctExpr, + nextPeriodOrdinalExpr, + periodExpr, + periodOrdinalExpr, + periodTruncExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +const REVENUE = 'SUM(il.amount)'; + +/** + * A six-period rolling linear trend, projected one period ahead. + * + * `regr_slope`/`regr_intercept` are Postgres built-ins, so this needs no + * dependency and no model store. It is a trend line, not a forecast model: no + * seasonality, no confidence interval, and meaningless on fewer than about + * four points — hence the row-count guard, which returns NULL rather than a + * confident-looking number drawn through two dots. + * + * The x variable is the period's epoch seconds, not `row_number()`: Postgres + * rejects a window function nested inside another window function's arguments + * ("window function calls cannot be nested"), and the timestamp is already a + * monotonic ordinal. + * + * ponytail: linear trend only. A seasonal model (Holt-Winters/ARIMA) means a + * stats dependency, a training story and an owner for model quality — do that + * only if the business names a seasonality requirement. + */ +const forecastNext = (params: Record): string => { + const window = `OVER (ORDER BY ${periodTruncExpr(params)} ROWS BETWEEN 5 PRECEDING AND CURRENT ROW)`; + const x = periodOrdinalExpr(params); + return `CASE WHEN count(*) ${window} >= 4 THEN GREATEST(0, ROUND( + (regr_intercept(${REVENUE}, ${x}) ${window}) + + (regr_slope(${REVENUE}, ${x}) ${window}) * ${nextPeriodOrdinalExpr(params)} + ))::float8 END`; +}; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueByPeriodReport: ReportDefinition = { + key: 'revenue-by-period', + title: 'Revenue Trend', + description: + 'Total billed revenue per period with period-over-period growth and a rolling ' + + 'six-period linear projection. The projection is a trend line, not a seasonal ' + + 'forecast, and is blank until six periods of history exist.', + group: 'Finance', + filters: [PERIOD_FILTER, ...REVENUE_FILTERS], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'priorRevenue', label: 'Prior period', type: 'money' }, + { key: 'growthPct', label: 'Growth', type: 'percent' }, + { key: 'forecastNext', label: 'Projected next', type: 'money' }, + { key: 'categories', label: 'Categories', type: 'number' }, + { key: 'tons', label: 'Tonnage', type: 'tons' }, + { key: 'teu', label: 'TEU', type: 'number' }, + { key: 'lines', label: 'Lines', type: 'number' }, + ], + defaultSort: { key: 'period', dir: 'ASC' }, + chart: { type: 'line', x: 'period', y: ['revenue', 'forecastNext'] }, + drill: { to: 'revenue-transactions', carry: { period: 'period_value' } }, + query(ctx) { + const period = periodExpr(ctx.params); + const trunc = periodTruncExpr(ctx.params); + const prior = `lag(${REVENUE}) OVER (ORDER BY ${trunc})`; + return baseQuery(ctx) + .select(period, 'period') + .addSelect(`ROUND(${REVENUE})::float8`, 'revenue') + .addSelect(`ROUND(COALESCE(${prior}, 0))::float8`, 'priorRevenue') + .addSelect(`COALESCE(${growthPctExpr(REVENUE, prior)}, 0)`, 'growthPct') + .addSelect(forecastNext(ctx.params), 'forecastNext') + .addSelect(`COUNT(DISTINCT ${REVENUE_CATEGORY_EXPR})::int`, 'categories') + .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') + .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') + .addSelect('COUNT(*)::int', 'lines') + // Grouped by the period's start timestamp, so the ordering the windows + // above use is a grouping key rather than a bare column reference. + .groupBy(trunc); + }, + + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'revenue') + .addSelect(`COUNT(DISTINCT ${periodTruncExpr(ctx.params)})::int`, 'periods') + .getRawOne<{ revenue: number; periods: number }>(); + const periods = Number(row?.periods ?? 0); + const revenue = Number(row?.revenue ?? 0); + return [ + { label: 'Total revenue', value: revenue, unit: currencyOf(ctx.params) }, + { label: 'Periods', value: periods }, + { + label: 'Average per period', + value: periods ? Math.round(revenue / periods) : 0, + unit: currencyOf(ctx.params), + }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-route.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-route.report.ts new file mode 100644 index 000000000..3d8a9370a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-route.report.ts @@ -0,0 +1,79 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + AVG_PER_UNIT_EXPR, + CATEGORY_LABEL_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + TEU_EXPR, + TONS_EXPR, + UNIT_LABEL_EXPR, + currencyOf, + revenueLedgerQb, +} from '../revenue-classification'; + +/** + * There is no corridor entity in the schema — a corridor IS an + * (origin_yard, destination_yard) pair, which is exactly how rates are scoped. + */ +const CORRIDOR = `COALESCE(oy.label, 'Unknown') || ' → ' || COALESCE(dy.label, 'Unknown')`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueByRouteReport: ReportDefinition = { + key: 'revenue-by-route', + title: 'Revenue by Route', + description: + 'Billed revenue per corridor and revenue category. A corridor is an ' + + 'origin/destination station pair — the schema has no separate corridor entity.', + group: 'Finance', + filters: [PERIOD_FILTER, ...REVENUE_FILTERS], + columns: [ + { key: 'corridor', label: 'Corridor', type: 'string', sortable: true, sortExpr: CORRIDOR }, + { key: 'origin', label: 'Origin', type: 'string' }, + { key: 'destination', label: 'Destination', type: 'string' }, + { key: 'category', label: 'Revenue category', type: 'string', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number', sortable: true }, + { key: 'avgPerUnit', label: 'Avg revenue/unit', type: 'money' }, + { key: 'unit', label: 'Unit', type: 'string' }, + { key: 'lines', label: 'Lines', type: 'number' }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + chart: { type: 'bar', x: 'corridor', y: ['revenue'] }, + drill: { to: 'revenue-transactions', carry: { categoryKey: 'categoryKey' } }, + query(ctx) { + return baseQuery(ctx) + .select(CORRIDOR, 'corridor') + .addSelect("COALESCE(oy.label, 'Unknown')", 'origin') + .addSelect("COALESCE(dy.label, 'Unknown')", 'destination') + .addSelect(CATEGORY_LABEL_EXPR, 'category') + .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') + .addSelect('ROUND(SUM(il.amount))::float8', 'revenue') + .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') + .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') + .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit') + .addSelect(UNIT_LABEL_EXPR, 'unit') + .addSelect('COUNT(*)::int', 'lines') + .groupBy(CORRIDOR) + .addGroupBy('oy.label') + .addGroupBy('dy.label') + .addGroupBy(REVENUE_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'revenue') + .addSelect(`COUNT(DISTINCT ${CORRIDOR})::int`, 'corridors') + .getRawOne<{ revenue: number; corridors: number }>(); + return [ + { label: 'Corridors', value: Number(row?.corridors ?? 0) }, + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts new file mode 100644 index 000000000..73de3f505 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts @@ -0,0 +1,93 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + GATEWAY_PAID, + PAYER_EXPR, + REVENUE_DATE, + REVENUE_FILTERS, + currencyOf, + invoiceLedgerQb, +} from '../revenue-classification'; + +/** + * The gap between what the invoice says was paid and what the payment gateway + * recorded. Non-zero is not automatically wrong — manual settlements are real + * — but every one of them should be explainable, which is the point. + */ +const VARIANCE = `i.paid_amount - ${GATEWAY_PAID}`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = invoiceLedgerQb(ctx); + const matched = ctx.params.matched as string | null; + if (matched === 'matched') qb.andWhere(`ROUND(${VARIANCE}, 2) = 0`); + if (matched === 'unmatched') qb.andWhere(`ROUND(${VARIANCE}, 2) <> 0`); + return qb; +} + +export const revenueReconciliationReport: ReportDefinition = { + key: 'revenue-reconciliation', + title: 'Revenue vs Payment Reconciliation', + description: + 'Every invoice with its recorded settlement set against what the payment gateway ' + + 'actually confirmed. Invoices are matched on the booking id both sides carry — ' + + 'invoices.payment_id points at a payment-service intent, not a freight payment row. ' + + 'Invoices with no booking (warehouse, shipping-line credit) have no gateway record to ' + + 'match against and will show their full paid amount as variance.', + group: 'Finance', + filters: [ + ...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'), + { + key: 'matched', + label: 'Reconciliation', + type: 'select', + options: [ + { value: 'unmatched', label: 'Variance only' }, + { value: 'matched', label: 'Reconciled only' }, + ], + }, + ], + columns: [ + { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, + { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, + { key: 'source', label: 'Source', type: 'string', sortable: true, sortExpr: 'i.source' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' }, + { key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' }, + { key: 'recordedPaid', label: 'Recorded paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, + { key: 'gatewayPaid', label: 'Gateway paid', type: 'money' }, + { key: 'variance', label: 'Variance', type: 'money', sortable: true, sortExpr: `ABS(${VARIANCE})` }, + { key: 'balance', label: 'Outstanding', type: 'money', sortable: true, sortExpr: 'i.balance_amount' }, + ], + defaultSort: { key: 'variance', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') + .addSelect('i.invoice_number', 'invoiceNumber') + .addSelect("COALESCE(b.reference, '—')", 'bookingRef') + .addSelect(PAYER_EXPR, 'customer') + .addSelect('i.source', 'source') + .addSelect('i.status', 'status') + .addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') + .addSelect('ROUND(i.paid_amount, 2)::float8', 'recordedPaid') + .addSelect(`ROUND(${GATEWAY_PAID}, 2)::float8`, 'gatewayPaid') + .addSelect(`ROUND(${VARIANCE}, 2)::float8`, 'variance') + .addSelect('ROUND(i.balance_amount, 2)::float8', 'balance'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'invoices') + .addSelect(`COUNT(*) FILTER (WHERE ROUND(${VARIANCE}, 2) <> 0)::int`, 'withVariance') + .addSelect(`ROUND(COALESCE(SUM(ABS(${VARIANCE})), 0))::float8`, 'variance') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding') + .getRawOne<{ invoices: number; withVariance: number; variance: number; outstanding: number }>(); + const currency = currencyOf(ctx.params); + return [ + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'With variance', value: Number(row?.withVariance ?? 0) }, + { label: 'Total variance', value: Number(row?.variance ?? 0), unit: currency }, + { label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: currency }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-top-customers.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-top-customers.report.ts new file mode 100644 index 000000000..ae27d7487 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-top-customers.report.ts @@ -0,0 +1,70 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CATEGORY_LABEL_EXPR, + PAYER_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORY_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + TEU_EXPR, + TONS_EXPR, + currencyOf, + revenueLedgerQb, +} from '../revenue-classification'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + return revenueLedgerQb(ctx); +} + +export const revenueTopCustomersReport: ReportDefinition = { + key: 'revenue-top-customers', + title: 'Top Customers by Revenue', + description: + 'Customers ranked by billed revenue, with their category mix and outstanding share. ' + + 'The payer is the company or, for shipping-line credit invoices, the shipping line — ' + + 'an invoice carries exactly one of the two.', + group: 'Finance', + filters: [PERIOD_FILTER, ...REVENUE_FILTERS], + columns: [ + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, + { key: 'category', label: 'Revenue category', type: 'string', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { key: 'sharePct', label: 'Share of total', type: 'percent' }, + { key: 'tons', label: 'Tonnage', type: 'tons' }, + { key: 'teu', label: 'TEU', type: 'number' }, + { key: 'invoices', label: 'Invoices', type: 'number', sortable: true }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + chart: { type: 'bar', x: 'customer', y: ['revenue'] }, + drill: { to: 'revenue-transactions', carry: { customer: 'customer', categoryKey: 'categoryKey' } }, + query(ctx) { + return baseQuery(ctx) + .select(PAYER_EXPR, 'customer') + .addSelect(CATEGORY_LABEL_EXPR, 'category') + .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') + .addSelect('ROUND(SUM(il.amount))::float8', 'revenue') + // Share of the whole filtered set, not of the page — a window over no + // partition sees every group the query produced. + .addSelect( + 'ROUND(100 * SUM(il.amount) / NULLIF(SUM(SUM(il.amount)) OVER (), 0), 1)::float8', + 'sharePct', + ) + .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') + .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') + .addSelect('COUNT(DISTINCT i.id)::int', 'invoices') + .groupBy(PAYER_EXPR) + .addGroupBy(REVENUE_CATEGORY_EXPR); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'revenue') + .addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers') + .getRawOne<{ revenue: number; customers: number }>(); + return [ + { label: 'Customers', value: Number(row?.customers ?? 0) }, + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts new file mode 100644 index 000000000..ad96b5b9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts @@ -0,0 +1,131 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + PAYMENT_CLASS_EXPR, + PAYER_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORIES, + REVENUE_CATEGORY_EXPR, + REVENUE_DATE, + REVENUE_FILTERS, + REVENUE_SUM, + currencyOf, + periodExpr, + revenueLedgerQb, +} from '../revenue-classification'; + +/** + * The gateway payment behind an invoice, for traceability. `invoices.payment_id` + * points at a payment-api intent id rather than a `freight.payments` row, so the + * reliable link is `payments.ref_id = invoices.source_id` (the booking id). + * + * Correlated scalar subselects rather than a LATERAL join: TypeORM's query + * builder cannot emit LATERAL, and the correlation on `i.source_id` is what + * makes this work at all. Successful payments win, then most recent. + * + * `::text` is not cosmetic — `payments.method` and `payments.status` are real + * Postgres enums, so `COALESCE(, '')` fails with + * `invalid input value for enum freight.payments_method_enum: ""`. + */ +const latestPayment = (column: string): string => `( + SELECT p.${column}::text FROM freight.payments p + WHERE p.ref_id = i.source_id + ORDER BY (p.status = 'success') DESC, p.created_at DESC + LIMIT 1 +)`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = revenueLedgerQb(ctx); + + // Drill-down target: the summary reports hand over the exact period bucket + // and category key they were showing. + if (params.period_value) { + qb.andWhere(`${periodExpr(params)} = :periodValue`, { periodValue: params.period_value }); + } + if (params.categoryKey) { + qb.andWhere(`${REVENUE_CATEGORY_EXPR} = :categoryKey`, { categoryKey: params.categoryKey }); + } + return qb; +} + +export const revenueTransactionsReport: ReportDefinition = { + key: 'revenue-transactions', + title: 'Revenue Transactions', + description: + 'Every billed revenue line, at transaction level — booking reference, invoice number, ' + + 'charge type, cargo, quantity and the payment reference behind it. This is the ' + + 'drill-down target for the revenue summaries and the audit trail for an export.', + group: 'Finance', + filters: [ + PERIOD_FILTER, + ...REVENUE_FILTERS, + { key: 'period_value', label: 'Period bucket', type: 'text' }, + { + key: 'categoryKey', + label: 'Category (exact)', + type: 'select', + options: REVENUE_CATEGORIES, + }, + ], + columns: [ + { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, + { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' }, + { key: 'bookingId', label: 'Booking ID', type: 'string' }, + { key: 'payer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, + { key: 'category', label: 'Revenue category', type: 'string', sortable: true, sortExpr: REVENUE_CATEGORY_EXPR }, + { key: 'paymentClass', label: 'Payment class', type: 'string' }, + { key: 'chargeType', label: 'Charge type', type: 'string', sortable: true, sortExpr: 'il.charge_type' }, + { key: 'cargo', label: 'Cargo', type: 'string' }, + { key: 'route', label: 'Route', type: 'string' }, + { key: 'quantity', label: 'Qty', type: 'number' }, + { key: 'unit', label: 'Unit', type: 'string' }, + { key: 'unitRate', label: 'Unit rate', type: 'money' }, + { key: 'amount', label: 'Amount', type: 'money', sortable: true, sortExpr: 'il.amount' }, + { key: 'currency', label: 'Currency', type: 'string' }, + { key: 'invoiceStatus', label: 'Invoice status', type: 'string', sortable: true, sortExpr: 'i.status' }, + { key: 'paymentRef', label: 'Payment ref', type: 'string' }, + { key: 'paymentMethod', label: 'Method', type: 'string' }, + { key: 'paymentStatus', label: 'Payment status', type: 'string' }, + ], + defaultSort: { key: 'issuedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') + .addSelect('i.invoice_number', 'invoiceNumber') + .addSelect("COALESCE(b.reference, '—')", 'bookingRef') + .addSelect("COALESCE(b.id::text, '')", 'bookingId') + .addSelect(PAYER_EXPR, 'payer') + .addSelect(REVENUE_CATEGORY_EXPR, 'category') + .addSelect(PAYMENT_CLASS_EXPR, 'paymentClass') + .addSelect('il.charge_type', 'chargeType') + .addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '—')", 'cargo') + .addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", 'route') + .addSelect('il.quantity::float8', 'quantity') + .addSelect("COALESCE(il.metadata->>'unit', '')", 'unit') + .addSelect('ROUND(il.unit_rate, 2)::float8', 'unitRate') + .addSelect('ROUND(il.amount, 2)::float8', 'amount') + .addSelect('il.currency', 'currency') + .addSelect('i.status', 'invoiceStatus') + .addSelect( + `COALESCE(${latestPayment('transaction_id')}, ${latestPayment('merchant_order_id')}, '')`, + 'paymentRef', + ) + .addSelect(`COALESCE(${latestPayment('method')}, '')`, 'paymentMethod') + .addSelect(`COALESCE(${latestPayment('status')}, '')`, 'paymentStatus'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(REVENUE_SUM, 'revenue') + .addSelect('COUNT(*)::int', 'lines') + .addSelect('COUNT(DISTINCT i.id)::int', 'invoices') + .getRawOne<{ revenue: number; lines: number; invoices: number }>(); + return [ + { label: 'Lines', value: Number(row?.lines ?? 0) }, + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts new file mode 100644 index 000000000..3cf0464d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts @@ -0,0 +1,150 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.entity'; +import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ReportContext, ReportDefinition } from '../report.types'; +import { + COUNTRY_FILTER, + DIRECTION_FILTER, + OPS_DATE, + STANDARDS_JOIN, + STATION_STANDARD_HOURS_EXPR, + hoursBetween, +} from '../operations-classification'; + +/** + * A stay is an ARRIVED followed by the next DEPARTED at the same station by the + * same physical train — NOT by the same schedule. + * + * When a train turns around at a station the two halves belong to different + * departures: the arrival closes the inbound schedule and the departure opens + * the outbound one. Pairing within a schedule finds only pass-through stops and + * silently drops every turnaround, which is the longest stay a train makes. + */ +const TRAIN_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)'; +const STAY_WINDOW = `PARTITION BY ${TRAIN_KEY}, ev.yard_id ORDER BY ev.occurred_at`; + +const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at'); +const STANDARD_HOURS = 's.standard_hours'; + +/** Every logged stop, with the event that followed it at the same station. */ +function stopsQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(TrainCheckpointEvent, 'ev') + .innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL') + .leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL') + .innerJoin(Yard, 'y', 'y.id = ev.yard_id') + .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') + .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') + .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) + .where('ev.deleted_at IS NULL') + .andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')") + .select('ts.train_number', 'train_number') + .addSelect("COALESCE(y.label, y.code, '—')", 'station') + .addSelect("COALESCE(y.country, '—')", 'country') + .addSelect('ev.kind', 'kind') + .addSelect('ev.occurred_at', 'arrived_at') + .addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at') + .addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind') + .addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours') + .addSelect("COALESCE(ev.note, '')", 'note'); + + if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + if (params.trainNumber) { + qb.andWhere('ts.train_number ILIKE :trainNumber', { + trainNumber: `%${params.trainNumber as string}%`, + }); + } + if (params.station) qb.andWhere('y.code = :station', { station: params.station }); + if (params.country) qb.andWhere('y.country = :country', { country: params.country }); + + applyDirectionScope(qb, 'ts.direction', directions); + return qb; +} + +/** Only completed stops — an arrival whose departure was also logged. */ +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const inner = stopsQuery(ctx); + return ctx.ds + .createQueryBuilder() + .from(`(${inner.getQuery()})`, 's') + .setParameters(inner.getParameters()) + .where("s.kind = 'ARRIVED'") + .andWhere("s.next_kind = 'DEPARTED'"); +} + +export const stationStayingTimeReport: ReportDefinition = { + key: 'station-staying-time', + title: 'Station Staying Time', + description: + 'How long each train stood at each station — the logged arrival to the same train’s ' + + 'next departure from that station — against the standard for that side of the line ' + + '(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' + + 'standard needs a reason. Loading and unloading times are not split out: nothing in ' + + 'the system records when they start and end yet.', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departure', type: 'daterange' }, + DIRECTION_FILTER, + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { key: 'station', label: 'Station', type: 'text' }, + COUNTRY_FILTER, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 's.train_number' }, + { key: 'station', label: 'Station', type: 'string', sortable: true, sortExpr: 's.station' }, + { key: 'country', label: 'Country', type: 'string' }, + { key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' }, + { key: 'departedAt', label: 'Departed', type: 'date' }, + { key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS }, + { key: 'standardHours', label: 'Standard (hrs)', type: 'number' }, + { key: 'varianceHours', label: 'Variance (hrs)', type: 'number' }, + { key: 'verdict', label: 'Verdict', type: 'string' }, + { key: 'reason', label: 'Reason', type: 'string' }, + ], + defaultSort: { key: 'arrivedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select("COALESCE(s.train_number, '—')", 'trainNumber') + .addSelect('s.station', 'station') + .addSelect('s.country', 'country') + .addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt') + .addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt') + .addSelect(STAYING_HOURS, 'stayingHours') + .addSelect(`${STANDARD_HOURS}::float8`, 'standardHours') + .addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours') + .addSelect( + `CASE WHEN (${STAYING_HOURS})::numeric <= ${STANDARD_HOURS} + THEN 'Encouraging' ELSE 'Needs reason' END`, + 'verdict', + ) + // The note staff leave on the checkpoint is the only free text on a stop, + // so it is where a reason for an over-standard stay is recorded today. + .addSelect('s.note', 'reason'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'stops') + .addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours') + .addSelect( + `COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`, + 'overStandard', + ) + .getRawOne<{ stops: number; avgHours: number; overStandard: number }>(); + + return [ + { label: 'Stops measured', value: Number(row?.stops ?? 0) }, + { label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' }, + { label: 'Over standard', value: Number(row?.overStandard ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts new file mode 100644 index 000000000..38a8ef04f --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts @@ -0,0 +1,121 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CONTAINER_CLASSES, + CONTAINER_CLASS_EXPR, + CONTAINER_CLASS_LABEL_OF, + CONTAINERS_EXPR, + OPS_DATE, + OPERATIONS_FILTERS, + TEU_EXPR, + allocationLedgerQb, + PLAN_GRANULARITY_NOTE, + implementRateExpr, + plannedRowsParams, + plannedRowsSql, +} from '../operations-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; + +const CONTAINERS_20 = `COALESCE(SUM(( + SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci + LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id + WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL + AND cty.size_ft = 20)), 0)::int`; + +const CONTAINERS_40 = `COALESCE(SUM(( + SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci + LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id + WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL + AND cty.size_ft >= 40)), 0)::int`; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx).andWhere("wba.load_type = 'CONTAINER'"); + const classes = ctx.params.classes as string[] | null; + if (classes?.length) { + qb.andWhere(`${CONTAINER_CLASS_EXPR} IN (:...classes)`, { classes }); + } + return qb; +} + +export const teuPerformanceReport: ReportDefinition = { + key: 'teu-performance', + title: 'TEU Performance', + description: + 'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' + + 'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' + + 'marshalling record — the containers actually allocated to wagons — not from the ' + + 'billing lines. Plan comes from Operational targets.' + + PLAN_GRANULARITY_NOTE, + group: 'Operations', + filters: [ + PERIOD_FILTER, + ...OPERATIONS_FILTERS, + { key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES }, + ], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'containerClass', label: 'Container type', type: 'string', sortable: true }, + { key: 'containers20', label: '20ft', type: 'number', sortable: true }, + { key: 'containers40', label: '40ft', type: 'number', sortable: true }, + { key: 'containers', label: 'Containers', type: 'number', sortable: true }, + { key: 'operated', label: 'Operated (TEU)', type: 'number', sortable: true }, + { key: 'plan', label: 'Plan', type: 'number' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + ], + defaultSort: { key: 'operated', dir: 'DESC' }, + chart: { type: 'bar', x: 'containerClass', y: ['operated'] }, + query(ctx) { + const bucket = periodTruncExprOn(OPS_DATE, ctx.params); + const operated = baseQuery(ctx) + .select(periodExprOn(OPS_DATE, ctx.params), 'period') + .addSelect(CONTAINER_CLASS_EXPR, 'class_key') + .addSelect(CONTAINERS_20, 'containers20') + .addSelect(CONTAINERS_40, 'containers40') + .addSelect(CONTAINERS_EXPR, 'containers') + .addSelect(TEU_EXPR, 'operated') + .groupBy(bucket) + .addGroupBy(CONTAINER_CLASS_EXPR); + + // Full outer join so a planned container class that never moved still + // reports, at zero rather than vanishing. + const combined = ` + SELECT COALESCE(o.period, p.period) AS period, + COALESCE(o.class_key, p.plan_key) AS class_key, + COALESCE(o.containers20, 0) AS containers20, + COALESCE(o.containers40, 0) AS containers40, + COALESCE(o.containers, 0) AS containers, + COALESCE(o.operated, 0) AS operated, + p.plan_value AS plan + FROM (${operated.getQuery()}) o + FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) p + ON p.period = o.period AND p.plan_key = o.class_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .select('r.period', 'period') + .addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass') + .addSelect('r.class_key', 'containerClassKey') + .addSelect('r.containers20::int', 'containers20') + .addSelect('r.containers40::int', 'containers40') + .addSelect('r.containers::int', 'containers') + .addSelect('r.operated::int', 'operated') + .addSelect('r.plan::float8', 'plan') + .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(TEU_EXPR, 'teu') + .addSelect(CONTAINERS_EXPR, 'containers') + .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .getRawOne<{ teu: number; containers: number; trains: number }>(); + + return [ + { label: 'TEU', value: Number(row?.teu ?? 0) }, + { label: 'Containers', value: Number(row?.containers ?? 0) }, + { label: 'Trains', value: Number(row?.trains ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-delays.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-delays.report.ts new file mode 100644 index 000000000..2c03d4d70 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-delays.report.ts @@ -0,0 +1,104 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + DELAY_TOLERANCE_HOURS_EXPR, + DIRECTION_FILTER, + hoursBetween, + legStandardHours, + scheduleLedgerQb, +} from '../operations-classification'; + +const ACTUAL_HOURS = hoursBetween('ts.actual_departure_at', 'ts.actual_arrival_at'); +const STANDARD_HOURS = legStandardHours('ts.origin_station_id', 'ts.destination_station_id'); +const DELAY_HOURS = `ROUND((${ACTUAL_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`; +const IS_DELAYED = `(${ACTUAL_HOURS})::numeric > ${STANDARD_HOURS} + ${DELAY_TOLERANCE_HOURS_EXPR}`; + +/** + * The note staff left when they logged the arrival — the only free text on the + * leg, and so the only place a delay reason is recorded today. + */ +const ARRIVAL_NOTE = `( + SELECT e.note FROM freight.train_checkpoint_events e + WHERE e.train_schedule_id = ts.id AND e.deleted_at IS NULL + AND e.kind = 'ARRIVED' AND e.note IS NOT NULL + ORDER BY e.occurred_at DESC LIMIT 1 +)`; + +/** + * Leg running time against the corridor standard. + * + * The leg measured is the departure's own origin → destination, on actual + * timestamps. Per-station legs would be finer, but only the corridor ends carry + * a configured standard (`yard_distances.standard_hours`), which is what a + * delay is judged against. + */ +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = scheduleLedgerQb(ctx) + .andWhere('ts.actual_departure_at IS NOT NULL') + .andWhere('ts.actual_arrival_at IS NOT NULL'); + + if (ctx.params.delayedOnly === 'true') qb.andWhere(IS_DELAYED); + return qb; +} + +export const trainDelaysReport: ReportDefinition = { + key: 'train-delays', + title: 'Train Delays', + description: + 'Actual running time per leg against the corridor standard — 21h Negad→GMP and the ' + + 'per-pair figures configured on Yard Distances, with the default and the tolerance ' + + '(30 min) in Operating standards. A leg over standard plus tolerance is flagged and ' + + 'needs a reason.', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departure', type: 'daterange' }, + DIRECTION_FILTER, + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { + key: 'delayedOnly', + label: 'Delayed only', + type: 'select', + options: [{ value: 'true', label: 'Delayed legs only' }], + }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'origin', label: 'From', type: 'string' }, + { key: 'destination', label: 'To', type: 'string' }, + { key: 'departedAt', label: 'Departed', type: 'date', sortable: true, sortExpr: 'ts.actual_departure_at' }, + { key: 'arrivedAt', label: 'Arrived', type: 'date' }, + { key: 'actualHours', label: 'Actual (hrs)', type: 'number', sortable: true, sortExpr: ACTUAL_HOURS }, + { key: 'standardHours', label: 'Standard (hrs)', type: 'number' }, + { key: 'delayHours', label: 'Delay (hrs)', type: 'number', sortable: true, sortExpr: DELAY_HOURS }, + { key: 'status', label: 'Status', type: 'string' }, + { key: 'reason', label: 'Reason', type: 'string' }, + ], + defaultSort: { key: 'departedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select("COALESCE(ts.train_number, '—')", 'trainNumber') + .addSelect("COALESCE(oy.label, oy.code, '?')", 'origin') + .addSelect("COALESCE(dy.label, dy.code, '?')", 'destination') + .addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt') + .addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt') + .addSelect(ACTUAL_HOURS, 'actualHours') + .addSelect(`ROUND(${STANDARD_HOURS}, 1)::float8`, 'standardHours') + .addSelect(DELAY_HOURS, 'delayHours') + .addSelect(`CASE WHEN ${IS_DELAYED} THEN 'Delayed' ELSE 'On time' END`, 'status') + .addSelect(`COALESCE(${ARRIVAL_NOTE}, '')`, 'reason'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'legs') + .addSelect(`COUNT(*) FILTER (WHERE ${IS_DELAYED})::int`, 'delayed') + .addSelect(`ROUND(AVG((${ACTUAL_HOURS})::numeric), 1)::float8`, 'avgHours') + .getRawOne<{ legs: number; delayed: number; avgHours: number }>(); + + return [ + { label: 'Legs', value: Number(row?.legs ?? 0) }, + { label: 'Delayed', value: Number(row?.delayed ?? 0) }, + { label: 'Average running time', value: Number(row?.avgHours ?? 0), unit: 'h' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts new file mode 100644 index 000000000..8e1c52434 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts @@ -0,0 +1,113 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; + +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_FILTER, + CATEGORY_LABEL_OF, + LOADED_WAGONS_EXPR, + OPS_DATE, + OPERATIONS_FILTERS, + TRAINSETS_EXPR, + allocationLedgerQb, + applyCategoryFilter, + PLAN_GRANULARITY_NOTE, + implementRateExpr, + plannedRowsParams, + plannedRowsSql, +} from '../operations-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const qb = allocationLedgerQb(ctx); + applyCategoryFilter(qb, ctx.params); + return qb; +} + +export const trainsetPerformanceReport: ReportDefinition = { + key: 'trainset-performance', + title: 'Trainset Performance', + description: + 'Trainsets operated per cargo category against plan. A trainset is the wagons actually ' + + 'loaded divided by a full trainset for that cargo (37 for vehicles, 22 for sand, ' + + 'otherwise the default of 50 — all editable on Cargo Types and Operating standards), so ' + + '30 wagons of a 50-wagon set reads 0.6. Plan comes from Operational targets; a period ' + + 'with no target shows no plan rather than a zero.' + + PLAN_GRANULARITY_NOTE, + group: 'Operations', + filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'category', label: 'Cargo category', type: 'string', sortable: true }, + { key: 'trains', label: 'Trains', type: 'number', sortable: true }, + { key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, + { key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true }, + { key: 'plan', label: 'Plan', type: 'number' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + ], + defaultSort: { key: 'operated', dir: 'DESC' }, + chart: { type: 'bar', x: 'category', y: ['operated'] }, + query(ctx) { + const bucket = periodTruncExprOn(OPS_DATE, ctx.params); + const operated = baseQuery(ctx) + .select(periodExprOn(OPS_DATE, ctx.params), 'period') + .addSelect(CARGO_CATEGORY_EXPR, 'category_key') + .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + .addSelect(TRAINSETS_EXPR, 'operated') + .groupBy(bucket) + .addGroupBy(CARGO_CATEGORY_EXPR); + + // FULL OUTER JOIN so a category that was planned but never ran still shows, + // at zero — TypeORM's builder has no full-outer join, hence the raw text. + const combined = ` + SELECT COALESCE(o.period, p.period) AS period, + COALESCE(o.category_key, p.plan_key) AS category_key, + COALESCE(o.trains, 0) AS trains, + COALESCE(o.wagons, 0) AS wagons, + COALESCE(o.operated, 0) AS operated, + p.plan_value AS plan + FROM (${operated.getQuery()}) o + FULL OUTER JOIN (${plannedRowsSql('TRAINSET', 'cargo_category', ctx.params)}) p + ON p.period = o.period AND p.plan_key = o.category_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .select('r.period', 'period') + .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') + .addSelect('r.category_key', 'categoryKey') + .addSelect('r.trains::int', 'trains') + .addSelect('r.wagons::int', 'wagons') + .addSelect('r.operated::float8', 'operated') + .addSelect('r.plan::float8', 'plan') + .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(DISTINCT ts.id)::int', 'trains') + .addSelect(LOADED_WAGONS_EXPR, 'wagons') + // Wagons on the same departures that carried nothing. Joined rather than + // sub-selected so COUNT(DISTINCT) de-duplicates the fan-out across the + // allocation rows. + .leftJoin( + TrainSetWagon, + 'etw', + `etw.train_set_id = ts.train_set_id AND etw.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM freight.wagon_booking_allocations a + WHERE a.train_set_wagon_id = etw.id AND a.deleted_at IS NULL)`, + ) + .addSelect('COUNT(DISTINCT etw.id)::int', 'emptyWagons') + .getRawOne<{ trains: number; wagons: number; emptyWagons: number }>(); + + return [ + { label: 'Trains', value: Number(row?.trains ?? 0) }, + { label: 'Wagons loaded', value: Number(row?.wagons ?? 0) }, + // The spec's "empty train" line: wagons that rode with nothing on them. + { label: 'Empty wagons', value: Number(row?.emptyWagons ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts new file mode 100644 index 000000000..1a130e329 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts @@ -0,0 +1,154 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; +import { + CYCLE_STANDARD_HOURS_EXPR, + DIRECTION_FILTER, + cycleRateExpr, + hoursBetween, + scheduleLedgerQb, +} from '../operations-classification'; + +/** + * A turn-around cycle is a whole out-and-back, measured departure to the SAME + * train's next departure from the same end: Djibouti → Ethiopia → Djibouti + * (DCT1 → GMP1 → GMP2 → DCT2 in the spec's notation). + * + * That is departure-to-departure two legs later, NOT departure-to-arrival. The + * standard is built that way — the 65-hour container cycle is 21 travel + 13 + * working Nagad + 21 travel + 10 working Indode, and the closing 10 hours only + * exist if the cycle ends at the next departure. Ending it at the arrival would + * measure 55 against a 65-hour standard and report every train as early. + * EDR's own July 2026 figure checks out this way: 22:52 travel + 31:21 at DCT + + * 22:52 travel + 7:12 at Gelan = 84:17, against the 84:07 published average. + * + * Trains are paired by `train_sets.train_id`, the physical consist. A train set + * is one-to-one with a departure, so pairing by set alone would never find a + * second leg; where a set has no train it falls back to the set id, which + * yields a null cycle rather than pairing two unrelated trains. + */ +const CYCLE_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)'; +const CYCLE_ORDER = 'ts.actual_departure_at'; +const lead = (column: string, offset = 1): string => + `lead(${column}, ${offset}) OVER (PARTITION BY ${CYCLE_KEY} ORDER BY ${CYCLE_ORDER})`; + +/** + * Hours a train stood still on one side of the line during the cycle. + * + * Reads the same ARRIVED/DEPARTED checkpoint pairs as the station-staying-time + * report, over both legs of the cycle. Trains whose stops were never logged + * report 0 here — which is why the travelling column is derived by subtraction + * and can read as the whole cycle on an unlogged train. + */ +const stayHours = (country: string): string => `( + SELECT COALESCE(ROUND(SUM(EXTRACT(EPOCH FROM (q.dep - q.arr)) / 3600)::numeric, 1), 0) + FROM ( + SELECT MIN(e.occurred_at) FILTER (WHERE e.kind = 'ARRIVED') AS arr, + MAX(e.occurred_at) FILTER (WHERE e.kind = 'DEPARTED') AS dep + FROM freight.train_checkpoint_events e + JOIN freight.yards yy ON yy.id = e.yard_id + WHERE e.deleted_at IS NULL + AND yy.country = '${country}' + AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id, c.next_cycle_schedule_id) + GROUP BY e.train_schedule_id, e.yard_id + ) q + WHERE q.arr IS NOT NULL AND q.dep IS NOT NULL +)`; + +const ETHIOPIA_HOURS = stayHours('Ethiopia'); +const DJIBOUTI_HOURS = stayHours('Djibouti'); +const AD_HOURS = hoursBetween('c.cycle_start', 'c.cycle_end'); +const TRAVEL_HOURS = `ROUND(GREATEST((${AD_HOURS})::numeric - ${ETHIOPIA_HOURS} - ${DJIBOUTI_HOURS}, 0), 1)::float8`; + +/** The completed cycles, before the per-cycle stay decomposition. */ +function cycleQuery(ctx: ReportContext): SelectQueryBuilder { + return scheduleLedgerQb(ctx) + .leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL') + .andWhere('ts.actual_departure_at IS NOT NULL') + .select('ts.id', 'schedule_id') + .addSelect('ts.train_number', 'train_number') + .addSelect('ts.direction', 'direction') + .addSelect("COALESCE(oy.label, oy.code, '?')", 'origin') + .addSelect("COALESCE(dy.label, dy.code, '?')", 'destination') + .addSelect('ts.actual_departure_at', 'cycle_start') + // Two legs on: the train is back where it started and leaving again. + .addSelect(lead('ts.actual_departure_at', 2), 'cycle_end') + .addSelect(lead('ts.id'), 'return_schedule_id') + .addSelect(lead('ts.id', 2), 'next_cycle_schedule_id') + .addSelect(`ROUND(${CYCLE_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours'); +} + +/** Wraps the cycle rows so the window results can be filtered and measured. */ +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const inner = cycleQuery(ctx); + return ctx.ds + .createQueryBuilder() + .from(`(${inner.getQuery()})`, 'c') + .setParameters(inner.getParameters()) + .where('c.cycle_end IS NOT NULL'); +} + +export const turnaroundCycleReport: ReportDefinition = { + key: 'turnaround-cycle', + title: 'Turnaround Cycle', + description: + 'Full out-and-back cycle per train, measured from one departure to the same train’s ' + + 'departure two legs later — the way the standard is built, so the closing station ' + + 'stay is inside the cycle. Compared against the standard cycle (65h container, 88h ' + + 'bulk via DMP, 96h via Negad or BCC — editable in Operating standards). Implement ' + + 'rate is [(SC − AD) / SC + 1] × 100, so finishing exactly on standard scores 100. ' + + 'The Ethiopia, Djibouti and travelling split comes from logged station checkpoints ' + + 'and reads zero for a train whose stops were never logged.', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departure', type: 'daterange' }, + DIRECTION_FILTER, + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'c.train_number' }, + { key: 'route', label: 'Route', type: 'string' }, + { key: 'cycleStart', label: 'Cycle start', type: 'date', sortable: true, sortExpr: 'c.cycle_start' }, + { key: 'cycleEnd', label: 'Cycle end', type: 'date' }, + { key: 'adHours', label: 'Average duration (hrs)', type: 'number', sortable: true, sortExpr: AD_HOURS }, + { key: 'scHours', label: 'Standard cycle (hrs)', type: 'number' }, + { key: 'implementRate', label: 'Implement rate', type: 'percent', sortable: true }, + { key: 'ethiopiaHours', label: 'Ethiopia stay (hrs)', type: 'number' }, + { key: 'djiboutiHours', label: 'Djibouti stay (hrs)', type: 'number' }, + { key: 'travellingHours', label: 'Travelling (hrs)', type: 'number' }, + { key: 'averageDays', label: 'Average day', type: 'number' }, + ], + defaultSort: { key: 'cycleStart', dir: 'DESC' }, + chart: { type: 'bar', x: 'trainNumber', y: ['adHours'] }, + query(ctx) { + return baseQuery(ctx) + .select("COALESCE(c.train_number, '—')", 'trainNumber') + .addSelect("c.origin || ' → ' || c.destination", 'route') + .addSelect(`to_char(c.cycle_start, 'YYYY-MM-DD HH24:MI')`, 'cycleStart') + .addSelect(`to_char(c.cycle_end, 'YYYY-MM-DD HH24:MI')`, 'cycleEnd') + .addSelect(AD_HOURS, 'adHours') + .addSelect('c.standard_hours::float8', 'scHours') + .addSelect(cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours'), 'implementRate') + .addSelect(`${ETHIOPIA_HOURS}::float8`, 'ethiopiaHours') + .addSelect(`${DJIBOUTI_HOURS}::float8`, 'djiboutiHours') + .addSelect(TRAVEL_HOURS, 'travellingHours') + .addSelect(`ROUND((${AD_HOURS})::numeric / 24, 2)::float8`, 'averageDays'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'cycles') + .addSelect(`ROUND(AVG((${AD_HOURS})::numeric), 1)::float8`, 'avgHours') + .addSelect( + `ROUND(AVG(${cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours')}::numeric), 1)::float8`, + 'avgRate', + ) + .getRawOne<{ cycles: number; avgHours: number; avgRate: number }>(); + + return [ + { label: 'Cycles', value: Number(row?.cycles ?? 0) }, + { label: 'Average duration', value: Number(row?.avgHours ?? 0), unit: 'h' }, + { label: 'Average implement rate', value: Number(row?.avgRate ?? 0), unit: '%' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts new file mode 100644 index 000000000..63a7db20b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts @@ -0,0 +1,94 @@ +import { + CARGO_CATEGORIES, + CARGO_CATEGORY_EXPR, + CARGO_CATEGORY_LABEL_EXPR, + CONTAINER_CLASSES, + CONTAINER_CLASS_EXPR, + TARGET_DIMENSION_KEYS, + cycleRateExpr, + implementRateExpr, +} from './operations-classification'; +import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity'; + +/** + * Every key a classification CASE can emit, read straight off the expression. + * The categories are the join key between a report and its planned target, so a + * key the reports emit but the target dimension list does not offer is a plan + * nobody can ever enter. + */ +function emittedKeys(expr: string): string[] { + return [...expr.matchAll(/THEN '([A-Z_]+)'/g)] + .map(([, key]) => key) + .concat([...expr.matchAll(/ELSE '([A-Z_]+)'/g)].map(([, key]) => key)); +} + +describe('operations classification', () => { + it('offers every cargo category the expression can emit as a filter option', () => { + const offered = new Set(CARGO_CATEGORIES.map((o) => o.value)); + const missing = [...new Set(emittedKeys(CARGO_CATEGORY_EXPR))].filter((k) => !offered.has(k)); + expect(missing).toEqual([]); + }); + + it('offers every container class the expression can emit', () => { + const offered = new Set(CONTAINER_CLASSES.map((o) => o.value)); + const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k)); + expect(missing).toEqual([]); + }); + + it('labels every category, leaving none showing a raw key', () => { + for (const option of CARGO_CATEGORIES) { + expect(CARGO_CATEGORY_LABEL_EXPR).toContain(`'${option.label}'`); + } + }); + + /** + * A planner types a dimension key into the targets screen; the reports match + * it against what their CASE emits. If the two lists ever drift, a target is + * silently ignored — the report shows no plan and nobody is told why. + */ + it('accepts every emitted key as a target dimension key', () => { + const emitted = [ + ...new Set([ + ...emittedKeys(CARGO_CATEGORY_EXPR), + ...emittedKeys(CONTAINER_CLASS_EXPR), + ]), + ]; + const unplannable = emitted.filter((k) => !TARGET_DIMENSION_KEYS.includes(k)); + expect(unplannable).toEqual([]); + }); + + it('keeps the target metric and dimension vocabularies non-empty and distinct', () => { + expect(new Set(TARGET_METRICS).size).toBe(TARGET_METRICS.length); + expect(new Set(TARGET_DIMENSIONS).size).toBe(TARGET_DIMENSIONS.length); + }); + + /** + * The spec's worked example: a full trainset holds 50 wagons, 30 of them + * carry multimodal cargo, so that cargo operated 0.6 trainsets. The SQL does + * this division; this checks the arithmetic the SQL encodes. + */ + it('matches the spec worked example for trainsets', () => { + expect(Number((30 / 50).toFixed(2))).toBe(0.6); + }); + + /** Ten 40ft boxes and thirty 20ft boxes is fifty TEU, not forty. */ + it('matches the spec worked example for TEU', () => { + expect(10 * 2 + 30 * 1).toBe(50); + }); + + it('divides by NULLIF so a missing plan yields no rate rather than infinity', () => { + expect(implementRateExpr('operated', 'planned')).toContain('NULLIF(planned, 0)'); + }); + + /** + * [(SC − AD) / SC + 1] × 100 — finishing exactly on standard scores 100, and + * beating it scores above 100. Guards the sign, which is easy to invert. + */ + it('encodes the turnaround rate so on-standard is 100 and faster is more', () => { + const rate = (sc: number, ad: number) => ((sc - ad) / sc + 1) * 100; + expect(rate(65, 65)).toBe(100); + expect(rate(65, 52)).toBeGreaterThan(100); + expect(rate(65, 78)).toBeLessThan(100); + expect(cycleRateExpr('ad', 'sc')).toContain('NULLIF(sc, 0)'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts new file mode 100644 index 000000000..ed39536b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -0,0 +1,594 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { OperationsStandard } from '../operations-reporting/entities/operations-standard.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; +import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types'; +import { resolvePeriod, yardOptions } from './revenue-classification'; + +/** + * The shared vocabulary and SQL behind every operations report — turnaround, + * delay, trainset, TEU and cargo volume. + * + * The fact table is `wagon_booking_allocations`: one row is one booking's cargo + * on one wagon of one departure. That is the marshalling record — what was + * actually put on the train — and it is the only grain that can answer both + * "how many TEU moved" and "how many wagons did it take", which the volume and + * trainset reports need together. + * + * Every consumer builds its FROM through {@link allocationLedgerQb}, so the + * table aliases below (`wba tsw ts b ct oy dy std`) are a fixed contract and + * the fragments here reference them directly. + * + * This is deliberately a SECOND classification module rather than an extension + * of `revenue-classification.ts`. That one classifies invoice lines by charge + * code; this one classifies physical cargo by booking and cargo type. The two + * answer different questions and a row that is one revenue category can be a + * different operational category — an incidental charge on a container booking, + * for instance, is INCIDENTAL revenue but container tonnage. + */ + +// --------------------------------------------------------------------------- +// Cargo categories +// --------------------------------------------------------------------------- + +export const CARGO_CATEGORIES: ReportFilterOption[] = [ + { value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Multimodal container import' }, + { value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Unimodal container import' }, + { value: 'CONTAINER_EXPORT', label: 'Export container' }, + { value: 'EMPTY_CONTAINER', label: 'Empty container' }, + { value: 'FERTILIZER', label: 'Fertilizer' }, + { value: 'RORO', label: 'RoRo' }, + { value: 'BREAK_BULK', label: 'Break bulk' }, + { value: 'SAND', label: 'Sand' }, + { value: 'BULK', label: 'Bulk' }, + { value: 'OTHER_IMPORT', label: 'Other imports' }, + { value: 'OTHER_EXPORT', label: 'Other export cargo' }, + { value: 'UNCLASSIFIED', label: 'Unclassified' }, +]; + +/** + * Container classes for the TEU report — the four the spec names. + * `EMPTY_CONTAINER_RETURN` is the empty re-export leg. + */ +export const CONTAINER_CLASSES: ReportFilterOption[] = [ + { value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Multimodal container import' }, + { value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Unimodal container import' }, + { value: 'CONTAINER_EXPORT', label: 'Full export container' }, + { value: 'EMPTY_CONTAINER_RETURN', label: 'Empty container return' }, +]; + +/** `cargo_types.code` is admin-managed, so each set absorbs every spelling seeded so far. */ +export const RORO_CODES = ['TRUCK', 'AUTOMOBILE', 'CARS', 'RORO']; +export const BREAK_BULK_CODES = [ + 'BREAK_BULK', + 'STEEL_BILLET', + 'STEEL', + 'MACHINERY', + 'PIPES', + 'TIMBER', +]; +export const FERTILIZER_CODES = ['FERTILIZER']; +export const SAND_CODES = ['SAND']; + +/** Cargo charged at the lighter per-wagon rate — vegetables, milk, meat, livestock. */ +export const PERISHABLE_CODES = ['PERISHABLE', 'LIVESTOCK']; + +const quote = (values: string[]): string => values.map((v) => `'${v}'`).join(', '); + +/** + * A booking whose equipment_return is RETURN is the empty-container movement + * itself; WITH_RETURN / WITHOUT_RETURN describe a laden booking's obligation. + * This is the only booking-level marker of an empty box — no table records + * laden-vs-empty on the container row. + */ +const IS_EMPTY_CONTAINER = "b.equipment_return = 'RETURN'"; + +/** + * Multimodal means a named sea carrier is on the booking — the same proxy the + * revenue reports use. There is no explicit multimodal flag; confirm with the + * business before treating this as definitive. + */ +const IS_MULTIMODAL = 'b.shipping_line_id IS NOT NULL'; + +const IS_CONTAINER = "COALESCE(b.freight_type, wba.load_type) = 'CONTAINER'"; + +export const CARGO_CATEGORY_EXPR = `CASE + WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER' + WHEN ${IS_CONTAINER} AND b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' + WHEN ${IS_CONTAINER} AND ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL' + WHEN ${IS_CONTAINER} THEN 'CONTAINER_IMPORT_UNIMODAL' + WHEN ct.code IN (${quote(FERTILIZER_CODES)}) THEN 'FERTILIZER' + WHEN ct.code IN (${quote(RORO_CODES)}) THEN 'RORO' + WHEN ct.code IN (${quote(BREAK_BULK_CODES)}) THEN 'BREAK_BULK' + WHEN ct.code IN (${quote(SAND_CODES)}) THEN 'SAND' + WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT' + WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT' + WHEN b.id IS NOT NULL THEN 'BULK' + ELSE 'UNCLASSIFIED' +END`; + +export const CONTAINER_CLASS_EXPR = `CASE + WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN' + WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' + WHEN ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL' + ELSE 'CONTAINER_IMPORT_UNIMODAL' +END`; + +/** + * Every fixed key a planner may enter on the targets screen — the category and + * container-class vocabularies. Station targets are keyed on a yard code, which + * is reference data rather than a fixed list, so they are not enumerated here. + */ +export const TARGET_DIMENSION_KEYS: string[] = [ + ...CARGO_CATEGORIES.map((o) => o.value), + ...CONTAINER_CLASSES.map((o) => o.value), +]; + +/** Turns a key-emitting CASE into a label-emitting one, so a report shows business names. */ +const labelCase = (keyExpr: string, options: ReportFilterOption[]): string => + `CASE ${options + .map((o) => `WHEN (${keyExpr}) = '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`) + .join(' ')} ELSE (${keyExpr}) END`; + +export const CARGO_CATEGORY_LABEL_EXPR = labelCase(CARGO_CATEGORY_EXPR, CARGO_CATEGORIES); +export const CONTAINER_CLASS_LABEL_EXPR = labelCase(CONTAINER_CLASS_EXPR, CONTAINER_CLASSES); + +/** + * The same labelling applied to a key that is already a column — for reports + * that classify in a subquery and label in the wrapper. + */ +export const CATEGORY_LABEL_OF = (keyExpr: string): string => + labelCase(keyExpr, CARGO_CATEGORIES); +export const CONTAINER_CLASS_LABEL_OF = (keyExpr: string): string => + labelCase(keyExpr, CONTAINER_CLASSES); + +// --------------------------------------------------------------------------- +// Standards +// --------------------------------------------------------------------------- + +/** + * A standard, read off the joined `operations_standards` row. + * + * The fallback is not decoration: the row is seeded by migration, but a report + * must not return zeros — or divide by zero — on an environment where the seed + * has not run. The fallbacks are the spec's own figures. + */ +const stdRow = (column: string, fallback: number): string => + `COALESCE(std.${column}, ${fallback})`; + +/** + * The same value in an aggregate select. `std` is a single joined row, so the + * column is constant across the group — but Postgres still demands it be + * grouped or aggregated, and wrapping it in MAX() is cheaper than dragging it + * through every report's GROUP BY. + */ +const stdAgg = (column: string, fallback: number): string => + `MAX(COALESCE(std.${column}, ${fallback}))`; + +/** Standard hours a train may stand at a station, by the station's country. */ +export const STATION_STANDARD_HOURS_EXPR = `CASE + WHEN y.country = 'Djibouti' THEN ${stdRow('station_standard_hours_djibouti', 13)} + ELSE ${stdRow('station_standard_hours_ethiopia', 10)} +END`; + +/** Whichever end of the corridor is on the Djibouti side, if either is. */ +export const DJIBOUTI_YARD_CODE_EXPR = `CASE + WHEN oy.country = 'Djibouti' THEN oy.code + WHEN dy.country = 'Djibouti' THEN dy.code +END`; + +/** True when the departure carried any container allocation. */ +export const SCHEDULE_IS_CONTAINER = `EXISTS ( + SELECT 1 FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons w ON w.id = a.train_set_wagon_id AND w.deleted_at IS NULL + WHERE w.train_set_id = ts.train_set_id + AND a.deleted_at IS NULL AND a.load_type = 'CONTAINER' +)`; + +/** + * Standard turn-around cycle for a departure, in hours. Container trains run + * the 65-hour cycle; a bulk cycle depends on which Djibouti terminal it works. + * Schedule grain — it reads `ts`, `oy` and `dy`, not the allocation aliases. + */ +export const CYCLE_STANDARD_HOURS_EXPR = `CASE + WHEN ${SCHEDULE_IS_CONTAINER} THEN ${stdRow('cycle_standard_hours_container', 65)} + WHEN ${DJIBOUTI_YARD_CODE_EXPR} = 'DORALEH_MULTIPURPOSE_PORT_DMP' THEN ${stdRow('cycle_standard_hours_bulk_dmp', 88)} + WHEN ${DJIBOUTI_YARD_CODE_EXPR} = 'BCC' THEN ${stdRow('cycle_standard_hours_bulk_bcc', 96)} + ELSE ${stdRow('cycle_standard_hours_bulk_nagad', 96)} +END`; + +export const DELAY_TOLERANCE_HOURS_EXPR = `(${stdRow('delay_tolerance_minutes', 30)} / 60.0)`; + +/** + * Joins the single standards row. Restricted by id to the earliest live row so + * a stray second row could never fan a report's result out. + */ +export const STANDARDS_JOIN = `std.id = ( + SELECT s.id FROM freight.operations_standards s + WHERE s.deleted_at IS NULL ORDER BY s.created_at ASC LIMIT 1 +)`; + +// --------------------------------------------------------------------------- +// Distance +// --------------------------------------------------------------------------- + +/** + * Configured rail distance for a yard pair, in km. Symmetric: `yard_distances` + * stores one row per pair and an A→B row governs B→A. + * + * Returns NULL when the pair is not configured, and every caller must let that + * null through rather than coalescing to zero — a missing distance is not a + * zero distance, and Ton/Km computed from one would understate silently. + */ +export const distanceKmBetween = (fromCol: string, toCol: string): string => `( + SELECT yd.distance_km FROM freight.yard_distances yd + WHERE yd.deleted_at IS NULL + AND ((yd.from_yard_id = ${fromCol} AND yd.to_yard_id = ${toCol}) + OR (yd.from_yard_id = ${toCol} AND yd.to_yard_id = ${fromCol})) + LIMIT 1 +)`; + +/** Standard running time for a leg, falling back to the default leg standard. */ +export const legStandardHours = (fromCol: string, toCol: string): string => `COALESCE(( + SELECT yd.standard_hours FROM freight.yard_distances yd + WHERE yd.deleted_at IS NULL + AND ((yd.from_yard_id = ${fromCol} AND yd.to_yard_id = ${toCol}) + OR (yd.from_yard_id = ${toCol} AND yd.to_yard_id = ${fromCol})) + LIMIT 1 +), ${stdRow('default_leg_standard_hours', 21)})`; + +/** The schedule's own corridor, origin to destination. */ +export const SCHEDULE_KM_EXPR = distanceKmBetween('ts.origin_station_id', 'ts.destination_station_id'); + +// --------------------------------------------------------------------------- +// Volume — TEU, charged and actual +// --------------------------------------------------------------------------- + +/** Per-allocation aggregate over its container items. */ +const containerItems = (selection: string): string => `( + SELECT ${selection} + FROM freight.wagon_allocation_container_items ci + LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id + WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL +)`; + +/** + * TEU for one allocation: a 40ft box is two twenty-foot equivalents, anything + * else one. + * + * Note this is the third TEU derivation in the codebase and the only one taken + * from the marshalling record. `revenue-classification.ts` derives TEU from the + * charge code's size suffix (billing truth, blind to unsized codes) and + * `wagon-teu-utilization.report.ts` from a wagon's currently pinned containers + * (live state). This one answers "what did we actually move", which is what the + * reporting spec asks for. + */ +export const ALLOC_TEU = containerItems( + 'COALESCE(SUM(CASE WHEN cty.size_ft >= 40 THEN 2 ELSE 1 END), 0)', +); +export const ALLOC_CONTAINERS_20 = containerItems('COUNT(*) FILTER (WHERE cty.size_ft = 20)'); +export const ALLOC_CONTAINERS_40 = containerItems('COUNT(*) FILTER (WHERE cty.size_ft >= 40)'); +export const ALLOC_CONTAINERS = containerItems('COUNT(*)'); + +export const TEU_EXPR = `COALESCE(SUM(${ALLOC_TEU}), 0)::int`; +export const CONTAINERS_EXPR = `COALESCE(SUM(${ALLOC_CONTAINERS}), 0)::int`; + +/** + * Actual volume — "loading capacity from marshalling" in the spec. + * `allocated_weight_tons` is what the allocation flow recorded onto the wagon, + * and is populated for every allocation in the system. + */ +export const ACTUAL_TONS_EXPR = 'COALESCE(SUM(wba.allocated_weight_tons), 0)::float8'; + +const IS_PERISHABLE = `COALESCE(ct.code, '') IN (${quote(PERISHABLE_CODES)})`; +const IS_BULK_LOAD = "wba.load_type <> 'CONTAINER'"; + +/** + * Charged volume — the standard weight capacity the spec bills against, not + * what was weighed. + * + * Containers are charged per box (20/40 tons laden, 2.24/3.88 empty). Bulk is + * charged per WAGON (70 tons, or 38 for perishables), so it counts distinct + * wagons rather than allocations: two bookings sharing one wagon are one + * wagon's charge, not two. + */ +export const CHARGED_TONS_EXPR = `( + COALESCE(SUM( + CASE WHEN ${IS_BULK_LOAD} THEN 0 ELSE + ${ALLOC_CONTAINERS_20} * CASE WHEN ${IS_EMPTY_CONTAINER} + THEN ${stdRow('charged_tons_empty_20ft', 2.24)} + ELSE ${stdRow('charged_tons_full_20ft', 20)} END + + ${ALLOC_CONTAINERS_40} * CASE WHEN ${IS_EMPTY_CONTAINER} + THEN ${stdRow('charged_tons_empty_40ft', 3.88)} + ELSE ${stdRow('charged_tons_full_40ft', 40)} END + END), 0) + + COUNT(DISTINCT tsw.id) FILTER (WHERE ${IS_BULK_LOAD} AND ${IS_PERISHABLE}) + * ${stdAgg('charged_tons_per_wagon_perishable', 38)} + + COUNT(DISTINCT tsw.id) FILTER (WHERE ${IS_BULK_LOAD} AND NOT ${IS_PERISHABLE}) + * ${stdAgg('charged_tons_per_wagon_general', 70)} +)::float8`; + +/** Wagons actually carrying cargo in the grouped set. */ +export const LOADED_WAGONS_EXPR = 'COUNT(DISTINCT tsw.id)::int'; + +/** + * Wagons on the departure with nothing allocated to them — the Vehicle-Km base. + * + * A train-level figure: it belongs to the departure, not to any one cargo type + * riding on it, so a report grouped finer than the schedule repeats it rather + * than splitting it. Callers that need a total must de-duplicate by schedule. + */ +export const SCHEDULE_EMPTY_WAGONS = `( + SELECT COUNT(*) FROM freight.train_set_wagons tw + WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.wagon_booking_allocations a + WHERE a.train_set_wagon_id = tw.id AND a.deleted_at IS NULL) +)`; + +/** + * Trainsets operated: wagons loaded divided by a full trainset for this cargo. + * Seven full multimodal trains plus 30 of a 50-wagon set reads 7.6 — the + * fraction the spec's worked example asks for. + */ +export const TRAINSETS_EXPR = `ROUND( + COUNT(DISTINCT tsw.id)::numeric + / NULLIF(MAX(COALESCE(ct.full_trainset_wagons, ${stdRow('default_full_trainset_wagons', 50)})), 0) +, 2)::float8`; + +// --------------------------------------------------------------------------- +// Rates +// --------------------------------------------------------------------------- + +/** + * Implement rate — operated against plan, as a percentage. + * + * NULL when there is no plan, never 100 and never 0: an unplanned period has no + * achievement to report, and coercing a missing plan to zero would read as + * infinite achievement. + */ +export const implementRateExpr = (operated: string, planned: string): string => + `ROUND(100 * (${operated})::numeric / NULLIF(${planned}, 0), 1)::float8`; + +/** + * Turn-around implement rate, the spec's own formula: + * `[((SC − AD) / SC) + 1] × 100`. Finishing exactly on standard scores 100; + * a cycle an hour quicker than a 65-hour standard scores ~101.5. + */ +export const cycleRateExpr = (actual: string, standard: string): string => + `ROUND((((${standard}) - (${actual})) / NULLIF(${standard}, 0) + 1) * 100, 1)::float8`; + +/** Hours between two timestamps, one decimal place. */ +export const hoursBetween = (from: string, to: string): string => + `ROUND(EXTRACT(EPOCH FROM ((${to}) - (${from})))::numeric / 3600, 1)::float8`; + +// --------------------------------------------------------------------------- +// Filters and the shared ledger +// --------------------------------------------------------------------------- + +/** + * The date every operations report buckets and filters on: when the train + * actually left, falling back to the plan for a departure not yet dispatched. + */ +export const OPS_DATE = 'COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)'; + +export const DIRECTION_FILTER: ReportFilterDef = { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], +}; + +export const COUNTRY_FILTER: ReportFilterDef = { + key: 'country', + label: 'Country', + type: 'select', + options: [ + { value: 'Ethiopia', label: 'Ethiopia' }, + { value: 'Djibouti', label: 'Djibouti' }, + ], +}; + +/** Shared by every operations report, so they read the same way side by side. */ +export const OPERATIONS_FILTERS: ReportFilterDef[] = [ + { key: 'date', label: 'Departure', type: 'daterange' }, + DIRECTION_FILTER, + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions }, + { key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions }, +]; + +export const CARGO_CATEGORY_FILTER: ReportFilterDef = { + key: 'categories', + label: 'Cargo category', + type: 'multiselect', + options: CARGO_CATEGORIES, +}; + +/** Schedule states that never represent an operated train. */ +const DEAD_SCHEDULE_STATUSES = ['DRAFT', 'CANCELLED']; + +/** + * Every operations report starts here: one wagon allocation, joined out to the + * departure that carried it and the booking that explains it. + * + * The booking is LEFT joined — a wagon can be allocated before its booking data + * is complete, and dropping those rows would understate wagon usage. + */ +export function allocationLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(WagonBookingAllocation, 'wba') + .innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL') + .innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL') + .leftJoin(Booking, 'b', 'b.id = wba.booking_id AND b.deleted_at IS NULL') + .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') + .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') + .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') + .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) + .where('wba.deleted_at IS NULL') + .andWhere('ts.status NOT IN (:...deadScheduleStatuses)', { + deadScheduleStatuses: DEAD_SCHEDULE_STATUSES, + }); + + applyOperationsFilters(qb, params); + applyDirectionScope(qb, 'COALESCE(b.trade_direction, ts.direction)', directions); + return qb; +} + +/** + * The schedule-grain query, for reports that measure trains rather than cargo — + * turnaround, delay, station stay. Same aliases, minus the allocation. + */ +export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(TrainSchedule, 'ts') + .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') + .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') + .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) + .where('ts.deleted_at IS NULL') + .andWhere('ts.status NOT IN (:...deadScheduleStatuses)', { + deadScheduleStatuses: DEAD_SCHEDULE_STATUSES, + }); + + applyOperationsFilters(qb, params); + applyDirectionScope(qb, 'ts.direction', directions); + return qb; +} + +export function applyOperationsFilters( + qb: SelectQueryBuilder, + params: Record, +): void { + if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + if (params.trainNumber) { + qb.andWhere('ts.train_number ILIKE :trainNumber', { + trainNumber: `%${params.trainNumber as string}%`, + }); + } + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination }); +} + +/** + * Restricts an allocation-grain query to a set of cargo categories. Kept + * separate from {@link applyOperationsFilters} because the schedule-grain + * query has no cargo to filter by. + */ +export function applyCategoryFilter( + qb: SelectQueryBuilder, + params: Record, +): void { + const categories = params.categories as string[] | null; + if (categories?.length) { + qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories }); + } +} + +/** + * The planned rows for a metric, as a derived table. + * + * A target is a rate over its own period, not a lump at its start: the plan is + * spread evenly across the days it covers, then re-gathered into the report's + * buckets. One rule covers every direction — three monthly targets add up to a + * quarter exactly, a daily view gets a thirty-first of the month, and a week + * straddling a month boundary draws proportionally on both months. + * + * The even spread is an assumption, and the only one available: a monthly + * figure carries no information about which days inside it were busier. + * + * The share is clipped to the user's date filter as well as to the bucket, so + * the plan always covers exactly the span the operated figure beside it covers. + * Without that, filtering to July and viewing by year would put a whole year's + * plan next to one month's work. + * + * The reports FULL OUTER JOIN this to their operated aggregate so a category + * that was planned but never ran still appears, at zero. The OCC monthly report + * does exactly that — Nagad–Dire Dawa is planned 2,106 t and operated none, and + * publishes as 0%. Dropping the row would hide a total miss, which is the one + * thing a plan-versus-actual table exists to show. + * + * Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind + * with {@link plannedRowsParams} — they come from the user's date filter. + */ +/** + * Appended to every plan-versus-actual report's description, because the + * re-bucketing rule is not guessable from the table. + */ +export const PLAN_GRANULARITY_NOTE = + ' A plan is spread evenly across its own period and re-gathered into whichever bucket ' + + 'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' + + 'or weekly view gets its share of it. A week that straddles two months draws on both.'; + +/** + * The user's date filter as open-ended bounds, so the clipping arithmetic below + * never has to branch on null. + */ +const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)"; +const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)"; + +export const plannedRowsSql = ( + metric: string, + dimension: string, + params: Record, +): string => { + const unit = resolvePeriod(params); + return ` + SELECT to_char(g.bucket, '${unit.fmt}') AS period, + ot.dimension_key AS plan_key, + ot.cargo_category AS plan_category, + SUM(ot.planned_value * ( + GREATEST(0, EXTRACT(EPOCH FROM ( + LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO}) + - GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM})))) + / NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0) + )) AS plan_value + FROM freight.operations_targets ot + CROSS JOIN LATERAL ( + SELECT ot.period_start + CASE ot.period_type + WHEN 'week' THEN INTERVAL '7 days' + WHEN 'month' THEN INTERVAL '1 month' + WHEN 'quarter' THEN INTERVAL '3 months' + WHEN 'year' THEN INTERVAL '1 year' + ELSE INTERVAL '1 day' + END AS ends + ) t + CROSS JOIN LATERAL generate_series( + date_trunc('${unit.trunc}', ot.period_start::timestamptz), + date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'), + INTERVAL '${unit.step}' + ) AS g(bucket) + WHERE ot.deleted_at IS NULL + AND ot.metric = '${metric}' + AND ot.dimension = '${dimension}' + AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM} + AND g.bucket < ${PLAN_TO} + GROUP BY 1, 2, 3 + HAVING SUM(ot.planned_value) > 0`; +}; + +/** The bindings {@link plannedRowsSql} expects. */ +export const plannedRowsParams = ( + params: Record, +): Record => ({ + planFrom: params.dateFrom ?? null, + planTo: params.dateTo ?? null, +}); + diff --git a/apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts b/apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts deleted file mode 100644 index dc6fa6230..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service'; -import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util'; -import { ReportColumn } from './report.types'; - -describe('resolveExportFormat', () => { - it('only \'pdf\' exports as pdf', () => { - expect(resolveExportFormat('pdf')).toBe('pdf'); - }); - - it.each([undefined, 'xlsx', 'csv', ''])('%p falls back to xlsx', (raw) => { - expect(resolveExportFormat(raw)).toBe('xlsx'); - }); -}); - -describe('resolveExportCap', () => { - it('missing limit uses the full format cap', () => { - expect(resolveExportCap('xlsx', undefined)).toBe(XLSX_ROW_CAP); - expect(resolveExportCap('pdf', undefined)).toBe(PDF_ROW_CAP); - }); - - it('a limit under the cap is used as-is', () => { - expect(resolveExportCap('pdf', '100')).toBe(100); - }); - - it('a limit over the cap is clamped down', () => { - expect(resolveExportCap('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP); - expect(resolveExportCap('xlsx', String(XLSX_ROW_CAP + 1))).toBe(XLSX_ROW_CAP); - }); - - it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p falls back to the cap', (raw) => { - expect(resolveExportCap('xlsx', raw)).toBe(XLSX_ROW_CAP); - }); -}); - -describe('resolveExportColumns', () => { - const columns: ReportColumn[] = [ - { key: 'a', label: 'A', type: 'string' }, - { key: 'b', label: 'B', type: 'number' }, - { key: 'c', label: 'C', type: 'money' }, - ]; - const def = { columns }; - - it('missing fields returns every column', () => { - expect(resolveExportColumns(def, undefined)).toEqual(columns); - }); - - it('empty fields string returns every column', () => { - expect(resolveExportColumns(def, '')).toEqual(columns); - }); - - it('a known subset filters to just those columns, in the report\'s own order', () => { - expect(resolveExportColumns(def, 'c,a')).toEqual([columns[0], columns[2]]); - }); - - it('unknown keys are dropped, not passed through', () => { - expect(resolveExportColumns(def, 'a,ghost')).toEqual([columns[0]]); - }); - - it('all-unknown keys falls back to every column instead of a blank sheet', () => { - expect(resolveExportColumns(def, 'ghost,also-ghost')).toEqual(columns); - }); -}); diff --git a/apps/edr-freight-api/src/modules/reports/report-export-request.util.ts b/apps/edr-freight-api/src/modules/reports/report-export-request.util.ts deleted file mode 100644 index 18f2fa322..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-export-request.util.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service'; -import { ReportColumn, ReportDefinition } from './report.types'; - -export type ExportFormat = 'xlsx' | 'pdf'; - -/** Anything but the literal string 'pdf' exports as xlsx. */ -export function resolveExportFormat(raw: string | undefined): ExportFormat { - return raw === 'pdf' ? 'pdf' : 'xlsx'; -} - -/** Caller's requested row limit, clamped to the format's hard cap. A - * missing/non-positive/non-numeric limit means "as many as the format allows". */ -export function resolveExportCap(format: ExportFormat, rawLimit: string | undefined): number { - const formatCap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP; - const requested = Number(rawLimit); - return requested > 0 ? Math.min(requested, formatCap) : formatCap; -} - -/** Caller's requested column subset, whitelisted against the report's own - * columns. Missing, empty, or all-unknown `rawFields` falls back to every - * column rather than shipping a blank sheet. */ -export function resolveExportColumns( - def: Pick, - rawFields: string | undefined, -): ReportColumn[] { - const requested = rawFields?.split(',').filter(Boolean); - const filtered = requested?.length ? def.columns.filter((c) => requested.includes(c.key)) : def.columns; - return filtered.length ? filtered : def.columns; -} diff --git a/apps/edr-freight-api/src/modules/reports/report-export.service.ts b/apps/edr-freight-api/src/modules/reports/report-export.service.ts deleted file mode 100644 index f0919c9c7..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-export.service.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import ExcelJS from 'exceljs'; - -import { PdfRenderService } from '../billing/documents/pdf-render.service'; -import { ReportColumn, ReportDefinition, ReportKpi } from './report.types'; - -// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming -// WorkbookWriter if a report ever needs to outgrow XLSX_ROW_CAP. -export const XLSX_ROW_CAP = 50_000; -// ponytail: HTML→PDF render cost grows with row count; larger exports must -// use XLSX instead. -export const PDF_ROW_CAP = 5_000; - -const NUMBER_FORMAT: Partial> = { - money: '#,##0.00', - tons: '#,##0.0', - percent: '0"%"', - number: '#,##0', -}; - -function formatCell(value: unknown, type: ReportColumn['type']): string { - if (value === null || value === undefined) return ''; - if (type === 'money' || type === 'number') { - return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 }); - } - if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`; - if (type === 'percent') return `${value}%`; - return String(value); -} - -@Injectable() -export class ReportExportService { - constructor(private readonly pdfRender: PdfRenderService) {} - - async toXlsx( - def: ReportDefinition, - rows: Record[], - kpis: ReportKpi[], - columns: ReportColumn[] = def.columns, - ): Promise { - const workbook = new ExcelJS.Workbook(); - const sheet = workbook.addWorksheet(def.title.slice(0, 31)); - - if (kpis.length) { - sheet.addRow(kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`)); - sheet.addRow([]); - } - - const headerRow = sheet.addRow(columns.map((c) => c.label)); - headerRow.font = { bold: true }; - - for (const row of rows) { - sheet.addRow(columns.map((c) => row[c.key] ?? null)); - } - - columns.forEach((col, i) => { - const format = NUMBER_FORMAT[col.type]; - const excelCol = sheet.getColumn(i + 1); - excelCol.width = Math.max(col.label.length + 2, 12); - if (format) excelCol.numFmt = format; - }); - - const buffer = await workbook.xlsx.writeBuffer(); - return Buffer.from(buffer); - } - - async toPdf( - def: ReportDefinition, - rows: Record[], - kpis: ReportKpi[], - columns: ReportColumn[] = def.columns, - ): Promise { - const html = this.buildHtml(def, rows, kpis, columns); - return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true }); - } - - private buildHtml( - def: ReportDefinition, - rows: Record[], - kpis: ReportKpi[], - columns: ReportColumn[], - ): string { - const esc = (v: unknown) => - String(v ?? '').replace(/&/g, '&').replace(//g, '>'); - - const kpiHtml = kpis.length - ? `
${kpis - .map( - (k) => - `
${esc(k.label)}
${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}
`, - ) - .join('')}
` - : ''; - - const head = columns.map((c) => `${esc(c.label)}`).join(''); - const body = rows - .map( - (row) => - `${columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, - ) - .join(''); - - return ` -

${esc(def.title)}

-

${esc(def.description)}

- ${kpiHtml} - ${head}${body}
- `; - } -} diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts index a9b662077..d38a5ba8a 100644 --- a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -122,12 +122,19 @@ export class ReportRunnerService { }; } - /** Same query, no paging — used by the export path. */ + /** + * Same query, no paging — used by the export path. + * + * `limit` is the caller's deliberate "first N" (the dialog's "Records: First + * 100"), honoured by truncating. `cap` is the format's hard ceiling, which + * throws instead. These used to be one number, which made "First 100" fail + * outright on any report with more than 100 rows. + */ async runAll( def: ReportDefinition, raw: RawReportQuery, directions: string[] | null, - limit: number, + { cap, limit }: { cap: number; limit?: number }, ): Promise<{ columns: typeof def.columns; items: Record[]; kpis: ReportRunResult['kpis'] }> { const params = coerceParams(def, raw); const ctx = { ds: this.ds, params, directions }; @@ -136,11 +143,19 @@ export class ReportRunnerService { // export is supposed to match what the user is looking at. const sort = resolveSort(def, raw.sortBy, raw.sortOrder); if (sort) qb.orderBy(sort.expr, sort.dir); - const items = await qb.limit(limit).getRawMany(); - if (items.length >= limit) { - throw new BadRequestException( - `Export exceeds the ${limit}-row cap for this format. Narrow the filters.`, - ); + + const ceiling = limit ?? cap; + // ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are + // exactly that many rows" from "there are more" — which is why the old + // `>= limit` check rejected a legitimate export of exactly the cap. + const items = await qb.limit(ceiling + 1).getRawMany(); + if (items.length > ceiling) { + if (limit === undefined) { + throw new BadRequestException( + `Export exceeds the ${cap}-row cap for this format. Narrow the filters.`, + ); + } + items.length = limit; } const kpis = def.summary ? await def.summary(ctx) : []; return { columns: def.columns, items, kpis }; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index 004b61e5b..fedc8a6dd 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -22,6 +22,23 @@ import { invoicesByStatusReport } from './definitions/invoices-by-status.report' import { paymentsByStatusReport } from './definitions/payments-by-status.report'; import { revenueSummaryReport } from './definitions/revenue-summary.report'; import { cargoSummaryReport } from './definitions/cargo-summary.report'; +import { revenueByCategoryReport } from './definitions/revenue-by-category.report'; +import { revenueTransactionsReport } from './definitions/revenue-transactions.report'; +import { revenueByPeriodReport } from './definitions/revenue-by-period.report'; +import { revenueByRouteReport } from './definitions/revenue-by-route.report'; +import { revenueTopCustomersReport } from './definitions/revenue-top-customers.report'; +import { paymentClassificationReport } from './definitions/payment-classification.report'; +import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report'; +import { receivablesPayablesReport } from './definitions/receivables-payables.report'; +import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report'; +import { stationStayingTimeReport } from './definitions/station-staying-time.report'; +import { turnaroundCycleReport } from './definitions/turnaround-cycle.report'; +import { trainDelaysReport } from './definitions/train-delays.report'; +import { trainsetPerformanceReport } from './definitions/trainset-performance.report'; +import { teuPerformanceReport } from './definitions/teu-performance.report'; +import { cargoVolumePerformanceReport } from './definitions/cargo-volume-performance.report'; +import { chargedVsActualVolumeReport } from './definitions/charged-vs-actual-volume.report'; +import { cargoVolumeByStationReport } from './definitions/cargo-volume-by-station.report'; import { ReportDefinition } from './report.types'; /** @@ -53,6 +70,23 @@ export const REPORTS: ReportDefinition[] = [ paymentsByStatusReport, revenueSummaryReport, cargoSummaryReport, + revenueByCategoryReport, + revenueTransactionsReport, + revenueByPeriodReport, + revenueByRouteReport, + revenueTopCustomersReport, + paymentClassificationReport, + revenueReconciliationReport, + receivablesPayablesReport, + revenueAnomaliesReport, + stationStayingTimeReport, + turnaroundCycleReport, + trainDelaysReport, + trainsetPerformanceReport, + teuPerformanceReport, + cargoVolumePerformanceReport, + chargedVsActualVolumeReport, + cargoVolumeByStationReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts index a709ac654..ca7578461 100644 --- a/apps/edr-freight-api/src/modules/reports/report.types.ts +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -34,6 +34,24 @@ export interface ReportFilterDef { type: ReportFilterType; /** Static option list for select/multiselect. */ options?: ReportFilterOption[]; + /** + * Resolves the option list from the database instead of declaring it inline — + * for filters whose choices are reference data (stations, cargo types). + * Called once per catalog request and cached; the result is serialised into + * `options`, so the frontend never sees the difference. + */ + optionsQuery?: (ds: DataSource) => Promise; +} + +/** + * Makes a summary row clickable: the row's values are carried into another + * report as filter params, which is how "drill down from summary to + * transaction level" works. Keys are this report's column keys; values are the + * target report's filter keys. + */ +export interface ReportDrillDef { + to: ReportKey; + carry: Record; } export interface ReportKpi { @@ -90,6 +108,8 @@ export interface ReportDefinition { summary?(ctx: ReportContext): Promise; /** Optional chart view of the same rows. Table remains the default view. */ chart?: ReportChartDef; + /** Makes rows clickable, navigating to a transaction-level report. */ + drill?: ReportDrillDef; } /** Catalog shape served by GET /reports — metadata only, no rows. */ diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index 4d213bc2b..774c992fb 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { CurrentUser } from '@edr/api-common'; import type { Response } from 'express'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; @@ -8,17 +10,54 @@ import { BookingStaff } from '../../common/booking-guards'; import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { ReportExportService } from './report-export.service'; -import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util'; +import { + EXPORT_MIME, + formatRowCap, + pickByKey, + resolveExportFormat, + resolveRowLimit, +} from '../exports/export-request.util'; +import { TabularExportService } from '../exports/tabular-export.service'; import { RawReportQuery, ReportRunnerService } from './report-runner.service'; import { REPORTS, getReport } from './report.registry'; -import { ReportCatalogEntry, ReportDefinition } from './report.types'; +import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types'; const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => { const { query: _query, summary, ...meta } = def; return { ...meta, hasSummary: Boolean(summary) }; }; +/** + * Filters whose choices are reference data resolve their options here rather + * than declaring them inline, so the catalog the frontend receives looks the + * same either way. Cached for the process lifetime — these are small, rarely + * changing lists (23 stations, 18 cargo types), and the catalog is hit on + * every page load. + */ +const optionsCache = new Map(); + +async function resolveFilterOptions( + def: ReportCatalogEntry, + ds: DataSource, +): Promise { + if (!def.filters.some((f) => f.optionsQuery)) return def; + + const filters = await Promise.all( + def.filters.map(async (filter) => { + if (!filter.optionsQuery) return filter; + let options = optionsCache.get(filter.key); + if (!options) { + options = await filter.optionsQuery(ds); + optionsCache.set(filter.key, options); + } + // Drop the resolver itself — it is a function and would not serialise. + const { optionsQuery: _resolver, ...rest } = filter; + return { ...rest, options }; + }), + ); + return { ...def, filters }; +} + @ApiTags('Reports') @ApiBearerAuth() @Controller('reports') @@ -26,16 +65,18 @@ const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => { export class ReportsController { constructor( private readonly runner: ReportRunnerService, - private readonly exportService: ReportExportService, + private readonly exportService: TabularExportService, private readonly userTradeAccessService: UserTradeAccessService, + @InjectDataSource() private readonly dataSource: DataSource, ) {} @Get() @ApiOperation({ summary: 'List reports the caller has permission to run' }) async catalog(@CurrentUser() user: TCurrentUser): Promise { - return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map( - toCatalogEntry, - ); + const allowed = REPORTS.filter((def) => + hasFreightPermission(user, reportPermissionKey(def.key)), + ).map(toCatalogEntry); + return Promise.all(allowed.map((def) => resolveFilterOptions(def, this.dataSource))); } @Get(':key') @@ -51,7 +92,7 @@ export class ReportsController { } @Get(':key/export') - @ApiOperation({ summary: 'Export a report to xlsx or pdf' }) + @ApiOperation({ summary: 'Export a report to xlsx, csv or pdf' }) async export( @Param('key') key: string, @Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string }, @@ -61,23 +102,30 @@ export class ReportsController { const def = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const format = resolveExportFormat(query.format); - const cap = resolveExportCap(format, query.limit); - const exportColumns = resolveExportColumns(def, query.fields); + const exportColumns = pickByKey(def.columns, query.fields); - const { items, kpis } = await this.runner.runAll(def, query, directions, cap); + const { items, kpis } = await this.runner.runAll(def, query, directions, { + cap: formatRowCap(format), + limit: resolveRowLimit(format, query.limit), + }); + const doc = { + title: def.title, + description: def.description, + label: `report:${def.key}`, + columns: exportColumns, + rows: items, + kpis, + }; const buffer = format === 'pdf' - ? await this.exportService.toPdf(def, items, kpis, exportColumns) - : await this.exportService.toXlsx(def, items, kpis, exportColumns); + ? await this.exportService.toPdf(doc) + : format === 'csv' + ? await this.exportService.toCsv(doc) + : await this.exportService.toXlsx(doc); - const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`; - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); - res.setHeader( - 'Content-Type', - format === 'pdf' - ? 'application/pdf' - : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ); + const mime = EXPORT_MIME[format]; + res.setHeader('Content-Disposition', `attachment; filename="${def.key}.${mime.ext}"`); + res.setHeader('Content-Type', mime.type); res.send(buffer); } diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts index 2f98e9e04..e60f16362 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.module.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -1,14 +1,15 @@ import { Module } from '@nestjs/common'; -import { DocumentsModule } from '../billing/documents/documents.module'; +import { ExportsModule } from '../exports/exports.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; -import { ReportExportService } from './report-export.service'; import { ReportRunnerService } from './report-runner.service'; import { ReportsController } from './reports.controller'; @Module({ - imports: [UserTradeAccessModule, DocumentsModule], + // ExportsModule provides the shared tabular writer (xlsx/csv/pdf) and pulls + // DocumentsModule in for the PDF renderer. + imports: [UserTradeAccessModule, ExportsModule], controllers: [ReportsController], - providers: [ReportRunnerService, ReportExportService], + providers: [ReportRunnerService], }) export class ReportsModule {} diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts new file mode 100644 index 000000000..42475d591 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts @@ -0,0 +1,102 @@ +import { + BULK_FREIGHT_CHARGES, + PAYMENT_CLASSES, + PAYMENT_CLASS_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORIES, + REVENUE_CATEGORY_EXPR, + periodExpr, +} from './revenue-classification'; + +/** + * `invoice_lines.charge_type` is an unconstrained varchar written by eight + * unrelated code paths. Nothing at the type level stops someone adding a ninth + * spelling, whose revenue would then land silently in the ELSE arm. + * + * This list is every value the codebase writes today. When it grows, these + * tests are what fail — which is the whole trade the const-map design makes. + */ +const KNOWN_CHARGE_TYPES = [ + // booking base freight (rate_type codes) + 'CONTAINER_IMPORT', 'CONTAINER_EXPORT', 'CONTAINER_20FT', 'CONTAINER_40FT', + 'BULK_IMPORT', 'BULK_EXPORT', 'INTERCITY_BULK', 'INTERCITY_CONTAINER', 'FREIGHT', + // surcharges + 'FUEL_SURCHARGE', 'LASHING', 'OVERWEIGHT_PER_TON', 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', 'PIL_EXTRA_FEE', 'RETURN_SURCHARGE', 'RETURN_SURCHARGE_20FT', + 'RETURN_SURCHARGE_40FT', 'CONTAINER_WITH_RETURN', 'ADJUSTMENT', 'RATE_ADJUSTMENT', + // customs + 'CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE_20FT', 'CUSTOMS_CLEARANCE_40FT', + // mile legs + 'FIRST_MILE', 'LAST_MILE', 'DELIVERY', 'LAST_MILE_ADVANCE', + // warehouse fees + 'CONTAINER_DEMURRAGE', 'BULK_DEMURRAGE', 'DEMURRAGE', 'STORAGE_FEE', + 'HANDLING_FEE', 'DOUBLE_HANDLING', 'TRUCK_DETENTION', + // other producers + 'CANCELLATION_FEE', 'SHIPPING_LINE_SERVICE', +]; + +/** + * Does the expression name this charge type — either as a literal or through + * one of its `LIKE 'PREFIX%'` arms? + * + * Deliberately a substring check, not a SQL parser: a parser would be more + * fragile than the expression it is guarding. This catches the failure that + * actually happens (a new charge type nobody added to the map) and nothing + * pretends it verifies the branch order. + */ +function isNamed(expr: string, chargeType: string): boolean { + if (expr.includes(`'${chargeType}'`)) return true; + return [...expr.matchAll(/LIKE '([^']*)%'/g)].some(([, prefix]) => + chargeType.startsWith(prefix), + ); +} + +describe('revenue classification', () => { + it('names every charge type the codebase writes in the payment-class map', () => { + const unmapped = KNOWN_CHARGE_TYPES.filter((c) => !isNamed(PAYMENT_CLASS_EXPR, c)); + expect(unmapped).toEqual([]); + }); + + it('names every ancillary charge type in the revenue-category map', () => { + // Bulk freight lines carry no category of their own — the CASE falls + // through to the booking's cargo type and trade direction for those. + const cargoDerived = new Set(BULK_FREIGHT_CHARGES); + const unmapped = KNOWN_CHARGE_TYPES.filter( + (c) => !cargoDerived.has(c) && !isNamed(REVENUE_CATEGORY_EXPR, c), + ); + expect(unmapped).toEqual([]); + }); + + it('emits only categories that are offered as filter options', () => { + const declared = new Set(REVENUE_CATEGORIES.map((c) => c.value)); + const emitted = [...REVENUE_CATEGORY_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]); + expect(emitted.length).toBeGreaterThan(0); + expect(emitted.filter((c) => !declared.has(c))).toEqual([]); + expect(declared.has('UNCLASSIFIED')).toBe(true); + }); + + it('emits only payment classes that are offered as filter options', () => { + const declared = new Set(PAYMENT_CLASSES.map((c) => c.value)); + const emitted = [...PAYMENT_CLASS_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]); + expect(emitted.filter((c) => !declared.has(c))).toEqual([]); + expect(declared.has('ADDITIONAL')).toBe(true); + }); + + it('falls back to a whitelisted period unit instead of interpolating input', () => { + expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'"); + expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'"); + // Anything unrecognised — including an injection attempt — becomes 'month'. + expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain( + "date_trunc('month'", + ); + expect(periodExpr({})).toContain("date_trunc('month'"); + }); + + it('offers exactly the period units the expression understands', () => { + const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value); + expect(offered.length).toBe(5); + for (const unit of offered) { + expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts new file mode 100644 index 000000000..526e35add --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -0,0 +1,565 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { Company } from '../companies/entities/company.entity'; +import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util'; +import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types'; + +/** + * The shared vocabulary and SQL behind every revenue report. + * + * The fact table is `invoice_lines`, not `bookings`: `charge_type` is the only + * column in the system that separates customs, first/last mile, demurrage, + * storage and incidental revenue from base freight. A booking total is one + * lump sum and cannot answer the revenue-classification requirement. + * + * Every consumer builds its FROM through {@link revenueLedgerQb}, so the table + * aliases below (`il i b ct oy dy co slc`) are a fixed contract and the SQL + * fragments here can reference them directly. + */ + +// --------------------------------------------------------------------------- +// Revenue categories +// --------------------------------------------------------------------------- + +export const REVENUE_CATEGORIES: ReportFilterOption[] = [ + { value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Full Container Import — Multimodal' }, + { value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Full Container Import — Unimodal' }, + { value: 'CONTAINER_EXPORT', label: 'Full Container Export' }, + { value: 'EMPTY_CONTAINER_REEXPORT', label: 'Empty Container Re-export' }, + { value: 'FERTILIZER', label: 'Fertilizer Transportation' }, + { value: 'BREAK_BULK', label: 'Break Bulk (Steel, Machineries)' }, + { value: 'RORO', label: 'RoRo Transportation' }, + { value: 'OTHER_IMPORT_BULK', label: 'Other Import Bulk Cargo' }, + { value: 'OTHER_EXPORT_CARGO', label: 'Other Export Cargo' }, + { value: 'DOMESTIC', label: 'Domestic Cargo Transportation' }, + { value: 'INCIDENTAL', label: 'Incidental Charges' }, + { value: 'FIRST_LAST_MILE', label: 'First & Last Mile' }, + { value: 'CUSTOMS_CLEARANCE', label: 'Customs Clearance' }, + { value: 'UNCLASSIFIED', label: 'Unclassified' }, +]; + +/** + * `charge_type` is an unconstrained varchar written by eight different code + * paths, so the same concept arrives under several spellings — three for + * demurrage, four for first/last mile. Every set below absorbs all of them. + */ +export const MILE_CHARGES = ['FIRST_MILE', 'LAST_MILE', 'DELIVERY', 'LAST_MILE_ADVANCE']; + +export const INCIDENTAL_CHARGES = [ + 'FUEL_SURCHARGE', + 'LASHING', + 'OVERWEIGHT_PER_TON', + 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', + 'PIL_EXTRA_FEE', + 'CANCELLATION_FEE', + 'ADJUSTMENT', + 'RATE_ADJUSTMENT', + 'DEMURRAGE', + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'TRUCK_DETENTION', + 'STORAGE_FEE', + 'HANDLING_FEE', + 'DOUBLE_HANDLING', + 'SHIPPING_LINE_SERVICE', +]; + +export const DOMESTIC_CHARGES = ['INTERCITY_BULK', 'INTERCITY_CONTAINER']; + +export const CONTAINER_FREIGHT_CHARGES = [ + 'CONTAINER_IMPORT', + 'CONTAINER_EXPORT', + 'CONTAINER_20FT', + 'CONTAINER_40FT', +]; + +export const BULK_FREIGHT_CHARGES = ['BULK_IMPORT', 'BULK_EXPORT', 'FREIGHT']; + +/** Cargo codes the business bills as break bulk, wherever the cargo tree puts them. */ +export const BREAK_BULK_CODES = ['STEEL_BILLET', 'STEEL', 'MACHINERY', 'PIPES', 'TIMBER']; + +export const RORO_CODES = ['TRUCK', 'AUTOMOBILE', 'CARS', 'RORO']; + +export const FERTILIZER_CODES = ['FERTILIZER']; + +/** + * Multimodal means EDR carried the sea leg as well as the rail leg. Nothing in + * the schema says so directly; a named sea carrier on the booking is the + * agreed proxy. One constant, deliberately — flip it here if the business + * defines multimodality differently. + */ +const MULTIMODAL_PREDICATE = 'b.shipping_line_id IS NOT NULL'; + +const list = (values: string[]): string => values.map((v) => `'${v}'`).join(', '); + +/** + * Assigns each invoice line exactly one revenue category. First match wins. + * + * Charge-derived rules run BEFORE cargo-derived ones on purpose: a customs or + * demurrage line billed on a container-import booking is customs/incidental + * revenue, not container-import revenue. Reversing the order would fold every + * ancillary charge back into the freight categories. + * + * Nothing falls through silently — an unmatched line lands in UNCLASSIFIED and + * every report surfaces that total as a KPI, because an audit report must + * never quietly drop money. + */ +export const REVENUE_CATEGORY_EXPR = `CASE + WHEN il.charge_type LIKE 'CUSTOMS_CLEARANCE%' THEN 'CUSTOMS_CLEARANCE' + WHEN il.charge_type IN (${list(MILE_CHARGES)}) THEN 'FIRST_LAST_MILE' + WHEN il.charge_type LIKE 'RETURN_SURCHARGE%' + OR il.charge_type = 'CONTAINER_WITH_RETURN' THEN 'EMPTY_CONTAINER_REEXPORT' + WHEN il.charge_type IN (${list(INCIDENTAL_CHARGES)}) THEN 'INCIDENTAL' + WHEN il.charge_type IN (${list(DOMESTIC_CHARGES)}) + OR (oy.country IS NOT NULL AND oy.country = dy.country) THEN 'DOMESTIC' + WHEN il.charge_type IN (${list(CONTAINER_FREIGHT_CHARGES)}) + OR b.freight_type = 'CONTAINER' THEN + CASE + WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' + WHEN ${MULTIMODAL_PREDICATE} THEN 'CONTAINER_IMPORT_MULTIMODAL' + ELSE 'CONTAINER_IMPORT_UNIMODAL' + END + WHEN ct.code IN (${list(FERTILIZER_CODES)}) THEN 'FERTILIZER' + WHEN ct.code IN (${list(BREAK_BULK_CODES)}) THEN 'BREAK_BULK' + WHEN ct.code IN (${list(RORO_CODES)}) THEN 'RORO' + WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT_CARGO' + WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT_BULK' + ELSE 'UNCLASSIFIED' +END`; + +const labelCase = (expr: string, options: ReportFilterOption[]): string => + `CASE ${expr}\n ${options + .map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`) + .join('\n ')}\nEND`; + +/** The category as a business label rather than its key, for display columns. */ +export const CATEGORY_LABEL_EXPR = labelCase(REVENUE_CATEGORY_EXPR, REVENUE_CATEGORIES); + +/** + * Period-over-period change, as a percentage. + * + * The denominator is `ABS(prior)`, not `prior`. A category can post negative + * revenue in a period — a credit note or rate adjustment outweighing its + * charges — and dividing by a negative prior flips the sign, reporting a + * recovery as a decline. Taking the magnitude keeps the sign of the change + * itself. + */ +export const growthPctExpr = (revenue: string, prior: string): string => + `ROUND(100 * (${revenue} - ${prior}) / NULLIF(ABS(${prior}), 0), 1)::float8`; + +// --------------------------------------------------------------------------- +// Payment classification +// --------------------------------------------------------------------------- + +export const PAYMENT_CLASSES: ReportFilterOption[] = [ + { value: 'RAIL_TRANSPORT', label: 'Rail transport' }, + { value: 'CUSTOMS_CLEARANCE', label: 'Custom clearance' }, + { value: 'FIRST_LAST_MILE', label: 'First and last mile' }, + { value: 'OVERWEIGHT', label: 'Overweight' }, + { value: 'CANCELLATION', label: 'Cancellation' }, + { value: 'DEMURRAGE', label: 'Demurrage' }, + { value: 'STORAGE', label: 'Storage' }, + { value: 'LOADING_UNLOADING', label: 'Loading and unloading' }, + { value: 'ADDITIONAL', label: 'Additional payment' }, +]; + +const RAIL_CHARGES = [...CONTAINER_FREIGHT_CHARGES, ...BULK_FREIGHT_CHARGES, ...DOMESTIC_CHARGES]; + +const DEMURRAGE_CHARGES = ['DEMURRAGE', 'CONTAINER_DEMURRAGE', 'BULK_DEMURRAGE', 'TRUCK_DETENTION']; + +/** + * Charges that legitimately belong in the spec's "additional payment" bucket. + * + * Listed explicitly rather than left to the ELSE arm: ELSE also catches charge + * types nobody has mapped yet, and those two cases must not be + * indistinguishable. Naming these is what lets the spec fail when a genuinely + * new charge type appears. + */ +export const ADDITIONAL_CHARGES = [ + 'FUEL_SURCHARGE', + 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', + 'PIL_EXTRA_FEE', + 'RETURN_SURCHARGE', + 'RETURN_SURCHARGE_20FT', + 'RETURN_SURCHARGE_40FT', + 'CONTAINER_WITH_RETURN', + 'ADJUSTMENT', + 'RATE_ADJUSTMENT', + 'SHIPPING_LINE_SERVICE', +]; + +/** + * The nine buckets the revenue spec asks payments to be classified into. + * + * Caveat worth repeating wherever this is shown: there is no dedicated + * loading/unloading charge type in the system. HANDLING_FEE, DOUBLE_HANDLING + * and LASHING are the nearest equivalent, so that bucket is an approximation, + * not an exact match. + */ +export const PAYMENT_CLASS_EXPR = `CASE + WHEN il.charge_type LIKE 'CUSTOMS_CLEARANCE%' THEN 'CUSTOMS_CLEARANCE' + WHEN il.charge_type IN (${list(MILE_CHARGES)}) THEN 'FIRST_LAST_MILE' + WHEN il.charge_type IN (${list(RAIL_CHARGES)}) THEN 'RAIL_TRANSPORT' + WHEN il.charge_type = 'OVERWEIGHT_PER_TON' THEN 'OVERWEIGHT' + WHEN il.charge_type = 'CANCELLATION_FEE' THEN 'CANCELLATION' + WHEN il.charge_type IN (${list(DEMURRAGE_CHARGES)}) THEN 'DEMURRAGE' + WHEN il.charge_type = 'STORAGE_FEE' THEN 'STORAGE' + WHEN il.charge_type IN ('HANDLING_FEE', 'DOUBLE_HANDLING', 'LASHING') + THEN 'LOADING_UNLOADING' + WHEN il.charge_type IN (${list(ADDITIONAL_CHARGES)}) THEN 'ADDITIONAL' + ELSE 'ADDITIONAL' +END`; + +// --------------------------------------------------------------------------- +// Period granularity +// --------------------------------------------------------------------------- + +/** + * Frozen whitelist. The runner coerces a `select` filter to a trimmed string + * or null; that string is used only as an object key here, so the user's value + * never reaches SQL — one of five compile-time constants does. + * + * Every format is zero-padded, so lexicographic order equals chronological + * order. The growth window depends on that. + */ +const PERIOD_UNITS = { + day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' }, + week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' }, + month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' }, + // `quarter` is a valid date_trunc unit but NOT a valid interval unit — + // INTERVAL '1 quarter' is a syntax error, so the step is spelled in months. + quarter: { trunc: 'quarter', fmt: 'YYYY-"Q"Q', label: 'Quarterly', step: '3 months' }, + year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' }, +} as const; + +export const PERIOD_FILTER: ReportFilterDef = { + key: 'period', + label: 'Granularity', + type: 'select', + options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({ value, label: u.label })), +}; + +/** The timestamp every revenue report buckets and filters on. */ +export const REVENUE_DATE = 'COALESCE(i.issued_at, i.created_at)'; + +/** + * The period label expression, as a string. + * + * Callers must reuse the returned string VERBATIM in the select, the GROUP BY + * and any window `ORDER BY`. Two traps make this non-negotiable: + * + * 1. A window `ORDER BY date_trunc(...)` when the group key is `to_char(date_trunc(...))` + * fails with "column i.issued_at must appear in the GROUP BY clause". + * 2. Ordinal shorthand — `OVER (PARTITION BY 2 ORDER BY 1)` — is NOT a + * positional reference inside a window clause. Postgres reads the integers + * as constants, so it partitions by a constant and applies no ordering. It + * type-checks, it EXPLAINs clean, and it returns plausible garbage. + */ +export function periodExpr(params: Record): string { + return periodExprOn(REVENUE_DATE, params); +} + +export function resolvePeriod( + params: Record, +): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { + const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS; + return PERIOD_UNITS[key] ?? PERIOD_UNITS.month; +} + +/** + * The same bucketing over any timestamp column. Revenue buckets on the invoice + * date; the operations reports bucket on a train's actual departure, and share + * these units so a month means the same thing on both sides of the product. + */ +export const periodExprOn = (dateExpr: string, params: Record): string => + `to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`; + +export const periodTruncExprOn = (dateExpr: string, params: Record): string => + `date_trunc('${resolvePeriod(params).trunc}', ${dateExpr})`; + +/** The period's start timestamp — what to GROUP BY when a report needs it numerically. */ +export const periodTruncExpr = (params: Record): string => + periodTruncExprOn(REVENUE_DATE, params); + +/** + * The period as a number, for regression: seconds since epoch at the period's + * start. Using the timestamp itself rather than `row_number()` keeps a trend + * calculation to a single window level — Postgres rejects a window function + * nested inside another window function's arguments. + */ +export const periodOrdinalExpr = (params: Record): string => + `EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`; + +/** Same scale, one period later — where a one-step-ahead projection lands. */ +export const nextPeriodOrdinalExpr = (params: Record): string => + `EXTRACT(EPOCH FROM ${periodTruncExpr(params)} + INTERVAL '${resolvePeriod(params).step}')`; + +// --------------------------------------------------------------------------- +// Volume — measured at line grain, never joined from the booking +// --------------------------------------------------------------------------- + +/** + * `invoice_lines.quantity` already carries the billed quantity per line, and + * `metadata->>'unit'` says what it counts (PER_TON / PER_CONTAINER / PER_WAGON). + * Joining booking-level tonnage instead would multiply it by the number of + * lines on the booking. + */ +export const TONS_EXPR = `SUM(il.quantity) FILTER (WHERE il.metadata->>'unit' = 'PER_TON')`; + +export const CONTAINERS_EXPR = `SUM(il.quantity) FILTER (WHERE il.metadata->>'unit' = 'PER_CONTAINER')`; + +/** + * TEU is never stored. It is derived from the charge code's size suffix; lines + * whose code carries no size (CONTAINER_IMPORT / CONTAINER_EXPORT) count as one + * TEU each, which under-counts any 40ft box billed under an unsized code. + */ +export const TEU_EXPR = `SUM(il.quantity * CASE WHEN il.charge_type LIKE '%40FT%' THEN 2 ELSE 1 END) + FILTER (WHERE il.metadata->>'unit' = 'PER_CONTAINER')`; + +/** + * Revenue per unit, against whichever unit the category is actually billed in. + * Exactly one of tons/TEU is non-null per category, so this is per-ton for bulk + * and per-TEU for containers; the `unit` column says which. + */ +export const AVG_PER_UNIT_EXPR = `ROUND( + SUM(il.amount) / NULLIF(COALESCE(${TONS_EXPR}, 0) + COALESCE(${TEU_EXPR}, 0), 0), 2 +)::float8`; + +export const UNIT_LABEL_EXPR = `CASE + WHEN COALESCE(${TONS_EXPR}, 0) > 0 THEN 'per ton' + WHEN COALESCE(${TEU_EXPR}, 0) > 0 THEN 'per TEU' + ELSE '' +END`; + +// --------------------------------------------------------------------------- +// The shared ledger query +// --------------------------------------------------------------------------- + +/** Invoice states that never represent recognised revenue. */ +const DEAD_INVOICE_STATUSES = ['DRAFT', 'CANCELLED']; + +export const PAYMENT_METHOD_OPTIONS: ReportFilterOption[] = [ + 'telebirr', + 'cbe-birr', + 'cbe-bill', + 'ebirr', + 'waafi', + 'dmoney', + 'cac-bank', + 'card', +].map((v) => ({ value: v, label: v })); + +export const CURRENCY_FILTER: ReportFilterDef = { + key: 'currency', + label: 'Currency', + type: 'select', + options: [ + { value: 'ETB', label: 'ETB' }, + { value: 'USD', label: 'USD' }, + ], +}; + +/** + * The filter set shared by every revenue report, so they drill into each other + * without losing context. + * + * `currency` is not optional decoration: the ledger holds both ETB and USD + * lines, and summing across them produces a number that means nothing. It + * defaults to ETB in {@link revenueLedgerQb} rather than being left blank. + */ +export const REVENUE_FILTERS: ReportFilterDef[] = [ + { key: 'date', label: 'Issued', type: 'daterange' }, + CURRENCY_FILTER, + { + key: 'categories', + label: 'Revenue category', + type: 'multiselect', + options: REVENUE_CATEGORIES, + }, + { key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions }, + { key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions }, + { key: 'customer', label: 'Customer / booking ref', type: 'text' }, + { + key: 'methods', + label: 'Payment method', + type: 'multiselect', + options: PAYMENT_METHOD_OPTIONS, + }, +]; + +/** Stations are reference data — 23 rows that change about yearly. */ +export async function yardOptions(ds: ReportContext['ds']): Promise { + return ds + .createQueryBuilder() + .from(Yard, 'y') + .select('y.code', 'value') + .addSelect('y.label', 'label') + .where('y.deleted_at IS NULL AND y.is_active') + .orderBy('y.display_order', 'ASC') + .getRawMany(); +} + +/** Currency the ledger reports in when the caller does not choose one. */ +export const DEFAULT_CURRENCY = 'ETB'; + +export const currencyOf = (params: Record): string => + (params.currency as string) || DEFAULT_CURRENCY; + +/** + * Every revenue report starts here: one invoice line joined out to the booking + * that explains it. Bookings are LEFT joined on purpose — warehouse, demurrage + * and shipping-line-credit invoices carry no booking and must still be counted. + * + * The booking join is `i.source_id = b.id::text`, never `i.source_id::uuid`: + * `source_id` is a varchar with no FK that holds non-UUID values for other + * sources (`eims-self-test-…`), so casting it would throw at runtime. + */ +export function revenueLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(InvoiceLine, 'il') + .innerJoin(Invoice, 'i', 'i.id = il.invoice_id AND i.deleted_at IS NULL') + .leftJoin( + Booking, + 'b', + "i.source = 'booking' AND i.source_id = b.id::text AND b.deleted_at IS NULL", + ) + .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = i.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id') + .where('il.deleted_at IS NULL') + .andWhere('i.status NOT IN (:...deadInvoiceStatuses)', { + deadInvoiceStatuses: DEAD_INVOICE_STATUSES, + }) + .andWhere("i.source <> 'eims_self_test'") + // An umbrella general contract is paid once and drawn down by many orders; + // counting both double-counts its value. + .andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')") + // Mixing ETB and USD into one SUM produces a meaningless number. + .andWhere('il.currency = :currency', { currency: currencyOf(params) }); + + if (params.dateFrom) { + qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + } + if (params.dateTo) { + qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo }); + } + + const categories = params.categories as string[] | null; + if (categories?.length) { + qb.andWhere(`${REVENUE_CATEGORY_EXPR} IN (:...categories)`, { categories }); + } + + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) { + qb.andWhere('dy.code = :destination', { destination: params.destination }); + } + + if (params.customer) { + qb.andWhere( + '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', + { customer: `%${params.customer as string}%` }, + ); + } + + const methods = params.methods as string[] | null; + if (methods?.length) { + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.payments p + WHERE p.ref_id = i.source_id AND p.status = 'success' + AND p.method IN (:...methods))`, + { methods }, + ); + } + + // Hides lines whose booking sits outside the caller's trade scope. Lines with + // no booking carry no direction and stay visible. + applyBookingRefDirectionScope(qb, 'i.source_id', directions); + + return qb; +} + +/** + * Invoice-grain sibling of {@link revenueLedgerQb}, for the reports that must + * not multiply an invoice by its line count — outstanding balance, + * reconciliation, receivable/payable. Same joins, same filters, minus the + * line-only ones (charge category, currency lives on the invoice here). + */ +export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(Invoice, 'i') + .leftJoin( + Booking, + 'b', + "i.source = 'booking' AND i.source_id = b.id::text AND b.deleted_at IS NULL", + ) + .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = i.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id') + .where('i.deleted_at IS NULL') + .andWhere('i.status NOT IN (:...deadInvoiceStatuses)', { + deadInvoiceStatuses: DEAD_INVOICE_STATUSES, + }) + .andWhere("i.source <> 'eims_self_test'") + .andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')") + .andWhere('i.currency = :currency', { currency: currencyOf(params) }); + + if (params.dateFrom) qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo }); + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination }); + if (params.customer) { + qb.andWhere( + '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', + { customer: `%${params.customer as string}%` }, + ); + } + + applyBookingRefDirectionScope(qb, 'i.source_id', directions); + return qb; +} + +/** + * What the payment gateway actually recorded against this invoice, summed. + * `invoices.payment_id` points at a payment-api intent id rather than a + * `freight.payments` row, so the reliable link is the booking id both sides + * carry. + */ +export const GATEWAY_PAID = `( + SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p + WHERE p.ref_id = i.source_id AND p.status = 'success' +)`; + +/** The payer, whichever of the two mutually exclusive payer columns is set. */ +export const PAYER_EXPR = "COALESCE(co.name, slc.name, 'Unknown')"; + +/** `SUM(amount)`, rounded to whole currency and typed as a JS number. */ +export const REVENUE_SUM = 'ROUND(COALESCE(SUM(il.amount), 0))::float8'; + +/** + * Settled share of a line, apportioned by how much of its invoice was paid. + * Invoice-level `paid_amount` cannot be attributed to a single line any other + * way. + */ +export const PAID_SHARE = + 'il.amount * CASE WHEN i.total_amount > 0 THEN i.paid_amount / i.total_amount ELSE 0 END'; + +/** The payment class as a business label rather than its key. */ +export const PAYMENT_CLASS_LABEL_EXPR = labelCase(PAYMENT_CLASS_EXPR, PAYMENT_CLASSES); diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 67f5d2547..a759d08dc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -94,6 +94,16 @@ export class CreateCargoTypeDto { @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ + description: + 'Wagons in a full trainset of this cargo (37 vehicles, 22 sand). Blank uses the default.', + example: 37, + }) + @IsOptional() + @IsInt() + @Min(1) + fullTrainsetWagons?: number; + @ApiPropertyOptional({ default: 1 }) @IsOptional() @IsInt() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts index 0615debdc..2c5c30d60 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNumber, IsUUID, Min } from 'class-validator'; +import { IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; const toNumber = ({ value }: { value: unknown }) => value === '' || value == null ? value : Number(value); @@ -19,4 +19,15 @@ export class CreateYardDistanceDto { @IsNumber() @Min(0.01) distanceKm!: number; + + @ApiPropertyOptional({ + description: + 'Standard running time for this leg in hours. Blank uses the default leg standard.', + example: 21, + }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + standardHours?: number; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 7084e233a..dc902b670 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -71,6 +71,15 @@ export class CargoType extends BaseEntity { @Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true }) tonsPerWagonMap?: Record | null; + /** + * Wagons in a full trainset of this cargo — 37 for vehicles, 22 for sand. + * The trainset report divides wagons actually loaded by this figure, so a + * train carrying 30 of a 50-wagon set reports 0.6 trainsets. Null falls back + * to `operations_standards.default_full_trainset_wagons`. + */ + @Column({ name: 'full_trainset_wagons', type: 'int', nullable: true }) + fullTrainsetWagons?: number | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts index 982079f47..6134ee6fc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts @@ -32,4 +32,15 @@ export class YardDistance extends BaseEntity { @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 }) distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm + + /** + * Standard running time for this leg, in hours — Negad→GMP 21, →Adama 20, + * →Modjo 20.5, →Sebeta 22. The delay report flags a leg that takes longer + * than this plus the tolerance. Null falls back to + * `operations_standards.default_leg_standard_hours`. + * + * Symmetric like the distance itself: an A→B row governs B→A too. + */ + @Column({ name: 'standard_hours', type: 'decimal', precision: 6, scale: 2, nullable: true }) + standardHours?: string | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 9c11005e9..c59016ad4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -204,6 +204,7 @@ export class CargoTypesService { itemsPerWagonMap: dto.itemsPerWagonMap, }), tonsPerWagonMap, + fullTrainsetWagons: dto.fullTrainsetWagons ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts index a41e593e2..93d069b73 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts @@ -61,6 +61,7 @@ export class YardDistancesService { fromYardId: dto.fromYardId, toYardId: dto.toYardId, distanceKm: dto.distanceKm.toFixed(2), + standardHours: dto.standardHours != null ? dto.standardHours.toFixed(2) : null, }); return toRow(created); } @@ -78,6 +79,9 @@ export class YardDistancesService { fromYardId, toYardId, ...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}), + ...(dto.standardHours !== undefined + ? { standardHours: dto.standardHours != null ? dto.standardHours.toFixed(2) : null } + : {}), }); if (!updated) throw new NotFoundException(`Yard distance ${id} not found`); return toRow(updated); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts index 7cc6b7115..9f9a68d4e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts @@ -1,6 +1,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { IdListParam } from '../../../common/dto/id-list.transform'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { TRAIN_SCHEDULE_STATUSES, @@ -50,15 +51,22 @@ export class ListTrainSchedulesQueryDto extends PaginationQueryDto { @IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[]) freightType?: TrainScheduleFreightType; - /** Origin station/yard id (exact match). */ - @ApiPropertyOptional({ format: 'uuid' }) + /** Origin station/yard — one id or a comma-separated list; matches ANY of them. */ + @ApiPropertyOptional({ + description: 'Origin station/yard id, or a comma-separated list (matches any of them).', + }) @IsOptional() - @IsUUID() - originStationId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + originStationId?: string[]; - /** Destination station/yard id (exact match). */ - @ApiPropertyOptional({ format: 'uuid' }) + /** Destination station/yard — one id or a comma-separated list; ANDed with the origin. */ + @ApiPropertyOptional({ + description: + 'Destination station/yard id, or a comma-separated list (matches any of them). ANDed with originStationId.', + }) @IsOptional() - @IsUUID() - destinationStationId?: string; + @IdListParam() + @IsUUID(undefined, { each: true }) + destinationStationId?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 45b971852..3c6080c36 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -4596,8 +4596,15 @@ export class TrainSchedulingService { const base: FindOptionsWhere = {}; if (allowedDirections) base.direction = In(allowedDirections) as never; if (query.status) base.status = query.status; - if (query.originStationId) base.originStationId = query.originStationId; - if (query.destinationStationId) base.destinationStationId = query.destinationStationId; + // Each end is an OR-list, the two ends AND together (origin-only and + // destination-only are both valid queries). `?.length` guards the empty + // array — `In([])` compiles to `IN ()`, a syntax error. + if (query.originStationId?.length) { + base.originStationId = In(query.originStationId) as never; + } + if (query.destinationStationId?.length) { + base.destinationStationId = In(query.destinationStationId) as never; + } if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never; // Search fans out across every human-recognizable label; each OR variant diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 328a6eaa8..4f48c2a8b 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -72,12 +72,14 @@ export class ListWagonsQueryDto { @Min(1) page?: number; - @ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100 }) + // 500 to match PaginationQueryDto — this DTO doesn't extend it, so the + // ceiling has to be repeated here or the wagons list alone rejects at 100. + @ApiPropertyOptional({ default: 10, minimum: 1, maximum: 500 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) - @Max(100) + @Max(500) pageSize?: number; @ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts index 0bbcdee48..8d7d60d49 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -3,28 +3,71 @@ import { DataSource, FindManyOptions, IsNull, ObjectLiteral, Repository } from ' import { Warehouse } from './entities/warehouse.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { SchedulingReadFacade } from './scheduling-read.facade'; + +export interface WarehouseDashboardFilter { + /** Inclusive day, `YYYY-MM-DD`. Both omitted → defaults to "today" (the original behaviour). */ + dateFrom?: string; + dateTo?: string; + /** Scopes every warehouse_inventory-derived counter. Ignored by the always-global ones (see below). */ + warehouseId?: string; +} export interface WarehouseDashboard { + // ── Always current — dateFrom/dateTo have no effect on these ────────────── totalWarehouses: number; totalInventory: number; - receivedToday: number; - // Inspection gate awaitingInspection: number; inspected: number; - // Export branch stored: number; reserved: number; readyForLoading: number; loaded: number; dispatched: number; - // Import branch readyForPickup: number; delivered: number; + /** Never warehouse-scoped — the container fleet isn't tied to a specific warehouse. */ + emptyContainers: number; + /** Never warehouse-scoped — trains aren't tied to a specific warehouse. */ + importTrains: number; + exportTrains: number; + // ── The one activity counter — respects dateFrom/dateTo (default: today) ── + received: number; } @Injectable() export class WarehouseDashboardService { - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly schedulingRead: SchedulingReadFacade, + ) {} + + private async safeQueryCount(sql: string, params: unknown[] = []): Promise { + try { + const rows = await this.dataSource.query(sql, params); + return Number(rows?.[0]?.count) || 0; + } catch { + return 0; + } + } + + /** ARRIVED trains with IMPORT-direction routing — same set as the import arrival queue. */ + private async safeImportTrains(): Promise { + try { + return (await this.schedulingRead.importArriveQueue()).length; + } catch { + return 0; + } + } + + /** Trains at/near Djibouti relevant to the export flow — same set as the Djibouti unloading queue. */ + private async safeExportTrains(): Promise { + try { + return (await this.schedulingRead.exportDjiboutiArrivalQueue()).length; + } catch { + return 0; + } + } private async safeCount( repo: Repository, @@ -37,24 +80,40 @@ export class WarehouseDashboardService { } } - private async safeReceivedToday(startOfToday: Date): Promise { + /** [inclusive start, exclusive end) for the "received" counter. Defaults to today. */ + private resolveRange(filter: WarehouseDashboardFilter): { start: Date; end: Date } { + if (!filter.dateFrom && !filter.dateTo) { + const start = new Date(); + start.setHours(0, 0, 0, 0); + return { start, end: new Date() }; + } + const start = filter.dateFrom ? new Date(`${filter.dateFrom}T00:00:00`) : new Date(0); + // Exclusive end = start of the day AFTER dateTo, so the whole end day is included. + const end = filter.dateTo + ? new Date(new Date(`${filter.dateTo}T00:00:00`).getTime() + 24 * 60 * 60 * 1000) + : new Date(); + return { start, end }; + } + + private async safeReceived(range: { start: Date; end: Date }, warehouseId?: string): Promise { try { - return await this.dataSource + const qb = this.dataSource .getRepository(WarehouseInventory) .createQueryBuilder('inv') - .where('inv.arrived_at >= :start', { start: startOfToday }) - .getCount(); + .where('inv.arrived_at >= :start AND inv.arrived_at < :end', range); + if (warehouseId) qb.andWhere('inv.warehouse_id = :warehouseId', { warehouseId }); + return await qb.getCount(); } catch { return 0; } } - async getDashboard(): Promise { + async getDashboard(filter: WarehouseDashboardFilter = {}): Promise { const warehouseRepo = this.dataSource.getRepository(Warehouse); const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); - - const startOfToday = new Date(); - startOfToday.setHours(0, 0, 0, 0); + const warehouseId = filter.warehouseId || undefined; + const scope = warehouseId ? { warehouseId } : {}; + const range = this.resolveRange(filter); const [ totalWarehouses, @@ -68,26 +127,39 @@ export class WarehouseDashboardService { dispatched, readyForPickup, delivered, - receivedToday, + received, + emptyContainers, + importTrains, + exportTrains, ] = await Promise.all([ this.safeCount(warehouseRepo), - this.safeCount(inventoryRepo), - this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), - this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }), - this.safeCount(inventoryRepo, { where: { status: 'STORED' } }), - this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }), - this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }), - this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }), - this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }), - this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }), - this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }), - this.safeReceivedToday(startOfToday), + this.safeCount(inventoryRepo, { where: { ...scope } }), + this.safeCount(inventoryRepo, { where: { ...scope, status: 'RECEIVED', inspectionStatus: IsNull() } }), + this.safeCount(inventoryRepo, { where: { ...scope, inspectionStatus: 'PASSED' } }), + this.safeCount(inventoryRepo, { where: { ...scope, status: 'STORED' } }), + this.safeCount(inventoryRepo, { where: { ...scope, status: 'RESERVED' } }), + this.safeCount(inventoryRepo, { where: { ...scope, status: 'READY_FOR_LOADING' } }), + this.safeCount(inventoryRepo, { where: { ...scope, status: 'LOADED' } }), + this.safeCount(inventoryRepo, { where: { ...scope, status: 'DISPATCHED' } }), + this.safeCount(inventoryRepo, { where: { ...scope, status: 'READY_FOR_PICKUP' } }), + this.safeCount(inventoryRepo, { where: { ...scope, status: 'DELIVERED' } }), + this.safeReceived(range, warehouseId), + // ponytail: the container fleet has no literal EMPTY status (AVAILABLE | LOADED | + // IN_TRANSIT | MAINTENANCE | DAMAGED) — AVAILABLE (not on a wagon, not in transit, + // not flagged) is the closest proxy for "empty and free to use". Revisit if the + // domain ever grows a real empty/full distinction per container. + this.safeQueryCount( + `SELECT count(*)::int AS count FROM freight.containers WHERE deleted_at IS NULL AND status = $1`, + ['AVAILABLE'], + ), + this.safeImportTrains(), + this.safeExportTrains(), ]); return { totalWarehouses, totalInventory, - receivedToday, + received, awaitingInspection, inspected, stored, @@ -97,6 +169,9 @@ export class WarehouseDashboardService { dispatched, readyForPickup, delivered, + emptyContainers, + importTrains, + exportTrains, }; } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index f3d894dfc..275afadf6 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -43,9 +43,20 @@ export class WarehousesController { @Get('dashboard') @BookingStaff(FREIGHT_PERMS.warehouseDashboard.view) - @ApiOperation({ summary: 'Warehouse dashboard metrics' }) - dashboard() { - return this.dashboardService.getDashboard(); + @ApiOperation({ + summary: 'Warehouse dashboard metrics', + description: + 'dateFrom/dateTo scope only the activity counters (currently just "received"); ' + + 'status-backlog and fleet counters are always current. Omit both for "received today" ' + + '(the original default). warehouseId scopes every warehouse_inventory-derived counter; ' + + 'totalWarehouses/emptyContainers/importTrains/exportTrains are never warehouse-scoped.', + }) + dashboard( + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + @Query('warehouseId') warehouseId?: string, + ) { + return this.dashboardService.getDashboard({ dateFrom, dateTo, warehouseId }); } @Post() diff --git a/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts b/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts new file mode 100644 index 000000000..120347132 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts @@ -0,0 +1,643 @@ +/** + * Loads the operations reporting reference data published by EDR for July 2026. + * + * Sources, both under the workspace root: + * - `EDR - Train Turn-Around Standard Time 2.docx` — the standard cycle + * times, broken down activity by activity. + * - `OCC_HQ_July_2026_Updated_Operation_Control_Center_OCC_Monthly_Report.pdf` + * — the July 2026 plan and actuals. + * + * What it writes: + * 1. `operations_standards` — the one settings row, from the docx. + * 2. `yard_distances.standard_hours` — the corridor leg standards. + * 3. `cargo_types` — a FERTILIZER type (the report's largest bulk category, + * absent from this database) and the full-trainset wagon counts. + * 4. `operations_targets` — the July 2026 plan: trainsets and tonnage per + * cargo category, TEU per container class, and tonnage per station. + * 5. The per-train records the report actually names: eleven container trains + * with their measured cycle durations, and the five DMP trains with their + * station staying times, as schedules plus checkpoints. + * + * Every figure is copied from the documents. Where something is derived rather + * than measured, the comment says so — see `buildCycleLegs`. + * + * Idempotent: re-running updates in place and never duplicates. Run with + * pnpm --filter @edr/freight-api run seed:occ-july-2026 + */ +import { DataSource } from 'typeorm'; + +import { AppDataSource } from '../data-source'; + +/** July 2026, the month every target below belongs to. */ +const PERIOD_TYPE = 'month'; +const PERIOD_START = '2026-07-01'; + +// --------------------------------------------------------------------------- +// 1. Operating standards — "EDR - Train Turn-Around Standard Time 2.docx" +// --------------------------------------------------------------------------- + +/** + * The docx totals each cycle activity by activity, and the station standards + * fall straight out of it: + * Djibouti side 1 + 0 + 2 + 6 + 2 + 2 = 13 hrs (container) + * Ethiopian side 1 + 1 + 6 + 1 + 1 = 10 hrs (container) + * container cycle 21 + 13 + 21 + 10 = 65 + * bulk via DMP 21 + 33 + 21 + 13 = 88 + * bulk via SDTV/Old-port/Nagad 21 + 41 + 21 + 13 = 96 + */ +const STANDARDS = { + station_standard_hours_ethiopia: 10, + station_standard_hours_djibouti: 13, + cycle_standard_hours_container: 65, + cycle_standard_hours_bulk_dmp: 88, + cycle_standard_hours_bulk_nagad: 96, + cycle_standard_hours_bulk_bcc: 96, + default_leg_standard_hours: 21, + delay_tolerance_minutes: 30, + charged_tons_full_20ft: 20, + charged_tons_full_40ft: 40, + charged_tons_empty_20ft: 2.24, + charged_tons_empty_40ft: 3.88, + charged_tons_per_wagon_general: 70, + charged_tons_per_wagon_perishable: 38, + default_full_trainset_wagons: 50, +}; + +// --------------------------------------------------------------------------- +// 2. Corridor leg standards +// --------------------------------------------------------------------------- + +/** + * Only the four legs the business gave figures for. Everything else is left + * null and falls back to `default_leg_standard_hours`, rather than being + * guessed at — the delay report judges trains against these. + * + * The docx's 21 hours covers 15:00 running at 50 km/h average, 40 min Dire Dawa + * inspection, 3:20 station dwell (10 min a station), 40 min Dewanle + * inspection and documentation, and 2:20 of maintenance-window allowance. + */ +const LEG_STANDARD_HOURS: Array<[string, string, number]> = [ + ['NAGAD', 'KALITY', 21], + ['NAGAD', 'ADAMA', 20], + ['NAGAD', 'MOJO', 20.5], + ['NAGAD', 'SEBETA', 22], +]; + +// --------------------------------------------------------------------------- +// 3. Cargo types +// --------------------------------------------------------------------------- + +/** "Full train set per cargo vehicle 37 wagons, sand 22 wagons." */ +const FULL_TRAINSET_WAGONS: Array<[string, number]> = [ + ['AUTOMOBILE', 37], + ['TRUCK', 37], + ['SAND', 22], +]; + +// --------------------------------------------------------------------------- +// 4. July 2026 plan +// --------------------------------------------------------------------------- + +/** Section 1.1, "Train type / Plan" column. */ +const TRAINSET_PLAN: Array<[string, number]> = [ + ['CONTAINER_IMPORT_MULTIMODAL', 69.3], + ['CONTAINER_IMPORT_UNIMODAL', 19.9], + ['CONTAINER_EXPORT', 28.0], + ['EMPTY_CONTAINER', 25.4], + ['FERTILIZER', 29.0], + ['RORO', 2.7], + ['BREAK_BULK', 2.0], + ['OTHER_IMPORT', 2.5], + ['OTHER_EXPORT', 2.3], + ['SAND', 1.6], + // The report's eleventh line, "Empty Train 73.0", has no cargo category to + // hang on — it is trains running with no cargo at all. The trainset report + // surfaces it as the "Empty wagons" KPI instead. +]; + +/** Section 2, "Container transport performance (TEU) / Plan". */ +const TEU_PLAN: Array<[string, number]> = [ + ['CONTAINER_IMPORT_MULTIMODAL', 7350], + ['CONTAINER_IMPORT_UNIMODAL', 2106], + ['CONTAINER_EXPORT', 2973], + ['EMPTY_CONTAINER_RETURN', 2689], +]; + +/** Section 3's plan bars — 357,551 t in total. */ +const VOLUME_PLAN: Array<[string, number]> = [ + ['CONTAINER_IMPORT_MULTIMODAL', 147000], + ['CONTAINER_IMPORT_UNIMODAL', 42126], + ['CONTAINER_EXPORT', 59452], + ['EMPTY_CONTAINER', 8068], + ['FERTILIZER', 75000], + ['RORO', 4247], + ['BREAK_BULK', 5096], + ['OTHER_IMPORT', 6370], + ['OTHER_EXPORT', 5945], + ['SAND', 4247], +]; + +/** + * Section 4, "Freight Stations (cargo volume)" — every station-pair line. + * + * The station is the Ethiopian end of the corridor, which is what the report's + * Ethiopian view groups by; Nagad is the other end on all of them. Galaan in + * the report is the yard coded KALITY here (GMP / Gelan Multipurpose Port). + * + * The per-station figures sum to the category totals above within ±1 tonne, the + * report's own rounding. + */ +const STATION_PLAN: Array<[string, string, number]> = [ + ['CONTAINER_IMPORT_MULTIMODAL', 'DIRE_DAWA', 2940], + ['CONTAINER_IMPORT_MULTIMODAL', 'MOJO', 122010], + ['CONTAINER_IMPORT_MULTIMODAL', 'KALITY', 22050], + + ['CONTAINER_IMPORT_UNIMODAL', 'DIRE_DAWA', 2106], + ['CONTAINER_IMPORT_UNIMODAL', 'MOJO', 843], + ['CONTAINER_IMPORT_UNIMODAL', 'KALITY', 37913], + ['CONTAINER_IMPORT_UNIMODAL', 'SEBETA', 1264], + + ['CONTAINER_EXPORT', 'SEBETA', 892], + ['CONTAINER_EXPORT', 'KALITY', 41914], + ['CONTAINER_EXPORT', 'MOJO', 16052], + ['CONTAINER_EXPORT', 'DIRE_DAWA', 595], + + ['EMPTY_CONTAINER', 'KALITY', 1614], + ['EMPTY_CONTAINER', 'MOJO', 6455], + + ['FERTILIZER', 'MEISO', 1500], + ['FERTILIZER', 'ADAMA', 18750], + ['FERTILIZER', 'MOJO', 18000], + ['FERTILIZER', 'KALITY', 18000], + ['FERTILIZER', 'SEBETA', 18750], + + ['RORO', 'KALITY', 4247], + + ['BREAK_BULK', 'ADAMA', 127], + ['BREAK_BULK', 'MOJO', 127], + ['BREAK_BULK', 'KALITY', 4841], + + ['OTHER_IMPORT', 'DIRE_DAWA', 32], + ['OTHER_IMPORT', 'ADAMA', 3185], + ['OTHER_IMPORT', 'KALITY', 3089], + ['OTHER_IMPORT', 'SEBETA', 64], + + ['OTHER_EXPORT', 'SEBETA', 297], + ['OTHER_EXPORT', 'KALITY', 595], + ['OTHER_EXPORT', 'ADAMA', 4816], + ['OTHER_EXPORT', 'MEISO', 59], + ['OTHER_EXPORT', 'BIKE', 59], + ['OTHER_EXPORT', 'DIRE_DAWA', 119], + + ['SAND', 'KALITY', 4247], +]; + +// --------------------------------------------------------------------------- +// 5. Per-train records +// --------------------------------------------------------------------------- + +const hours = (h: number, m = 0, s = 0): number => h + m / 60 + s / 3600; +const MS_PER_HOUR = 3_600_000; +const addHours = (from: Date, h: number): Date => new Date(from.getTime() + h * MS_PER_HOUR); + +/** Section 8, "Container train turnround cycle analysis" — measured per train. */ +const CONTAINER_CYCLES: Array<[string, number]> = [ + ['8001', hours(82, 22, 15)], + ['8101', hours(87, 36, 53)], + ['8201', hours(84, 17, 7)], + ['8301', hours(82, 46, 54)], + ['8401', hours(83, 16, 47)], + ['8501', hours(83, 18, 54)], + ['8601', hours(85, 41, 54)], + ['8701', hours(85, 23, 0)], + ['8801', hours(81, 15, 38)], + ['8901', hours(85, 12, 36)], + ['9001', hours(88, 8, 0)], +]; + +/** Section 7: expected 21:00:00, actual average 22:52:31 across 246.2 trains. */ +const ACTUAL_TRAVEL_HOURS = hours(22, 52, 31); + +/** Section 9, Gelan chart: average total staying time 7:12:17. */ +const GELAN_STAY_HOURS = hours(7, 12, 17); + +/** + * Section 9, "Loading/Unloading time (Container) from DMP" — per train, real. + * `[train number, loading/unloading hours, total staying hours]`. + */ +const DMP_TRAINS: Array<[string, number, number]> = [ + ['9002/395M', hours(1, 31), hours(32, 10)], + ['9002/398M', hours(3, 37), hours(33, 28)], + ['8002/308M', hours(2, 1), hours(26, 50)], + ['9002/388M', hours(1, 7), hours(60, 37)], + ['8902/389M', hours(8, 13), hours(38, 37)], +]; + +/** + * A cycle's three departures. + * + * The cycle TOTAL is measured — it is the figure the report publishes for that + * train. Its internal split is not: the report only publishes averages, so the + * legs use the month's average actual travel time (22:52:31) and the Gelan + * average station stay (7:12:17), leaving the Djibouti stay as the remainder. + * That remainder lands near the report's own DCT average of 31:20:56, which is + * the cross-check that the split is sane rather than invented. + */ +function buildCycleLegs(cycleStart: Date, cycleHours: number) { + const arriveEthiopia = addHours(cycleStart, ACTUAL_TRAVEL_HOURS); + const departEthiopia = addHours(arriveEthiopia, GELAN_STAY_HOURS); + const arriveDjibouti = addHours(departEthiopia, ACTUAL_TRAVEL_HOURS); + const nextCycleStart = addHours(cycleStart, cycleHours); + return { arriveEthiopia, departEthiopia, arriveDjibouti, nextCycleStart }; +} + +// --------------------------------------------------------------------------- + +interface Ids { + yards: Map; + cargoTypes: Map; + locomotiveId: string | null; +} + +async function loadIds(ds: DataSource): Promise { + const yards = new Map(); + for (const row of await ds.query>( + `SELECT id, code FROM freight.yards WHERE deleted_at IS NULL`, + )) { + yards.set(row.code, row.id); + } + const cargoTypes = new Map(); + for (const row of await ds.query>( + `SELECT id, code FROM freight.cargo_types WHERE deleted_at IS NULL`, + )) { + cargoTypes.set(row.code, row.id); + } + const [loco] = await ds.query>( + `SELECT id FROM freight.locomotives WHERE deleted_at IS NULL ORDER BY created_at LIMIT 1`, + ); + return { yards, cargoTypes, locomotiveId: loco?.id ?? null }; +} + +async function seedStandards(ds: DataSource): Promise { + const columns = Object.keys(STANDARDS); + const values = Object.values(STANDARDS); + const assignments = columns.map((c, i) => `${c} = $${i + 1}`).join(', '); + + // Read first, then write by id. TypeORM returns `[rows, rowCount]` from an + // UPDATE ... RETURNING but a bare array from a SELECT, and treating the + // former as rows silently counts two of everything. + const existing = await ds.query>( + `SELECT id FROM freight.operations_standards WHERE deleted_at IS NULL ORDER BY created_at LIMIT 1`, + ); + if (existing.length) { + await ds.query( + `UPDATE freight.operations_standards SET ${assignments}, updated_at = now() WHERE id = $${columns.length + 1}`, + [...values, existing[0].id], + ); + } else { + await ds.query( + `INSERT INTO freight.operations_standards (${columns.join(', ')}) + VALUES (${columns.map((_, i) => `$${i + 1}`).join(', ')})`, + values, + ); + } + console.log(`standards : ${columns.length} figures set`); +} + +async function seedLegStandards(ds: DataSource, ids: Ids): Promise { + let set = 0; + const missing: string[] = []; + for (const [from, to, h] of LEG_STANDARD_HOURS) { + const a = ids.yards.get(from); + const b = ids.yards.get(to); + if (!a || !b) { + missing.push(`${from}-${to} (yard missing)`); + continue; + } + // Symmetric, like the distance itself: match the pair either way round. + const rows = await ds.query>( + `SELECT id FROM freight.yard_distances + WHERE deleted_at IS NULL + AND ((from_yard_id = $1 AND to_yard_id = $2) OR (from_yard_id = $2 AND to_yard_id = $1))`, + [a, b], + ); + if (!rows.length) { + missing.push(`${from}-${to} (no distance row)`); + continue; + } + for (const row of rows) { + await ds.query( + `UPDATE freight.yard_distances SET standard_hours = $2, updated_at = now() WHERE id = $1`, + [row.id, h], + ); + set++; + } + } + console.log(`leg standards : ${set} set${missing.length ? `, skipped ${missing.join(', ')}` : ''}`); +} + +async function seedCargoTypes(ds: DataSource, ids: Ids): Promise { + if (!ids.cargoTypes.has('FERTILIZER')) { + // The report's largest bulk category. Billed per ton, like the other bulk + // commodities seeded by pricing-data.seeder. + const [row] = await ds.query>( + `INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active, display_order) + VALUES ('FERTILIZER', 'Fertilizer', 'PER_TON', true, + (SELECT COALESCE(MAX(display_order), 0) + 1 FROM freight.cargo_types)) + RETURNING id`, + ); + ids.cargoTypes.set('FERTILIZER', row.id); + console.log('cargo types : FERTILIZER created'); + } + + let set = 0; + for (const [code, wagons] of FULL_TRAINSET_WAGONS) { + const id = ids.cargoTypes.get(code); + if (!id) continue; + await ds.query( + `UPDATE freight.cargo_types SET full_trainset_wagons = $2, updated_at = now() WHERE id = $1`, + [id, wagons], + ); + set++; + } + console.log(`trainset wagons : ${set} cargo types set`); +} + +async function upsertTarget( + ds: DataSource, + metric: string, + dimension: string, + dimensionKey: string, + plannedValue: number, + cargoCategory: string | null, + note: string, +): Promise { + await ds.query( + `INSERT INTO freight.operations_targets + (period_type, period_start, metric, dimension, dimension_key, cargo_category, planned_value, note) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (period_type, period_start, metric, dimension, dimension_key, COALESCE(cargo_category, '')) + WHERE deleted_at IS NULL + DO UPDATE SET planned_value = EXCLUDED.planned_value, + note = EXCLUDED.note, + updated_at = now()`, + [PERIOD_TYPE, PERIOD_START, metric, dimension, dimensionKey, cargoCategory, plannedValue, note], + ); +} + +async function seedTargets(ds: DataSource, ids: Ids): Promise { + const note = 'OCC July 2026 monthly report'; + + for (const [key, value] of TRAINSET_PLAN) { + await upsertTarget(ds, 'TRAINSET', 'cargo_category', key, value, null, note); + } + for (const [key, value] of TEU_PLAN) { + await upsertTarget(ds, 'TEU', 'container_class', key, value, null, note); + } + for (const [key, value] of VOLUME_PLAN) { + await upsertTarget(ds, 'VOLUME_TONS', 'cargo_category', key, value, null, note); + } + + const skipped: string[] = []; + let stations = 0; + for (const [category, yardCode, value] of STATION_PLAN) { + if (!ids.yards.has(yardCode)) { + skipped.push(`${yardCode}/${category}`); + continue; + } + await upsertTarget(ds, 'VOLUME_TONS', 'station', yardCode, value, category, note); + stations++; + } + + console.log( + `targets : ${TRAINSET_PLAN.length} trainset, ${TEU_PLAN.length} TEU, ` + + `${VOLUME_PLAN.length} volume, ${stations} station` + + (skipped.length ? ` (skipped ${skipped.join(', ')})` : ''), + ); +} + +/** One departure: its physical train, its set, and the schedule row. */ +async function upsertSchedule( + ds: DataSource, + args: { + reference: string; + trainId: string; + trainNumber: string; + direction: 'IMPORT' | 'EXPORT'; + originYardId: string; + destinationYardId: string; + departedAt: Date; + arrivedAt: Date | null; + }, +): Promise { + const existing = await ds.query>( + `SELECT id FROM freight.train_schedules WHERE reference = $1 AND deleted_at IS NULL`, + [args.reference], + ); + if (existing.length) { + await ds.query( + `UPDATE freight.train_schedules + SET actual_departure_at = $2, actual_arrival_at = $3, updated_at = now() + WHERE id = $1`, + [existing[0].id, args.departedAt, args.arrivedAt], + ); + return existing[0].id; + } + + const [set] = await ds.query>( + `INSERT INTO freight.train_sets + (train_id, locomotive_id, total_weight_tons, total_length_meters, wagon_count, status) + VALUES ($1, (SELECT id FROM freight.locomotives WHERE deleted_at IS NULL ORDER BY created_at LIMIT 1), + 0, 0, 0, 'COMPLETED') + RETURNING id`, + [args.trainId], + ); + + const [schedule] = await ds.query>( + `INSERT INTO freight.train_schedules + (train_set_id, origin_station_id, destination_station_id, scheduled_departure_date, + scheduled_arrival_date, actual_departure_at, actual_arrival_at, status, train_number, + direction, reference, booking_window_status) + VALUES ($1, $2, $3, $4, $5, $4, $5, 'ARRIVED', $6, $7, $8, 'CLOSED') + RETURNING id`, + [ + set.id, + args.originYardId, + args.destinationYardId, + args.departedAt, + args.arrivedAt, + args.trainNumber, + args.direction, + args.reference, + ], + ); + return schedule.id; +} + +async function upsertTrain(ds: DataSource, code: string): Promise { + const existing = await ds.query>( + `SELECT id FROM freight.trains WHERE code = $1 AND deleted_at IS NULL`, + [code], + ); + if (existing.length) return existing[0].id; + const [row] = await ds.query>( + `INSERT INTO freight.trains (code, status) VALUES ($1, 'AVAILABLE') RETURNING id`, + [code], + ); + return row.id; +} + +async function upsertCheckpoint( + ds: DataSource, + scheduleId: string, + yardId: string, + sequenceNo: number, + kind: 'ARRIVED' | 'DEPARTED', + occurredAt: Date, + note: string, +): Promise { + const existing = await ds.query>( + `SELECT id FROM freight.train_checkpoint_events + WHERE train_schedule_id = $1 AND yard_id = $2 AND kind = $3 AND deleted_at IS NULL`, + [scheduleId, yardId, kind], + ); + if (existing.length) { + await ds.query( + `UPDATE freight.train_checkpoint_events SET occurred_at = $2, note = $3, updated_at = now() + WHERE id = $1`, + [existing[0].id, occurredAt, note], + ); + return; + } + await ds.query( + `INSERT INTO freight.train_checkpoint_events + (train_schedule_id, yard_id, sequence_no, kind, occurred_at, note) + VALUES ($1, $2, $3, $4, $5, $6)`, + [scheduleId, yardId, sequenceNo, kind, occurredAt, note], + ); +} + +/** + * The eleven container trains, each as three departures so one full cycle is + * measurable: Nagad → Gelan, Gelan → Nagad, then Nagad again. + */ +async function seedContainerCycles(ds: DataSource, ids: Ids): Promise { + const nagad = ids.yards.get('NAGAD'); + const gelan = ids.yards.get('KALITY'); + if (!nagad || !gelan) { + console.log('container cycles: skipped — NAGAD or KALITY yard missing'); + return; + } + + // Cycles are staggered a day apart through July so the month reads as a + // sequence rather than eleven trains leaving at once. + let cycles = 0; + for (const [index, [trainNumber, cycleHours]] of CONTAINER_CYCLES.entries()) { + const trainId = await upsertTrain(ds, `OCC-${trainNumber}`); + const cycleStart = new Date(Date.UTC(2026, 6, 2 + index, 6, 0, 0)); + const legs = buildCycleLegs(cycleStart, cycleHours); + + const leg1 = await upsertSchedule(ds, { + reference: `OCC-2026-07-${trainNumber}-1`, + trainId, + trainNumber, + direction: 'IMPORT', + originYardId: nagad, + destinationYardId: gelan, + departedAt: cycleStart, + arrivedAt: legs.arriveEthiopia, + }); + const leg2 = await upsertSchedule(ds, { + reference: `OCC-2026-07-${trainNumber}-2`, + trainId, + trainNumber, + direction: 'EXPORT', + originYardId: gelan, + destinationYardId: nagad, + departedAt: legs.departEthiopia, + arrivedAt: legs.arriveDjibouti, + }); + const leg3 = await upsertSchedule(ds, { + reference: `OCC-2026-07-${trainNumber}-3`, + trainId, + trainNumber, + direction: 'IMPORT', + originYardId: nagad, + destinationYardId: gelan, + departedAt: legs.nextCycleStart, + arrivedAt: null, + }); + + const stayNote = 'OCC July 2026 — station staying time'; + await upsertCheckpoint(ds, leg1, gelan, 1, 'ARRIVED', legs.arriveEthiopia, stayNote); + await upsertCheckpoint(ds, leg2, gelan, 0, 'DEPARTED', legs.departEthiopia, stayNote); + await upsertCheckpoint(ds, leg2, nagad, 1, 'ARRIVED', legs.arriveDjibouti, stayNote); + await upsertCheckpoint(ds, leg3, nagad, 0, 'DEPARTED', legs.nextCycleStart, stayNote); + cycles++; + } + console.log(`container cycles: ${cycles} trains, 3 departures each`); +} + +/** The five DMP trains, with the staying times the report measured for them. */ +async function seedDmpTrains(ds: DataSource, ids: Ids): Promise { + const dmp = ids.yards.get('DORALEH_MULTIPURPOSE_PORT_DMP'); + const gelan = ids.yards.get('KALITY'); + if (!dmp || !gelan) { + console.log('DMP trains : skipped — DMP or KALITY yard missing'); + return; + } + + for (const [index, [trainNumber, handlingHours, stayingHours]] of DMP_TRAINS.entries()) { + const trainId = await upsertTrain(ds, `OCC-${trainNumber}`); + const arrivedAtDmp = new Date(Date.UTC(2026, 6, 3 + index * 2, 4, 0, 0)); + const departedDmp = addHours(arrivedAtDmp, stayingHours); + const arrivedGelan = addHours(departedDmp, hours(21)); + + const schedule = await upsertSchedule(ds, { + // `reference` is varchar(20), so the month is implied by the seed itself. + reference: `OCC-DMP-${trainNumber.replace('/', '-')}`, + trainId, + trainNumber, + direction: 'IMPORT', + originYardId: dmp, + destinationYardId: gelan, + departedAt: departedDmp, + arrivedAt: arrivedGelan, + }); + + // The loading/unloading figure has nowhere of its own to live yet — no + // table records when handling starts and ends — so it rides on the stop's + // note, where the staying-time report surfaces it as the stop's reason. + const note = + `OCC July 2026 — loading/unloading ${handlingHours.toFixed(2)}h of ` + + `${stayingHours.toFixed(2)}h total staying`; + await upsertCheckpoint(ds, schedule, dmp, 0, 'ARRIVED', arrivedAtDmp, note); + await upsertCheckpoint(ds, schedule, dmp, 0, 'DEPARTED', departedDmp, note); + } + console.log(`DMP trains : ${DMP_TRAINS.length} trains with measured staying times`); +} + +async function main(): Promise { + const ds = await AppDataSource.initialize(); + console.log(`seeding OCC July 2026 into ${ds.options.database as string}\n`); + + const ids = await loadIds(ds); + if (!ids.locomotiveId) { + throw new Error('No locomotive in this database — train sets require one.'); + } + + await seedStandards(ds); + await seedLegStandards(ds, ids); + await seedCargoTypes(ds, ids); + await seedTargets(ds, ids); + await seedContainerCycles(ds, ids); + await seedDmpTrains(ds, ids); + + console.log('\ndone'); + await ds.destroy(); +} + +void main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/validate-export-datasets.ts b/apps/edr-freight-api/src/scripts/validate-export-datasets.ts new file mode 100644 index 000000000..5ee036d3d --- /dev/null +++ b/apps/edr-freight-api/src/scripts/validate-export-datasets.ts @@ -0,0 +1,83 @@ +/** + * EXPLAIN-validates every export dataset against the real database. + * + * CLAUDE.md hard rule: raw SQL must be validated against a real DB before it + * ships. Every dataset is hand-written SQL expressions over wide tables where + * column drift is documented history, so a typo is a runtime 500 no type-check + * can catch. This builds each dataset's WIDEST query (all fields selected, so + * every join and every subquery is exercised) plus its count query, and runs + * both through EXPLAIN. + * + * npx ts-node -r tsconfig-paths/register src/scripts/validate-export-datasets.ts + */ +import 'dotenv/config'; + +import AppDataSource from '../data-source'; +import { buildExportCountQuery, buildExportQuery } from '../modules/exports/export-query.builder'; +import { DATASETS } from '../modules/exports/export.registry'; + +async function main(): Promise { + await AppDataSource.initialize(); + let failed = 0; + + for (const dataset of DATASETS) { + const ctx = { ds: AppDataSource, params: {}, directions: null }; + + const cases: [string, () => { sql: string; params: unknown[] }][] = [ + [ + `${dataset.key} (all ${dataset.fields.length} fields)`, + () => { + const qb = buildExportQuery(dataset, dataset.fields, ctx); + return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] }; + }, + ], + [ + `${dataset.key} (count)`, + () => { + const qb = buildExportCountQuery(dataset, ctx); + return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] }; + }, + ], + ]; + + // Each field ALONE. The all-fields query above cannot catch a field that + // references an alias it forgot to declare in `requires` — some other + // field's `requires` pulls that join in, so it only 42P01s when that one + // checkbox is ticked on its own. This is the check that finds it. + for (const field of dataset.fields) { + cases.push([ + `${dataset.key}.${field.key}`, + () => { + const qb = buildExportQuery(dataset, [field], ctx); + return { sql: qb.getQuery(), params: qb.getParameters() as unknown as unknown[] }; + }, + ]); + } + + let fieldFailures = 0; + for (const [label, build] of cases) { + const isPerField = label.startsWith(`${dataset.key}.`); + try { + const { sql } = build(); + // Parameters are all optional filters and unset here, so the generated + // SQL carries no placeholders — EXPLAIN it directly. + await AppDataSource.query(`EXPLAIN ${sql}`); + if (!isPerField) console.log(` ok ${label}`); + } catch (error) { + failed += 1; + if (isPerField) fieldFailures += 1; + console.error(` FAIL ${label}`); + console.error(` ${(error as Error).message.split('\n')[0]}`); + } + } + if (!fieldFailures) { + console.log(` ok ${dataset.key} (each of ${dataset.fields.length} fields alone)`); + } + } + + await AppDataSource.destroy(); + console.log(failed ? `\n${failed} query/queries failed.` : '\nAll export dataset SQL validated.'); + process.exit(failed ? 1 : 0); +} + +void main(); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 6efa2d78e..1a6170336 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -23,6 +23,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ // so a mid-list insert would shift ids already seeded for later slugs. "truck-types", "transit-agents", + "operations-targets", ] as const; export type RuleEngineResourceSlug = @@ -78,6 +79,23 @@ export const REPORT_KEYS = [ "payments-by-status", "revenue-summary", "cargo-summary", + "revenue-by-category", + "revenue-transactions", + "revenue-by-period", + "revenue-by-route", + "revenue-top-customers", + "payment-classification", + "revenue-reconciliation", + "receivables-payables", + "revenue-anomalies", + "station-staying-time", + "turnaround-cycle", + "train-delays", + "trainset-performance", + "teu-performance", + "cargo-volume-performance", + "charged-vs-actual-volume", + "cargo-volume-by-station", ] as const; export type ReportKey = (typeof REPORT_KEYS)[number]; @@ -387,6 +405,7 @@ const RULE_ENGINE_VIEW_IDS: Record = { "approval-rules": "b2000001-0001-4000-8000-000000000013", "yard-distances": "b2000001-0001-4000-8000-000000000018", "transit-agents": "b2000003-0001-4000-8000-000000000001", + "operations-targets": "b2000003-0001-4000-8000-000000000002", }; // CRUD replaces the retired coarse `:manage`. New ids live in a fresh block @@ -1311,6 +1330,30 @@ export const PORT_TERMINAL_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// E. Additional charges — ad-hoc finance charges raised against a booking +export const ADDITIONAL_CHARGE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f2e00001-0001-4000-8000-000000000001", + "edr_freight_app:additional_charges:view", + "View additional charges", + ), + perm( + "f2e00001-0001-4000-8000-000000000002", + "edr_freight_app:additional_charges:create", + "Create additional charge", + ), + perm( + "f2e00001-0001-4000-8000-000000000003", + "edr_freight_app:additional_charges:send", + "Send additional charge to customer", + ), + perm( + "f2e00001-0001-4000-8000-000000000004", + "edr_freight_app:additional_charges:cancel", + "Cancel additional charge", + ), +]; + // E'. Train-scheduling finer actions (augment existing view/manage) export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1504,6 +1547,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:exchange_rate:manage", "Set the USD-ETB fallback rate", ), + perm( + "b5000001-0001-4000-8000-000000000001", + "edr_freight_app:settings:operations_standards:view", + "View operating standards", + ), + perm( + "b5000001-0001-4000-8000-000000000002", + "edr_freight_app:settings:operations_standards:manage", + "Edit operating standards", + ), perm( "b4d00001-0001-4000-8000-000000000003", "edr_freight_app:settings:manual_payment:view", @@ -1684,6 +1737,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:warehouse_fee_invoices:get_notification", "Receive warehouse fee accrual notifications", ), + perm( + "f3a00001-0001-4000-8000-000000000009", + "edr_freight_app:additional_charges:get_notification", + "Receive additional charge notifications", + ), ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ @@ -1697,6 +1755,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...FLEET_ROAD_PERMISSIONS, ...WAREHOUSE_PERMISSIONS, ...PORT_TERMINAL_PERMISSIONS, + ...ADDITIONAL_CHARGE_PERMISSIONS, ...SCHEDULING_EXTRA_PERMISSIONS, ...CONFIG_SETTINGS_PERMISSIONS, ...STAFF_IAM_PERMISSIONS, @@ -2141,6 +2200,14 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:warehouse_fee_invoices:get_notification", }, + additionalCharges: { + view: "edr_freight_app:additional_charges:view", + create: "edr_freight_app:additional_charges:create", + send: "edr_freight_app:additional_charges:send", + cancel: "edr_freight_app:additional_charges:cancel", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:additional_charges:get_notification", + }, settings: { fileUpload: { view: "edr_freight_app:settings:file_upload:view", @@ -2173,6 +2240,12 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", }, + // Standard station stay, cycle and leg times, and the charged-tonnage + // factors the operations reports measure actual performance against. + operationsStandards: { + view: "edr_freight_app:settings:operations_standards:view", + manage: "edr_freight_app:settings:operations_standards:manage", + }, // Whether Finance may settle invoices by hand, per currency. Split // view/manage on purpose: Finance reads it (the worklist offers only the // enabled currencies) but must not switch its own channel on — same @@ -2310,6 +2383,9 @@ export const NOTIFICATION_PERMISSION_ANCHORS: Record = { [FREIGHT_PERMS.warehouseFeeInvoices.getNotification]: [ FREIGHT_PERMS.warehouseFeeInvoices.view, ], + [FREIGHT_PERMS.additionalCharges.getNotification]: [ + FREIGHT_PERMS.additionalCharges.view, + ], }; /** Both arms of a freight-type-split permission (for one-of route guards). */ @@ -2516,6 +2592,12 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.shippingLineCredits.invoice, FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid, FREIGHT_PERMS.shippingLineCredits.invoiceCancel, + // Additional Payments: Finance is the only role that raises and sends + // ad-hoc charges to a customer. + FREIGHT_PERMS.additionalCharges.view, + FREIGHT_PERMS.additionalCharges.create, + FREIGHT_PERMS.additionalCharges.send, + FREIGHT_PERMS.additionalCharges.cancel, ], // Global Logistics: manages ONLY the customs-clearance queue. Scoped out of // the general booking-request list (no bookings:view) — instead a dedicated diff --git a/apps/edr-freight-web/backoffice/index.html b/apps/edr-freight-web/backoffice/index.html index 2711aaccf..796a88a4f 100644 --- a/apps/edr-freight-web/backoffice/index.html +++ b/apps/edr-freight-web/backoffice/index.html @@ -7,7 +7,7 @@ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 68b812f6c..db2423c17 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -45,7 +45,7 @@ import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage"; import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config"; -import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect"; +import ReportsLandingPage from "./pages/reports/ReportsLandingPage"; import ReportPage from "./pages/reports/ReportPage"; import AuditLogsPage from "./pages/AuditLogsPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; @@ -86,6 +86,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; +import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage"; import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard"; import FirstMilePage from "./pages/operations/FirstMilePage"; @@ -248,7 +249,7 @@ const App = () => { path="reports" element={ - + } /> @@ -1202,6 +1203,16 @@ const App = () => { } /> */} + + + + } + /> } /> = { + DRAFT: { label: "Draft", color: "gray" }, + SENT: { label: "Sent — unpaid", color: "orange" }, + PAID: { label: "Paid", color: "edr-green" }, + CANCELLED: { label: "Cancelled", color: "red" }, +}; + +export interface AdditionalPaymentsTabProps { + bookingId: string; + onViewFile: (file: { name: string; url: string }) => void; +} + +/** + * Ad-hoc extra charges finance raises against a booking — any number, free-text + * reason. Draft until sent; sending issues the payable invoice and notifies the + * customer (in-app + SMS + email). Settles the same way every invoice does. + */ +export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPaymentsTabProps) { + const qc = useQueryClient(); + const [modalOpen, setModalOpen] = useState(false); + + const { data: charges, isLoading } = useQuery({ + queryKey: ["additional-charges", bookingId], + queryFn: () => bookingsService.getAdditionalCharges(bookingId), + }); + + const refresh = (next: Freight.AdditionalCharge[]) => + qc.setQueryData(["additional-charges", bookingId], next); + const onError = (e: unknown) => + toast.error(extractErrorMessage(e, "Could not update the charge")); + + const create = useMutation({ + mutationFn: (p: { + reason: string; + amount: number; + currency: string; + action: "draft" | "send"; + file?: File | null; + }) => bookingsService.createAdditionalCharge(bookingId, p), + onSuccess: (next, p) => { + toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved"); + refresh(next); + setModalOpen(false); + }, + onError, + }); + const send = useMutation({ + mutationFn: (chargeId: string) => bookingsService.sendAdditionalCharge(bookingId, chargeId), + onSuccess: (next) => { + toast.success("Charge sent to the customer"); + refresh(next); + }, + onError, + }); + const cancel = useMutation({ + mutationFn: (chargeId: string) => bookingsService.cancelAdditionalCharge(bookingId, chargeId), + onSuccess: (next) => { + toast.success("Charge cancelled"); + refresh(next); + }, + onError, + }); + + if (isLoading) { + return ( + + + Loading additional charges… + + ); + } + + const rows = charges ?? []; + const busy = send.isPending || cancel.isPending; + + return ( + + + + Additional charges + + + + + {rows.length === 0 && ( + + No additional charges raised on this booking yet. + + )} + + {rows.map((charge) => ( + send.mutate(charge.id)} + onCancel={() => cancel.mutate(charge.id)} + /> + ))} + + setModalOpen(false)} + busy={create.isPending} + onSubmit={(p) => create.mutate(p)} + /> + + ); +} + +function ChargeCard({ + charge, + busy, + onViewFile, + onSend, + onCancel, +}: { + charge: Freight.AdditionalCharge; + busy: boolean; + onViewFile: (file: { name: string; url: string }) => void; + onSend: () => void; + onCancel: () => void; +}) { + const meta = STATUS_META[charge.status]; + + return ( + + + + + + + {charge.reason} + + + Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "} + {formatDateTime(charge.createdAt)} + + {charge.sentAt && ( + + Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "} + {formatDateTime(charge.sentAt)} + {charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""} + + )} + {charge.paidAt && ( + + Paid · {formatDateTime(charge.paidAt)} + {charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""} + + )} + {charge.cancelledAt && ( + + Cancelled · {formatDateTime(charge.cancelledAt)} + {charge.cancelReason ? ` — ${charge.cancelReason}` : ""} + + )} + + + + + {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.currency} + + + {meta.label} + + + + + {charge.file && ( + + + + {charge.file.name} + + {isViewable({ name: charge.file.name, url: "" }) && ( + + + void fetchViewableFile(charge.file!.id, charge.file!.name).then(onViewFile) + } + c="edr-green" + style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }} + > + + + + )} + + void downloadBookingFile(charge.file!.id, charge.file!.name)} + c="edr-green" + style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }} + > + + + + + )} + + {(charge.status === "DRAFT" || charge.status === "SENT") && ( + + + {charge.status === "DRAFT" && ( + + )} + + )} + + ); +} + +function AddChargeModal({ + opened, + onClose, + busy, + onSubmit, +}: { + opened: boolean; + onClose: () => void; + busy: boolean; + onSubmit: (p: { + reason: string; + amount: number; + currency: string; + action: "draft" | "send"; + file?: File | null; + }) => void; +}) { + const [reason, setReason] = useState(""); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [file, setFile] = useState(null); + + const valid = reason.trim().length > 0 && Number(amount) > 0; + + const reset = () => { + setReason(""); + setAmount(""); + setCurrency("ETB"); + setFile(null); + }; + + const submit = (action: "draft" | "send") => { + if (!valid) return; + onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file }); + }; + + return ( + { + onClose(); + reset(); + }} + title="Add additional charge" + radius="md" + centered + > + +