mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1351 from Tria-plc/freight/nati-2
feat(WIP): filtering, exporting and more reports
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal file
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal 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;
|
||||||
|
});
|
||||||
32
apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts
Normal file
32
apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A station's plan is per station AND per cargo type, not per station.
|
||||||
|
*
|
||||||
|
* The OCC monthly report plans "Nagad–Mojo multimodal container 122,010 t" and
|
||||||
|
* "Nagad–Mojo fertilizer 18,000 t" as separate lines against the same station,
|
||||||
|
* which the single `dimension_key` column cannot express: a station-keyed target
|
||||||
|
* would apply the whole station's plan to each of its cargo types.
|
||||||
|
*
|
||||||
|
* `cargo_category` is nullable, so `cargo_category` and `container_class`
|
||||||
|
* targets are unaffected — they leave it null and stay keyed on
|
||||||
|
* `dimension_key` alone. The uniqueness index moves to include it, since
|
||||||
|
* (station, category) is now the slot.
|
||||||
|
*/
|
||||||
|
export class OperationsTargetCargoCategory3590000000000 implements MigrationInterface {
|
||||||
|
name = "OperationsTargetCargoCategory3590000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/edr-freight-api/src/modules/exports/export-query.builder.ts
Normal file
101
apps/edr-freight-api/src/modules/exports/export-query.builder.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/edr-freight-api/src/modules/exports/export.registry.ts
Normal file
33
apps/edr-freight-api/src/modules/exports/export.registry.ts
Normal 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);
|
||||||
135
apps/edr-freight-api/src/modules/exports/export.types.ts
Normal file
135
apps/edr-freight-api/src/modules/exports/export.types.ts
Normal 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' };
|
||||||
|
}
|
||||||
156
apps/edr-freight-api/src/modules/exports/exports.controller.ts
Normal file
156
apps/edr-freight-api/src/modules/exports/exports.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/edr-freight-api/src/modules/exports/exports.module.ts
Normal file
23
apps/edr-freight-api/src/modules/exports/exports.module.ts
Normal 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 {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
|
||||||
|
import { CreateOperationsTargetDto } from './create-operations-target.dto';
|
||||||
|
|
||||||
|
export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 Nagad–Mojo container and Nagad–Mojo fertilizer
|
||||||
|
* separately. Null on `cargo_category` and `container_class` targets, whose
|
||||||
|
* `dimensionKey` already carries the category.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'cargo_category', type: 'varchar', length: 60, nullable: true })
|
||||||
|
cargoCategory?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'note', type: 'text', nullable: true })
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ' +
|
||||||
|
'station’s yard code.' +
|
||||||
|
PLAN_GRANULARITY_NOTE,
|
||||||
|
group: 'Operations',
|
||||||
|
filters: [PERIOD_FILTER, COUNTRY_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||||
|
columns: [
|
||||||
|
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||||
|
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
||||||
|
{ key: 'origination', label: 'Origination', type: 'string' },
|
||||||
|
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true },
|
||||||
|
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
|
||||||
|
{ key: 'plan', label: 'Plan', type: 'tons' },
|
||||||
|
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
|
||||||
|
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||||
|
{ key: 'wagons', label: 'Wagons', type: 'number' },
|
||||||
|
{ key: 'trains', label: 'Trains', type: 'number' },
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'operated', dir: 'DESC' },
|
||||||
|
chart: { type: 'bar', x: 'station', y: ['operated'] },
|
||||||
|
query(ctx) {
|
||||||
|
const { params } = ctx;
|
||||||
|
const bucket = periodTruncExprOn(OPS_DATE, params);
|
||||||
|
const stationCode = stationExpr(params, 'code');
|
||||||
|
|
||||||
|
const operated = baseQuery(ctx)
|
||||||
|
.select(periodExprOn(OPS_DATE, params), 'period')
|
||||||
|
.addSelect(stationCode, 'station_code')
|
||||||
|
.addSelect(`COALESCE(${stationExpr(params, 'label')}, ${stationCode}, '?')`, 'station')
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(${originationExpr(params, 'label')}, ${originationExpr(params, 'code')}, '?')`,
|
||||||
|
'origination',
|
||||||
|
)
|
||||||
|
.addSelect(CARGO_CATEGORY_EXPR, 'category_key')
|
||||||
|
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated')
|
||||||
|
.addSelect(TEU_EXPR, 'teu')
|
||||||
|
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||||
|
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
|
||||||
|
.groupBy(bucket)
|
||||||
|
.addGroupBy(stationCode)
|
||||||
|
.addGroupBy(stationExpr(params, 'label'))
|
||||||
|
.addGroupBy(originationExpr(params, 'label'))
|
||||||
|
.addGroupBy(originationExpr(params, 'code'))
|
||||||
|
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||||
|
|
||||||
|
// A station plan is keyed on station AND cargo type, so the join needs
|
||||||
|
// both. Full outer, so a station-and-cargo line that was planned and never
|
||||||
|
// ran still reports its miss — the OCC report is full of those.
|
||||||
|
const combined = `
|
||||||
|
SELECT COALESCE(o.period, p.period) AS period,
|
||||||
|
COALESCE(o.station_code, p.plan_key) AS station_code,
|
||||||
|
COALESCE(o.station,
|
||||||
|
(SELECT y2.label FROM freight.yards y2
|
||||||
|
WHERE y2.code = p.plan_key AND y2.deleted_at IS NULL LIMIT 1),
|
||||||
|
p.plan_key) AS station,
|
||||||
|
COALESCE(o.origination, '—') AS origination,
|
||||||
|
COALESCE(o.category_key, p.plan_category) AS category_key,
|
||||||
|
COALESCE(o.operated, 0) AS operated,
|
||||||
|
COALESCE(o.teu, 0) AS teu,
|
||||||
|
COALESCE(o.wagons, 0) AS wagons,
|
||||||
|
COALESCE(o.trains, 0) AS trains,
|
||||||
|
p.plan_value AS plan
|
||||||
|
FROM (${operated.getQuery()}) o
|
||||||
|
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p
|
||||||
|
ON p.period = o.period
|
||||||
|
AND p.plan_key = o.station_code
|
||||||
|
AND p.plan_category = o.category_key`;
|
||||||
|
|
||||||
|
return ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.from(`(${combined})`, 'r')
|
||||||
|
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) })
|
||||||
|
.select('r.period', 'period')
|
||||||
|
.addSelect('r.station', 'station')
|
||||||
|
.addSelect('r.origination', 'origination')
|
||||||
|
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
|
||||||
|
.addSelect('r.category_key', 'categoryKey')
|
||||||
|
.addSelect('r.operated::float8', 'operated')
|
||||||
|
.addSelect('r.plan::float8', 'plan')
|
||||||
|
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
|
||||||
|
.addSelect('r.teu::int', 'teu')
|
||||||
|
.addSelect('r.wagons::int', 'wagons')
|
||||||
|
.addSelect('r.trains::int', 'trains');
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await baseQuery(ctx)
|
||||||
|
.select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
|
||||||
|
.addSelect(`COUNT(DISTINCT ${stationExpr(ctx.params, 'code')})::int`, 'stations')
|
||||||
|
.addSelect(TEU_EXPR, 'teu')
|
||||||
|
.getRawOne<{ actual: number; stations: number; teu: number }>();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Volume', value: Number(row?.actual ?? 0), unit: 't' },
|
||||||
|
{ label: 'Stations', value: Number(row?.stations ?? 0) },
|
||||||
|
{ label: 'TEU', value: Number(row?.teu ?? 0) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 train’s ' +
|
||||||
|
'cargo types rather than being split between them.',
|
||||||
|
group: 'Operations',
|
||||||
|
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||||
|
columns: [
|
||||||
|
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||||
|
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
|
||||||
|
{ key: 'station', label: 'Station', type: 'string' },
|
||||||
|
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
|
||||||
|
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
|
||||||
|
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
|
||||||
|
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||||
|
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
|
||||||
|
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
||||||
|
{ key: 'distanceKm', label: 'Distance (km)', type: 'number' },
|
||||||
|
{ key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true },
|
||||||
|
{ key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' },
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'departedAt', dir: 'DESC' },
|
||||||
|
query(ctx) {
|
||||||
|
return baseQuery(ctx)
|
||||||
|
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
|
||||||
|
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt')
|
||||||
|
.addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station')
|
||||||
|
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||||
|
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
||||||
|
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
|
||||||
|
.addSelect(TEU_EXPR, 'teu')
|
||||||
|
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||||
|
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
||||||
|
.addSelect(`${ROUTE_KM}::float8`, 'distanceKm')
|
||||||
|
.addSelect(TON_KM, 'tonKm')
|
||||||
|
.addSelect(VEHICLE_KM, 'vehicleKm')
|
||||||
|
.groupBy('ts.id')
|
||||||
|
.addGroupBy('ts.train_number')
|
||||||
|
.addGroupBy('ts.actual_departure_at')
|
||||||
|
.addGroupBy('ts.scheduled_departure_date')
|
||||||
|
.addGroupBy('oy.label')
|
||||||
|
.addGroupBy('oy.code')
|
||||||
|
.addGroupBy('dy.label')
|
||||||
|
.addGroupBy('dy.code')
|
||||||
|
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await baseQuery(ctx)
|
||||||
|
.select(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged')
|
||||||
|
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
|
||||||
|
.addSelect(
|
||||||
|
`COUNT(DISTINCT ts.id) FILTER (WHERE ${SCHEDULE_KM_EXPR} IS NULL)::int`,
|
||||||
|
'unmeasuredTrains',
|
||||||
|
)
|
||||||
|
.getRawOne<{ charged: number; actual: number; unmeasuredTrains: number }>();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' },
|
||||||
|
{ label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' },
|
||||||
|
// Always shown, even at zero: a corridor with no configured distance
|
||||||
|
// silently drops out of Ton/Km, and that must be visible.
|
||||||
|
{ label: 'Trains without a configured distance', value: Number(row?.unmeasuredTrains ?? 0) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 train’s ' +
|
||||||
|
'next departure from that station — against the standard for that side of the line ' +
|
||||||
|
'(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' +
|
||||||
|
'standard needs a reason. Loading and unloading times are not split out: nothing in ' +
|
||||||
|
'the system records when they start and end yet.',
|
||||||
|
group: 'Operations',
|
||||||
|
filters: [
|
||||||
|
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||||
|
DIRECTION_FILTER,
|
||||||
|
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
|
||||||
|
{ key: 'station', label: 'Station', type: 'text' },
|
||||||
|
COUNTRY_FILTER,
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 's.train_number' },
|
||||||
|
{ key: 'station', label: 'Station', type: 'string', sortable: true, sortExpr: 's.station' },
|
||||||
|
{ key: 'country', label: 'Country', type: 'string' },
|
||||||
|
{ key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' },
|
||||||
|
{ key: 'departedAt', label: 'Departed', type: 'date' },
|
||||||
|
{ key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS },
|
||||||
|
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
|
||||||
|
{ key: 'varianceHours', label: 'Variance (hrs)', type: 'number' },
|
||||||
|
{ key: 'verdict', label: 'Verdict', type: 'string' },
|
||||||
|
{ key: 'reason', label: 'Reason', type: 'string' },
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
|
||||||
|
query(ctx) {
|
||||||
|
return baseQuery(ctx)
|
||||||
|
.select("COALESCE(s.train_number, '—')", 'trainNumber')
|
||||||
|
.addSelect('s.station', 'station')
|
||||||
|
.addSelect('s.country', 'country')
|
||||||
|
.addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
|
||||||
|
.addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||||
|
.addSelect(STAYING_HOURS, 'stayingHours')
|
||||||
|
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
|
||||||
|
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
|
||||||
|
.addSelect(
|
||||||
|
`CASE WHEN (${STAYING_HOURS})::numeric <= ${STANDARD_HOURS}
|
||||||
|
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||||
|
'verdict',
|
||||||
|
)
|
||||||
|
// The note staff leave on the checkpoint is the only free text on a stop,
|
||||||
|
// so it is where a reason for an over-standard stay is recorded today.
|
||||||
|
.addSelect('s.note', 'reason');
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await baseQuery(ctx)
|
||||||
|
.select('COUNT(*)::int', 'stops')
|
||||||
|
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
|
||||||
|
.addSelect(
|
||||||
|
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
|
||||||
|
'overStandard',
|
||||||
|
)
|
||||||
|
.getRawOne<{ stops: number; avgHours: number; overStandard: number }>();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
||||||
|
{ label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
||||||
|
{ label: 'Over standard', value: Number(row?.overStandard ?? 0) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 train’s ' +
|
||||||
|
'departure two legs later — the way the standard is built, so the closing station ' +
|
||||||
|
'stay is inside the cycle. Compared against the standard cycle (65h container, 88h ' +
|
||||||
|
'bulk via DMP, 96h via Negad or BCC — editable in Operating standards). Implement ' +
|
||||||
|
'rate is [(SC − AD) / SC + 1] × 100, so finishing exactly on standard scores 100. ' +
|
||||||
|
'The Ethiopia, Djibouti and travelling split comes from logged station checkpoints ' +
|
||||||
|
'and reads zero for a train whose stops were never logged.',
|
||||||
|
group: 'Operations',
|
||||||
|
filters: [
|
||||||
|
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||||
|
DIRECTION_FILTER,
|
||||||
|
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'c.train_number' },
|
||||||
|
{ key: 'route', label: 'Route', type: 'string' },
|
||||||
|
{ key: 'cycleStart', label: 'Cycle start', type: 'date', sortable: true, sortExpr: 'c.cycle_start' },
|
||||||
|
{ key: 'cycleEnd', label: 'Cycle end', type: 'date' },
|
||||||
|
{ key: 'adHours', label: 'Average duration (hrs)', type: 'number', sortable: true, sortExpr: AD_HOURS },
|
||||||
|
{ key: 'scHours', label: 'Standard cycle (hrs)', type: 'number' },
|
||||||
|
{ key: 'implementRate', label: 'Implement rate', type: 'percent', sortable: true },
|
||||||
|
{ key: 'ethiopiaHours', label: 'Ethiopia stay (hrs)', type: 'number' },
|
||||||
|
{ key: 'djiboutiHours', label: 'Djibouti stay (hrs)', type: 'number' },
|
||||||
|
{ key: 'travellingHours', label: 'Travelling (hrs)', type: 'number' },
|
||||||
|
{ key: 'averageDays', label: 'Average day', type: 'number' },
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'cycleStart', dir: 'DESC' },
|
||||||
|
chart: { type: 'bar', x: 'trainNumber', y: ['adHours'] },
|
||||||
|
query(ctx) {
|
||||||
|
return baseQuery(ctx)
|
||||||
|
.select("COALESCE(c.train_number, '—')", 'trainNumber')
|
||||||
|
.addSelect("c.origin || ' → ' || c.destination", 'route')
|
||||||
|
.addSelect(`to_char(c.cycle_start, 'YYYY-MM-DD HH24:MI')`, 'cycleStart')
|
||||||
|
.addSelect(`to_char(c.cycle_end, 'YYYY-MM-DD HH24:MI')`, 'cycleEnd')
|
||||||
|
.addSelect(AD_HOURS, 'adHours')
|
||||||
|
.addSelect('c.standard_hours::float8', 'scHours')
|
||||||
|
.addSelect(cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours'), 'implementRate')
|
||||||
|
.addSelect(`${ETHIOPIA_HOURS}::float8`, 'ethiopiaHours')
|
||||||
|
.addSelect(`${DJIBOUTI_HOURS}::float8`, 'djiboutiHours')
|
||||||
|
.addSelect(TRAVEL_HOURS, 'travellingHours')
|
||||||
|
.addSelect(`ROUND((${AD_HOURS})::numeric / 24, 2)::float8`, 'averageDays');
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await baseQuery(ctx)
|
||||||
|
.select('COUNT(*)::int', 'cycles')
|
||||||
|
.addSelect(`ROUND(AVG((${AD_HOURS})::numeric), 1)::float8`, 'avgHours')
|
||||||
|
.addSelect(
|
||||||
|
`ROUND(AVG(${cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours')}::numeric), 1)::float8`,
|
||||||
|
'avgRate',
|
||||||
|
)
|
||||||
|
.getRawOne<{ cycles: number; avgHours: number; avgRate: number }>();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Cycles', value: Number(row?.cycles ?? 0) },
|
||||||
|
{ label: 'Average duration', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
||||||
|
{ label: 'Average implement rate', value: Number(row?.avgRate ?? 0), unit: '%' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 — Nagad–Dire Dawa is planned 2,106 t and operated none, and
|
||||||
|
* publishes as 0%. Dropping the row would hide a total miss, which is the one
|
||||||
|
* thing a plan-versus-actual table exists to show.
|
||||||
|
*
|
||||||
|
* Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind
|
||||||
|
* with {@link plannedRowsParams} — they come from the user's date filter.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Appended to every plan-versus-actual report's description, because the
|
||||||
|
* re-bucketing rule is not guessable from the table.
|
||||||
|
*/
|
||||||
|
export const PLAN_GRANULARITY_NOTE =
|
||||||
|
' A plan is spread evenly across its own period and re-gathered into whichever bucket ' +
|
||||||
|
'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' +
|
||||||
|
'or weekly view gets its share of it. A week that straddles two months draws on both.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's date filter as open-ended bounds, so the clipping arithmetic below
|
||||||
|
* never has to branch on null.
|
||||||
|
*/
|
||||||
|
const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)";
|
||||||
|
const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)";
|
||||||
|
|
||||||
|
export const plannedRowsSql = (
|
||||||
|
metric: string,
|
||||||
|
dimension: string,
|
||||||
|
params: Record<string, 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,
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export class CargoTypesService {
|
|||||||
itemsPerWagonMap: dto.itemsPerWagonMap,
|
itemsPerWagonMap: dto.itemsPerWagonMap,
|
||||||
}),
|
}),
|
||||||
tonsPerWagonMap,
|
tonsPerWagonMap,
|
||||||
|
fullTrainsetWagons: dto.fullTrainsetWagons ?? null,
|
||||||
displayOrder,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)' })
|
||||||
|
|||||||
643
apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts
Normal file
643
apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts
Normal 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);
|
||||||
|
});
|
||||||
83
apps/edr-freight-api/src/scripts/validate-export-datasets.ts
Normal file
83
apps/edr-freight-api/src/scripts/validate-export-datasets.ts
Normal 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();
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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(", ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user