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-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/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index d64361bf8..ac1c2ef24 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -46,6 +46,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 +259,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 +308,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 +324,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 +468,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"); 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/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/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/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/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 da280a271..fedc8a6dd 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -31,6 +31,14 @@ import { paymentClassificationReport } from './definitions/payment-classificatio 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'; /** @@ -71,6 +79,14 @@ export const REPORTS: ReportDefinition[] = [ 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/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index 107e11097..774c992fb 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -10,8 +10,14 @@ 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, ReportFilterOption } from './report.types'; @@ -59,7 +65,7 @@ async function resolveFilterOptions( 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, ) {} @@ -86,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 }, @@ -96,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.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index ebe52ef6b..526e35add 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -264,18 +264,30 @@ export const REVENUE_DATE = 'COALESCE(i.issued_at, i.created_at)'; * type-checks, it EXPLAINs clean, and it returns plausible garbage. */ export function periodExpr(params: Record): string { - const unit = resolvePeriod(params); - return `to_char(${periodTruncExpr(params)}, '${unit.fmt}')`; + return periodExprOn(REVENUE_DATE, params); } -function resolvePeriod(params: Record): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { +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 => - `date_trunc('${resolvePeriod(params).trunc}', ${REVENUE_DATE})`; + periodTruncExprOn(REVENUE_DATE, params); /** * The period as a number, for regression: seconds since epoch at the period's 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 9a20c0486..1587e2808 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 @@ -4585,8 +4585,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/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 c78dede1b..6e4a289c5 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 = @@ -87,6 +88,14 @@ export const REPORT_KEYS = [ "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]; @@ -396,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 @@ -1513,6 +1523,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", @@ -2182,6 +2202,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 diff --git a/apps/edr-freight-web/backoffice/index.html b/apps/edr-freight-web/backoffice/index.html index ef6fc82c5..796a88a4f 100644 --- a/apps/edr-freight-web/backoffice/index.html +++ b/apps/edr-freight-web/backoffice/index.html @@ -4,6 +4,12 @@ EDR Freight Backoffice + + +
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f0e9c2254..db2423c17 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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"; @@ -1202,6 +1203,16 @@ const App = () => { } /> */} + + + + } + /> } /> ; + label?: string; + size?: "xs" | "sm"; +} + +/** + * Opens the export dialog for one dataset. Renders nothing when the caller + * lacks permission for that dataset — the catalog only returns what they may + * export, so an absent entry IS the permission check. + */ +export function ExportButton({ + datasetKey, + params, + label = "Export", + size = "xs", +}: ExportButtonProps) { + const [opened, setOpened] = useState(false); + const { data: catalog, isLoading } = useQuery( + api.exports.catalog.queryOptions({ staleTime: 5 * 60_000 }), + ); + + const dataset = catalog?.find((d) => d.key === datasetKey); + + const exportParams = useMemo(() => { + const out: ExportParams = {}; + for (const [key, value] of Object.entries(params ?? {})) { + if (PAGINATION_KEYS.includes(key)) continue; + if (value === undefined || value === null || value === "") continue; + out[key] = value as string | number; + } + return out; + }, [params]); + + if (isLoading || !dataset) return null; + + return ( + <> + + + + + {opened && ( + setOpened(false)} + dataset={dataset} + params={exportParams} + /> + )} + + ); +} + +export default ExportButton; diff --git a/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx b/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx new file mode 100644 index 000000000..c8b19e56d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx @@ -0,0 +1,447 @@ +import { useMemo, useState } from "react"; +import { + Accordion, + Alert, + Anchor, + Badge, + Button, + Checkbox, + Chip, + Divider, + Group, + Loader, + Modal, + Popover, + Radio, + ScrollArea, + Select, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Download, FileSpreadsheet, FileText, Search, Table, TriangleAlert, X } from "lucide-react"; + +import { extractDownloadErrorMessage } from "@/components/warehouses/options"; +import { saveBlob } from "@/components/warehouses/pdf"; +import { useSavedViews } from "@/components/filters"; +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/services/api"; +import { exportsService } from "@/services/exports.service"; +import type { + ExportDatasetEntry, + ExportFormat, + ExportParams, +} from "@/types/exports"; + +const FORMAT_META: Record = { + csv: { label: "CSV", Icon: Table, hint: "Best for many columns" }, + xlsx: { label: "Excel", Icon: FileSpreadsheet, hint: "Typed number columns" }, + pdf: { label: "PDF", Icon: FileText, hint: "Few columns only" }, +}; + +const ROW_SCOPES = [ + { value: "all", label: "All matching filters" }, + { value: "100", label: "First 100" }, + { value: "1000", label: "First 1,000" }, + { value: "5000", label: "First 5,000" }, +]; + +/** Beyond this a PDF's columns are too narrow to read; we warn, the server allows it. */ +const PDF_FIELD_WARN = 12; + +export interface ExportDialogProps { + opened: boolean; + onClose: () => void; + dataset: ExportDatasetEntry; + /** The page's current filters. Pagination keys are stripped by ExportButton. */ + params: ExportParams; +} + +export function ExportDialog({ opened, onClose, dataset, params }: ExportDialogProps) { + const { toast } = useToast(); + const defaultKeys = useMemo( + () => dataset.fields.filter((f) => f.default).map((f) => f.key), + [dataset.fields], + ); + + const [selected, setSelected] = useState(defaultKeys); + // xlsx by default: typed number and date columns, so a spreadsheet opens it + // without the "is this text?" pass CSV needs. Falls back to whatever the + // dataset does offer rather than presetting a format it would reject. + const [format, setFormat] = useState( + () => (dataset.formats.includes("xlsx") ? "xlsx" : dataset.formats[0]), + ); + const [scope, setScope] = useState("all"); + const [search, setSearch] = useState(""); + const [exporting, setExporting] = useState(false); + const [presetName, setPresetName] = useState(""); + const [savePresetOpen, setSavePresetOpen] = useState(false); + + // A preset is stored as a query string so the existing saved-views hook can + // hold it unchanged — see useExportPresets note below. + const presets = useSavedViews(`export:${dataset.key}`); + + const { data: countData, isLoading: countLoading } = useQuery({ + ...api.exports.count.queryOptions({ input: { key: dataset.key, params } }), + enabled: opened, + staleTime: 30_000, + }); + + const total = countData?.total; + const cap = dataset.caps[format]; + const limit = scope === "all" ? undefined : Number(scope); + const rowsToExport = total === undefined ? undefined : Math.min(total, limit ?? total); + const overCap = total !== undefined && limit === undefined && total > cap; + + const selectedSet = useMemo(() => new Set(selected), [selected]); + const fieldKeys = useMemo(() => new Set(dataset.fields.map((f) => f.key)), [dataset.fields]); + + const visibleByGroup = useMemo(() => { + const q = search.trim().toLowerCase(); + const out = new Map(); + for (const group of dataset.groups) { + const fields = dataset.fields.filter( + (f) => f.group === group.id && (!q || f.label.toLowerCase().includes(q)), + ); + if (fields.length) out.set(group.id, fields); + } + return out; + }, [dataset.fields, dataset.groups, search]); + + // Which groups are expanded. Real state, NOT derived from the selection: + // deriving it made the accordion fully controlled with no way to change it, + // so clicking a group that had nothing selected re-collapsed on the next + // render and the group could only be opened by selecting a field in it. + // Seeded from `default` (not the live selection) so clearing every field + // doesn't slam the open groups shut underneath the user. + const [expanded, setExpanded] = useState(() => + dataset.groups + .filter((g) => dataset.fields.some((f) => f.group === g.id && f.default)) + .map((g) => g.id), + ); + + // Searching force-opens every group holding a match, so a hit can't hide + // inside a collapsed section. It only overrides what is displayed — the + // user's own expand state is untouched and returns when the search clears. + const openGroups = search.trim() ? [...visibleByGroup.keys()] : expanded; + + const toggleField = (key: string) => + setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); + + const toggleGroup = (groupId: string) => { + const keys = dataset.fields.filter((f) => f.group === groupId).map((f) => f.key); + const allOn = keys.every((k) => selectedSet.has(k)); + setSelected((prev) => + allOn ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])], + ); + }; + + const applyPreset = (query: string) => { + const p = new URLSearchParams(query); + // Drop any key the catalog no longer offers — a stale preset must not 400 + // the download by asking for a field that has since been removed. + const keys = (p.get("fields") ?? "").split(",").filter((k) => fieldKeys.has(k)); + if (keys.length) setSelected(keys); + const f = p.get("format") as ExportFormat | null; + if (f && dataset.formats.includes(f)) setFormat(f); + }; + + const savePreset = () => { + const name = presetName.trim(); + if (!name) return; + presets.save( + new URLSearchParams({ name, format, fields: selected.join(",") }).toString(), + ); + setPresetName(""); + setSavePresetOpen(false); + }; + + const handleDownload = async () => { + setExporting(true); + try { + const blob = await exportsService.download(dataset.key, format, selected, { + ...params, + ...(limit ? { limit } : {}), + }); + saveBlob(blob, `${dataset.key}-${new Date().toISOString().slice(0, 10)}.${format}`); + onClose(); + } catch (error) { + // Blob error bodies need the async decoder, or the server's row-cap + // message degrades to "Request failed with status code 400". + toast({ + variant: "destructive", + title: "Export failed", + description: await extractDownloadErrorMessage(error), + }); + } finally { + setExporting(false); + } + }; + + return ( + + + {/* Presets */} + + setSelected(defaultKeys)}> + Default columns + + setSelected(dataset.fields.map((f) => f.key))} + > + All columns + + {presets.views.map((view) => { + const name = new URLSearchParams(view.query).get("name") ?? "Preset"; + return ( + applyPreset(view.query)} + > + + {name} + { + e.stopPropagation(); + presets.remove(view.id); + }} + /> + + + ); + })} + + + + + + + setPresetName(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && savePreset()} + autoFocus + /> + + + + + + + + + {/* Pick the data on the left, configure the file on the right. Stacks + on a phone, where neither column has room to sit beside the other. */} +
+ {/* Fields */} +
+ + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + + + {selected.length} of {dataset.fields.length} fields selected + + + + + + + {dataset.groups.map((group) => { + const fields = visibleByGroup.get(group.id); + if (!fields) return null; + const groupKeys = dataset.fields + .filter((f) => f.group === group.id) + .map((f) => f.key); + const on = groupKeys.filter((k) => selectedSet.has(k)).length; + return ( + + + + 0 && on < groupKeys.length} + onClick={(e) => { + e.stopPropagation(); + toggleGroup(group.id); + }} + onChange={() => undefined} + /> + + {group.label} + + + {on}/{groupKeys.length} + + + + + + {fields.map((field) => ( + toggleField(field.key)} + /> + ))} + + + + ); + })} + + + +
+ + {/* Options */} +
+ +
+ + Format + + setFormat(v as ExportFormat)}> + + {dataset.formats.map((f) => { + const { label, Icon } = FORMAT_META[f]; + return ( + + + {/* Radio.Card's own checked state is a border tint + and nothing else, which reads as unselected at + this size. The Indicator is what actually says + which format is picked, as the report export + dialog's cards already do. */} + + + + + + {label} + + + + ); + })} + + +
+ + - - catalog?.some((r) => r.key === key)); + const visible = (keys: string[]) => + keys.filter((key) => catalog?.some((r) => r.key === key)); - // No revenue reports for this user — fall back to the old behaviour and send - // them to the first report they can actually open. - if (!visible.length) { + const revenue = visible(REVENUE_TILES); + const operations = visible(OPERATIONS_TILES); + + // No dashboard reports for this user — fall back to the old behaviour and + // send them to the first report they can actually open. + if (!revenue.length && !operations.length) { const first = catalog?.[0]; return ; } @@ -37,14 +48,31 @@ export default function ReportsLandingPage() { - - {visible.map((key) => ( - - ))} - + + {revenue.length > 0 && ( + + Revenue + + {revenue.map((key) => ( + + ))} + + + )} + + {operations.length > 0 && ( + + Operations + + {operations.map((key) => ( + + ))} + + + )} ); diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index a83a90fb4..77313f69c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -314,7 +314,9 @@ const RuleEngineResourcePage = () => { (f) => f.name === "originYardId" || f.name === "fromYardId" || - f.name === "toYardId", + f.name === "toYardId" || + // Operational targets pick a station by yard code. + f.name === "dimensionKey", ), ); const { data: yardOptions, isLoading: yardOptionsLoading } = @@ -476,6 +478,20 @@ const RuleEngineResourcePage = () => { .map(({ label, value }) => ({ label, value })), }; } + // An operational target's key is a category, a container class, or a + // station's YARD CODE — never a yard id, because the reports match it + // against what their classification CASE emits. + if (field.name === "dimensionKey") { + const staticOptions = field.optionsFromValues; + return { + ...field, + type: "select" as const, + optionsFromValues: (values: Record) => + String(values.dimension ?? "") === "station" + ? (yardOptions ?? []).map(({ label, code }) => ({ label, value: code })) + : (staticOptions?.(values) ?? []), + }; + } if (field.name === "originYardId" || field.name === "destinationYardId") { const end = field.name === "originYardId" ? "origin" : "destination"; return { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 4ff9aa3fb..cc899322a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -140,6 +140,37 @@ const TRADE_DIRECTIONS = [ { label: "Both", value: "BOTH" }, ]; +/** + * The cargo categories and container classes an operational target may be + * keyed on. + * + * Mirrors CARGO_CATEGORIES / CONTAINER_CLASSES in the API's + * `modules/reports/operations-classification.ts`, which is the source of truth: + * a report matches a target by this exact key, so a value here that the API + * does not emit is a plan the report will never find. The API spec + * `operations-classification.spec.ts` guards the API side of the pair. + */ +export const OPERATIONS_CARGO_CATEGORIES = [ + { label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" }, + { label: "Unimodal container import", value: "CONTAINER_IMPORT_UNIMODAL" }, + { label: "Export container", value: "CONTAINER_EXPORT" }, + { label: "Empty container", value: "EMPTY_CONTAINER" }, + { label: "Fertilizer", value: "FERTILIZER" }, + { label: "RoRo", value: "RORO" }, + { label: "Break bulk", value: "BREAK_BULK" }, + { label: "Sand", value: "SAND" }, + { label: "Bulk", value: "BULK" }, + { label: "Other imports", value: "OTHER_IMPORT" }, + { label: "Other export cargo", value: "OTHER_EXPORT" }, +]; + +export const OPERATIONS_CONTAINER_CLASSES = [ + { label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" }, + { label: "Unimodal container import", value: "CONTAINER_IMPORT_UNIMODAL" }, + { label: "Full export container", value: "CONTAINER_EXPORT" }, + { label: "Empty container return", value: "EMPTY_CONTAINER_RETURN" }, +]; + // Mirrors the YardCountry enum in @edr/types — the only two countries on the line. const YARD_COUNTRIES = [ { label: "Ethiopia", value: "Ethiopia" }, @@ -463,6 +494,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ optional: true, placeholder: "Select parent cargo type (optional)", }, + { + name: "fullTrainsetWagons", + label: "Wagons in a full trainset", + type: "number", + optional: true, + description: + "What the Trainset Performance report divides loaded wagons by — 37 for vehicles, 22 for sand. Leave blank to use the default in Operating standards.", + }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, { name: "hasLashing", label: "Charge lashing fee", type: "boolean" }, { @@ -624,6 +663,108 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" }, ], }, + { + slug: "operations-targets", + label: "Operational Targets", + category: "configuration", + subtitle: + "Planned TEU, trainsets and tonnage per period — the Plan column in the operations reports", + searchPlaceholder: "Search by category, station or note...", + supportsSearch: true, + cardTitleKey: "appliesToLabel", + cardSubtitleKey: "periodStart", + columns: [ + // The *Label columns are readable twins the API sends alongside the stored + // codes (see OperationsTargetsService.toRow) — the codes themselves are + // enums the reports join on and stay out of the grid. + { id: "periodStart", header: "Period start", accessorKey: "periodStart", format: "date" }, + { id: "periodLabel", header: "Period", accessorKey: "periodLabel" }, + { id: "metricLabel", header: "Metric", accessorKey: "metricLabel" }, + { id: "dimensionLabel", header: "Plan by", accessorKey: "dimensionLabel" }, + { id: "appliesToLabel", header: "Applies to", accessorKey: "appliesToLabel" }, + { id: "cargoCategoryLabel", header: "Cargo category", accessorKey: "cargoCategoryLabel" }, + { id: "plannedValue", header: "Plan", accessorKey: "plannedValue", format: "number" }, + ], + formFields: [ + { + name: "metric", + label: "Metric", + type: "select", + required: true, + options: [ + { label: "TEU", value: "TEU" }, + { label: "Trainsets", value: "TRAINSET" }, + { label: "Volume (tons)", value: "VOLUME_TONS" }, + ], + }, + { + name: "periodType", + label: "Period", + type: "select", + required: true, + options: [ + { label: "Weekly", value: "week" }, + { label: "Monthly", value: "month" }, + { label: "Quarterly", value: "quarter" }, + { label: "Yearly", value: "year" }, + ], + }, + { + name: "periodStart", + label: "Period start", + type: "date", + required: true, + description: "Any date inside the period — snapped to its start on save.", + }, + { + name: "dimension", + label: "Plan by", + type: "select", + required: true, + options: [ + { label: "Cargo category", value: "cargo_category" }, + { label: "Station", value: "station" }, + { label: "Container class", value: "container_class" }, + ], + }, + { + name: "dimensionKey", + label: "Applies to", + type: "select", + required: true, + placeholder: "Select", + // The valid keys depend on the chosen dimension, and must match what the + // reports emit exactly — a mismatch here is a target the report never + // finds. Station options are the live yard codes, injected by + // RuleEngineResourcePage. + optionsFromValues: (values) => { + const dimension = String(values.dimension ?? ""); + if (dimension === "container_class") return OPERATIONS_CONTAINER_CLASSES; + if (dimension === "station") return []; + return OPERATIONS_CARGO_CATEGORIES; + }, + }, + { + name: "cargoCategory", + label: "Cargo category", + type: "select", + required: true, + // A station's plan is per station AND per cargo type — the OCC report + // plans Nagad-Mojo container and Nagad-Mojo fertilizer separately. The + // other two dimensions already carry the category in the key above. + showWhen: { field: "dimension", equals: ["station"] }, + options: OPERATIONS_CARGO_CATEGORIES, + }, + { + name: "plannedValue", + label: "Planned value", + type: "number", + required: true, + description: "TEU, trainsets or tonnes — whichever the metric above is.", + }, + { name: "note", label: "Note", type: "text", optional: true }, + ], + }, { slug: "yard-distances", label: "Yard Distances", @@ -637,6 +778,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" }, { id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" }, { id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" }, + { id: "standardHours", header: "Standard (hrs)", accessorKey: "standardHours", format: "number" }, ], formFields: [ // Options injected at render from useYardOptions (RuleEngineResourcePage). @@ -650,6 +792,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ description: "Symmetric — one entry covers both directions. Route segments between these yards use this value.", }, + { + name: "standardHours", + label: "Standard running time (hrs)", + type: "number", + optional: true, + description: + "What the Train Delays report judges this leg against — 21h Negad to GMP, 20h to Adama, 20.5h to Modjo, 22h to Sebeta. Leave blank to use the default in Operating standards.", + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx new file mode 100644 index 000000000..e443fcffd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx @@ -0,0 +1,282 @@ +import { useState } from "react"; +import { Save } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { Input } from "@/shared/common/ui/input"; +import { Button } from "@/shared/common/ui/button"; +import { + useOperationsStandardsQuery, + useUpdateOperationsStandards, +} from "@/hooks/useOperationsStandards"; +import type { OperationsStandards } from "@/services/operationsStandards.service"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { useAuth } from "@/auth/useAuth"; + +type Field = { + name: keyof Omit; + label: string; + hint: string; + unit: string; + integer?: boolean; +}; + +type Section = { title: string; description: string; fields: Field[] }; + +/** + * Grouped the way the reporting spec reads, so an operator changing "the + * Djibouti standard" finds it next to the Ethiopian one rather than hunting a + * flat list of fifteen numbers. + */ +const SECTIONS: Section[] = [ + { + title: "Station staying time", + description: + "How long a train may stand at a station before the stop needs a reason. Used by Station Staying Time.", + fields: [ + { + name: "stationStandardHoursEthiopia", + label: "Ethiopian stations", + hint: "Standard stop on the Ethiopian side", + unit: "hrs", + }, + { + name: "stationStandardHoursDjibouti", + label: "Djibouti stations", + hint: "Standard stop on the Djibouti side", + unit: "hrs", + }, + ], + }, + { + title: "Turnaround cycle", + description: + "The full out-and-back a train is expected to complete in. Used by Turnaround Cycle.", + fields: [ + { + name: "cycleStandardHoursContainer", + label: "Container", + hint: "10 + 21 + 13 + 21", + unit: "hrs", + }, + { + name: "cycleStandardHoursBulkDmp", + label: "Bulk via DMP", + hint: "13 + 21 + 33 + 21", + unit: "hrs", + }, + { + name: "cycleStandardHoursBulkNagad", + label: "Bulk via Negad", + hint: "13 + 21 + 41 + 21", + unit: "hrs", + }, + { + name: "cycleStandardHoursBulkBcc", + label: "Bulk via BCC", + hint: "13 + 21 + 41 + 21", + unit: "hrs", + }, + ], + }, + { + title: "Delay", + description: + "Used by Train Delays when a yard pair has no standard of its own. Per-corridor times live on Yard Distances.", + fields: [ + { + name: "defaultLegStandardHours", + label: "Default leg standard", + hint: "Negad to GMP is 21 hours", + unit: "hrs", + }, + { + name: "delayToleranceMinutes", + label: "Tolerance", + hint: "Grace before a leg counts as delayed", + unit: "min", + integer: true, + }, + ], + }, + { + title: "Charged volume", + description: + "The standard weight capacity cargo is charged on, as opposed to what was weighed. Used by Charged and Actual Volumes.", + fields: [ + { + name: "chargedTonsFull20ft", + label: "Laden 20ft container", + hint: "Per container", + unit: "t", + }, + { + name: "chargedTonsFull40ft", + label: "Laden 40ft container", + hint: "Per container", + unit: "t", + }, + { + name: "chargedTonsEmpty20ft", + label: "Empty 20ft container", + hint: "Per container", + unit: "t", + }, + { + name: "chargedTonsEmpty40ft", + label: "Empty 40ft container", + hint: "Per container", + unit: "t", + }, + { + name: "chargedTonsPerWagonGeneral", + label: "Wagon of steel, fertilizer, rice, sugar", + hint: "Per wagon", + unit: "t", + }, + { + name: "chargedTonsPerWagonPerishable", + label: "Wagon of vegetables, milk, meat, livestock", + hint: "Per wagon", + unit: "t", + }, + ], + }, + { + title: "Trainset", + description: + "Used by Trainset Performance when a cargo type has no wagon count of its own — set those on Cargo Types.", + fields: [ + { + name: "defaultFullTrainsetWagons", + label: "Wagons in a full trainset", + hint: "37 for vehicles and 22 for sand are set per cargo type", + unit: "wagons", + integer: true, + }, + ], + }, +]; + +const ALL_FIELDS = SECTIONS.flatMap((s) => s.fields); + +/** + * The operating standards the operations reports measure against. + * + * A single settings row rather than constants in the code, because the business + * treats these as tunable — the corridor standard is explicitly described as + * flexible. Every value here changes what a report calls on-time, encouraging, + * or on plan, so the page shows what each one drives. + */ +export default function OperationsStandardsPage() { + const { user } = useAuth(); + const { data, isLoading } = useOperationsStandardsQuery(); + const update = useUpdateOperationsStandards(); + const [draft, setDraft] = useState>({}); + + const canEdit = + hasPermission(user, FREIGHT_PERMS.settings.operationsStandards.manage) || + hasPermission(user, FREIGHT_PERMS.admin); + + const valueOf = (field: Field): string => + draft[field.name] ?? (data ? String(data[field.name] ?? "") : ""); + + const invalid = (field: Field): boolean => { + const raw = draft[field.name]; + if (raw === undefined) return false; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return true; + return field.integer ? !Number.isInteger(parsed) : false; + }; + + const anyInvalid = ALL_FIELDS.some(invalid); + const dirty = Object.keys(draft).length > 0; + + const handleSave = async () => { + if (anyInvalid || !dirty) return; + const patch = Object.fromEntries( + Object.entries(draft).map(([key, value]) => [key, Number(value)]), + ); + await update.mutateAsync(patch); + setDraft({}); + }; + + return ( +
+
+
+

Operating standards

+

+ The figures every operations report measures actual performance + against. Changing one changes what the reports call on time, over + standard, or on plan — it does not change any charge a customer + pays. +

+
+ +
+ + {SECTIONS.map((section) => ( + + + {section.title} + {section.description} + + + {section.fields.map((field) => ( +
+ +
+ + setDraft((d) => ({ ...d, [field.name]: e.target.value })) + } + /> + + {field.unit} + +
+

+ {invalid(field) + ? field.integer + ? "Must be a whole number above zero" + : "Must be above zero" + : field.hint} +

+
+ ))} +
+
+ ))} + + {!canEdit && ( +

+ You can view these standards but not change them. +

+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 7aa6c77b2..b80822c56 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -5,11 +5,13 @@ import { Box, Button, Card, + Center, Checkbox, Divider, Group, Menu, Modal, + SegmentedControl, Select, SimpleGrid, Stack, @@ -19,7 +21,6 @@ import { ThemeIcon, } from "@mantine/core"; import { DateTimePicker } from "@mantine/dates"; -import { useDebouncedValue } from "@mantine/hooks"; import { isAxiosError } from "axios"; import { ArrowRight, @@ -27,27 +28,33 @@ import { CalendarClock, Clock, Eye, + LayoutGrid, MoreHorizontal, Navigation, Pencil, Play, Send, + Table2, Train, Weight, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import FleetToolbar from "@/components/fleet/FleetToolbar"; -import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; +import { + FilterBar, + routeParams, + toRuleEngineFooterProps, + useFilters, + type FilterDef, + type SortOption, +} from "@/components/filters"; +import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; -import { - directionColor, - directionRowStyle, -} from "@/components/trainBuilder/trainStatus"; +import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; import CreateScheduleWindowFields, { buildWindowRulePayload, @@ -55,10 +62,8 @@ import CreateScheduleWindowFields, { } from "@/components/trainScheduling/CreateScheduleWindowFields"; import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal"; import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions"; -import { - RouteCorridor, - StatusPill, -} from "@/components/trainScheduling/scheduleVisuals"; +import { ExportButton } from "@/components/export/ExportButton"; +import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; import { formatRouteLabel } from "@/services/routes.service"; @@ -67,12 +72,34 @@ import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions"; import type { CreateScheduleWindowRulePayload, - FreightType, TrainScheduleListFilters, TrainScheduleListItem, - TrainScheduleStatus, } from "@/types/trainScheduling"; -import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; +import { DataTable, DataTableFooter } from "@edr/ui-common"; + +const SCHEDULE_STATUS_OPTIONS = [ + { value: "DRAFT", label: "Draft" }, + { value: "SCHEDULED", label: "Scheduled" }, + { value: "DISPATCHED", label: "Dispatched" }, + { value: "ARRIVED", label: "Arrived" }, + { value: "CANCELLED", label: "Cancelled" }, +]; + +const FREIGHT_TYPE_OPTIONS = [ + { value: "CONTAINER", label: "Container" }, + { value: "BULK", label: "Bulk" }, + { value: "MIXED", label: "Mixed" }, +]; + +/** Server sort fields (TRAIN_SCHEDULE_SORT_FIELDS) in the shared "field:DIR" form. */ +const SORT_OPTIONS: SortOption[] = [ + { value: "createdAt:DESC", label: "Newest created" }, + { value: "createdAt:ASC", label: "Oldest created" }, + { value: "scheduledDepartureDate:DESC", label: "Departure ↓" }, + { value: "scheduledDepartureDate:ASC", label: "Departure ↑" }, + { value: "reference:ASC", label: "Reference ↑" }, + { value: "reference:DESC", label: "Reference ↓" }, +]; /** `min` for a `datetime-local` input: now, in the browser's local zone. */ const nowLocalDateTime = () => { @@ -114,34 +141,16 @@ export default function TrainScheduleV2ListPage() { const canCreate = canCreateSchedule(user); const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch); const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2"); - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [search, setSearch] = useState(""); - const [debouncedSearch] = useDebouncedValue(search, 300); - const [statusFilter, setStatusFilter] = useState<"ALL" | TrainScheduleStatus>("ALL"); - const [freightFilter, setFreightFilter] = useState<"ALL" | FreightType>("ALL"); - // Origin/destination hold yard IDs ("ALL" = no filter); the server matches - // the schedule's origin_station_id / destination_station_id exactly. - const [originFilter, setOriginFilter] = useState("ALL"); - const [destinationFilter, setDestinationFilter] = useState("ALL"); - // Default: newest-created first, matching the API's default order. Values - // are the server sort fields (see TRAIN_SCHEDULE_SORT_FIELDS). - const [sortBy, setSortBy] = useState< - "createdAt" | "scheduledDepartureDate" | "reference" - >("createdAt"); - const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); const [createOpen, setCreateOpen] = useState(false); const [windowSettingsId, setWindowSettingsId] = useState(null); // Dispatch is irreversible from this screen, so it goes through an explicit // confirmation. - const [dispatchTarget, setDispatchTarget] = - useState(null); + const [dispatchTarget, setDispatchTarget] = useState(null); // Actual departure — defaults to now when the dialog opens; past is fine. const [dispatchAt, setDispatchAt] = useState(null); // Cancelling is likewise irreversible — confirmed before the mutation fires. - const [cancelTarget, setCancelTarget] = - useState(null); - const [editDateSchedule, setEditDateSchedule] = - useState(null); + const [cancelTarget, setCancelTarget] = useState(null); + const [editDateSchedule, setEditDateSchedule] = useState(null); const [routeId, setRouteId] = useState(""); const [scheduleDate, setScheduleDate] = useState(""); const [trainId, setTrainId] = useState(""); @@ -155,51 +164,59 @@ export default function TrainScheduleV2ListPage() { const [windowForm, setWindowForm] = useState(null); // Recomputed each time the create modal opens so a long-lived tab can't keep // offering a stale "now" as the earliest selectable departure. - const minScheduleDate = useMemo( - () => (createOpen ? nowLocalDateTime() : ""), - [createOpen], + const minScheduleDate = useMemo(() => (createOpen ? nowLocalDateTime() : ""), [createOpen]); + + // Yard options for the origin/destination filters (shared routes reference + // list, so the choices don't shrink to whatever the current page shows). + const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 })); + const yardOptions = useMemo( + () => + (yardsQuery.data ?? []).map((y) => ({ + value: y.id, + label: y.label ?? y.code, + })), + [yardsQuery.data], ); - const resetPage = useCallback(() => { - setPagination((prev) => - prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }, - ); - }, [setPagination]); + // One Route pill covering both ends. It is the paired `route` type — which + // no longer forces both sides to be filled — so filtering by origin alone, + // by destination alone, or by several yards per side all still work, and the + // two ends read as the one thing an operator is actually picking. + const scheduleFilterDefs: FilterDef[] = useMemo( + () => [ + { + key: "status", + label: "Status", + type: "enum", + multiple: false, + options: SCHEDULE_STATUS_OPTIONS, + }, + { + key: "freightType", + label: "Freight", + type: "enum", + multiple: false, + options: FREIGHT_TYPE_OPTIONS, + }, + { + key: "route", + label: "Route", + type: "route", + options: yardOptions, + toParams: routeParams("originStationId", "destinationStationId"), + }, + ], + [yardOptions], + ); - // Search resets the page only once the debounced value settles — resetting - // per keystroke would refetch page 1 mid-typing. - useEffect(() => { - resetPage(); - }, [debouncedSearch, resetPage]); + const controls = useFilters(scheduleFilterDefs, { + defaultSort: "createdAt:DESC", + pageSize: 10, + }); // Fully server-driven list: pagination, search, filters, and sort all travel // as query params; the response envelope carries the page + totals. - const filters = useMemo( - () => ({ - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - ...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}), - ...(statusFilter !== "ALL" ? { status: statusFilter } : {}), - ...(freightFilter !== "ALL" ? { freightType: freightFilter } : {}), - ...(originFilter !== "ALL" ? { originStationId: originFilter } : {}), - ...(destinationFilter !== "ALL" - ? { destinationStationId: destinationFilter } - : {}), - sortBy, - sortOrder: sortDir === "asc" ? "ASC" : "DESC", - }), - [ - pagination.pageIndex, - pagination.pageSize, - debouncedSearch, - statusFilter, - freightFilter, - originFilter, - destinationFilter, - sortBy, - sortDir, - ], - ); + const filters = controls.params as unknown as TrainScheduleListFilters; const schedulesQuery = useQuery( api.trainScheduling.scheduleList.queryOptions({ @@ -211,14 +228,7 @@ export default function TrainScheduleV2ListPage() { staleTime: 30_000, }), ); - // Yard options for the origin/destination filters (shared routes reference - // list, so the choices don't shrink to whatever the current page shows). - const yardsQuery = useQuery( - api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }), - ); - const routesQuery = useQuery( - api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }), - ); + const routesQuery = useQuery(api.routes.list.queryOptions({ input: { status: "AVAILABLE" } })); const trainsQuery = useQuery( api.trainScheduling.availableTrains.queryOptions({ input: { routeId }, @@ -235,9 +245,7 @@ export default function TrainScheduleV2ListPage() { }), ); const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); - const dispatchSchedule = useMutation( - api.trainScheduling.dispatchSchedule.mutationOptions(), - ); + const dispatchSchedule = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions()); const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the @@ -252,9 +260,7 @@ export default function TrainScheduleV2ListPage() { const trainYardHint = useMemo(() => { if (!selectedRoute) return "Select a route first"; const originLabel = - selectedRoute.originYard?.label ?? - selectedRoute.originYard?.code ?? - "the route origin yard"; + selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "the route origin yard"; return `All schedulable built trains are shown — those not yet at ${originLabel} or already on future schedules are flagged`; }, [selectedRoute]); @@ -266,7 +272,6 @@ export default function TrainScheduleV2ListPage() { // current page, and the meta envelope carries the totals. const schedules = schedulesQuery.data?.items ?? []; const totalSchedules = schedulesQuery.data?.meta.total ?? 0; - const pageCount = Math.max(1, schedulesQuery.data?.meta.totalPages ?? 1); // Status/weight tiles count the visible page only — board-wide numbers would // need a dedicated summary endpoint now that the list is server-paginated. @@ -286,92 +291,62 @@ export default function TrainScheduleV2ListPage() { return base; }, [schedules]); - // Corridor filter options: every yard from the shared reference list, sent - // to the server as origin/destination station IDs. - const yardOptions = useMemo( - () => - (yardsQuery.data ?? []).map((y) => ({ - value: y.id, - label: y.label ?? y.code, - })), - [yardsQuery.data], - ); - const columns = useMemo((): ColumnDef[] => { const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; return [ { - id: "reference", - header: "Ref", + // Train, reference and status share one identity column — three + // stacked lines cost the width of the widest, not three columns. + id: "train", + header: "Train", + size: 170, meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - {row.original.reference ?? "—"} - - ), + cell: ({ row }) => , }, { id: "date", header: "Departure", + size: 110, meta: { headerClassName, cellClassName }, cell: ({ row }) => { const { day, time } = splitDate(row.original.scheduleDate); return ( - - - - - - - {day} - - - {time || "—"} - - - + + + {day} + + + {time || "—"} + + ); }, }, { id: "route", header: "Route", + size: 280, meta: { headerClassName, cellClassName }, cell: ({ row }) => ( - + {row.original.routeName ?? "—"} {row.original.direction ? ( - + {row.original.direction} ) : null} - + @@ -383,59 +358,6 @@ export default function TrainScheduleV2ListPage() { meta: { headerClassName, cellClassName }, cell: ({ row }) => , }, - { - id: "train", - header: "Train", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => { - // Schedules created from the Train Builder show the direction-matched - // run number first (falling back to the train code); legacy rows fall - // back to their locomotive set. - if (row.original.train) { - const subtitle = [row.original.trainNumber ? row.original.train.code : null, - row.original.train.trainName] - .filter(Boolean) - .join(" · "); - return ( - - - - - {row.original.trainNumber ?? row.original.train.code} - - {subtitle ? ( - - {subtitle} - - ) : null} - - - ); - } - const locos = - row.original.locomotives && row.original.locomotives.length > 0 - ? row.original.locomotives - : row.original.locomotive - ? [row.original.locomotive] - : []; - if (!locos.length) { - return ( - - — - - ); - } - return ( - - - - {locos[0].code} - {locos.length > 1 ? ` +${locos.length - 1}` : ""} - - - ); - }, - }, { id: "metrics", header: "Load", @@ -448,15 +370,9 @@ export default function TrainScheduleV2ListPage() { ), }, - { - id: "status", - header: "Status", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => , - }, { id: "actions", - size:32, + size: 32, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => { const schedule = row.original; @@ -481,9 +397,7 @@ export default function TrainScheduleV2ListPage() { } onClick={() => - navigate( - `/dashboard/operations/train-scheduling-v2/${schedule.id}/track`, - ) + navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`) } > Track @@ -560,10 +474,7 @@ export default function TrainScheduleV2ListPage() { toast({ title: "Booking window settings are still loading", variant: "destructive" }); return; } - const built = buildWindowRulePayload( - windowForm, - selectedRoute?.direction === "EXPORT", - ); + const built = buildWindowRulePayload(windowForm, selectedRoute?.direction === "EXPORT"); if ("error" in built) { toast({ title: built.error, variant: "destructive" }); return; @@ -631,114 +542,42 @@ export default function TrainScheduleV2ListPage() { - - { - if (!v) return; - setFreightFilter(v as "ALL" | FreightType); - resetPage(); - }} - data={[ - { value: "ALL", label: "All freight" }, - { value: "CONTAINER", label: "Container" }, - { value: "BULK", label: "Bulk" }, - { value: "MIXED", label: "Mixed" }, - ]} - w={140} - styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} - /> - { - setDestinationFilter(v ?? "ALL"); - resetPage(); - }} - data={[ - { value: "ALL", label: "All destinations" }, - ...yardOptions, - ]} - w={170} - styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} - /> - handlePageSizeChange(Number(e.target.value))} - className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20" - > - {opts.pageSizeOptions?.map((size) => ( - - ))} - + aria-label={labels.rowsPerPage} + size="sm" + w={96} + inputMode="numeric" + value={sizeDraft} + data={sizeOptions} + comboboxProps={{ position: "top", withinPortal: true }} + onChange={setSizeDraft} + onOptionSubmit={commitPageSize} + onBlur={() => commitPageSize(sizeDraft)} + onKeyDown={(e) => { + if (e.key === "Enter") commitPageSize(sizeDraft); + if (e.key === "Escape") setSizeDraft(`${pageSize}`); + }} + /> )} {opts.showRowCount && (