mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue
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 { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||
import { OperationsReportingModule } from "./modules/operations-reporting/operations-reporting.module";
|
||||
import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
|
||||
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
||||
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||
@@ -101,6 +102,7 @@ import { RoutesModule } from "./modules/routes/routes.module";
|
||||
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||
import { OverviewModule } from "./modules/overview/overview.module";
|
||||
import { ReportsModule } from "./modules/reports/reports.module";
|
||||
import { ExportsModule } from "./modules/exports/exports.module";
|
||||
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||
@@ -219,6 +221,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
OperationsReportingModule,
|
||||
PaymentSettingsModule,
|
||||
StampSettingsModule,
|
||||
LogoSettingsModule,
|
||||
@@ -239,6 +242,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
ReportsModule,
|
||||
ExportsModule,
|
||||
UserTradeAccessModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
|
||||
@@ -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)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
/**
|
||||
* Ceiling is 500, matching `MAX_PAGE_SIZE` in `common/utils/pagination.util.ts`
|
||||
* and the backoffice table footer's largest option. The three have to agree:
|
||||
* a lower value here turns the footer's top preset into a 400, a higher one
|
||||
* lets a request through that the util then silently truncates.
|
||||
*/
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 500 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(String(value), 10) || 20)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Max(500)
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
|
||||
@@ -20,7 +20,12 @@ export interface NormalizedPage {
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
/**
|
||||
* Must stay in step with `@Max` on `PaginationQueryDto.pageSize` and with
|
||||
* `MAX_PAGE_SIZE` in the backoffice's data-table footer — the DTO rejects,
|
||||
* this clamps, and the footer is what actually asks for the number.
|
||||
*/
|
||||
const MAX_PAGE_SIZE = 500;
|
||||
|
||||
/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
|
||||
export function normalizePagination(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`.
|
||||
*
|
||||
* `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`:
|
||||
* a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously.
|
||||
* `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck
|
||||
* batch (webhook never arrived) can be found and reconciled by conversation id.
|
||||
*/
|
||||
export class EimsBulkRegistration3580000000000 implements MigrationInterface {
|
||||
name = "EimsBulkRegistration3580000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
ADD COLUMN IF NOT EXISTS in_flight_conversation_id text
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
DROP COLUMN IF EXISTS in_flight_conversation_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_bulk_conversation_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */
|
||||
export class AdditionalCharge3620000000000 implements MigrationInterface {
|
||||
name = 'AdditionalCharge3620000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."additional_charge" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
"booking_id" uuid NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"status" character varying(20) NOT NULL DEFAULT 'DRAFT',
|
||||
"amount" numeric(14,2) NOT NULL,
|
||||
"currency" character varying(8) NOT NULL,
|
||||
"file_record_id" uuid,
|
||||
"invoice_id" uuid,
|
||||
"payment_reference" character varying(64),
|
||||
"created_by_staff_id" uuid,
|
||||
"sent_by_staff_id" uuid,
|
||||
"sent_at" timestamptz,
|
||||
"paid_at" timestamptz,
|
||||
"cancelled_by_staff_id" uuid,
|
||||
"cancelled_at" timestamptz,
|
||||
"cancel_reason" text,
|
||||
CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id")
|
||||
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking"
|
||||
ON "freight"."additional_charge" ("booking_id")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { logCtx } from "@edr/api-common";
|
||||
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { AdditionalCharge } from "../bookings/entities/additional-charge.entity";
|
||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||
// payers straight off the table.
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
@@ -46,6 +47,29 @@ export interface PayInvoiceOptions {
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an invoice's `sourceId` actually points at, resolved for display.
|
||||
*
|
||||
* `source` alone ("warehouse", "booking", …) says which subsystem raised the
|
||||
* invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every
|
||||
* source except a shipping-line credit hangs off a booking — directly
|
||||
* (booking/clearance) or through the warehouse/first-mile/last-mile record —
|
||||
* so the booking reference is the one label that identifies almost any row.
|
||||
*/
|
||||
export interface InvoiceSourceRef {
|
||||
/** Booking behind the invoice, when there is one. Null for shipping-line credits. */
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
tradeDirection: string | null;
|
||||
/** Warehouse-sourced rows: the goods-received note the fees were raised against. */
|
||||
grnNumber: string | null;
|
||||
/** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */
|
||||
shippingLineName: string | null;
|
||||
}
|
||||
|
||||
/** Row shape of the backoffice invoice list: the entity plus its resolved source. */
|
||||
export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null };
|
||||
|
||||
/** Booking context attached to a finance offline-USD invoice row. */
|
||||
export interface OfflineUsdBookingInfo {
|
||||
id: string;
|
||||
@@ -236,8 +260,32 @@ export class BillingService {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
// Searches what the row actually shows: its number, who it bills, and
|
||||
// the source record behind it (booking reference, GRN, shipping line).
|
||||
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
|
||||
// Requires the `company` alias — every caller of this joins it.
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
`(invoice.invoiceNumber ILIKE :search
|
||||
OR invoice.sourceId ILIKE :search
|
||||
OR company.name ILIKE :search
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.bookings b
|
||||
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
|
||||
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
|
||||
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
|
||||
WHERE b.reference ILIKE :search
|
||||
AND (b.id::text = invoice.source_id
|
||||
OR wi.id::text = invoice.source_id
|
||||
OR fm.id::text = invoice.source_id
|
||||
OR lm.id::text = invoice.source_id))
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory wi2
|
||||
WHERE wi2.id::text = invoice.source_id
|
||||
AND wi2.grn_number ILIKE :search)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.shipping_line_companies slc
|
||||
WHERE slc.id::text = invoice.source_id
|
||||
AND slc.name ILIKE :search))`,
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
@@ -261,7 +309,7 @@ export class BillingService {
|
||||
/** Per-user trade-direction scope, applied via the source booking. */
|
||||
tradeDirections?: string[];
|
||||
} = {},
|
||||
): Promise<{ items: Invoice[]; total: number }> {
|
||||
): Promise<{ items: InvoiceListRow[]; total: number }> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
@@ -277,7 +325,92 @@ export class BillingService {
|
||||
this.applyInvoiceFilters(qb, filter);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
return { items: await this.attachShippingLineCompanies(items), total };
|
||||
const withLines = await this.attachShippingLineCompanies(items);
|
||||
return { items: await this.attachSourceRefs(withLines), total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each row's `sourceId` to the record it points at, in one query for
|
||||
* the whole page. `sourceId` is a bare varchar pointer with no FK and no
|
||||
* relation to eager-load, and which table it addresses depends on `source` —
|
||||
* so this walks every candidate table at once and lands on the booking
|
||||
* through whichever one matched.
|
||||
*
|
||||
* `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence
|
||||
* the shape guard before every cast — an unguarded `::uuid` throws on those.
|
||||
*/
|
||||
private async attachSourceRefs<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 +469,9 @@ export class BillingService {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
|
||||
// so the alias has to exist even though the summary only sums money.
|
||||
.leftJoin("invoice.company", "company")
|
||||
.select("invoice.currency", "currency")
|
||||
.addSelect("SUM(invoice.paidAmount)", "collected")
|
||||
.groupBy("invoice.currency");
|
||||
@@ -1823,6 +1959,14 @@ export class BillingService {
|
||||
.getRepository(Booking)
|
||||
.update({ id: invoice.sourceId }, { pnrCode: billReference });
|
||||
}
|
||||
// Same reference, for an ad-hoc additional charge — its own column, since
|
||||
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
|
||||
// can carry many of these at once.
|
||||
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
|
||||
await this.dataSource
|
||||
.getRepository(AdditionalCharge)
|
||||
.update({ id: invoice.sourceId }, { paymentReference: billReference });
|
||||
}
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept for local demos only.
|
||||
|
||||
@@ -203,4 +203,12 @@ export class Invoice extends BaseEntity {
|
||||
@ManyToOne(() => Invoice)
|
||||
@JoinColumn({ name: "related_invoice_id" })
|
||||
relatedInvoice?: Invoice | null;
|
||||
|
||||
/**
|
||||
* Which `POST /v1/bulkRegister` batch this invoice was submitted in, if any — MoR's own
|
||||
* conversation id, not one we generate. Lets a stuck batch (webhook never arrived) be found and
|
||||
* reconciled. Null for every invoice filed through single `/v1/register`.
|
||||
*/
|
||||
@Column({ name: "eims_bulk_conversation_id", type: "text", nullable: true })
|
||||
eimsBulkConversationId?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AdditionalChargeRepository extends BaseRepository<AdditionalCharge> {
|
||||
constructor(@InjectRepository(AdditionalCharge) repository: Repository<AdditionalCharge>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { AdditionalChargeRepository } from './additional-charge.repository';
|
||||
import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||
import { CreateAdditionalChargeDto } from './dto/additional-charge.dto';
|
||||
|
||||
const FILE_RESOURCE = 'additional_charges';
|
||||
|
||||
/**
|
||||
* Ad-hoc extra charges finance raises against a booking, independent of
|
||||
* `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS
|
||||
* row per booking). Any number per booking, free-text reason. DRAFT until
|
||||
* sent; sending issues the payable invoice and notifies the customer
|
||||
* (in-app + SMS + email). Settles via `additional_charge.invoice.paid`,
|
||||
* same event-driven pattern as every other invoice source.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdditionalChargeService {
|
||||
private readonly logger = new Logger(AdditionalChargeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly repository: AdditionalChargeRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
private async findOwned(bookingId: string, chargeId: string): Promise<AdditionalCharge> {
|
||||
const charge = await this.repository.findById(chargeId);
|
||||
if (!charge || charge.bookingId !== bookingId) {
|
||||
throw new NotFoundException('Additional charge not found');
|
||||
}
|
||||
return charge;
|
||||
}
|
||||
|
||||
async list(bookingId: string): Promise<Freight.AdditionalCharge[]> {
|
||||
const rows = await this.repository.findAll({
|
||||
where: { bookingId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return this.toDtoList(rows);
|
||||
}
|
||||
|
||||
async create(
|
||||
bookingId: string,
|
||||
dto: CreateAdditionalChargeDto,
|
||||
staffId: string,
|
||||
file?: Express.Multer.File,
|
||||
): Promise<Freight.AdditionalCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const shouldSend = dto.action === 'send';
|
||||
|
||||
const chargeId = await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(AdditionalCharge);
|
||||
let saved = await repo.save(
|
||||
repo.create({
|
||||
bookingId,
|
||||
reason: dto.reason.trim(),
|
||||
amount: dto.amount.toFixed(2),
|
||||
currency: dto.currency.trim().toUpperCase(),
|
||||
status: 'DRAFT',
|
||||
createdByStaffId: staffId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (file) {
|
||||
const record = await this.filesService.upload({
|
||||
resourceId: saved.id,
|
||||
resource: FILE_RESOURCE,
|
||||
code: FILE_RESOURCE,
|
||||
file,
|
||||
uploadedByUserId: staffId,
|
||||
});
|
||||
await repo.update(saved.id, { fileRecordId: record.id });
|
||||
}
|
||||
|
||||
if (shouldSend) {
|
||||
saved = await this.issueInvoice(manager, saved.id, booking, staffId);
|
||||
}
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
if (shouldSend) await this.notifyCustomerSent(chargeId);
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
async send(bookingId: string, chargeId: string, staffId: string): Promise<Freight.AdditionalCharge[]> {
|
||||
const charge = await this.findOwned(bookingId, chargeId);
|
||||
if (charge.status !== 'DRAFT') {
|
||||
throw new ConflictException('Only a draft charge can be sent.');
|
||||
}
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
|
||||
await this.dataSource.transaction((manager) =>
|
||||
this.issueInvoice(manager, charge.id, booking, staffId),
|
||||
);
|
||||
await this.notifyCustomerSent(charge.id);
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */
|
||||
private async issueInvoice(
|
||||
manager: EntityManager,
|
||||
chargeId: string,
|
||||
booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null },
|
||||
staffId: string,
|
||||
): Promise<AdditionalCharge> {
|
||||
const repo = manager.getRepository(AdditionalCharge);
|
||||
const charge = await repo.findOneByOrFail({ id: chargeId });
|
||||
|
||||
const invoice = await this.billing.generateInvoice(
|
||||
{
|
||||
source: Freight.InvoiceSource.AdditionalCharge,
|
||||
sourceId: charge.id,
|
||||
type: 'ADDITIONAL_CHARGE',
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: charge.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'ADDITIONAL_CHARGE',
|
||||
description: `${charge.reason} — ${booking.reference ?? booking.id}`,
|
||||
amount: Number(charge.amount),
|
||||
},
|
||||
],
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await repo.update(charge.id, {
|
||||
status: 'SENT',
|
||||
invoiceId: invoice.id,
|
||||
sentByStaffId: staffId,
|
||||
sentAt: new Date(),
|
||||
});
|
||||
this.logger.log(
|
||||
`Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
return repo.findOneByOrFail({ id: charge.id });
|
||||
}
|
||||
|
||||
private async notifyCustomerSent(chargeId: string): Promise<void> {
|
||||
try {
|
||||
const charge = await this.repository.findById(chargeId);
|
||||
if (!charge) return;
|
||||
const booking = await this.bookingsService.findById(charge.bookingId);
|
||||
if (!booking.companyId) return;
|
||||
const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
title: 'New charge on your booking',
|
||||
body,
|
||||
link: `/bookings/${charge.bookingId}`,
|
||||
data: {
|
||||
bookingId: charge.bookingId,
|
||||
chargeId: charge.id,
|
||||
amount: Number(charge.amount),
|
||||
currency: charge.currency,
|
||||
},
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
staffId: string,
|
||||
reason?: string,
|
||||
): Promise<Freight.AdditionalCharge[]> {
|
||||
const charge = await this.findOwned(bookingId, chargeId);
|
||||
if (charge.status !== 'DRAFT' && charge.status !== 'SENT') {
|
||||
throw new ConflictException('Only a draft or unpaid charge can be cancelled.');
|
||||
}
|
||||
if (charge.status === 'SENT' && charge.invoiceId) {
|
||||
await this.billing.cancelInvoice(charge.invoiceId);
|
||||
}
|
||||
await this.repository.update(charge.id, {
|
||||
status: 'CANCELLED',
|
||||
cancelledByStaffId: staffId,
|
||||
cancelledAt: new Date(),
|
||||
cancelReason: reason ?? null,
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
|
||||
@OnEvent('additional_charge.invoice.paid')
|
||||
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
const charge = await this.repository.findById(payload.sourceId);
|
||||
if (!charge || charge.status === 'PAID') return;
|
||||
await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() });
|
||||
|
||||
try {
|
||||
const booking = await this.bookingsService.findById(charge.bookingId);
|
||||
if (!booking.companyId) return;
|
||||
const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title: 'Charge payment received',
|
||||
body,
|
||||
link: `/bookings/${charge.bookingId}`,
|
||||
data: { bookingId: charge.bookingId, chargeId: charge.id },
|
||||
});
|
||||
await this.inbox.notify({
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title: 'Additional charge paid',
|
||||
body,
|
||||
link: `/bookings/${charge.bookingId}`,
|
||||
data: { bookingId: charge.bookingId, chargeId: charge.id },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async toDtoList(rows: AdditionalCharge[]): Promise<Freight.AdditionalCharge[]> {
|
||||
if (!rows.length) return [];
|
||||
|
||||
const filesByCharge = await this.filesService.findByResourceIdsGrouped(
|
||||
rows.map((r) => r.id),
|
||||
FILE_RESOURCE,
|
||||
);
|
||||
const names = await this.bookingsRepository.resolveStaffNames(
|
||||
rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]),
|
||||
);
|
||||
|
||||
const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id));
|
||||
const invoices = invoiceIds.length
|
||||
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
|
||||
: [];
|
||||
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
|
||||
|
||||
return rows.map((r) => {
|
||||
const file = filesByCharge.get(r.id)?.[0];
|
||||
return {
|
||||
id: r.id,
|
||||
bookingId: r.bookingId,
|
||||
reason: r.reason,
|
||||
status: r.status,
|
||||
amount: Number(r.amount),
|
||||
currency: r.currency,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
invoiceId: r.invoiceId ?? null,
|
||||
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null,
|
||||
paymentReference: r.paymentReference ?? null,
|
||||
createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null,
|
||||
sentAt: r.sentAt?.toISOString() ?? null,
|
||||
paidAt: r.paidAt?.toISOString() ?? null,
|
||||
cancelledAt: r.cancelledAt?.toISOString() ?? null,
|
||||
cancelReason: r.cancelReason ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -42,10 +42,16 @@ import type { Response } from "express";
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingPayablesService } from './booking-payables.service';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
<<<<<<< HEAD
|
||||
import {
|
||||
BillClearanceChargeDto,
|
||||
RejectClearanceChargeDto,
|
||||
} from './dto/clearance-charge.dto';
|
||||
=======
|
||||
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
||||
import { AdditionalChargeService } from './additional-charge.service';
|
||||
import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto';
|
||||
>>>>>>> 82c795999efce5e4422dd332551cecab2592764d
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
@@ -181,6 +187,7 @@ export class BookingsController {
|
||||
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||
private readonly bookingPayablesService: BookingPayablesService,
|
||||
private readonly clearanceEventService: ClearanceEventService,
|
||||
private readonly additionalChargeService: AdditionalChargeService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -1278,6 +1285,64 @@ export class BookingsController {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Additional charges (ad-hoc finance billing) ────────────────────────────
|
||||
|
||||
@Get(":id/additional-charges")
|
||||
@MixedAudience(FREIGHT_PERMS.additionalCharges.view)
|
||||
@ApiOperation({ summary: "Ad-hoc extra charges finance has raised against this booking" })
|
||||
async getAdditionalCharges(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view);
|
||||
if (!isStaff) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const charges = await this.additionalChargeService.list(id);
|
||||
// A charge finance hasn't sent yet isn't the customer's to see.
|
||||
return isStaff ? charges : charges.filter((c) => c.status !== "DRAFT");
|
||||
}
|
||||
|
||||
@Post(":id/additional-charges")
|
||||
@BookingStaff(FREIGHT_PERMS.additionalCharges.create)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Finance raises a new additional charge — draft, or send to the customer immediately",
|
||||
})
|
||||
createAdditionalCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() dto: CreateAdditionalChargeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.additionalChargeService.create(id, dto, resolveAuthUserId(user), file);
|
||||
}
|
||||
|
||||
@Post(":id/additional-charges/:chargeId/send")
|
||||
@BookingStaff(FREIGHT_PERMS.additionalCharges.send)
|
||||
@ApiOperation({ summary: "Issue the draft charge's payable invoice and notify the customer" })
|
||||
sendAdditionalCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.additionalChargeService.send(id, chargeId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(":id/additional-charges/:chargeId/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.additionalCharges.cancel)
|
||||
@ApiOperation({ summary: "Withdraw a draft or unpaid additional charge" })
|
||||
cancelAdditionalCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@Body() dto: CancelAdditionalChargeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.additionalChargeService.cancel(id, chargeId, resolveAuthUserId(user), dto.reason);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/output-documents")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -38,6 +38,9 @@ import { BookingsService } from './bookings.service';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
|
||||
import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||
import { AdditionalChargeRepository } from './additional-charge.repository';
|
||||
import { AdditionalChargeService } from './additional-charge.service';
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingPayablesService } from './booking-payables.service';
|
||||
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
|
||||
@@ -83,6 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ConsolidationApproval,
|
||||
BookingClearanceCharge,
|
||||
BookingClearanceEvent,
|
||||
AdditionalCharge,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
@@ -121,6 +125,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingClearanceChargeService,
|
||||
BookingPayablesService,
|
||||
ClearanceEventService,
|
||||
AdditionalChargeRepository,
|
||||
AdditionalChargeService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
|
||||
@@ -79,8 +79,10 @@ export interface BookingListFilterOptions {
|
||||
createdTo?: string;
|
||||
scheduledFrom?: string;
|
||||
scheduledTo?: string;
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
/** Any of these origin yards (OR). ANDed with `destinationYardId`. */
|
||||
originYardId?: string[];
|
||||
/** Any of these destination yards (OR). ANDed with `originYardId`. */
|
||||
destinationYardId?: string[];
|
||||
isGovernment?: 'true' | 'false';
|
||||
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
|
||||
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
|
||||
@@ -1151,14 +1153,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
scheduledTo: options.scheduledTo,
|
||||
});
|
||||
}
|
||||
if (options.originYardId) {
|
||||
qb.andWhere('booking.origin_yard_id = :originYardId', {
|
||||
originYardId: options.originYardId,
|
||||
// Each end is its own OR-list, and the two ends AND together — so
|
||||
// "leaving Nagad or DMP" and "leaving Nagad, arriving Gelan" are both
|
||||
// expressible. `?.length` guards the empty array: `IN ()` is a syntax error.
|
||||
if (options.originYardId?.length) {
|
||||
qb.andWhere('booking.origin_yard_id IN (:...originYardIds)', {
|
||||
originYardIds: options.originYardId,
|
||||
});
|
||||
}
|
||||
if (options.destinationYardId) {
|
||||
qb.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||
destinationYardId: options.destinationYardId,
|
||||
if (options.destinationYardId?.length) {
|
||||
qb.andWhere('booking.destination_yard_id IN (:...destinationYardIds)', {
|
||||
destinationYardIds: options.destinationYardId,
|
||||
});
|
||||
}
|
||||
if (options.isGovernment === 'true') {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator';
|
||||
|
||||
export class CreateAdditionalChargeDto {
|
||||
@ApiProperty({ example: 'Re-weighing fee at Mojo dry port' })
|
||||
@IsString()
|
||||
@Length(1, 2000)
|
||||
reason!: string;
|
||||
|
||||
@ApiProperty({ example: 4500 })
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
@IsString()
|
||||
@Length(3, 8)
|
||||
currency!: string;
|
||||
|
||||
/** 'send' issues the invoice + notifies the customer immediately; omit/'draft' just saves it. */
|
||||
@ApiPropertyOptional({ enum: ['draft', 'send'], default: 'draft' })
|
||||
@IsOptional()
|
||||
@IsIn(['draft', 'send'])
|
||||
action?: 'draft' | 'send';
|
||||
}
|
||||
|
||||
export class CancelAdditionalChargeDto {
|
||||
@ApiPropertyOptional({ example: 'Raised in error' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 2000)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TRADE_DIRECTIONS,
|
||||
} from './create-booking.dto';
|
||||
import { PAYMENT_STATUSES } from '../entities/booking.entity';
|
||||
import { IdListParam } from '../../../common/dto/id-list.transform';
|
||||
|
||||
export class FilterBookingDto {
|
||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||
@@ -96,15 +97,23 @@ export class FilterBookingDto {
|
||||
@IsDateString()
|
||||
scheduledTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Filter by origin yard — one id or a comma-separated list; a booking matches if it leaves ANY of them.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
originYardId?: string[];
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Filter by destination yard — one id or a comma-separated list; a booking matches if it arrives at ANY of them. Combined with originYardId by AND.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
destinationYardId?: string[];
|
||||
|
||||
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const ADDITIONAL_CHARGE_STATUSES = [
|
||||
'DRAFT',
|
||||
'SENT',
|
||||
'PAID',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type AdditionalChargeStatus = (typeof ADDITIONAL_CHARGE_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* An ad-hoc extra charge finance raises against a booking — free-text reason,
|
||||
* any number per booking (unlike `BookingClearanceCharge`, which caps at one
|
||||
* per type). DRAFT until finance sends it; sending issues the payable invoice
|
||||
* and notifies the customer (in-app + SMS + email). PAID via the standard
|
||||
* `additional_charge.invoice.paid` settlement event.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'additional_charge' })
|
||||
@Index(['bookingId'])
|
||||
export class AdditionalCharge extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'reason', type: 'text' })
|
||||
reason!: string;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||
status!: AdditionalChargeStatus;
|
||||
|
||||
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 })
|
||||
amount!: string;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8 })
|
||||
currency!: string;
|
||||
|
||||
/** The supporting attachment (FileRecord), if any. */
|
||||
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||
fileRecordId?: string | null;
|
||||
|
||||
/** The payable invoice issued for this charge (null until SENT). */
|
||||
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
/** CBE bill reference / PNR the customer pays against, once issued. */
|
||||
@Column({ name: 'payment_reference', type: 'varchar', length: 64, nullable: true })
|
||||
paymentReference?: string | null;
|
||||
|
||||
@Column({ name: 'created_by_staff_id', type: 'uuid', nullable: true })
|
||||
createdByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'sent_by_staff_id', type: 'uuid', nullable: true })
|
||||
sentByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
|
||||
sentAt?: Date | null;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'cancelled_by_staff_id', type: 'uuid', nullable: true })
|
||||
cancelledByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||
cancelledAt?: Date | null;
|
||||
|
||||
@Column({ name: 'cancel_reason', type: 'text', nullable: true })
|
||||
cancelReason?: string | null;
|
||||
}
|
||||
@@ -48,8 +48,10 @@ export interface ContractListFilterOptions {
|
||||
hasClearanceDocuments?: boolean;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
/** Any of these origin yards (OR). ANDed with `destinationYardId`. */
|
||||
originYardId?: string[];
|
||||
/** Any of these destination yards (OR). ANDed with `originYardId`. */
|
||||
destinationYardId?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -494,20 +496,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
// Routes are one-to-many (a contract can list several lanes), so origin
|
||||
// and destination each need their own EXISTS — a plain join would
|
||||
// duplicate the contract row per matching route.
|
||||
if (omit !== 'originYardId' && options.originYardId) {
|
||||
// Each end is an OR-list, the two ends AND together. Note this still means
|
||||
// "has a route from one of these origins" AND "has a route to one of these
|
||||
// destinations" — not necessarily the SAME route, which is what the two
|
||||
// separate EXISTS have always meant and what the filter bar's two
|
||||
// independent pickers describe.
|
||||
if (omit !== 'originYardId' && options.originYardId?.length) {
|
||||
qb.andWhere(
|
||||
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
|
||||
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
|
||||
'AND cr_o.origin_yard_id = :originYardId)',
|
||||
{ originYardId: options.originYardId },
|
||||
'AND cr_o.origin_yard_id IN (:...originYardIds))',
|
||||
{ originYardIds: options.originYardId },
|
||||
);
|
||||
}
|
||||
if (omit !== 'destinationYardId' && options.destinationYardId) {
|
||||
if (omit !== 'destinationYardId' && options.destinationYardId?.length) {
|
||||
qb.andWhere(
|
||||
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
|
||||
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
|
||||
'AND cr_d.destination_yard_id = :destinationYardId)',
|
||||
{ destinationYardId: options.destinationYardId },
|
||||
'AND cr_d.destination_yard_id IN (:...destinationYardIds))',
|
||||
{ destinationYardIds: options.destinationYardId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
import { IdListParam } from '../../../common/dto/id-list.transform';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
@@ -62,20 +63,22 @@ export class FilterContractDto {
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Only contracts with a route starting at this yard.',
|
||||
description:
|
||||
'Only contracts with a route starting at one of these yards — a single id or a comma-separated list.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
originYardId?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Only contracts with a route ending at this yard.',
|
||||
description:
|
||||
'Only contracts with a route ending at one of these yards — a single id or a comma-separated list. ANDed with originYardId.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
destinationYardId?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ArrayMinSize, IsArray, IsUUID } from "class-validator";
|
||||
|
||||
/** `POST invoices/eims/bulk-register` body — see `EimsBulkRegistrationService.registerBulk`. */
|
||||
export class BulkRegisterEimsInvoiceDto {
|
||||
@ApiProperty({ type: [String], description: "Invoice IDs to register with MoR EIMS in one batch." })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID("4", { each: true })
|
||||
invoiceIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
import { buildEimsSeller } from "./eims-invoice-context";
|
||||
|
||||
const SYSTEM_NUMBER = "B0360154BA";
|
||||
const INVOICE_A = "11111111-1111-4111-8111-111111111111";
|
||||
const INVOICE_B = "22222222-2222-4222-8222-222222222222";
|
||||
const CONVERSATION_ID = "2345678901-1735900502800-c04f8dd6-e6e2-4198-b871-c6e504fc14f5";
|
||||
|
||||
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||
({
|
||||
id: INVOICE_A,
|
||||
invoiceNumber: "INV-20260807-00001",
|
||||
currency: "ETB",
|
||||
companyId: "company-1",
|
||||
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
|
||||
totalAmount: "10000.00",
|
||||
eimsStatus: EimsInvoiceStatus.NotSubmitted,
|
||||
eimsIrn: null,
|
||||
eimsDocumentType: "INV",
|
||||
eimsBulkConversationId: null,
|
||||
company: {
|
||||
name: "ABC Trading PLC",
|
||||
tin: "0999930000",
|
||||
vatNumber: "123475885858",
|
||||
phone: "0912345678",
|
||||
region: "13",
|
||||
zone: "SHA",
|
||||
woreda: "574",
|
||||
kebele: "03",
|
||||
houseNo: "NEW",
|
||||
country: "Ethiopia",
|
||||
},
|
||||
...over,
|
||||
}) as unknown as Invoice;
|
||||
|
||||
const LINES = (id: string) => [
|
||||
{
|
||||
invoiceId: id,
|
||||
chargeType: "RAIL_FREIGHT",
|
||||
description: "Addis to Djibouti",
|
||||
quantity: "1.00",
|
||||
unitRate: "10000.00",
|
||||
amount: "10000.00",
|
||||
},
|
||||
];
|
||||
|
||||
/** In-memory stand-in covering the query/manager surface this service actually calls. */
|
||||
class FakeDb {
|
||||
invoices = new Map<string, Invoice>();
|
||||
state: EimsSystemState;
|
||||
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||
|
||||
constructor(invoices: Invoice[], state: Partial<EimsSystemState> = {}) {
|
||||
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||
this.state = {
|
||||
id: "state-1",
|
||||
systemNumber: SYSTEM_NUMBER,
|
||||
nextInvoiceCounter: 1,
|
||||
nextDocumentNumber: 1,
|
||||
previousIrn: null,
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
inFlightDocumentNumber: null,
|
||||
inFlightConversationId: null,
|
||||
blockedReason: null,
|
||||
...state,
|
||||
} as EimsSystemState;
|
||||
}
|
||||
|
||||
private matches(entity: Invoice | EimsSystemState, where: Record<string, unknown>): boolean {
|
||||
return Object.entries(where).every(([key, value]) => (entity as never)[key] === value);
|
||||
}
|
||||
|
||||
private queryBuilder(entityCtor: unknown) {
|
||||
let where: Record<string, unknown> = {};
|
||||
const builder = {
|
||||
setLock: () => builder,
|
||||
where: (_clause: string, params: Record<string, unknown>) => {
|
||||
where = { ...where, ...this.normalizeParams(params) };
|
||||
return builder;
|
||||
},
|
||||
andWhere: (_clause: string, params: Record<string, unknown>) => {
|
||||
where = { ...where, ...this.normalizeParams(params) };
|
||||
return builder;
|
||||
},
|
||||
getOne: async () => this.find(entityCtor, where)[0] ?? null,
|
||||
getMany: async () => this.find(entityCtor, where),
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
private normalizeParams(params: Record<string, unknown>): Record<string, unknown> {
|
||||
// Test-only mapping from the SQL param names used in the service's own queries to entity fields.
|
||||
const map: Record<string, string> = {
|
||||
invoiceId: "id",
|
||||
systemNumber: "systemNumber",
|
||||
id: "eimsBulkConversationId",
|
||||
};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(params)) out[map[k] ?? k] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
private find(entityCtor: unknown, where: Record<string, unknown>): Array<Invoice | EimsSystemState> {
|
||||
const isState = entityCtor === EimsSystemState;
|
||||
const pool: Array<Invoice | EimsSystemState> = isState ? [this.state] : [...this.invoices.values()];
|
||||
return pool.filter((e) => this.matches(e, where));
|
||||
}
|
||||
|
||||
private manager = {
|
||||
createQueryBuilder: (entityCtor: unknown) => this.queryBuilder(entityCtor),
|
||||
query: async () => [],
|
||||
findOne: async (entityCtor: unknown, options: { where: Record<string, unknown> }) =>
|
||||
this.find(entityCtor, options.where)[0] ?? null,
|
||||
update: async (entityCtor: unknown, idOrWhere: string | Record<string, unknown>, patch: Record<string, unknown>) => {
|
||||
const targets =
|
||||
typeof idOrWhere === "string"
|
||||
? this.find(entityCtor, { id: idOrWhere })
|
||||
: this.find(entityCtor, idOrWhere);
|
||||
for (const t of targets) Object.assign(t, patch);
|
||||
return { affected: targets.length };
|
||||
},
|
||||
getRepository: (entityCtor: unknown) => ({
|
||||
findOne: async (options: { where: { id: string } }) => this.find(entityCtor, { id: options.where.id })[0] ?? null,
|
||||
}),
|
||||
};
|
||||
|
||||
asDataSource(): DataSource {
|
||||
return {
|
||||
manager: this.manager,
|
||||
// Routed by SQL text: the lines lookup and sendCompanyChannels' contact lookup share this
|
||||
// one entry point in the real DataSource.
|
||||
query: async (sql: string) => {
|
||||
if (sql.includes("invoice_lines")) {
|
||||
return [...this.invoices.keys()].flatMap((id) => LINES(id));
|
||||
}
|
||||
return this.companyContact ? [this.companyContact] : [];
|
||||
},
|
||||
transaction: async (body: (m: unknown) => Promise<unknown>) => body(this.manager),
|
||||
getRepository: () => ({
|
||||
find: async (options: { where: { id: { value: string[] } } }) => {
|
||||
const ids = options.where.id.value ?? [];
|
||||
return ids.map((id: string) => this.invoices.get(id)).filter(Boolean);
|
||||
},
|
||||
createQueryBuilder: (alias: string) => {
|
||||
void alias;
|
||||
return this.queryBuilder(Invoice);
|
||||
},
|
||||
count: async (options: { where: Record<string, unknown> }) => this.find(Invoice, options.where).length,
|
||||
}),
|
||||
} as unknown as DataSource;
|
||||
}
|
||||
}
|
||||
|
||||
const build = (db: FakeDb, postSigned: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) =>
|
||||
new EimsBulkRegistrationService(
|
||||
db.asDataSource(),
|
||||
{ get: () => eimsConfig({ invoice: eimsInvoiceConfig() }) } as unknown as ConfigService,
|
||||
{ postSigned } as unknown as EimsClientService,
|
||||
{ getSessionContext: async () => ({ systemNumber: SYSTEM_NUMBER, systemType: "SYS" }) } as unknown as EimsAuthService,
|
||||
{ directSend } as unknown as NotificationsService,
|
||||
{ getSellerDetails: (c: unknown) => buildEimsSeller(c as never) } as unknown as EimsSellerCacheService,
|
||||
);
|
||||
|
||||
const accepted = (conversationId = CONVERSATION_ID) => ({ conversationId, status: 202 });
|
||||
|
||||
describe("EimsBulkRegistrationService.registerBulk", () => {
|
||||
it("reserves sequential counters, sends one signed array, and claims MoR's real conversation id", async () => {
|
||||
const db = new FakeDb(
|
||||
[invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })],
|
||||
{ nextInvoiceCounter: 5, nextDocumentNumber: 5, previousIrn: "prev-irn" },
|
||||
);
|
||||
const postSigned = jest.fn().mockResolvedValue(accepted());
|
||||
|
||||
const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]);
|
||||
|
||||
expect(result).toEqual({ conversationId: CONVERSATION_ID, accepted: [INVOICE_A, INVOICE_B], alreadyRegistered: [] });
|
||||
const [, request] = postSigned.mock.calls[0];
|
||||
expect(request).toHaveLength(2);
|
||||
expect(request[0].SourceSystem.InvoiceCounter).toBe(5);
|
||||
expect(request[0].DocumentDetails.DocumentNumber).toBe("5");
|
||||
expect(request[0].ReferenceDetails.PreviousIrn).toBe("prev-irn");
|
||||
expect(request[1].SourceSystem.InvoiceCounter).toBe(6);
|
||||
// Only the first item in a bulk batch chains — the rest have no IRN to reference yet.
|
||||
expect(request[1].ReferenceDetails.PreviousIrn).toBe("");
|
||||
|
||||
expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID });
|
||||
expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID });
|
||||
expect(db.state.nextInvoiceCounter).toBe(7);
|
||||
expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID);
|
||||
});
|
||||
|
||||
it("skips an already-registered invoice, without consuming a counter for it", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ eimsIrn: "already-irn", eimsStatus: EimsInvoiceStatus.Registered }),
|
||||
invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" }),
|
||||
]);
|
||||
const postSigned = jest.fn().mockResolvedValue(accepted());
|
||||
|
||||
const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]);
|
||||
|
||||
expect(result.alreadyRegistered).toEqual([INVOICE_A]);
|
||||
expect(result.accepted).toEqual([INVOICE_B]);
|
||||
const [, request] = postSigned.mock.calls[0];
|
||||
expect(request).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses the whole batch — no reservation, no HTTP call — when a DEB note has no registered original", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ eimsDocumentType: "DEB", relatedInvoice: { eimsIrn: null, invoiceNumber: "INV-orig" } as never }),
|
||||
]);
|
||||
const postSigned = jest.fn();
|
||||
|
||||
await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
expect(db.state.inFlightConversationId).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses when a single-invoice submission is already in flight", async () => {
|
||||
const db = new FakeDb([invoiceRow()], { inFlightInvoiceId: "some-other-invoice" });
|
||||
await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses when another bulk batch is already in flight", async () => {
|
||||
const db = new FakeDb([invoiceRow()], { inFlightConversationId: "other-conversation" });
|
||||
await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("a deterministic rejection rolls back the whole block and clears the in-flight marker", async () => {
|
||||
const db = new FakeDb(
|
||||
[invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })],
|
||||
{ nextInvoiceCounter: 5, nextDocumentNumber: 5 },
|
||||
);
|
||||
const postSigned = jest.fn().mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "bad", 400));
|
||||
|
||||
await expect(build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B])).rejects.toBeInstanceOf(EimsApiException);
|
||||
|
||||
expect(db.state.nextInvoiceCounter).toBe(5);
|
||||
expect(db.state.nextDocumentNumber).toBe(5);
|
||||
expect(db.state.inFlightConversationId).toBeNull();
|
||||
expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Failed);
|
||||
expect(db.invoices.get(INVOICE_A)?.eimsBulkConversationId).toBeNull();
|
||||
});
|
||||
|
||||
it("an ambiguous failure blocks the system number and leaves counters consumed", async () => {
|
||||
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 5, nextDocumentNumber: 5 });
|
||||
const postSigned = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "timed out"));
|
||||
|
||||
await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(EimsApiException);
|
||||
|
||||
expect(db.state.nextInvoiceCounter).toBe(6);
|
||||
expect(db.state.blockedReason).toMatch(/never acknowledged/);
|
||||
expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Unknown);
|
||||
});
|
||||
|
||||
it("refuses an empty invoice list", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
await expect(build(db, jest.fn()).registerBulk([])).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsBulkRegistrationService.handleBulkCallback", () => {
|
||||
const submittingRow = (over: Partial<Invoice>) =>
|
||||
invoiceRow({
|
||||
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||
eimsBulkConversationId: CONVERSATION_ID,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("settles a mixed success/error callback, advancing previousIrn to the last accepted item", async () => {
|
||||
const db = new FakeDb(
|
||||
[
|
||||
submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }),
|
||||
submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }),
|
||||
],
|
||||
{ inFlightConversationId: CONVERSATION_ID },
|
||||
);
|
||||
|
||||
const results = await build(db, jest.fn()).handleBulkCallback([
|
||||
{ irn: "irn-a", status: "A", documentNumber: "5" },
|
||||
{ ruleError: [{ portion: "DocumentDetails", errorMessage: ["bad date"] }], status: "ERROR", docNo: "6" },
|
||||
{ conversionId: CONVERSATION_ID },
|
||||
]);
|
||||
|
||||
expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "irn-a" });
|
||||
expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed });
|
||||
expect(db.state.previousIrn).toBe("irn-a");
|
||||
expect(db.state.inFlightConversationId).toBeNull();
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ invoiceId: INVOICE_A, success: true, irn: "irn-a" }),
|
||||
expect.objectContaining({ invoiceId: INVOICE_B, success: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a callback for an unknown or already-settled conversation", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const results = await build(db, jest.fn()).handleBulkCallback([
|
||||
{ irn: "irn-x", status: "A", documentNumber: "1" },
|
||||
{ conversionId: "no-such-conversation" },
|
||||
]);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not clear the in-flight marker while another invoice in the batch is still submitting", async () => {
|
||||
const db = new FakeDb(
|
||||
[
|
||||
submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }),
|
||||
submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }),
|
||||
],
|
||||
{ inFlightConversationId: CONVERSATION_ID },
|
||||
);
|
||||
|
||||
// Callback only reports on one of the two invoices in this batch.
|
||||
await build(db, jest.fn()).handleBulkCallback([
|
||||
{ irn: "irn-a", status: "A", documentNumber: "5" },
|
||||
{ conversionId: CONVERSATION_ID },
|
||||
]);
|
||||
|
||||
expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID);
|
||||
});
|
||||
|
||||
it("reports the current state without re-settling an invoice that already resolved", async () => {
|
||||
const db = new FakeDb(
|
||||
[
|
||||
invoiceRow({
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
eimsIrn: "irn-a",
|
||||
eimsDocumentNumber: "1",
|
||||
eimsBulkConversationId: CONVERSATION_ID,
|
||||
}),
|
||||
],
|
||||
{ inFlightConversationId: CONVERSATION_ID },
|
||||
);
|
||||
|
||||
const results = await build(db, jest.fn()).handleBulkCallback([
|
||||
{ irn: "irn-a", status: "A", documentNumber: "1" },
|
||||
{ conversionId: CONVERSATION_ID },
|
||||
]);
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ invoiceId: INVOICE_A, success: true, message: expect.stringContaining("Already settled") }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||
import {
|
||||
EimsBulkCallbackItem,
|
||||
EimsBulkRegisterAcceptedResponse,
|
||||
EimsBulkRegisterItemResult,
|
||||
EimsBulkRegisterRequest,
|
||||
EimsInvoiceError,
|
||||
EimsInvoiceStatus,
|
||||
} from "./eims-registration.types";
|
||||
|
||||
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
|
||||
|
||||
interface BulkReservation {
|
||||
stateId: string;
|
||||
invoice: Invoice & { lines: EimsMapperLine[] };
|
||||
documentType: EimsDocumentType;
|
||||
relatedDocument: string | null;
|
||||
invoiceCounter: number;
|
||||
documentNumber: string;
|
||||
previousIrn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers many invoices with MoR EIMS in one call — `POST /v1/bulkRegister`.
|
||||
*
|
||||
* Fundamentally different shape from `EimsInvoiceRegistrationService.registerInvoiceWithEims`:
|
||||
* that endpoint answers synchronously (an IRN or a rejection, in the HTTP response itself). Bulk
|
||||
* does not — it returns only `{conversationId, status:202}` immediately, and the real per-invoice
|
||||
* results (a mix of accepted/rejected in one array, per the collection's own examples) arrive later
|
||||
* as a POST to a webhook MoR was configured with out of band. That means this service has two
|
||||
* halves that don't share a call stack: `registerBulk` reserves and submits; `handleBulkCallback`
|
||||
* — invoked by `EimsWebhookController`, whenever MoR gets around to it — settles.
|
||||
*
|
||||
* Reservation follows the same durable-reservation doctrine as the single-invoice service (counters
|
||||
* consumed and the holder recorded, committed, before the HTTP call leaves the process), extended
|
||||
* to a contiguous block of N counters instead of one. The "something is in flight" marker is
|
||||
* `EimsSystemState.inFlightConversationId`, not `inFlightInvoiceId` — a whole batch is outstanding,
|
||||
* not one invoice — and the two markers block each other: a single registration cannot start while
|
||||
* a bulk batch is pending, and vice versa, because they share the same counter sequence.
|
||||
*
|
||||
* The conversation id is not known until MoR's 202 response arrives, so reservation stamps a
|
||||
* locally-generated placeholder token first (same "commit the reservation before the network call"
|
||||
* reasoning as the single flow), then swaps it for MoR's real conversation id right after — the only
|
||||
* value the webhook callback can actually use to find this batch again.
|
||||
*
|
||||
* Not live-testable from this sandbox (no route to MoR's real gateway) — signing the whole array as
|
||||
* one envelope, the way single `/v1/register` was confirmed live to need despite the collection's
|
||||
* raw example showing no envelope, is the reasonable extension of that confirmed behavior, not a
|
||||
* blind guess, but it has not itself been exercised against the real gateway.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsBulkRegistrationService {
|
||||
private readonly logger = new Logger(EimsBulkRegistrationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly client: EimsClientService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly sellerCache: EimsSellerCacheService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve counters for every eligible invoice and submit them as one `/v1/bulkRegister` call.
|
||||
* An invoice that already has an IRN is silently skipped (idempotent, matching single register);
|
||||
* everything else must pass the same DEB/CRE precondition single register checks, or the whole
|
||||
* call is refused before anything is reserved.
|
||||
*/
|
||||
async registerBulk(
|
||||
invoiceIds: string[],
|
||||
): Promise<{ conversationId: string | null; accepted: string[]; alreadyRegistered: string[] }> {
|
||||
const cfg = this.cfg;
|
||||
assertEimsInvoiceConfig(cfg);
|
||||
|
||||
const ids = [...new Set(invoiceIds)];
|
||||
if (ids.length === 0) {
|
||||
throw new BadRequestException({ code: "EIMS_BULK_EMPTY", message: "No invoice ids given" });
|
||||
}
|
||||
|
||||
const invoices = await this.loadInvoicesForMapping(ids);
|
||||
const alreadyRegistered = invoices.filter((inv) => inv.eimsIrn).map((inv) => inv.id);
|
||||
const pending = invoices.filter((inv) => !inv.eimsIrn);
|
||||
|
||||
// Same DEB/CRE precondition as single register, checked for every pending invoice before any
|
||||
// counter is touched: a bad member must fail the whole batch, not surface mid-submission.
|
||||
const prepared = pending.map((invoice) => {
|
||||
const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV";
|
||||
let relatedDocument: string | null = null;
|
||||
if (documentType !== "INV") {
|
||||
if (!invoice.relatedInvoice) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_RELATED_INVOICE_REQUIRED",
|
||||
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`,
|
||||
});
|
||||
}
|
||||
if (!invoice.relatedInvoice.eimsIrn) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
||||
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`,
|
||||
});
|
||||
}
|
||||
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
||||
}
|
||||
return { invoice, documentType, relatedDocument };
|
||||
});
|
||||
|
||||
if (prepared.length === 0) {
|
||||
return { conversationId: null, accepted: [], alreadyRegistered };
|
||||
}
|
||||
|
||||
const session = await this.auth.getSessionContext();
|
||||
const placeholder = `local:${randomUUID()}`;
|
||||
const reservations = await this.reserveBulk(prepared, session.systemNumber, placeholder);
|
||||
|
||||
let conversationId: string;
|
||||
try {
|
||||
const requests: EimsBulkRegisterRequest = reservations.map((r) =>
|
||||
toEimsInvoice(
|
||||
r.invoice,
|
||||
this.sellerCache.getSellerDetails(cfg),
|
||||
buildEimsContext(cfg, {
|
||||
documentNumber: r.documentNumber,
|
||||
invoiceCounter: r.invoiceCounter,
|
||||
previousIrn: r.previousIrn,
|
||||
session,
|
||||
documentType: r.documentType,
|
||||
reason: r.invoice.eimsReason,
|
||||
relatedDocument: r.relatedDocument,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const response = await this.client.postSigned<EimsBulkRegisterRequest, EimsBulkRegisterAcceptedResponse>(
|
||||
"/v1/bulkRegister",
|
||||
requests,
|
||||
);
|
||||
if (!response?.conversationId) {
|
||||
throw new EimsApiException(
|
||||
"SCHEMA_VALIDATION",
|
||||
"EIMS bulkRegister returned no conversationId",
|
||||
response?.status,
|
||||
);
|
||||
}
|
||||
conversationId = response.conversationId;
|
||||
} catch (err) {
|
||||
await this.settleBulkFailure(reservations, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.claimConversationId(placeholder, conversationId);
|
||||
this.logger.log(
|
||||
`Bulk-registered ${reservations.length} invoice(s) with EIMS (conversation ${conversationId}), awaiting callback`,
|
||||
);
|
||||
return { conversationId, accepted: reservations.map((r) => r.invoice.id), alreadyRegistered };
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a batch's callback, whenever MoR gets around to sending it. Called by
|
||||
* `EimsWebhookController` with the raw parsed array body — no auth on that route (MoR calls it,
|
||||
* not a logged-in user), so the only thing standing between this and a forged callback is the
|
||||
* conversation id itself: an item is only ever applied to an invoice actually holding that exact
|
||||
* id, and an unknown id is logged and ignored rather than touching anything.
|
||||
*/
|
||||
async handleBulkCallback(items: EimsBulkCallbackItem[]): Promise<EimsBulkRegisterItemResult[]> {
|
||||
const settlements = items.filter(
|
||||
(item): item is Exclude<EimsBulkCallbackItem, { conversationId?: string; conversionId?: string }> =>
|
||||
"irn" in item || "ruleError" in item,
|
||||
);
|
||||
|
||||
const conversationId = this.markerFrom(items);
|
||||
const invoices = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.where("invoice.eims_bulk_conversation_id = :id", { id: conversationId })
|
||||
.getMany();
|
||||
|
||||
if (invoices.length === 0) {
|
||||
this.logger.warn(
|
||||
`EIMS bulk callback for an unknown or already-settled conversation — ignored (${settlements.length} item(s))`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const byDocumentNumber = new Map(invoices.map((inv) => [inv.eimsDocumentNumber, inv]));
|
||||
// Process in invoiceCounter order so `previousIrn` ends up as the last-accepted item's IRN —
|
||||
// the same "advance the chain" semantics as single register's settleSuccess.
|
||||
const ordered = [...settlements].sort((a, b) => {
|
||||
const invA = byDocumentNumber.get("documentNumber" in a ? a.documentNumber : a.docNo);
|
||||
const invB = byDocumentNumber.get("documentNumber" in b ? b.documentNumber : b.docNo);
|
||||
return (invA?.eimsInvoiceCounter ?? 0) - (invB?.eimsInvoiceCounter ?? 0);
|
||||
});
|
||||
|
||||
const results: EimsBulkRegisterItemResult[] = [];
|
||||
for (const item of ordered) {
|
||||
const docNumber = "documentNumber" in item ? item.documentNumber : item.docNo;
|
||||
const invoice = byDocumentNumber.get(docNumber);
|
||||
if (!invoice) {
|
||||
this.logger.warn(`EIMS bulk callback item for unknown document number ${docNumber} — ignored`);
|
||||
continue;
|
||||
}
|
||||
if (invoice.eimsStatus !== EimsInvoiceStatus.Submitting) {
|
||||
// Already settled — a duplicate callback delivery. Report the current state, touch nothing.
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
success: invoice.eimsStatus === EimsInvoiceStatus.Registered,
|
||||
message: `Already settled (${invoice.eimsStatus})`,
|
||||
irn: invoice.eimsIrn ?? undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("irn" in item) {
|
||||
await this.settleBulkItemSuccess(invoice, item.irn, conversationId, item.signedQR);
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
success: true,
|
||||
message: `Registered with EIMS (IRN ${item.irn})`,
|
||||
irn: item.irn,
|
||||
});
|
||||
} else {
|
||||
const message = item.ruleError.flatMap((e) => e.errorMessage).join("; ") || "EIMS bulk rule validation error";
|
||||
await this.settleBulkItemFailure(invoice, message);
|
||||
results.push({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, success: false, message });
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the batch's in-flight marker only once nothing submitted under this conversation is
|
||||
// still waiting — a partial/incremental callback (not expected per the collection's docs, but
|
||||
// not ruled out either) must not prematurely unblock the system number.
|
||||
const stillPending = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.count({ where: { eimsBulkConversationId: conversationId, eimsStatus: EimsInvoiceStatus.Submitting } });
|
||||
if (stillPending === 0) {
|
||||
await this.dataSource.manager.update(
|
||||
EimsSystemState,
|
||||
{ inFlightConversationId: conversationId },
|
||||
{ inFlightConversationId: null },
|
||||
);
|
||||
this.logger.log(`EIMS bulk conversation ${conversationId} fully settled (${results.length} item(s))`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── transactions ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
|
||||
private async reserveBulk(
|
||||
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
|
||||
systemNumber: string,
|
||||
placeholder: string,
|
||||
): Promise<BulkReservation[]> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const state = await this.lockSystemState(manager, systemNumber);
|
||||
|
||||
if (state.blockedReason) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_SYSTEM_BLOCKED",
|
||||
message: `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. Resolve the affected invoice before registering anything else.`,
|
||||
});
|
||||
}
|
||||
if (state.inFlightInvoiceId) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_SUBMISSION_IN_FLIGHT",
|
||||
message: `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
|
||||
});
|
||||
}
|
||||
if (state.inFlightConversationId) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_BULK_IN_FLIGHT",
|
||||
message: `A bulk submission (conversation ${state.inFlightConversationId}) is already in flight on system ${systemNumber}. Wait for its callback, or resolve it if the process was interrupted.`,
|
||||
});
|
||||
}
|
||||
|
||||
let counter = Number(state.nextInvoiceCounter);
|
||||
let docNumber = Number(state.nextDocumentNumber);
|
||||
let previousIrn = state.previousIrn ?? "";
|
||||
const reservations: BulkReservation[] = [];
|
||||
|
||||
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
|
||||
// on the opposite lock order.
|
||||
for (const { invoice, documentType, relatedDocument } of prepared) {
|
||||
const locked = await this.lockInvoice(manager, invoice.id);
|
||||
const thisCounter = counter++;
|
||||
const thisDocNumber = String(docNumber++);
|
||||
const thisPreviousIrn = reservations.length === 0 ? previousIrn : "";
|
||||
|
||||
await manager.update(Invoice, invoice.id, {
|
||||
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||
eimsInvoiceCounter: thisCounter,
|
||||
eimsDocumentNumber: thisDocNumber,
|
||||
eimsSubmittedAt: new Date(),
|
||||
eimsLastError: null,
|
||||
eimsBulkConversationId: placeholder,
|
||||
} as QueryDeepPartialEntity<Invoice>);
|
||||
|
||||
reservations.push({
|
||||
stateId: state.id,
|
||||
invoice: Object.assign(locked, { lines: invoice.lines }),
|
||||
documentType,
|
||||
relatedDocument,
|
||||
invoiceCounter: thisCounter,
|
||||
documentNumber: thisDocNumber,
|
||||
previousIrn: thisPreviousIrn,
|
||||
});
|
||||
}
|
||||
|
||||
await manager.update(EimsSystemState, state.id, {
|
||||
nextInvoiceCounter: counter,
|
||||
nextDocumentNumber: docNumber,
|
||||
inFlightConversationId: placeholder,
|
||||
});
|
||||
|
||||
return reservations;
|
||||
});
|
||||
}
|
||||
|
||||
/** Swap the local placeholder for MoR's real conversation id, on both the state row and every invoice. */
|
||||
private async claimConversationId(placeholder: string, conversationId: string): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.update(EimsSystemState, { inFlightConversationId: placeholder }, { inFlightConversationId: conversationId });
|
||||
await manager.update(Invoice, { eimsBulkConversationId: placeholder }, { eimsBulkConversationId: conversationId });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TX2b for the whole batch — the same determinism doctrine as single register's settleFailure,
|
||||
* applied once since `/v1/bulkRegister` either accepts the whole array (202) or fails as one HTTP
|
||||
* call; there is no per-item answer yet at this point, only after the callback.
|
||||
*/
|
||||
private async settleBulkFailure(reservations: BulkReservation[], err: unknown): Promise<void> {
|
||||
const api = err instanceof EimsApiException ? err : null;
|
||||
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
|
||||
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
||||
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
|
||||
const lastError: EimsInvoiceError = {
|
||||
kind: api?.kind ?? localKind,
|
||||
message: (err as Error)?.message ?? "unknown error",
|
||||
httpStatus: api?.httpStatus,
|
||||
details: api?.details,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
const first = reservations[0];
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const r of reservations) {
|
||||
await manager.update(Invoice, r.invoice.id, {
|
||||
eimsStatus: status,
|
||||
eimsLastError: lastError,
|
||||
...(deterministic ? { eimsBulkConversationId: null } : {}),
|
||||
} as QueryDeepPartialEntity<Invoice>);
|
||||
}
|
||||
await manager.update(
|
||||
EimsSystemState,
|
||||
first.stateId,
|
||||
deterministic
|
||||
? {
|
||||
// The whole block returns: MoR never counted a refused batch against either sequence.
|
||||
nextInvoiceCounter: first.invoiceCounter,
|
||||
nextDocumentNumber: Number(first.documentNumber),
|
||||
inFlightConversationId: null,
|
||||
}
|
||||
: {
|
||||
blockedReason:
|
||||
`A bulk submission of ${reservations.length} invoice(s) (starting counter ${first.invoiceCounter}) ` +
|
||||
`was sent but never acknowledged (${lastError.kind}). No further document can be filed until it is resolved.`,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.error(`EIMS bulk submission ${status}: ${lastError.message}`);
|
||||
}
|
||||
|
||||
/** One callback item accepted. */
|
||||
private async settleBulkItemSuccess(
|
||||
invoice: Invoice,
|
||||
irn: string,
|
||||
conversationId: string,
|
||||
signedQR?: string,
|
||||
): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.lockInvoice(manager, invoice.id);
|
||||
await manager.update(Invoice, invoice.id, {
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
eimsIrn: irn,
|
||||
eimsSignedQr: signedQR ?? null,
|
||||
eimsLastError: null,
|
||||
});
|
||||
// Looked up by conversation id, not system number — this batch's state row is whichever one
|
||||
// is holding this conversation, which is exactly what `inFlightConversationId` already tracks.
|
||||
await manager.update(EimsSystemState, { inFlightConversationId: conversationId }, { previousIrn: irn });
|
||||
});
|
||||
this.logger.log(`Invoice ${invoice.invoiceNumber} registered with EIMS via bulk (IRN ${irn})`);
|
||||
|
||||
if (invoice.companyId) {
|
||||
try {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
this.notifications,
|
||||
invoice.companyId,
|
||||
`Invoice ${invoice.invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One callback item rejected. Unlike single register's settleFailure, the counter/document
|
||||
* number are not returned — MoR's own bulk processing already advanced the whole array's
|
||||
* allocation regardless of this item's individual outcome, so there is nothing local to roll back.
|
||||
*/
|
||||
private async settleBulkItemFailure(invoice: Invoice, message: string): Promise<void> {
|
||||
const lastError: EimsInvoiceError = { kind: "RULE_VALIDATION", message, at: new Date().toISOString() };
|
||||
await this.dataSource.manager.update(Invoice, invoice.id, {
|
||||
eimsStatus: EimsInvoiceStatus.Failed,
|
||||
eimsLastError: lastError,
|
||||
} as QueryDeepPartialEntity<Invoice>);
|
||||
this.logger.error(`Invoice ${invoice.invoiceNumber} EIMS bulk registration FAILED: ${message}`);
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private markerFrom(items: EimsBulkCallbackItem[]): string {
|
||||
const marker = items.find((i) => "conversationId" in i || "conversionId" in i) as
|
||||
| { conversationId?: string; conversionId?: string }
|
||||
| undefined;
|
||||
return marker?.conversationId ?? marker?.conversionId ?? "";
|
||||
}
|
||||
|
||||
|
||||
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||
const invoice = await manager
|
||||
.createQueryBuilder(Invoice, "invoice")
|
||||
.setLock("pessimistic_write")
|
||||
.where("invoice.id = :invoiceId", { invoiceId })
|
||||
.getOne();
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private async lockSystemState(manager: EntityManager, systemNumber: string): Promise<EimsSystemState> {
|
||||
const select = () =>
|
||||
manager
|
||||
.createQueryBuilder(EimsSystemState, "state")
|
||||
.setLock("pessimistic_write")
|
||||
.where("state.system_number = :systemNumber", { systemNumber })
|
||||
.getOne();
|
||||
|
||||
const existing = await select();
|
||||
if (existing) return existing;
|
||||
|
||||
await manager.query(
|
||||
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1) ON CONFLICT (system_number) DO NOTHING`,
|
||||
[systemNumber],
|
||||
);
|
||||
const created = await select();
|
||||
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
private async loadInvoicesForMapping(invoiceIds: string[]): Promise<Array<Invoice & { lines: EimsMapperLine[] }>> {
|
||||
const invoices = await this.dataSource.getRepository(Invoice).find({
|
||||
where: { id: In(invoiceIds) },
|
||||
relations: { company: true, companyProfile: true, relatedInvoice: true },
|
||||
});
|
||||
const found = new Set(invoices.map((inv) => inv.id));
|
||||
const missing = invoiceIds.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new NotFoundException(`Invoice(s) not found: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const lines: Array<EimsMapperLine & { invoiceId: string }> = await this.dataSource.query(
|
||||
`SELECT invoice_id AS "invoiceId", charge_type AS "chargeType", description, quantity,
|
||||
unit_rate AS "unitRate", amount, currency, metadata
|
||||
FROM freight.invoice_lines
|
||||
WHERE invoice_id = ANY($1) AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`,
|
||||
[invoiceIds],
|
||||
);
|
||||
const linesByInvoice = new Map<string, EimsMapperLine[]>();
|
||||
for (const line of lines) {
|
||||
const { invoiceId, ...rest } = line;
|
||||
if (!linesByInvoice.has(invoiceId)) linesByInvoice.set(invoiceId, []);
|
||||
linesByInvoice.get(invoiceId)!.push(rest);
|
||||
}
|
||||
|
||||
// Preserve the caller's given order — reservation and result ordering both depend on it.
|
||||
return invoiceIds.map((id) => {
|
||||
const invoice = invoices.find((inv) => inv.id === id)!;
|
||||
return Object.assign(invoice, { lines: linesByInvoice.get(id) ?? [] });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { sendPdf } from "../billing/billing.controller";
|
||||
import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto";
|
||||
import { BulkRegisterEimsInvoiceDto } from "./dto/bulk-register-eims-invoice.dto";
|
||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
||||
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsReceiptService } from "./eims-receipt.service";
|
||||
@@ -39,6 +41,7 @@ import { EimsReceiptService } from "./eims-receipt.service";
|
||||
export class EimsInvoiceController {
|
||||
constructor(
|
||||
private readonly registration: EimsInvoiceRegistrationService,
|
||||
private readonly bulkRegistration: EimsBulkRegistrationService,
|
||||
private readonly cancellation: EimsCancellationService,
|
||||
private readonly receipts: EimsReceiptService,
|
||||
) {}
|
||||
@@ -53,6 +56,18 @@ export class EimsInvoiceController {
|
||||
return this.registration.registerInvoiceWithEims(id);
|
||||
}
|
||||
|
||||
@Post("eims/bulk-register")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR " +
|
||||
"accepted the batch (a conversation id), not the per-invoice outcome. Real results (IRN or " +
|
||||
"rejection per invoice) arrive later via MoR's own callback; poll GET :id/eims/status.",
|
||||
})
|
||||
bulkRegister(@Body() dto: BulkRegisterEimsInvoiceDto) {
|
||||
return this.bulkRegistration.registerBulk(dto.invoiceIds);
|
||||
}
|
||||
|
||||
@Post(":id/eims/verify")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||
@ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||
import { EimsErrorResponse } from "./eims.types";
|
||||
|
||||
/**
|
||||
@@ -129,6 +130,62 @@ export interface EimsBulkCancelItemResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /v1/bulkRegister` — same array-of-full-documents shape as single register (`EimsInvoiceRequest`
|
||||
* from `eims-invoice.mapper.ts`), one element per invoice, sent as one signed envelope.
|
||||
*/
|
||||
export type EimsBulkRegisterRequest = EimsInvoiceRequest[];
|
||||
|
||||
/**
|
||||
* Immediate response to `bulkRegister` — unlike single register, this is not the result, just an
|
||||
* acknowledgement. The real per-invoice outcomes arrive later via `EimsBulkCallbackItem`s pushed to
|
||||
* a webhook MoR was configured with out of band (see `EimsBulkRegistrationService`).
|
||||
*/
|
||||
export interface EimsBulkRegisterAcceptedResponse {
|
||||
conversationId: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/** A settled item in the async callback — `irn` present means MoR accepted this document. */
|
||||
export interface EimsBulkCallbackSuccessItem {
|
||||
irn: string;
|
||||
status: string;
|
||||
documentNumber: string;
|
||||
signedQR?: string;
|
||||
signedInvoice?: string;
|
||||
}
|
||||
|
||||
/** A rejected item in the async callback — `docNo` echoes what we submitted as `DocumentNumber`. */
|
||||
export interface EimsBulkCallbackErrorItem {
|
||||
ruleError: Array<{ portion: string; errorMessage: string[] }>;
|
||||
status: string;
|
||||
docNo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback array's last element, per the collection's own examples — never a settlement result,
|
||||
* just the batch id echoed back. Spelled two different ways across the collection's own docs
|
||||
* ("conversationId" on the initial 202, "conversionId" in the callback examples); accept both.
|
||||
*/
|
||||
export interface EimsBulkCallbackMarker {
|
||||
conversationId?: string;
|
||||
conversionId?: string;
|
||||
}
|
||||
|
||||
export type EimsBulkCallbackItem =
|
||||
| EimsBulkCallbackSuccessItem
|
||||
| EimsBulkCallbackErrorItem
|
||||
| EimsBulkCallbackMarker;
|
||||
|
||||
/** One invoice's outcome once a bulk batch's callback has been processed. */
|
||||
export interface EimsBulkRegisterItemResult {
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
irn?: string;
|
||||
}
|
||||
|
||||
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
||||
export interface EimsInvoiceError {
|
||||
kind: string;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, HttpCode, Post } from "@nestjs/common";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { EimsBulkCallbackItem } from "./eims-registration.types";
|
||||
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||
|
||||
/**
|
||||
* MoR's own callback for `POST /v1/bulkRegister`, not a route a person calls. Public — MoR has no
|
||||
* JWT to send — so the conversation id embedded in the payload is the only thing standing between
|
||||
* this and a forged callback: `EimsBulkRegistrationService.handleBulkCallback` only ever touches
|
||||
* invoices actually holding that exact id, and an unrecognised one is logged and ignored. See the
|
||||
* "Callback Mechanism" section of the collection's own docs for the payload shape.
|
||||
*/
|
||||
@ApiExcludeController()
|
||||
@Controller("eims/webhook")
|
||||
export class EimsWebhookController {
|
||||
constructor(private readonly bulk: EimsBulkRegistrationService) {}
|
||||
|
||||
@Public()
|
||||
@Post("bulk-register")
|
||||
@HttpCode(200)
|
||||
bulkRegisterCallback(@Body() items: EimsBulkCallbackItem[]) {
|
||||
return this.bulk.handleBulkCallback(items);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { NotificationInboxModule } from "../notification-inbox/notification-inbo
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||
@@ -17,6 +18,7 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
|
||||
import { EimsReceiptService } from "./eims-receipt.service";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
import { EimsWebhookController } from "./eims-webhook.controller";
|
||||
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
|
||||
@@ -40,13 +42,14 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
// so this stays a plain one-directional import, not a new cycle.
|
||||
CompaniesModule,
|
||||
],
|
||||
controllers: [EimsInvoiceController],
|
||||
controllers: [EimsInvoiceController, EimsWebhookController],
|
||||
providers: [
|
||||
EimsCredentialsProvider,
|
||||
EimsSignerService,
|
||||
EimsAuthService,
|
||||
EimsClientService,
|
||||
EimsInvoiceRegistrationService,
|
||||
EimsBulkRegistrationService,
|
||||
EimsAutoSubmitService,
|
||||
EimsCancellationService,
|
||||
EimsReceiptService,
|
||||
@@ -56,6 +59,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
EimsAuthService,
|
||||
EimsClientService,
|
||||
EimsInvoiceRegistrationService,
|
||||
EimsBulkRegistrationService,
|
||||
EimsCancellationService,
|
||||
EimsReceiptService,
|
||||
],
|
||||
|
||||
@@ -54,4 +54,11 @@ export class EimsSystemState extends BaseEntity {
|
||||
*/
|
||||
@Column({ name: "blocked_reason", type: "text", nullable: true })
|
||||
blockedReason?: string | null;
|
||||
|
||||
/**
|
||||
* Bulk equivalent of `in_flight_invoice_id` — a whole batch, not one invoice, is outstanding
|
||||
* while MoR processes `POST /v1/bulkRegister` asynchronously. See `EimsBulkRegistrationService`.
|
||||
*/
|
||||
@Column({ name: "in_flight_conversation_id", type: "text", nullable: true })
|
||||
inFlightConversationId?: string | null;
|
||||
}
|
||||
|
||||
@@ -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,76 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
PAID_SHARE,
|
||||
PAYMENT_CLASSES,
|
||||
PAYMENT_CLASS_EXPR,
|
||||
PAYMENT_CLASS_LABEL_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
currencyOf,
|
||||
periodExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = revenueLedgerQb(ctx);
|
||||
const classes = ctx.params.classes as string[] | null;
|
||||
if (classes?.length) {
|
||||
qb.andWhere(`${PAYMENT_CLASS_EXPR} IN (:...classes)`, { classes });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const paymentClassificationReport: ReportDefinition = {
|
||||
key: 'payment-classification',
|
||||
title: 'Payment Classification',
|
||||
description:
|
||||
'What customers actually paid for, per period: rail transport, customs clearance, ' +
|
||||
'first/last mile, overweight, cancellation, demurrage, storage, loading and unloading, ' +
|
||||
'and additional charges. Note there is no dedicated loading/unloading charge type in ' +
|
||||
'the system — handling and double-handling fees stand in for it.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
PERIOD_FILTER,
|
||||
...REVENUE_FILTERS,
|
||||
{ key: 'classes', label: 'Payment class', type: 'multiselect', options: PAYMENT_CLASSES },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'paymentClass', label: 'Payment class', type: 'string', sortable: true },
|
||||
{ key: 'billed', label: 'Billed', type: 'money', sortable: true },
|
||||
{ key: 'settled', label: 'Settled', type: 'money', sortable: true },
|
||||
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
|
||||
{ key: 'lines', label: 'Lines', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'billed', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'paymentClass', y: ['billed'] },
|
||||
query(ctx) {
|
||||
const period = periodExpr(ctx.params);
|
||||
return baseQuery(ctx)
|
||||
.select(period, 'period')
|
||||
.addSelect(PAYMENT_CLASS_LABEL_EXPR, 'paymentClass')
|
||||
.addSelect('ROUND(SUM(il.amount))::float8', 'billed')
|
||||
.addSelect(`ROUND(SUM(${PAID_SHARE}))::float8`, 'settled')
|
||||
.addSelect(`ROUND(SUM(il.amount) - SUM(${PAID_SHARE}))::float8`, 'outstanding')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
.groupBy(period)
|
||||
.addGroupBy(PAYMENT_CLASS_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'billed')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'settled')
|
||||
.getRawOne<{ billed: number; settled: number }>();
|
||||
const currency = currencyOf(ctx.params);
|
||||
const billed = Number(row?.billed ?? 0);
|
||||
const settled = Number(row?.settled ?? 0);
|
||||
return [
|
||||
{ label: 'Billed', value: billed, unit: currency },
|
||||
{ label: 'Settled', value: settled, unit: currency },
|
||||
{ label: 'Outstanding', value: Math.round(billed - settled), unit: currency },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types';
|
||||
import {
|
||||
PAYER_EXPR,
|
||||
REVENUE_DATE,
|
||||
REVENUE_FILTERS,
|
||||
currencyOf,
|
||||
invoiceLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
export const LEDGER_SIDES: ReportFilterOption[] = [
|
||||
{ value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' },
|
||||
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' },
|
||||
{ value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' },
|
||||
{ value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' },
|
||||
{ value: 'SETTLED', label: 'Settled' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Which side of the ledger an invoice sits on.
|
||||
*
|
||||
* Receivable = EDR delivered and is owed money — the shipping-line credit
|
||||
* arrangement, plus any invoice still carrying a balance.
|
||||
* Payable = the customer paid for something EDR did not deliver, so the money
|
||||
* is a refund liability rather than revenue: cancellation fees, and prepaid
|
||||
* invoices whose booking died.
|
||||
*/
|
||||
const SIDE_EXPR = `CASE
|
||||
WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT'
|
||||
THEN 'RECEIVABLE_CREDIT'
|
||||
WHEN i.type = 'WAGON_CANCEL_FEE' THEN 'PAYABLE_CANCELLATION'
|
||||
WHEN i.paid_amount > 0 AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED')
|
||||
THEN 'PAYABLE_UNDELIVERED'
|
||||
WHEN i.balance_amount > 0 THEN 'RECEIVABLE_OPEN'
|
||||
ELSE 'SETTLED'
|
||||
END`;
|
||||
|
||||
const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label]));
|
||||
const SIDE_LABEL_EXPR = `CASE ${SIDE_EXPR}
|
||||
${[...LABELS].map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`).join('\n ')}
|
||||
END`;
|
||||
|
||||
/** Money at stake on this row: what is owed, or what may have to be given back. */
|
||||
const EXPOSURE = `CASE
|
||||
WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount
|
||||
ELSE i.balance_amount
|
||||
END`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = invoiceLedgerQb(ctx);
|
||||
const sides = ctx.params.sides as string[] | null;
|
||||
if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const receivablesPayablesReport: ReportDefinition = {
|
||||
key: 'receivables-payables',
|
||||
title: 'Receivables and Payables',
|
||||
description:
|
||||
'Splits customer money two ways: receivable, where EDR delivered and is owed — ' +
|
||||
'including shipping-line credit services — and payable, where the customer paid but ' +
|
||||
'the service was not delivered, such as cancellation fees and prepayments against ' +
|
||||
'dead bookings. Payable amounts are a refund liability, not revenue.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'),
|
||||
{ key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR },
|
||||
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
|
||||
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
|
||||
{ key: 'bookingRef', label: 'Booking', type: 'string' },
|
||||
{ key: 'bookingStatus', label: 'Booking status', type: 'string' },
|
||||
{ key: 'customer', label: 'Payer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
|
||||
{ key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' },
|
||||
{ key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' },
|
||||
{ key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE },
|
||||
],
|
||||
defaultSort: { key: 'exposure', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'side', y: ['exposure'] },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(SIDE_LABEL_EXPR, 'side')
|
||||
.addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
|
||||
.addSelect('i.invoice_number', 'invoiceNumber')
|
||||
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
||||
.addSelect("COALESCE(b.status, '—')", 'bookingStatus')
|
||||
.addSelect(PAYER_EXPR, 'customer')
|
||||
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced')
|
||||
.addSelect('ROUND(i.paid_amount, 2)::float8', 'paid')
|
||||
.addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(
|
||||
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`,
|
||||
'receivable',
|
||||
)
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`,
|
||||
'payable',
|
||||
)
|
||||
.addSelect('COUNT(*)::int', 'invoices')
|
||||
.getRawOne<{ receivable: number; payable: number; invoices: number }>();
|
||||
const currency = currencyOf(ctx.params);
|
||||
return [
|
||||
{ label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency },
|
||||
{ label: 'Payable', value: Number(row?.payable ?? 0), unit: currency },
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
CATEGORY_LABEL_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
growthPctExpr,
|
||||
periodExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
const REVENUE = 'SUM(il.amount)';
|
||||
|
||||
const THRESHOLDS = [
|
||||
{ value: '10', label: '±10%' },
|
||||
{ value: '25', label: '±25%' },
|
||||
{ value: '50', label: '±50%' },
|
||||
];
|
||||
|
||||
const thresholdOf = (params: Record<string, unknown>): number => {
|
||||
const raw = Number(params.threshold);
|
||||
return THRESHOLDS.some((t) => Number(t.value) === raw) ? raw : 25;
|
||||
};
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueAnomaliesReport: ReportDefinition = {
|
||||
key: 'revenue-anomalies',
|
||||
title: 'Revenue Anomalies',
|
||||
description:
|
||||
'Periods where a revenue category moved more than the chosen threshold against the ' +
|
||||
'previous period. Pull-based on purpose: this surfaces the spikes and drops for review ' +
|
||||
'rather than paging anyone, so the thresholds can be tuned against real numbers first.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
PERIOD_FILTER,
|
||||
{ key: 'threshold', label: 'Threshold', type: 'select', options: THRESHOLDS },
|
||||
...REVENUE_FILTERS,
|
||||
],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true },
|
||||
{ key: 'direction', label: 'Movement', type: 'string' },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'priorRevenue', label: 'Prior period', type: 'money' },
|
||||
{ key: 'growthPct', label: 'Change', type: 'percent', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'period', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
const period = periodExpr(ctx.params);
|
||||
// ORDER BY the same expression this query GROUPs BY — the to_char label, not
|
||||
// the inner date_trunc. Ordering by the unwrapped timestamp raises
|
||||
// "column i.issued_at must appear in the GROUP BY clause". The label formats
|
||||
// are zero-padded, so lexicographic order is chronological order.
|
||||
const prior = `lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`;
|
||||
const change = growthPctExpr(REVENUE, prior);
|
||||
|
||||
const inner = baseQuery(ctx)
|
||||
.select(period, 'period')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect(`ROUND(${REVENUE})::float8`, 'revenue')
|
||||
.addSelect(`ROUND(COALESCE(${prior}, 0))::float8`, 'priorRevenue')
|
||||
.addSelect(change, 'growthPct')
|
||||
.addSelect(
|
||||
`CASE WHEN ${REVENUE} >= COALESCE(${prior}, 0) THEN 'Spike' ELSE 'Drop' END`,
|
||||
'direction',
|
||||
)
|
||||
.groupBy(period)
|
||||
.addGroupBy(REVENUE_CATEGORY_EXPR);
|
||||
|
||||
// The threshold cannot live in WHERE or HAVING — both are evaluated before
|
||||
// window functions, and `growthPct` is one. Wrapping is the only place the
|
||||
// comparison is legal. A period with no predecessor yields NULL, and
|
||||
// `ABS(NULL) >= n` is NULL, so those rows drop out without an extra guard.
|
||||
return ctx.ds
|
||||
.createQueryBuilder()
|
||||
.select('a.*')
|
||||
.from(`(${inner.getQuery()})`, 'a')
|
||||
.setParameters(inner.getParameters())
|
||||
.where('ABS(a."growthPct") >= :threshold', { threshold: thresholdOf(ctx.params) });
|
||||
},
|
||||
|
||||
async summary(ctx) {
|
||||
const [sql, params] = revenueAnomaliesReport.query(ctx).getQueryAndParameters();
|
||||
const rows: Array<{ spikes: number; drops: number }> = await ctx.ds.query(
|
||||
`SELECT COUNT(*) FILTER (WHERE a.direction = 'Spike')::int AS spikes,
|
||||
COUNT(*) FILTER (WHERE a.direction = 'Drop')::int AS drops
|
||||
FROM (${sql}) a`,
|
||||
params,
|
||||
);
|
||||
return [
|
||||
{ label: 'Spikes', value: Number(rows[0]?.spikes ?? 0) },
|
||||
{ label: 'Drops', value: Number(rows[0]?.drops ?? 0) },
|
||||
{ label: 'Threshold', value: thresholdOf(ctx.params), unit: '%' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
AVG_PER_UNIT_EXPR,
|
||||
CATEGORY_LABEL_EXPR,
|
||||
CONTAINERS_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
TEU_EXPR,
|
||||
TONS_EXPR,
|
||||
UNIT_LABEL_EXPR,
|
||||
currencyOf,
|
||||
growthPctExpr,
|
||||
periodExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
const REVENUE = 'SUM(il.amount)';
|
||||
|
||||
/**
|
||||
* Previous period's revenue for the same category.
|
||||
*
|
||||
* Postgres evaluates window functions after GROUP BY, so `lag(SUM(...))` is
|
||||
* legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the
|
||||
* ORDER BY must repeat their grouping expressions verbatim: ordering by the
|
||||
* inner `date_trunc` when the group key is the `to_char` wrapper fails, and
|
||||
* ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window
|
||||
* clause, silently producing an unordered partition.
|
||||
*/
|
||||
const priorRevenue = (period: string): string =>
|
||||
`lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`;
|
||||
|
||||
const growthPct = (period: string): string => growthPctExpr(REVENUE, priorRevenue(period));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueByCategoryReport: ReportDefinition = {
|
||||
key: 'revenue-by-category',
|
||||
title: 'Revenue by Category',
|
||||
description:
|
||||
'Billed revenue in the twelve rail revenue categories, per period, with volume and ' +
|
||||
'period-over-period growth. Growth compares against the previous period inside the ' +
|
||||
'selected date range, so the earliest period always reads zero. ' +
|
||||
'Multimodal means a named sea carrier is on the booking.',
|
||||
group: 'Finance',
|
||||
filters: [PERIOD_FILTER, ...REVENUE_FILTERS],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'priorRevenue', label: 'Prior period', type: 'money' },
|
||||
{ key: 'growthPct', label: 'Growth', type: 'percent' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
|
||||
{ key: 'containers', label: 'Containers', type: 'number' },
|
||||
{ key: 'avgPerUnit', label: 'Avg revenue/unit', type: 'money' },
|
||||
{ key: 'unit', label: 'Unit', type: 'string' },
|
||||
{ key: 'lines', label: 'Lines', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'category', y: ['revenue'] },
|
||||
/**
|
||||
* Row click opens the transaction list for exactly this bucket.
|
||||
*
|
||||
* `period` carries into `period_value` (the bucket, e.g. "2026-08"), NOT into
|
||||
* `period` — that filter is the granularity, and handing it a date string
|
||||
* would silently reset it to monthly. The granularity itself rides along
|
||||
* from the filters already applied.
|
||||
*
|
||||
* `categoryKey` rather than `category`: the visible column holds the business
|
||||
* label, and the target filters on the key.
|
||||
*/
|
||||
drill: {
|
||||
to: 'revenue-transactions',
|
||||
carry: { period: 'period_value', categoryKey: 'categoryKey' },
|
||||
},
|
||||
query(ctx) {
|
||||
const period = periodExpr(ctx.params);
|
||||
return baseQuery(ctx)
|
||||
.select(period, 'period')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect(`ROUND(${REVENUE})::float8`, 'revenue')
|
||||
.addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue')
|
||||
.addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct')
|
||||
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
|
||||
.addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers')
|
||||
.addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit')
|
||||
.addSelect(UNIT_LABEL_EXPR, 'unit')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
.groupBy(period)
|
||||
.addGroupBy(REVENUE_CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${REVENUE_CATEGORY_EXPR} = 'UNCLASSIFIED'), 0))::float8`,
|
||||
'unclassified',
|
||||
)
|
||||
.addSelect(`COUNT(DISTINCT ${REVENUE_CATEGORY_EXPR})::int`, 'categories')
|
||||
// Invoice-level balance is deliberately NOT summed here — the ledger is
|
||||
// at line grain, so a 3-line invoice would count its balance three times.
|
||||
// Outstanding lives on `revenue-reconciliation`, at invoice grain.
|
||||
.getRawOne<{ revenue: number; unclassified: number; categories: number }>();
|
||||
|
||||
const currency = currencyOf(ctx.params);
|
||||
return [
|
||||
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency },
|
||||
{ label: 'Categories', value: Number(row?.categories ?? 0) },
|
||||
// Always shown, even at zero: an audit report must never quietly drop money.
|
||||
{ label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
TEU_EXPR,
|
||||
TONS_EXPR,
|
||||
currencyOf,
|
||||
growthPctExpr,
|
||||
nextPeriodOrdinalExpr,
|
||||
periodExpr,
|
||||
periodOrdinalExpr,
|
||||
periodTruncExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
const REVENUE = 'SUM(il.amount)';
|
||||
|
||||
/**
|
||||
* A six-period rolling linear trend, projected one period ahead.
|
||||
*
|
||||
* `regr_slope`/`regr_intercept` are Postgres built-ins, so this needs no
|
||||
* dependency and no model store. It is a trend line, not a forecast model: no
|
||||
* seasonality, no confidence interval, and meaningless on fewer than about
|
||||
* four points — hence the row-count guard, which returns NULL rather than a
|
||||
* confident-looking number drawn through two dots.
|
||||
*
|
||||
* The x variable is the period's epoch seconds, not `row_number()`: Postgres
|
||||
* rejects a window function nested inside another window function's arguments
|
||||
* ("window function calls cannot be nested"), and the timestamp is already a
|
||||
* monotonic ordinal.
|
||||
*
|
||||
* ponytail: linear trend only. A seasonal model (Holt-Winters/ARIMA) means a
|
||||
* stats dependency, a training story and an owner for model quality — do that
|
||||
* only if the business names a seasonality requirement.
|
||||
*/
|
||||
const forecastNext = (params: Record<string, unknown>): string => {
|
||||
const window = `OVER (ORDER BY ${periodTruncExpr(params)} ROWS BETWEEN 5 PRECEDING AND CURRENT ROW)`;
|
||||
const x = periodOrdinalExpr(params);
|
||||
return `CASE WHEN count(*) ${window} >= 4 THEN GREATEST(0, ROUND(
|
||||
(regr_intercept(${REVENUE}, ${x}) ${window})
|
||||
+ (regr_slope(${REVENUE}, ${x}) ${window}) * ${nextPeriodOrdinalExpr(params)}
|
||||
))::float8 END`;
|
||||
};
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueByPeriodReport: ReportDefinition = {
|
||||
key: 'revenue-by-period',
|
||||
title: 'Revenue Trend',
|
||||
description:
|
||||
'Total billed revenue per period with period-over-period growth and a rolling ' +
|
||||
'six-period linear projection. The projection is a trend line, not a seasonal ' +
|
||||
'forecast, and is blank until six periods of history exist.',
|
||||
group: 'Finance',
|
||||
filters: [PERIOD_FILTER, ...REVENUE_FILTERS],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'priorRevenue', label: 'Prior period', type: 'money' },
|
||||
{ key: 'growthPct', label: 'Growth', type: 'percent' },
|
||||
{ key: 'forecastNext', label: 'Projected next', type: 'money' },
|
||||
{ key: 'categories', label: 'Categories', type: 'number' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons' },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'lines', label: 'Lines', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'period', dir: 'ASC' },
|
||||
chart: { type: 'line', x: 'period', y: ['revenue', 'forecastNext'] },
|
||||
drill: { to: 'revenue-transactions', carry: { period: 'period_value' } },
|
||||
query(ctx) {
|
||||
const period = periodExpr(ctx.params);
|
||||
const trunc = periodTruncExpr(ctx.params);
|
||||
const prior = `lag(${REVENUE}) OVER (ORDER BY ${trunc})`;
|
||||
return baseQuery(ctx)
|
||||
.select(period, 'period')
|
||||
.addSelect(`ROUND(${REVENUE})::float8`, 'revenue')
|
||||
.addSelect(`ROUND(COALESCE(${prior}, 0))::float8`, 'priorRevenue')
|
||||
.addSelect(`COALESCE(${growthPctExpr(REVENUE, prior)}, 0)`, 'growthPct')
|
||||
.addSelect(forecastNext(ctx.params), 'forecastNext')
|
||||
.addSelect(`COUNT(DISTINCT ${REVENUE_CATEGORY_EXPR})::int`, 'categories')
|
||||
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
// Grouped by the period's start timestamp, so the ordering the windows
|
||||
// above use is a grouping key rather than a bare column reference.
|
||||
.groupBy(trunc);
|
||||
},
|
||||
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect(`COUNT(DISTINCT ${periodTruncExpr(ctx.params)})::int`, 'periods')
|
||||
.getRawOne<{ revenue: number; periods: number }>();
|
||||
const periods = Number(row?.periods ?? 0);
|
||||
const revenue = Number(row?.revenue ?? 0);
|
||||
return [
|
||||
{ label: 'Total revenue', value: revenue, unit: currencyOf(ctx.params) },
|
||||
{ label: 'Periods', value: periods },
|
||||
{
|
||||
label: 'Average per period',
|
||||
value: periods ? Math.round(revenue / periods) : 0,
|
||||
unit: currencyOf(ctx.params),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
AVG_PER_UNIT_EXPR,
|
||||
CATEGORY_LABEL_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
TEU_EXPR,
|
||||
TONS_EXPR,
|
||||
UNIT_LABEL_EXPR,
|
||||
currencyOf,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
/**
|
||||
* There is no corridor entity in the schema — a corridor IS an
|
||||
* (origin_yard, destination_yard) pair, which is exactly how rates are scoped.
|
||||
*/
|
||||
const CORRIDOR = `COALESCE(oy.label, 'Unknown') || ' → ' || COALESCE(dy.label, 'Unknown')`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueByRouteReport: ReportDefinition = {
|
||||
key: 'revenue-by-route',
|
||||
title: 'Revenue by Route',
|
||||
description:
|
||||
'Billed revenue per corridor and revenue category. A corridor is an ' +
|
||||
'origin/destination station pair — the schema has no separate corridor entity.',
|
||||
group: 'Finance',
|
||||
filters: [PERIOD_FILTER, ...REVENUE_FILTERS],
|
||||
columns: [
|
||||
{ key: 'corridor', label: 'Corridor', type: 'string', sortable: true, sortExpr: CORRIDOR },
|
||||
{ key: 'origin', label: 'Origin', type: 'string' },
|
||||
{ key: 'destination', label: 'Destination', type: 'string' },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
|
||||
{ key: 'avgPerUnit', label: 'Avg revenue/unit', type: 'money' },
|
||||
{ key: 'unit', label: 'Unit', type: 'string' },
|
||||
{ key: 'lines', label: 'Lines', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'corridor', y: ['revenue'] },
|
||||
drill: { to: 'revenue-transactions', carry: { categoryKey: 'categoryKey' } },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(CORRIDOR, 'corridor')
|
||||
.addSelect("COALESCE(oy.label, 'Unknown')", 'origin')
|
||||
.addSelect("COALESCE(dy.label, 'Unknown')", 'destination')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect('ROUND(SUM(il.amount))::float8', 'revenue')
|
||||
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
|
||||
.addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit')
|
||||
.addSelect(UNIT_LABEL_EXPR, 'unit')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
.groupBy(CORRIDOR)
|
||||
.addGroupBy('oy.label')
|
||||
.addGroupBy('dy.label')
|
||||
.addGroupBy(REVENUE_CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect(`COUNT(DISTINCT ${CORRIDOR})::int`, 'corridors')
|
||||
.getRawOne<{ revenue: number; corridors: number }>();
|
||||
return [
|
||||
{ label: 'Corridors', value: Number(row?.corridors ?? 0) },
|
||||
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
GATEWAY_PAID,
|
||||
PAYER_EXPR,
|
||||
REVENUE_DATE,
|
||||
REVENUE_FILTERS,
|
||||
currencyOf,
|
||||
invoiceLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
/**
|
||||
* The gap between what the invoice says was paid and what the payment gateway
|
||||
* recorded. Non-zero is not automatically wrong — manual settlements are real
|
||||
* — but every one of them should be explainable, which is the point.
|
||||
*/
|
||||
const VARIANCE = `i.paid_amount - ${GATEWAY_PAID}`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = invoiceLedgerQb(ctx);
|
||||
const matched = ctx.params.matched as string | null;
|
||||
if (matched === 'matched') qb.andWhere(`ROUND(${VARIANCE}, 2) = 0`);
|
||||
if (matched === 'unmatched') qb.andWhere(`ROUND(${VARIANCE}, 2) <> 0`);
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const revenueReconciliationReport: ReportDefinition = {
|
||||
key: 'revenue-reconciliation',
|
||||
title: 'Revenue vs Payment Reconciliation',
|
||||
description:
|
||||
'Every invoice with its recorded settlement set against what the payment gateway ' +
|
||||
'actually confirmed. Invoices are matched on the booking id both sides carry — ' +
|
||||
'invoices.payment_id points at a payment-service intent, not a freight payment row. ' +
|
||||
'Invoices with no booking (warehouse, shipping-line credit) have no gateway record to ' +
|
||||
'match against and will show their full paid amount as variance.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'),
|
||||
{
|
||||
key: 'matched',
|
||||
label: 'Reconciliation',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'unmatched', label: 'Variance only' },
|
||||
{ value: 'matched', label: 'Reconciled only' },
|
||||
],
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
|
||||
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
|
||||
{ key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
|
||||
{ key: 'source', label: 'Source', type: 'string', sortable: true, sortExpr: 'i.source' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' },
|
||||
{ key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' },
|
||||
{ key: 'recordedPaid', label: 'Recorded paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' },
|
||||
{ key: 'gatewayPaid', label: 'Gateway paid', type: 'money' },
|
||||
{ key: 'variance', label: 'Variance', type: 'money', sortable: true, sortExpr: `ABS(${VARIANCE})` },
|
||||
{ key: 'balance', label: 'Outstanding', type: 'money', sortable: true, sortExpr: 'i.balance_amount' },
|
||||
],
|
||||
defaultSort: { key: 'variance', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
|
||||
.addSelect('i.invoice_number', 'invoiceNumber')
|
||||
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
||||
.addSelect(PAYER_EXPR, 'customer')
|
||||
.addSelect('i.source', 'source')
|
||||
.addSelect('i.status', 'status')
|
||||
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced')
|
||||
.addSelect('ROUND(i.paid_amount, 2)::float8', 'recordedPaid')
|
||||
.addSelect(`ROUND(${GATEWAY_PAID}, 2)::float8`, 'gatewayPaid')
|
||||
.addSelect(`ROUND(${VARIANCE}, 2)::float8`, 'variance')
|
||||
.addSelect('ROUND(i.balance_amount, 2)::float8', 'balance');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'invoices')
|
||||
.addSelect(`COUNT(*) FILTER (WHERE ROUND(${VARIANCE}, 2) <> 0)::int`, 'withVariance')
|
||||
.addSelect(`ROUND(COALESCE(SUM(ABS(${VARIANCE})), 0))::float8`, 'variance')
|
||||
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
|
||||
.getRawOne<{ invoices: number; withVariance: number; variance: number; outstanding: number }>();
|
||||
const currency = currencyOf(ctx.params);
|
||||
return [
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
{ label: 'With variance', value: Number(row?.withVariance ?? 0) },
|
||||
{ label: 'Total variance', value: Number(row?.variance ?? 0), unit: currency },
|
||||
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: currency },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
CATEGORY_LABEL_EXPR,
|
||||
PAYER_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
TEU_EXPR,
|
||||
TONS_EXPR,
|
||||
currencyOf,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueTopCustomersReport: ReportDefinition = {
|
||||
key: 'revenue-top-customers',
|
||||
title: 'Top Customers by Revenue',
|
||||
description:
|
||||
'Customers ranked by billed revenue, with their category mix and outstanding share. ' +
|
||||
'The payer is the company or, for shipping-line credit invoices, the shipping line — ' +
|
||||
'an invoice carries exactly one of the two.',
|
||||
group: 'Finance',
|
||||
filters: [PERIOD_FILTER, ...REVENUE_FILTERS],
|
||||
columns: [
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'sharePct', label: 'Share of total', type: 'percent' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons' },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'invoices', label: 'Invoices', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'customer', y: ['revenue'] },
|
||||
drill: { to: 'revenue-transactions', carry: { customer: 'customer', categoryKey: 'categoryKey' } },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(PAYER_EXPR, 'customer')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect('ROUND(SUM(il.amount))::float8', 'revenue')
|
||||
// Share of the whole filtered set, not of the page — a window over no
|
||||
// partition sees every group the query produced.
|
||||
.addSelect(
|
||||
'ROUND(100 * SUM(il.amount) / NULLIF(SUM(SUM(il.amount)) OVER (), 0), 1)::float8',
|
||||
'sharePct',
|
||||
)
|
||||
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
|
||||
.addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
|
||||
.groupBy(PAYER_EXPR)
|
||||
.addGroupBy(REVENUE_CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
|
||||
.getRawOne<{ revenue: number; customers: number }>();
|
||||
return [
|
||||
{ label: 'Customers', value: Number(row?.customers ?? 0) },
|
||||
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
PAYMENT_CLASS_EXPR,
|
||||
PAYER_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORIES,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_DATE,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
currencyOf,
|
||||
periodExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
/**
|
||||
* The gateway payment behind an invoice, for traceability. `invoices.payment_id`
|
||||
* points at a payment-api intent id rather than a `freight.payments` row, so the
|
||||
* reliable link is `payments.ref_id = invoices.source_id` (the booking id).
|
||||
*
|
||||
* Correlated scalar subselects rather than a LATERAL join: TypeORM's query
|
||||
* builder cannot emit LATERAL, and the correlation on `i.source_id` is what
|
||||
* makes this work at all. Successful payments win, then most recent.
|
||||
*
|
||||
* `::text` is not cosmetic — `payments.method` and `payments.status` are real
|
||||
* Postgres enums, so `COALESCE(<enum>, '')` fails with
|
||||
* `invalid input value for enum freight.payments_method_enum: ""`.
|
||||
*/
|
||||
const latestPayment = (column: string): string => `(
|
||||
SELECT p.${column}::text FROM freight.payments p
|
||||
WHERE p.ref_id = i.source_id
|
||||
ORDER BY (p.status = 'success') DESC, p.created_at DESC
|
||||
LIMIT 1
|
||||
)`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = revenueLedgerQb(ctx);
|
||||
|
||||
// Drill-down target: the summary reports hand over the exact period bucket
|
||||
// and category key they were showing.
|
||||
if (params.period_value) {
|
||||
qb.andWhere(`${periodExpr(params)} = :periodValue`, { periodValue: params.period_value });
|
||||
}
|
||||
if (params.categoryKey) {
|
||||
qb.andWhere(`${REVENUE_CATEGORY_EXPR} = :categoryKey`, { categoryKey: params.categoryKey });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const revenueTransactionsReport: ReportDefinition = {
|
||||
key: 'revenue-transactions',
|
||||
title: 'Revenue Transactions',
|
||||
description:
|
||||
'Every billed revenue line, at transaction level — booking reference, invoice number, ' +
|
||||
'charge type, cargo, quantity and the payment reference behind it. This is the ' +
|
||||
'drill-down target for the revenue summaries and the audit trail for an export.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
PERIOD_FILTER,
|
||||
...REVENUE_FILTERS,
|
||||
{ key: 'period_value', label: 'Period bucket', type: 'text' },
|
||||
{
|
||||
key: 'categoryKey',
|
||||
label: 'Category (exact)',
|
||||
type: 'select',
|
||||
options: REVENUE_CATEGORIES,
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
|
||||
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
|
||||
{ key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
|
||||
{ key: 'bookingId', label: 'Booking ID', type: 'string' },
|
||||
{ key: 'payer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true, sortExpr: REVENUE_CATEGORY_EXPR },
|
||||
{ key: 'paymentClass', label: 'Payment class', type: 'string' },
|
||||
{ key: 'chargeType', label: 'Charge type', type: 'string', sortable: true, sortExpr: 'il.charge_type' },
|
||||
{ key: 'cargo', label: 'Cargo', type: 'string' },
|
||||
{ key: 'route', label: 'Route', type: 'string' },
|
||||
{ key: 'quantity', label: 'Qty', type: 'number' },
|
||||
{ key: 'unit', label: 'Unit', type: 'string' },
|
||||
{ key: 'unitRate', label: 'Unit rate', type: 'money' },
|
||||
{ key: 'amount', label: 'Amount', type: 'money', sortable: true, sortExpr: 'il.amount' },
|
||||
{ key: 'currency', label: 'Currency', type: 'string' },
|
||||
{ key: 'invoiceStatus', label: 'Invoice status', type: 'string', sortable: true, sortExpr: 'i.status' },
|
||||
{ key: 'paymentRef', label: 'Payment ref', type: 'string' },
|
||||
{ key: 'paymentMethod', label: 'Method', type: 'string' },
|
||||
{ key: 'paymentStatus', label: 'Payment status', type: 'string' },
|
||||
],
|
||||
defaultSort: { key: 'issuedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
|
||||
.addSelect('i.invoice_number', 'invoiceNumber')
|
||||
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
||||
.addSelect("COALESCE(b.id::text, '')", 'bookingId')
|
||||
.addSelect(PAYER_EXPR, 'payer')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'category')
|
||||
.addSelect(PAYMENT_CLASS_EXPR, 'paymentClass')
|
||||
.addSelect('il.charge_type', 'chargeType')
|
||||
.addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '—')", 'cargo')
|
||||
.addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", 'route')
|
||||
.addSelect('il.quantity::float8', 'quantity')
|
||||
.addSelect("COALESCE(il.metadata->>'unit', '')", 'unit')
|
||||
.addSelect('ROUND(il.unit_rate, 2)::float8', 'unitRate')
|
||||
.addSelect('ROUND(il.amount, 2)::float8', 'amount')
|
||||
.addSelect('il.currency', 'currency')
|
||||
.addSelect('i.status', 'invoiceStatus')
|
||||
.addSelect(
|
||||
`COALESCE(${latestPayment('transaction_id')}, ${latestPayment('merchant_order_id')}, '')`,
|
||||
'paymentRef',
|
||||
)
|
||||
.addSelect(`COALESCE(${latestPayment('method')}, '')`, 'paymentMethod')
|
||||
.addSelect(`COALESCE(${latestPayment('status')}, '')`, 'paymentStatus');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
.addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
|
||||
.getRawOne<{ revenue: number; lines: number; invoices: number }>();
|
||||
return [
|
||||
{ label: 'Lines', value: Number(row?.lines ?? 0) },
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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(
|
||||
def: ReportDefinition,
|
||||
raw: RawReportQuery,
|
||||
directions: string[] | null,
|
||||
limit: number,
|
||||
{ cap, limit }: { cap: number; limit?: number },
|
||||
): Promise<{ columns: typeof def.columns; items: Record<string, unknown>[]; kpis: ReportRunResult['kpis'] }> {
|
||||
const params = coerceParams(def, raw);
|
||||
const ctx = { ds: this.ds, params, directions };
|
||||
@@ -136,12 +143,20 @@ export class ReportRunnerService {
|
||||
// export is supposed to match what the user is looking at.
|
||||
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
|
||||
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||
const items = await qb.limit(limit).getRawMany();
|
||||
if (items.length >= limit) {
|
||||
|
||||
const ceiling = limit ?? cap;
|
||||
// ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are
|
||||
// exactly that many rows" from "there are more" — which is why the old
|
||||
// `>= limit` check rejected a legitimate export of exactly the cap.
|
||||
const items = await qb.limit(ceiling + 1).getRawMany();
|
||||
if (items.length > ceiling) {
|
||||
if (limit === undefined) {
|
||||
throw new BadRequestException(
|
||||
`Export exceeds the ${limit}-row cap for this format. Narrow the filters.`,
|
||||
`Export exceeds the ${cap}-row cap for this format. Narrow the filters.`,
|
||||
);
|
||||
}
|
||||
items.length = limit;
|
||||
}
|
||||
const kpis = def.summary ? await def.summary(ctx) : [];
|
||||
return { columns: def.columns, items, kpis };
|
||||
}
|
||||
|
||||
@@ -22,6 +22,23 @@ import { invoicesByStatusReport } from './definitions/invoices-by-status.report'
|
||||
import { paymentsByStatusReport } from './definitions/payments-by-status.report';
|
||||
import { revenueSummaryReport } from './definitions/revenue-summary.report';
|
||||
import { cargoSummaryReport } from './definitions/cargo-summary.report';
|
||||
import { revenueByCategoryReport } from './definitions/revenue-by-category.report';
|
||||
import { revenueTransactionsReport } from './definitions/revenue-transactions.report';
|
||||
import { revenueByPeriodReport } from './definitions/revenue-by-period.report';
|
||||
import { revenueByRouteReport } from './definitions/revenue-by-route.report';
|
||||
import { revenueTopCustomersReport } from './definitions/revenue-top-customers.report';
|
||||
import { paymentClassificationReport } from './definitions/payment-classification.report';
|
||||
import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report';
|
||||
import { receivablesPayablesReport } from './definitions/receivables-payables.report';
|
||||
import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report';
|
||||
import { stationStayingTimeReport } from './definitions/station-staying-time.report';
|
||||
import { turnaroundCycleReport } from './definitions/turnaround-cycle.report';
|
||||
import { trainDelaysReport } from './definitions/train-delays.report';
|
||||
import { trainsetPerformanceReport } from './definitions/trainset-performance.report';
|
||||
import { teuPerformanceReport } from './definitions/teu-performance.report';
|
||||
import { cargoVolumePerformanceReport } from './definitions/cargo-volume-performance.report';
|
||||
import { chargedVsActualVolumeReport } from './definitions/charged-vs-actual-volume.report';
|
||||
import { cargoVolumeByStationReport } from './definitions/cargo-volume-by-station.report';
|
||||
import { ReportDefinition } from './report.types';
|
||||
|
||||
/**
|
||||
@@ -53,6 +70,23 @@ export const REPORTS: ReportDefinition[] = [
|
||||
paymentsByStatusReport,
|
||||
revenueSummaryReport,
|
||||
cargoSummaryReport,
|
||||
revenueByCategoryReport,
|
||||
revenueTransactionsReport,
|
||||
revenueByPeriodReport,
|
||||
revenueByRouteReport,
|
||||
revenueTopCustomersReport,
|
||||
paymentClassificationReport,
|
||||
revenueReconciliationReport,
|
||||
receivablesPayablesReport,
|
||||
revenueAnomaliesReport,
|
||||
stationStayingTimeReport,
|
||||
turnaroundCycleReport,
|
||||
trainDelaysReport,
|
||||
trainsetPerformanceReport,
|
||||
teuPerformanceReport,
|
||||
cargoVolumePerformanceReport,
|
||||
chargedVsActualVolumeReport,
|
||||
cargoVolumeByStationReport,
|
||||
];
|
||||
|
||||
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
|
||||
|
||||
@@ -34,6 +34,24 @@ export interface ReportFilterDef {
|
||||
type: ReportFilterType;
|
||||
/** Static option list for select/multiselect. */
|
||||
options?: ReportFilterOption[];
|
||||
/**
|
||||
* Resolves the option list from the database instead of declaring it inline —
|
||||
* for filters whose choices are reference data (stations, cargo types).
|
||||
* Called once per catalog request and cached; the result is serialised into
|
||||
* `options`, so the frontend never sees the difference.
|
||||
*/
|
||||
optionsQuery?: (ds: DataSource) => Promise<ReportFilterOption[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a summary row clickable: the row's values are carried into another
|
||||
* report as filter params, which is how "drill down from summary to
|
||||
* transaction level" works. Keys are this report's column keys; values are the
|
||||
* target report's filter keys.
|
||||
*/
|
||||
export interface ReportDrillDef {
|
||||
to: ReportKey;
|
||||
carry: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ReportKpi {
|
||||
@@ -90,6 +108,8 @@ export interface ReportDefinition {
|
||||
summary?(ctx: ReportContext): Promise<ReportKpi[]>;
|
||||
/** Optional chart view of the same rows. Table remains the default view. */
|
||||
chart?: ReportChartDef;
|
||||
/** Makes rows clickable, navigating to a transaction-level report. */
|
||||
drill?: ReportDrillDef;
|
||||
}
|
||||
|
||||
/** Catalog shape served by GET /reports — metadata only, no rows. */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { Response } from 'express';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
@@ -8,17 +10,54 @@ import { BookingStaff } from '../../common/booking-guards';
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { ReportExportService } from './report-export.service';
|
||||
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
|
||||
import {
|
||||
EXPORT_MIME,
|
||||
formatRowCap,
|
||||
pickByKey,
|
||||
resolveExportFormat,
|
||||
resolveRowLimit,
|
||||
} from '../exports/export-request.util';
|
||||
import { TabularExportService } from '../exports/tabular-export.service';
|
||||
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
|
||||
import { REPORTS, getReport } from './report.registry';
|
||||
import { ReportCatalogEntry, ReportDefinition } from './report.types';
|
||||
import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types';
|
||||
|
||||
const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => {
|
||||
const { query: _query, summary, ...meta } = def;
|
||||
return { ...meta, hasSummary: Boolean(summary) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters whose choices are reference data resolve their options here rather
|
||||
* than declaring them inline, so the catalog the frontend receives looks the
|
||||
* same either way. Cached for the process lifetime — these are small, rarely
|
||||
* changing lists (23 stations, 18 cargo types), and the catalog is hit on
|
||||
* every page load.
|
||||
*/
|
||||
const optionsCache = new Map<string, ReportFilterOption[]>();
|
||||
|
||||
async function resolveFilterOptions(
|
||||
def: ReportCatalogEntry,
|
||||
ds: DataSource,
|
||||
): Promise<ReportCatalogEntry> {
|
||||
if (!def.filters.some((f) => f.optionsQuery)) return def;
|
||||
|
||||
const filters = await Promise.all(
|
||||
def.filters.map(async (filter) => {
|
||||
if (!filter.optionsQuery) return filter;
|
||||
let options = optionsCache.get(filter.key);
|
||||
if (!options) {
|
||||
options = await filter.optionsQuery(ds);
|
||||
optionsCache.set(filter.key, options);
|
||||
}
|
||||
// Drop the resolver itself — it is a function and would not serialise.
|
||||
const { optionsQuery: _resolver, ...rest } = filter;
|
||||
return { ...rest, options };
|
||||
}),
|
||||
);
|
||||
return { ...def, filters };
|
||||
}
|
||||
|
||||
@ApiTags('Reports')
|
||||
@ApiBearerAuth()
|
||||
@Controller('reports')
|
||||
@@ -26,16 +65,18 @@ const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => {
|
||||
export class ReportsController {
|
||||
constructor(
|
||||
private readonly runner: ReportRunnerService,
|
||||
private readonly exportService: ReportExportService,
|
||||
private readonly exportService: TabularExportService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List reports the caller has permission to run' })
|
||||
async catalog(@CurrentUser() user: TCurrentUser): Promise<ReportCatalogEntry[]> {
|
||||
return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map(
|
||||
toCatalogEntry,
|
||||
);
|
||||
const allowed = REPORTS.filter((def) =>
|
||||
hasFreightPermission(user, reportPermissionKey(def.key)),
|
||||
).map(toCatalogEntry);
|
||||
return Promise.all(allowed.map((def) => resolveFilterOptions(def, this.dataSource)));
|
||||
}
|
||||
|
||||
@Get(':key')
|
||||
@@ -51,7 +92,7 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get(':key/export')
|
||||
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
|
||||
@ApiOperation({ summary: 'Export a report to xlsx, csv or pdf' })
|
||||
async export(
|
||||
@Param('key') key: string,
|
||||
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
|
||||
@@ -61,23 +102,30 @@ export class ReportsController {
|
||||
const def = this.resolve(key, user);
|
||||
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
const format = resolveExportFormat(query.format);
|
||||
const cap = resolveExportCap(format, query.limit);
|
||||
const exportColumns = resolveExportColumns(def, query.fields);
|
||||
const exportColumns = pickByKey(def.columns, query.fields);
|
||||
|
||||
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
|
||||
const { items, kpis } = await this.runner.runAll(def, query, directions, {
|
||||
cap: formatRowCap(format),
|
||||
limit: resolveRowLimit(format, query.limit),
|
||||
});
|
||||
const doc = {
|
||||
title: def.title,
|
||||
description: def.description,
|
||||
label: `report:${def.key}`,
|
||||
columns: exportColumns,
|
||||
rows: items,
|
||||
kpis,
|
||||
};
|
||||
const buffer =
|
||||
format === 'pdf'
|
||||
? await this.exportService.toPdf(def, items, kpis, exportColumns)
|
||||
: await this.exportService.toXlsx(def, items, kpis, exportColumns);
|
||||
? await this.exportService.toPdf(doc)
|
||||
: format === 'csv'
|
||||
? await this.exportService.toCsv(doc)
|
||||
: await this.exportService.toXlsx(doc);
|
||||
|
||||
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
format === 'pdf'
|
||||
? 'application/pdf'
|
||||
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
const mime = EXPORT_MIME[format];
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${def.key}.${mime.ext}"`);
|
||||
res.setHeader('Content-Type', mime.type);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||
import { ExportsModule } from '../exports/exports.module';
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { ReportExportService } from './report-export.service';
|
||||
import { ReportRunnerService } from './report-runner.service';
|
||||
import { ReportsController } from './reports.controller';
|
||||
|
||||
@Module({
|
||||
imports: [UserTradeAccessModule, DocumentsModule],
|
||||
// ExportsModule provides the shared tabular writer (xlsx/csv/pdf) and pulls
|
||||
// DocumentsModule in for the PDF renderer.
|
||||
imports: [UserTradeAccessModule, ExportsModule],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportRunnerService, ReportExportService],
|
||||
providers: [ReportRunnerService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
BULK_FREIGHT_CHARGES,
|
||||
PAYMENT_CLASSES,
|
||||
PAYMENT_CLASS_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORIES,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
periodExpr,
|
||||
} from './revenue-classification';
|
||||
|
||||
/**
|
||||
* `invoice_lines.charge_type` is an unconstrained varchar written by eight
|
||||
* unrelated code paths. Nothing at the type level stops someone adding a ninth
|
||||
* spelling, whose revenue would then land silently in the ELSE arm.
|
||||
*
|
||||
* This list is every value the codebase writes today. When it grows, these
|
||||
* tests are what fail — which is the whole trade the const-map design makes.
|
||||
*/
|
||||
const KNOWN_CHARGE_TYPES = [
|
||||
// booking base freight (rate_type codes)
|
||||
'CONTAINER_IMPORT', 'CONTAINER_EXPORT', 'CONTAINER_20FT', 'CONTAINER_40FT',
|
||||
'BULK_IMPORT', 'BULK_EXPORT', 'INTERCITY_BULK', 'INTERCITY_CONTAINER', 'FREIGHT',
|
||||
// surcharges
|
||||
'FUEL_SURCHARGE', 'LASHING', 'OVERWEIGHT_PER_TON', 'HAZARD_SURCHARGE',
|
||||
'REEFER_SURCHARGE', 'PIL_EXTRA_FEE', 'RETURN_SURCHARGE', 'RETURN_SURCHARGE_20FT',
|
||||
'RETURN_SURCHARGE_40FT', 'CONTAINER_WITH_RETURN', 'ADJUSTMENT', 'RATE_ADJUSTMENT',
|
||||
// customs
|
||||
'CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE_20FT', 'CUSTOMS_CLEARANCE_40FT',
|
||||
// mile legs
|
||||
'FIRST_MILE', 'LAST_MILE', 'DELIVERY', 'LAST_MILE_ADVANCE',
|
||||
// warehouse fees
|
||||
'CONTAINER_DEMURRAGE', 'BULK_DEMURRAGE', 'DEMURRAGE', 'STORAGE_FEE',
|
||||
'HANDLING_FEE', 'DOUBLE_HANDLING', 'TRUCK_DETENTION',
|
||||
// other producers
|
||||
'CANCELLATION_FEE', 'SHIPPING_LINE_SERVICE',
|
||||
];
|
||||
|
||||
/**
|
||||
* Does the expression name this charge type — either as a literal or through
|
||||
* one of its `LIKE 'PREFIX%'` arms?
|
||||
*
|
||||
* Deliberately a substring check, not a SQL parser: a parser would be more
|
||||
* fragile than the expression it is guarding. This catches the failure that
|
||||
* actually happens (a new charge type nobody added to the map) and nothing
|
||||
* pretends it verifies the branch order.
|
||||
*/
|
||||
function isNamed(expr: string, chargeType: string): boolean {
|
||||
if (expr.includes(`'${chargeType}'`)) return true;
|
||||
return [...expr.matchAll(/LIKE '([^']*)%'/g)].some(([, prefix]) =>
|
||||
chargeType.startsWith(prefix),
|
||||
);
|
||||
}
|
||||
|
||||
describe('revenue classification', () => {
|
||||
it('names every charge type the codebase writes in the payment-class map', () => {
|
||||
const unmapped = KNOWN_CHARGE_TYPES.filter((c) => !isNamed(PAYMENT_CLASS_EXPR, c));
|
||||
expect(unmapped).toEqual([]);
|
||||
});
|
||||
|
||||
it('names every ancillary charge type in the revenue-category map', () => {
|
||||
// Bulk freight lines carry no category of their own — the CASE falls
|
||||
// through to the booking's cargo type and trade direction for those.
|
||||
const cargoDerived = new Set(BULK_FREIGHT_CHARGES);
|
||||
const unmapped = KNOWN_CHARGE_TYPES.filter(
|
||||
(c) => !cargoDerived.has(c) && !isNamed(REVENUE_CATEGORY_EXPR, c),
|
||||
);
|
||||
expect(unmapped).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits only categories that are offered as filter options', () => {
|
||||
const declared = new Set(REVENUE_CATEGORIES.map((c) => c.value));
|
||||
const emitted = [...REVENUE_CATEGORY_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]);
|
||||
expect(emitted.length).toBeGreaterThan(0);
|
||||
expect(emitted.filter((c) => !declared.has(c))).toEqual([]);
|
||||
expect(declared.has('UNCLASSIFIED')).toBe(true);
|
||||
});
|
||||
|
||||
it('emits only payment classes that are offered as filter options', () => {
|
||||
const declared = new Set(PAYMENT_CLASSES.map((c) => c.value));
|
||||
const emitted = [...PAYMENT_CLASS_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]);
|
||||
expect(emitted.filter((c) => !declared.has(c))).toEqual([]);
|
||||
expect(declared.has('ADDITIONAL')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to a whitelisted period unit instead of interpolating input', () => {
|
||||
expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'");
|
||||
expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'");
|
||||
// Anything unrecognised — including an injection attempt — becomes 'month'.
|
||||
expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain(
|
||||
"date_trunc('month'",
|
||||
);
|
||||
expect(periodExpr({})).toContain("date_trunc('month'");
|
||||
});
|
||||
|
||||
it('offers exactly the period units the expression understands', () => {
|
||||
const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value);
|
||||
expect(offered.length).toBe(5);
|
||||
for (const unit of offered) {
|
||||
expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Company } from '../companies/entities/company.entity';
|
||||
import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types';
|
||||
|
||||
/**
|
||||
* The shared vocabulary and SQL behind every revenue report.
|
||||
*
|
||||
* The fact table is `invoice_lines`, not `bookings`: `charge_type` is the only
|
||||
* column in the system that separates customs, first/last mile, demurrage,
|
||||
* storage and incidental revenue from base freight. A booking total is one
|
||||
* lump sum and cannot answer the revenue-classification requirement.
|
||||
*
|
||||
* Every consumer builds its FROM through {@link revenueLedgerQb}, so the table
|
||||
* aliases below (`il i b ct oy dy co slc`) are a fixed contract and the SQL
|
||||
* fragments here can reference them directly.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Revenue categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const REVENUE_CATEGORIES: ReportFilterOption[] = [
|
||||
{ value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Full Container Import — Multimodal' },
|
||||
{ value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Full Container Import — Unimodal' },
|
||||
{ value: 'CONTAINER_EXPORT', label: 'Full Container Export' },
|
||||
{ value: 'EMPTY_CONTAINER_REEXPORT', label: 'Empty Container Re-export' },
|
||||
{ value: 'FERTILIZER', label: 'Fertilizer Transportation' },
|
||||
{ value: 'BREAK_BULK', label: 'Break Bulk (Steel, Machineries)' },
|
||||
{ value: 'RORO', label: 'RoRo Transportation' },
|
||||
{ value: 'OTHER_IMPORT_BULK', label: 'Other Import Bulk Cargo' },
|
||||
{ value: 'OTHER_EXPORT_CARGO', label: 'Other Export Cargo' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic Cargo Transportation' },
|
||||
{ value: 'INCIDENTAL', label: 'Incidental Charges' },
|
||||
{ value: 'FIRST_LAST_MILE', label: 'First & Last Mile' },
|
||||
{ value: 'CUSTOMS_CLEARANCE', label: 'Customs Clearance' },
|
||||
{ value: 'UNCLASSIFIED', label: 'Unclassified' },
|
||||
];
|
||||
|
||||
/**
|
||||
* `charge_type` is an unconstrained varchar written by eight different code
|
||||
* paths, so the same concept arrives under several spellings — three for
|
||||
* demurrage, four for first/last mile. Every set below absorbs all of them.
|
||||
*/
|
||||
export const MILE_CHARGES = ['FIRST_MILE', 'LAST_MILE', 'DELIVERY', 'LAST_MILE_ADVANCE'];
|
||||
|
||||
export const INCIDENTAL_CHARGES = [
|
||||
'FUEL_SURCHARGE',
|
||||
'LASHING',
|
||||
'OVERWEIGHT_PER_TON',
|
||||
'HAZARD_SURCHARGE',
|
||||
'REEFER_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
'CANCELLATION_FEE',
|
||||
'ADJUSTMENT',
|
||||
'RATE_ADJUSTMENT',
|
||||
'DEMURRAGE',
|
||||
'CONTAINER_DEMURRAGE',
|
||||
'BULK_DEMURRAGE',
|
||||
'TRUCK_DETENTION',
|
||||
'STORAGE_FEE',
|
||||
'HANDLING_FEE',
|
||||
'DOUBLE_HANDLING',
|
||||
'SHIPPING_LINE_SERVICE',
|
||||
];
|
||||
|
||||
export const DOMESTIC_CHARGES = ['INTERCITY_BULK', 'INTERCITY_CONTAINER'];
|
||||
|
||||
export const CONTAINER_FREIGHT_CHARGES = [
|
||||
'CONTAINER_IMPORT',
|
||||
'CONTAINER_EXPORT',
|
||||
'CONTAINER_20FT',
|
||||
'CONTAINER_40FT',
|
||||
];
|
||||
|
||||
export const BULK_FREIGHT_CHARGES = ['BULK_IMPORT', 'BULK_EXPORT', 'FREIGHT'];
|
||||
|
||||
/** Cargo codes the business bills as break bulk, wherever the cargo tree puts them. */
|
||||
export const BREAK_BULK_CODES = ['STEEL_BILLET', 'STEEL', 'MACHINERY', 'PIPES', 'TIMBER'];
|
||||
|
||||
export const RORO_CODES = ['TRUCK', 'AUTOMOBILE', 'CARS', 'RORO'];
|
||||
|
||||
export const FERTILIZER_CODES = ['FERTILIZER'];
|
||||
|
||||
/**
|
||||
* Multimodal means EDR carried the sea leg as well as the rail leg. Nothing in
|
||||
* the schema says so directly; a named sea carrier on the booking is the
|
||||
* agreed proxy. One constant, deliberately — flip it here if the business
|
||||
* defines multimodality differently.
|
||||
*/
|
||||
const MULTIMODAL_PREDICATE = 'b.shipping_line_id IS NOT NULL';
|
||||
|
||||
const list = (values: string[]): string => values.map((v) => `'${v}'`).join(', ');
|
||||
|
||||
/**
|
||||
* Assigns each invoice line exactly one revenue category. First match wins.
|
||||
*
|
||||
* Charge-derived rules run BEFORE cargo-derived ones on purpose: a customs or
|
||||
* demurrage line billed on a container-import booking is customs/incidental
|
||||
* revenue, not container-import revenue. Reversing the order would fold every
|
||||
* ancillary charge back into the freight categories.
|
||||
*
|
||||
* Nothing falls through silently — an unmatched line lands in UNCLASSIFIED and
|
||||
* every report surfaces that total as a KPI, because an audit report must
|
||||
* never quietly drop money.
|
||||
*/
|
||||
export const REVENUE_CATEGORY_EXPR = `CASE
|
||||
WHEN il.charge_type LIKE 'CUSTOMS_CLEARANCE%' THEN 'CUSTOMS_CLEARANCE'
|
||||
WHEN il.charge_type IN (${list(MILE_CHARGES)}) THEN 'FIRST_LAST_MILE'
|
||||
WHEN il.charge_type LIKE 'RETURN_SURCHARGE%'
|
||||
OR il.charge_type = 'CONTAINER_WITH_RETURN' THEN 'EMPTY_CONTAINER_REEXPORT'
|
||||
WHEN il.charge_type IN (${list(INCIDENTAL_CHARGES)}) THEN 'INCIDENTAL'
|
||||
WHEN il.charge_type IN (${list(DOMESTIC_CHARGES)})
|
||||
OR (oy.country IS NOT NULL AND oy.country = dy.country) THEN 'DOMESTIC'
|
||||
WHEN il.charge_type IN (${list(CONTAINER_FREIGHT_CHARGES)})
|
||||
OR b.freight_type = 'CONTAINER' THEN
|
||||
CASE
|
||||
WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
|
||||
WHEN ${MULTIMODAL_PREDICATE} THEN 'CONTAINER_IMPORT_MULTIMODAL'
|
||||
ELSE 'CONTAINER_IMPORT_UNIMODAL'
|
||||
END
|
||||
WHEN ct.code IN (${list(FERTILIZER_CODES)}) THEN 'FERTILIZER'
|
||||
WHEN ct.code IN (${list(BREAK_BULK_CODES)}) THEN 'BREAK_BULK'
|
||||
WHEN ct.code IN (${list(RORO_CODES)}) THEN 'RORO'
|
||||
WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT_CARGO'
|
||||
WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT_BULK'
|
||||
ELSE 'UNCLASSIFIED'
|
||||
END`;
|
||||
|
||||
const labelCase = (expr: string, options: ReportFilterOption[]): string =>
|
||||
`CASE ${expr}\n ${options
|
||||
.map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`)
|
||||
.join('\n ')}\nEND`;
|
||||
|
||||
/** The category as a business label rather than its key, for display columns. */
|
||||
export const CATEGORY_LABEL_EXPR = labelCase(REVENUE_CATEGORY_EXPR, REVENUE_CATEGORIES);
|
||||
|
||||
/**
|
||||
* Period-over-period change, as a percentage.
|
||||
*
|
||||
* The denominator is `ABS(prior)`, not `prior`. A category can post negative
|
||||
* revenue in a period — a credit note or rate adjustment outweighing its
|
||||
* charges — and dividing by a negative prior flips the sign, reporting a
|
||||
* recovery as a decline. Taking the magnitude keeps the sign of the change
|
||||
* itself.
|
||||
*/
|
||||
export const growthPctExpr = (revenue: string, prior: string): string =>
|
||||
`ROUND(100 * (${revenue} - ${prior}) / NULLIF(ABS(${prior}), 0), 1)::float8`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payment classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PAYMENT_CLASSES: ReportFilterOption[] = [
|
||||
{ value: 'RAIL_TRANSPORT', label: 'Rail transport' },
|
||||
{ value: 'CUSTOMS_CLEARANCE', label: 'Custom clearance' },
|
||||
{ value: 'FIRST_LAST_MILE', label: 'First and last mile' },
|
||||
{ value: 'OVERWEIGHT', label: 'Overweight' },
|
||||
{ value: 'CANCELLATION', label: 'Cancellation' },
|
||||
{ value: 'DEMURRAGE', label: 'Demurrage' },
|
||||
{ value: 'STORAGE', label: 'Storage' },
|
||||
{ value: 'LOADING_UNLOADING', label: 'Loading and unloading' },
|
||||
{ value: 'ADDITIONAL', label: 'Additional payment' },
|
||||
];
|
||||
|
||||
const RAIL_CHARGES = [...CONTAINER_FREIGHT_CHARGES, ...BULK_FREIGHT_CHARGES, ...DOMESTIC_CHARGES];
|
||||
|
||||
const DEMURRAGE_CHARGES = ['DEMURRAGE', 'CONTAINER_DEMURRAGE', 'BULK_DEMURRAGE', 'TRUCK_DETENTION'];
|
||||
|
||||
/**
|
||||
* Charges that legitimately belong in the spec's "additional payment" bucket.
|
||||
*
|
||||
* Listed explicitly rather than left to the ELSE arm: ELSE also catches charge
|
||||
* types nobody has mapped yet, and those two cases must not be
|
||||
* indistinguishable. Naming these is what lets the spec fail when a genuinely
|
||||
* new charge type appears.
|
||||
*/
|
||||
export const ADDITIONAL_CHARGES = [
|
||||
'FUEL_SURCHARGE',
|
||||
'HAZARD_SURCHARGE',
|
||||
'REEFER_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
'RETURN_SURCHARGE',
|
||||
'RETURN_SURCHARGE_20FT',
|
||||
'RETURN_SURCHARGE_40FT',
|
||||
'CONTAINER_WITH_RETURN',
|
||||
'ADJUSTMENT',
|
||||
'RATE_ADJUSTMENT',
|
||||
'SHIPPING_LINE_SERVICE',
|
||||
];
|
||||
|
||||
/**
|
||||
* The nine buckets the revenue spec asks payments to be classified into.
|
||||
*
|
||||
* Caveat worth repeating wherever this is shown: there is no dedicated
|
||||
* loading/unloading charge type in the system. HANDLING_FEE, DOUBLE_HANDLING
|
||||
* and LASHING are the nearest equivalent, so that bucket is an approximation,
|
||||
* not an exact match.
|
||||
*/
|
||||
export const PAYMENT_CLASS_EXPR = `CASE
|
||||
WHEN il.charge_type LIKE 'CUSTOMS_CLEARANCE%' THEN 'CUSTOMS_CLEARANCE'
|
||||
WHEN il.charge_type IN (${list(MILE_CHARGES)}) THEN 'FIRST_LAST_MILE'
|
||||
WHEN il.charge_type IN (${list(RAIL_CHARGES)}) THEN 'RAIL_TRANSPORT'
|
||||
WHEN il.charge_type = 'OVERWEIGHT_PER_TON' THEN 'OVERWEIGHT'
|
||||
WHEN il.charge_type = 'CANCELLATION_FEE' THEN 'CANCELLATION'
|
||||
WHEN il.charge_type IN (${list(DEMURRAGE_CHARGES)}) THEN 'DEMURRAGE'
|
||||
WHEN il.charge_type = 'STORAGE_FEE' THEN 'STORAGE'
|
||||
WHEN il.charge_type IN ('HANDLING_FEE', 'DOUBLE_HANDLING', 'LASHING')
|
||||
THEN 'LOADING_UNLOADING'
|
||||
WHEN il.charge_type IN (${list(ADDITIONAL_CHARGES)}) THEN 'ADDITIONAL'
|
||||
ELSE 'ADDITIONAL'
|
||||
END`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Period granularity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Frozen whitelist. The runner coerces a `select` filter to a trimmed string
|
||||
* or null; that string is used only as an object key here, so the user's value
|
||||
* never reaches SQL — one of five compile-time constants does.
|
||||
*
|
||||
* Every format is zero-padded, so lexicographic order equals chronological
|
||||
* order. The growth window depends on that.
|
||||
*/
|
||||
const PERIOD_UNITS = {
|
||||
day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' },
|
||||
week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' },
|
||||
month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' },
|
||||
// `quarter` is a valid date_trunc unit but NOT a valid interval unit —
|
||||
// INTERVAL '1 quarter' is a syntax error, so the step is spelled in months.
|
||||
quarter: { trunc: 'quarter', fmt: 'YYYY-"Q"Q', label: 'Quarterly', step: '3 months' },
|
||||
year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' },
|
||||
} as const;
|
||||
|
||||
export const PERIOD_FILTER: ReportFilterDef = {
|
||||
key: 'period',
|
||||
label: 'Granularity',
|
||||
type: 'select',
|
||||
options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({ value, label: u.label })),
|
||||
};
|
||||
|
||||
/** The timestamp every revenue report buckets and filters on. */
|
||||
export const REVENUE_DATE = 'COALESCE(i.issued_at, i.created_at)';
|
||||
|
||||
/**
|
||||
* The period label expression, as a string.
|
||||
*
|
||||
* Callers must reuse the returned string VERBATIM in the select, the GROUP BY
|
||||
* and any window `ORDER BY`. Two traps make this non-negotiable:
|
||||
*
|
||||
* 1. A window `ORDER BY date_trunc(...)` when the group key is `to_char(date_trunc(...))`
|
||||
* fails with "column i.issued_at must appear in the GROUP BY clause".
|
||||
* 2. Ordinal shorthand — `OVER (PARTITION BY 2 ORDER BY 1)` — is NOT a
|
||||
* positional reference inside a window clause. Postgres reads the integers
|
||||
* as constants, so it partitions by a constant and applies no ordering. It
|
||||
* type-checks, it EXPLAINs clean, and it returns plausible garbage.
|
||||
*/
|
||||
export function periodExpr(params: Record<string, unknown>): string {
|
||||
return periodExprOn(REVENUE_DATE, params);
|
||||
}
|
||||
|
||||
export function resolvePeriod(
|
||||
params: Record<string, unknown>,
|
||||
): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] {
|
||||
const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS;
|
||||
return PERIOD_UNITS[key] ?? PERIOD_UNITS.month;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same bucketing over any timestamp column. Revenue buckets on the invoice
|
||||
* date; the operations reports bucket on a train's actual departure, and share
|
||||
* these units so a month means the same thing on both sides of the product.
|
||||
*/
|
||||
export const periodExprOn = (dateExpr: string, params: Record<string, 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. */
|
||||
export const periodTruncExpr = (params: Record<string, unknown>): string =>
|
||||
periodTruncExprOn(REVENUE_DATE, params);
|
||||
|
||||
/**
|
||||
* The period as a number, for regression: seconds since epoch at the period's
|
||||
* start. Using the timestamp itself rather than `row_number()` keeps a trend
|
||||
* calculation to a single window level — Postgres rejects a window function
|
||||
* nested inside another window function's arguments.
|
||||
*/
|
||||
export const periodOrdinalExpr = (params: Record<string, unknown>): string =>
|
||||
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`;
|
||||
|
||||
/** Same scale, one period later — where a one-step-ahead projection lands. */
|
||||
export const nextPeriodOrdinalExpr = (params: Record<string, unknown>): string =>
|
||||
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)} + INTERVAL '${resolvePeriod(params).step}')`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Volume — measured at line grain, never joined from the booking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `invoice_lines.quantity` already carries the billed quantity per line, and
|
||||
* `metadata->>'unit'` says what it counts (PER_TON / PER_CONTAINER / PER_WAGON).
|
||||
* Joining booking-level tonnage instead would multiply it by the number of
|
||||
* lines on the booking.
|
||||
*/
|
||||
export const TONS_EXPR = `SUM(il.quantity) FILTER (WHERE il.metadata->>'unit' = 'PER_TON')`;
|
||||
|
||||
export const CONTAINERS_EXPR = `SUM(il.quantity) FILTER (WHERE il.metadata->>'unit' = 'PER_CONTAINER')`;
|
||||
|
||||
/**
|
||||
* TEU is never stored. It is derived from the charge code's size suffix; lines
|
||||
* whose code carries no size (CONTAINER_IMPORT / CONTAINER_EXPORT) count as one
|
||||
* TEU each, which under-counts any 40ft box billed under an unsized code.
|
||||
*/
|
||||
export const TEU_EXPR = `SUM(il.quantity * CASE WHEN il.charge_type LIKE '%40FT%' THEN 2 ELSE 1 END)
|
||||
FILTER (WHERE il.metadata->>'unit' = 'PER_CONTAINER')`;
|
||||
|
||||
/**
|
||||
* Revenue per unit, against whichever unit the category is actually billed in.
|
||||
* Exactly one of tons/TEU is non-null per category, so this is per-ton for bulk
|
||||
* and per-TEU for containers; the `unit` column says which.
|
||||
*/
|
||||
export const AVG_PER_UNIT_EXPR = `ROUND(
|
||||
SUM(il.amount) / NULLIF(COALESCE(${TONS_EXPR}, 0) + COALESCE(${TEU_EXPR}, 0), 0), 2
|
||||
)::float8`;
|
||||
|
||||
export const UNIT_LABEL_EXPR = `CASE
|
||||
WHEN COALESCE(${TONS_EXPR}, 0) > 0 THEN 'per ton'
|
||||
WHEN COALESCE(${TEU_EXPR}, 0) > 0 THEN 'per TEU'
|
||||
ELSE ''
|
||||
END`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The shared ledger query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Invoice states that never represent recognised revenue. */
|
||||
const DEAD_INVOICE_STATUSES = ['DRAFT', 'CANCELLED'];
|
||||
|
||||
export const PAYMENT_METHOD_OPTIONS: ReportFilterOption[] = [
|
||||
'telebirr',
|
||||
'cbe-birr',
|
||||
'cbe-bill',
|
||||
'ebirr',
|
||||
'waafi',
|
||||
'dmoney',
|
||||
'cac-bank',
|
||||
'card',
|
||||
].map((v) => ({ value: v, label: v }));
|
||||
|
||||
export const CURRENCY_FILTER: ReportFilterDef = {
|
||||
key: 'currency',
|
||||
label: 'Currency',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The filter set shared by every revenue report, so they drill into each other
|
||||
* without losing context.
|
||||
*
|
||||
* `currency` is not optional decoration: the ledger holds both ETB and USD
|
||||
* lines, and summing across them produces a number that means nothing. It
|
||||
* defaults to ETB in {@link revenueLedgerQb} rather than being left blank.
|
||||
*/
|
||||
export const REVENUE_FILTERS: ReportFilterDef[] = [
|
||||
{ key: 'date', label: 'Issued', type: 'daterange' },
|
||||
CURRENCY_FILTER,
|
||||
{
|
||||
key: 'categories',
|
||||
label: 'Revenue category',
|
||||
type: 'multiselect',
|
||||
options: REVENUE_CATEGORIES,
|
||||
},
|
||||
{ key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions },
|
||||
{ key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions },
|
||||
{ key: 'customer', label: 'Customer / booking ref', type: 'text' },
|
||||
{
|
||||
key: 'methods',
|
||||
label: 'Payment method',
|
||||
type: 'multiselect',
|
||||
options: PAYMENT_METHOD_OPTIONS,
|
||||
},
|
||||
];
|
||||
|
||||
/** Stations are reference data — 23 rows that change about yearly. */
|
||||
export async function yardOptions(ds: ReportContext['ds']): Promise<ReportFilterOption[]> {
|
||||
return ds
|
||||
.createQueryBuilder()
|
||||
.from(Yard, 'y')
|
||||
.select('y.code', 'value')
|
||||
.addSelect('y.label', 'label')
|
||||
.where('y.deleted_at IS NULL AND y.is_active')
|
||||
.orderBy('y.display_order', 'ASC')
|
||||
.getRawMany<ReportFilterOption>();
|
||||
}
|
||||
|
||||
/** Currency the ledger reports in when the caller does not choose one. */
|
||||
export const DEFAULT_CURRENCY = 'ETB';
|
||||
|
||||
export const currencyOf = (params: Record<string, unknown>): string =>
|
||||
(params.currency as string) || DEFAULT_CURRENCY;
|
||||
|
||||
/**
|
||||
* Every revenue report starts here: one invoice line joined out to the booking
|
||||
* that explains it. Bookings are LEFT joined on purpose — warehouse, demurrage
|
||||
* and shipping-line-credit invoices carry no booking and must still be counted.
|
||||
*
|
||||
* The booking join is `i.source_id = b.id::text`, never `i.source_id::uuid`:
|
||||
* `source_id` is a varchar with no FK that holds non-UUID values for other
|
||||
* sources (`eims-self-test-…`), so casting it would throw at runtime.
|
||||
*/
|
||||
export function revenueLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(InvoiceLine, 'il')
|
||||
.innerJoin(Invoice, 'i', 'i.id = il.invoice_id AND i.deleted_at IS NULL')
|
||||
.leftJoin(
|
||||
Booking,
|
||||
'b',
|
||||
"i.source = 'booking' AND i.source_id = b.id::text AND b.deleted_at IS NULL",
|
||||
)
|
||||
.leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id')
|
||||
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
|
||||
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
|
||||
.leftJoin(Company, 'co', 'co.id = i.company_id')
|
||||
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id')
|
||||
.where('il.deleted_at IS NULL')
|
||||
.andWhere('i.status NOT IN (:...deadInvoiceStatuses)', {
|
||||
deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
|
||||
})
|
||||
.andWhere("i.source <> 'eims_self_test'")
|
||||
// An umbrella general contract is paid once and drawn down by many orders;
|
||||
// counting both double-counts its value.
|
||||
.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')")
|
||||
// Mixing ETB and USD into one SUM produces a meaningless number.
|
||||
.andWhere('il.currency = :currency', { currency: currencyOf(params) });
|
||||
|
||||
if (params.dateFrom) {
|
||||
qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
|
||||
}
|
||||
if (params.dateTo) {
|
||||
qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo });
|
||||
}
|
||||
|
||||
const categories = params.categories as string[] | null;
|
||||
if (categories?.length) {
|
||||
qb.andWhere(`${REVENUE_CATEGORY_EXPR} IN (:...categories)`, { categories });
|
||||
}
|
||||
|
||||
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
|
||||
if (params.destination) {
|
||||
qb.andWhere('dy.code = :destination', { destination: params.destination });
|
||||
}
|
||||
|
||||
if (params.customer) {
|
||||
qb.andWhere(
|
||||
'(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)',
|
||||
{ customer: `%${params.customer as string}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const methods = params.methods as string[] | null;
|
||||
if (methods?.length) {
|
||||
qb.andWhere(
|
||||
`EXISTS (SELECT 1 FROM freight.payments p
|
||||
WHERE p.ref_id = i.source_id AND p.status = 'success'
|
||||
AND p.method IN (:...methods))`,
|
||||
{ methods },
|
||||
);
|
||||
}
|
||||
|
||||
// Hides lines whose booking sits outside the caller's trade scope. Lines with
|
||||
// no booking carry no direction and stay visible.
|
||||
applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||
|
||||
return qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice-grain sibling of {@link revenueLedgerQb}, for the reports that must
|
||||
* not multiply an invoice by its line count — outstanding balance,
|
||||
* reconciliation, receivable/payable. Same joins, same filters, minus the
|
||||
* line-only ones (charge category, currency lives on the invoice here).
|
||||
*/
|
||||
export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Invoice, 'i')
|
||||
.leftJoin(
|
||||
Booking,
|
||||
'b',
|
||||
"i.source = 'booking' AND i.source_id = b.id::text AND b.deleted_at IS NULL",
|
||||
)
|
||||
.leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id')
|
||||
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
|
||||
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
|
||||
.leftJoin(Company, 'co', 'co.id = i.company_id')
|
||||
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id')
|
||||
.where('i.deleted_at IS NULL')
|
||||
.andWhere('i.status NOT IN (:...deadInvoiceStatuses)', {
|
||||
deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
|
||||
})
|
||||
.andWhere("i.source <> 'eims_self_test'")
|
||||
.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')")
|
||||
.andWhere('i.currency = :currency', { currency: currencyOf(params) });
|
||||
|
||||
if (params.dateFrom) qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo });
|
||||
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
|
||||
if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination });
|
||||
if (params.customer) {
|
||||
qb.andWhere(
|
||||
'(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)',
|
||||
{ customer: `%${params.customer as string}%` },
|
||||
);
|
||||
}
|
||||
|
||||
applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||
return qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the payment gateway actually recorded against this invoice, summed.
|
||||
* `invoices.payment_id` points at a payment-api intent id rather than a
|
||||
* `freight.payments` row, so the reliable link is the booking id both sides
|
||||
* carry.
|
||||
*/
|
||||
export const GATEWAY_PAID = `(
|
||||
SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p
|
||||
WHERE p.ref_id = i.source_id AND p.status = 'success'
|
||||
)`;
|
||||
|
||||
/** The payer, whichever of the two mutually exclusive payer columns is set. */
|
||||
export const PAYER_EXPR = "COALESCE(co.name, slc.name, 'Unknown')";
|
||||
|
||||
/** `SUM(amount)`, rounded to whole currency and typed as a JS number. */
|
||||
export const REVENUE_SUM = 'ROUND(COALESCE(SUM(il.amount), 0))::float8';
|
||||
|
||||
/**
|
||||
* Settled share of a line, apportioned by how much of its invoice was paid.
|
||||
* Invoice-level `paid_amount` cannot be attributed to a single line any other
|
||||
* way.
|
||||
*/
|
||||
export const PAID_SHARE =
|
||||
'il.amount * CASE WHEN i.total_amount > 0 THEN i.paid_amount / i.total_amount ELSE 0 END';
|
||||
|
||||
/** The payment class as a business label rather than its key. */
|
||||
export const PAYMENT_CLASS_LABEL_EXPR = labelCase(PAYMENT_CLASS_EXPR, PAYMENT_CLASSES);
|
||||
@@ -94,6 +94,16 @@ export class CreateCargoTypeDto {
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Wagons in a full trainset of this cargo (37 vehicles, 22 sand). Blank uses the default.',
|
||||
example: 37,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
fullTrainsetWagons?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, IsUUID, Min } from 'class-validator';
|
||||
import { IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
|
||||
const toNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? value : Number(value);
|
||||
@@ -19,4 +19,15 @@ export class CreateYardDistanceDto {
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
distanceKm!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Standard running time for this leg in hours. Blank uses the default leg standard.',
|
||||
example: 21,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
standardHours?: number;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,15 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true })
|
||||
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 })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
|
||||
@@ -32,4 +32,15 @@ export class YardDistance extends BaseEntity {
|
||||
|
||||
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 })
|
||||
distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm
|
||||
|
||||
/**
|
||||
* Standard running time for this leg, in hours — Negad→GMP 21, →Adama 20,
|
||||
* →Modjo 20.5, →Sebeta 22. The delay report flags a leg that takes longer
|
||||
* than this plus the tolerance. Null falls back to
|
||||
* `operations_standards.default_leg_standard_hours`.
|
||||
*
|
||||
* Symmetric like the distance itself: an A→B row governs B→A too.
|
||||
*/
|
||||
@Column({ name: 'standard_hours', type: 'decimal', precision: 6, scale: 2, nullable: true })
|
||||
standardHours?: string | null;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ export class CargoTypesService {
|
||||
itemsPerWagonMap: dto.itemsPerWagonMap,
|
||||
}),
|
||||
tonsPerWagonMap,
|
||||
fullTrainsetWagons: dto.fullTrainsetWagons ?? null,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export class YardDistancesService {
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
distanceKm: dto.distanceKm.toFixed(2),
|
||||
standardHours: dto.standardHours != null ? dto.standardHours.toFixed(2) : null,
|
||||
});
|
||||
return toRow(created);
|
||||
}
|
||||
@@ -78,6 +79,9 @@ export class YardDistancesService {
|
||||
fromYardId,
|
||||
toYardId,
|
||||
...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}),
|
||||
...(dto.standardHours !== undefined
|
||||
? { standardHours: dto.standardHours != null ? dto.standardHours.toFixed(2) : null }
|
||||
: {}),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`Yard distance ${id} not found`);
|
||||
return toRow(updated);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { IdListParam } from '../../../common/dto/id-list.transform';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import {
|
||||
TRAIN_SCHEDULE_STATUSES,
|
||||
@@ -50,15 +51,22 @@ export class ListTrainSchedulesQueryDto extends PaginationQueryDto {
|
||||
@IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[])
|
||||
freightType?: TrainScheduleFreightType;
|
||||
|
||||
/** Origin station/yard id (exact match). */
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
/** Origin station/yard — one id or a comma-separated list; matches ANY of them. */
|
||||
@ApiPropertyOptional({
|
||||
description: 'Origin station/yard id, or a comma-separated list (matches any of them).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originStationId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
originStationId?: string[];
|
||||
|
||||
/** Destination station/yard id (exact match). */
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
/** Destination station/yard — one id or a comma-separated list; ANDed with the origin. */
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Destination station/yard id, or a comma-separated list (matches any of them). ANDed with originStationId.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
destinationStationId?: string[];
|
||||
}
|
||||
|
||||
@@ -4596,8 +4596,15 @@ export class TrainSchedulingService {
|
||||
const base: FindOptionsWhere<TrainSchedule> = {};
|
||||
if (allowedDirections) base.direction = In(allowedDirections) as never;
|
||||
if (query.status) base.status = query.status;
|
||||
if (query.originStationId) base.originStationId = query.originStationId;
|
||||
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
|
||||
// Each end is an OR-list, the two ends AND together (origin-only and
|
||||
// destination-only are both valid queries). `?.length` guards the empty
|
||||
// array — `In([])` compiles to `IN ()`, a syntax error.
|
||||
if (query.originStationId?.length) {
|
||||
base.originStationId = In(query.originStationId) as never;
|
||||
}
|
||||
if (query.destinationStationId?.length) {
|
||||
base.destinationStationId = In(query.destinationStationId) as never;
|
||||
}
|
||||
if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
|
||||
|
||||
// Search fans out across every human-recognizable label; each OR variant
|
||||
|
||||
@@ -72,12 +72,14 @@ export class ListWagonsQueryDto {
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100 })
|
||||
// 500 to match PaginationQueryDto — this DTO doesn't extend it, so the
|
||||
// ceiling has to be repeated here or the wagons list alone rejects at 100.
|
||||
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 500 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Max(500)
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user