Merge pull request #1351 from Tria-plc/freight/nati-2

feat(WIP): filtering, exporting and more reports
This commit is contained in:
Nathnael Wondisha
2026-08-20 14:38:39 +03:00
committed by GitHub
119 changed files with 8506 additions and 921 deletions

View File

@@ -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 { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-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 { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
import { LogoSettingsModule } from "./modules/logo-settings/logo-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 { WarehousesModule } from "./modules/warehouses/warehouses.module";
import { OverviewModule } from "./modules/overview/overview.module"; import { OverviewModule } from "./modules/overview/overview.module";
import { ReportsModule } from "./modules/reports/reports.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 { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.module";
import { DriversModule } from "./modules/drivers/drivers.module"; import { DriversModule } from "./modules/drivers/drivers.module";
@@ -219,6 +221,7 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsModule, FileUploadSettingsModule,
DropdownSettingsModule, DropdownSettingsModule,
ExchangeSettingsModule, ExchangeSettingsModule,
OperationsReportingModule,
PaymentSettingsModule, PaymentSettingsModule,
StampSettingsModule, StampSettingsModule,
LogoSettingsModule, LogoSettingsModule,
@@ -239,6 +242,7 @@ if (!process.env.APPLICATION_NAME) {
WarehousesModule, WarehousesModule,
OverviewModule, OverviewModule,
ReportsModule, ReportsModule,
ExportsModule,
UserTradeAccessModule, UserTradeAccessModule,
VehiclesModule, VehiclesModule,
DriversModule, DriversModule,

View File

@@ -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 = <T>(cls: new () => T, query: Record<string, unknown>): 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);
});
});

View File

@@ -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;
});

View File

@@ -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);
});
});

View File

@@ -19,12 +19,18 @@ export class PaginationQueryDto {
@Min(1) @Min(1)
page?: number; 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() @IsOptional()
@Transform(({ value }) => parseInt(String(value), 10) || 20) @Transform(({ value }) => parseInt(String(value), 10) || 20)
@IsInt() @IsInt()
@Min(1) @Min(1)
@Max(100) @Max(500)
pageSize?: number; pageSize?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({

View File

@@ -20,7 +20,12 @@ export interface NormalizedPage {
} }
const DEFAULT_PAGE_SIZE = 20; 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). */ /** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
export function normalizePagination( export function normalizePagination(

View File

@@ -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<void> {
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<void> {
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;`);
}
}

View File

@@ -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 "NagadMojo multimodal container 122,010 t" and
* "NagadMojo 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<void> {
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<void> {
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;
`);
}
}

View File

@@ -46,6 +46,29 @@ export interface PayInvoiceOptions {
failureUrl?: string; 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. */ /** Booking context attached to a finance offline-USD invoice row. */
export interface OfflineUsdBookingInfo { export interface OfflineUsdBookingInfo {
id: string; id: string;
@@ -236,8 +259,32 @@ export class BillingService {
qb.andWhere("invoice.status = :status", { status: filter.status }); qb.andWhere("invoice.status = :status", { status: filter.status });
} }
if (filter.search) { 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( 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}%` }, { search: `%${filter.search}%` },
); );
} }
@@ -261,7 +308,7 @@ export class BillingService {
/** Per-user trade-direction scope, applied via the source booking. */ /** Per-user trade-direction scope, applied via the source booking. */
tradeDirections?: string[]; tradeDirections?: string[];
} = {}, } = {},
): Promise<{ items: Invoice[]; total: number }> { ): Promise<{ items: InvoiceListRow[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1; const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize = const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
@@ -277,7 +324,92 @@ export class BillingService {
this.applyInvoiceFilters(qb, filter); this.applyInvoiceFilters(qb, filter);
const [items, total] = await qb.getManyAndCount(); 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<T extends Invoice>(
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 const qb = this.dataSource
.getRepository(Invoice) .getRepository(Invoice)
.createQueryBuilder("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") .select("invoice.currency", "currency")
.addSelect("SUM(invoice.paidAmount)", "collected") .addSelect("SUM(invoice.paidAmount)", "collected")
.groupBy("invoice.currency"); .groupBy("invoice.currency");

View File

@@ -79,8 +79,10 @@ export interface BookingListFilterOptions {
createdTo?: string; createdTo?: string;
scheduledFrom?: string; scheduledFrom?: string;
scheduledTo?: string; scheduledTo?: string;
originYardId?: string; /** Any of these origin yards (OR). ANDed with `destinationYardId`. */
destinationYardId?: string; originYardId?: string[];
/** Any of these destination yards (OR). ANDed with `originYardId`. */
destinationYardId?: string[];
isGovernment?: 'true' | 'false'; isGovernment?: 'true' | 'false';
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */ /** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER'; customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
@@ -1151,14 +1153,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
scheduledTo: options.scheduledTo, scheduledTo: options.scheduledTo,
}); });
} }
if (options.originYardId) { // Each end is its own OR-list, and the two ends AND together — so
qb.andWhere('booking.origin_yard_id = :originYardId', { // "leaving Nagad or DMP" and "leaving Nagad, arriving Gelan" are both
originYardId: options.originYardId, // 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) { if (options.destinationYardId?.length) {
qb.andWhere('booking.destination_yard_id = :destinationYardId', { qb.andWhere('booking.destination_yard_id IN (:...destinationYardIds)', {
destinationYardId: options.destinationYardId, destinationYardIds: options.destinationYardId,
}); });
} }
if (options.isGovernment === 'true') { if (options.isGovernment === 'true') {

View File

@@ -9,6 +9,7 @@ import {
TRADE_DIRECTIONS, TRADE_DIRECTIONS,
} from './create-booking.dto'; } from './create-booking.dto';
import { PAYMENT_STATUSES } from '../entities/booking.entity'; import { PAYMENT_STATUSES } from '../entities/booking.entity';
import { IdListParam } from '../../../common/dto/id-list.transform';
export class FilterBookingDto { export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES }) @ApiPropertyOptional({ enum: BOOKING_STATUSES })
@@ -96,15 +97,23 @@ export class FilterBookingDto {
@IsDateString() @IsDateString()
scheduledTo?: string; 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() @IsOptional()
@IsUUID() @IdListParam()
originYardId?: string; @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() @IsOptional()
@IsUUID() @IdListParam()
destinationYardId?: string; @IsUUID(undefined, { each: true })
destinationYardId?: string[];
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' }) @ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
@IsOptional() @IsOptional()

View File

@@ -48,8 +48,10 @@ export interface ContractListFilterOptions {
hasClearanceDocuments?: boolean; hasClearanceDocuments?: boolean;
createdFrom?: string; createdFrom?: string;
createdTo?: string; createdTo?: string;
originYardId?: string; /** Any of these origin yards (OR). ANDed with `destinationYardId`. */
destinationYardId?: string; originYardId?: string[];
/** Any of these destination yards (OR). ANDed with `originYardId`. */
destinationYardId?: string[];
} }
@Injectable() @Injectable()
@@ -494,20 +496,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
// Routes are one-to-many (a contract can list several lanes), so origin // Routes are one-to-many (a contract can list several lanes), so origin
// and destination each need their own EXISTS — a plain join would // and destination each need their own EXISTS — a plain join would
// duplicate the contract row per matching route. // 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( qb.andWhere(
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' + 'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' + 'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
'AND cr_o.origin_yard_id = :originYardId)', 'AND cr_o.origin_yard_id IN (:...originYardIds))',
{ originYardId: options.originYardId }, { originYardIds: options.originYardId },
); );
} }
if (omit !== 'destinationYardId' && options.destinationYardId) { if (omit !== 'destinationYardId' && options.destinationYardId?.length) {
qb.andWhere( qb.andWhere(
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' + 'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' + 'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
'AND cr_d.destination_yard_id = :destinationYardId)', 'AND cr_d.destination_yard_id IN (:...destinationYardIds))',
{ destinationYardId: options.destinationYardId }, { destinationYardIds: options.destinationYardId },
); );
} }
} }

View File

@@ -3,6 +3,7 @@ import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity'; 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 TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
@@ -62,20 +63,22 @@ export class FilterContractDto {
paymentCurrency?: string; paymentCurrency?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'uuid', description:
description: 'Only contracts with a route starting at this yard.', 'Only contracts with a route starting at one of these yards — a single id or a comma-separated list.',
}) })
@IsOptional() @IsOptional()
@IsUUID() @IdListParam()
originYardId?: string; @IsUUID(undefined, { each: true })
originYardId?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'uuid', description:
description: 'Only contracts with a route ending at this yard.', 'Only contracts with a route ending at one of these yards — a single id or a comma-separated list. ANDed with originYardId.',
}) })
@IsOptional() @IsOptional()
@IsUUID() @IdListParam()
destinationYardId?: string; @IsUUID(undefined, { each: true })
destinationYardId?: string[];
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' }) @ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
@IsOptional() @IsOptional()

View File

@@ -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);
},
};

View File

@@ -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);
},
};

View File

@@ -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.
},
};

View File

@@ -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);
},
};

View File

@@ -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.
},
};

View File

@@ -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);
},
};

View File

@@ -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);
},
};

View File

@@ -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.
},
};

View File

@@ -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.
},
};

View File

@@ -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<ExportFilterOption[]>;
}
/** Raw query-string bag. Per-registry filter keys, so `forbidNonWhitelisted` can't police it. */
export type RawFilterQuery = Record<string, string | undefined>;
/**
* 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<string, unknown> {
const params: Record<string, unknown> = {};
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<string, ExportFilterOption[]>();
export async function resolveFilterOptions(
filters: ExportFilterDef[],
ds: DataSource,
): Promise<ExportFilterDef[]> {
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 };
}),
);
}

View File

@@ -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([]);
});
});

View File

@@ -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<string>();
const onStack = new Set<string>();
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<ObjectLiteral> {
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<ObjectLiteral> {
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,
};
}

View File

@@ -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);
});
});

View File

@@ -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<ExportFormat, { type: string; ext: string }> = {
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<T extends { key: string }>(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;
}

View File

@@ -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<number> {
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<Record<string, unknown>[]> {
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.`,
);
}
}

View File

@@ -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);

View File

@@ -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<ObjectLiteral>;
/** 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<string, unknown>;
/** 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<ObjectLiteral>; 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<ObjectLiteral>): 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<ExportField, 'key' | 'label' | 'type' | 'group' | 'default'>[];
filters: ExportFilterDef[];
formats: ('csv' | 'xlsx' | 'pdf')[];
caps: { csv: number; xlsx: number; pdf: number };
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
}

View File

@@ -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<string, string | undefined>;
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<ExportCatalogEntry[]> {
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<void> {
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;
}
}

View File

@@ -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 {}

View File

@@ -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);
});
});

View File

@@ -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 <h1>. */
title: string;
description?: string;
/** Log label handed to PdfRenderService, e.g. "report:bookings-list". */
label: string;
columns: ExportColumnLike[];
rows: Record<string, unknown>[];
kpis?: ExportKpiLike[];
}
const NUMBER_FORMAT: Partial<Record<ExportFieldType, string>> = {
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<Buffer> {
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<Buffer> {
const workbook = this.buildWorkbook(doc, { includeKpis: false });
const buffer = await workbook.csv.writeBuffer();
return Buffer.from(buffer);
}
async toPdf(doc: TabularDoc): Promise<Buffer> {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kpiHtml = kpis?.length
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
.map(
(k) =>
`<div class="tile"><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
)
.join('')}</div>`
: '';
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const body = rows
.map(
(row) =>
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
)
.join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
h1 { font-size: 16px; margin-bottom: 4px; }
p.subtitle { color: #666; margin: 0 0 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
th { background: #f3f3f3; }
</style></head><body>
<h1>${esc(title)}</h1>
<p class="subtitle">${esc(description ?? '')}</p>
${kpiHtml}
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
</body></html>`;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateOperationsTargetDto } from './create-operations-target.dto';
export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {}

View File

@@ -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;
}

View File

@@ -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<TargetMetric, string> = {
TEU: 'TEU',
TRAINSET: 'Trainsets',
VOLUME_TONS: 'Volume (tons)',
};
export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
cargo_category: 'Cargo category',
station: 'Station',
container_class: 'Container class',
};
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
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 NagadMojo container and NagadMojo 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;
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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<OperationsStandard>,
) {}
async get(): Promise<OperationsStandard> {
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<OperationsStandard> {
const current = await this.get();
await this.repository.update(current.id, { ...dto, updatedById: userId });
return this.get();
}
}

View File

@@ -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);
}
}

View File

@@ -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<string, Map<string, string>> = {
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<OperationsTarget>,
) {}
/** Yard code → label, for station targets. Reference data, read per list. */
private async yardLabels(): Promise<Map<string, string>> {
const rows = await this.repository.manager.query<Array<{ code: string; label: string }>>(
`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<string, string>): 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<PaginatedResponse<OperationsTargetRow>> {
const sortable: Record<string, string> = {
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<OperationsTarget> {
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<OperationsTarget> {
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<OperationsTarget> {
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<void> {
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<void> {
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`,
);
}
}
}

View File

@@ -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, unknown>): 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<string, unknown>, 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<string, unknown>, 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<ObjectLiteral> {
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 ' +
'stations 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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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 trains ' +
'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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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<ObjectLiteral> {
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 trains ' +
'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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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' },
];
},
};

View File

@@ -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<ObjectLiteral> {
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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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<ObjectLiteral> {
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 trains ' +
'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: '%' },
];
},
};

View File

@@ -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)');
});
});

View File

@@ -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<ObjectLiteral> {
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<ObjectLiteral> {
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<ObjectLiteral>,
params: Record<string, unknown>,
): 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<ObjectLiteral>,
params: Record<string, unknown>,
): 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 — NagadDire 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, unknown>,
): 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<string, unknown>,
): Record<string, unknown> => ({
planFrom: params.dateFrom ?? null,
planTo: params.dateTo ?? null,
});

View File

@@ -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);
});
});

View File

@@ -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<ReportDefinition, 'columns'>,
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;
}

View File

@@ -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<Record<ReportColumn['type'], string>> = {
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<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[] = def.columns,
): Promise<Buffer> {
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<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[] = def.columns,
): Promise<Buffer> {
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<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[],
): string {
const esc = (v: unknown) =>
String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kpiHtml = kpis.length
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
.map(
(k) =>
`<div><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
)
.join('')}</div>`
: '';
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const body = rows
.map(
(row) =>
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
)
.join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
h1 { font-size: 16px; margin-bottom: 4px; }
p.desc { color: #666; margin-top: 0 0 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
th { background: #f3f3f3; }
</style></head><body>
<h1>${esc(def.title)}</h1>
<p class="desc">${esc(def.description)}</p>
${kpiHtml}
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
</body></html>`;
}
}

View File

@@ -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( async runAll(
def: ReportDefinition, def: ReportDefinition,
raw: RawReportQuery, raw: RawReportQuery,
directions: string[] | null, directions: string[] | null,
limit: number, { cap, limit }: { cap: number; limit?: number },
): Promise<{ columns: typeof def.columns; items: Record<string, unknown>[]; kpis: ReportRunResult['kpis'] }> { ): Promise<{ columns: typeof def.columns; items: Record<string, unknown>[]; kpis: ReportRunResult['kpis'] }> {
const params = coerceParams(def, raw); const params = coerceParams(def, raw);
const ctx = { ds: this.ds, params, directions }; 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. // export is supposed to match what the user is looking at.
const sort = resolveSort(def, raw.sortBy, raw.sortOrder); const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
if (sort) qb.orderBy(sort.expr, sort.dir); if (sort) qb.orderBy(sort.expr, sort.dir);
const items = await qb.limit(limit).getRawMany();
if (items.length >= limit) { const ceiling = limit ?? cap;
throw new BadRequestException( // ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are
`Export exceeds the ${limit}-row cap for this format. Narrow the filters.`, // 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) : []; const kpis = def.summary ? await def.summary(ctx) : [];
return { columns: def.columns, items, kpis }; return { columns: def.columns, items, kpis };

View File

@@ -31,6 +31,14 @@ import { paymentClassificationReport } from './definitions/payment-classificatio
import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report'; import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report';
import { receivablesPayablesReport } from './definitions/receivables-payables.report'; import { receivablesPayablesReport } from './definitions/receivables-payables.report';
import { revenueAnomaliesReport } from './definitions/revenue-anomalies.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'; import { ReportDefinition } from './report.types';
/** /**
@@ -71,6 +79,14 @@ export const REPORTS: ReportDefinition[] = [
revenueReconciliationReport, revenueReconciliationReport,
receivablesPayablesReport, receivablesPayablesReport,
revenueAnomaliesReport, revenueAnomaliesReport,
stationStayingTimeReport,
turnaroundCycleReport,
trainDelaysReport,
trainsetPerformanceReport,
teuPerformanceReport,
cargoVolumePerformanceReport,
chargedVsActualVolumeReport,
cargoVolumeByStationReport,
]; ];
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r])); const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));

View File

@@ -10,8 +10,14 @@ import { BookingStaff } from '../../common/booking-guards';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { ReportExportService } from './report-export.service'; import {
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util'; 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 { RawReportQuery, ReportRunnerService } from './report-runner.service';
import { REPORTS, getReport } from './report.registry'; import { REPORTS, getReport } from './report.registry';
import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types'; import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types';
@@ -59,7 +65,7 @@ async function resolveFilterOptions(
export class ReportsController { export class ReportsController {
constructor( constructor(
private readonly runner: ReportRunnerService, private readonly runner: ReportRunnerService,
private readonly exportService: ReportExportService, private readonly exportService: TabularExportService,
private readonly userTradeAccessService: UserTradeAccessService, private readonly userTradeAccessService: UserTradeAccessService,
@InjectDataSource() private readonly dataSource: DataSource, @InjectDataSource() private readonly dataSource: DataSource,
) {} ) {}
@@ -86,7 +92,7 @@ export class ReportsController {
} }
@Get(':key/export') @Get(':key/export')
@ApiOperation({ summary: 'Export a report to xlsx or pdf' }) @ApiOperation({ summary: 'Export a report to xlsx, csv or pdf' })
async export( async export(
@Param('key') key: string, @Param('key') key: string,
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string }, @Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
@@ -96,23 +102,30 @@ export class ReportsController {
const def = this.resolve(key, user); const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format); const format = resolveExportFormat(query.format);
const cap = resolveExportCap(format, query.limit); const exportColumns = pickByKey(def.columns, query.fields);
const exportColumns = resolveExportColumns(def, 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 = const buffer =
format === 'pdf' format === 'pdf'
? await this.exportService.toPdf(def, items, kpis, exportColumns) ? await this.exportService.toPdf(doc)
: await this.exportService.toXlsx(def, items, kpis, exportColumns); : format === 'csv'
? await this.exportService.toCsv(doc)
: await this.exportService.toXlsx(doc);
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`; const mime = EXPORT_MIME[format];
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${def.key}.${mime.ext}"`);
res.setHeader( res.setHeader('Content-Type', mime.type);
'Content-Type',
format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.send(buffer); res.send(buffer);
} }

View File

@@ -1,14 +1,15 @@
import { Module } from '@nestjs/common'; 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 { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { ReportExportService } from './report-export.service';
import { ReportRunnerService } from './report-runner.service'; import { ReportRunnerService } from './report-runner.service';
import { ReportsController } from './reports.controller'; import { ReportsController } from './reports.controller';
@Module({ @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], controllers: [ReportsController],
providers: [ReportRunnerService, ReportExportService], providers: [ReportRunnerService],
}) })
export class ReportsModule {} export class ReportsModule {}

View File

@@ -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. * type-checks, it EXPLAINs clean, and it returns plausible garbage.
*/ */
export function periodExpr(params: Record<string, unknown>): string { export function periodExpr(params: Record<string, unknown>): string {
const unit = resolvePeriod(params); return periodExprOn(REVENUE_DATE, params);
return `to_char(${periodTruncExpr(params)}, '${unit.fmt}')`;
} }
function resolvePeriod(params: Record<string, unknown>): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { export function resolvePeriod(
params: Record<string, unknown>,
): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] {
const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS; const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS;
return PERIOD_UNITS[key] ?? PERIOD_UNITS.month; 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, unknown>): string =>
`to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`;
export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): string =>
`date_trunc('${resolvePeriod(params).trunc}', ${dateExpr})`;
/** The period's start timestamp — what to GROUP BY when a report needs it numerically. */ /** The period's start timestamp — what to GROUP BY when a report needs it numerically. */
export const periodTruncExpr = (params: Record<string, unknown>): string => export const periodTruncExpr = (params: Record<string, unknown>): 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 * The period as a number, for regression: seconds since epoch at the period's

View File

@@ -94,6 +94,16 @@ export class CreateCargoTypeDto {
@IsBoolean() @IsBoolean()
isActive?: boolean; 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 }) @ApiPropertyOptional({ default: 1 })
@IsOptional() @IsOptional()
@IsInt() @IsInt()

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; 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 }) => const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value); value === '' || value == null ? value : Number(value);
@@ -19,4 +19,15 @@ export class CreateYardDistanceDto {
@IsNumber() @IsNumber()
@Min(0.01) @Min(0.01)
distanceKm!: number; 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;
} }

View File

@@ -71,6 +71,15 @@ export class CargoType extends BaseEntity {
@Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true }) @Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true })
tonsPerWagonMap?: Record<string, number> | null; tonsPerWagonMap?: Record<string, number> | 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 }) @Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean; requiresDirectorApproval!: boolean;

View File

@@ -32,4 +32,15 @@ export class YardDistance extends BaseEntity {
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 }) @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 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;
} }

View File

@@ -204,6 +204,7 @@ export class CargoTypesService {
itemsPerWagonMap: dto.itemsPerWagonMap, itemsPerWagonMap: dto.itemsPerWagonMap,
}), }),
tonsPerWagonMap, tonsPerWagonMap,
fullTrainsetWagons: dto.fullTrainsetWagons ?? null,
displayOrder, displayOrder,
}); });
} }

View File

@@ -61,6 +61,7 @@ export class YardDistancesService {
fromYardId: dto.fromYardId, fromYardId: dto.fromYardId,
toYardId: dto.toYardId, toYardId: dto.toYardId,
distanceKm: dto.distanceKm.toFixed(2), distanceKm: dto.distanceKm.toFixed(2),
standardHours: dto.standardHours != null ? dto.standardHours.toFixed(2) : null,
}); });
return toRow(created); return toRow(created);
} }
@@ -78,6 +79,9 @@ export class YardDistancesService {
fromYardId, fromYardId,
toYardId, toYardId,
...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}), ...(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`); if (!updated) throw new NotFoundException(`Yard distance ${id} not found`);
return toRow(updated); return toRow(updated);

View File

@@ -1,6 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator'; import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { IdListParam } from '../../../common/dto/id-list.transform';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { import {
TRAIN_SCHEDULE_STATUSES, TRAIN_SCHEDULE_STATUSES,
@@ -50,15 +51,22 @@ export class ListTrainSchedulesQueryDto extends PaginationQueryDto {
@IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[]) @IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[])
freightType?: TrainScheduleFreightType; freightType?: TrainScheduleFreightType;
/** Origin station/yard id (exact match). */ /** Origin station/yard — one id or a comma-separated list; matches ANY of them. */
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({
description: 'Origin station/yard id, or a comma-separated list (matches any of them).',
})
@IsOptional() @IsOptional()
@IsUUID() @IdListParam()
originStationId?: string; @IsUUID(undefined, { each: true })
originStationId?: string[];
/** Destination station/yard id (exact match). */ /** Destination station/yard — one id or a comma-separated list; ANDed with the origin. */
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({
description:
'Destination station/yard id, or a comma-separated list (matches any of them). ANDed with originStationId.',
})
@IsOptional() @IsOptional()
@IsUUID() @IdListParam()
destinationStationId?: string; @IsUUID(undefined, { each: true })
destinationStationId?: string[];
} }

View File

@@ -4585,8 +4585,15 @@ export class TrainSchedulingService {
const base: FindOptionsWhere<TrainSchedule> = {}; const base: FindOptionsWhere<TrainSchedule> = {};
if (allowedDirections) base.direction = In(allowedDirections) as never; if (allowedDirections) base.direction = In(allowedDirections) as never;
if (query.status) base.status = query.status; if (query.status) base.status = query.status;
if (query.originStationId) base.originStationId = query.originStationId; // Each end is an OR-list, the two ends AND together (origin-only and
if (query.destinationStationId) base.destinationStationId = query.destinationStationId; // 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; if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
// Search fans out across every human-recognizable label; each OR variant // Search fans out across every human-recognizable label; each OR variant

View File

@@ -72,12 +72,14 @@ export class ListWagonsQueryDto {
@Min(1) @Min(1)
page?: number; 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() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
@Max(100) @Max(500)
pageSize?: number; pageSize?: number;
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' }) @ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })

View File

@@ -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<string, string>;
cargoTypes: Map<string, string>;
locomotiveId: string | null;
}
async function loadIds(ds: DataSource): Promise<Ids> {
const yards = new Map<string, string>();
for (const row of await ds.query<Array<{ id: string; code: string }>>(
`SELECT id, code FROM freight.yards WHERE deleted_at IS NULL`,
)) {
yards.set(row.code, row.id);
}
const cargoTypes = new Map<string, string>();
for (const row of await ds.query<Array<{ id: string; code: string }>>(
`SELECT id, code FROM freight.cargo_types WHERE deleted_at IS NULL`,
)) {
cargoTypes.set(row.code, row.id);
}
const [loco] = await ds.query<Array<{ id: string }>>(
`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<void> {
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<Array<{ id: string }>>(
`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<void> {
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<Array<{ id: string }>>(
`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<void> {
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<Array<{ id: string }>>(
`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<void> {
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<void> {
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<string> {
const existing = await ds.query<Array<{ id: string }>>(
`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<Array<{ id: string }>>(
`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<Array<{ id: string }>>(
`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<string> {
const existing = await ds.query<Array<{ id: string }>>(
`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<Array<{ id: string }>>(
`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<void> {
const existing = await ds.query<Array<{ id: string }>>(
`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<void> {
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<void> {
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<void> {
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);
});

View File

@@ -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<void> {
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();

View File

@@ -23,6 +23,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
// so a mid-list insert would shift ids already seeded for later slugs. // so a mid-list insert would shift ids already seeded for later slugs.
"truck-types", "truck-types",
"transit-agents", "transit-agents",
"operations-targets",
] as const; ] as const;
export type RuleEngineResourceSlug = export type RuleEngineResourceSlug =
@@ -87,6 +88,14 @@ export const REPORT_KEYS = [
"revenue-reconciliation", "revenue-reconciliation",
"receivables-payables", "receivables-payables",
"revenue-anomalies", "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; ] as const;
export type ReportKey = (typeof REPORT_KEYS)[number]; export type ReportKey = (typeof REPORT_KEYS)[number];
@@ -396,6 +405,7 @@ const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
"approval-rules": "b2000001-0001-4000-8000-000000000013", "approval-rules": "b2000001-0001-4000-8000-000000000013",
"yard-distances": "b2000001-0001-4000-8000-000000000018", "yard-distances": "b2000001-0001-4000-8000-000000000018",
"transit-agents": "b2000003-0001-4000-8000-000000000001", "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 // 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", "edr_freight_app:settings:exchange_rate:manage",
"Set the USD-ETB fallback rate", "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( perm(
"b4d00001-0001-4000-8000-000000000003", "b4d00001-0001-4000-8000-000000000003",
"edr_freight_app:settings:manual_payment:view", "edr_freight_app:settings:manual_payment:view",
@@ -2182,6 +2202,12 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage", 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 // Whether Finance may settle invoices by hand, per currency. Split
// view/manage on purpose: Finance reads it (the worklist offers only the // view/manage on purpose: Finance reads it (the worklist offers only the
// enabled currencies) but must not switch its own channel on — same // enabled currencies) but must not switch its own channel on — same

View File

@@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Freight Backoffice</title> <title>EDR Freight Backoffice</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -86,6 +86,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage";
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard"; import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard";
import FirstMilePage from "./pages/operations/FirstMilePage"; import FirstMilePage from "./pages/operations/FirstMilePage";
@@ -1202,6 +1203,16 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> */} /> */}
<Route
path="configuration/operations-standards"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.operationsStandards.view}
>
<OperationsStandardsPage />
</RequirePermission>
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} /> <Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route <Route
path="configuration/cargo-types/:id" path="configuration/cargo-types/:id"

View File

@@ -0,0 +1,86 @@
import { useMemo, useState } from "react";
import { Button, Tooltip } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Download } from "lucide-react";
import { api } from "@/services/api";
import type { ExportParams } from "@/types/exports";
import { ExportDialog } from "./ExportDialog";
/**
* Pagination is a screen concern, never an export one — stripped here, once,
* rather than at each of the pages that mount this.
*/
const PAGINATION_KEYS = ["page", "pageSize", "skip", "take"];
export interface ExportButtonProps {
/** Catalog dataset key, e.g. "bookings". */
datasetKey: string;
/**
* The page's current filters — `useFilters().params` verbatim, or a
* non-migrated page's hand-built filter object. Deliberately not typed as
* `UseFilters`: four of the pages that need this haven't migrated yet.
*/
params?: Record<string, unknown>;
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<ExportParams>(() => {
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 (
<>
<Tooltip label={dataset.description} openDelay={500}>
<Button
variant="default"
radius="md"
size={size}
leftSection={<Download size={14} />}
onClick={() => setOpened(true)}
>
{label}
</Button>
</Tooltip>
{opened && (
<ExportDialog
opened={opened}
onClose={() => setOpened(false)}
dataset={dataset}
params={exportParams}
/>
)}
</>
);
}
export default ExportButton;

View File

@@ -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<ExportFormat, { label: string; Icon: typeof FileText; hint: string }> = {
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<string[]>(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<ExportFormat>(
() => (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<string, typeof dataset.fields>();
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<string[]>(() =>
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 (
<Modal
opened={opened}
onClose={onClose}
title={`Export ${dataset.title.toLowerCase()}`}
size="62rem"
radius="md"
>
<Stack gap="md">
{/* Presets */}
<Group gap="xs" wrap="wrap">
<Chip size="xs" checked={false} onClick={() => setSelected(defaultKeys)}>
Default columns
</Chip>
<Chip
size="xs"
checked={false}
onClick={() => setSelected(dataset.fields.map((f) => f.key))}
>
All columns
</Chip>
{presets.views.map((view) => {
const name = new URLSearchParams(view.query).get("name") ?? "Preset";
return (
<Chip
key={view.id}
size="xs"
checked={false}
onClick={() => applyPreset(view.query)}
>
<Group gap={4} wrap="nowrap">
{name}
<X
size={12}
onClick={(e) => {
e.stopPropagation();
presets.remove(view.id);
}}
/>
</Group>
</Chip>
);
})}
<Popover opened={savePresetOpen} onChange={setSavePresetOpen} position="bottom-start">
<Popover.Target>
<Button
variant="subtle"
size="compact-xs"
disabled={!selected.length}
onClick={() => setSavePresetOpen((o) => !o)}
>
Save preset
</Button>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group gap="xs" wrap="nowrap">
<TextInput
size="xs"
placeholder="Preset name"
value={presetName}
onChange={(e) => setPresetName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && savePreset()}
autoFocus
/>
<Button size="compact-xs" onClick={savePreset} disabled={!presetName.trim()}>
Save
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<Divider />
{/* 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. */}
<div className="flex flex-col gap-6 sm:flex-row">
{/* Fields */}
<div className="min-w-0 flex-[7]">
<Stack gap="xs">
<TextInput
size="xs"
placeholder="Search fields…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{selected.length} of {dataset.fields.length} fields selected
</Text>
<Button variant="subtle" size="compact-xs" onClick={() => setSelected(defaultKeys)}>
Reset
</Button>
</Group>
<ScrollArea.Autosize mah={420} type="auto">
<Accordion
multiple
value={openGroups}
onChange={setExpanded}
chevronPosition="left"
variant="contained"
>
{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 (
<Accordion.Item key={group.id} value={group.id}>
<Accordion.Control>
<Group gap="xs" wrap="nowrap">
<Checkbox
size="xs"
checked={on === groupKeys.length}
indeterminate={on > 0 && on < groupKeys.length}
onClick={(e) => {
e.stopPropagation();
toggleGroup(group.id);
}}
onChange={() => undefined}
/>
<Text size="sm" fw={500}>
{group.label}
</Text>
<Badge size="xs" variant="light" color={on ? "edr-green" : "gray"}>
{on}/{groupKeys.length}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={2}>
{fields.map((field) => (
<Checkbox
key={field.key}
size="xs"
label={field.label}
checked={selectedSet.has(field.key)}
onChange={() => toggleField(field.key)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</ScrollArea.Autosize>
</Stack>
</div>
{/* Options */}
<div className="min-w-0 flex-[5]">
<Stack gap="md">
<div>
<Text size="sm" fw={600} mb="xs">
Format
</Text>
<Radio.Group value={format} onChange={(v) => setFormat(v as ExportFormat)}>
<SimpleGrid cols={dataset.formats.length} spacing="xs">
{dataset.formats.map((f) => {
const { label, Icon } = FORMAT_META[f];
return (
<Radio.Card key={f} value={f} radius="md" p="xs">
<Stack gap={4} align="center">
{/* 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. */}
<Group gap={6} wrap="nowrap">
<Radio.Indicator size="xs" />
<Icon size={18} />
</Group>
<Text size="xs" fw={500}>
{label}
</Text>
</Stack>
</Radio.Card>
);
})}
</SimpleGrid>
</Radio.Group>
</div>
<Select
label="Rows"
size="sm"
radius="md"
value={scope}
onChange={(v) => setScope(v ?? "all")}
data={ROW_SCOPES}
allowDeselect={false}
/>
<div>
<Text size="xs" c="dimmed">
Matching rows
</Text>
<Group gap="xs">
{countLoading ? (
<Loader size="xs" />
) : (
<Text size="lg" fw={600}>
{total?.toLocaleString() ?? "—"}
</Text>
)}
</Group>
</div>
{overCap && (
<Alert color="red" icon={<TriangleAlert size={16} />} p="xs">
<Text size="xs">
{total?.toLocaleString()} rows exceeds the {cap.toLocaleString()}-row{" "}
{FORMAT_META[format].label} limit. Narrow the filters
{dataset.caps.csv > cap ? ", switch to CSV," : ""} or{" "}
<Anchor size="xs" onClick={() => setScope(String(cap))}>
export the first {cap.toLocaleString()}
</Anchor>
.
</Text>
</Alert>
)}
{format === "pdf" && selected.length > PDF_FIELD_WARN && (
<Alert color="yellow" icon={<TriangleAlert size={16} />} p="xs">
<Text size="xs">
{selected.length} columns is more than a PDF can show legibly. CSV or Excel
keeps them readable.
</Text>
</Alert>
)}
<Text size="xs" c="dimmed">
Uses the filters currently applied on this page.
</Text>
</Stack>
</div>
</div>
<Group justify="flex-end">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
radius="md"
loading={exporting}
disabled={!selected.length || overCap}
leftSection={<Download size={16} />}
onClick={() => void handleDownload()}
>
{rowsToExport === undefined
? "Export"
: `Export ${rowsToExport.toLocaleString()} ${rowsToExport === 1 ? "row" : "rows"}`}
</Button>
</Group>
</Stack>
</Modal>
);
}
export default ExportDialog;

View File

@@ -21,8 +21,9 @@ const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
}; };
// Most bodies fit a narrow popover; a date range needs room for the presets // Most bodies fit a narrow popover; a date range needs room for the presets
// sidebar next to the calendar, so it gets a wider minimum. // sidebar next to the calendar, and a route needs room for two multi-selects'
const DROPDOWN_WIDTH: Partial<Record<FilterDef["type"], number>> = { date: 340 }; // worth of yard chips, so both get a wider minimum.
const DROPDOWN_WIDTH: Partial<Record<FilterDef["type"], number>> = { date: 340, route: 320 };
export interface FilterPillProps { export interface FilterPillProps {
def: FilterDef; def: FilterDef;

View File

@@ -4,7 +4,7 @@ import { DatePickerInput } from "@mantine/dates";
import { CalendarDays } from "lucide-react"; import { CalendarDays } from "lucide-react";
import { getDateRangePresets } from "@/components/common/dateRangePresets"; import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates"; import { startOfDayIso, endOfDayIso, isoToLocalDateStr, parseDateStr } from "../dates";
import { DEFAULT_OP } from "../types"; import { DEFAULT_OP } from "../types";
import type { DateFilterDef, Operator } from "../types"; import type { DateFilterDef, Operator } from "../types";
import { OperatorSelect } from "../OperatorSelect"; import { OperatorSelect } from "../OperatorSelect";
@@ -20,9 +20,14 @@ export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<Date
// filter) would otherwise open on the range UI with no way to switch off // filter) would otherwise open on the range UI with no way to switch off
// it, since OperatorSelect hides itself when there's only one choice. // it, since OperatorSelect hides itself when there's only one choice.
const [op, setOp] = useState<Operator>(value?.op ?? def.operators?.[0] ?? DEFAULT_OP.date); const [op, setOp] = useState<Operator>(value?.op ?? def.operators?.[0] ?? DEFAULT_OP.date);
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects. // Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects — and
const [from, setFrom] = useState<string | null>(value?.v[0]?.slice(0, 10) ?? null); // the stored values are UTC instants, so they must come back through
const [to, setTo] = useState<string | null>(value?.v[1]?.slice(0, 10) ?? null); // `isoToLocalDateStr`, not a slice (see its comment: a slice reopens the
// "from" side a day early east of UTC).
const [from, setFrom] = useState<string | null>(
value?.v[0] ? isoToLocalDateStr(value.v[0]) : null,
);
const [to, setTo] = useState<string | null>(value?.v[1] ? isoToLocalDateStr(value.v[1]) : null);
const apply = () => { const apply = () => {
if (op === "between") { if (op === "between") {

View File

@@ -11,19 +11,29 @@ import type { FilterBodyProps } from "./TextBody";
const SEARCH_THRESHOLD = 8; const SEARCH_THRESHOLD = 8;
/** /**
* Stretches the Checkbox/Radio's native <label> across the full popover * Whole-row hit target. The only element that toggles a Mantine Checkbox /
* width and pads it, so the clickable/tappable area is the whole row — * Radio is its native <label>, and that label wraps its own text and nothing
* not just the ~14px input square — plus a hover cue. `body`/`labelWrapper` * else — the row's padding and the gutter beside the input square lie
* are Mantine's part names for this; `cursor: pointer` on the row (not just * OUTSIDE it. Styling those on `root` therefore bought a hover cue over an
* the input) makes the affordance visible before you even click. * area that swallowed the click.
*
* The fix is a `::before` stretched over the (relatively positioned) root:
* that pseudo-element is part of the label's own box, so a click anywhere in
* the row hits the label and toggles the input. `cursor: pointer` goes on the
* root for the same reason — the affordance must cover what is clickable.
*/ */
const ROW_STYLES = { const ROW_STYLES = {
root: { padding: "10px 10px", borderRadius: 6 }, root: { position: "relative" as const, padding: "10px 10px", borderRadius: 6, cursor: "pointer" },
body: { alignItems: "center" as const }, body: { alignItems: "center" as const },
labelWrapper: { flex: 1 }, labelWrapper: { flex: 1 },
label: { cursor: "pointer", paddingLeft: 8 }, label: { cursor: "pointer", paddingLeft: 8 },
}; };
const ROW_CLASSES = {
root: "hover:bg-gray-100 transition-colors",
label: "before:absolute before:inset-0 before:content-['']",
};
function OptionLabel({ label, count }: { label: string; count?: number }) { function OptionLabel({ label, count }: { label: string; count?: number }) {
return ( return (
<Group justify="space-between" wrap="nowrap" gap="sm"> <Group justify="space-between" wrap="nowrap" gap="sm">
@@ -96,7 +106,7 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
// just the tiny checkbox square) toggles the option too. // just the tiny checkbox square) toggles the option too.
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />} label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
styles={ROW_STYLES} styles={ROW_STYLES}
classNames={{ root: "hover:bg-gray-100 transition-colors" }} classNames={ROW_CLASSES}
/> />
))} ))}
</Stack> </Stack>
@@ -115,7 +125,7 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
size="sm" size="sm"
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />} label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
styles={ROW_STYLES} styles={ROW_STYLES}
classNames={{ root: "hover:bg-gray-100 transition-colors" }} classNames={ROW_CLASSES}
/> />
))} ))}
</Stack> </Stack>

View File

@@ -1,59 +1,127 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Select, Stack } from "@mantine/core"; import { ActionIcon, Button, Group, MultiSelect, Stack, Text, Tooltip } from "@mantine/core";
import { ArrowRight } from "lucide-react"; import { ArrowDown, ArrowUpDown } from "lucide-react";
import type { RouteFilterDef } from "../types"; import type { RouteFilterDef } from "../types";
import { decodeRouteValue, encodeRouteValue, type RouteSelection } from "../route";
import type { FilterBodyProps } from "./TextBody"; import type { FilterBodyProps } from "./TextBody";
type Side = keyof RouteSelection;
/** /**
* Origin + destination picked together, each a searchable `Select` over the * Origin and destination as two independent multi-selects, either of which may
* page's yard list — typing filters by yard name, same as any Mantine * be left empty. That is the whole point: "everything leaving Nagad" and
* Select. No `OperatorSelect`: a route pair has exactly one operator ("is"), * "everything arriving at Gelan" are real questions an operator asks, and the
* which is why DEFAULT_OP.route is the only entry the generic bar needs. * previous body — two single Selects behind an Apply gated on
* `origin && destination` — could only ask the third one.
*
* Semantics are OR inside a side, AND across the two, which the hint line
* below spells out in words rather than making the user infer it from a
* checkbox list.
*
* No `OperatorSelect`: a route still has exactly one operator ("is"), which is
* why DEFAULT_OP.route is the only entry the generic bar needs.
*/ */
export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<RouteFilterDef>) { export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<RouteFilterDef>) {
const [origin, setOrigin] = useState<string | null>(value?.v[0] ?? null); const [sel, setSel] = useState<RouteSelection>(() => decodeRouteValue(value?.v ?? []));
const [destination, setDestination] = useState<string | null>(value?.v[1] ?? null); const [search, setSearch] = useState<Record<Side, string>>({ origins: "", destinations: "" });
const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id;
const list = (ids: string[]) => ids.map(label).join(" or ");
// Spelled out, because "OR within a side, AND across sides" is not something
// two stacked pickers communicate on their own.
const hint = !sel.origins.length && !sel.destinations.length
? "Type to search stations. Pick an origin, a destination, or both."
: !sel.destinations.length
? `Everything leaving ${list(sel.origins)}.`
: !sel.origins.length
? `Everything arriving at ${list(sel.destinations)}.`
: `From ${list(sel.origins)} to ${list(sel.destinations)}.`;
const apply = () => { const apply = () => {
onChange(origin && destination ? { op: "is", v: [origin, destination] } : undefined); onChange(encodeRouteValue(sel));
onClose(); onClose();
}; };
// This popover already lives inside FilterPill's own Popover. A Select's // This popover already lives inside FilterPill's own Popover. A dropdown
// dropdown portals separately by default, so a click on an option registers // portals separately by default, so a click on an option registers as
// as "outside" the outer Popover and closes the whole filter before a pick // "outside" the outer Popover and closes the whole filter before a pick
// lands — same nested-portal bug DateBody had. Un-portalling keeps it // lands — same nested-portal bug DateBody had. Un-portalling keeps it
// inside the outer popover's DOM subtree instead. // inside the outer popover's DOM subtree instead.
const comboboxProps = { withinPortal: false } as const; const comboboxProps = { withinPortal: false } as const;
/**
* The list stays shut until there is something typed. Two open triggers have
* to be neutralised for that, not one: `openOnFocus={false}` handles the
* focus, but MultiSelect's PillsInput root ALSO calls `openDropdown()` on
* every click, ungated — so the only reliable lever is driving
* `dropdownOpened` ourselves off the search text.
*
* Consequence worth knowing: Mantine clears the search on each pick
* (`clearSearchOnChange`, default true), so the list closes after one is
* chosen and typing reopens it. That is the intended resting state — the
* popover opens showing what is already selected, not a wall of stations.
*/
const sideProps = (side: Side) => ({
data: def.options,
placeholder: sel[side].length ? "Add another" : "Any",
value: sel[side],
onChange: (next: string[]) => setSel((s) => ({ ...s, [side]: next })),
searchValue: search[side],
onSearchChange: (q: string) => setSearch((s) => ({ ...s, [side]: q })),
dropdownOpened: search[side].trim().length > 0,
openOnFocus: false,
comboboxProps,
searchable: true,
clearable: true,
hidePickedOptions: true,
maxDropdownHeight: 200,
nothingFoundMessage: "No station matches",
});
return ( return (
<Stack gap="xs" w={240}> <Stack gap={6} w={300}>
<Select <MultiSelect label="From" autoFocus {...sideProps("origins")} />
label="Origin"
placeholder="Any" <Group justify="center" gap={6} wrap="nowrap">
data={def.options} <ArrowDown size={14} className="text-gray-400" />
value={origin} <Tooltip label="Swap origin and destination" withinPortal={false}>
onChange={setOrigin} <ActionIcon
comboboxProps={comboboxProps} size="sm"
searchable radius="xl"
clearable variant="subtle"
autoFocus color="gray"
/> aria-label="Swap origin and destination"
<ArrowRight size={14} className="text-gray-400" style={{ alignSelf: "center" }} /> disabled={!sel.origins.length && !sel.destinations.length}
<Select onClick={() => setSel((s) => ({ origins: s.destinations, destinations: s.origins }))}
label="Destination" >
placeholder="Any" <ArrowUpDown size={13} />
data={def.options} </ActionIcon>
value={destination} </Tooltip>
onChange={setDestination} </Group>
comboboxProps={comboboxProps}
searchable <MultiSelect label="To" {...sideProps("destinations")} />
clearable
/> <Text size="xs" c="dimmed" mt={2}>
<Button size="sm" onClick={apply} disabled={!(origin && destination)}> {hint}
Apply </Text>
</Button>
<Group gap="xs" grow mt={2}>
<Button
size="sm"
variant="default"
disabled={!sel.origins.length && !sel.destinations.length}
onClick={() => setSel({ origins: [], destinations: [] })}
>
Clear
</Button>
{/* Enabled even when empty: applying nothing removes the filter, which
is how every other body's Apply behaves. */}
<Button size="sm" onClick={apply}>
Apply
</Button>
</Group>
</Stack> </Stack>
); );
} }

View File

@@ -32,6 +32,23 @@ export function parseDateStr(dateStr: string): Date {
return new Date(y, (m || 1) - 1, d || 1); return new Date(y, (m || 1) - 1, d || 1);
} }
/**
* Inverse of `parseDateStr` + `startOfDayIso`/`endOfDayIso`: the LOCAL
* `YYYY-MM-DD` an ISO instant falls on.
*
* `iso.slice(0, 10)` is the tempting version and it is wrong. Those instants
* came out of `toISOString()`, so they are UTC — for Ethiopia (UTC+3) a local
* end-of-day is `…T20:59:59.999Z` on the SAME day but a local start-of-day is
* `…T21:00:00.000Z` on the PREVIOUS one. Slicing therefore reopens the picker
* (and printed the pill) a day early on the "from" side only.
*/
export function isoToLocalDateStr(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** /**
* `toParams` for a date `FilterDef` widened to `["between", "before", "after"]` * `toParams` for a date `FilterDef` widened to `["between", "before", "after"]`
* operators. `DateBody` always emits a single-element `v` for before/after — * operators. `DateBody` always emits a single-element `v` for before/after —

View File

@@ -1,5 +1,7 @@
import { formatDate } from "@/lib/format";
import { parseFilters } from "./url"; import { parseFilters } from "./url";
import type { FilterDef, FilterValue } from "./types"; import { decodeRouteValue } from "./route";
import { OPERATOR_LABELS, type FilterDef, type FilterValue } from "./types";
/** Human-readable text for one filter's current value — same text a /** Human-readable text for one filter's current value — same text a
* FilterPill shows, and what a saved view's auto-generated label is built * FilterPill shows, and what a saved view's auto-generated label is built
@@ -10,12 +12,29 @@ export function formatFilterValue(def: FilterDef, value: FilterValue): string {
const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v); const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v);
return labels.join(", "); return labels.join(", ");
} }
if (def.type === "date" && value.v.length === 2) { if (def.type === "date") {
return `${value.v[0].slice(0, 10)}${value.v[1].slice(0, 10)}`; // `v` holds UTC instants (startOfDayIso/endOfDayIso call toISOString), so
// slicing the first 10 characters printed the UTC calendar day — one day
// EARLIER than the one picked, for anyone east of UTC. `formatDate` reads
// the instant back in local time, which is the day the user actually chose.
// Single-sided ops carry their operator, since "Created | Aug 20" alone
// doesn't say whether that's a floor or a ceiling.
const days = value.v.map(formatDate);
if (value.op === "between" && days.length === 2) return `${days[0]}${days[1]}`;
return `${OPERATOR_LABELS[value.op]} ${days[0]}`;
} }
if (def.type === "route" && value.v.length === 2) { if (def.type === "route") {
const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id; const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id;
return `${label(value.v[0])}${label(value.v[1])}`; // "Any" reads as an unconstrained end; a long side collapses to "first +N"
// so the pill can't grow past the rest of the bar.
const side = (ids: string[]) =>
ids.length === 0
? "Any"
: ids.length <= 2
? ids.map(label).join(", ")
: `${label(ids[0])} +${ids.length - 1}`;
const { origins, destinations } = decodeRouteValue(value.v);
return `${side(origins)}${side(destinations)}`;
} }
return value.v.join(", "); return value.v.join(", ");
} }

View File

@@ -1,6 +1,7 @@
export * from "./types"; export * from "./types";
export * from "./url"; export * from "./url";
export * from "./dates"; export * from "./dates";
export * from "./route";
export * from "./format"; export * from "./format";
export * from "./clientFilter"; export * from "./clientFilter";
export * from "./ruleEngineFooterProps"; export * from "./ruleEngineFooterProps";

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { FilterDef } from "./types";
import { decodeRouteValue, encodeRouteValue, routeParams } from "./route";
import { decodeFilterValue, encodeFilterValue, toApiParams } from "./url";
import { formatFilterValue } from "./format";
const NAGAD = "11111111-1111-1111-1111-111111111111";
const DMP = "22222222-2222-2222-2222-222222222222";
const GELAN = "33333333-3333-3333-3333-333333333333";
const ROUTE: FilterDef = {
key: "route",
label: "Route",
type: "route",
options: [
{ value: NAGAD, label: "Nagad" },
{ value: DMP, label: "DMP" },
{ value: GELAN, label: "Gelan" },
],
toParams: routeParams("originYardId", "destinationYardId"),
};
describe("route filter value", () => {
it("round-trips each side independently, through the URL codec", () => {
const cases = [
{ origins: [NAGAD], destinations: [] },
{ origins: [], destinations: [GELAN] },
{ origins: [NAGAD, DMP], destinations: [GELAN] },
];
for (const sel of cases) {
const value = encodeRouteValue(sel)!;
const raw = encodeFilterValue("route", value);
expect(decodeRouteValue(decodeFilterValue("route", raw)!.v)).toEqual(sel);
}
});
it("is no filter at all when both sides are empty", () => {
expect(encodeRouteValue({ origins: [], destinations: [] })).toBeUndefined();
});
it("omits an unconstrained side rather than sending an empty param", () => {
const value = encodeRouteValue({ origins: [NAGAD, DMP], destinations: [] })!;
expect(toApiParams([ROUTE], { route: value })).toEqual({
originYardId: `${NAGAD},${DMP}`,
destinationYardId: undefined,
});
});
it("still reads the legacy untagged `route=<origin>,<destination>` pair", () => {
expect(decodeRouteValue([NAGAD, GELAN])).toEqual({
origins: [NAGAD],
destinations: [GELAN],
});
});
it("labels an empty side 'Any' in the pill", () => {
const value = encodeRouteValue({ origins: [], destinations: [GELAN] })!;
expect(formatFilterValue(ROUTE, value)).toBe("Any → Gelan");
});
});

View File

@@ -0,0 +1,67 @@
import type { FilterValue } from "./types";
const ORIGIN = "o:";
const DEST = "d:";
export interface RouteSelection {
origins: string[];
destinations: string[];
}
/**
* A route filter's `v` is one flat, TAGGED list — `["o:<yardId>", "d:<yardId>", …]`.
*
* It has to be flat because `url.ts` knows exactly one encoding for a filter
* value: comma-split inside a single query param. The tags are what buy back
* the two sides, and with them the three things the old fixed
* `[origin, destination]` pair could not express:
*
* - origin only — "everything leaving Nagad"
* - destination only — "everything arriving at Gelan"
* - several yards per side — "leaving Nagad OR DMP, arriving Gelan OR Indode"
*
* Semantics: OR within a side, AND across the two. An empty side is not a
* filter at all (see {@link routeParams}), never "matches nothing".
*/
export function decodeRouteValue(v: string[]): RouteSelection {
const origins: string[] = [];
const destinations: string[] = [];
for (const entry of v) {
if (entry.startsWith(ORIGIN)) origins.push(entry.slice(ORIGIN.length));
else if (entry.startsWith(DEST)) destinations.push(entry.slice(DEST.length));
}
// Legacy `?route=<originId>,<destinationId>`: deep links and saved views
// written before the tags existed. Untagged, and always exactly the pair.
if (!origins.length && !destinations.length && v.length === 2) {
return { origins: [v[0]], destinations: [v[1]] };
}
return { origins, destinations };
}
/** Inverse of {@link decodeRouteValue}. `undefined` when both sides are empty — that is "no filter". */
export function encodeRouteValue(sel: RouteSelection): FilterValue | undefined {
const v = [
...sel.origins.map((id) => `${ORIGIN}${id}`),
...sel.destinations.map((id) => `${DEST}${id}`),
];
return v.length ? { op: "is", v } : undefined;
}
/**
* `toParams` for a route filter — each side onto its own comma-separated API
* param, mirroring `dateRangeParams`. An empty side maps to `undefined` so
* `cleanParams` drops the param entirely; sending `originYardId=` instead
* would have the server filter on an empty list.
*/
export function routeParams(
originKey: string,
destinationKey: string,
): (v: FilterValue) => Record<string, string | undefined> {
return (value) => {
const { origins, destinations } = decodeRouteValue(value.v);
return {
[originKey]: origins.join(",") || undefined,
[destinationKey]: destinations.join(",") || undefined,
};
};
}

View File

@@ -89,11 +89,11 @@ export interface BooleanFilterDef extends FilterDefBase {
} }
/** /**
* Origin + destination picked together as one pill — `v` is always the * Origin + destination as one pill, each side holding any number of yards and
* 2-slot pair `[originYardId, destinationYardId]`, never partial (the body's * either side allowed to be empty. `v` is the tagged flat list described in
* Apply button stays disabled until both sides are chosen, same rule * `route.ts` — use `decodeRouteValue` / `encodeRouteValue` to read or write it,
* `DateBody` uses for a `between` range). One shared `options` list drives * and `routeParams(originKey, destinationKey)` as the def's `toParams`. One
* both selects. * shared `options` list drives both sides.
*/ */
export interface RouteFilterDef extends FilterDefBase { export interface RouteFilterDef extends FilterDefBase {
type: "route"; type: "route";

View File

@@ -571,6 +571,11 @@ export const buildSidebarSections = (
href: "/dashboard/configuration/exchange-rate", href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.settings.exchangeRate.view, permission: FREIGHT_PERMS.settings.exchangeRate.view,
}, },
{
label: "Operating standards",
href: "/dashboard/configuration/operations-standards",
permission: FREIGHT_PERMS.settings.operationsStandards.view,
},
{ {
label: "Manual payments", label: "Manual payments",
href: "/dashboard/configuration/manual-payments", href: "/dashboard/configuration/manual-payments",

View File

@@ -4,44 +4,93 @@ import { getPositionKeys } from "@/lib/permissions";
/** One overview composition. Every backoffice user lands on exactly one of these. */ /** One overview composition. Every backoffice user lands on exactly one of these. */
export type OverviewLayoutKey = export type OverviewLayoutKey =
| "executive" | "executive"
| "operations" | "operation"
| "occ" | "occ"
| "marketing" | "marketer"
| "finance" | "finance"
| "clearance"; | "clearance";
export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = { export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
executive: "Executive dashboard", executive: "Executive dashboard",
operations: "Operations dashboard", operation: "Operations dashboard",
occ: "Control centre dashboard", occ: "Control centre dashboard",
marketing: "Marketing dashboard", marketer: "Marketing dashboard",
finance: "Finance dashboard", finance: "Finance dashboard",
clearance: "Clearance & logistics dashboard", clearance: "Clearance & logistics dashboard",
}; };
/** /**
* Role/position key → layout, in match priority order: a user holding several * Position/role key → layout, in match priority order: a user holding several
* of these keys gets the first match, so the specific operational view wins * of these keys gets the first match, so the specific operational view wins
* over the broad executive one. Position keys are matched too because the IAM * over the broad executive one. Roles are matched alongside positions because
* payload models the GL desks as positions (`ethiopian_gl`) on some accounts * the IAM payload models the GL desks as positions (`ethiopian_gl`) on some
* and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`. * accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
*
* The `edr_freight_app/…` keys are the org's real position keys (root desks and
* their sub-positions) as configured under Unit → Departments. They are typed
* by hand in the Add/Edit Department form, so a new sub-position appears here
* only once someone adds it — unmapped keys fall through to `executive`.
*/ */
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [ const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
["edr_operations_officer", "operations"], // ── Clearance & logistics: both GL desks, root and sub-positions ──────────
["truck_machinery_chief", "operations"],
["edr_line_staff", "occ"],
["edr_gl_ethiopia", "clearance"],
["edr_gl_djibouti", "clearance"],
["ethiopian_gl", "clearance"], ["ethiopian_gl", "clearance"],
["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief
["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director
["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer
["djibouti_gl", "clearance"], ["djibouti_gl", "clearance"],
["edr_marketing", "marketing"], ["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director
["edr_finance", "finance"], ["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief
["edr_director", "executive"], ["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer
["edr_ceo", "executive"], ["edr_gl_ethiopia", "clearance"], // legacy role form
["edr_org_manager", "executive"], ["edr_gl_djibouti", "clearance"], // legacy role form
// ── Control centre ───────────────────────────────────────────────────────
["edr_freight_app/occ_001", "occ"], // OCC
["edr_freight_app/occ_005", "occ"], // OCC Director
["edr_line_staff", "occ"], // legacy role form
// ── Operations: operations desk, track & machinery, rolling stock ─────────
["edr_freight_app/opn", "operation"], // Operation
["edr_freight_app/opcf", "operation"], // Operation Chief
["edr_freight_app/opdr", "operation"], // Operation Director
["edr_freight_app/opco", "operation"], // Operation Officer
["edr_freight_app/opp_005", "operation"], // Operation Dispatcher
["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director
["edr_freight_app/track_001", "operation"], // Track And Machinery
["edr_freight_app/ttk_001", "operation"], // Track Director
["edr_freight_app/tto_001", "operation"], // Track Operator
["edr_freight_app/rool_001", "operation"], // Rolling Stock
["edr_freight_app/rl_003", "operation"], // Rolling Stock Director
["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead
["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher
["operation", "operation"],
["operations_chief", "operation"],
["dispatcher", "operation"],
["truck_machinery_chief", "operation"],
["edr_operations_officer", "operation"], // legacy role form
// ── Marketing ────────────────────────────────────────────────────────────
["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing
["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director
["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief
["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer
["marketer", "marketer"],
["edr_marketing", "marketer"], // legacy role form
// ── Finance ──────────────────────────────────────────────────────────────
["edr_freight_app/finance", "finance"],
["edr_finance", "finance"], // legacy role form
// ── Executive: org-wide desks with no operational queue of their own ──────
["ceo", "executive"],
["director", "executive"],
["chief", "executive"],
["edr_ceo", "executive"], // legacy role form
["edr_director", "executive"], // legacy role form
["edr_org_manager", "executive"], // legacy role form
]; ];
/** Unmapped roles (superadmin, IAM admins, new roles) keep the executive layout. */ /** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */
export function resolveOverviewLayout( export function resolveOverviewLayout(
user: AuthUser | null | undefined, user: AuthUser | null | undefined,
): OverviewLayoutKey { ): OverviewLayoutKey {

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { DateInput } from "@mantine/dates";
import { Loader2, Plus, Trash2 } from "lucide-react"; import { Loader2, Plus, Trash2 } from "lucide-react";
import { import {
ActionIcon, ActionIcon,
@@ -162,16 +163,6 @@ const inputStyles = {
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" }, label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
} as const; } as const;
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
<Group gap={4} wrap="nowrap">
<span>{label}</span>
{required ? (
<Text component="span" c="red" size="sm">
*
</Text>
) : null}
</Group>
);
const RuleEngineFormDialog = ({ const RuleEngineFormDialog = ({
open, open,
@@ -405,7 +396,11 @@ const RuleEngineFormDialog = ({
); );
} }
const label = <FieldLabel label={field.label} required={field.required} />; // A plain string, so Mantine renders the label and its required asterisk
// itself. Passing an element here put a flex box inside the <label>,
// which added a line of dead space above every input and bumped
// Mantine's own asterisk onto a line of its own.
const label = field.label;
if (field.type === "tierList") { if (field.type === "tierList") {
const rows = Array.isArray(values[field.name]) const rows = Array.isArray(values[field.name])
@@ -513,6 +508,7 @@ const RuleEngineFormDialog = ({
<MultiSelect <MultiSelect
key={field.name} key={field.name}
label={label} label={label}
withAsterisk={field.required}
description={field.description} description={field.description}
placeholder={ placeholder={
selectOptionsLoading selectOptionsLoading
@@ -602,6 +598,31 @@ const RuleEngineFormDialog = ({
); );
} }
if (field.type === "date") {
const raw = String(values[field.name] ?? "");
return (
<DateInput
key={field.name}
label={label}
description={field.description}
placeholder="Select date"
// Mantine's DateValue accepts a `YYYY-MM-DD` string, which is exactly
// what the API's date columns take — so the value passes straight
// through with no Date round-trip, and none of the UTC-parsing shift
// that `new Date("2026-01-01")` would introduce east of Greenwich.
value={raw || null}
onChange={(v) => setField(field.name, v ?? "")}
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
required={field.required}
error={fieldErrors[field.name] || undefined}
clearable
size="md"
radius="md"
styles={inputStyles}
/>
);
}
const isNumber = field.type === "number"; const isNumber = field.type === "number";
const computed = field.computeValue ? field.computeValue(values) : undefined; const computed = field.computeValue ? field.computeValue(values) : undefined;
@@ -610,7 +631,7 @@ const RuleEngineFormDialog = ({
key={field.name} key={field.name}
label={label} label={label}
description={field.description} description={field.description}
type={isNumber ? "number" : field.type === "date" ? "date" : "text"} type={isNumber ? "number" : "text"}
// Every rule-engine number (sizes, capacities, counts, points, rates, // Every rule-engine number (sizes, capacities, counts, points, rates,
// display order) is a non-negative magnitude — reject negatives outright // display order) is a non-negative magnitude — reject negatives outright
// rather than letting a typed "-" reach the API. // rather than letting a typed "-" reach the API.

View File

@@ -201,16 +201,20 @@ export function StatTile({
/** /**
* Origin → destination corridor visual: two anchored stops joined by a rail * Origin → destination corridor visual: two anchored stops joined by a rail
* line. `variant="compact"` is for dense table rows; `default` for cards. * line. `variant="compact"` is for dense table rows; `default` for cards.
* `orientation="vertical"` stacks the stops as waypoints, which keeps a long
* yard name off one wide line in a table cell.
*/ */
export function RouteCorridor({ export function RouteCorridor({
origin, origin,
destination, destination,
variant = "default", variant = "default",
orientation = "horizontal",
onDark = false, onDark = false,
}: { }: {
origin?: string | null; origin?: string | null;
destination?: string | null; destination?: string | null;
variant?: "default" | "compact"; variant?: "default" | "compact";
orientation?: "horizontal" | "vertical";
onDark?: boolean; onDark?: boolean;
}) { }) {
const compact = variant === "compact"; const compact = variant === "compact";
@@ -218,6 +222,48 @@ export function RouteCorridor({
const strong = onDark ? "white" : "var(--mantine-color-gray-8)"; const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)"; const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
const accent = onDark ? "white" : freightBrand.primary; const accent = onDark ? "white" : freightBrand.primary;
const dot = compact ? 7 : 9;
if (orientation === "vertical") {
return (
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={dot}
h={dot}
style={{
borderRadius: 999,
flexShrink: 0,
border: `2px solid ${accent}`,
background: onDark ? "transparent" : "white",
}}
/>
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
{origin ?? "—"}
</Text>
</Group>
{/* Rail between the stops, aligned to the dot centres. */}
<Box
ml={dot / 2 - 1}
style={{
width: 0,
height: compact ? 10 : 14,
borderLeft: `2px dashed ${lineColor}`,
}}
/>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={dot}
h={dot}
style={{ borderRadius: 999, flexShrink: 0, background: accent }}
/>
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
{destination ?? "—"}
</Text>
</Group>
</Stack>
);
}
return ( return (
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}> <Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>

View File

@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
BASE: "/exchange-settings", BASE: "/exchange-settings",
}, },
OPERATIONS_STANDARDS: {
BASE: "/operations-standards",
},
MANUAL_PAYMENT_SETTINGS: { MANUAL_PAYMENT_SETTINGS: {
BASE: "/payment-settings/manual", BASE: "/payment-settings/manual",
}, },
@@ -172,6 +176,12 @@ export const URL_CONSTANTS = {
EXPORT: (key: string) => `/reports/${key}/export`, EXPORT: (key: string) => `/reports/${key}/export`,
}, },
EXPORTS: {
CATALOG: "/exports",
COUNT: (key: string) => `/exports/${key}/count`,
DOWNLOAD: (key: string) => `/exports/${key}/download`,
},
OVERVIEW: { OVERVIEW: {
BASE: "/overview", BASE: "/overview",
BOOKINGS: "/overview/bookings", BOOKINGS: "/overview/bookings",
@@ -563,6 +573,7 @@ export const URL_CONSTANTS = {
YARD_BY_ID: (id: string) => `/yards/${id}`, YARD_BY_ID: (id: string) => `/yards/${id}`,
YARD_DISTANCES: "/yard-distances", YARD_DISTANCES: "/yard-distances",
OPERATIONS_TARGETS: "/operations-targets",
YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`, YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`,
SHIPPING_LINES: "/shipping-lines", SHIPPING_LINES: "/shipping-lines",

View File

@@ -221,6 +221,8 @@ export interface YardOption {
label: string; label: string;
value: string; value: string;
country: string; country: string;
/** The yard's business code — what config keyed on a station stores. */
code: string;
} }
/** /**
@@ -243,6 +245,7 @@ export const useYardOptions = (enabled = true) =>
label: label && code ? `${label} (${code})` : label || code || String(row.id), label: label && code ? `${label} (${code})` : label || code || String(row.id),
value: String(row.id), value: String(row.id),
country: String(row.country ?? ""), country: String(row.country ?? ""),
code,
}; };
}), }),
}); });

View File

@@ -0,0 +1,35 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
operationsStandardsService,
type OperationsStandardsPatch,
} from "@/services/operationsStandards.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["operationsStandards"];
export const useOperationsStandardsQuery = () =>
useQuery({
queryKey: QUERY_KEY,
queryFn: () => operationsStandardsService.get(),
});
export const useUpdateOperationsStandards = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (patch: OperationsStandardsPatch) =>
operationsStandardsService.update(patch),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(
t("operationsStandards.updated", "Operating standards updated"),
);
},
onError: handleError,
});
};

View File

@@ -379,6 +379,12 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage", 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. Finance holds // Whether Finance may settle invoices by hand, per currency. Finance holds
// `view` (the worklist offers only enabled currencies); `manage` is admin. // `view` (the worklist offers only enabled currencies); `manage` is admin.
manualPayment: { manualPayment: {

View File

@@ -27,8 +27,9 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { ExportButton } from "@/components/export/ExportButton";
import { formatDate, humanize } from "@/lib/format"; import { formatDate, humanize } from "@/lib/format";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters"; import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -170,7 +171,7 @@ export default function BookingRequestsPage() {
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true }, { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
{ {
key: "route", label: "Route", type: "route", options: yardOptions, key: "route", label: "Route", type: "route", options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), toParams: routeParams("originYardId", "destinationYardId"),
}, },
{ {
key: "created", label: "Created", type: "date", secondary: true, key: "created", label: "Created", type: "date", secondary: true,
@@ -547,7 +548,9 @@ export default function BookingRequestsPage() {
controls={controls} controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…" searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests" viewId="booking-requests"
/> >
<ExportButton datasetKey="bookings" params={controls.params} />
</FilterBar>
</Box> </Box>
{showEmpty ? ( {showEmpty ? (

View File

@@ -52,7 +52,8 @@ import {
DataTableFooter, DataTableFooter,
type ColumnDef, type ColumnDef,
} from "@edr/ui-common"; } from "@edr/ui-common";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters"; import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
/** Every filterable status — the pill tabs are gone, so the select carries them all. */ /** Every filterable status — the pill tabs are gone, so the select carries them all. */
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map( const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
@@ -183,7 +184,7 @@ export default function ContractRequestsPage() {
label: "Route", label: "Route",
type: "route", type: "route",
options: yardOptions, options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), toParams: routeParams("originYardId", "destinationYardId"),
}, },
], ],
[filterOptions, yardOptions, serviceTypeOptions], [filterOptions, yardOptions, serviceTypeOptions],
@@ -468,7 +469,9 @@ export default function ContractRequestsPage() {
searchPlaceholder="Search reference or customer…" searchPlaceholder="Search reference or customer…"
sortOptions={SORT_OPTIONS} sortOptions={SORT_OPTIONS}
viewId="contract-requests" viewId="contract-requests"
/> >
<ExportButton datasetKey="contracts" params={controls.params} />
</FilterBar>
</Box> </Box>
{showEmpty ? ( {showEmpty ? (

View File

@@ -38,6 +38,7 @@ import type { Company, CompanyStatus } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer"; import { isOnboardingDraft } from "@/types/customer";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters"; import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
/** /**
* The list's segmented views. "Pending approval" means submitted-and-awaiting- * The list's segmented views. "Pending approval" means submitted-and-awaiting-
@@ -318,6 +319,7 @@ export default function CustomersPage() {
{ label: "Active", value: "active" }, { label: "Active", value: "active" },
]} ]}
/> />
<ExportButton datasetKey="customers" params={controls.params} />
</FilterBar> </FilterBar>
</Box> </Box>

Some files were not shown because too many files have changed in this diff Show More