This commit is contained in:
marshalyordanos
2026-08-21 15:22:52 +03:00
272 changed files with 20535 additions and 3331 deletions

View File

@@ -51,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { 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,

View File

@@ -0,0 +1,57 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { FilterBookingDto } from '../../modules/bookings/dto/filter-booking.dto';
import { ListTrainSchedulesQueryDto } from '../../modules/train-scheduling/dto/list-train-schedules-query.dto';
/**
* The route filters carry one id, `a,b`, or a repeated param, and the
* repositories then branch on `?.length` before emitting `IN (:...ids)`.
* Two things have to hold or that breaks at runtime, not compile time:
* the value must always arrive as an array (a bare string would make
* `.length` count characters), and an absent/blank param must arrive as
* `undefined`, never `[]` — TypeORM turns `[]` into the syntax error `IN ()`.
*/
// Real-shaped v4s: the variant nibble must be 8/9/a/b, so `1111…` is NOT a
// valid UUID and would fail `@IsUUID` for reasons that have nothing to do
// with the list transform under test.
const A = '0a5d4b1e-1b2c-4d3e-8f90-1234567890ab';
const B = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const parse = <T>(cls: new () => T, query: Record<string, unknown>): T =>
plainToInstance(cls, query);
describe('route id-list query params', () => {
it('accepts a single id, still as an array', () => {
const dto = parse(FilterBookingDto, { originYardId: A });
expect(dto.originYardId).toEqual([A]);
expect(validateSync(dto)).toHaveLength(0);
});
it('splits a comma-separated list', () => {
const dto = parse(FilterBookingDto, { originYardId: `${A}, ${B}` });
expect(dto.originYardId).toEqual([A, B]);
expect(validateSync(dto)).toHaveLength(0);
});
it('accepts the repeated-param form', () => {
const dto = parse(ListTrainSchedulesQueryDto, { destinationStationId: [A, B] });
expect(dto.destinationStationId).toEqual([A, B]);
expect(validateSync(dto)).toHaveLength(0);
});
it.each([undefined, '', ','])('yields undefined, never [], for %p', (raw) => {
expect(parse(FilterBookingDto, { originYardId: raw }).originYardId).toBeUndefined();
});
it('leaves the two ends independent — one side set, the other absent', () => {
const dto = parse(FilterBookingDto, { originYardId: A });
expect(dto.originYardId).toEqual([A]);
expect(dto.destinationYardId).toBeUndefined();
});
it('still rejects a non-uuid inside the list', () => {
const dto = parse(FilterBookingDto, { originYardId: `${A},not-a-uuid` });
expect(validateSync(dto)).not.toHaveLength(0);
});
});

View File

@@ -0,0 +1,29 @@
import { Transform } from 'class-transformer';
/**
* A query param that carries one id, a comma-separated list (`a,b,c`), or the
* same key repeated — and always lands on the DTO as a `string[]`.
*
* Two details matter:
*
* - It yields `undefined`, never `[]`, when nothing usable is left. `@IsOptional`
* then short-circuits, and — more importantly — a repository that does
* `if (ids?.length)` can never be handed an empty array, which TypeORM turns
* into the syntax error `IN ()`.
* - It is backwards compatible with the single-value form these params used to
* take, so existing deep links and saved views keep working unchanged.
*
* Pair it with `@IsUUID(undefined, { each: true })` (or the relevant `each`
* validator) — this only reshapes the value, it does not validate it.
*/
export const IdListParam = () =>
Transform(({ value }: { value: unknown }) => {
const raw = Array.isArray(value) ? value : [value];
const ids = raw
.flatMap((entry) =>
entry === undefined || entry === null ? [] : String(entry).split(','),
)
.map((s) => s.trim())
.filter(Boolean);
return ids.length ? ids : undefined;
});

View File

@@ -0,0 +1,32 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { PaginationQueryDto } from './pagination-query.dto';
import { ListWagonsQueryDto } from '../../modules/wagons/dto/list-wagons-query.dto';
import { normalizePagination } from '../utils/pagination.util';
/**
* The page-size ceiling is stated in three places that must agree: `@Max` on
* PaginationQueryDto, the same `@Max` repeated on ListWagonsQueryDto (which
* doesn't extend it), and `MAX_PAGE_SIZE` in pagination.util. A fourth copy
* lives outside this package — `MAX_PAGE_SIZE` in @edr/ui-common's data-table
* footer, which is what actually asks for the number. Drift between any of
* them shows up as a 400 on the largest rows-per-page option, so pin them.
*/
const errorsFor = (cls: any, pageSize: unknown) =>
validateSync(plainToInstance(cls, { pageSize }), { whitelist: false });
describe('page size ceiling', () => {
it.each([PaginationQueryDto, ListWagonsQueryDto])('accepts 500 on %p', (cls) => {
expect(errorsFor(cls, 500)).toHaveLength(0);
});
it.each([PaginationQueryDto, ListWagonsQueryDto])('rejects 501 on %p', (cls) => {
expect(errorsFor(cls, 501)).not.toHaveLength(0);
});
it('does not truncate 500 in the service-side clamp', () => {
expect(normalizePagination({ page: 1, pageSize: 500 }).take).toBe(500);
expect(normalizePagination({ page: 1, pageSize: 501 }).take).toBe(500);
});
});

View File

@@ -19,12 +19,18 @@ export class PaginationQueryDto {
@Min(1)
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({

View File

@@ -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(

View File

@@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service',
FUEL: 'Fuel surcharge',
};

View File

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

View File

@@ -0,0 +1,114 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Reference data for the operations reporting suite (turnaround, delay,
* trainset, TEU, cargo volume).
*
* Two new tables and two new columns:
*
* - `operations_standards` — single-row settings table, same shape as
* `logo_settings` / `exchange_settings`. Holds the railway's standard times
* and charged-tonnage factors. Editable in the backoffice because the
* business calls the corridor standard "flexible".
* - `operations_targets` — the planned side of every "Plan / Operated /
* Implement Rate" table in the spec. One row per period × metric ×
* dimension value.
* - `yard_distances.standard_hours` — the per-corridor standard transit time
* (Negad→GMP 21h, →Adama 20h, →Modjo 20.5h, →Sebeta 22h). Null falls back to
* `operations_standards.default_leg_standard_hours`.
* - `cargo_types.full_trainset_wagons` — wagons in a full trainset of this
* cargo (37 for vehicles, 22 for sand). Null falls back to
* `operations_standards.default_full_trainset_wagons`.
*
* The seed row is inserted only when the table is empty, so re-running this
* never overwrites values an operator has since edited.
*/
export class OperationsReporting3580000000000 implements MigrationInterface {
name = "OperationsReporting3580000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.operations_standards (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
station_standard_hours_ethiopia numeric(6,2) NOT NULL DEFAULT 10,
station_standard_hours_djibouti numeric(6,2) NOT NULL DEFAULT 13,
cycle_standard_hours_container numeric(6,2) NOT NULL DEFAULT 65,
cycle_standard_hours_bulk_dmp numeric(6,2) NOT NULL DEFAULT 88,
cycle_standard_hours_bulk_nagad numeric(6,2) NOT NULL DEFAULT 96,
cycle_standard_hours_bulk_bcc numeric(6,2) NOT NULL DEFAULT 96,
default_leg_standard_hours numeric(6,2) NOT NULL DEFAULT 21,
delay_tolerance_minutes integer NOT NULL DEFAULT 30,
charged_tons_full_20ft numeric(8,2) NOT NULL DEFAULT 20,
charged_tons_full_40ft numeric(8,2) NOT NULL DEFAULT 40,
charged_tons_empty_20ft numeric(8,2) NOT NULL DEFAULT 2.24,
charged_tons_empty_40ft numeric(8,2) NOT NULL DEFAULT 3.88,
charged_tons_per_wagon_general numeric(8,2) NOT NULL DEFAULT 70,
charged_tons_per_wagon_perishable numeric(8,2) NOT NULL DEFAULT 38,
default_full_trainset_wagons integer NOT NULL DEFAULT 50,
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
// Column defaults carry every value — the seed only needs the row to exist.
await queryRunner.query(`
INSERT INTO freight.operations_standards (id)
SELECT gen_random_uuid()
WHERE NOT EXISTS (SELECT 1 FROM freight.operations_standards WHERE deleted_at IS NULL);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.operations_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
period_type varchar(10) NOT NULL,
period_start date NOT NULL,
metric varchar(20) NOT NULL,
dimension varchar(20) NOT NULL,
dimension_key varchar(60) NOT NULL,
planned_value numeric(14,3) NOT NULL,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
// Partial unique index rather than a table constraint, so a soft-deleted
// target can be re-created — same choice as yard_distances.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
WHERE deleted_at IS NULL;
`);
// The reports look targets up by period and metric, never by id.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_operations_targets_lookup
ON freight.operations_targets (metric, period_type, period_start)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.yard_distances
ADD COLUMN IF NOT EXISTS standard_hours numeric(6,2);
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS full_trainset_wagons integer;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS full_trainset_wagons;`,
);
await queryRunner.query(
`ALTER TABLE freight.yard_distances DROP COLUMN IF EXISTS standard_hours;`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_targets;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_standards;`);
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* A station's plan is per station AND per cargo type, not per station.
*
* The OCC monthly report plans "NagadMojo multimodal container 122,010 t" and
* "NagadMojo fertilizer 18,000 t" as separate lines against the same station,
* which the single `dimension_key` column cannot express: a station-keyed target
* would apply the whole station's plan to each of its cargo types.
*
* `cargo_category` is nullable, so `cargo_category` and `container_class`
* targets are unaffected — they leave it null and stay keyed on
* `dimension_key` alone. The uniqueness index moves to include it, since
* (station, category) is now the slot.
*/
export class OperationsTargetCargoCategory3590000000000 implements MigrationInterface {
name = "OperationsTargetCargoCategory3590000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.operations_targets
ADD COLUMN IF NOT EXISTS cargo_category varchar(60);
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
// COALESCE rather than a plain column list: a partial unique index treats
// NULLs as distinct, which would let the same category target be entered
// twice over.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
ON freight.operations_targets (
period_type, period_start, metric, dimension, dimension_key,
COALESCE(cargo_category, '')
)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.operations_targets DROP COLUMN IF EXISTS cargo_category;
`);
}
}

View File

@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Clearance charges are no longer one-of-each in a fixed order: GL Ethiopia
* may raise several MISCELLANEOUS charges, and either level may be created
* first. Port charges stay unique per booking (one port bill per shipment),
* enforced by a partial index instead of the old blanket (booking_id, type)
* uniqueness that also capped miscellaneous at one.
*/
export class MultipleMiscClearanceCharges3610000000000
implements MigrationInterface
{
name = 'MultipleMiscClearanceCharges3610000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_booking_type"
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_port"
ON "freight"."booking_clearance_charge" ("booking_id")
WHERE "type" = 'PORT_CHARGES' AND "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_charge_booking"
ON "freight"."booking_clearance_charge" ("booking_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// No-op on the uniqueness: restoring the blanket (booking_id, type) index
// would fail on any booking that has since raised a second miscellaneous
// charge, which is exactly what this migration set out to allow.
await queryRunner.query(`
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_port"
`);
}
}

View File

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

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-schedule wagon yard plan — where THIS departure expects each consist
* wagon to board, independent of where the wagon physically stands today.
*
* `wagons.current_yard_id` is one physical fact shared by every schedule of a
* built train, so a train standing in Mojo could not be sold from Dire for a
* departure next week. The plan is a sparse jsonb map `{ wagonId: yardId }`
* on the schedule: a wagon missing from the map boards from its physical yard.
* Booking capacity, fleet availability and wagon pinning all read the plan;
* dispatch refuses to leave until the plan and the physical yards agree.
*/
export class SchedulePlannedWagonYards3620000000000 implements MigrationInterface {
name = 'SchedulePlannedWagonYards3620000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards
`);
}
}

View File

@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The customer now approves a clearance charge before it becomes an invoice:
* GL describes the price, SENDs it, the customer ACCEPTs (invoice issued, charge
* locked) or REJECTs with a note (GL revises and re-sends). Charges that were
* already sent as invoices under the old flow are carried over as ACCEPTED so
* their invoices stay payable.
*/
export class ClearanceChargeCustomerDecision3630000000000
implements MigrationInterface
{
name = 'ClearanceChargeCustomerDecision3630000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."booking_clearance_charge"
ADD COLUMN IF NOT EXISTS "description" text,
ADD COLUMN IF NOT EXISTS "customer_note" text,
ADD COLUMN IF NOT EXISTS "customer_decided_at" timestamptz,
ADD COLUMN IF NOT EXISTS "customer_decided_by" uuid
`);
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'ACCEPTED'
WHERE "status" = 'SENT' AND "invoice_id" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'SENT'
WHERE "status" = 'ACCEPTED'
`);
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'BILLED'
WHERE "status" = 'REJECTED'
`);
await queryRunner.query(`
ALTER TABLE "freight"."booking_clearance_charge"
DROP COLUMN IF EXISTS "description",
DROP COLUMN IF EXISTS "customer_note",
DROP COLUMN IF EXISTS "customer_decided_at",
DROP COLUMN IF EXISTS "customer_decided_by"
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Ethiopian-side-only customs clearance:
*
* - service_types.includes_ethiopian_customs_only marks a customs service that
* EDR clears on the Ethiopian side only. Same clearance flow; only the fee
* differs — pricing looks up the ETHIOPIAN_CUSTOMS_CLEARANCE rate instead of
* CUSTOMS_CLEARANCE.
* - rates.trigger widens to 30 chars to fit the new trigger value.
* - CK_rates_yard_scope gains ETHIOPIAN_CUSTOMS_CLEARANCE in its yard-carrying
* branch: it is priced per origin → destination leg like customs clearance.
*/
export class EthiopianCustomsClearance3640000000000 implements MigrationInterface {
name = 'EthiopianCustomsClearance3640000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.service_types
ADD COLUMN IF NOT EXISTS includes_ethiopian_customs_only boolean NOT NULL DEFAULT false
`);
await queryRunner.query(
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(30)`,
);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
// Rows on the new trigger would not fit varchar(20) — drop them first.
await queryRunner.query(
`DELETE FROM freight.rates WHERE trigger = 'ETHIOPIAN_CUSTOMS_CLEARANCE'`,
);
await queryRunner.query(
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(20)`,
);
await queryRunner.query(
`ALTER TABLE freight.service_types DROP COLUMN IF EXISTS includes_ethiopian_customs_only`,
);
}
}

View File

@@ -47,6 +47,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/doc-requests": ["GL asks the customer for additional clearance documents", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],

View File

@@ -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.

View File

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

View File

@@ -0,0 +1,33 @@
import { adHocLabel } from './clearance.util';
/**
* The customer's typed document name travels to the API inside the multipart
* field code (`custom_<slug>_<n>`) — the only channel a part has — and comes
* back out here for GL's review grid. Mirror of `adHocSlug` in the portal's
* useClearanceFlow.
*/
const adHocSlug = (name: string) =>
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
const roundTrip = (typed: string) => adHocLabel(`custom_${adHocSlug(typed)}_17877000000000`);
describe('adHocLabel', () => {
it('recovers the name the customer typed', () => {
expect(roundTrip('Special permit')).toBe('Special permit');
expect(roundTrip('Fumigation Certificate')).toBe('Fumigation certificate');
expect(roundTrip('bank slip #2')).toBe('Bank slip 2');
});
it('returns null when there is no name to show, so callers use the filename', () => {
expect(roundTrip('')).toBeNull();
// Legacy uploads keyed `custom_<timestamp>_<n>` carry no name — without the
// digits guard this would surface "1755780000000" as the document label.
expect(adHocLabel('custom_1755780000000_0')).toBeNull();
expect(adHocLabel('commercial_invoice')).toBeNull();
});
});

View File

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

View File

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

View File

@@ -14,9 +14,11 @@ import { Invoice } from '../billing/entities/invoice.entity';
import { FilesService } from '../files/files.service';
import { BookingsService } from './bookings.service';
import { BookingsRepository } from './bookings.repository';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { Booking } from './entities/booking.entity';
import {
BookingClearanceCharge,
ClearanceChargeStatus,
ClearanceChargeType,
} from './entities/booking-clearance-charge.entity';
import { ClearanceEventService } from './clearance-event.service';
@@ -32,13 +34,22 @@ const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
MISCELLANEOUS: 'Miscellaneous charges',
};
/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */
export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet<ClearanceChargeStatus> =
new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */
export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean =>
status !== 'ACCEPTED' && status !== 'PAID';
/**
* Post-finalization clearance charges billed to the customer. Two levels per
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
* (amount + currency) and sends the invoice; once that invoice is paid GL
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
* through the portal gateway, other currencies through Finance's manual
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
* Post-finalization clearance charges billed to the customer: one port charge
* (document from GL Djibouti, priced by GL Ethiopia) and any number of
* miscellaneous charges. GL prices + describes a charge and SENDs it; the
* customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues
* the payable invoice and locks the charge. ETB invoices are paid through the
* portal gateway, other currencies through Finance's manual settlement
* worklist — both settle via `clearance_charge.invoice.paid`.
*/
@Injectable()
export class BookingClearanceChargeService {
@@ -51,6 +62,7 @@ export class BookingClearanceChargeService {
private readonly bookingsService: BookingsService,
private readonly bookingsRepository: BookingsRepository,
private readonly clearanceEvents: ClearanceEventService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private repo() {
@@ -104,6 +116,11 @@ export class BookingClearanceChargeService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
amount: c.amount != null ? Number(c.amount) : null,
currency: c.currency ?? null,
description: c.description ?? null,
customerNote: c.customerNote ?? null,
customerDecidedAt: c.customerDecidedAt
? c.customerDecidedAt.toISOString()
: null,
invoiceId: c.invoiceId ?? null,
invoiceNumber: c.invoiceId
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
@@ -121,6 +138,24 @@ export class BookingClearanceChargeService {
});
}
/** The customer's view: only charges GL has sent them. */
async listForCustomer(bookingId: string): Promise<Freight.ClearanceCharge[]> {
return (await this.list(bookingId)).filter((c) =>
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status),
);
}
private async findCharge(
bookingId: string,
chargeId: string,
): Promise<BookingClearanceCharge> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
return charge;
}
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
async uploadPortDocument(
bookingId: string,
@@ -180,22 +215,21 @@ export class BookingClearanceChargeService {
}
/**
* GL Ethiopia sets (or, on the customer's request, revises) amount +
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
* is immutable.
* GL Ethiopia sets (or, after a customer rejection, revises) amount +
* currency + description. Allowed until the customer accepts: an ACCEPTED
* charge already carries an invoice and a PAID one is settled.
*/
async billCharge(
bookingId: string,
chargeId: string,
input: { amount: number; currency: string },
input: { amount: number; currency: string; description?: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status === 'PAID') {
throw new ConflictException('A paid charge can no longer be changed.');
const charge = await this.findCharge(bookingId, chargeId);
if (!canStaffEditCharge(charge.status)) {
throw new ConflictException(
'The customer has accepted this charge — it can no longer be changed.',
);
}
if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.');
@@ -203,53 +237,117 @@ export class BookingClearanceChargeService {
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
if (charge.status === 'SENT' && charge.invoiceId) {
await this.billing.cancelInvoice(charge.invoiceId);
const description = (input.description ?? charge.description ?? '').trim();
if (charge.type === 'MISCELLANEOUS' && !description) {
throw new BadRequestException('Describe what this charge is for.');
}
const currency = input.currency.trim().toUpperCase();
const revised = charge.status === 'SENT' || charge.status === 'REJECTED';
// Back to draft: the customer's previous decision no longer applies.
await this.repo().update(charge.id, {
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
currency,
description: description || null,
status: 'BILLED',
invoiceId: null,
customerNote: null,
customerDecidedAt: null,
customerDecidedBy: null,
billedByStaffId: staffId,
billedAt: new Date(),
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_BILLED',
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
charge.type
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
].toLowerCase()}: ${input.amount} ${currency}${
description ? `${description}` : ''
}`,
actorId: staffId,
metadata: {
chargeType: charge.type,
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
revised: charge.status === 'SENT',
currency,
description: description || null,
revised,
},
});
return this.list(bookingId);
}
/** GL Ethiopia issues the payable invoice to the customer. */
/**
* GL Ethiopia proposes the priced charge to the customer. No invoice yet —
* that is issued when the customer accepts. Re-sending after a rejection
* goes through here too.
*/
async sendCharge(
bookingId: string,
chargeId: string,
staffId?: string,
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status !== 'BILLED') {
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
throw new ConflictException(
'Set the amount and currency before sending the charge to the customer.',
charge.status === 'DOC_UPLOADED'
? 'Set the amount and currency before sending the charge to the customer.'
: 'This charge has already been sent to the customer.',
);
}
const revised = charge.status === 'REJECTED';
const amount = Number(charge.amount);
const currency = charge.currency ?? 'ETB';
await this.repo().update(charge.id, {
status: 'SENT',
customerNote: null,
customerDecidedAt: null,
customerDecidedBy: null,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_SENT',
label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[
charge.type
].toLowerCase()} to the customer for approval: ${amount} ${currency}`,
actorId: staffId ?? null,
metadata: {
chargeType: charge.type,
amount,
currency,
description: charge.description ?? null,
revised,
},
});
const booking = await this.bookingsService.findById(bookingId);
this.notifier.clearanceChargeProposed(booking, {
label: CHARGE_LABEL[charge.type],
amount,
currency,
description: charge.description ?? null,
revised,
});
return this.list(bookingId);
}
/** Customer agrees to the price: the payable invoice is issued and the charge locks. */
async customerAccept(
bookingId: string,
chargeId: string,
userId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'SENT' && charge.status !== 'REJECTED') {
throw new ConflictException(
charge.status === 'ACCEPTED' || charge.status === 'PAID'
? 'This charge has already been accepted.'
: 'This charge is not awaiting your decision.',
);
}
const amount = Number(charge.amount);
const currency = charge.currency ?? 'ETB';
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.ClearanceCharge,
// The charge's own id, NOT the booking id — booking-scoped invoice
@@ -258,105 +356,156 @@ export class BookingClearanceChargeService {
type: charge.type,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: charge.currency ?? 'ETB',
currency,
lines: [
{
chargeType: charge.type,
description: `${CHARGE_LABEL[charge.type]}${booking.reference ?? bookingId}`,
amount: Number(charge.amount),
description: `${CHARGE_LABEL[charge.type]}${
booking.reference ?? bookingId
}${charge.description ? `: ${charge.description}` : ''}`,
amount,
},
],
});
await this.repo().update(charge.id, {
status: 'SENT',
status: 'ACCEPTED',
invoiceId: invoice.id,
customerNote: null,
customerDecidedAt: new Date(),
customerDecidedBy: userId,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_INVOICE_SENT',
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
actorId: staffId ?? null,
action: 'CHARGE_ACCEPTED',
label: `Customer accepted ${CHARGE_LABEL[
charge.type
].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`,
actorType: 'CUSTOMER',
actorId: userId,
metadata: {
chargeType: charge.type,
invoiceNumber: invoice.invoiceNumber,
amount: Number(charge.amount),
currency: charge.currency,
amount,
currency,
},
});
this.notifier.clearanceChargeInvoiceIssued(booking, {
label: CHARGE_LABEL[charge.type],
amount,
currency,
invoiceNumber: invoice.invoiceNumber,
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
`Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`,
);
return this.list(bookingId);
return this.listForCustomer(bookingId);
}
/** Customer declines the price with a reason; GL revises and re-sends. */
async customerReject(
bookingId: string,
chargeId: string,
note: string,
userId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'SENT') {
throw new ConflictException(
charge.status === 'ACCEPTED' || charge.status === 'PAID'
? 'This charge has already been accepted.'
: 'This charge is not awaiting your decision.',
);
}
if (!note?.trim()) {
throw new BadRequestException('Say why you are rejecting this charge.');
}
await this.repo().update(charge.id, {
status: 'REJECTED',
customerNote: note.trim(),
customerDecidedAt: new Date(),
customerDecidedBy: userId,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_REJECTED',
label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`,
actorType: 'CUSTOMER',
actorId: userId,
metadata: { chargeType: charge.type, note: note.trim() },
});
this.notifier.clearanceChargeRejectedToStaff(booking, {
label: CHARGE_LABEL[charge.type],
note: note.trim(),
});
return this.listForCustomer(bookingId);
}
/**
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
* currency). Second payment level: allowed only once the port charge is paid.
* GL Ethiopia creates a miscellaneous charge whole (document + amount +
* currency + what it is for). Lands as a BILLED draft; GL sends it next.
*/
async createMiscellaneous(
bookingId: string,
file: Express.Multer.File,
input: { amount: number; currency: string },
input: { amount: number; currency: string; description?: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking);
const port = await this.repo().findOne({
where: { bookingId, type: 'PORT_CHARGES' },
});
if (port?.status !== 'PAID') {
throw new ConflictException(
'Miscellaneous charges open after the port charge is paid.',
);
}
const existing = await this.repo().findOne({
where: { bookingId, type: 'MISCELLANEOUS' },
});
if (existing) {
throw new ConflictException(
'This booking already has a miscellaneous charge — revise it instead.',
);
}
// No ordering and no cap: a miscellaneous charge may be raised before,
// after or alongside the port charge, and a booking may carry several.
if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.');
}
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
const description = input.description?.trim() ?? '';
if (!description) {
throw new BadRequestException('Describe what this charge is for.');
}
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: CHARGE_FILE_CODE.MISCELLANEOUS,
file,
},
{ userId: staffId },
);
await this.repo().save(
// Save the row first so its id can key the document. A booking may carry
// several miscellaneous charges, and `upsertByCode` retires whatever sits
// under the same code — a shared code would silently delete the previous
// charge's document.
const charge = await this.repo().save(
this.repo().create({
bookingId,
type: 'MISCELLANEOUS',
status: 'BILLED',
fileRecordId: record.id,
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
description,
uploadedByStaffId: staffId,
uploadedAt: new Date(),
billedByStaffId: staffId,
billedAt: new Date(),
}),
);
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`,
file,
},
{ userId: staffId },
);
await this.repo().update(charge.id, { fileRecordId: record.id });
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_MISC_CREATED',
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}${description}`,
actorId: staffId,
metadata: {
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
description,
fileName: file.originalname,
},
});

View File

@@ -0,0 +1,22 @@
import {
CUSTOMER_VISIBLE_CHARGE_STATUSES,
canStaffEditCharge,
} from './booking-clearance-charge.service';
import { CLEARANCE_CHARGE_STATUSES } from './entities/booking-clearance-charge.entity';
describe('clearance charge status guards', () => {
it('locks the charge once the customer has accepted or paid', () => {
expect(canStaffEditCharge('ACCEPTED')).toBe(false);
expect(canStaffEditCharge('PAID')).toBe(false);
for (const s of ['DOC_UPLOADED', 'BILLED', 'SENT', 'REJECTED'] as const) {
expect(canStaffEditCharge(s)).toBe(true);
}
});
it('hides GL drafts from the customer and shows everything sent', () => {
const visible = CLEARANCE_CHARGE_STATUSES.filter((s) =>
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(s),
);
expect(visible).toEqual(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
});
});

View File

@@ -202,6 +202,17 @@ export class BookingLifecycleNotifierService {
}
/** A clearance document was queried and needs the customer to re-upload. */
/** GL asked the customer for additional clearance document(s). */
additionalDocsRequested(b: Booking, note: string): void {
const msg =
`Additional document(s) requested on booking ${b.reference}: ` +
`${note} Please upload them from the portal.`;
void this.notifyContact(b, msg, 'ADDITIONAL DOCUMENTS REQUESTED');
this.inApp(b, 'Additional documents requested', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
documentQueried(b: Booking, fileKey: string, note: string): void {
const msg =
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
@@ -410,6 +421,55 @@ export class BookingLifecycleNotifierService {
});
}
// ── Clearance charges (port + miscellaneous) ───────────────────────────────
/** GL proposed (or re-proposed) a clearance charge — the customer accepts or rejects it in the portal. */
clearanceChargeProposed(
b: Booking,
c: {
label: string;
amount: number;
currency: string;
description: string | null;
revised: boolean;
},
): void {
const msg =
`${c.revised ? 'Revised ' + c.label.toLowerCase() : c.label} of ${c.amount} ${c.currency}` +
`${c.description ? ` (${c.description})` : ''} on booking ${b.reference} ` +
`await your approval. Please accept or reject them in the portal.`;
void this.notifyContact(b, msg, c.revised ? 'CLEARANCE CHARGE REVISED' : 'CLEARANCE CHARGE SENT');
this.inApp(b, c.revised ? `${c.label} revised` : `${c.label} need your approval`, msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** The customer accepted a clearance charge — its invoice is now payable. */
clearanceChargeInvoiceIssued(
b: Booking,
c: { label: string; amount: number; currency: string; invoiceNumber: string },
): void {
const msg =
`Invoice ${c.invoiceNumber} for ${c.label.toLowerCase()} (${c.amount} ${c.currency}) ` +
`on booking ${b.reference} is ready. Please pay it from the portal.`;
void this.notifyContact(b, msg, 'CLEARANCE CHARGE INVOICE');
this.inApp(b, `${c.label} invoice issued`, msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** The customer rejected a clearance charge — GL Ethiopia revises and re-sends. */
clearanceChargeRejectedToStaff(b: Booking, c: { label: string; note: string }): void {
const msg =
`The customer rejected the ${c.label.toLowerCase()} on booking ${this.ref(b)}: ` +
`"${c.note}". Revise and re-send from the clearance page.`;
this.inAppStaff(b, `${c.label} rejected — ${this.ref(b)}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/clearance/${b.id}`,
});
}
/** GL confirmed the final-invoice payment slip. */
finalInvoicePaid(b: Booking): void {
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;

View File

@@ -0,0 +1,107 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
/** Invoice statuses a customer can still settle (mirrors the portal's PAYABLE_STATUSES). */
const PAYABLE_INVOICE_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
/** Booking statuses at which the freight invoice is actually due (mirrors BookingsService). */
const FREIGHT_PAYABLE_BOOKING_STATUSES = [
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'AWAITING_PAYMENT',
];
/**
* One row per outstanding item. `invoices.status` / `bookings.status` are
* Postgres enums, hence the ::text casts. `amount` is NULL for items that only need the
* customer's review (a proposed clearance charge, a draft final invoice) so
* they count but do not inflate "amount due".
*/
const SQL = `
-- Central invoices on the booking: freight (only while the booking is in a
-- payable status), wagon-cancellation fee, GL final invoice (+ its DRAFT,
-- which waits for the customer's approval).
SELECT i.source_id AS "bookingId", i.currency,
CASE WHEN i.status::text = 'DRAFT' THEN NULL ELSE i.balance_amount END AS amount
FROM freight.invoices i
JOIN freight.bookings b ON b.id::text = i.source_id AND b.deleted_at IS NULL
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'booking'
AND (
(i.status::text = ANY($2::text[]) AND i.balance_amount > 0
AND (i.type IN ('WAGON_CANCEL_FEE', 'GL_FINAL') OR b.status::text = ANY($3::text[])))
OR (i.type = 'GL_FINAL' AND i.status::text = 'DRAFT')
)
UNION ALL
-- Accepted clearance charges whose invoice is still unpaid.
SELECT c.booking_id::text, i.currency, i.balance_amount
FROM freight.invoices i
JOIN freight.booking_clearance_charge c ON c.id::text = i.source_id AND c.deleted_at IS NULL
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'clearance_charge'
AND i.status::text = ANY($2::text[]) AND i.balance_amount > 0
UNION ALL
-- Clearance charges waiting for the customer to accept or reject the price.
SELECT c.booking_id::text, c.currency, NULL::numeric
FROM freight.booking_clearance_charge c
JOIN freight.bookings b ON b.id = c.booking_id AND b.deleted_at IS NULL
WHERE b.company_id = $1 AND c.deleted_at IS NULL AND c.status = 'SENT'
UNION ALL
-- Duty / tax advised by customs, payment slip not uploaded yet.
SELECT m.booking_id::text, m.metadata->>'dutyCurrency',
NULLIF(m.metadata->>'dutyAmount', '')::numeric
FROM freight.clearance_milestones m
JOIN freight.bookings b ON b.id = m.booking_id AND b.deleted_at IS NULL
WHERE b.company_id = $1 AND m.deleted_at IS NULL AND m.status = 'COMPLETED'
AND (
(m.milestone_code = 'DUTY_TAXES_ADVISED' AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones p
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'DUTY_TAX_PAID'
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
OR
(m.milestone_code = 'SECOND_DUTY_ADVISED' AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones p
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'SECOND_DUTY_PAID'
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
)
`;
/**
* Everything a customer still has to act on, per booking, in one query. Drives
* the "Pay" badge on the home and booking-list rows; the booking's Payments tab
* composes the same items client-side from the per-booking endpoints.
*/
@Injectable()
export class BookingPayablesService {
constructor(private readonly dataSource: DataSource) {}
async summarizeForCompany(
companyId: string,
): Promise<Freight.BookingPayableSummary[]> {
const rows: Array<{
bookingId: string;
currency: string | null;
amount: string | null;
}> = await this.dataSource.query(SQL, [
companyId,
PAYABLE_INVOICE_STATUSES,
FREIGHT_PAYABLE_BOOKING_STATUSES,
]);
const byBooking = new Map<string, Freight.BookingPayableSummary>();
for (const r of rows) {
const s = byBooking.get(r.bookingId) ?? {
bookingId: r.bookingId,
count: 0,
totals: [],
};
s.count += 1;
const amount = Number(r.amount ?? 0);
if (r.currency && amount > 0) {
const t = s.totals.find((x) => x.currency === r.currency);
if (t) t.amount += amount;
else s.totals.push({ currency: r.currency, amount });
}
byBooking.set(r.bookingId, s);
}
return [...byBooking.values()];
}
}

View File

@@ -57,6 +57,7 @@ describe('BookingPricingService — domestic corridor', () => {
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
});
@@ -333,6 +334,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking
: [],
}),
} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
const containerBooking = (overrides: Record<string, unknown> = {}) =>
@@ -388,6 +390,30 @@ describe('BookingPricingService — customs clearance fee billed on the booking
expect(line!.amount).toBe(200);
});
it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => {
const ethiopianFee = {
...containerFee20,
id: 'rate-et-20',
rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
rateValue: 40,
} as Rate;
// No serviceType relation on the booking (like the GL/portal shipment
// preview) — the flag must be resolved from serviceTypeId.
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
(service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = {
findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }),
};
const result = await service.computePriceForBooking(
containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }),
);
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
expect(line).toBeDefined();
expect(line!.amount).toBe(160);
expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false);
});
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
const service = makeService({ liveRates: [bulkFeePerTon] });
const result = await service.computePriceForBooking(containerBooking());
@@ -553,6 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => {
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
}),
} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
@@ -683,6 +710,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ findById: jest.fn() } as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
const booking = (

View File

@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
@@ -84,6 +85,7 @@ export class BookingPricingService {
private readonly exchangeService: ExchangeService,
private readonly containerValidationService: ContainerValidationService,
private readonly cargoTypesService: CargoTypesService,
private readonly serviceTypesService: ServiceTypesService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -1060,9 +1062,27 @@ export class BookingPricingService {
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
// An Ethiopian-side-only customs service prices off its own rate; the
// contract froze its snapshots under the matching code prefix. Resolved by
// id when the relation isn't loaded — the GL / portal shipment previews
// price a transient booking object, and a missing relation must not
// silently quote the standard fee the created booking is then billed
// differently for.
const serviceType =
booking.serviceType ??
(booking.serviceTypeId
? await this.serviceTypesService.findById(booking.serviceTypeId).catch(() => null)
: null);
const customsType = serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
? 'Ethiopian customs clearance service'
: 'Customs clearance service';
const onLeg = liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.rateType === customsType &&
r.currency === 'USD' &&
r.tradeDirection === booking.tradeDirection &&
r.originYardId === booking.originYardId &&
@@ -1070,20 +1090,20 @@ export class BookingPricingService {
);
const missingRateMessage = (scope: string): string =>
`No customs clearance service fee is configured for ${scope} on this ` +
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
`origin → destination. Ask EDR to configure the ${customsType} rate for this route.`;
if (booking.freightType === 'CONTAINER') {
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
const hasPerSizeSnapshot =
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
frozenRates?.has(`${customsType}_20FT`) ||
frozenRates?.has(`${customsType}_40FT`);
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
if (legacyFlat && !hasPerSizeSnapshot) {
const amount = Number(legacyFlat.unitPrice);
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service',
code: customsType,
description: customsLabel,
amount,
unitAmount: amount,
unit: 'FLAT',
@@ -1106,7 +1126,7 @@ export class BookingPricingService {
// unknown type — falls through to the live per-type lookup below
}
const frozen = sizeFt
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
: null;
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
if (!frozen && !live) {
@@ -1124,8 +1144,8 @@ export class BookingPricingService {
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (!(amount > 0)) continue;
lineItems.push({
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
code: sizeFt ? `${customsType}_${sizeFt}FT` : customsType,
description: `${customsLabel}${sizeFt ? ` (${sizeFt}ft)` : ''}`,
amount,
unitAmount,
unit,
@@ -1141,7 +1161,7 @@ export class BookingPricingService {
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
// Live lookup: the rate scoped to the booking's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback.
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
const live =
(booking.cargoTypeId
? onLeg.find(
@@ -1172,8 +1192,8 @@ export class BookingPricingService {
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service (bulk)',
code: customsType,
description: `${customsLabel} (bulk)`,
amount,
unitAmount,
unit,

View File

@@ -28,6 +28,7 @@ import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import {
adHocLabel,
clearanceCodesForBooking,
clearanceDocumentsOpen,
} from './clearance.util';
@@ -643,6 +644,12 @@ export class BookingTransitionService {
}>;
allApproved: boolean;
documentsOpen: boolean;
docRequests: Array<{
id: string;
note: string;
byName: string | null;
at: string;
}>;
phase?: string | null;
milestones?: unknown[];
nextAction?: unknown;
@@ -674,9 +681,14 @@ export class BookingTransitionService {
bookingId,
"CHANGES_REQUESTED",
);
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
"ADDITIONAL_DOC_REQUEST",
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId),
...docRequestNotes.map((n) => n.authorId),
]);
const documents: Awaited<
@@ -731,7 +743,9 @@ export class BookingTransitionService {
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
// What the customer called it, falling back to the filename for rows
// uploaded before the name was carried through.
label: f.title || adHocLabel(f.code) || f.name,
required: false,
uploadedBy: "customer",
settingCode: "custom",
@@ -763,9 +777,51 @@ export class BookingTransitionService {
documents,
allApproved,
documentsOpen: clearanceDocumentsOpen(booking),
docRequests: docRequestNotes.map((n) => ({
id: n.id,
note: n.note,
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
at: n.createdAt.toISOString(),
})),
};
}
/**
* GL asks the customer for additional clearance document(s). Stored as a
* review-note thread shown on both the GL clearance page and the customer's
* portal; the customer answers with an ad-hoc upload. Allowed for as long as
* documents are open (until the shipment is paid).
*/
async requestAdditionalDocuments(
bookingId: string,
note: string,
staffId: string,
): Promise<void> {
const booking = await this.bookingsService.findById(bookingId);
if (!clearanceDocumentsOpen(booking)) {
throw new ConflictException(
`Clearance documents are closed for this booking (status "${booking.status}").`,
);
}
if (!note?.trim()) {
throw new BadRequestException("Describe the document(s) you need.");
}
await this.bookingsRepository.createReviewNote(
bookingId,
note.trim(),
"ADDITIONAL_DOC_REQUEST",
staffId,
);
await this.clearanceEvents.record({
bookingId,
action: "ADDITIONAL_DOCS_REQUESTED",
label: "Requested additional document(s) from the customer",
actorId: staffId,
metadata: { note: note.trim() },
});
this.notifier.additionalDocsRequested(booking, note.trim());
}
/**
* True when every REQUIRED field of the booking's customer-input clearance set
* has an APPROVED review row. The 100% gate before clearance can be finalized.
@@ -837,6 +893,10 @@ export class BookingTransitionService {
resource: "bookings",
code: file.fieldname,
file,
// Ad-hoc uploads carry the name the customer typed (fieldname
// `custom_<label>_<n>`); it is what GL sees in the review grid instead
// of a raw filename like "scan_003.pdf".
title: adHocLabel(file.fieldname),
});
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith("custom_")

View File

@@ -40,8 +40,15 @@ import {
import type { Response } from "express";
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingPayablesService } from './booking-payables.service';
import { ClearanceEventService } from './clearance-event.service';
import {
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';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
@@ -175,7 +182,9 @@ export class BookingsController {
private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService,
private readonly bookingPayablesService: BookingPayablesService,
private readonly clearanceEventService: ClearanceEventService,
private readonly additionalChargeService: AdditionalChargeService,
) {}
@Post()
@@ -312,6 +321,21 @@ export class BookingsController {
return this.bookingsService.getListSummary(filter);
}
@Get("my-payables")
@PortalCustomer()
@ApiOperation({
summary:
"Outstanding customer payments per booking — invoices to pay, prices to accept, duty slips to upload",
})
async findMyPayables(@CurrentUser() user: AuthUserPayload) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(
resolveAuthUserId(user),
);
return companyId
? this.bookingPayablesService.summarizeForCompany(companyId)
: [];
}
@Get("my")
@PortalCustomer()
@ApiOperation({
@@ -1083,6 +1107,25 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/doc-requests")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL asks the customer for additional clearance document(s) — shown on the portal with author and time",
})
async requestAdditionalDocuments(
@Param("id", ParseUUIDPipe) id: string,
@Body("note") note: string,
@CurrentUser() user: AuthUserPayload,
) {
await this.transitionService.requestAdditionalDocuments(
id,
note,
resolveAuthUserId(user),
);
return { success: true };
}
@Get(":id/clearance/history")
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions,
@@ -1099,15 +1142,63 @@ export class BookingsController {
// ── Clearance charges (post-finalization customer billing) ────────────────
@Get(":id/clearance/charges")
@BookingStaff([
@MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary: "Clearance charges billed to the customer (port + miscellaneous)",
summary:
"Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them",
})
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceChargeService.list(id);
async getClearanceCharges(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const isStaff =
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
if (isStaff) return this.clearanceChargeService.list(id);
const booking = await this.bookingsService.findById(id);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
return this.clearanceChargeService.listForCustomer(id);
}
@Post(":id/clearance/charges/:chargeId/accept")
@PortalCustomer()
@ApiOperation({
summary:
"Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge",
})
acceptClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.customerAccept(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/:chargeId/reject")
@PortalCustomer()
@ApiOperation({
summary:
"Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends",
})
rejectClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: RejectClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.customerReject(
id,
chargeId,
dto.note,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/port-document")
@@ -1134,7 +1225,7 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
"GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)",
})
billClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@@ -1154,7 +1245,7 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
"GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)",
})
sendClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@@ -1174,7 +1265,7 @@ export class BookingsController {
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
"GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send",
})
createMiscellaneousCharge(
@Param("id", ParseUUIDPipe) id: string,
@@ -1191,6 +1282,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())

View File

@@ -38,7 +38,11 @@ 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';
import { ClearanceEventService } from './clearance-event.service';
import { BookingContainer } from './entities/booking-container.entity';
@@ -82,6 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ConsolidationApproval,
BookingClearanceCharge,
BookingClearanceEvent,
AdditionalCharge,
]),
BillingModule,
DocumentsModule,
@@ -118,7 +123,10 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
BookingPayablesService,
ClearanceEventService,
AdditionalChargeRepository,
AdditionalChargeService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,

View File

@@ -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') {

View File

@@ -146,3 +146,20 @@ export function clearanceDocumentsOpen(booking: Booking): boolean {
if (booking.paymentStatus === 'PAID') return false;
return true;
}
/**
* The label the customer typed for an ad-hoc clearance document, recovered from
* its file code. The portal encodes it as `custom_<slug>_<n>`; a plain
* `custom_<n>` (older uploads, or an unnamed row) yields null so callers fall
* back to the filename.
*/
export function adHocLabel(fileKey: string): string | null {
const m = /^custom_(.+)_\d+$/.exec(fileKey);
if (!m) return null;
// Legacy keys are `custom_<timestamp>_<n>`, which this regex reads as a label
// of digits. Those carry no name — reject them so the caller falls back to
// the filename instead of showing "1755780000000".
if (/^\d+$/.test(m[1])) return null;
const label = m[1].replace(/-/g, ' ').trim();
return label ? label.charAt(0).toUpperCase() + label.slice(1) : null;
}

View File

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

View File

@@ -1,6 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
import {
IsNumber,
IsOptional,
IsPositive,
IsString,
Length,
MaxLength,
} from 'class-validator';
export class BillClearanceChargeDto {
@ApiProperty({ example: 12500.5 })
@@ -13,4 +20,18 @@ export class BillClearanceChargeDto {
@IsString()
@Length(3, 8)
currency!: string;
/** What the price is for. Required for miscellaneous charges (checked in the service). */
@ApiPropertyOptional({ example: 'Container cleaning and weighbridge fee' })
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
}
export class RejectClearanceChargeDto {
@ApiProperty({ example: 'The weighbridge fee was already paid at the port.' })
@IsString()
@Length(1, 1000)
note!: string;
}

View File

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

View File

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

View File

@@ -9,20 +9,24 @@ export const CLEARANCE_CHARGE_STATUSES = [
'DOC_UPLOADED',
'BILLED',
'SENT',
'REJECTED',
'ACCEPTED',
'PAID',
] as const;
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
/**
* Post-finalization clearance charge billed to the customer — at most one
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid`
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only
* after the port charge is paid.
* Clearance charge billed to the customer. One PORT_CHARGES row per booking
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
* sets amount + currency + description (BILLED) and proposes it to the
* customer (SENT). The customer either REJECTS with a note (GL revises and
* re-sends) or ACCEPTS, which issues the invoice and locks the charge; the
* billing `clearance_charge.invoice.paid` event marks it PAID. The two levels
* are independent — either may be raised first.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
@Index(['bookingId', 'type'], { unique: true })
@Index(['bookingId'])
export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@@ -47,6 +51,20 @@ export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
currency?: string | null;
/** What the price is for, written by GL. */
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
/** Customer's reason when REJECTED; cleared when GL revises. */
@Column({ name: 'customer_note', type: 'text', nullable: true })
customerNote?: string | null;
@Column({ name: 'customer_decided_at', type: 'timestamptz', nullable: true })
customerDecidedAt?: Date | null;
@Column({ name: 'customer_decided_by', type: 'uuid', nullable: true })
customerDecidedBy?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;

View File

@@ -11,6 +11,12 @@ export const REVIEW_NOTE_TYPES = [
* (price/files). One row per round — the draft/change-request loop can repeat.
*/
'DRAFT_DECL_CHANGE_REQUEST',
/**
* GL asked the customer for additional clearance document(s). Shown as a
* thread on both the GL clearance page and the customer's portal — the
* customer answers by uploading an ad-hoc document.
*/
'ADDITIONAL_DOC_REQUEST',
] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];

View File

@@ -23,7 +23,7 @@ import {
type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { adHocLabel, clearanceCodesForBooking } from '../bookings/clearance.util';
import { assertDoCollectionDates } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
@@ -66,6 +66,12 @@ export interface BookingClearanceView {
}>;
allApproved: boolean;
documentsOpen: boolean;
docRequests: Array<{
id: string;
note: string;
byName: string | null;
at: string;
}>;
phase?: string | null;
milestones?: Array<{
id: string;
@@ -206,9 +212,14 @@ export class BookingClearanceService {
bookingId,
'CHANGES_REQUESTED',
);
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
'ADDITIONAL_DOC_REQUEST',
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId),
...docRequestNotes.map((n) => n.authorId),
]);
const documents: BookingClearanceView['documents'] = [];
@@ -257,7 +268,9 @@ export class BookingClearanceService {
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
// What the customer called it, falling back to the filename for rows
// uploaded before the name was carried through.
label: f.title || adHocLabel(f.code) || f.name,
required: false,
uploadedBy: 'customer',
settingCode: 'custom',
@@ -334,7 +347,8 @@ export class BookingClearanceService {
} catch {
train = null;
}
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
// Removed from the clearance flow — see gl-operations.service.
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
@@ -357,6 +371,12 @@ export class BookingClearanceService {
documents,
allApproved,
documentsOpen: clearanceDocumentsOpen(booking),
docRequests: docRequestNotes.map((n) => ({
id: n.id,
note: n.note,
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
at: n.createdAt.toISOString(),
})),
phase,
milestones: milestones.map((m) => ({
id: m.id,

View File

@@ -330,7 +330,9 @@ export class ContractClearanceService {
let train: ClearanceTrainState | null = null;
let bookingMilestones: ClearanceMilestone[] = [];
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
// Removed from the clearance flow — see gl-operations.service. Kept in the
// payload (always null) so existing consumers keep type-checking.
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
if (cycle?.bookingId) {
try {
train = await this.glOperationsService.trainState(cycle.bookingId);
@@ -340,7 +342,6 @@ export class ContractClearanceService {
bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);

View File

@@ -376,12 +376,21 @@ export class ContractPricingService {
// own container-type rate), bulk contracts freeze the route's bulk fee.
// A customs contract may not proceed without the fee(s) configured.
if (contract.customsClearingEnabled) {
// An Ethiopian-side-only customs service prices off its own rate; the
// snapshot codes carry the same prefix so booking pricing finds them.
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
? 'Ethiopian customs clearance service'
: 'Customs clearance service';
// Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const onLeg = route
? liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.rateType === customsType &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
@@ -406,14 +415,14 @@ export class ContractPricingService {
);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
);
}
lineItems.push({
// Distinct code per size so the frozen snapshots don't collide —
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
label: `Customs clearance service (${size})`,
// booking pricing looks each size up by <customsType>_<FT>FT.
code: `${customsType}_${sizeFt}FT`,
label: `${customsLabel} (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
@@ -432,12 +441,12 @@ export class ContractPricingService {
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
);
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
code: customsType,
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
cargoTypeCode: scope?.cargoType?.code ?? null,

View File

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

View File

@@ -124,11 +124,14 @@ export class ContractsService {
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
/** The service type a contract is sold under (null when the id is unknown). */
private resolveServiceType(serviceTypeId: string): Promise<ServiceType | null> {
return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } });
}
/** Whether a service type bundles customs clearance. */
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
const serviceType = await this.resolveServiceType(serviceTypeId);
return serviceType?.includesCustoms ?? false;
}
@@ -324,7 +327,8 @@ export class ContractsService {
}
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const serviceType = await this.resolveServiceType(dto.serviceTypeId);
const includesCustoms = serviceType?.includesCustoms ?? false;
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
@@ -344,6 +348,8 @@ export class ContractsService {
freightType: dto.freightType,
paymentCurrency: 'USD',
customsClearingEnabled: includesCustoms,
// Decides which customs fee the probe looks up (Ethiopian-only vs full).
serviceType,
isHazardous: dto.isHazardous ?? false,
isReefer: dto.isReefer ?? false,
equipmentReturn: dto.equipmentReturn ?? null,

View File

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

View File

@@ -779,7 +779,15 @@ export class GlOperationsService {
};
}
/** Final-invoice state joined with its document + slip files, for clearance views. */
/**
* Final-invoice state joined with its document + slip files.
*
* RETIRED from the clearance flow: the post-offload GL Djibouti invoice is no
* longer part of the export process, is not rendered on either desk or the
* portal, and never gated anything downstream. The endpoints and this reader
* stay so already-issued invoices remain resolvable; nothing calls it from a
* clearance view any more.
*/
async finalInvoiceSummary(
bookingId: string,
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
],

View File

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

View File

@@ -0,0 +1,223 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { Contract } from '../../contracts/entities/contract.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { Train } from '../../trains/entities/train.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
/**
* Domain semantics shared with `reports/definitions/bookings-list.report.ts`.
* Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
* holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
* overrides `total_amount`. Getting either wrong misreports money or weight.
*/
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const STATUS_OPTIONS = [
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT',
'ARRIVED', 'DELIVERED', 'COMPLETED',
].map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
export const bookingsDataset: ExportDataset = {
key: 'bookings',
title: 'Bookings',
description: 'Every booking, with customer, route, cargo, contract and payment detail',
group: 'Commercial',
permission: FREIGHT_PERMS.bookings.view,
base: { entity: Booking, alias: 'b' },
// Every join is a LEFT join (see ExportJoin) — ticking a field must never
// change which rows come back.
joins: [
{ alias: 'c', entity: Company, on: 'c.id = b.company_id' },
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = b.company_profile_id' },
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = b.shipping_line_company_id' },
{ alias: 'o', entity: Yard, on: 'o.id = b.origin_yard_id' },
{ alias: 'd', entity: Yard, on: 'd.id = b.destination_yard_id' },
{ alias: 'cty', entity: CargoType, on: 'cty.id = b.cargo_type_id' },
{ alias: 'st', entity: ServiceType, on: 'st.id = b.service_type_id' },
{ alias: 'sl', entity: ShippingLine, on: 'sl.id = b.shipping_line_id' },
{ alias: 'ct', entity: Contract, on: 'ct.id = b.contract_id' },
{ alias: 't', entity: Train, on: 't.id = b.train_id' },
// Transitive: the contract's own customer, reachable only once `ct` is in.
{ alias: 'ctc', entity: Company, on: 'ctc.id = ct.company_id', requires: ['ct'] },
],
// `search` matches the customer name, so `c` is always present — which is
// also why the count query joins it.
alwaysJoin: ['c'],
groups: [
{ id: 'booking', label: 'Booking' },
{ id: 'customer', label: 'Customer' },
{ id: 'route', label: 'Route' },
{ id: 'cargo', label: 'Cargo' },
{ id: 'payment', label: 'Payment' },
{ id: 'scheduling', label: 'Scheduling' },
{ id: 'contract', label: 'Contract' },
{ id: 'firstMile', label: 'First mile' },
{ id: 'lastMile', label: 'Last mile' },
{ id: 'clearance', label: 'Clearance' },
],
fields: [
// ---- Booking -------------------------------------------------------
{ key: 'reference', label: 'Reference', type: 'string', group: 'booking', default: true, select: 'b.reference', sortExpr: 'b.reference' },
{ key: 'status', label: 'Status', type: 'string', group: 'booking', default: true, select: 'b.status', sortExpr: 'b.status' },
{ key: 'bookingType', label: 'Booking type', type: 'string', group: 'booking', select: 'b.booking_type' },
{ key: 'contractKind', label: 'Contract kind', type: 'string', group: 'booking', select: 'b.contract_kind' },
{ key: 'createdAt', label: 'Created', type: 'datetime', group: 'booking', default: true, select: `to_char(b.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.created_at' },
{ key: 'updatedAt', label: 'Updated', type: 'datetime', group: 'booking', select: `to_char(b.updated_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.updated_at' },
{ key: 'expiresAt', label: 'Expires', type: 'date', group: 'booking', select: `to_char(b.expires_at, 'YYYY-MM-DD')` },
{ key: 'createdByRole', label: 'Created by role', type: 'string', group: 'booking', select: 'b.created_by_role' },
{ key: 'isSplit', label: 'Split booking', type: 'boolean', group: 'booking', select: 'b.is_split' },
{ key: 'priorityScore', label: 'Priority score', type: 'number', group: 'booking', select: 'b.priority_score', sortExpr: 'b.priority_score' },
{ key: 'versionNumber', label: 'Version', type: 'number', group: 'booking', select: 'b.version_number' },
{ key: 'pnrCode', label: 'PNR code', type: 'string', group: 'booking', select: 'b.pnr_code' },
// ---- Customer (the "more than the UI shows" payload) ----------------
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
{ key: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' },
{ key: 'customerKind', label: 'Customer kind', type: 'string', group: 'customer', requires: ['c'], select: 'c.kind' },
{ key: 'customerStatus', label: 'Customer status', type: 'string', group: 'customer', requires: ['c'], select: 'c.status' },
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
{ key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' },
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
{ key: 'customerContact', label: 'Contact person', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_name' },
{ key: 'customerContactPhone', label: 'Contact phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_phone' },
{ key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' },
{ key: 'customerCountry', label: 'Customer country', type: 'string', group: 'customer', requires: ['c'], select: 'c.country' },
{ key: 'customerRegion', label: 'Customer region', type: 'string', group: 'customer', requires: ['c'], select: 'c.region' },
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
{ key: 'customerProfileType', label: 'Profile type', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.type' },
{ key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'b.is_government' },
{ key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'b.government_institution' },
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' },
// ---- Route ----------------------------------------------------------
{ key: 'origin', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['o'], select: 'o.label' },
{ key: 'originCode', label: 'Origin code', type: 'string', group: 'route', requires: ['o'], select: 'o.code' },
{ key: 'destination', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['d'], select: 'd.label' },
{ key: 'destinationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['d'], select: 'd.code' },
{ key: 'tradeDirection', label: 'Direction', type: 'string', group: 'route', default: true, select: 'b.trade_direction', sortExpr: 'b.trade_direction' },
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' },
// ---- Cargo -----------------------------------------------------------
{ key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
{ key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' },
{ key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' },
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' },
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' },
{ key: 'shippingLine', label: 'Shipping line', type: 'string', group: 'cargo', requires: ['sl'], select: 'sl.label' },
{
// One-to-many, so it aggregates in a correlated subquery rather than a
// join — a join here would multiply rows and break the count contract.
key: 'containerNumbers', label: 'Container numbers', type: 'string', group: 'cargo',
select: `(SELECT string_agg(bc.container_number, ' | ' ORDER BY bc.container_number)
FROM freight.booking_container bc
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL)`,
},
// ---- Payment ----------------------------------------------------------
{ key: 'amount', label: 'Amount', type: 'money', group: 'payment', default: true, select: `ROUND(${REVENUE}, 2)::float8`, sortExpr: REVENUE },
{ key: 'totalAmount', label: 'Total amount (pre-adjustment)', type: 'money', group: 'payment', select: 'b.total_amount::float8' },
{ key: 'adjustedTotalAmount', label: 'Adjusted total', type: 'money', group: 'payment', select: 'b.adjusted_total_amount::float8' },
{ key: 'adjustmentReason', label: 'Adjustment reason', type: 'string', group: 'payment', select: 'b.adjustment_reason' },
{ key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', default: true, select: 'b.payment_status', sortExpr: 'b.payment_status' },
{ key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'payment', select: 'b.payment_currency' },
{ key: 'paymentDeadline', label: 'Payment deadline', type: 'datetime', group: 'payment', select: `to_char(b.payment_deadline, 'YYYY-MM-DD HH24:MI')` },
// ---- Scheduling --------------------------------------------------------
{ key: 'scheduledDate', label: 'Scheduled date', type: 'date', group: 'scheduling', default: true, select: `to_char(b.scheduled_date, 'YYYY-MM-DD')`, sortExpr: 'b.scheduled_date' },
{ key: 'schedulingStatus', label: 'Scheduling status', type: 'string', group: 'scheduling', select: 'b.scheduling_status' },
{ key: 'wagonsRequired', label: 'Wagons required', type: 'number', group: 'scheduling', select: 'b.wagons_required' },
{ key: 'trainCode', label: 'Train', type: 'string', group: 'scheduling', requires: ['t'], select: 't.code' },
{ key: 'loadedAt', label: 'Loaded at', type: 'datetime', group: 'scheduling', select: `to_char(b.loaded_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'arrivedAt', label: 'Arrived at', type: 'datetime', group: 'scheduling', select: `to_char(b.arrived_at, 'YYYY-MM-DD HH24:MI')` },
// ---- Contract ----------------------------------------------------------
{ key: 'contractReference', label: 'Contract reference', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.reference' },
{ key: 'contractStatus', label: 'Contract status', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.status' },
{ key: 'contractCustomer', label: 'Contract customer', type: 'string', group: 'contract', requires: ['ctc'], select: 'ctc.name' },
{ key: 'contractType', label: 'Contract type', type: 'string', group: 'contract', select: 'b.contract_type' },
{ key: 'contractValidFrom', label: 'Contract valid from', type: 'date', group: 'contract', select: `to_char(b.contract_valid_from, 'YYYY-MM-DD')` },
{ key: 'contractValidUntil', label: 'Contract valid until', type: 'date', group: 'contract', select: `to_char(b.contract_valid_until, 'YYYY-MM-DD')` },
{ key: 'fullyExecutedAt', label: 'Fully executed at', type: 'datetime', group: 'contract', select: `to_char(b.fully_executed_at, 'YYYY-MM-DD HH24:MI')` },
// ---- First / last mile ---------------------------------------------------
{ key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'firstMile', select: 'b.first_mile_pickup_address' },
{ key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'lastMile', select: 'b.last_mile_delivery_address' },
{ key: 'customerTruckPlate', label: 'Customer truck plate', type: 'string', group: 'lastMile', select: 'b.customer_truck_plate_number' },
{ key: 'customerTruckDriver', label: 'Customer truck driver', type: 'string', group: 'lastMile', select: 'b.customer_truck_driver_name' },
{ key: 'exportHandoverMode', label: 'Handover mode', type: 'string', group: 'lastMile', select: 'b.export_handover_mode' },
// ---- Clearance -------------------------------------------------------------
{ key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'b.customs_clearing_enabled' },
{ key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'b.customs_clearing_agent' },
{ key: 'clearancePhase', label: 'Clearance phase', type: 'string', group: 'clearance', select: 'b.clearance_current_phase' },
{ key: 'dutyRequired', label: 'Duty required', type: 'boolean', group: 'clearance', select: 'b.duty_required' },
{ key: 'vesselArrivalDate', label: 'Vessel arrival', type: 'date', group: 'clearance', select: `to_char(b.vessel_arrival_date, 'YYYY-MM-DD')` },
{ key: 'doCollectedDate', label: 'DO collected', type: 'date', group: 'clearance', select: `to_char(b.do_collected_date, 'YYYY-MM-DD')` },
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
],
filters: [
{ key: 'created', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
{
key: 'tradeDirection', label: 'Direction', type: 'select',
options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })),
},
{
key: 'freightType', label: 'Freight type', type: 'select',
options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })),
},
{ key: 'paymentStatus', label: 'Payment status', type: 'select', options: [
{ value: 'PENDING', label: 'Pending' },
{ value: 'PNR_GENERATED', label: 'PNR generated' },
{ value: 'VERIFICATION_IN_PROGRESS', label: 'Verification in progress' },
{ value: 'PAID', label: 'Paid' },
{ value: 'FAILED', label: 'Failed' },
] },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
defaultSort: { key: 'createdAt', dir: 'DESC' },
scope(ctx, qb) {
const { params, directions } = ctx;
// andWhere, not where: `where()` resets any condition already on the
// builder, so scope() would silently drop anything a caller added first.
qb.andWhere('b.deleted_at IS NULL');
if (params.createdFrom) qb.andWhere('b.created_at >= :createdFrom', { createdFrom: params.createdFrom });
if (params.createdTo) qb.andWhere('b.created_at < :createdTo', { createdTo: params.createdTo });
const statuses = params.statuses as string[] | null;
if (statuses?.length) qb.andWhere('b.status IN (:...statuses)', { statuses });
if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
if (params.search) {
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
}
// Trade-direction ACL. Without this the export returns rows the user's own
// list page would not show them.
applyDirectionScope(qb, 'b.trade_direction', directions);
},
};

View File

@@ -0,0 +1,161 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { Contract } from '../../contracts/entities/contract.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
export const contractsDataset: ExportDataset = {
key: 'contracts',
title: 'Contracts',
description: 'Contracts with customer, terms, routes, approval and clearance detail',
group: 'Commercial',
permission: FREIGHT_PERMS.contracts.view,
base: { entity: Contract, alias: 'ct' },
joins: [
{ alias: 'c', entity: Company, on: 'c.id = ct.company_id' },
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = ct.company_profile_id' },
{ alias: 'st', entity: ServiceType, on: 'st.id = ct.service_type_id' },
{ alias: 'ren', entity: Contract, on: 'ren.id = ct.renewal_of_id' },
],
// `search` matches the customer name.
alwaysJoin: ['c'],
groups: [
{ id: 'contract', label: 'Contract' },
{ id: 'customer', label: 'Customer' },
{ id: 'terms', label: 'Terms' },
{ id: 'routes', label: 'Routes & cargo' },
{ id: 'approval', label: 'Approval' },
{ id: 'clearance', label: 'Clearance' },
],
fields: [
{ key: 'reference', label: 'Reference', type: 'string', group: 'contract', default: true, select: 'ct.reference', sortExpr: 'ct.reference' },
{ key: 'status', label: 'Status', type: 'string', group: 'contract', default: true, select: 'ct.status', sortExpr: 'ct.status' },
{ key: 'contractKind', label: 'Kind', type: 'string', group: 'contract', default: true, select: 'ct.contract_kind' },
{ key: 'contractType', label: 'Type', type: 'string', group: 'contract', select: 'ct.contract_type' },
{ key: 'createdAt', label: 'Created', type: 'date', group: 'contract', default: true, select: `to_char(ct.created_at, 'YYYY-MM-DD')`, sortExpr: 'ct.created_at' },
{ key: 'submittedAt', label: 'Submitted', type: 'datetime', group: 'contract', select: `to_char(ct.submitted_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'versionNumber', label: 'Version', type: 'number', group: 'contract', select: 'ct.version_number' },
{ key: 'renewalOf', label: 'Renewal of', type: 'string', group: 'contract', requires: ['ren'], select: 'ren.reference' },
{ key: 'statusBeforeSuspension', label: 'Status before suspension', type: 'string', group: 'contract', select: 'ct.status_before_suspension' },
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
{ key: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' },
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
{ key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'ct.is_government' },
{ key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'ct.government_institution' },
{ key: 'tradeDirection', label: 'Direction', type: 'string', group: 'terms', default: true, select: 'ct.trade_direction', sortExpr: 'ct.trade_direction' },
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'terms', default: true, select: 'ct.freight_type' },
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'terms', requires: ['st'], select: 'st.service_name' },
{ key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'terms', select: 'ct.payment_currency' },
{ key: 'validFrom', label: 'Valid from', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, sortExpr: 'ct.contract_valid_from' },
{ key: 'validUntil', label: 'Valid until', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_until, 'YYYY-MM-DD')` },
{ key: 'validityDays', label: 'Validity (days)', type: 'number', group: 'terms', select: 'ct.contract_validity_days' },
{ key: 'expiresAt', label: 'Expires', type: 'date', group: 'terms', select: `to_char(ct.expires_at, 'YYYY-MM-DD')` },
{ key: 'estimatedShipmentDate', label: 'Est. shipment date', type: 'date', group: 'terms', select: `to_char(ct.estimated_shipment_date, 'YYYY-MM-DD')` },
{ key: 'equipmentReturn', label: 'Equipment return', type: 'string', group: 'terms', select: 'ct.equipment_return' },
{ key: 'pricingDisplayMode', label: 'Pricing display mode', type: 'string', group: 'terms', select: 'ct.pricing_display_mode' },
{
key: 'routes', label: 'Routes', type: 'string', group: 'routes',
select: `(SELECT string_agg(o.label || ' -> ' || d.label, ' | ' ORDER BY cr.sort_order)
FROM freight.contract_routes cr
JOIN freight.yards o ON o.id = cr.origin_yard_id
JOIN freight.yards d ON d.id = cr.destination_yard_id
WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`,
},
{
key: 'routeCount', label: 'Route count', type: 'number', group: 'routes',
select: `(SELECT COUNT(*)::int FROM freight.contract_routes cr
WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`,
},
{
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'routes',
select: `(SELECT COUNT(*)::int FROM freight.bookings b
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL)`,
},
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'routes', select: 'ct.is_hazardous' },
{ key: 'hazardClass', label: 'Hazard class', type: 'string', group: 'routes', select: 'ct.hazard_class' },
{ key: 'unNumber', label: 'UN number', type: 'string', group: 'routes', select: 'ct.un_number' },
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'routes', select: 'ct.is_reefer' },
{ key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'approval', select: `to_char(ct.approved_by_staff_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'signedByDirectorAt', label: 'Director signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_director_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'signedByCeoAt', label: 'CEO signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_ceo_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'customerSignedAt', label: 'Customer signed', type: 'datetime', group: 'approval', select: `to_char(ct.customer_signed_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'fullyExecutedAt', label: 'Fully executed', type: 'datetime', group: 'approval', default: true, select: `to_char(ct.fully_executed_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'lockedAt', label: 'Locked at', type: 'datetime', group: 'approval', select: `to_char(ct.locked_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'contractGeneratedAt', label: 'Document generated', type: 'datetime', group: 'approval', select: `to_char(ct.contract_generated_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'clearanceStatus', label: 'Clearance status', type: 'string', group: 'clearance', select: 'ct.clearance_status' },
{ key: 'clearanceCycleNumber', label: 'Clearance cycle', type: 'number', group: 'clearance', select: 'ct.clearance_cycle_number' },
{ key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'ct.customs_clearing_enabled' },
{ key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'ct.customs_clearing_agent' },
{ key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'clearance', select: 'ct.first_mile_pickup_address' },
{ key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'clearance', select: 'ct.last_mile_delivery_address' },
],
filters: [
{ key: 'created', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect' },
{ key: 'contractKind', label: 'Kind', type: 'text' },
{ key: 'tradeDirection', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) },
{ key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) },
{ key: 'paymentCurrency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' },
] },
{ key: 'serviceTypeId', label: 'Service type', type: 'text' },
// Routes are one-to-many on contract_routes, so these filter via EXISTS
// rather than a column comparison.
{ key: 'originYardId', label: 'Origin', type: 'text' },
{ key: 'destinationYardId', label: 'Destination', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
defaultSort: { key: 'createdAt', dir: 'DESC' },
scope(ctx, qb) {
const { params, directions } = ctx;
qb.andWhere('ct.deleted_at IS NULL');
if (params.createdFrom) qb.andWhere('ct.created_at >= :createdFrom', { createdFrom: params.createdFrom });
if (params.createdTo) qb.andWhere('ct.created_at < :createdTo', { createdTo: params.createdTo });
const statuses = params.statuses as string[] | null;
if (statuses?.length) qb.andWhere('ct.status IN (:...statuses)', { statuses });
if (params.contractKind) qb.andWhere('ct.contract_kind = :contractKind', { contractKind: params.contractKind });
if (params.tradeDirection) qb.andWhere('ct.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
if (params.freightType) qb.andWhere('ct.freight_type = :freightType', { freightType: params.freightType });
if (params.paymentCurrency) qb.andWhere('ct.payment_currency = :paymentCurrency', { paymentCurrency: params.paymentCurrency });
if (params.serviceTypeId) qb.andWhere('ct.service_type_id = :serviceTypeId', { serviceTypeId: params.serviceTypeId });
if (params.originYardId) {
qb.andWhere(
`EXISTS (SELECT 1 FROM freight.contract_routes cr
WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL
AND cr.origin_yard_id = :originYardId)`,
{ originYardId: params.originYardId },
);
}
if (params.destinationYardId) {
qb.andWhere(
`EXISTS (SELECT 1 FROM freight.contract_routes cr2
WHERE cr2.contract_id = ct.id AND cr2.deleted_at IS NULL
AND cr2.destination_yard_id = :destinationYardId)`,
{ destinationYardId: params.destinationYardId },
);
}
if (params.companyId) qb.andWhere('ct.company_id = :companyId', { companyId: params.companyId });
if (params.search) {
qb.andWhere('(ct.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
}
applyDirectionScope(qb, 'ct.trade_direction', directions);
},
};

View File

@@ -0,0 +1,137 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Company } from '../../companies/entities/company.entity';
import { ExportDataset } from '../export.types';
/**
* ONE ROW PER COMPANY. A company has many `company_profiles`, so every
* profile-derived field aggregates in a subquery rather than joining — a join
* would multiply rows and make the file disagree with the count endpoint.
* If per-profile rows are ever wanted, that is a separate `company-profiles`
* dataset, not a flag on this one.
*/
export const customersDataset: ExportDataset = {
key: 'customers',
title: 'Customers',
description: 'Companies with registration, contact, address and activity detail',
group: 'Commercial',
permission: FREIGHT_PERMS.customers.view,
base: { entity: Company, alias: 'c' },
joins: [],
groups: [
{ id: 'identity', label: 'Identity' },
{ id: 'registration', label: 'Registration' },
{ id: 'contact', label: 'Contact' },
{ id: 'address', label: 'Address' },
{ id: 'profiles', label: 'Profiles' },
{ id: 'activity', label: 'Activity' },
],
fields: [
{ key: 'name', label: 'Company', type: 'string', group: 'identity', default: true, select: 'c.name', sortExpr: 'c.name' },
{ key: 'type', label: 'Type', type: 'string', group: 'identity', default: true, select: 'c.type' },
{ key: 'kind', label: 'Kind', type: 'string', group: 'identity', default: true, select: 'c.kind' },
{ key: 'status', label: 'Status', type: 'string', group: 'identity', default: true, select: 'c.status', sortExpr: 'c.status' },
{ key: 'statusDescription', label: 'Status note', type: 'string', group: 'identity', select: 'c.status_description' },
{ key: 'nationality', label: 'Nationality', type: 'string', group: 'identity', select: 'c.nationality' },
// date_registered / renewal_date / renewed_* are varchar in the schema,
// not dates — exported verbatim rather than pushed through to_char.
{ key: 'tin', label: 'TIN', type: 'string', group: 'registration', default: true, select: 'c.tin' },
{ key: 'vatNumber', label: 'VAT number', type: 'string', group: 'registration', select: 'c.vat_number' },
{ key: 'fanNumber', label: 'FAN number', type: 'string', group: 'registration', select: 'c.fan_number' },
{ key: 'licenceNumber', label: 'Licence number', type: 'string', group: 'registration', select: 'c.licence_number' },
{ key: 'dateRegistered', label: 'Date registered', type: 'string', group: 'registration', select: 'c.date_registered' },
{ key: 'renewalDate', label: 'Renewal date', type: 'string', group: 'registration', select: 'c.renewal_date' },
{ key: 'renewedFrom', label: 'Renewed from', type: 'string', group: 'registration', select: 'c.renewed_from' },
{ key: 'renewedTo', label: 'Renewed to', type: 'string', group: 'registration', select: 'c.renewed_to' },
{ key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'registration', select: `to_char(c.approved_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'phone', label: 'Phone', type: 'string', group: 'contact', default: true, select: 'c.phone' },
{ key: 'email', label: 'Email', type: 'string', group: 'contact', default: true, select: 'c.email' },
{ key: 'etradePhone', label: 'eTrade phone', type: 'string', group: 'contact', select: 'c.etrade_phone' },
{ key: 'website', label: 'Website', type: 'string', group: 'contact', select: 'c.website' },
{ key: 'contactPersonName', label: 'Contact person', type: 'string', group: 'contact', select: 'c.contact_person_name' },
{ key: 'contactPersonPhone', label: 'Contact phone', type: 'string', group: 'contact', select: 'c.contact_person_phone' },
{ key: 'country', label: 'Country', type: 'string', group: 'address', select: 'c.country' },
{ key: 'region', label: 'Region', type: 'string', group: 'address', select: 'c.region' },
{ key: 'zone', label: 'Zone', type: 'string', group: 'address', select: 'c.zone' },
{ key: 'woreda', label: 'Woreda', type: 'string', group: 'address', select: 'c.woreda' },
{ key: 'kebele', label: 'Kebele', type: 'string', group: 'address', select: 'c.kebele' },
{ key: 'houseNo', label: 'House no.', type: 'string', group: 'address', select: 'c.house_no' },
{ key: 'address', label: 'Address', type: 'string', group: 'address', select: 'c.address' },
{
key: 'profileCount', label: 'Profile count', type: 'number', group: 'profiles', default: true,
select: `(SELECT COUNT(*)::int FROM freight.company_profiles cp
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
},
{
key: 'profileTypes', label: 'Profile types', type: 'string', group: 'profiles',
select: `(SELECT string_agg(DISTINCT cp.type, ' | ') FROM freight.company_profiles cp
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
},
{
key: 'profileReferences', label: 'Profile references', type: 'string', group: 'profiles',
select: `(SELECT string_agg(cp.reference, ' | ' ORDER BY cp.reference) FROM freight.company_profiles cp
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
},
{
key: 'profileStatuses', label: 'Profile statuses', type: 'string', group: 'profiles',
select: `(SELECT string_agg(DISTINCT cp.status, ' | ') FROM freight.company_profiles cp
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
},
{ key: 'createdAt', label: 'Registered on', type: 'date', group: 'activity', default: true, select: `to_char(c.created_at, 'YYYY-MM-DD')`, sortExpr: 'c.created_at' },
{
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'activity',
select: `(SELECT COUNT(*)::int FROM freight.bookings b
WHERE b.company_id = c.id AND b.deleted_at IS NULL)`,
},
{
key: 'contractCount', label: 'Contracts', type: 'number', group: 'activity',
select: `(SELECT COUNT(*)::int FROM freight.contracts ct
WHERE ct.company_id = c.id AND ct.deleted_at IS NULL)`,
},
{
key: 'invoicedTotal', label: 'Invoiced total', type: 'money', group: 'activity',
select: `(SELECT ROUND(COALESCE(SUM(i.total_amount), 0), 2)::float8 FROM freight.invoices i
WHERE i.company_id = c.id AND i.deleted_at IS NULL)`,
},
{
key: 'outstandingBalance', label: 'Outstanding balance', type: 'money', group: 'activity',
select: `(SELECT ROUND(COALESCE(SUM(i.balance_amount), 0), 2)::float8 FROM freight.invoices i
WHERE i.company_id = c.id AND i.deleted_at IS NULL)`,
},
],
filters: [
{ key: 'created', label: 'Registered', type: 'daterange' },
{ key: 'type', label: 'Type', type: 'text' },
{ key: 'kind', label: 'Kind', type: 'select', options: [
{ value: 'commercial', label: 'Commercial' },
{ value: 'government', label: 'Government' },
] },
{ key: 'status', label: 'Status', type: 'text' },
{ key: 'search', label: 'Search name, TIN or email', type: 'text' },
],
defaultSort: { key: 'name', dir: 'ASC' },
scope(ctx, qb) {
const { params } = ctx;
qb.andWhere('c.deleted_at IS NULL');
if (params.createdFrom) qb.andWhere('c.created_at >= :createdFrom', { createdFrom: params.createdFrom });
if (params.createdTo) qb.andWhere('c.created_at < :createdTo', { createdTo: params.createdTo });
if (params.type) qb.andWhere('c.type = :type', { type: params.type });
if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind });
if (params.status) qb.andWhere('c.status = :status', { status: params.status });
if (params.search) {
qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', {
search: `%${params.search as string}%`,
});
}
// Companies carry no trade direction — nothing to scope. Intentional.
},
};

View File

@@ -0,0 +1,130 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
/**
* Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a
* signature blob) and `eims_last_error` (a raw error dump). The
* human-meaningful status/IRN/document-number fields are kept.
*/
export const invoicesDataset: ExportDataset = {
key: 'invoices',
title: 'Invoices',
description: 'Invoices with customer, amounts, payment status and EIMS state',
group: 'Finance',
permission: FREIGHT_PERMS.invoices.view,
base: { entity: Invoice, alias: 'i' },
joins: [
{ alias: 'c', entity: Company, on: 'c.id = i.company_id' },
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = i.company_profile_id' },
// No relation object on the entity for this FK — the service hydrates it
// with a second query. In a dataset it is just a join by column.
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' },
{ alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' },
],
alwaysJoin: ['c'],
groups: [
{ id: 'invoice', label: 'Invoice' },
{ id: 'customer', label: 'Customer' },
{ id: 'amounts', label: 'Amounts' },
{ id: 'payment', label: 'Payment' },
{ id: 'lines', label: 'Lines' },
{ id: 'eims', label: 'EIMS' },
],
fields: [
{ key: 'invoiceNumber', label: 'Invoice no.', type: 'string', group: 'invoice', default: true, select: 'i.invoice_number', sortExpr: 'i.invoice_number' },
{ key: 'status', label: 'Status', type: 'string', group: 'invoice', default: true, select: 'i.status', sortExpr: 'i.status' },
{ key: 'type', label: 'Type', type: 'string', group: 'invoice', select: 'i.type' },
{ key: 'source', label: 'Source', type: 'string', group: 'invoice', default: true, select: 'i.source' },
{ key: 'sourceId', label: 'Source reference', type: 'string', group: 'invoice', select: 'i.source_id' },
{ key: 'issuedAt', label: 'Issued', type: 'date', group: 'invoice', default: true, select: `to_char(i.issued_at, 'YYYY-MM-DD')`, sortExpr: 'i.issued_at' },
{ key: 'dueAt', label: 'Due', type: 'date', group: 'invoice', default: true, select: `to_char(i.due_at, 'YYYY-MM-DD')`, sortExpr: 'i.due_at' },
{ key: 'createdAt', label: 'Created', type: 'date', group: 'invoice', select: `to_char(i.created_at, 'YYYY-MM-DD')`, sortExpr: 'i.created_at' },
{ key: 'relatedInvoice', label: 'Related invoice', type: 'string', group: 'invoice', requires: ['rel'], select: 'rel.invoice_number' },
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
{ key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' },
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
{ key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' },
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' },
{ key: 'subtotalAmount', label: 'Subtotal', type: 'money', group: 'amounts', select: 'i.subtotal_amount::float8' },
{ key: 'taxAmount', label: 'Tax', type: 'money', group: 'amounts', select: 'i.tax_amount::float8' },
{ key: 'totalAmount', label: 'Total', type: 'money', group: 'amounts', default: true, select: 'i.total_amount::float8', sortExpr: 'i.total_amount' },
{ key: 'paidAmount', label: 'Paid', type: 'money', group: 'amounts', default: true, select: 'i.paid_amount::float8' },
{ key: 'balanceAmount', label: 'Balance', type: 'money', group: 'amounts', default: true, select: 'i.balance_amount::float8', sortExpr: 'i.balance_amount' },
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' },
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` },
{
key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment',
select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now()
THEN EXTRACT(DAY FROM now() - i.due_at)::int ELSE 0 END`,
},
{
key: 'lineCount', label: 'Line count', type: 'number', group: 'lines',
select: `(SELECT COUNT(*)::int FROM freight.invoice_lines il
WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`,
},
{
key: 'lineCharges', label: 'Charges', type: 'string', group: 'lines',
select: `(SELECT string_agg(il.charge_type || ': ' || ROUND(il.amount, 2), ' | ' ORDER BY il.charge_type)
FROM freight.invoice_lines il
WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`,
},
{ key: 'eimsStatus', label: 'EIMS status', type: 'string', group: 'eims', select: 'i.eims_status' },
{ key: 'eimsIrn', label: 'EIMS IRN', type: 'string', group: 'eims', select: 'i.eims_irn' },
{ key: 'eimsDocumentNumber', label: 'EIMS document no.', type: 'string', group: 'eims', select: 'i.eims_document_number' },
{ key: 'eimsDocumentType', label: 'EIMS document type', type: 'string', group: 'eims', select: 'i.eims_document_type' },
{ key: 'eimsSubmittedAt', label: 'EIMS submitted', type: 'datetime', group: 'eims', select: `to_char(i.eims_submitted_at, 'YYYY-MM-DD HH24:MI')` },
// eims_ack_date is varchar in the schema, not a timestamp.
{ key: 'eimsAckDate', label: 'EIMS acknowledged', type: 'string', group: 'eims', select: 'i.eims_ack_date' },
{ key: 'eimsCancelledAt', label: 'EIMS cancelled', type: 'datetime', group: 'eims', select: `to_char(i.eims_cancelled_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'eimsCancellationReasonCode', label: 'EIMS cancellation reason', type: 'string', group: 'eims', select: 'i.eims_cancellation_reason_code' },
],
filters: [
{ key: 'issued', label: 'Issued', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect' },
// The invoices list page sends a single `status`; accept both so its
// on-screen filter actually carries into the export.
{ key: 'status', label: 'Status (single)', type: 'text' },
{ key: 'currency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' },
] },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
scope(ctx, qb) {
const { params, directions } = ctx;
qb.andWhere('i.deleted_at IS NULL');
if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom });
if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo });
const statuses = params.statuses as string[] | null;
if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses });
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency });
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
if (params.search) {
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
}
// ACL: invoices.source_id is a varchar pointer at the originating booking.
applyBookingRefDirectionScope(qb, 'i.source_id', directions);
},
};

View File

@@ -0,0 +1,79 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ExportDataset } from '../export.types';
const STATUS_OPTIONS = ['AVAILABLE', 'IN_USE', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
export const locomotivesDataset: ExportDataset = {
key: 'locomotives',
title: 'Locomotives',
description: 'Locomotive fleet with capacity, traction specs and current location',
group: 'Fleet',
permission: FREIGHT_PERMS.locomotives.view,
base: { entity: Locomotive, alias: 'l' },
joins: [{ alias: 'y', entity: Yard, on: 'y.id = l.current_yard_id' }],
groups: [
{ id: 'identity', label: 'Locomotive' },
{ id: 'capacity', label: 'Capacity & traction' },
{ id: 'location', label: 'Status & location' },
{ id: 'assignment', label: 'Assignment' },
],
fields: [
{ key: 'code', label: 'Code', type: 'string', group: 'identity', default: true, select: 'l.code', sortExpr: 'l.code' },
{ key: 'name', label: 'Name', type: 'string', group: 'identity', default: true, select: 'l.name' },
{ key: 'locomotiveType', label: 'Type', type: 'string', group: 'identity', default: true, select: 'l.locomotive_type' },
{ key: 'createdAt', label: 'Added', type: 'date', group: 'identity', select: `to_char(l.created_at, 'YYYY-MM-DD')`, sortExpr: 'l.created_at' },
{ key: 'maxPullWeightTons', label: 'Max pull weight (t)', type: 'tons', group: 'capacity', default: true, select: 'l.max_pull_weight_tons::float8', sortExpr: 'l.max_pull_weight_tons' },
{ key: 'maxTrainLengthMeters', label: 'Max train length (m)', type: 'number', group: 'capacity', default: true, select: 'l.max_train_length_meters::float8' },
{ key: 'overageToleranceTons', label: 'Overage tolerance (t)', type: 'tons', group: 'capacity', select: 'l.overage_tolerance_tons::float8' },
{ key: 'overageToleranceMeters', label: 'Overage tolerance (m)', type: 'number', group: 'capacity', select: 'l.overage_tolerance_meters::float8' },
{ key: 'powerKw', label: 'Power (kW)', type: 'number', group: 'capacity', select: 'l.power_kw::float8' },
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number', group: 'capacity', select: 'l.traction_force_kn::float8' },
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number', group: 'capacity', select: 'l.max_speed_kmh::float8' },
{ key: 'status', label: 'Status', type: 'string', group: 'location', default: true, select: 'l.status', sortExpr: 'l.status' },
{ key: 'availableFrom', label: 'Available from', type: 'date', group: 'location', select: `to_char(l.available_from, 'YYYY-MM-DD')` },
{ key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' },
{ key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' },
{ key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' },
{
// One-to-many -> aggregate in a subquery, never a join.
key: 'assignedTrains', label: 'Assigned trains', type: 'string', group: 'assignment',
select: `(SELECT string_agg(DISTINCT tr.code, ' | ')
FROM freight.train_set_locomotives tsl
JOIN freight.train_sets ts ON ts.id = tsl.train_set_id AND ts.deleted_at IS NULL
JOIN freight.trains tr ON tr.id = ts.train_id AND tr.deleted_at IS NULL
WHERE tsl.locomotive_id = l.id AND tsl.deleted_at IS NULL)`,
},
],
filters: [
{ key: 'status', label: 'Status', type: 'select', options: STATUS_OPTIONS },
{ key: 'locomotiveType', label: 'Type', type: 'text' },
{ key: 'currentYardId', label: 'Current yard', type: 'text' },
{ key: 'search', label: 'Search code or name', type: 'text' },
],
defaultSort: { key: 'code', dir: 'ASC' },
scope(ctx, qb) {
const { params } = ctx;
qb.andWhere('l.deleted_at IS NULL');
if (params.status) qb.andWhere('l.status = :status', { status: params.status });
if (params.locomotiveType) qb.andWhere('l.locomotive_type = :locomotiveType', { locomotiveType: params.locomotiveType });
if (params.currentYardId) qb.andWhere('l.current_yard_id = :currentYardId', { currentYardId: params.currentYardId });
if (params.search) {
qb.andWhere('(l.code ILIKE :search OR l.name ILIKE :search)', { search: `%${params.search as string}%` });
}
// Locomotives carry no trade direction — nothing to scope. Intentional.
},
};

View File

@@ -0,0 +1,113 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { PaymentEntity } from '../../payment/entities/payment.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
/**
* `freight.payments` breaks two conventions this codebase otherwise holds to,
* both verified against the live schema:
*
* 1. It does NOT extend @edr/api-common's BaseEntity — there is no
* `updated_at` and no `deleted_at`. A soft-delete guard here is a 42703,
* which is why `scope()` below applies the ACL only.
* 2. The failure columns are `failer_code` / `failer_message`, not `failure_*`.
*
* Raw gateway payloads (`raw_initiation`, `client_action`) are deliberately
* not exposed as fields.
*/
export const paymentsDataset: ExportDataset = {
key: 'payments',
title: 'Payments',
description: 'Payment transactions with method, status, and the booking and customer they belong to',
group: 'Finance',
permission: FREIGHT_PERMS.payments.view,
base: { entity: PaymentEntity, alias: 'p' },
joins: [
// ref_id is a varchar pointer at the booking, so the cast is required.
{ alias: 'bk', entity: Booking, on: 'bk.id::text = p.ref_id' },
{ alias: 'c', entity: Company, on: 'c.id = bk.company_id', requires: ['bk'] },
],
groups: [
{ id: 'payment', label: 'Payment' },
{ id: 'amounts', label: 'Amounts' },
{ id: 'gateway', label: 'Gateway' },
{ id: 'booking', label: 'Booking' },
{ id: 'customer', label: 'Customer' },
],
fields: [
{ key: 'merchantOrderId', label: 'Order ID', type: 'string', group: 'payment', default: true, select: 'p.merchant_order_id', sortExpr: 'p.merchant_order_id' },
{ key: 'status', label: 'Status', type: 'string', group: 'payment', default: true, select: 'p.status', sortExpr: 'p.status' },
{ key: 'method', label: 'Method', type: 'string', group: 'payment', default: true, select: 'p.method', sortExpr: 'p.method' },
{ key: 'type', label: 'Type', type: 'string', group: 'payment', select: 'p.type' },
{ key: 'referenceType', label: 'Reference type', type: 'string', group: 'payment', select: 'p.reference_type' },
{ key: 'createdAt', label: 'Created', type: 'datetime', group: 'payment', default: true, select: `to_char(p.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.created_at' },
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', default: true, select: `to_char(p.paid_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.paid_at' },
{ key: 'refundedAt', label: 'Refunded at', type: 'datetime', group: 'payment', select: `to_char(p.refunded_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'expiresAt', label: 'Expires at', type: 'datetime', group: 'payment', select: `to_char(p.expires_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'amount', label: 'Amount', type: 'money', group: 'amounts', default: true, select: 'p.amount::float8', sortExpr: 'p.amount' },
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'p.currency' },
{
// payment_refunds stores MINOR units (amount_minor), unlike payments.amount
// which is major. Divide, or a 50.00 refund exports as 5000.
key: 'refundedTotal', label: 'Refunded total', type: 'money', group: 'amounts',
select: `(SELECT ROUND(COALESCE(SUM(pr.amount_minor), 0) / 100.0, 2)::float8
FROM freight.payment_refunds pr WHERE pr.payment_id = p.id)`,
},
{ key: 'transactionId', label: 'Transaction ID', type: 'string', group: 'gateway', select: 'p.transaction_id' },
{ key: 'failerCode', label: 'Failure code', type: 'string', group: 'gateway', select: 'p.failer_code' },
{ key: 'failureMessage', label: 'Failure message', type: 'string', group: 'gateway', select: 'p.failer_message' },
{ key: 'reason', label: 'Reason', type: 'string', group: 'gateway', select: 'p.reason' },
{ key: 'bookingReference', label: 'Booking', type: 'string', group: 'booking', default: true, requires: ['bk'], select: 'bk.reference' },
{ key: 'bookingStatus', label: 'Booking status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.status' },
{ key: 'bookingPaymentStatus', label: 'Booking payment status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.payment_status' },
{ key: 'bookingTradeDirection', label: 'Direction', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.trade_direction' },
{ key: 'bookingFreightType', label: 'Freight type', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.freight_type' },
{ key: 'bookingPnrCode', label: 'PNR code', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.pnr_code' },
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name' },
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
],
filters: [
{ key: 'created', label: 'Created', type: 'daterange' },
{ key: 'status', label: 'Status', type: 'select', options: [
'action-required', 'processing', 'success', 'failed', 'canceled', 'refunded',
].map((v) => ({ value: v, label: v })) },
{ key: 'method', label: 'Method', type: 'select', options: [
'telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill',
].map((v) => ({ value: v, label: v })) },
{ key: 'currency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' },
] },
{ key: 'search', label: 'Search order or transaction ID', type: 'text' },
],
defaultSort: { key: 'createdAt', dir: 'DESC' },
scope(ctx, qb) {
const { params, directions } = ctx;
// No `p.deleted_at IS NULL` — this table has no soft-delete column.
if (params.createdFrom) qb.andWhere('p.created_at >= :createdFrom', { createdFrom: params.createdFrom });
if (params.createdTo) qb.andWhere('p.created_at < :createdTo', { createdTo: params.createdTo });
if (params.status) qb.andWhere('p.status = :status', { status: params.status });
if (params.method) qb.andWhere('p.method = :method', { method: params.method });
if (params.currency) qb.andWhere('p.currency = :currency', { currency: params.currency });
if (params.search) {
qb.andWhere('(p.merchant_order_id ILIKE :search OR p.transaction_id ILIKE :search)', {
search: `%${params.search as string}%`,
});
}
applyBookingRefDirectionScope(qb, 'p.ref_id', directions);
},
};

View File

@@ -0,0 +1,138 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Route } from '../../routes/entities/route.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
/**
* `freightType` is NOT a column on train_schedules — it is derived from the
* bookings aboard (the list service does this with an EXISTS subquery). Exposed
* here as an aggregate over those bookings, never as `sch.freight_type`.
*/
export const trainSchedulesDataset: ExportDataset = {
key: 'train-schedules',
title: 'Train schedules',
description: 'Scheduled trains with route, timings, booking window and load',
group: 'Operations',
permission: FREIGHT_PERMS.trainScheduling.view,
base: { entity: TrainSchedule, alias: 'sch' },
joins: [
{ alias: 'rt', entity: Route, on: 'rt.id = sch.route_id' },
{ alias: 'os', entity: Yard, on: 'os.id = sch.origin_station_id' },
{ alias: 'ds', entity: Yard, on: 'ds.id = sch.destination_station_id' },
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = sch.shipping_line_company_id' },
],
groups: [
{ id: 'schedule', label: 'Schedule' },
{ id: 'route', label: 'Route' },
{ id: 'timings', label: 'Timings' },
{ id: 'window', label: 'Booking window' },
{ id: 'load', label: 'Load' },
],
fields: [
{ key: 'reference', label: 'Reference', type: 'string', group: 'schedule', default: true, select: 'sch.reference', sortExpr: 'sch.reference' },
{ key: 'status', label: 'Status', type: 'string', group: 'schedule', default: true, select: 'sch.status', sortExpr: 'sch.status' },
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'schedule', default: true, select: 'sch.train_number' },
{ key: 'voyageNumber', label: 'Voyage number', type: 'string', group: 'schedule', select: 'sch.voyage_number' },
{ key: 'direction', label: 'Direction', type: 'string', group: 'schedule', default: true, select: 'sch.direction' },
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'schedule', requires: ['slc'], select: 'slc.name' },
{ key: 'bookingCycleNo', label: 'Booking cycle', type: 'number', group: 'schedule', select: 'sch.booking_cycle_no' },
{ key: 'reverseWagonOrder', label: 'Reverse wagon order', type: 'boolean', group: 'schedule', select: 'sch.reverse_wagon_order' },
{ key: 'createdAt', label: 'Created', type: 'date', group: 'schedule', select: `to_char(sch.created_at, 'YYYY-MM-DD')`, sortExpr: 'sch.created_at' },
{ key: 'originStation', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['os'], select: 'os.label' },
{ key: 'originStationCode', label: 'Origin code', type: 'string', group: 'route', requires: ['os'], select: 'os.code' },
{ key: 'destinationStation', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['ds'], select: 'ds.label' },
{ key: 'destinationStationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['ds'], select: 'ds.code' },
{ key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' },
{ key: 'scheduledDeparture', label: 'Scheduled departure', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'sch.scheduled_departure_date' },
{ key: 'scheduledArrival', label: 'Scheduled arrival', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI')` },
{ key: 'actualDeparture', label: 'Actual departure', type: 'datetime', group: 'timings', select: `to_char(sch.actual_departure_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'actualArrival', label: 'Actual arrival', type: 'datetime', group: 'timings', select: `to_char(sch.actual_arrival_at, 'YYYY-MM-DD HH24:MI')` },
{
key: 'departureDelayHours', label: 'Departure delay (h)', type: 'number', group: 'timings',
select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_departure_at - sch.scheduled_departure_date)) / 3600.0, 2)::float8`,
},
{
key: 'transitHours', label: 'Transit time (h)', type: 'number', group: 'timings',
select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_arrival_at - sch.actual_departure_at)) / 3600.0, 2)::float8`,
},
{ key: 'bookingWindowStatus', label: 'Window status', type: 'string', group: 'window', select: 'sch.booking_window_status' },
{ key: 'windowPhase', label: 'Window phase', type: 'string', group: 'window', select: 'sch.window_phase' },
{ key: 'windowOpensAt', label: 'Window opens', type: 'datetime', group: 'window', select: `to_char(sch.window_opens_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'windowClosesAt', label: 'Window closes', type: 'datetime', group: 'window', select: `to_char(sch.window_closes_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'docReviewEndsAt', label: 'Doc review ends', type: 'datetime', group: 'window', select: `to_char(sch.doc_review_ends_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'paymentPhaseEndsAt', label: 'Payment phase ends', type: 'datetime', group: 'window', select: `to_char(sch.payment_phase_ends_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'windowRuleCustom', label: 'Custom window rules', type: 'boolean', group: 'window', select: 'sch.window_rule_custom' },
{ key: 'maxWagons', label: 'Max wagons', type: 'number', group: 'load', select: 'sch.max_wagons' },
{
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'load', default: true,
select: `(SELECT COUNT(*)::int FROM freight.bookings b
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
},
{
// Derived, not a column — see the file header.
key: 'freightTypes', label: 'Freight types', type: 'string', group: 'load', default: true,
select: `(SELECT string_agg(DISTINCT b.freight_type, ' | ') FROM freight.bookings b
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
},
{
key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true,
select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8
FROM freight.bookings b
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
},
{
key: 'assignedWagonCount', label: 'Wagons assigned', type: 'number', group: 'load',
select: `(SELECT COUNT(*)::int FROM freight.wagons w
WHERE w.current_train_schedule_id = sch.id AND w.deleted_at IS NULL)`,
},
],
filters: [
{ key: 'departure', label: 'Departure', type: 'daterange' },
{ key: 'status', label: 'Status', type: 'text' },
{ key: 'direction', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) },
// freightType is derived from the bookings aboard, so it filters via
// EXISTS — the same shape the list service's scheduleFreightTypeFilter uses.
{ key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) },
{ key: 'originStationId', label: 'Origin', type: 'text' },
{ key: 'destinationStationId', label: 'Destination', type: 'text' },
{ key: 'search', label: 'Search reference or train number', type: 'text' },
],
defaultSort: { key: 'scheduledDeparture', dir: 'DESC' },
scope(ctx, qb) {
const { params, directions } = ctx;
qb.andWhere('sch.deleted_at IS NULL');
if (params.departureFrom) qb.andWhere('sch.scheduled_departure_date >= :departureFrom', { departureFrom: params.departureFrom });
if (params.departureTo) qb.andWhere('sch.scheduled_departure_date < :departureTo', { departureTo: params.departureTo });
if (params.status) qb.andWhere('sch.status = :status', { status: params.status });
if (params.direction) qb.andWhere('sch.direction = :direction', { direction: params.direction });
if (params.freightType) {
qb.andWhere(
`EXISTS (SELECT 1 FROM freight.bookings fb
WHERE fb.train_schedule_id = sch.id AND fb.deleted_at IS NULL
AND fb.freight_type = :freightType)`,
{ freightType: params.freightType },
);
}
if (params.originStationId) qb.andWhere('sch.origin_station_id = :originStationId', { originStationId: params.originStationId });
if (params.destinationStationId) qb.andWhere('sch.destination_station_id = :destinationStationId', { destinationStationId: params.destinationStationId });
if (params.search) {
qb.andWhere('(sch.reference ILIKE :search OR sch.train_number ILIKE :search)', { search: `%${params.search as string}%` });
}
// Schedules carry their own `direction` column, so scope on that directly
// rather than through the bookings aboard.
applyDirectionScope(qb, 'sch.direction', directions);
},
};

View File

@@ -0,0 +1,90 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Route } from '../../routes/entities/route.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Train } from '../../trains/entities/train.entity';
import { ExportDataset } from '../export.types';
/**
* The list endpoint (`trains.service.ts findAll`) loads NO relations, so the
* UI shows raw FK uuids where names belong. This dataset resolves them, which
* makes it the clearest "the export shows more than the screen" case in the set.
*/
export const trainsDataset: ExportDataset = {
key: 'trains',
title: 'Trains',
description: 'Trains with route, stations, capacity and current composition',
group: 'Fleet',
permission: FREIGHT_PERMS.trains.view,
base: { entity: Train, alias: 't' },
joins: [
{ alias: 'y', entity: Yard, on: 'y.id = t.current_yard_id' },
{ alias: 'rt', entity: Route, on: 'rt.id = t.route_id' },
{ alias: 'os', entity: Yard, on: 'os.id = t.origin_station_id' },
{ alias: 'ds', entity: Yard, on: 'ds.id = t.destination_station_id' },
],
groups: [
{ id: 'train', label: 'Train' },
{ id: 'route', label: 'Route & stations' },
{ id: 'capacity', label: 'Capacity' },
{ id: 'composition', label: 'Composition' },
],
fields: [
{ key: 'code', label: 'Code', type: 'string', group: 'train', default: true, select: 't.code', sortExpr: 't.code' },
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'train', default: true, select: 't.train_number' },
{ key: 'trainName', label: 'Train name', type: 'string', group: 'train', default: true, select: 't.train_name' },
{ key: 'status', label: 'Status', type: 'string', group: 'train', default: true, select: 't.status', sortExpr: 't.status' },
{ key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'train', select: 't.import_train_number' },
{ key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'train', select: 't.export_train_number' },
{ key: 'locomotiveNumber', label: 'Locomotive number', type: 'string', group: 'train', select: 't.locomotive_number' },
{ key: 'notes', label: 'Notes', type: 'string', group: 'train', select: 't.notes' },
{ key: 'remarks', label: 'Remarks', type: 'string', group: 'train', select: 't.remarks' },
{ key: 'createdAt', label: 'Added', type: 'date', group: 'train', select: `to_char(t.created_at, 'YYYY-MM-DD')`, sortExpr: 't.created_at' },
{ key: 'currentYard', label: 'Current yard', type: 'string', group: 'route', default: true, requires: ['y'], select: 'y.label' },
{ key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'route', requires: ['y'], select: 'y.code' },
{ key: 'routeDirection', label: 'Route direction', type: 'string', group: 'route', requires: ['rt'], select: 'rt.direction' },
{ key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' },
{ key: 'originStation', label: 'Origin station', type: 'string', group: 'route', requires: ['os'], select: 'os.label' },
{ key: 'destinationStation', label: 'Destination station', type: 'string', group: 'route', requires: ['ds'], select: 'ds.label' },
{ key: 'departureTime', label: 'Departure time', type: 'string', group: 'route', select: 't.departure_time::text' },
{ key: 'arrivalTime', label: 'Arrival time', type: 'string', group: 'route', select: 't.arrival_time::text' },
{ key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'capacity', default: true, select: 't.capacity_tons::float8', sortExpr: 't.capacity_tons' },
{
key: 'wagonCount', label: 'Wagons attached', type: 'number', group: 'composition', default: true,
select: `(SELECT COUNT(*)::int FROM freight.wagons w
WHERE w.train_id = t.id AND w.deleted_at IS NULL)`,
},
{
key: 'wagonNumbers', label: 'Wagon numbers', type: 'string', group: 'composition',
select: `(SELECT string_agg(w.wagon_number, ' | ' ORDER BY w.sequence_number NULLS LAST, w.wagon_number)
FROM freight.wagons w
WHERE w.train_id = t.id AND w.deleted_at IS NULL)`,
},
],
filters: [
{ key: 'status', label: 'Status', type: 'text' },
{ key: 'currentYardId', label: 'Current yard', type: 'text' },
{ key: 'search', label: 'Search code, number or name', type: 'text' },
],
defaultSort: { key: 'code', dir: 'ASC' },
scope(ctx, qb) {
const { params } = ctx;
qb.andWhere('t.deleted_at IS NULL');
if (params.status) qb.andWhere('t.status = :status', { status: params.status });
if (params.currentYardId) qb.andWhere('t.current_yard_id = :currentYardId', { currentYardId: params.currentYardId });
if (params.search) {
qb.andWhere('(t.code ILIKE :search OR t.train_number ILIKE :search OR t.train_name ILIKE :search)', {
search: `%${params.search as string}%`,
});
}
// Trains carry no trade direction — nothing to scope. Intentional.
},
};

View File

@@ -0,0 +1,110 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { Train } from '../../trains/entities/train.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { ExportDataset } from '../export.types';
/**
* Two traps this dataset works around:
*
* 1. Tare weight, payload and length live on WAGON_TYPE, not wagon — which is
* why they carry `requires: ['wt']` and their sortExpr points at `wt`.
* 2. `lastMaintenanceAt` / `lastAvailableAt` come from a grouped query over
* wagon_status_logs in the service (`attachStatusDates`). Ported here as
* correlated subqueries so they compose with everything else and cost
* nothing when unticked. Note the log columns are `from_status`/`to_status`,
* not `status`.
*/
export const wagonsDataset: ExportDataset = {
key: 'wagons',
title: 'Wagons',
description: 'Wagon fleet with type specs, location, train assignment and status history',
group: 'Fleet',
permission: FREIGHT_PERMS.wagons.view,
base: { entity: Wagon, alias: 'w' },
joins: [
{ alias: 'wt', entity: WagonType, on: 'wt.id = w.wagon_type_id' },
{ alias: 'y', entity: Yard, on: 'y.id = w.current_yard_id' },
{ alias: 't', entity: Train, on: 't.id = w.train_id' },
{ alias: 'sch', entity: TrainSchedule, on: 'sch.id = w.current_train_schedule_id' },
],
groups: [
{ id: 'wagon', label: 'Wagon' },
{ id: 'type', label: 'Type & specs' },
{ id: 'location', label: 'Location' },
{ id: 'assignment', label: 'Assignment' },
{ id: 'history', label: 'Status history' },
],
fields: [
{ key: 'wagonNumber', label: 'Wagon number', type: 'string', group: 'wagon', default: true, select: 'w.wagon_number', sortExpr: 'w.wagon_number' },
{ key: 'status', label: 'Status', type: 'string', group: 'wagon', default: true, select: 'w.status', sortExpr: 'w.status' },
{ key: 'sequenceNumber', label: 'Sequence no.', type: 'number', group: 'wagon', select: 'w.sequence_number' },
{ key: 'notes', label: 'Notes', type: 'string', group: 'wagon', select: 'w.notes' },
{ key: 'createdAt', label: 'Added', type: 'date', group: 'wagon', select: `to_char(w.created_at, 'YYYY-MM-DD')`, sortExpr: 'w.created_at' },
{ key: 'wagonType', label: 'Type', type: 'string', group: 'type', default: true, requires: ['wt'], select: 'wt.name', sortExpr: 'wt.name' },
{ key: 'wagonTypeCode', label: 'Type code', type: 'string', group: 'type', requires: ['wt'], select: 'wt.code' },
{ key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'type', default: true, requires: ['wt'], select: 'wt.capacity_tons::float8', sortExpr: 'wt.capacity_tons' },
{ key: 'tareWeightTons', label: 'Tare weight (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.tare_weight_tons::float8' },
{ key: 'lengthMeters', label: 'Length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.length_meters::float8' },
{ key: 'equatedLengthM', label: 'Equated length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.equated_length_m::float8' },
{ key: 'maxContainerGrossT', label: 'Max container gross (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.max_container_gross_t::float8' },
{ key: 'supportsContainer', label: 'Supports container', type: 'boolean', group: 'type', requires: ['wt'], select: 'wt.supports_container' },
{ key: 'supportedLoadTypes', label: 'Supported load types', type: 'string', group: 'type', requires: ['wt'], select: 'wt.supported_load_types::text' },
{ key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' },
{ key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' },
{ key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' },
{ key: 'trainCode', label: 'Train', type: 'string', group: 'assignment', default: true, requires: ['t'], select: 't.code' },
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'assignment', requires: ['t'], select: 't.train_number' },
{ key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'assignment', select: 'w.export_train_number' },
{ key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'assignment', select: 'w.import_train_number' },
{ key: 'scheduleReference', label: 'Current schedule', type: 'string', group: 'assignment', requires: ['sch'], select: 'sch.reference' },
{ key: 'scheduleDeparture', label: 'Schedule departure', type: 'date', group: 'assignment', requires: ['sch'], select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD')` },
{
key: 'lastMaintenanceAt', label: 'Last maintenance', type: 'datetime', group: 'history',
select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI')
FROM freight.wagon_status_logs l
WHERE l.wagon_id = w.id AND l.to_status = 'MAINTENANCE' AND l.deleted_at IS NULL)`,
},
{
key: 'lastAvailableAt', label: 'Last available', type: 'datetime', group: 'history',
select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI')
FROM freight.wagon_status_logs l
WHERE l.wagon_id = w.id AND l.to_status = 'AVAILABLE' AND l.deleted_at IS NULL)`,
},
{
key: 'statusChangeCount', label: 'Status changes', type: 'number', group: 'history',
select: `(SELECT COUNT(*)::int FROM freight.wagon_status_logs l
WHERE l.wagon_id = w.id AND l.deleted_at IS NULL)`,
},
],
filters: [
{ key: 'status', label: 'Status', type: 'text' },
{ key: 'wagonTypeId', label: 'Wagon type', type: 'text' },
{ key: 'currentYardId', label: 'Current yard', type: 'text' },
{ key: 'trainId', label: 'Train', type: 'text' },
{ key: 'search', label: 'Search wagon number', type: 'text' },
],
defaultSort: { key: 'wagonNumber', dir: 'ASC' },
scope(ctx, qb) {
const { params } = ctx;
qb.andWhere('w.deleted_at IS NULL');
if (params.status) qb.andWhere('w.status = :status', { status: params.status });
if (params.wagonTypeId) qb.andWhere('w.wagon_type_id = :wagonTypeId', { wagonTypeId: params.wagonTypeId });
if (params.currentYardId) qb.andWhere('w.current_yard_id = :currentYardId', { currentYardId: params.currentYardId });
if (params.trainId) qb.andWhere('w.train_id = :trainId', { trainId: params.trainId });
if (params.search) qb.andWhere('w.wagon_number ILIKE :search', { search: `%${params.search as string}%` });
// Wagons carry no trade direction — nothing to scope. Intentional.
},
};

View File

@@ -0,0 +1,81 @@
import { DataSource } from 'typeorm';
const DAY_MS = 24 * 60 * 60 * 1000;
export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
export interface ExportFilterOption {
value: string;
label: string;
}
export interface ExportFilterDef {
key: string;
label: string;
type: ExportFilterType;
/** Static choices. Mutually exclusive with `optionsQuery`. */
options?: ExportFilterOption[];
/** Reference-data choices resolved from the DB and cached for the process. */
optionsQuery?: (ds: DataSource) => Promise<ExportFilterOption[]>;
}
/** Raw query-string bag. Per-registry filter keys, so `forbidNonWhitelisted` can't police it. */
export type RawFilterQuery = Record<string, string | undefined>;
/**
* Coerce raw query strings into typed filter params per a filter declaration
* list. Unknown keys are dropped rather than rejected.
*
* Shared by the report runner and the export runner so the `daterange`
* handling in particular cannot drift between them: `To` is pushed forward a
* day because callers mean an INCLUSIVE end date while the SQL bound is
* exclusive (`created_at < :dateTo`).
*/
export function coerceFilterParams(
filters: ExportFilterDef[],
raw: RawFilterQuery,
): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const filter of filters) {
if (filter.type === 'daterange') {
const from = raw[`${filter.key}From`];
const to = raw[`${filter.key}To`];
params[`${filter.key}From`] = from ? new Date(from).toISOString() : null;
params[`${filter.key}To`] = to
? new Date(new Date(to).getTime() + DAY_MS).toISOString()
: null;
} else if (filter.type === 'multiselect') {
const csv = raw[filter.key];
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
params[filter.key] = items.length ? items : null;
} else {
params[filter.key] = raw[filter.key]?.trim() || null;
}
}
return params;
}
/**
* Process-lifetime cache for `optionsQuery` results — small, rarely-changing
* reference lists (23 stations, 18 cargo types) hit on every catalog load.
*
* ponytail: keyed by filter key alone, so two registries sharing a filter key
* share one option list. Key by `${registry}:${filterKey}` if that ever bites.
*/
const optionsCache = new Map<string, ExportFilterOption[]>();
export async function resolveFilterOptions(
filters: ExportFilterDef[],
ds: DataSource,
): Promise<ExportFilterDef[]> {
return Promise.all(
filters.map(async (filter) => {
if (!filter.optionsQuery) return filter;
const cached = optionsCache.get(filter.key);
if (cached) return { ...filter, options: cached, optionsQuery: undefined };
const options = await filter.optionsQuery(ds);
optionsCache.set(filter.key, options);
return { ...filter, options, optionsQuery: undefined };
}),
);
}

View File

@@ -0,0 +1,92 @@
import { resolveJoins } from './export-query.builder';
import { ExportDataset, ExportField } from './export.types';
const field = (key: string, requires?: string[]): ExportField => ({
key,
label: key,
type: 'string',
group: 'g',
select: `x.${key}`,
requires,
});
/** Entities are never dereferenced by resolveJoins — only the alias graph matters. */
const entity = {} as ExportDataset['joins'][number]['entity'];
const dataset = (
joins: ExportDataset['joins'],
alwaysJoin?: string[],
): ExportDataset =>
({
key: 'test',
joins,
alwaysJoin,
fields: [],
}) as unknown as ExportDataset;
describe('resolveJoins', () => {
it('pulls in only the joins the selected fields ask for', () => {
const ds = dataset([
{ alias: 'a', entity, on: 'a.id = b.a_id' },
{ alias: 'z', entity, on: 'z.id = b.z_id' },
]);
expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']);
});
it('selecting nothing still applies alwaysJoin — the count query relies on this', () => {
const ds = dataset(
[
{ alias: 'a', entity, on: 'a.id = b.a_id' },
{ alias: 'z', entity, on: 'z.id = b.z_id' },
],
['a'],
);
expect(resolveJoins(ds, []).map((j) => j.alias)).toEqual(['a']);
});
it('resolves a transitive dependency, dependency first', () => {
const ds = dataset([
{ alias: 'ct', entity, on: 'ct.id = b.contract_id' },
{ alias: 'ctc', entity, on: 'ctc.id = ct.company_id', requires: ['ct'] },
]);
expect(resolveJoins(ds, [field('x', ['ctc'])]).map((j) => j.alias)).toEqual(['ct', 'ctc']);
});
it('resolves a multi-hop chain in order', () => {
const ds = dataset([
{ alias: 'a', entity, on: 'a.id = b.a_id' },
{ alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] },
{ alias: 'cc', entity, on: 'cc.id = bb.c_id', requires: ['bb'] },
]);
expect(resolveJoins(ds, [field('x', ['cc'])]).map((j) => j.alias)).toEqual(['a', 'bb', 'cc']);
});
it('emits a shared join once, not per field that needs it', () => {
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
const joins = resolveJoins(ds, [field('one', ['a']), field('two', ['a'])]);
expect(joins.map((j) => j.alias)).toEqual(['a']);
});
it('does not duplicate a join already pulled in by alwaysJoin', () => {
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }], ['a']);
expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']);
});
it('throws on a cycle rather than looping forever', () => {
const ds = dataset([
{ alias: 'a', entity, on: 'a.id = bb.a_id', requires: ['bb'] },
{ alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] },
]);
expect(() => resolveJoins(ds, [field('x', ['a'])])).toThrow(/join cycle/);
});
it('throws on an undeclared alias — a typo must fail loudly, not silently 42P01', () => {
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
expect(() => resolveJoins(ds, [field('x', ['ghost'])])).toThrow(/unknown join alias "ghost"/);
});
it('a field with no requires pulls in no joins at all', () => {
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
expect(resolveJoins(ds, [field('plain')])).toEqual([]);
});
});

View File

@@ -0,0 +1,101 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ExportContext, ExportDataset, ExportField, ExportJoin } from './export.types';
/**
* Sort expression fallback: the SELECT alias TypeORM emitted, quoted. TypeORM
* double-quotes `addSelect` aliases (preserving case), so ordering by the bare
* key lets Postgres fold it to lowercase and 42703 on any camelCase alias.
*/
export const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`;
/**
* Selected fields -> the joins they need, transitively, dependencies first.
* DFS post-order over `requires`, memoized. Deterministic: `alwaysJoin` first,
* then fields in the dataset's own declaration order.
*/
export function resolveJoins(dataset: ExportDataset, fields: ExportField[]): ExportJoin[] {
const byAlias = new Map(dataset.joins.map((j) => [j.alias, j]));
const out: ExportJoin[] = [];
const done = new Set<string>();
const onStack = new Set<string>();
const visit = (alias: string): void => {
if (done.has(alias)) return;
if (onStack.has(alias)) {
throw new Error(`export "${dataset.key}": join cycle at alias "${alias}"`);
}
const join = byAlias.get(alias);
if (!join) {
throw new Error(`export "${dataset.key}": unknown join alias "${alias}"`);
}
onStack.add(alias);
for (const dep of join.requires ?? []) visit(dep);
onStack.delete(alias);
done.add(alias);
out.push(join);
};
for (const alias of dataset.alwaysJoin ?? []) visit(alias);
for (const field of fields) for (const alias of field.requires ?? []) visit(alias);
return out;
}
/** The download query: base + only the joins the selected fields need. */
export function buildExportQuery(
dataset: ExportDataset,
fields: ExportField[],
ctx: ExportContext,
): SelectQueryBuilder<ObjectLiteral> {
const qb = ctx.ds.createQueryBuilder().from(dataset.base.entity, dataset.base.alias);
for (const join of resolveJoins(dataset, fields)) {
qb.leftJoin(join.entity, join.alias, join.on);
}
for (const field of fields) qb.addSelect(field.select, field.key);
dataset.scope(ctx, qb);
return qb;
}
/**
* The count query: same base, same `scope()`, same WHERE — but no field joins
* and no selects. Exact rather than an estimate, because every lazy join is a
* left join to a to-one side and so cannot change the row count.
*/
export function buildExportCountQuery(
dataset: ExportDataset,
ctx: ExportContext,
): SelectQueryBuilder<ObjectLiteral> {
const qb = ctx.ds
.createQueryBuilder()
.select('COUNT(*)::int', 'total')
.from(dataset.base.entity, dataset.base.alias);
for (const join of resolveJoins(dataset, [])) {
qb.leftJoin(join.entity, join.alias, join.on);
}
dataset.scope(ctx, qb);
return qb;
}
/**
* Resolve a requested sort against the SELECTED fields. Restricting to selected
* fields means a sort can never pull in a join the projection didn't already
* need — which is what keeps the count query's join set correct.
*/
export function resolveExportSort(
dataset: ExportDataset,
fields: ExportField[],
sortBy?: string,
sortOrder?: string,
): { expr: string; dir: 'ASC' | 'DESC' } | null {
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const requested = sortBy && fields.find((f) => f.key === sortBy && f.sortExpr);
if (requested) return { expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir };
if (!dataset.defaultSort) return null;
const fallback = fields.find((f) => f.key === dataset.defaultSort!.key);
if (!fallback) return null;
return {
expr: fallback.sortExpr ?? aliasSortExpr(fallback.key),
dir: dataset.defaultSort.dir,
};
}

View File

@@ -0,0 +1,87 @@
import {
EXPORT_MIME,
formatRowCap,
pickByKey,
resolveExportFormat,
resolveRowLimit,
} from './export-request.util';
import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service';
describe('resolveExportFormat', () => {
it('only \'pdf\' exports as pdf', () => {
expect(resolveExportFormat('pdf')).toBe('pdf');
});
it('\'csv\' exports as csv', () => {
expect(resolveExportFormat('csv')).toBe('csv');
});
it.each([undefined, 'xlsx', 'doc', ''])('%p falls back to xlsx', (raw) => {
expect(resolveExportFormat(raw)).toBe('xlsx');
});
});
describe('formatRowCap', () => {
it('is the format\'s hard ceiling and is not caller-controllable', () => {
expect(formatRowCap('xlsx')).toBe(XLSX_ROW_CAP);
expect(formatRowCap('csv')).toBe(CSV_ROW_CAP);
expect(formatRowCap('pdf')).toBe(PDF_ROW_CAP);
});
});
describe('resolveRowLimit', () => {
it('no limit means "everything, up to the cap"', () => {
expect(resolveRowLimit('xlsx', undefined)).toBeUndefined();
});
it('an explicit limit is the caller asking to be truncated — kept as-is', () => {
// Distinct from the cap: 100 here must yield 100 rows, not a 400, even
// when the unfiltered result is far larger.
expect(resolveRowLimit('pdf', '100')).toBe(100);
});
it('an explicit limit over the format cap is clamped down', () => {
expect(resolveRowLimit('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP);
expect(resolveRowLimit('csv', String(CSV_ROW_CAP + 1))).toBe(CSV_ROW_CAP);
});
it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p means no limit', (raw) => {
expect(resolveRowLimit('xlsx', raw)).toBeUndefined();
});
});
describe('EXPORT_MIME', () => {
it('every format has a content type and a matching extension', () => {
expect(EXPORT_MIME.csv.ext).toBe('csv');
expect(EXPORT_MIME.xlsx.ext).toBe('xlsx');
expect(EXPORT_MIME.pdf.type).toBe('application/pdf');
});
});
describe('pickByKey', () => {
const columns = [
{ key: 'a', label: 'A', type: 'string' as const },
{ key: 'b', label: 'B', type: 'number' as const },
{ key: 'c', label: 'C', type: 'money' as const },
];
it('missing fields returns every column', () => {
expect(pickByKey(columns, undefined)).toEqual(columns);
});
it('empty fields string returns every column', () => {
expect(pickByKey(columns, '')).toEqual(columns);
});
it('a known subset filters to just those, in the source\'s own order', () => {
expect(pickByKey(columns, 'c,a')).toEqual([columns[0], columns[2]]);
});
it('unknown keys are dropped, not passed through', () => {
expect(pickByKey(columns, 'a,ghost')).toEqual([columns[0]]);
});
it('all-unknown keys falls back to everything instead of a blank sheet', () => {
expect(pickByKey(columns, 'ghost,also-ghost')).toEqual(columns);
});
});

View File

@@ -0,0 +1,58 @@
import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service';
export type ExportFormat = 'xlsx' | 'csv' | 'pdf';
/** Anything but the literal 'pdf' or 'csv' exports as xlsx. */
export function resolveExportFormat(raw: string | undefined): ExportFormat {
if (raw === 'pdf') return 'pdf';
if (raw === 'csv') return 'csv';
return 'xlsx';
}
/**
* The format's hard ceiling. Not caller-controllable: exceeding it is an error,
* because a silently short file is worse than a clear failure.
*/
export function formatRowCap(format: ExportFormat): number {
return format === 'pdf' ? PDF_ROW_CAP : format === 'csv' ? CSV_ROW_CAP : XLSX_ROW_CAP;
}
/**
* The caller's deliberate "just the first N rows", clamped to the format cap.
* `undefined` means "everything, up to the cap".
*
* This is a DIFFERENT thing from the cap and must not share a number with it.
* Conflating them (as this code did originally) makes the dialog's
* "Records: First 100" option fail outright on any export with more than 100
* rows — the user explicitly asked to be truncated, so truncating is the
* correct answer, not a 400.
*/
export function resolveRowLimit(
format: ExportFormat,
rawLimit: string | undefined,
): number | undefined {
const requested = Number(rawLimit);
return requested > 0 ? Math.min(requested, formatRowCap(format)) : undefined;
}
/** Content type + file extension per format, for the download response headers. */
export const EXPORT_MIME: Record<ExportFormat, { type: string; ext: string }> = {
xlsx: {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ext: 'xlsx',
},
csv: { type: 'text/csv; charset=utf-8', ext: 'csv' },
pdf: { type: 'application/pdf', ext: 'pdf' },
};
/**
* Caller's requested subset, whitelisted against what they're allowed to have.
* Missing, empty, or all-unknown `raw` falls back to every entry rather than
* shipping a blank sheet. Generic over `{ key }` so it serves both a report's
* `columns` and a dataset's `fields`.
*/
export function pickByKey<T extends { key: string }>(all: T[], raw: string | undefined): T[] {
const requested = raw?.split(',').filter(Boolean);
const filtered = requested?.length ? all.filter((c) => requested.includes(c.key)) : all;
return filtered.length ? filtered : all;
}

View File

@@ -0,0 +1,69 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { coerceFilterParams, RawFilterQuery } from './export-filter.util';
import {
buildExportCountQuery,
buildExportQuery,
resolveExportSort,
} from './export-query.builder';
import { ExportDataset, ExportField } from './export.types';
@Injectable()
export class ExportRunnerService {
constructor(@InjectDataSource() private readonly ds: DataSource) {}
private context(dataset: ExportDataset, raw: RawFilterQuery, directions: string[] | null) {
return { ds: this.ds, params: coerceFilterParams(dataset.filters, raw), directions };
}
/**
* Exact row count for the current filters. Exact rather than estimated
* because lazy joins are all left joins to to-one sides, so the count cannot
* depend on which fields the caller picked.
*/
async count(
dataset: ExportDataset,
raw: RawFilterQuery,
directions: string[] | null,
): Promise<number> {
const qb = buildExportCountQuery(dataset, this.context(dataset, raw, directions));
const row = await qb.getRawOne<{ total: number }>();
return Number(row?.total ?? 0);
}
/**
* Matching rows.
*
* `limit` is the caller's deliberate "first N" truncation — honoured
* silently, because they asked for it. `cap` is the format's hard ceiling —
* exceeding it throws, because a silently short file is worse than a clear
* error: nothing downstream reveals that rows are missing.
*/
async run(
dataset: ExportDataset,
fields: ExportField[],
raw: RawFilterQuery,
directions: string[] | null,
{ cap, limit }: { cap: number; limit?: number },
): Promise<Record<string, unknown>[]> {
const ctx = this.context(dataset, raw, directions);
const qb = buildExportQuery(dataset, fields, ctx);
const sort = resolveExportSort(dataset, fields, raw.sortBy, raw.sortOrder);
if (sort) qb.orderBy(sort.expr, sort.dir);
const ceiling = limit ?? cap;
// ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are
// exactly that many rows" from "there are more".
const items = await qb.limit(ceiling + 1).getRawMany();
if (items.length <= ceiling) return items;
// Asked to be truncated -> truncate. Hit the hard cap -> say so.
if (limit !== undefined) return items.slice(0, limit);
throw new BadRequestException(
`This export has more than ${cap.toLocaleString()} rows, the limit for this format. Narrow the filters, or export a smaller number of rows.`,
);
}
}

View File

@@ -0,0 +1,33 @@
import { bookingsDataset } from './datasets/bookings.dataset';
import { contractsDataset } from './datasets/contracts.dataset';
import { customersDataset } from './datasets/customers.dataset';
import { invoicesDataset } from './datasets/invoices.dataset';
import { locomotivesDataset } from './datasets/locomotives.dataset';
import { paymentsDataset } from './datasets/payments.dataset';
import { trainSchedulesDataset } from './datasets/train-schedules.dataset';
import { trainsDataset } from './datasets/trains.dataset';
import { wagonsDataset } from './datasets/wagons.dataset';
import { ExportDataset } from './export.types';
/**
* Every exportable dataset.
*
* Adding one = a new file under `datasets/` + an entry here. No frontend edit,
* no route, no permission seed — the dialog is driven entirely by the catalog
* this registry serves, and a dataset reuses its module's existing `view` key.
*/
export const DATASETS: ExportDataset[] = [
bookingsDataset,
contractsDataset,
customersDataset,
invoicesDataset,
paymentsDataset,
trainSchedulesDataset,
locomotivesDataset,
trainsDataset,
wagonsDataset,
];
const BY_KEY = new Map(DATASETS.map((d) => [d.key, d]));
export const getDataset = (key: string): ExportDataset | undefined => BY_KEY.get(key);

View File

@@ -0,0 +1,135 @@
import {
DataSource,
EntityTarget,
ObjectLiteral,
ObjectType,
SelectQueryBuilder,
} from 'typeorm';
import { ExportFilterDef } from './export-filter.util';
import { ExportFieldType } from './tabular-export.service';
export type { ExportFieldType };
/**
* A lazily-applied relation.
*
* There is deliberately no `kind: 'inner' | 'left'` here — every join is
* emitted as a LEFT JOIN, and the type makes anything else unrepresentable.
* An inner join added only because someone ticked a checkbox would silently
* change the rowset (ticking "Customer TIN" would drop every booking with a
* null company_id), so two exports of the same filters would disagree on their
* row count. Anything that genuinely must narrow rows belongs in `scope()`,
* where it is unconditional and visible.
*
* The payoff: because a left join to a to-one side can neither add nor remove
* rows, the row count is independent of which fields are selected — which is
* what lets the count endpoint be exact rather than an estimate.
*/
export interface ExportJoin {
/** Alias used by field `select` expressions and by `requires`. */
alias: string;
/** Entity class. Narrower than `EntityTarget` to match TypeORM's join overload. */
entity: ObjectType<ObjectLiteral>;
/** ON condition; may reference the base alias and any alias in `requires`. */
on: string;
/** Other join aliases this join's ON clause depends on. Resolved transitively. */
requires?: string[];
}
/**
* One exportable column.
*
* `select` must yield exactly ONE value per base row. To surface a one-to-many
* relation (a company's profiles, a booking's containers), aggregate inside a
* correlated subquery — `(SELECT string_agg(...) FROM ... WHERE ... = base.id)`
* — rather than adding a join, which would multiply rows and break the count.
*
* Sensitive columns are simply never declared: raw gateway payloads
* (payments.raw_initiation, client_action), signature/crypto blobs
* (invoices.eims_signed_qr), internal error dumps (eims_last_error), raw jsonb
* snapshots (pricing_breakdown, document_snapshot, financial_terms,
* attributes, business_license_files), bare internal user UUIDs, and internal
* review/rejection notes. Fields are opt-in, so omission is the whole
* enforcement mechanism.
*/
export interface ExportField {
/** Response key, sheet header id, and the picker's checkbox id. */
key: string;
label: string;
type: ExportFieldType;
/** Scalar SQL projected as `key`. */
select: string;
/** Join aliases `select` references. Omit for base-table-only fields. */
requires?: string[];
/** Picker group id; must exist in the dataset's `groups`. */
group: string;
/** Pre-ticked when the dialog opens with no preset. */
default?: boolean;
/** ORDER BY expression. Presence makes the field sortable. */
sortExpr?: string;
}
export interface ExportGroup {
id: string;
label: string;
}
export interface ExportContext {
ds: DataSource;
/** Filter values, already coerced by `coerceFilterParams`. */
params: Record<string, unknown>;
/** Trade-scope directions. `null` = unrestricted, `[]` = show nothing. */
directions: string[] | null;
}
export interface ExportDataset {
key: string;
title: string;
description: string;
group: 'Commercial' | 'Operations' | 'Finance' | 'Fleet';
/**
* Permission to export this dataset. Reuses the module's existing `view`
* key — if you may see these rows on their list page, you may export them.
* The export never returns a row the list endpoint would not.
*/
permission: string;
base: { entity: EntityTarget<ObjectLiteral>; alias: string };
joins: ExportJoin[];
/**
* Aliases applied unconditionally because `scope()` references them. This is
* the only reason a join is eager, and the count query applies exactly these.
*/
alwaysJoin?: string[];
groups: ExportGroup[];
fields: ExportField[];
filters: ExportFilterDef[];
/** Must name a field whose `sortExpr` references only the base alias. */
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
/**
* Base WHERE (soft-delete guard), filter application, and the trade-direction
* ACL. Runs identically for the count and download queries, so the row count
* the dialog shows is exactly what lands in the file.
*
* A dataset whose table carries a trade direction MUST apply it here, or the
* export leaks rows the user cannot see on the list page.
*/
scope(ctx: ExportContext, qb: SelectQueryBuilder<ObjectLiteral>): void;
}
/**
* What `GET /exports` serves. `select` / `requires` / `sortExpr` are raw SQL
* and a map of the schema — they never leave the server.
*/
export interface ExportCatalogEntry {
key: string;
title: string;
description: string;
group: ExportDataset['group'];
groups: ExportGroup[];
fields: Pick<ExportField, 'key' | 'label' | 'type' | 'group' | 'default'>[];
filters: ExportFilterDef[];
formats: ('csv' | 'xlsx' | 'pdf')[];
caps: { csv: number; xlsx: number; pdf: number };
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
}

View File

@@ -0,0 +1,156 @@
import { Controller, Get, NotFoundException, Param, Query, Res, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CurrentUser } from '@edr/api-common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { Response } from 'express';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { resolveFilterOptions } from './export-filter.util';
import {
EXPORT_MIME,
formatRowCap,
pickByKey,
resolveExportFormat,
resolveRowLimit,
} from './export-request.util';
import { ExportRunnerService } from './export-runner.service';
import { DATASETS, getDataset } from './export.registry';
import { ExportCatalogEntry, ExportDataset, ExportField } from './export.types';
import { CSV_ROW_CAP, PDF_ROW_CAP, TabularExportService, XLSX_ROW_CAP } from './tabular-export.service';
/** Raw query bag — filter keys are per-dataset, so DTO whitelisting can't police it. */
type RawExportQuery = Record<string, string | undefined>;
const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
/**
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
* the schema — they never leave the server.
*/
const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
key: dataset.key,
title: dataset.title,
description: dataset.description,
group: dataset.group,
groups: dataset.groups,
fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({
key,
label,
type,
group,
default: isDefault,
})),
filters: dataset.filters,
formats: ['csv', 'xlsx', 'pdf'],
caps: CAPS,
defaultSort: dataset.defaultSort,
});
/**
* Generic table export. One dataset per major table, each describing far more
* fields than its list page shows — including related-entity detail.
*/
@ApiTags('Exports')
@ApiBearerAuth()
@Controller('exports')
@UseGuards(JwtGuard)
export class ExportsController {
constructor(
private readonly runner: ExportRunnerService,
private readonly writer: TabularExportService,
private readonly userTradeAccessService: UserTradeAccessService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@Get()
@ApiOperation({ summary: 'List datasets the caller has permission to export' })
async catalog(@CurrentUser() user: TCurrentUser): Promise<ExportCatalogEntry[]> {
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
return Promise.all(
allowed.map(async (d) => ({
...toCatalogEntry(d),
filters: await resolveFilterOptions(d.filters, this.dataSource),
})),
);
}
@Get(':key/count')
@ApiOperation({ summary: 'Exact row count for the given filters, plus the per-format caps' })
async count(
@Param('key') key: string,
@Query() query: RawExportQuery,
@CurrentUser() user: TCurrentUser,
): Promise<{ total: number; caps: typeof CAPS }> {
const dataset = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const total = await this.runner.count(dataset, query, directions);
return { total, caps: CAPS };
}
@Get(':key/download')
@ApiOperation({ summary: 'Export a dataset to csv, xlsx or pdf' })
async download(
@Param('key') key: string,
@Query() query: RawExportQuery & { format?: string; fields?: string; limit?: string },
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
const dataset = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format);
const fields = this.resolveFields(dataset, query.fields);
const rows = await this.runner.run(dataset, fields, query, directions, {
cap: formatRowCap(format),
limit: resolveRowLimit(format, query.limit),
});
const doc = {
title: dataset.title,
description: dataset.description,
label: `export:${dataset.key}`,
columns: fields.map(({ key: k, label, type }) => ({ key: k, label, type })),
rows,
};
const buffer =
format === 'pdf'
? await this.writer.toPdf(doc)
: format === 'csv'
? await this.writer.toCsv(doc)
: await this.writer.toXlsx(doc);
const mime = EXPORT_MIME[format];
const stamp = new Date().toISOString().slice(0, 10);
res.setHeader('Content-Disposition', `attachment; filename="${dataset.key}-${stamp}.${mime.ext}"`);
res.setHeader('Content-Type', mime.type);
res.send(buffer);
}
/**
* Requested fields, whitelisted against the dataset. No `fields=` means the
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
* all of them on an unparameterised call is nobody's intent.
*/
private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] {
if (raw?.trim()) {
const picked = pickByKey(dataset.fields, raw);
// pickByKey falls back to everything when nothing matched; for a dataset
// the safer read of "all keys unknown" is still the default set.
if (picked.length !== dataset.fields.length) return picked;
}
const defaults = dataset.fields.filter((f) => f.default);
return defaults.length ? defaults : dataset.fields;
}
private resolve(key: string, user: TCurrentUser): ExportDataset {
const dataset = getDataset(key);
if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`);
// Export rides the dataset's own list-page view permission: if you may see
// these rows, you may export them.
assertFreightPermission(user, dataset.permission);
return dataset;
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { DocumentsModule } from '../billing/documents/documents.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { ExportRunnerService } from './export-runner.service';
import { ExportsController } from './exports.controller';
import { TabularExportService } from './tabular-export.service';
/**
* Generic table export: a dataset registry describing far more fields than each
* list page shows (related-entity detail included), plus the shared tabular
* writer (csv / xlsx / pdf) the reports module also writes through.
*
* `TabularExportService` is exported so ReportsModule can reuse it without
* pulling in the dataset machinery.
*/
@Module({
imports: [DocumentsModule, UserTradeAccessModule],
controllers: [ExportsController],
providers: [TabularExportService, ExportRunnerService],
exports: [TabularExportService],
})
export class ExportsModule {}

View File

@@ -0,0 +1,65 @@
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { TabularDoc, TabularExportService } from './tabular-export.service';
/** The PDF path is puppeteer-backed; these specs only cover the sheet writers. */
const service = new TabularExportService(null as unknown as PdfRenderService);
const doc: TabularDoc = {
title: 'Bookings',
description: 'every booking',
label: 'test',
columns: [
{ key: 'ref', label: 'Reference', type: 'string' },
{ key: 'customer', label: 'Customer', type: 'string' },
{ key: 'amount', label: 'Amount', type: 'money' },
{ key: 'gov', label: 'Government', type: 'boolean' },
],
rows: [
{ ref: 'BK-1', customer: 'Acme, Inc.', amount: 1234.5, gov: true },
{ ref: 'BK-2', customer: 'Quote "Q" Ltd', amount: null, gov: false },
],
kpis: [{ label: 'Bookings', value: 2 }],
};
describe('TabularExportService.toCsv', () => {
it('quotes a value containing the delimiter — the reason we do not hand-roll join(",")', async () => {
const csv = (await service.toCsv(doc)).toString('utf8');
expect(csv).toContain('"Acme, Inc."');
});
it('escapes embedded double quotes by doubling them', async () => {
const csv = (await service.toCsv(doc)).toString('utf8');
expect(csv).toContain('"Quote ""Q"" Ltd"');
});
it('starts at the header row — no KPI preamble, so the file parses as a plain table', async () => {
const csv = (await service.toCsv(doc)).toString('utf8');
expect(csv.split('\n')[0]).toBe('Reference,Customer,Amount,Government');
expect(csv).not.toContain('Bookings: 2');
});
it('emits one line per row plus the header', async () => {
const csv = (await service.toCsv(doc)).toString('utf8');
expect(csv.trim().split('\n').filter(Boolean)).toHaveLength(3);
});
it('only the selected columns are written, in the order given', async () => {
const csv = (
await service.toCsv({ ...doc, columns: [doc.columns[2], doc.columns[0]] })
).toString('utf8');
expect(csv.split('\n')[0]).toBe('Amount,Reference');
});
});
describe('TabularExportService.toXlsx', () => {
it('writes a real xlsx (a zip, so it starts with the PK magic bytes)', async () => {
const buffer = await service.toXlsx(doc);
expect(buffer.subarray(0, 2).toString('utf8')).toBe('PK');
expect(buffer.length).toBeGreaterThan(1000);
});
it('a title longer than Excel\'s 31-char sheet-name limit does not throw', async () => {
const longTitle = 'A'.repeat(60);
await expect(service.toXlsx({ ...doc, title: longTitle })).resolves.toBeInstanceOf(Buffer);
});
});

View File

@@ -0,0 +1,180 @@
import { Injectable } from '@nestjs/common';
import ExcelJS from 'exceljs';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming
// WorkbookWriter if an export ever needs to outgrow XLSX_ROW_CAP.
export const XLSX_ROW_CAP = 50_000;
// ponytail: CSV is buffered through the same Workbook as xlsx, so it shares the
// cap. Switch to qb.stream() + res.write() if a dataset needs more than this.
export const CSV_ROW_CAP = 50_000;
// ponytail: HTML→PDF render cost grows with row count; larger exports must
// use XLSX or CSV instead.
export const PDF_ROW_CAP = 5_000;
/**
* Value types a tabular export understands. A superset of `ReportColumn['type']`
* so a report's own columns are assignable here unchanged.
*/
export type ExportFieldType =
| 'string'
| 'number'
| 'money'
| 'tons'
| 'percent'
| 'date'
| 'datetime'
| 'boolean';
/** The minimum a column must describe to be written to a sheet. */
export interface ExportColumnLike {
key: string;
label: string;
type: ExportFieldType;
}
/** Headline figures printed above the table. xlsx/pdf only — never in CSV. */
export interface ExportKpiLike {
label: string;
value: number;
unit?: string;
}
/**
* One tabular document, independent of where the rows came from. A report and a
* dataset export both reduce to this, which is what lets them share one writer.
*/
export interface TabularDoc {
/** Sheet name (truncated to Excel's 31-char limit) and the PDF's <h1>. */
title: string;
description?: string;
/** Log label handed to PdfRenderService, e.g. "report:bookings-list". */
label: string;
columns: ExportColumnLike[];
rows: Record<string, unknown>[];
kpis?: ExportKpiLike[];
}
const NUMBER_FORMAT: Partial<Record<ExportFieldType, string>> = {
money: '#,##0.00',
tons: '#,##0.0',
percent: '0"%"',
number: '#,##0',
};
function formatCell(value: unknown, type: ExportFieldType): string {
if (value === null || value === undefined) return '';
if (type === 'money' || type === 'number') {
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 });
}
if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`;
if (type === 'percent') return `${value}%`;
if (type === 'boolean') return value ? 'Yes' : 'No';
return String(value);
}
@Injectable()
export class TabularExportService {
constructor(private readonly pdfRender: PdfRenderService) {}
async toXlsx(doc: TabularDoc): Promise<Buffer> {
const workbook = this.buildWorkbook(doc, { includeKpis: true });
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
/**
* CSV via ExcelJS's own writer, off the same Workbook xlsx builds — it already
* handles quoting, embedded commas and embedded newlines. Hand-rolling
* `row.join(',')` breaks on the first customer name containing a comma.
*
* KPIs are deliberately omitted: a preamble row plus a blank row before the
* header stops the file parsing as a plain table, and CSV's whole point here
* is being machine-readable.
*/
async toCsv(doc: TabularDoc): Promise<Buffer> {
const workbook = this.buildWorkbook(doc, { includeKpis: false });
const buffer = await workbook.csv.writeBuffer();
return Buffer.from(buffer);
}
async toPdf(doc: TabularDoc): Promise<Buffer> {
const html = this.buildHtml(doc);
return this.pdfRender.htmlToPdfBuffer(html, {
label: doc.label,
landscape: true,
// Without this, a box with no Chromium silently degrades to
// genericFallbackPdf — a ~900-character text dump instead of a table.
// buildTabularFallbackPdf parses exactly the markup buildHtml emits.
fallback: buildTabularFallbackPdf,
});
}
private buildWorkbook(doc: TabularDoc, opts: { includeKpis: boolean }): ExcelJS.Workbook {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(doc.title.slice(0, 31));
const { columns, rows, kpis } = doc;
if (opts.includeKpis && kpis?.length) {
sheet.addRow(
kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`),
);
sheet.addRow([]);
}
const headerRow = sheet.addRow(columns.map((c) => c.label));
headerRow.font = { bold: true };
for (const row of rows) {
sheet.addRow(columns.map((c) => row[c.key] ?? null));
}
columns.forEach((col, i) => {
const format = NUMBER_FORMAT[col.type];
const excelCol = sheet.getColumn(i + 1);
excelCol.width = Math.max(col.label.length + 2, 12);
if (format) excelCol.numFmt = format;
});
return workbook;
}
private buildHtml(doc: TabularDoc): string {
const { title, description, columns, rows, kpis } = doc;
const esc = (v: unknown) =>
String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kpiHtml = kpis?.length
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
.map(
(k) =>
`<div class="tile"><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
)
.join('')}</div>`
: '';
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const body = rows
.map(
(row) =>
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
)
.join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
h1 { font-size: 16px; margin-bottom: 4px; }
p.subtitle { color: #666; margin: 0 0 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
th { background: #f3f3f3; }
</style></head><body>
<h1>${esc(title)}</h1>
<p class="subtitle">${esc(description ?? '')}</p>
${kpiHtml}
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
</body></html>`;
}
}

View File

@@ -0,0 +1,74 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDateString,
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
import {
TARGET_DIMENSIONS,
TARGET_METRICS,
TARGET_PERIOD_TYPES,
TargetDimension,
TargetMetric,
TargetPeriodType,
} from '../entities/operations-target.entity';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
export class CreateOperationsTargetDto {
@ApiProperty({ enum: TARGET_PERIOD_TYPES })
@IsIn(TARGET_PERIOD_TYPES as unknown as string[])
periodType!: TargetPeriodType;
@ApiProperty({
example: '2026-08-01',
description: 'Any date inside the bucket — normalised to the bucket start on write.',
})
@IsDateString()
periodStart!: string;
@ApiProperty({ enum: TARGET_METRICS })
@IsIn(TARGET_METRICS as unknown as string[])
metric!: TargetMetric;
@ApiProperty({ enum: TARGET_DIMENSIONS })
@IsIn(TARGET_DIMENSIONS as unknown as string[])
dimension!: TargetDimension;
@ApiProperty({
example: 'CONTAINER_IMPORT_MULTIMODAL',
description: 'Category key, container-class key or yard code — not a display label.',
})
@IsString()
@MaxLength(60)
dimensionKey!: string;
@ApiProperty({ example: 1200 })
@Transform(toNumber)
@IsNumber()
@Min(0)
plannedValue!: number;
@ApiPropertyOptional({
description:
'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.',
example: 'CONTAINER_IMPORT_MULTIMODAL',
})
@IsOptional()
@IsString()
@MaxLength(60)
cargoCategory?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import {
TARGET_DIMENSIONS,
TARGET_METRICS,
TARGET_PERIOD_TYPES,
TargetDimension,
TargetMetric,
TargetPeriodType,
} from '../entities/operations-target.entity';
export class ListOperationsTargetsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: TARGET_PERIOD_TYPES })
@IsOptional()
@IsIn(TARGET_PERIOD_TYPES as unknown as string[])
periodType?: TargetPeriodType;
@ApiPropertyOptional({ enum: TARGET_METRICS })
@IsOptional()
@IsIn(TARGET_METRICS as unknown as string[])
metric?: TargetMetric;
@ApiPropertyOptional({ enum: TARGET_DIMENSIONS })
@IsOptional()
@IsIn(TARGET_DIMENSIONS as unknown as string[])
dimension?: TargetDimension;
}

View File

@@ -0,0 +1,118 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsInt, IsNumber, IsOptional, Min } from 'class-validator';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
/**
* Every field optional — the backoffice form PATCHes only what changed. A
* standard of zero is rejected: it would make every implement-rate division
* blow up or read as infinite achievement.
*/
export class UpdateOperationsStandardsDto {
@ApiPropertyOptional({ example: 10 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
stationStandardHoursEthiopia?: number;
@ApiPropertyOptional({ example: 13 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
stationStandardHoursDjibouti?: number;
@ApiPropertyOptional({ example: 65 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
cycleStandardHoursContainer?: number;
@ApiPropertyOptional({ example: 88 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
cycleStandardHoursBulkDmp?: number;
@ApiPropertyOptional({ example: 96 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
cycleStandardHoursBulkNagad?: number;
@ApiPropertyOptional({ example: 96 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
cycleStandardHoursBulkBcc?: number;
@ApiPropertyOptional({ example: 21 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
defaultLegStandardHours?: number;
@ApiPropertyOptional({ example: 30 })
@IsOptional()
@Transform(toNumber)
@IsInt()
@Min(0)
delayToleranceMinutes?: number;
@ApiPropertyOptional({ example: 20 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsFull20ft?: number;
@ApiPropertyOptional({ example: 40 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsFull40ft?: number;
@ApiPropertyOptional({ example: 2.24 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsEmpty20ft?: number;
@ApiPropertyOptional({ example: 3.88 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsEmpty40ft?: number;
@ApiPropertyOptional({ example: 70 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsPerWagonGeneral?: number;
@ApiPropertyOptional({ example: 38 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsPerWagonPerishable?: number;
@ApiPropertyOptional({ example: 50 })
@IsOptional()
@Transform(toNumber)
@IsInt()
@Min(1)
defaultFullTrainsetWagons?: number;
}

View File

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

View File

@@ -0,0 +1,185 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity } from 'typeorm';
/**
* numeric comes back from pg as a string. Every value here is arithmetic in a
* report expression, so convert on read rather than making each caller do it.
*/
const asNumber = {
to: (value: number) => value,
from: (value: string | null) => (value === null ? null : Number(value)),
};
/**
* Single-row table holding the railway's operating standards — the numbers the
* operations reports measure actual performance against. Same single-row shape
* as `logo_settings` and `exchange_settings`; the app never inserts a second row.
*
* These live in the database rather than in a constants file because the
* business treats them as tunable (the corridor standard is explicitly
* described as "flexible"), and a planner must be able to change one without a
* deployment.
*/
@Entity({ schema: 'freight', name: 'operations_standards' })
export class OperationsStandard extends BaseEntity {
/** Standard time a train may stand at an Ethiopian station, in hours. */
@Column({
name: 'station_standard_hours_ethiopia',
type: 'numeric',
precision: 6,
scale: 2,
default: 10,
transformer: asNumber,
})
stationStandardHoursEthiopia!: number;
/** Standard time a train may stand at a Djibouti station, in hours. */
@Column({
name: 'station_standard_hours_djibouti',
type: 'numeric',
precision: 6,
scale: 2,
default: 13,
transformer: asNumber,
})
stationStandardHoursDjibouti!: number;
/** Container turn-around cycle: 10 + 21 + 13 + 21. */
@Column({
name: 'cycle_standard_hours_container',
type: 'numeric',
precision: 6,
scale: 2,
default: 65,
transformer: asNumber,
})
cycleStandardHoursContainer!: number;
/** Bulk cycle via DMP: 13 + 21 + 33 + 21. */
@Column({
name: 'cycle_standard_hours_bulk_dmp',
type: 'numeric',
precision: 6,
scale: 2,
default: 88,
transformer: asNumber,
})
cycleStandardHoursBulkDmp!: number;
/** Bulk cycle via Negad freight yard: 13 + 21 + 41 + 21. */
@Column({
name: 'cycle_standard_hours_bulk_nagad',
type: 'numeric',
precision: 6,
scale: 2,
default: 96,
transformer: asNumber,
})
cycleStandardHoursBulkNagad!: number;
/** Bulk cycle via BCC: 13 + 21 + 41 + 21. */
@Column({
name: 'cycle_standard_hours_bulk_bcc',
type: 'numeric',
precision: 6,
scale: 2,
default: 96,
transformer: asNumber,
})
cycleStandardHoursBulkBcc!: number;
/**
* Standard running time for one corridor leg, used when the yard pair has no
* `yard_distances.standard_hours` of its own.
*/
@Column({
name: 'default_leg_standard_hours',
type: 'numeric',
precision: 6,
scale: 2,
default: 21,
transformer: asNumber,
})
defaultLegStandardHours!: number;
/** Grace on top of the leg standard before a train counts as delayed. */
@Column({ name: 'delay_tolerance_minutes', type: 'int', default: 30 })
delayToleranceMinutes!: number;
/** Charged tonnage per laden 20ft container. */
@Column({
name: 'charged_tons_full_20ft',
type: 'numeric',
precision: 8,
scale: 2,
default: 20,
transformer: asNumber,
})
chargedTonsFull20ft!: number;
/** Charged tonnage per laden 40ft container. */
@Column({
name: 'charged_tons_full_40ft',
type: 'numeric',
precision: 8,
scale: 2,
default: 40,
transformer: asNumber,
})
chargedTonsFull40ft!: number;
/** Charged tonnage per empty 20ft container. */
@Column({
name: 'charged_tons_empty_20ft',
type: 'numeric',
precision: 8,
scale: 2,
default: 2.24,
transformer: asNumber,
})
chargedTonsEmpty20ft!: number;
/** Charged tonnage per empty 40ft container. */
@Column({
name: 'charged_tons_empty_40ft',
type: 'numeric',
precision: 8,
scale: 2,
default: 3.88,
transformer: asNumber,
})
chargedTonsEmpty40ft!: number;
/** Charged tonnage per wagon of steel, fertilizer, rice, sugar, livestock. */
@Column({
name: 'charged_tons_per_wagon_general',
type: 'numeric',
precision: 8,
scale: 2,
default: 70,
transformer: asNumber,
})
chargedTonsPerWagonGeneral!: number;
/** Charged tonnage per wagon of vegetables, milk, meat and other perishables. */
@Column({
name: 'charged_tons_per_wagon_perishable',
type: 'numeric',
precision: 8,
scale: 2,
default: 38,
transformer: asNumber,
})
chargedTonsPerWagonPerishable!: number;
/**
* Wagons in a full trainset when the cargo type has no
* `cargo_types.full_trainset_wagons` of its own.
*/
@Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 })
defaultFullTrainsetWagons!: number;
/** IAM user id of the last operator to change a standard. */
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
updatedById?: string | null;
}

View File

@@ -0,0 +1,95 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/** Planning buckets the reports offer. Mirrors the reports' period filter. */
export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const;
export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number];
/** What is being planned. */
export const TARGET_METRICS = ['TEU', 'TRAINSET', 'VOLUME_TONS'] as const;
export type TargetMetric = (typeof TARGET_METRICS)[number];
/** Which axis `dimensionKey` names. */
export const TARGET_DIMENSIONS = ['cargo_category', 'station', 'container_class'] as const;
export type TargetDimension = (typeof TARGET_DIMENSIONS)[number];
/**
* How each stored code reads on screen. The columns are enums the reports match
* on, so the stored values must stay exactly as they are — these exist for the
* admin grid, which otherwise shows `VOLUME_TONS` and `cargo_category` verbatim.
*/
export const TARGET_METRIC_LABELS: Record<TargetMetric, string> = {
TEU: 'TEU',
TRAINSET: 'Trainsets',
VOLUME_TONS: 'Volume (tons)',
};
export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
cargo_category: 'Cargo category',
station: 'Station',
container_class: 'Container class',
};
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
week: 'Weekly',
month: 'Monthly',
quarter: 'Quarterly',
year: 'Yearly',
};
/**
* The planned side of every "Plan / Operated / Implement Rate" table in the
* operations reporting spec. One row is one planned number: a period, a metric,
* and the dimension value it applies to.
*
* `dimensionKey` holds a category key (not a label) — the same keys
* `operations-classification.ts` emits, so a report can join on it directly.
*
* Uniqueness on the five-column slot is a partial index in the database
* (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a soft-deleted
* target can be re-created — the same choice `yard_distances` makes.
*/
@Entity({ schema: 'freight', name: 'operations_targets' })
@Index(['metric', 'periodType', 'periodStart'])
export class OperationsTarget extends BaseEntity {
@Column({ name: 'period_type', type: 'varchar', length: 10 })
periodType!: TargetPeriodType;
/** First day of the bucket, normalised on write (Monday, 1st, quarter start). */
@Column({ name: 'period_start', type: 'date' })
periodStart!: string;
@Column({ name: 'metric', type: 'varchar', length: 20 })
metric!: TargetMetric;
@Column({ name: 'dimension', type: 'varchar', length: 20 })
dimension!: TargetDimension;
/** Category key, container-class key, or yard code — never a display label. */
@Column({ name: 'dimension_key', type: 'varchar', length: 60 })
dimensionKey!: string;
@Column({
name: 'planned_value',
type: 'numeric',
precision: 14,
scale: 3,
transformer: {
to: (value: number) => value,
from: (value: string | null) => (value === null ? null : Number(value)),
},
})
plannedValue!: number;
/**
* Only for `station` targets, where the plan is per station AND per cargo
* type — the OCC report plans NagadMojo container and NagadMojo fertilizer
* separately. Null on `cargo_category` and `container_class` targets, whose
* `dimensionKey` already carries the category.
*/
@Column({ name: 'cargo_category', type: 'varchar', length: 60, nullable: true })
cargoCategory?: string | null;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
}

View File

@@ -0,0 +1,26 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OperationsStandard } from './entities/operations-standard.entity';
import { OperationsTarget } from './entities/operations-target.entity';
import { OperationsStandardsController } from './operations-standards.controller';
import { OperationsStandardsService } from './operations-standards.service';
import { OperationsTargetsController } from './operations-targets.controller';
import { OperationsTargetsService } from './operations-targets.service';
/**
* Reference data behind the operations reports: the railway's operating
* standards (one settings row) and the planned targets the reports compare
* actuals against.
*
* Global because the reports module reads the standards row on every run and
* has no other reason to import this.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])],
controllers: [OperationsStandardsController, OperationsTargetsController],
providers: [OperationsStandardsService, OperationsTargetsService],
exports: [OperationsStandardsService, OperationsTargetsService],
})
export class OperationsReportingModule {}

View File

@@ -0,0 +1,30 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto';
import { OperationsStandardsService } from './operations-standards.service';
@ApiTags('operations-standards')
@ApiBearerAuth()
@Controller('operations-standards')
export class OperationsStandardsController {
constructor(private readonly service: OperationsStandardsService) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.operationsStandards.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: 'Standard times and charged-tonnage factors used by the operations reports' })
get() {
return this.service.get();
}
@Patch()
@BookingStaff([FREIGHT_PERMS.settings.operationsStandards.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: 'Change one or more operating standards' })
update(@Body() dto: UpdateOperationsStandardsDto, @CurrentUser() user: TCurrentUser) {
return this.service.update(dto, user?.id ?? null);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto';
import { OperationsStandard } from './entities/operations-standard.entity';
/**
* Owns the single `operations_standards` row — the times and tonnage factors
* every operations report measures actual performance against.
*
* The migration seeds the row, but `get()` creates it on demand as well: a
* report that cannot read a standard would have to fall back to a hardcoded
* number, which is exactly what putting these in the database was meant to
* avoid.
*/
@Injectable()
export class OperationsStandardsService {
constructor(
@InjectRepository(OperationsStandard)
private readonly repository: Repository<OperationsStandard>,
) {}
async get(): Promise<OperationsStandard> {
const existing = await this.repository.findOne({
where: { deletedAt: IsNull() },
order: { createdAt: 'ASC' },
});
if (existing) return existing;
// Every column has a database default, so an empty insert is the seed row.
return this.repository.save(this.repository.create({}));
}
async update(
dto: UpdateOperationsStandardsDto,
userId: string | null,
): Promise<OperationsStandard> {
const current = await this.get();
await this.repository.update(current.id, { ...dto, updatedById: userId });
return this.get();
}
}

View File

@@ -0,0 +1,68 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
RuleEngineCreate,
RuleEngineDelete,
RuleEngineUpdate,
RuleEngineView,
} from '../../common/rule-engine-guards';
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto';
import { OperationsTargetsService } from './operations-targets.service';
@ApiTags('operations-targets')
@Controller('operations-targets')
@ApiBearerAuth()
export class OperationsTargetsController {
constructor(private readonly service: OperationsTargetsService) {}
@Get()
@RuleEngineView('operations-targets')
@ApiOperation({ summary: 'List planned operational targets' })
findAll(@Query() query: ListOperationsTargetsQueryDto) {
return this.service.findAll(query);
}
@Get(':id')
@RuleEngineView('operations-targets')
@ApiOperation({ summary: 'Get a target by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineCreate('operations-targets')
@ApiOperation({ summary: 'Create a planned target' })
create(@Body() dto: CreateOperationsTargetDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineUpdate('operations-targets')
@ApiOperation({ summary: 'Update a planned target' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateOperationsTargetDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineDelete('operations-targets')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a planned target' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,218 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, IsNull, Repository } from 'typeorm';
import { paginateQuery } from '../../common/utils/pagination.util';
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto';
import {
OperationsTarget,
TARGET_DIMENSION_LABELS,
TARGET_METRIC_LABELS,
TARGET_PERIOD_LABELS,
TargetPeriodType,
} from './entities/operations-target.entity';
import {
CARGO_CATEGORIES,
CONTAINER_CLASSES,
} from '../reports/operations-classification';
/**
* Normalises any date inside a bucket to the bucket's first day, matching
* Postgres `date_trunc` — which is what the reports group by. Week starts
* Monday, the same as `date_trunc('week', …)` and ISO week numbering.
*
* Done in UTC throughout: the stored column is a bare `date`, and running the
* arithmetic in local time would shift a 1st-of-month target into the previous
* month for anyone east of Greenwich.
*/
export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string {
const d = new Date(`${value.slice(0, 10)}T00:00:00Z`);
switch (periodType) {
case 'week': {
// getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in.
const offset = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - offset);
break;
}
case 'month':
d.setUTCDate(1);
break;
case 'quarter':
d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1);
break;
case 'year':
d.setUTCMonth(0, 1);
break;
}
return d.toISOString().slice(0, 10);
}
/**
* Flat row shape for the backoffice config grid: the stored codes stay put —
* the reports join on them — and readable twins ride alongside, the same way
* `YardDistancesService` adds `fromYardLabel`.
*/
export type OperationsTargetRow = OperationsTarget & {
metricLabel: string;
dimensionLabel: string;
periodLabel: string;
appliesToLabel: string;
cargoCategoryLabel: string;
};
/**
* Resolved per dimension, not from one merged map: `CONTAINER_EXPORT` is in
* both vocabularies and reads differently in each ("Export container" as a
* cargo category, "Full export container" as a container class). Merging them
* silently gave every cargo-category row the container-class wording.
*/
const LABELS_BY_DIMENSION: Record<string, Map<string, string>> = {
cargo_category: new Map(CARGO_CATEGORIES.map((o) => [o.value, o.label])),
container_class: new Map(CONTAINER_CLASSES.map((o) => [o.value, o.label])),
};
const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category;
@Injectable()
export class OperationsTargetsService {
constructor(
@InjectRepository(OperationsTarget)
private readonly repository: Repository<OperationsTarget>,
) {}
/** Yard code → label, for station targets. Reference data, read per list. */
private async yardLabels(): Promise<Map<string, string>> {
const rows = await this.repository.manager.query<Array<{ code: string; label: string }>>(
`SELECT code, label FROM freight.yards WHERE deleted_at IS NULL`,
);
return new Map(rows.map((r) => [r.code, r.label]));
}
private toRow(target: OperationsTarget, yards: Map<string, string>): OperationsTargetRow {
const appliesToLabel =
target.dimension === 'station'
? (yards.get(target.dimensionKey) ?? target.dimensionKey)
: (LABELS_BY_DIMENSION[target.dimension]?.get(target.dimensionKey) ??
target.dimensionKey);
return Object.assign(target, {
metricLabel: TARGET_METRIC_LABELS[target.metric] ?? target.metric,
dimensionLabel: TARGET_DIMENSION_LABELS[target.dimension] ?? target.dimension,
periodLabel: TARGET_PERIOD_LABELS[target.periodType] ?? target.periodType,
appliesToLabel,
// Only station targets carry one, and it is always a cargo category.
cargoCategoryLabel: target.cargoCategory
? (CARGO_CATEGORY_LABELS.get(target.cargoCategory) ?? target.cargoCategory)
: '',
});
}
async findAll(
query: ListOperationsTargetsQueryDto,
): Promise<PaginatedResponse<OperationsTargetRow>> {
const sortable: Record<string, string> = {
periodStart: 'target.period_start',
metric: 'target.metric',
dimension: 'target.dimension',
dimensionKey: 'target.dimension_key',
plannedValue: 'target.planned_value',
createdAt: 'target.created_at',
};
const sortBy = sortable[query.sortBy ?? ''] ?? sortable.periodStart;
const qb = this.repository
.createQueryBuilder('target')
.orderBy(sortBy, query.sortOrder ?? 'DESC')
.addOrderBy('target.dimension_key', 'ASC');
if (query.periodType) qb.andWhere('target.period_type = :pt', { pt: query.periodType });
if (query.metric) qb.andWhere('target.metric = :m', { m: query.metric });
if (query.dimension) qb.andWhere('target.dimension = :d', { d: query.dimension });
if (query.search) {
qb.andWhere(
new Brackets((w) =>
w
.where('target.dimension_key ILIKE :s', { s: `%${query.search}%` })
.orWhere('target.note ILIKE :s', { s: `%${query.search}%` }),
),
);
}
const [page, yards] = await Promise.all([paginateQuery(qb, query), this.yardLabels()]);
return { ...page, items: page.items.map((t) => this.toRow(t, yards)) };
}
async findById(id: string): Promise<OperationsTarget> {
const found = await this.repository.findOne({ where: { id } });
if (!found) throw new NotFoundException(`Operations target ${id} not found`);
return found;
}
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart);
const cargoCategory = dto.cargoCategory ?? null;
await this.assertSlotFree({ ...dto, periodStart, cargoCategory });
return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory }));
}
async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> {
const current = await this.findById(id);
const periodType = dto.periodType ?? current.periodType;
const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart);
const next = {
periodType,
periodStart,
metric: dto.metric ?? current.metric,
dimension: dto.dimension ?? current.dimension,
dimensionKey: dto.dimensionKey ?? current.dimensionKey,
cargoCategory:
dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null,
};
await this.assertSlotFree(next, id);
await this.repository.update(id, {
...next,
...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}),
...(dto.note !== undefined ? { note: dto.note } : {}),
});
return this.findById(id);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
/**
* One planned number per (period, metric, dimension value). The database
* enforces this too — the check is here to turn a 23505 into a message that
* says which slot is taken.
*/
private async assertSlotFree(
slot: Pick<
OperationsTarget,
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory'
>,
ignoreId?: string,
): Promise<void> {
const existing = await this.repository.findOne({
where: {
periodType: slot.periodType,
periodStart: slot.periodStart,
metric: slot.metric,
dimension: slot.dimension,
dimensionKey: slot.dimensionKey,
cargoCategory: slot.cargoCategory ?? IsNull(),
deletedAt: IsNull(),
},
});
if (existing && existing.id !== ignoreId) {
throw new ConflictException(
`A ${slot.metric} target for ${slot.dimensionKey} in the ${slot.periodType} starting ${slot.periodStart} already exists`,
);
}
}
}

View File

@@ -0,0 +1,173 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportContext, ReportDefinition } from '../report.types';
import {
ACTUAL_TONS_EXPR,
CARGO_CATEGORY_EXPR,
CARGO_CATEGORY_FILTER,
CATEGORY_LABEL_OF,
COUNTRY_FILTER,
LOADED_WAGONS_EXPR,
OPS_DATE,
OPERATIONS_FILTERS,
TEU_EXPR,
allocationLedgerQb,
applyCategoryFilter,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
plannedRowsSql,
} from '../operations-classification';
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
/** The two sides of the line. Anything else is ignored rather than interpolated. */
const COUNTRIES = ['Ethiopia', 'Djibouti'];
const countryOf = (params: Record<string, unknown>): string | null => {
const value = String(params.country ?? '');
return COUNTRIES.includes(value) ? value : null;
};
/**
* Which end of the corridor this report calls "the station".
*
* With a country chosen it is that country's end — the Ethiopian view lists
* GMP, Modjo, Adama and the rest; the Djibouti view lists DMP, DCT and Nagad,
* which is the second format the spec asks for. With no country chosen it is
* the destination, so the report still reads sensibly.
*
* The country is whitelisted above before it reaches the SQL: it arrives as a
* filter value, and a CASE expression cannot take a bound parameter here
* because the same expression has to appear verbatim in the GROUP BY.
*/
const stationExpr = (params: Record<string, unknown>, column: string): string => {
const country = countryOf(params);
if (!country) return `dy.${column}`;
return `CASE WHEN oy.country = '${country}' THEN oy.${column} ELSE dy.${column} END`;
};
/** The other end of the same corridor — the spec's "origination" column. */
const originationExpr = (params: Record<string, unknown>, column: string): string => {
const country = countryOf(params);
if (!country) return `oy.${column}`;
return `CASE WHEN oy.country = '${country}' THEN dy.${column} ELSE oy.${column} END`;
};
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = allocationLedgerQb(ctx);
applyCategoryFilter(qb, ctx.params);
const country = countryOf(ctx.params);
// Only corridors that touch the chosen side have a station on it.
if (country) {
qb.andWhere('(oy.country = :sideCountry OR dy.country = :sideCountry)', {
sideCountry: country,
});
}
return qb;
}
export const cargoVolumeByStationReport: ReportDefinition = {
key: 'cargo-volume-by-station',
title: 'Cargo Volume by Station',
description:
'Tonnage by station and cargo type against plan. Choose a country to switch between ' +
'the Ethiopian view (GMP, Modjo, Dire Dawa, Adama, Sebeta) and the Djibouti view ' +
'(DMP, DCT, Nagad), which changes which end of the corridor counts as the station and ' +
'which counts as the origination. Plan comes from Operational targets, keyed on the ' +
'stations yard code.' +
PLAN_GRANULARITY_NOTE,
group: 'Operations',
filters: [PERIOD_FILTER, COUNTRY_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
columns: [
{ key: 'period', label: 'Period', type: 'string', sortable: true },
{ key: 'station', label: 'Station', type: 'string', sortable: true },
{ key: 'origination', label: 'Origination', type: 'string' },
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Wagons', type: 'number' },
{ key: 'trains', label: 'Trains', type: 'number' },
],
defaultSort: { key: 'operated', dir: 'DESC' },
chart: { type: 'bar', x: 'station', y: ['operated'] },
query(ctx) {
const { params } = ctx;
const bucket = periodTruncExprOn(OPS_DATE, params);
const stationCode = stationExpr(params, 'code');
const operated = baseQuery(ctx)
.select(periodExprOn(OPS_DATE, params), 'period')
.addSelect(stationCode, 'station_code')
.addSelect(`COALESCE(${stationExpr(params, 'label')}, ${stationCode}, '?')`, 'station')
.addSelect(
`COALESCE(${originationExpr(params, 'label')}, ${originationExpr(params, 'code')}, '?')`,
'origination',
)
.addSelect(CARGO_CATEGORY_EXPR, 'category_key')
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated')
.addSelect(TEU_EXPR, 'teu')
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
.groupBy(bucket)
.addGroupBy(stationCode)
.addGroupBy(stationExpr(params, 'label'))
.addGroupBy(originationExpr(params, 'label'))
.addGroupBy(originationExpr(params, 'code'))
.addGroupBy(CARGO_CATEGORY_EXPR);
// A station plan is keyed on station AND cargo type, so the join needs
// both. Full outer, so a station-and-cargo line that was planned and never
// ran still reports its miss — the OCC report is full of those.
const combined = `
SELECT COALESCE(o.period, p.period) AS period,
COALESCE(o.station_code, p.plan_key) AS station_code,
COALESCE(o.station,
(SELECT y2.label FROM freight.yards y2
WHERE y2.code = p.plan_key AND y2.deleted_at IS NULL LIMIT 1),
p.plan_key) AS station,
COALESCE(o.origination, '—') AS origination,
COALESCE(o.category_key, p.plan_category) AS category_key,
COALESCE(o.operated, 0) AS operated,
COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan
FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p
ON p.period = o.period
AND p.plan_key = o.station_code
AND p.plan_category = o.category_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) })
.select('r.period', 'period')
.addSelect('r.station', 'station')
.addSelect('r.origination', 'origination')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.teu::int', 'teu')
.addSelect('r.wagons::int', 'wagons')
.addSelect('r.trains::int', 'trains');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
.addSelect(`COUNT(DISTINCT ${stationExpr(ctx.params, 'code')})::int`, 'stations')
.addSelect(TEU_EXPR, 'teu')
.getRawOne<{ actual: number; stations: number; teu: number }>();
return [
{ label: 'Volume', value: Number(row?.actual ?? 0), unit: 't' },
{ label: 'Stations', value: Number(row?.stations ?? 0) },
{ label: 'TEU', value: Number(row?.teu ?? 0) },
];
},
};

View File

@@ -0,0 +1,109 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportContext, ReportDefinition } from '../report.types';
import {
ACTUAL_TONS_EXPR,
CARGO_CATEGORY_EXPR,
CARGO_CATEGORY_FILTER,
CATEGORY_LABEL_OF,
CHARGED_TONS_EXPR,
LOADED_WAGONS_EXPR,
OPS_DATE,
OPERATIONS_FILTERS,
TEU_EXPR,
allocationLedgerQb,
applyCategoryFilter,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
plannedRowsSql,
} from '../operations-classification';
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = allocationLedgerQb(ctx);
applyCategoryFilter(qb, ctx.params);
return qb;
}
export const cargoVolumePerformanceReport: ReportDefinition = {
key: 'cargo-volume-performance',
title: 'Cargo Volume Performance',
description:
'Tonnage moved per cargo category against plan. Operated is the actual loaded weight ' +
'from the marshalling record; charged volume is the standard weight capacity the same ' +
'cargo is billed on. Plan comes from Operational targets and is measured against the ' +
'actual, not the charged, tonnage.' +
PLAN_GRANULARITY_NOTE,
group: 'Operations',
filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
columns: [
{ key: 'period', label: 'Period', type: 'string', sortable: true },
{ key: 'category', label: 'Cargo category', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
{ key: 'trains', label: 'Trains', type: 'number' },
],
defaultSort: { key: 'operated', dir: 'DESC' },
chart: { type: 'bar', x: 'category', y: ['operated'] },
query(ctx) {
const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
const operated = baseQuery(ctx)
.select(periodExprOn(OPS_DATE, ctx.params), 'period')
.addSelect(CARGO_CATEGORY_EXPR, 'category_key')
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated')
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged_tons')
.addSelect(TEU_EXPR, 'teu')
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
.groupBy(bucket)
.addGroupBy(CARGO_CATEGORY_EXPR);
// Full outer join so a planned cargo category that moved nothing still
// reports its miss instead of disappearing from the table.
const combined = `
SELECT COALESCE(o.period, p.period) AS period,
COALESCE(o.category_key, p.plan_key) AS category_key,
COALESCE(o.operated, 0) AS operated,
COALESCE(o.charged_tons, 0) AS charged_tons,
COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan
FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) p
ON p.period = o.period AND p.plan_key = o.category_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
.select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.charged_tons::float8', 'chargedTons')
.addSelect('r.teu::int', 'teu')
.addSelect('r.wagons::int', 'wagons')
.addSelect('r.trains::int', 'trains');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged')
.addSelect(TEU_EXPR, 'teu')
.getRawOne<{ actual: number; charged: number; teu: number }>();
return [
{ label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' },
{ label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' },
{ label: 'TEU', value: Number(row?.teu ?? 0) },
];
},
};

View File

@@ -0,0 +1,110 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportContext, ReportDefinition } from '../report.types';
import {
ACTUAL_TONS_EXPR,
CARGO_CATEGORY_EXPR,
CARGO_CATEGORY_FILTER,
CARGO_CATEGORY_LABEL_EXPR,
CHARGED_TONS_EXPR,
LOADED_WAGONS_EXPR,
OPERATIONS_FILTERS,
SCHEDULE_EMPTY_WAGONS,
SCHEDULE_KM_EXPR,
TEU_EXPR,
allocationLedgerQb,
applyCategoryFilter,
} from '../operations-classification';
/**
* Distance and empty-wagon count belong to the departure, so they are constant
* within a group that includes `ts.id` — MAX() satisfies Postgres without
* dragging a scalar subselect through the GROUP BY.
*/
const ROUTE_KM = `MAX(${SCHEDULE_KM_EXPR})`;
const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
/**
* Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no
* configured distance. A missing distance is not a zero distance, and zeroing
* it would understate the corridor's work without anyone noticing.
*/
const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${ROUTE_KM}, 1)::float8`;
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${ROUTE_KM}, 1)::float8`;
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = allocationLedgerQb(ctx);
applyCategoryFilter(qb, ctx.params);
return qb;
}
export const chargedVsActualVolumeReport: ReportDefinition = {
key: 'charged-vs-actual-volume',
title: 'Charged and Actual Volumes',
description:
'Charged versus actual volume per train and cargo type, with Ton/Km and Vehicle-Km. ' +
'Charged volume is the standard weight capacity — 20 and 40 tons per laden container, ' +
'2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for perishables — ' +
'all editable in Operating standards. Actual volume is what the marshalling recorded. ' +
'Vehicle-Km counts the empty wagons on that train, so it repeats across the trains ' +
'cargo types rather than being split between them.',
group: 'Operations',
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
{ key: 'station', label: 'Station', type: 'string' },
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
{ key: 'distanceKm', label: 'Distance (km)', type: 'number' },
{ key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true },
{ key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' },
],
defaultSort: { key: 'departedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt')
.addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station')
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
.addSelect(TEU_EXPR, 'teu')
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
.addSelect(`${ROUTE_KM}::float8`, 'distanceKm')
.addSelect(TON_KM, 'tonKm')
.addSelect(VEHICLE_KM, 'vehicleKm')
.groupBy('ts.id')
.addGroupBy('ts.train_number')
.addGroupBy('ts.actual_departure_at')
.addGroupBy('ts.scheduled_departure_date')
.addGroupBy('oy.label')
.addGroupBy('oy.code')
.addGroupBy('dy.label')
.addGroupBy('dy.code')
.addGroupBy(CARGO_CATEGORY_EXPR);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged')
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
.addSelect(
`COUNT(DISTINCT ts.id) FILTER (WHERE ${SCHEDULE_KM_EXPR} IS NULL)::int`,
'unmeasuredTrains',
)
.getRawOne<{ charged: number; actual: number; unmeasuredTrains: number }>();
return [
{ label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' },
{ label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' },
// Always shown, even at zero: a corridor with no configured distance
// silently drops out of Ton/Km, and that must be visible.
{ label: 'Trains without a configured distance', value: Number(row?.unmeasuredTrains ?? 0) },
];
},
};

View File

@@ -0,0 +1,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 },
];
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,150 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.entity';
import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
import {
COUNTRY_FILTER,
DIRECTION_FILTER,
OPS_DATE,
STANDARDS_JOIN,
STATION_STANDARD_HOURS_EXPR,
hoursBetween,
} from '../operations-classification';
/**
* A stay is an ARRIVED followed by the next DEPARTED at the same station by the
* same physical train — NOT by the same schedule.
*
* When a train turns around at a station the two halves belong to different
* departures: the arrival closes the inbound schedule and the departure opens
* the outbound one. Pairing within a schedule finds only pass-through stops and
* silently drops every turnaround, which is the longest stay a train makes.
*/
const TRAIN_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)';
const STAY_WINDOW = `PARTITION BY ${TRAIN_KEY}, ev.yard_id ORDER BY ev.occurred_at`;
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
const STANDARD_HOURS = 's.standard_hours';
/** Every logged stop, with the event that followed it at the same station. */
function stopsQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainCheckpointEvent, 'ev')
.innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL')
.leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL')
.innerJoin(Yard, 'y', 'y.id = ev.yard_id')
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
.where('ev.deleted_at IS NULL')
.andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')")
.select('ts.train_number', 'train_number')
.addSelect("COALESCE(y.label, y.code, '—')", 'station')
.addSelect("COALESCE(y.country, '—')", 'country')
.addSelect('ev.kind', 'kind')
.addSelect('ev.occurred_at', 'arrived_at')
.addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at')
.addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind')
.addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours')
.addSelect("COALESCE(ev.note, '')", 'note');
if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
if (params.trainNumber) {
qb.andWhere('ts.train_number ILIKE :trainNumber', {
trainNumber: `%${params.trainNumber as string}%`,
});
}
if (params.station) qb.andWhere('y.code = :station', { station: params.station });
if (params.country) qb.andWhere('y.country = :country', { country: params.country });
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
/** Only completed stops — an arrival whose departure was also logged. */
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const inner = stopsQuery(ctx);
return ctx.ds
.createQueryBuilder()
.from(`(${inner.getQuery()})`, 's')
.setParameters(inner.getParameters())
.where("s.kind = 'ARRIVED'")
.andWhere("s.next_kind = 'DEPARTED'");
}
export const stationStayingTimeReport: ReportDefinition = {
key: 'station-staying-time',
title: 'Station Staying Time',
description:
'How long each train stood at each station — the logged arrival to the same trains ' +
'next departure from that station — against the standard for that side of the line ' +
'(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' +
'standard needs a reason. Loading and unloading times are not split out: nothing in ' +
'the system records when they start and end yet.',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departure', type: 'daterange' },
DIRECTION_FILTER,
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'station', label: 'Station', type: 'text' },
COUNTRY_FILTER,
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 's.train_number' },
{ key: 'station', label: 'Station', type: 'string', sortable: true, sortExpr: 's.station' },
{ key: 'country', label: 'Country', type: 'string' },
{ key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' },
{ key: 'departedAt', label: 'Departed', type: 'date' },
{ key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS },
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
{ key: 'varianceHours', label: 'Variance (hrs)', type: 'number' },
{ key: 'verdict', label: 'Verdict', type: 'string' },
{ key: 'reason', label: 'Reason', type: 'string' },
],
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select("COALESCE(s.train_number, '—')", 'trainNumber')
.addSelect('s.station', 'station')
.addSelect('s.country', 'country')
.addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
.addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
.addSelect(STAYING_HOURS, 'stayingHours')
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
.addSelect(
`CASE WHEN (${STAYING_HOURS})::numeric <= ${STANDARD_HOURS}
THEN 'Encouraging' ELSE 'Needs reason' END`,
'verdict',
)
// The note staff leave on the checkpoint is the only free text on a stop,
// so it is where a reason for an over-standard stay is recorded today.
.addSelect('s.note', 'reason');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'stops')
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
.addSelect(
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
'overStandard',
)
.getRawOne<{ stops: number; avgHours: number; overStandard: number }>();
return [
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
{ label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' },
{ label: 'Over standard', value: Number(row?.overStandard ?? 0) },
];
},
};

View File

@@ -0,0 +1,121 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportContext, ReportDefinition } from '../report.types';
import {
CONTAINER_CLASSES,
CONTAINER_CLASS_EXPR,
CONTAINER_CLASS_LABEL_OF,
CONTAINERS_EXPR,
OPS_DATE,
OPERATIONS_FILTERS,
TEU_EXPR,
allocationLedgerQb,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
plannedRowsSql,
} from '../operations-classification';
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
const CONTAINERS_20 = `COALESCE(SUM((
SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id
WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL
AND cty.size_ft = 20)), 0)::int`;
const CONTAINERS_40 = `COALESCE(SUM((
SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id
WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL
AND cty.size_ft >= 40)), 0)::int`;
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = allocationLedgerQb(ctx).andWhere("wba.load_type = 'CONTAINER'");
const classes = ctx.params.classes as string[] | null;
if (classes?.length) {
qb.andWhere(`${CONTAINER_CLASS_EXPR} IN (:...classes)`, { classes });
}
return qb;
}
export const teuPerformanceReport: ReportDefinition = {
key: 'teu-performance',
title: 'TEU Performance',
description:
'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' +
'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' +
'marshalling record — the containers actually allocated to wagons — not from the ' +
'billing lines. Plan comes from Operational targets.' +
PLAN_GRANULARITY_NOTE,
group: 'Operations',
filters: [
PERIOD_FILTER,
...OPERATIONS_FILTERS,
{ key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES },
],
columns: [
{ key: 'period', label: 'Period', type: 'string', sortable: true },
{ key: 'containerClass', label: 'Container type', type: 'string', sortable: true },
{ key: 'containers20', label: '20ft', type: 'number', sortable: true },
{ key: 'containers40', label: '40ft', type: 'number', sortable: true },
{ key: 'containers', label: 'Containers', type: 'number', sortable: true },
{ key: 'operated', label: 'Operated (TEU)', type: 'number', sortable: true },
{ key: 'plan', label: 'Plan', type: 'number' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
],
defaultSort: { key: 'operated', dir: 'DESC' },
chart: { type: 'bar', x: 'containerClass', y: ['operated'] },
query(ctx) {
const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
const operated = baseQuery(ctx)
.select(periodExprOn(OPS_DATE, ctx.params), 'period')
.addSelect(CONTAINER_CLASS_EXPR, 'class_key')
.addSelect(CONTAINERS_20, 'containers20')
.addSelect(CONTAINERS_40, 'containers40')
.addSelect(CONTAINERS_EXPR, 'containers')
.addSelect(TEU_EXPR, 'operated')
.groupBy(bucket)
.addGroupBy(CONTAINER_CLASS_EXPR);
// Full outer join so a planned container class that never moved still
// reports, at zero rather than vanishing.
const combined = `
SELECT COALESCE(o.period, p.period) AS period,
COALESCE(o.class_key, p.plan_key) AS class_key,
COALESCE(o.containers20, 0) AS containers20,
COALESCE(o.containers40, 0) AS containers40,
COALESCE(o.containers, 0) AS containers,
COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan
FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) p
ON p.period = o.period AND p.plan_key = o.class_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
.select('r.period', 'period')
.addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass')
.addSelect('r.class_key', 'containerClassKey')
.addSelect('r.containers20::int', 'containers20')
.addSelect('r.containers40::int', 'containers40')
.addSelect('r.containers::int', 'containers')
.addSelect('r.operated::int', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(TEU_EXPR, 'teu')
.addSelect(CONTAINERS_EXPR, 'containers')
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
.getRawOne<{ teu: number; containers: number; trains: number }>();
return [
{ label: 'TEU', value: Number(row?.teu ?? 0) },
{ label: 'Containers', value: Number(row?.containers ?? 0) },
{ label: 'Trains', value: Number(row?.trains ?? 0) },
];
},
};

View File

@@ -0,0 +1,104 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportContext, ReportDefinition } from '../report.types';
import {
DELAY_TOLERANCE_HOURS_EXPR,
DIRECTION_FILTER,
hoursBetween,
legStandardHours,
scheduleLedgerQb,
} from '../operations-classification';
const ACTUAL_HOURS = hoursBetween('ts.actual_departure_at', 'ts.actual_arrival_at');
const STANDARD_HOURS = legStandardHours('ts.origin_station_id', 'ts.destination_station_id');
const DELAY_HOURS = `ROUND((${ACTUAL_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`;
const IS_DELAYED = `(${ACTUAL_HOURS})::numeric > ${STANDARD_HOURS} + ${DELAY_TOLERANCE_HOURS_EXPR}`;
/**
* The note staff left when they logged the arrival — the only free text on the
* leg, and so the only place a delay reason is recorded today.
*/
const ARRIVAL_NOTE = `(
SELECT e.note FROM freight.train_checkpoint_events e
WHERE e.train_schedule_id = ts.id AND e.deleted_at IS NULL
AND e.kind = 'ARRIVED' AND e.note IS NOT NULL
ORDER BY e.occurred_at DESC LIMIT 1
)`;
/**
* Leg running time against the corridor standard.
*
* The leg measured is the departure's own origin → destination, on actual
* timestamps. Per-station legs would be finer, but only the corridor ends carry
* a configured standard (`yard_distances.standard_hours`), which is what a
* delay is judged against.
*/
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = scheduleLedgerQb(ctx)
.andWhere('ts.actual_departure_at IS NOT NULL')
.andWhere('ts.actual_arrival_at IS NOT NULL');
if (ctx.params.delayedOnly === 'true') qb.andWhere(IS_DELAYED);
return qb;
}
export const trainDelaysReport: ReportDefinition = {
key: 'train-delays',
title: 'Train Delays',
description:
'Actual running time per leg against the corridor standard — 21h Negad→GMP and the ' +
'per-pair figures configured on Yard Distances, with the default and the tolerance ' +
'(30 min) in Operating standards. A leg over standard plus tolerance is flagged and ' +
'needs a reason.',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departure', type: 'daterange' },
DIRECTION_FILTER,
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{
key: 'delayedOnly',
label: 'Delayed only',
type: 'select',
options: [{ value: 'true', label: 'Delayed legs only' }],
},
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'origin', label: 'From', type: 'string' },
{ key: 'destination', label: 'To', type: 'string' },
{ key: 'departedAt', label: 'Departed', type: 'date', sortable: true, sortExpr: 'ts.actual_departure_at' },
{ key: 'arrivedAt', label: 'Arrived', type: 'date' },
{ key: 'actualHours', label: 'Actual (hrs)', type: 'number', sortable: true, sortExpr: ACTUAL_HOURS },
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
{ key: 'delayHours', label: 'Delay (hrs)', type: 'number', sortable: true, sortExpr: DELAY_HOURS },
{ key: 'status', label: 'Status', type: 'string' },
{ key: 'reason', label: 'Reason', type: 'string' },
],
defaultSort: { key: 'departedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
.addSelect("COALESCE(oy.label, oy.code, '?')", 'origin')
.addSelect("COALESCE(dy.label, dy.code, '?')", 'destination')
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
.addSelect(ACTUAL_HOURS, 'actualHours')
.addSelect(`ROUND(${STANDARD_HOURS}, 1)::float8`, 'standardHours')
.addSelect(DELAY_HOURS, 'delayHours')
.addSelect(`CASE WHEN ${IS_DELAYED} THEN 'Delayed' ELSE 'On time' END`, 'status')
.addSelect(`COALESCE(${ARRIVAL_NOTE}, '')`, 'reason');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'legs')
.addSelect(`COUNT(*) FILTER (WHERE ${IS_DELAYED})::int`, 'delayed')
.addSelect(`ROUND(AVG((${ACTUAL_HOURS})::numeric), 1)::float8`, 'avgHours')
.getRawOne<{ legs: number; delayed: number; avgHours: number }>();
return [
{ label: 'Legs', value: Number(row?.legs ?? 0) },
{ label: 'Delayed', value: Number(row?.delayed ?? 0) },
{ label: 'Average running time', value: Number(row?.avgHours ?? 0), unit: 'h' },
];
},
};

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