Merge pull request #1377 from Tria-plc/staging

Staging
This commit is contained in:
marshal
2026-08-21 13:27:44 +03:00
committed by GitHub
293 changed files with 24419 additions and 3860 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,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Post-finalization clearance charges billed to the customer: one PORT_CHARGES
* and one MISCELLANEOUS row max per booking, each carrying a document, amount,
* currency and its own payable invoice.
*/
export class BookingClearanceCharge3590000000000 implements MigrationInterface {
name = 'BookingClearanceCharge3590000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_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,
"type" character varying(20) NOT NULL,
"status" character varying(20) NOT NULL DEFAULT 'DOC_UPLOADED',
"file_record_id" uuid,
"amount" numeric(14,2),
"currency" character varying(8),
"invoice_id" uuid,
"uploaded_by_staff_id" uuid,
"uploaded_at" timestamptz,
"billed_by_staff_id" uuid,
"billed_at" timestamptz,
"paid_at" timestamptz,
CONSTRAINT "pk_booking_clearance_charge" PRIMARY KEY ("id"),
CONSTRAINT "fk_booking_clearance_charge_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_booking_type"
ON "freight"."booking_clearance_charge" ("booking_id", "type")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`,
);
}
}

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,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Per-booking clearance action history — drives the History tab. */
export class BookingClearanceEvent3600000000000 implements MigrationInterface {
name = 'BookingClearanceEvent3600000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_event" (
"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,
"action" character varying(64) NOT NULL,
"label" character varying(500) NOT NULL,
"actor_type" character varying(16) NOT NULL DEFAULT 'STAFF',
"actor_id" uuid,
"actor_name" character varying(150),
"metadata" jsonb,
CONSTRAINT "pk_booking_clearance_event" PRIMARY KEY ("id"),
CONSTRAINT "fk_booking_clearance_event_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_event_booking_created"
ON "freight"."booking_clearance_event" ("booking_id", "created_at")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."booking_clearance_event"`,
);
}
}

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,11 @@ 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"],
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "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

@@ -0,0 +1,540 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
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';
/** File-record codes the charge documents are stored under on the booking. */
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
PORT_CHARGES: 'clearance_charge_port',
MISCELLANEOUS: 'clearance_charge_misc',
};
const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
PORT_CHARGES: 'Port charges',
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: 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 {
private readonly logger = new Logger(BookingClearanceChargeService.name);
constructor(
private readonly dataSource: DataSource,
private readonly filesService: FilesService,
private readonly billing: BillingService,
private readonly bookingsService: BookingsService,
private readonly bookingsRepository: BookingsRepository,
private readonly clearanceEvents: ClearanceEventService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private repo() {
return this.dataSource.getRepository(BookingClearanceCharge);
}
/**
* Charges are a post-finalization step: block while the customer's clearance
* documents are still being collected/reviewed.
*/
private assertClearanceFinalized(booking: Booking): void {
const inReview =
booking.status === 'AWAITING_DOCUMENTS' ||
booking.status === 'DOCUMENTS_UNDER_REVIEW';
if (inReview && !booking.preClearanceFinalizedAt) {
throw new BadRequestException(
'Clearance charges open after document clearance is finalized.',
);
}
}
async list(bookingId: string): Promise<Freight.ClearanceCharge[]> {
const charges = await this.repo().find({
where: { bookingId },
order: { createdAt: 'ASC' },
});
if (charges.length === 0) return [];
const files = await this.filesService.findByResource(bookingId, 'bookings');
const fileById = new Map(files.map((f) => [f.id, f]));
const names = await this.bookingsRepository.resolveStaffNames(
charges.flatMap((c) => [c.uploadedByStaffId, c.billedByStaffId]),
);
const invoiceIds = charges
.map((c) => c.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 charges.map((c) => {
const file = c.fileRecordId ? (fileById.get(c.fileRecordId) ?? null) : null;
return {
id: c.id,
bookingId: c.bookingId,
type: c.type,
status: c.status,
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)
: null,
uploadedByName: c.uploadedByStaffId
? (names.get(c.uploadedByStaffId) ?? null)
: null,
uploadedAt: c.uploadedAt ? c.uploadedAt.toISOString() : null,
billedByName: c.billedByStaffId
? (names.get(c.billedByStaffId) ?? null)
: null,
billedAt: c.billedAt ? c.billedAt.toISOString() : null,
paidAt: c.paidAt ? c.paidAt.toISOString() : null,
};
});
}
/** 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,
file: Express.Multer.File,
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking);
const existing = await this.repo().findOne({
where: { bookingId, type: 'PORT_CHARGES' },
});
if (existing && existing.status !== 'DOC_UPLOADED') {
throw new ConflictException(
'The port charge has already been billed — ask GL Ethiopia to revise it instead.',
);
}
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: CHARGE_FILE_CODE.PORT_CHARGES,
file,
},
{ userId: staffId },
);
if (existing) {
await this.repo().update(existing.id, {
fileRecordId: record.id,
uploadedByStaffId: staffId,
uploadedAt: new Date(),
});
} else {
await this.repo().save(
this.repo().create({
bookingId,
type: 'PORT_CHARGES',
status: 'DOC_UPLOADED',
fileRecordId: record.id,
uploadedByStaffId: staffId,
uploadedAt: new Date(),
}),
);
}
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_PORT_DOC_UPLOADED',
label: existing
? 'Replaced the port-charges document'
: 'Uploaded the port-charges document',
actorId: staffId,
metadata: { fileName: file.originalname },
});
return this.list(bookingId);
}
/**
* 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; description?: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
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.');
}
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
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,
description: description || null,
status: 'BILLED',
customerNote: null,
customerDecidedAt: null,
customerDecidedBy: null,
billedByStaffId: staffId,
billedAt: new Date(),
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_BILLED',
label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
charge.type
].toLowerCase()}: ${input.amount} ${currency}${
description ? `${description}` : ''
}`,
actorId: staffId,
metadata: {
chargeType: charge.type,
amount: input.amount,
currency,
description: description || null,
revised,
},
});
return this.list(bookingId);
}
/**
* 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,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
throw new ConflictException(
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
// lookups (findPayable/expirePayable/CBE billQuery) must never match it.
sourceId: charge.id,
type: charge.type,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency,
lines: [
{
chargeType: charge.type,
description: `${CHARGE_LABEL[charge.type]}${
booking.reference ?? bookingId
}${charge.description ? `: ${charge.description}` : ''}`,
amount,
},
],
});
await this.repo().update(charge.id, {
status: 'ACCEPTED',
invoiceId: invoice.id,
customerNote: null,
customerDecidedAt: new Date(),
customerDecidedBy: userId,
});
await this.clearanceEvents.record({
bookingId,
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,
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} accepted; invoice ${invoice.invoiceNumber}`,
);
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 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; description?: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking);
// 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.');
}
// 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',
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()}${description}`,
actorId: staffId,
metadata: {
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
description,
fileName: file.originalname,
},
});
return this.list(bookingId);
}
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
@OnEvent('clearance_charge.invoice.paid')
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
const charge = await this.repo().findOne({
where: { id: payload.sourceId },
});
if (!charge || charge.status === 'PAID') return;
await this.repo().update(charge.id, {
status: 'PAID',
paidAt: new Date(),
});
await this.clearanceEvents.record({
bookingId: charge.bookingId,
action: 'CHARGE_PAID',
label: `${CHARGE_LABEL[charge.type]} paid (invoice ${payload.invoiceNumber})`,
actorType: 'SYSTEM',
metadata: {
chargeType: charge.type,
invoiceNumber: payload.invoiceNumber,
},
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
);
}
}

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

@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, ruleEngineService, contractService };

View File

@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
@@ -172,6 +173,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
@@ -262,6 +264,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, filesService };

View File

@@ -71,6 +71,7 @@ describe('BookingTransitionService — operation review', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, invoiceService };
@@ -172,6 +173,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService };

View File

@@ -32,6 +32,7 @@ describe('BookingTransitionService — paired staff decisions', () => {
{} as never, // invoiceService
{} as never, // containerValidationService
{} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{} as never, // events
undefined, // milestoneService
dataSource as never,

View File

@@ -27,7 +27,16 @@ import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
import {
adHocLabel,
clearanceCodesForBooking,
clearanceDocumentsOpen,
} from './clearance.util';
import {
buildClearanceDocHistory,
type ClearanceDocEvent,
} from './clearance-doc-history.util';
import { ClearanceEventService } from './clearance-event.service';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -68,6 +77,7 @@ export class BookingTransitionService {
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly clearanceEvents: ClearanceEventService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
// Optional + last so the hand-constructed service in *.spec.ts files keeps
@@ -627,8 +637,19 @@ export class BookingTransitionService {
file: { id: string; name: string; url: string } | null;
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
note: string | null;
uploadedAt: string | null;
reviewedAt: string | null;
reviewedByName: string | null;
history: ClearanceDocEvent[];
}>;
allApproved: boolean;
documentsOpen: boolean;
docRequests: Array<{
id: string;
note: string;
byName: string | null;
at: string;
}>;
phase?: string | null;
milestones?: unknown[];
nextAction?: unknown;
@@ -652,6 +673,23 @@ export class BookingTransitionService {
const reviewByKey = new Map(
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
);
const allVersions = await this.filesService.findAllVersionsByResource(
bookingId,
"bookings",
);
const queryNotes = await this.bookingsRepository.findReviewNotes(
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<
ReturnType<BookingTransitionService["getClearanceView"]>
@@ -680,6 +718,18 @@ export class BookingTransitionService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: field.fileKey,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
};
@@ -693,13 +743,27 @@ 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",
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: f.code,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
@@ -712,9 +776,52 @@ export class BookingTransitionService {
outputCode,
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.
@@ -751,12 +858,17 @@ export class BookingTransitionService {
async submitClearanceDocuments(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
]);
// Documents stay open until the shipment is paid — a customs shipment keeps
// collecting paperwork (amended invoices, port documents) well past
// clearance finalization. See {@link clearanceDocumentsOpen}.
if (!clearanceDocumentsOpen(booking)) {
throw new ConflictException(
`Clearance documents are closed for this booking (status "${booking.status}").`,
);
}
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) {
throw new BadRequestException(
@@ -781,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_")
@@ -794,21 +910,41 @@ export class BookingTransitionService {
});
}
await this.bookingsRepository.update(bookingId, {
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
// Only the pre-finalization submission drives the booking into review.
// A later addition (an amended invoice while the shipment is already
// scheduled) must never rewind the status or reopen the phased workflow —
// it lands as a new PENDING document for GL to approve where it stands.
const inDocumentPhase =
booking.status === "AWAITING_DOCUMENTS" ||
booking.status === "DOCUMENTS_UNDER_REVIEW";
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
if (inDocumentPhase) {
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
} as never);
}
}
const fileKeys = files.map((f) => f.fieldname);
await this.clearanceEvents.record({
bookingId,
action: 'DOCS_SUBMITTED',
label: `Customer submitted ${files.length} clearance document(s): ${fileKeys.join(', ')}`,
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { fileKeys },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceDocsUploadedToStaff(fresh);
return fresh;
@@ -861,7 +997,14 @@ export class BookingTransitionService {
note?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
// GL keeps reviewing for as long as the customer can still submit — the
// two sides share one predicate so they can never drift apart. Documents
// added after clearance was finalized still need approving/querying.
if (!clearanceDocumentsOpen(booking)) {
throw new ConflictException(
`Clearance documents are closed for this booking (status "${booking.status}").`,
);
}
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
const existing =
@@ -878,15 +1021,6 @@ export class BookingTransitionService {
"A note is required when querying a document",
);
}
if (
status === 'QUERIED' &&
this.isPhasedCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
'Customer documents cannot be queried after pre-clearance is finalized.',
);
}
await this.bookingsRepository.setDocumentReviewStatus(
bookingId,
@@ -896,6 +1030,16 @@ export class BookingTransitionService {
staffId,
note,
);
await this.clearanceEvents.record({
bookingId,
action: status === 'APPROVED' ? 'DOC_APPROVED' : 'DOC_QUERIED',
label:
status === 'APPROVED'
? `Approved document "${fileKey.replace(/_/g, ' ')}"`
: `Opened query on document "${fileKey.replace(/_/g, ' ')}"`,
actorId: staffId,
metadata: { fileKey, note: note ?? null },
});
if (status === "QUERIED") {
await this.bookingsRepository.createReviewNote(
bookingId,
@@ -903,7 +1047,10 @@ export class BookingTransitionService {
"CHANGES_REQUESTED",
staffId,
);
if (this.isPhasedCustoms(booking)) {
// Reopening the review phase only makes sense while clearance is still
// being decided. Querying a document that arrived afterwards must not
// drag a finalized shipment back into the GL review phase.
if (this.isPhasedCustoms(booking) && !booking.preClearanceFinalizedAt) {
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
@@ -915,7 +1062,10 @@ export class BookingTransitionService {
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedCustoms(updated)) {
// Same reasoning as the query branch: advance the workflow only while
// clearance is still open. Approving a late-added document leaves an
// already-finalized shipment's phase exactly where it is.
if (this.isPhasedCustoms(updated) && !updated.preClearanceFinalizedAt) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
@@ -936,6 +1086,7 @@ export class BookingTransitionService {
async uploadClearanceOutputDocuments(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
@@ -956,6 +1107,15 @@ export class BookingTransitionService {
file,
});
}
await this.clearanceEvents.record({
bookingId,
action: 'OUTPUT_DOCS_UPLOADED',
label: `Uploaded customs output document(s): ${files
.map((f) => f.fieldname.replace(/_/g, ' '))
.join(', ')}`,
actorId: userId ?? null,
metadata: { fileKeys: files.map((f) => f.fieldname) },
});
return this.bookingsService.findById(bookingId);
}
@@ -963,7 +1123,7 @@ export class BookingTransitionService {
* GL confirms clearance: requires every customer document APPROVED (100% gate)
* and, for customs, the required output documents present → CLEARANCE_READY.
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedCustoms(booking)) {
throw new BadRequestException(
@@ -1019,6 +1179,12 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'CLEARANCE_FINALIZED',
label: 'Finalized document approval — clearance ready',
actorId: userId ?? null,
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceReady(fresh);
return fresh;
@@ -1045,6 +1211,8 @@ export class BookingTransitionService {
* the customer pools, so the gate here would wrongly reject them).
*/
bypassDayPool?: boolean;
/** Acting user, recorded in the clearance history. */
userId?: string;
},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
@@ -1159,6 +1327,14 @@ export class BookingTransitionService {
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -38,6 +39,16 @@ import {
} from "@nestjs/swagger";
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';
@@ -170,6 +181,10 @@ export class BookingsController {
private readonly userTradeAccessService: UserTradeAccessService,
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()
@@ -306,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({
@@ -971,10 +1001,12 @@ export class BookingsController {
async submitClearanceDocuments(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.submitClearanceDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -991,11 +1023,13 @@ export class BookingsController {
async proceedToOperation(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
{ userId: resolveAuthUserId(user) },
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1073,6 +1107,239 @@ 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,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
})
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceEventService.list(id);
}
// ── Clearance charges (post-finalization customer billing) ────────────────
@Get(":id/clearance/charges")
@MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
"Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them",
})
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")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document",
})
uploadPortChargeDocument(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
if (!file) throw new BadRequestException("A document file is required");
return this.clearanceChargeService.uploadPortDocument(
id,
file,
resolveAuthUserId(user),
);
}
@Patch(":id/clearance/charges/:chargeId/bill")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)",
})
billClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: BillClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.billCharge(
id,
chargeId,
dto,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/:chargeId/send")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)",
})
sendClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.sendCharge(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/miscellaneous")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send",
})
createMiscellaneousCharge(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body() dto: BillClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
if (!file) throw new BadRequestException("A document file is required");
return this.clearanceChargeService.createMiscellaneous(
id,
file,
dto,
resolveAuthUserId(user),
);
}
// ── 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())
@@ -1081,10 +1348,12 @@ export class BookingsController {
async uploadClearanceOutput(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1095,8 +1364,14 @@ export class BookingsController {
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id);
async finalizeClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.finalizeClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1109,8 +1384,13 @@ export class BookingsController {
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
const booking = await this.bookingClearanceService.requestTransitAssignee(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1123,8 +1403,13 @@ export class BookingsController {
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
const booking = await this.bookingClearanceService.assignTransitAssignee(
id,
transitAgentId,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1208,8 +1493,14 @@ export class BookingsController {
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
})
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
async acceptBookingDraftDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1235,8 +1526,14 @@ export class BookingsController {
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.finalizePreClearance(id);
async finalizeBookingPreClearance(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.finalizePreClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1248,8 +1545,13 @@ export class BookingsController {
async uploadBookingDutySlip(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
const booking = await this.bookingClearanceService.uploadDutySlip(
id,
file,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -37,6 +37,14 @@ import { ContainerValidationService } from './container-validation.service';
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';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
@@ -76,6 +84,9 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckAssignment,
CustomerTruckContainer,
ConsolidationApproval,
BookingClearanceCharge,
BookingClearanceEvent,
AdditionalCharge,
]),
BillingModule,
DocumentsModule,
@@ -111,6 +122,11 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
BookingPayablesService,
ClearanceEventService,
AdditionalChargeRepository,
AdditionalChargeService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,
@@ -126,6 +142,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
exports: [
BookingsService,
BookingsRepository,
ClearanceEventService,
BookingPricingService,
ContainerValidationService,
BookingInvoiceService,

View File

@@ -13,6 +13,7 @@ import {
} from 'typeorm';
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
@@ -78,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';
@@ -619,6 +622,29 @@ export class BookingsRepository extends BaseRepository<Booking> {
});
}
/**
* Bookings (of those given) that have at least one customer document still
* waiting on GL — PENDING or QUERIED. Includes ad-hoc `custom_*` documents,
* which no milestone tracks, so a file added after clearance was finalized
* still surfaces as needing review. One query for a whole queue page.
*/
async findBookingsWithUnreviewedDocuments(
bookingIds: string[],
): Promise<Set<string>> {
if (bookingIds.length === 0) return new Set();
const rows = (await this.dataSource
.getRepository(BookingDocumentReview)
.createQueryBuilder('r')
.select('DISTINCT r.booking_id', 'bookingId')
.where('r.booking_id IN (:...bookingIds)', { bookingIds })
.andWhere('r.status IN (:...statuses)', {
statuses: ['PENDING', 'QUERIED'],
})
.andWhere('r.deleted_at IS NULL')
.getRawMany()) as Array<{ bookingId: string }>;
return new Set(rows.map((r) => r.bookingId));
}
findDocumentReview(
bookingId: string,
settingCode: string,
@@ -660,6 +686,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
await repo.save(repo.create({ ...input, status: 'PENDING' }));
}
/** Display names for reviewer staff ids — one query for the whole set. */
async resolveStaffNames(
staffIds: (string | null | undefined)[],
): Promise<Map<string, string>> {
return resolveIamUserNames(this.dataSource, staffIds);
}
/** GL marks a document APPROVED or QUERIED (with an optional note). */
async setDocumentReviewStatus(
bookingId: string,
@@ -1120,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

@@ -0,0 +1,74 @@
import type { BookingDocumentReview } from './entities/booking-document-review.entity';
import type { BookingReviewNote } from './entities/booking-review-note.entity';
import type { FileRecord } from '../files/entities/file.entity';
/** One entry of a clearance document's per-card audit trail, oldest first. */
export interface ClearanceDocEvent {
type: 'UPLOADED' | 'RESUBMITTED' | 'QUERIED' | 'APPROVED';
at: string;
byName: string | null;
note: string | null;
}
/**
* Query review notes are written as `Document "<fileKey>" queried: <note>`
* (see BookingTransitionService.reviewDocument) — the only place a past query
* decision survives after the customer re-uploads and the review row resets.
*/
const QUERY_NOTE_RE = /^Document "(.+?)" queried: ([\s\S]*)$/;
/**
* Per-document audit trail assembled from data the flow already persists:
* every stored file version (first = customer upload, later ones = the
* customer's amendment responses), every query note (who opened it, when,
* why), and the review row's current approval. Approvals that were later
* reset by a re-upload are the one thing not kept anywhere — the trail shows
* the decision that currently stands.
*/
export function buildClearanceDocHistory(input: {
fileKey: string;
/** All versions of all files on the booking, createdAt ASC, deleted included. */
allVersions: FileRecord[];
/** CHANGES_REQUESTED review notes for the booking. */
queryNotes: BookingReviewNote[];
review: BookingDocumentReview | null;
/** staff id → display name. */
names: Map<string, string>;
}): ClearanceDocEvent[] {
const { fileKey, allVersions, queryNotes, review, names } = input;
const events: ClearanceDocEvent[] = [];
const versions = allVersions.filter((v) => v.code === fileKey);
versions.forEach((v, i) => {
events.push({
type: i === 0 ? 'UPLOADED' : 'RESUBMITTED',
at: v.createdAt.toISOString(),
byName: v.uploadedByName ?? null,
note: null,
});
});
for (const n of queryNotes) {
const m = QUERY_NOTE_RE.exec(n.note);
if (!m || m[1] !== fileKey) continue;
events.push({
type: 'QUERIED',
at: n.createdAt.toISOString(),
byName: n.authorId ? (names.get(n.authorId) ?? null) : null,
note: m[2] || null,
});
}
if (review?.status === 'APPROVED' && review.reviewedAt) {
events.push({
type: 'APPROVED',
at: review.reviewedAt.toISOString(),
byName: review.reviewedByStaffId
? (names.get(review.reviewedByStaffId) ?? null)
: null,
note: null,
});
}
return events.sort((a, b) => a.at.localeCompare(b.at));
}

View File

@@ -0,0 +1,47 @@
import { Booking } from './entities/booking.entity';
import { clearanceDocumentsOpen } from './clearance.util';
/**
* The customer may attach clearance documents — and GL may review them — right
* up to payment, not merely until clearance is finalized. Both the upload and
* the review endpoint gate on this one predicate, so a drift here silently
* desynchronizes the two sides.
*/
const booking = (patch: Partial<Booking>): Booking =>
({ status: 'CLEARANCE_READY', paymentStatus: 'PENDING', ...patch }) as Booking;
describe('clearanceDocumentsOpen', () => {
it('stays open across the whole pre-payment flow', () => {
for (const status of [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
'OPERATION_REQUEST_PENDING',
'SELECTED_FOR_BATCH',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
]) {
expect(clearanceDocumentsOpen(booking({ status }))).toBe(true);
}
});
it('closes once the shipment is paid or finished', () => {
for (const status of ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED']) {
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
}
});
it('closes on a dead booking', () => {
for (const status of ['REJECTED', 'CANCELLED', 'EXPIRED']) {
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
}
});
it('closes when payment settled before the status caught up', () => {
expect(
clearanceDocumentsOpen(
booking({ status: 'PNR_GENERATED', paymentStatus: 'PAID' }),
),
).toBe(false);
});
});

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { Freight } from '@edr/types';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import {
BookingClearanceEvent,
ClearanceEventActorType,
} from './entities/booking-clearance-event.entity';
export interface RecordClearanceEventInput {
bookingId: string;
action: string;
/** Human sentence for the History tab, frozen at write time. */
label: string;
actorType?: ClearanceEventActorType;
/** IAM user id (staff or portal customer); name is resolved here. */
actorId?: string | null;
metadata?: Record<string, unknown> | null;
/** Join the caller's transaction so the event commits (or rolls back) with the action. */
manager?: EntityManager;
}
/**
* The clearance History tab's write/read path. Every clearance mutation calls
* {@link record} — document reviews, phased workflow steps, customer charges.
* Recording is deliberately NOT fire-and-forget: the insert shares the caller's
* transaction when a manager is passed, and otherwise a failed insert fails the
* action, because a silent gap in an audit trail is worse than a retry.
*/
@Injectable()
export class ClearanceEventService {
private readonly logger = new Logger(ClearanceEventService.name);
constructor(private readonly dataSource: DataSource) {}
async record(input: RecordClearanceEventInput): Promise<void> {
const mg = input.manager ?? this.dataSource.manager;
const actorName = input.actorId
? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get(
input.actorId,
) ?? null)
: null;
await mg.save(
mg.create(BookingClearanceEvent, {
bookingId: input.bookingId,
action: input.action,
label: input.label,
actorType: input.actorType ?? 'STAFF',
actorId: input.actorId ?? null,
actorName,
metadata: input.metadata ?? null,
}),
);
this.logger.log(
`clearance-history ${input.action} on booking ${input.bookingId}${
actorName ? ` by ${actorName}` : ''
}`,
);
}
/** History for one booking, newest first. */
async list(bookingId: string): Promise<Freight.ClearanceHistoryEvent[]> {
const rows = await this.dataSource
.getRepository(BookingClearanceEvent)
.find({ where: { bookingId }, order: { createdAt: 'DESC' } });
// Rows whose actor name failed to resolve at write time get one more try.
const missing = rows
.filter((r) => !r.actorName && r.actorId)
.map((r) => r.actorId as string);
const names = missing.length
? await resolveIamUserNames(this.dataSource, missing).catch(
() => new Map<string, string>(),
)
: new Map<string, string>();
return rows.map((r) => ({
id: r.id,
action: r.action,
label: r.label,
actorType: r.actorType,
actorName:
r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null),
metadata: r.metadata ?? null,
at: r.createdAt.toISOString(),
}));
}
}

View File

@@ -113,3 +113,53 @@ export function clearanceCodesForBooking(booking: Booking): {
includesCustoms,
};
}
/**
* Statuses after which clearance documents are closed: the shipment is paid
* and moving. Everything before that — review, clearance ready, operation
* request, batch selection, PNR, payment verification — still accepts new
* customer documents and still lets GL review them.
*/
const CLEARANCE_DOCS_CLOSED_STATUSES = new Set<string>([
'PAID',
'IN_TRANSIT',
'ARRIVED',
'COMPLETED',
'REJECTED',
'CANCELLED',
'EXPIRED',
]);
/**
* True while the customer may still attach clearance documents and GL may
* still approve or query them.
*
* Clearance finalization is NOT the cut-off: a customs shipment keeps
* collecting paperwork (amended invoices, revised packing lists, port
* documents) right up to the final invoice being settled. Both the customer's
* upload endpoint and GL's review endpoint gate on this one predicate, so the
* two sides can never drift apart.
*/
export function clearanceDocumentsOpen(booking: Booking): boolean {
if (CLEARANCE_DOCS_CLOSED_STATUSES.has(booking.status)) return false;
// Payment settled ahead of the status transition (webhook ordering).
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

@@ -0,0 +1,37 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsNumber,
IsOptional,
IsPositive,
IsString,
Length,
MaxLength,
} from 'class-validator';
export class BillClearanceChargeDto {
@ApiProperty({ example: 12500.5 })
@Type(() => Number)
@IsNumber()
@IsPositive()
amount!: number;
@ApiProperty({ example: 'ETB' })
@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

@@ -0,0 +1,86 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const CLEARANCE_CHARGE_TYPES = ['PORT_CHARGES', 'MISCELLANEOUS'] as const;
export type ClearanceChargeType = (typeof CLEARANCE_CHARGE_TYPES)[number];
export const CLEARANCE_CHARGE_STATUSES = [
'DOC_UPLOADED',
'BILLED',
'SENT',
'REJECTED',
'ACCEPTED',
'PAID',
] as const;
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
/**
* 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'])
export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'type', type: 'varchar', length: 20 })
type!: ClearanceChargeType;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DOC_UPLOADED' })
status!: ClearanceChargeStatus;
/** The supporting charge document (FileRecord). */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
amount?: string | null;
@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;
@Column({ name: 'uploaded_by_staff_id', type: 'uuid', nullable: true })
uploadedByStaffId?: string | null;
@Column({ name: 'uploaded_at', type: 'timestamptz', nullable: true })
uploadedAt?: Date | null;
@Column({ name: 'billed_by_staff_id', type: 'uuid', nullable: true })
billedByStaffId?: string | null;
@Column({ name: 'billed_at', type: 'timestamptz', nullable: true })
billedAt?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
}

View File

@@ -0,0 +1,48 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const CLEARANCE_EVENT_ACTOR_TYPES = ['STAFF', 'CUSTOMER', 'SYSTEM'] as const;
export type ClearanceEventActorType = (typeof CLEARANCE_EVENT_ACTOR_TYPES)[number];
/**
* One row per action in a booking's clearance flow — the History tab's source
* of truth. Written explicitly (and, where the caller runs one, inside the
* caller's transaction) by every clearance mutation: document review, phased
* workflow steps (transit, declaration, duty, DO/RO, permits), and customer
* charges. `action` is a stable machine code; `label` is the human sentence
* rendered as written, so old rows survive later wording changes.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_event' })
@Index(['bookingId', 'createdAt'])
export class BookingClearanceEvent extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
/** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */
@Column({ name: 'action', type: 'varchar', length: 64 })
action!: string;
/** Human sentence shown in the History tab, frozen at write time. */
@Column({ name: 'label', type: 'varchar', length: 500 })
label!: string;
@Column({ name: 'actor_type', type: 'varchar', length: 16, default: 'STAFF' })
actorType!: ClearanceEventActorType;
/** IAM user id of the actor (null for SYSTEM events). */
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
actorId?: string | null;
/** Display name resolved at write time (iam.users); null when unresolvable. */
@Column({ name: 'actor_name', type: 'varchar', length: 150, nullable: true })
actorName?: string | null;
/** Action details: fileKey, note, amount, currency, file names, … */
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: Record<string, unknown> | 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

@@ -40,6 +40,9 @@ function makeService(overrides?: {
findDocumentReviews: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(booking),
findByStatuses: jest.fn().mockResolvedValue([]),
findBookingsWithUnreviewedDocuments: jest
.fn()
.mockResolvedValue(new Set<string>()),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
@@ -115,6 +118,7 @@ function makeService(overrides?: {
} as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
{ record: jest.fn() } as never, // clearanceEvents
);
return {

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';
@@ -35,6 +35,13 @@ import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
import {
buildClearanceDocHistory,
type ClearanceDocEvent,
} from '../bookings/clearance-doc-history.util';
import { ClearanceEventService } from '../bookings/clearance-event.service';
import { clearanceDocumentsOpen } from '../bookings/clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
export interface BookingClearanceView {
@@ -52,8 +59,19 @@ export interface BookingClearanceView {
file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
note: string | null;
uploadedAt: string | null;
reviewedAt: string | null;
reviewedByName: string | null;
history: ClearanceDocEvent[];
}>;
allApproved: boolean;
documentsOpen: boolean;
docRequests: Array<{
id: string;
note: string;
byName: string | null;
at: string;
}>;
phase?: string | null;
milestones?: Array<{
id: string;
@@ -160,6 +178,7 @@ export class BookingClearanceService {
private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
private readonly yardScope: YardScopeService,
private readonly clearanceEvents: ClearanceEventService,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -185,6 +204,23 @@ export class BookingClearanceService {
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
const allVersions = await this.filesService.findAllVersionsByResource(
bookingId,
'bookings',
);
const queryNotes = await this.bookingsRepository.findReviewNotes(
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'] = [];
@@ -208,6 +244,18 @@ export class BookingClearanceService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: field.fileKey,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
};
@@ -220,13 +268,27 @@ 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',
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: f.code,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
@@ -285,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);
@@ -307,6 +370,13 @@ export class BookingClearanceService {
outputCode,
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,
@@ -479,6 +549,7 @@ export class BookingClearanceService {
async requestTransitAssignee(
bookingId: string,
note: string | undefined,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
@@ -486,6 +557,13 @@ export class BookingClearanceService {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestNote: note?.trim() || null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_REQUESTED',
label: 'Requested a transit assignee from GL Djibouti',
actorId: userId ?? null,
metadata: { note: note?.trim() || null },
});
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
return this.bookingsService.findById(bookingId);
@@ -497,7 +575,11 @@ export class BookingClearanceService {
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
async assignTransitAssignee(
bookingId: string,
transitAgentId: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
@@ -511,6 +593,13 @@ export class BookingClearanceService {
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
label: `Assigned transit officer "${agent.name}"`,
actorId: userId ?? null,
metadata: { transitAgentId, agentName: agent.name, previous },
});
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
@@ -563,6 +652,13 @@ export class BookingClearanceService {
? ContractDocPhase.GlEtPostClearance
: ContractDocPhase.CustomerDuty,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DECLARATION_UPLOADED',
label: `Uploaded customs declaration (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
@@ -616,6 +712,19 @@ export class BookingClearanceService {
);
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
}
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_ADVISED',
label: dto.dutyRequired
? `Advised duty/tax of ${dto.amount} ${dto.currency ?? 'ETB'}`
: 'Advised that no duty/tax applies',
actorId: userId ?? null,
metadata: {
dutyRequired: dto.dutyRequired,
amount: dto.amount ?? null,
currency: dto.currency ?? null,
},
});
return this.bookingsService.findById(bookingId);
}
@@ -663,6 +772,14 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_SENT',
label: `Sent draft customs declaration (estimated ${price} ${currency})`,
actorId: userId ?? null,
metadata: { price, currency, fileNames: files.map((f) => f.originalname) },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationReady(updated, price, currency);
return updated;
@@ -672,7 +789,7 @@ export class BookingClearanceService {
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.
*/
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
async acceptDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
@@ -684,6 +801,13 @@ export class BookingClearanceService {
}
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_ACCEPTED',
label: 'Customer accepted the draft customs declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
@@ -733,12 +857,25 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_CHANGE_REQUESTED',
label: 'Customer requested a change to the draft declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { note: note.trim() },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
async uploadDutySlip(
bookingId: string,
file: Express.Multer.File,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty slip upload applies only to import bookings.');
@@ -760,6 +897,15 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_SLIP_UPLOADED',
label: 'Customer uploaded the duty/tax payment slip',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { fileName: file.originalname },
});
this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId);
}
@@ -792,11 +938,18 @@ export class BookingClearanceService {
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_PERMIT_UPLOADED',
label: `Uploaded transit permit (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
async finalizePreClearance(bookingId: string): Promise<Booking> {
async finalizePreClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
@@ -816,6 +969,12 @@ export class BookingClearanceService {
preClearanceFinalizedAt: new Date(),
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'PRE_CLEARANCE_FINALIZED',
label: 'Finalized pre-clearance — handed over to GL Djibouti collection',
actorId: userId ?? null,
});
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(bookingId, 'bookings');
@@ -849,6 +1008,17 @@ export class BookingClearanceService {
vesselArrivalDate,
doCollectedDate,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DELIVERY_ORDER_UPLOADED',
label: 'Uploaded Delivery Order',
actorId: userId ?? null,
metadata: {
vesselArrivalDate: vesselArrivalDate ?? null,
doCollectedDate: doCollectedDate ?? null,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
@@ -906,6 +1076,16 @@ export class BookingClearanceService {
vesselDepartureDate,
roAmendmentRequestedAt: null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'RELEASE_ORDER_UPLOADED',
label: `Uploaded Release Order (vessel departs ${vesselDepartureDate})`,
actorId: userId ?? null,
metadata: {
vesselDepartureDate,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (leadDays < minDays) {
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
@@ -965,6 +1145,13 @@ export class BookingClearanceService {
userId,
);
}
await this.clearanceEvents.record({
bookingId,
action: 'RO_AMENDMENT_REQUESTED',
label: 'Requested a port amendment on the Release Order',
actorId: userId ?? null,
metadata: { note: reason },
});
return this.bookingsService.findById(bookingId);
}
@@ -980,6 +1167,12 @@ export class BookingClearanceService {
'EXPORT_RELEASED',
);
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
await this.clearanceEvents.record({
bookingId,
action: 'EXPORT_RELEASE_CONFIRMED',
label: 'Confirmed export release',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
@@ -991,8 +1184,30 @@ export class BookingClearanceService {
for (const b of candidates) {
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
if (!belongsOnEtClearanceQueue(milestones)) continue;
// Surfaced on the queue row: every required document is approved even
// though the booking status stays DOCUMENTS_UNDER_REVIEW until finalize.
(b as Booking & { allDocsApproved?: boolean }).allDocsApproved =
milestones.some(
(m) =>
m.milestoneCode === 'DOCUMENTS_APPROVED' &&
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
);
filtered.push(b);
}
// A document added after clearance was finalized lands as PENDING without
// moving the booking's status — the row would otherwise still read
// "Clearance ready" while GL has something waiting. Ad-hoc documents are
// tracked by no milestone, so this reads the review rows directly.
const pending = await this.bookingsRepository.findBookingsWithUnreviewedDocuments(
filtered.map((b) => b.id),
);
for (const b of filtered) {
(b as Booking & { hasDocumentsAwaitingReview?: boolean })
.hasDocumentsAwaitingReview = pending.has(b.id);
}
const rows = await this.attachContractSummary(filtered);
return this.narrowToYardScope(rows, user);
}

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

@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Where the booking-contract view reads the global stamp live, the contracts
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
* company stamp can never restamp an already-executed contract. These specs
* pin the sourcing split: EDR always seals with the global stamp and staff
* never supply one, while the customer must upload their own.
* The staff signature seals with the ONE global stamp by REFERENCE: the
* signature row stores the current global stampFileId instead of re-uploading
* a copy per contract. That id stays valid after the stamp is replaced
* (StampSettingsService never deletes retired stamp files), so each contract
* keeps the exact seal it was signed with. These specs pin the sourcing
* split: EDR always seals with the global stamp and staff never supply one,
* while the customer must upload their own.
*/
describe('applySignature stamp sourcing', () => {
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
const GLOBAL_STAMP_FILE_ID = 'file-global-stamp';
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
const build = (globalStampFileId: string | null = GLOBAL_STAMP_FILE_ID) => {
const uploads: Array<{ code: string; image: string }> = [];
const saved: unknown[] = [];
const service = Object.create(
@@ -22,7 +25,10 @@ describe('applySignature stamp sourcing', () => {
Object.assign(service, {
logger: { warn: jest.fn(), log: jest.fn() },
stampSettings: {
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
get: jest.fn().mockResolvedValue({
id: 's-1',
stampFileId: globalStampFileId,
}),
},
contractsRepository: {
saveSignature: jest.fn((row: unknown) => {
@@ -62,35 +68,36 @@ describe('applySignature stamp sourcing', () => {
signatureImageBase64: 'data:image/png;base64,U0lH',
};
it('seals the EDR side with the global stamp', async () => {
it('seals the EDR side by referencing the global stamp file, without re-uploading it', async () => {
const { service, uploads, saved } = build();
await apply(service, staffDto);
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.code)).toEqual(['signature_staff']);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
it('ignores a stamp a staff client tries to supply', async () => {
const { service, uploads } = build();
const { service, uploads, saved } = build();
await apply(service, {
...staffDto,
stampImageBase64: 'data:image/png;base64,SEFDSw==',
});
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.image)).not.toContain(
'data:image/png;base64,SEFDSw==',
);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
/**
* Failing loudly matters here: getStampImageUrl degrades to null when the
* stamp cannot be inlined, and silently executing an unsealed contract would
* be worse than refusing to counter-sign.
* Failing loudly matters here: silently executing an unsealed contract
* would be worse than refusing to counter-sign.
*/
it('refuses to counter-sign when no global stamp is configured', async () => {
const { service, saved } = build(null);

View File

@@ -1128,17 +1128,27 @@ export class ContractTransitionService {
);
}
// Snapshot whichever stamp applies onto the signature row rather than
// referencing the global one, so replacing the company stamp later can
// never restamp an already-executed contract.
let stampImageBase64 = dto.stampImageBase64 ?? null;
// STAFF seals by REFERENCE to the one global stamp file — no per-contract
// copy of the image. Safe because StampSettingsService.setStamp/clearStamp
// never delete a replaced stamp file: the referenced id keeps rendering
// the exact seal that was current at signing, even after the global stamp
// is later replaced. The customer's stamp is their own upload and is still
// stored per contract.
let stampFileId: string | null = null;
if (role === 'STAFF') {
stampImageBase64 = await this.stampSettings.getStampImageUrl();
if (!stampImageBase64) {
stampFileId = (await this.stampSettings.get()).stampFileId ?? null;
if (!stampFileId) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
} else if (dto.stampImageBase64) {
const stampRecord = await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
);
stampFileId = stampRecord.id;
}
const fileRecord = await this.uploadSignatureAsset(
@@ -1146,13 +1156,6 @@ export class ContractTransitionService {
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
stampImageBase64,
)
: null;
await this.contractsRepository.saveSignature({
contractId: contract.id,
@@ -1160,7 +1163,7 @@ export class ContractTransitionService {
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
stampFileId: stampRecord?.id ?? null,
stampFileId,
consentText: dto.consentText ?? 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

@@ -84,6 +84,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
});
}
/**
* Every version of every document on a resource, oldest first — superseded
* versions included. One query for a whole document grid's upload history.
*/
findAllVersionsByResource(
resourceId: string,
resource: string,
): Promise<FileRecord[]> {
return this.repository.find({
where: { resourceId, resource },
withDeleted: true,
order: { createdAt: "ASC" },
});
}
/**
* Documents belonging to any of the given resources that a reviewer has asked
* the customer to correct. Used by the approval gate, so it takes a list of

View File

@@ -327,6 +327,14 @@ export class FilesService {
return this.filesRepository.findByResource(resourceId, resource);
}
/** All versions of every document on a resource (superseded included), oldest first. */
findAllVersionsByResource(
resourceId: string,
resource: string,
): Promise<FileRecord[]> {
return this.filesRepository.findAllVersionsByResource(resourceId, resource);
}
/**
* Files for many resources of one kind, grouped by resource id. Resources with
* no files are absent from the map (callers should default to `[]`).

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

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