mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
merge
This commit is contained in:
@@ -51,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||
import { OperationsReportingModule } from "./modules/operations-reporting/operations-reporting.module";
|
||||
import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
|
||||
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
||||
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||
@@ -101,6 +102,7 @@ import { RoutesModule } from "./modules/routes/routes.module";
|
||||
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||
import { OverviewModule } from "./modules/overview/overview.module";
|
||||
import { ReportsModule } from "./modules/reports/reports.module";
|
||||
import { ExportsModule } from "./modules/exports/exports.module";
|
||||
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||
@@ -219,6 +221,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
OperationsReportingModule,
|
||||
PaymentSettingsModule,
|
||||
StampSettingsModule,
|
||||
LogoSettingsModule,
|
||||
@@ -239,6 +242,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
ReportsModule,
|
||||
ExportsModule,
|
||||
UserTradeAccessModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
|
||||
import { FilterBookingDto } from '../../modules/bookings/dto/filter-booking.dto';
|
||||
import { ListTrainSchedulesQueryDto } from '../../modules/train-scheduling/dto/list-train-schedules-query.dto';
|
||||
|
||||
/**
|
||||
* The route filters carry one id, `a,b`, or a repeated param, and the
|
||||
* repositories then branch on `?.length` before emitting `IN (:...ids)`.
|
||||
* Two things have to hold or that breaks at runtime, not compile time:
|
||||
* the value must always arrive as an array (a bare string would make
|
||||
* `.length` count characters), and an absent/blank param must arrive as
|
||||
* `undefined`, never `[]` — TypeORM turns `[]` into the syntax error `IN ()`.
|
||||
*/
|
||||
// Real-shaped v4s: the variant nibble must be 8/9/a/b, so `1111…` is NOT a
|
||||
// valid UUID and would fail `@IsUUID` for reasons that have nothing to do
|
||||
// with the list transform under test.
|
||||
const A = '0a5d4b1e-1b2c-4d3e-8f90-1234567890ab';
|
||||
const B = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
|
||||
const parse = <T>(cls: new () => T, query: Record<string, unknown>): T =>
|
||||
plainToInstance(cls, query);
|
||||
|
||||
describe('route id-list query params', () => {
|
||||
it('accepts a single id, still as an array', () => {
|
||||
const dto = parse(FilterBookingDto, { originYardId: A });
|
||||
expect(dto.originYardId).toEqual([A]);
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('splits a comma-separated list', () => {
|
||||
const dto = parse(FilterBookingDto, { originYardId: `${A}, ${B}` });
|
||||
expect(dto.originYardId).toEqual([A, B]);
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts the repeated-param form', () => {
|
||||
const dto = parse(ListTrainSchedulesQueryDto, { destinationStationId: [A, B] });
|
||||
expect(dto.destinationStationId).toEqual([A, B]);
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([undefined, '', ','])('yields undefined, never [], for %p', (raw) => {
|
||||
expect(parse(FilterBookingDto, { originYardId: raw }).originYardId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the two ends independent — one side set, the other absent', () => {
|
||||
const dto = parse(FilterBookingDto, { originYardId: A });
|
||||
expect(dto.originYardId).toEqual([A]);
|
||||
expect(dto.destinationYardId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still rejects a non-uuid inside the list', () => {
|
||||
const dto = parse(FilterBookingDto, { originYardId: `${A},not-a-uuid` });
|
||||
expect(validateSync(dto)).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal file
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* A query param that carries one id, a comma-separated list (`a,b,c`), or the
|
||||
* same key repeated — and always lands on the DTO as a `string[]`.
|
||||
*
|
||||
* Two details matter:
|
||||
*
|
||||
* - It yields `undefined`, never `[]`, when nothing usable is left. `@IsOptional`
|
||||
* then short-circuits, and — more importantly — a repository that does
|
||||
* `if (ids?.length)` can never be handed an empty array, which TypeORM turns
|
||||
* into the syntax error `IN ()`.
|
||||
* - It is backwards compatible with the single-value form these params used to
|
||||
* take, so existing deep links and saved views keep working unchanged.
|
||||
*
|
||||
* Pair it with `@IsUUID(undefined, { each: true })` (or the relevant `each`
|
||||
* validator) — this only reshapes the value, it does not validate it.
|
||||
*/
|
||||
export const IdListParam = () =>
|
||||
Transform(({ value }: { value: unknown }) => {
|
||||
const raw = Array.isArray(value) ? value : [value];
|
||||
const ids = raw
|
||||
.flatMap((entry) =>
|
||||
entry === undefined || entry === null ? [] : String(entry).split(','),
|
||||
)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return ids.length ? ids : undefined;
|
||||
});
|
||||
32
apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts
Normal file
32
apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from './pagination-query.dto';
|
||||
import { ListWagonsQueryDto } from '../../modules/wagons/dto/list-wagons-query.dto';
|
||||
import { normalizePagination } from '../utils/pagination.util';
|
||||
|
||||
/**
|
||||
* The page-size ceiling is stated in three places that must agree: `@Max` on
|
||||
* PaginationQueryDto, the same `@Max` repeated on ListWagonsQueryDto (which
|
||||
* doesn't extend it), and `MAX_PAGE_SIZE` in pagination.util. A fourth copy
|
||||
* lives outside this package — `MAX_PAGE_SIZE` in @edr/ui-common's data-table
|
||||
* footer, which is what actually asks for the number. Drift between any of
|
||||
* them shows up as a 400 on the largest rows-per-page option, so pin them.
|
||||
*/
|
||||
const errorsFor = (cls: any, pageSize: unknown) =>
|
||||
validateSync(plainToInstance(cls, { pageSize }), { whitelist: false });
|
||||
|
||||
describe('page size ceiling', () => {
|
||||
it.each([PaginationQueryDto, ListWagonsQueryDto])('accepts 500 on %p', (cls) => {
|
||||
expect(errorsFor(cls, 500)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([PaginationQueryDto, ListWagonsQueryDto])('rejects 501 on %p', (cls) => {
|
||||
expect(errorsFor(cls, 501)).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not truncate 500 in the service-side clamp', () => {
|
||||
expect(normalizePagination({ page: 1, pageSize: 500 }).take).toBe(500);
|
||||
expect(normalizePagination({ page: 1, pageSize: 501 }).take).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -19,12 +19,18 @@ export class PaginationQueryDto {
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
/**
|
||||
* Ceiling is 500, matching `MAX_PAGE_SIZE` in `common/utils/pagination.util.ts`
|
||||
* and the backoffice table footer's largest option. The three have to agree:
|
||||
* a lower value here turns the footer's top preset into a 400, a higher one
|
||||
* lets a request through that the util then silently truncates.
|
||||
*/
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 500 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(String(value), 10) || 20)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Max(500)
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
|
||||
@@ -20,7 +20,12 @@ export interface NormalizedPage {
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
/**
|
||||
* Must stay in step with `@Max` on `PaginationQueryDto.pageSize` and with
|
||||
* `MAX_PAGE_SIZE` in the backoffice's data-table footer — the DTO rejects,
|
||||
* this clamps, and the footer is what actually asks for the number.
|
||||
*/
|
||||
const MAX_PAGE_SIZE = 500;
|
||||
|
||||
/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
|
||||
export function normalizePagination(
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`.
|
||||
*
|
||||
* `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`:
|
||||
* a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously.
|
||||
* `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck
|
||||
* batch (webhook never arrived) can be found and reconciled by conversation id.
|
||||
*/
|
||||
export class EimsBulkRegistration3580000000000 implements MigrationInterface {
|
||||
name = "EimsBulkRegistration3580000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
ADD COLUMN IF NOT EXISTS in_flight_conversation_id text
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
DROP COLUMN IF EXISTS in_flight_conversation_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_bulk_conversation_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Reference data for the operations reporting suite (turnaround, delay,
|
||||
* trainset, TEU, cargo volume).
|
||||
*
|
||||
* Two new tables and two new columns:
|
||||
*
|
||||
* - `operations_standards` — single-row settings table, same shape as
|
||||
* `logo_settings` / `exchange_settings`. Holds the railway's standard times
|
||||
* and charged-tonnage factors. Editable in the backoffice because the
|
||||
* business calls the corridor standard "flexible".
|
||||
* - `operations_targets` — the planned side of every "Plan / Operated /
|
||||
* Implement Rate" table in the spec. One row per period × metric ×
|
||||
* dimension value.
|
||||
* - `yard_distances.standard_hours` — the per-corridor standard transit time
|
||||
* (Negad→GMP 21h, →Adama 20h, →Modjo 20.5h, →Sebeta 22h). Null falls back to
|
||||
* `operations_standards.default_leg_standard_hours`.
|
||||
* - `cargo_types.full_trainset_wagons` — wagons in a full trainset of this
|
||||
* cargo (37 for vehicles, 22 for sand). Null falls back to
|
||||
* `operations_standards.default_full_trainset_wagons`.
|
||||
*
|
||||
* The seed row is inserted only when the table is empty, so re-running this
|
||||
* never overwrites values an operator has since edited.
|
||||
*/
|
||||
export class OperationsReporting3580000000000 implements MigrationInterface {
|
||||
name = "OperationsReporting3580000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.operations_standards (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
station_standard_hours_ethiopia numeric(6,2) NOT NULL DEFAULT 10,
|
||||
station_standard_hours_djibouti numeric(6,2) NOT NULL DEFAULT 13,
|
||||
cycle_standard_hours_container numeric(6,2) NOT NULL DEFAULT 65,
|
||||
cycle_standard_hours_bulk_dmp numeric(6,2) NOT NULL DEFAULT 88,
|
||||
cycle_standard_hours_bulk_nagad numeric(6,2) NOT NULL DEFAULT 96,
|
||||
cycle_standard_hours_bulk_bcc numeric(6,2) NOT NULL DEFAULT 96,
|
||||
default_leg_standard_hours numeric(6,2) NOT NULL DEFAULT 21,
|
||||
delay_tolerance_minutes integer NOT NULL DEFAULT 30,
|
||||
charged_tons_full_20ft numeric(8,2) NOT NULL DEFAULT 20,
|
||||
charged_tons_full_40ft numeric(8,2) NOT NULL DEFAULT 40,
|
||||
charged_tons_empty_20ft numeric(8,2) NOT NULL DEFAULT 2.24,
|
||||
charged_tons_empty_40ft numeric(8,2) NOT NULL DEFAULT 3.88,
|
||||
charged_tons_per_wagon_general numeric(8,2) NOT NULL DEFAULT 70,
|
||||
charged_tons_per_wagon_perishable numeric(8,2) NOT NULL DEFAULT 38,
|
||||
default_full_trainset_wagons integer NOT NULL DEFAULT 50,
|
||||
updated_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
|
||||
// Column defaults carry every value — the seed only needs the row to exist.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.operations_standards (id)
|
||||
SELECT gen_random_uuid()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.operations_standards WHERE deleted_at IS NULL);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.operations_targets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
period_type varchar(10) NOT NULL,
|
||||
period_start date NOT NULL,
|
||||
metric varchar(20) NOT NULL,
|
||||
dimension varchar(20) NOT NULL,
|
||||
dimension_key varchar(60) NOT NULL,
|
||||
planned_value numeric(14,3) NOT NULL,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
|
||||
// Partial unique index rather than a table constraint, so a soft-deleted
|
||||
// target can be re-created — same choice as yard_distances.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
|
||||
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
|
||||
// The reports look targets up by period and metric, never by id.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_operations_targets_lookup
|
||||
ON freight.operations_targets (metric, period_type, period_start)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yard_distances
|
||||
ADD COLUMN IF NOT EXISTS standard_hours numeric(6,2);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD COLUMN IF NOT EXISTS full_trainset_wagons integer;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS full_trainset_wagons;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.yard_distances DROP COLUMN IF EXISTS standard_hours;`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_targets;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_standards;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* A station's plan is per station AND per cargo type, not per station.
|
||||
*
|
||||
* The OCC monthly report plans "Nagad–Mojo multimodal container 122,010 t" and
|
||||
* "Nagad–Mojo fertilizer 18,000 t" as separate lines against the same station,
|
||||
* which the single `dimension_key` column cannot express: a station-keyed target
|
||||
* would apply the whole station's plan to each of its cargo types.
|
||||
*
|
||||
* `cargo_category` is nullable, so `cargo_category` and `container_class`
|
||||
* targets are unaffected — they leave it null and stay keyed on
|
||||
* `dimension_key` alone. The uniqueness index moves to include it, since
|
||||
* (station, category) is now the slot.
|
||||
*/
|
||||
export class OperationsTargetCargoCategory3590000000000 implements MigrationInterface {
|
||||
name = "OperationsTargetCargoCategory3590000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.operations_targets
|
||||
ADD COLUMN IF NOT EXISTS cargo_category varchar(60);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
|
||||
|
||||
// COALESCE rather than a plain column list: a partial unique index treats
|
||||
// NULLs as distinct, which would let the same category target be entered
|
||||
// twice over.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
|
||||
ON freight.operations_targets (
|
||||
period_type, period_start, metric, dimension, dimension_key,
|
||||
COALESCE(cargo_category, '')
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
|
||||
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.operations_targets DROP COLUMN IF EXISTS cargo_category;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/doc-requests": ["GL asks the customer for additional clearance documents", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
|
||||
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
|
||||
|
||||
@@ -13,6 +13,7 @@ import { logCtx } from "@edr/api-common";
|
||||
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { AdditionalCharge } from "../bookings/entities/additional-charge.entity";
|
||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||
// payers straight off the table.
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
@@ -46,6 +47,29 @@ export interface PayInvoiceOptions {
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an invoice's `sourceId` actually points at, resolved for display.
|
||||
*
|
||||
* `source` alone ("warehouse", "booking", …) says which subsystem raised the
|
||||
* invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every
|
||||
* source except a shipping-line credit hangs off a booking — directly
|
||||
* (booking/clearance) or through the warehouse/first-mile/last-mile record —
|
||||
* so the booking reference is the one label that identifies almost any row.
|
||||
*/
|
||||
export interface InvoiceSourceRef {
|
||||
/** Booking behind the invoice, when there is one. Null for shipping-line credits. */
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
tradeDirection: string | null;
|
||||
/** Warehouse-sourced rows: the goods-received note the fees were raised against. */
|
||||
grnNumber: string | null;
|
||||
/** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */
|
||||
shippingLineName: string | null;
|
||||
}
|
||||
|
||||
/** Row shape of the backoffice invoice list: the entity plus its resolved source. */
|
||||
export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null };
|
||||
|
||||
/** Booking context attached to a finance offline-USD invoice row. */
|
||||
export interface OfflineUsdBookingInfo {
|
||||
id: string;
|
||||
@@ -236,8 +260,32 @@ export class BillingService {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
// Searches what the row actually shows: its number, who it bills, and
|
||||
// the source record behind it (booking reference, GRN, shipping line).
|
||||
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
|
||||
// Requires the `company` alias — every caller of this joins it.
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
`(invoice.invoiceNumber ILIKE :search
|
||||
OR invoice.sourceId ILIKE :search
|
||||
OR company.name ILIKE :search
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.bookings b
|
||||
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
|
||||
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
|
||||
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
|
||||
WHERE b.reference ILIKE :search
|
||||
AND (b.id::text = invoice.source_id
|
||||
OR wi.id::text = invoice.source_id
|
||||
OR fm.id::text = invoice.source_id
|
||||
OR lm.id::text = invoice.source_id))
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory wi2
|
||||
WHERE wi2.id::text = invoice.source_id
|
||||
AND wi2.grn_number ILIKE :search)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.shipping_line_companies slc
|
||||
WHERE slc.id::text = invoice.source_id
|
||||
AND slc.name ILIKE :search))`,
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
@@ -261,7 +309,7 @@ export class BillingService {
|
||||
/** Per-user trade-direction scope, applied via the source booking. */
|
||||
tradeDirections?: string[];
|
||||
} = {},
|
||||
): Promise<{ items: Invoice[]; total: number }> {
|
||||
): Promise<{ items: InvoiceListRow[]; total: number }> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
@@ -277,7 +325,92 @@ export class BillingService {
|
||||
this.applyInvoiceFilters(qb, filter);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
return { items: await this.attachShippingLineCompanies(items), total };
|
||||
const withLines = await this.attachShippingLineCompanies(items);
|
||||
return { items: await this.attachSourceRefs(withLines), total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each row's `sourceId` to the record it points at, in one query for
|
||||
* the whole page. `sourceId` is a bare varchar pointer with no FK and no
|
||||
* relation to eager-load, and which table it addresses depends on `source` —
|
||||
* so this walks every candidate table at once and lands on the booking
|
||||
* through whichever one matched.
|
||||
*
|
||||
* `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence
|
||||
* the shape guard before every cast — an unguarded `::uuid` throws on those.
|
||||
*/
|
||||
private async attachSourceRefs<T extends Invoice>(
|
||||
invoices: T[],
|
||||
): Promise<(T & { sourceRef: InvoiceSourceRef | null })[]> {
|
||||
const sourceIds = [
|
||||
...new Set(invoices.map((i) => i.sourceId).filter(Boolean)),
|
||||
];
|
||||
if (!sourceIds.length) {
|
||||
return invoices.map((invoice) => ({ ...invoice, sourceRef: null }));
|
||||
}
|
||||
|
||||
const rows: {
|
||||
sourceId: string;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
tradeDirection: string | null;
|
||||
grnNumber: string | null;
|
||||
shippingLineName: string | null;
|
||||
}[] = await this.dataSource.query(
|
||||
`SELECT s.source_id AS "sourceId",
|
||||
b.id::text AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
wi.grn_number AS "grnNumber",
|
||||
slc.name AS "shippingLineName"
|
||||
FROM unnest($1::text[]) AS s(source_id)
|
||||
LEFT JOIN freight.warehouse_inventory wi
|
||||
ON wi.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||
THEN s.source_id::uuid END)
|
||||
AND wi.deleted_at IS NULL
|
||||
LEFT JOIN freight.first_mile fm
|
||||
ON fm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||
THEN s.source_id::uuid END)
|
||||
AND fm.deleted_at IS NULL
|
||||
LEFT JOIN freight.last_mile lm
|
||||
ON lm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||
THEN s.source_id::uuid END)
|
||||
AND lm.deleted_at IS NULL
|
||||
LEFT JOIN freight.bookings b
|
||||
ON b.id = COALESCE(wi.booking_id, fm.booking_id, lm.booking_id,
|
||||
CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||
THEN s.source_id::uuid END)
|
||||
AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.shipping_line_companies slc
|
||||
ON slc.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
|
||||
THEN s.source_id::uuid END)
|
||||
AND slc.deleted_at IS NULL`,
|
||||
[sourceIds],
|
||||
);
|
||||
|
||||
const bySourceId = new Map(rows.map((r) => [r.sourceId, r]));
|
||||
return invoices.map((invoice) => {
|
||||
const row = bySourceId.get(invoice.sourceId);
|
||||
const sourceRef: InvoiceSourceRef | null = row
|
||||
? {
|
||||
bookingId: row.bookingId,
|
||||
bookingReference: row.bookingReference,
|
||||
tradeDirection: row.tradeDirection,
|
||||
grnNumber: row.grnNumber,
|
||||
shippingLineName: row.shippingLineName,
|
||||
}
|
||||
: null;
|
||||
// Nothing resolved (an EIMS self-test row, a deleted record) → null,
|
||||
// and the UI falls back to the plain source label.
|
||||
const resolved =
|
||||
sourceRef &&
|
||||
(sourceRef.bookingId ||
|
||||
sourceRef.grnNumber ||
|
||||
sourceRef.shippingLineName)
|
||||
? sourceRef
|
||||
: null;
|
||||
return { ...invoice, sourceRef: resolved };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,6 +469,9 @@ export class BillingService {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
|
||||
// so the alias has to exist even though the summary only sums money.
|
||||
.leftJoin("invoice.company", "company")
|
||||
.select("invoice.currency", "currency")
|
||||
.addSelect("SUM(invoice.paidAmount)", "collected")
|
||||
.groupBy("invoice.currency");
|
||||
@@ -1823,6 +1959,14 @@ export class BillingService {
|
||||
.getRepository(Booking)
|
||||
.update({ id: invoice.sourceId }, { pnrCode: billReference });
|
||||
}
|
||||
// Same reference, for an ad-hoc additional charge — its own column, since
|
||||
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
|
||||
// can carry many of these at once.
|
||||
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
|
||||
await this.dataSource
|
||||
.getRepository(AdditionalCharge)
|
||||
.update({ id: invoice.sourceId }, { paymentReference: billReference });
|
||||
}
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept for local demos only.
|
||||
|
||||
@@ -203,4 +203,12 @@ export class Invoice extends BaseEntity {
|
||||
@ManyToOne(() => Invoice)
|
||||
@JoinColumn({ name: "related_invoice_id" })
|
||||
relatedInvoice?: Invoice | null;
|
||||
|
||||
/**
|
||||
* Which `POST /v1/bulkRegister` batch this invoice was submitted in, if any — MoR's own
|
||||
* conversation id, not one we generate. Lets a stuck batch (webhook never arrived) be found and
|
||||
* reconciled. Null for every invoice filed through single `/v1/register`.
|
||||
*/
|
||||
@Column({ name: "eims_bulk_conversation_id", type: "text", nullable: true })
|
||||
eimsBulkConversationId?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AdditionalChargeRepository extends BaseRepository<AdditionalCharge> {
|
||||
constructor(@InjectRepository(AdditionalCharge) repository: Repository<AdditionalCharge>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { AdditionalChargeRepository } from './additional-charge.repository';
|
||||
import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||
import { CreateAdditionalChargeDto } from './dto/additional-charge.dto';
|
||||
|
||||
const FILE_RESOURCE = 'additional_charges';
|
||||
|
||||
/**
|
||||
* Ad-hoc extra charges finance raises against a booking, independent of
|
||||
* `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS
|
||||
* row per booking). Any number per booking, free-text reason. DRAFT until
|
||||
* sent; sending issues the payable invoice and notifies the customer
|
||||
* (in-app + SMS + email). Settles via `additional_charge.invoice.paid`,
|
||||
* same event-driven pattern as every other invoice source.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdditionalChargeService {
|
||||
private readonly logger = new Logger(AdditionalChargeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly repository: AdditionalChargeRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
private async findOwned(bookingId: string, chargeId: string): Promise<AdditionalCharge> {
|
||||
const charge = await this.repository.findById(chargeId);
|
||||
if (!charge || charge.bookingId !== bookingId) {
|
||||
throw new NotFoundException('Additional charge not found');
|
||||
}
|
||||
return charge;
|
||||
}
|
||||
|
||||
async list(bookingId: string): Promise<Freight.AdditionalCharge[]> {
|
||||
const rows = await this.repository.findAll({
|
||||
where: { bookingId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return this.toDtoList(rows);
|
||||
}
|
||||
|
||||
async create(
|
||||
bookingId: string,
|
||||
dto: CreateAdditionalChargeDto,
|
||||
staffId: string,
|
||||
file?: Express.Multer.File,
|
||||
): Promise<Freight.AdditionalCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const shouldSend = dto.action === 'send';
|
||||
|
||||
const chargeId = await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(AdditionalCharge);
|
||||
let saved = await repo.save(
|
||||
repo.create({
|
||||
bookingId,
|
||||
reason: dto.reason.trim(),
|
||||
amount: dto.amount.toFixed(2),
|
||||
currency: dto.currency.trim().toUpperCase(),
|
||||
status: 'DRAFT',
|
||||
createdByStaffId: staffId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (file) {
|
||||
const record = await this.filesService.upload({
|
||||
resourceId: saved.id,
|
||||
resource: FILE_RESOURCE,
|
||||
code: FILE_RESOURCE,
|
||||
file,
|
||||
uploadedByUserId: staffId,
|
||||
});
|
||||
await repo.update(saved.id, { fileRecordId: record.id });
|
||||
}
|
||||
|
||||
if (shouldSend) {
|
||||
saved = await this.issueInvoice(manager, saved.id, booking, staffId);
|
||||
}
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
if (shouldSend) await this.notifyCustomerSent(chargeId);
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
async send(bookingId: string, chargeId: string, staffId: string): Promise<Freight.AdditionalCharge[]> {
|
||||
const charge = await this.findOwned(bookingId, chargeId);
|
||||
if (charge.status !== 'DRAFT') {
|
||||
throw new ConflictException('Only a draft charge can be sent.');
|
||||
}
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
|
||||
await this.dataSource.transaction((manager) =>
|
||||
this.issueInvoice(manager, charge.id, booking, staffId),
|
||||
);
|
||||
await this.notifyCustomerSent(charge.id);
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */
|
||||
private async issueInvoice(
|
||||
manager: EntityManager,
|
||||
chargeId: string,
|
||||
booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null },
|
||||
staffId: string,
|
||||
): Promise<AdditionalCharge> {
|
||||
const repo = manager.getRepository(AdditionalCharge);
|
||||
const charge = await repo.findOneByOrFail({ id: chargeId });
|
||||
|
||||
const invoice = await this.billing.generateInvoice(
|
||||
{
|
||||
source: Freight.InvoiceSource.AdditionalCharge,
|
||||
sourceId: charge.id,
|
||||
type: 'ADDITIONAL_CHARGE',
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: charge.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'ADDITIONAL_CHARGE',
|
||||
description: `${charge.reason} — ${booking.reference ?? booking.id}`,
|
||||
amount: Number(charge.amount),
|
||||
},
|
||||
],
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await repo.update(charge.id, {
|
||||
status: 'SENT',
|
||||
invoiceId: invoice.id,
|
||||
sentByStaffId: staffId,
|
||||
sentAt: new Date(),
|
||||
});
|
||||
this.logger.log(
|
||||
`Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
return repo.findOneByOrFail({ id: charge.id });
|
||||
}
|
||||
|
||||
private async notifyCustomerSent(chargeId: string): Promise<void> {
|
||||
try {
|
||||
const charge = await this.repository.findById(chargeId);
|
||||
if (!charge) return;
|
||||
const booking = await this.bookingsService.findById(charge.bookingId);
|
||||
if (!booking.companyId) return;
|
||||
const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
title: 'New charge on your booking',
|
||||
body,
|
||||
link: `/bookings/${charge.bookingId}`,
|
||||
data: {
|
||||
bookingId: charge.bookingId,
|
||||
chargeId: charge.id,
|
||||
amount: Number(charge.amount),
|
||||
currency: charge.currency,
|
||||
},
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
staffId: string,
|
||||
reason?: string,
|
||||
): Promise<Freight.AdditionalCharge[]> {
|
||||
const charge = await this.findOwned(bookingId, chargeId);
|
||||
if (charge.status !== 'DRAFT' && charge.status !== 'SENT') {
|
||||
throw new ConflictException('Only a draft or unpaid charge can be cancelled.');
|
||||
}
|
||||
if (charge.status === 'SENT' && charge.invoiceId) {
|
||||
await this.billing.cancelInvoice(charge.invoiceId);
|
||||
}
|
||||
await this.repository.update(charge.id, {
|
||||
status: 'CANCELLED',
|
||||
cancelledByStaffId: staffId,
|
||||
cancelledAt: new Date(),
|
||||
cancelReason: reason ?? null,
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
|
||||
@OnEvent('additional_charge.invoice.paid')
|
||||
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
const charge = await this.repository.findById(payload.sourceId);
|
||||
if (!charge || charge.status === 'PAID') return;
|
||||
await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() });
|
||||
|
||||
try {
|
||||
const booking = await this.bookingsService.findById(charge.bookingId);
|
||||
if (!booking.companyId) return;
|
||||
const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title: 'Charge payment received',
|
||||
body,
|
||||
link: `/bookings/${charge.bookingId}`,
|
||||
data: { bookingId: charge.bookingId, chargeId: charge.id },
|
||||
});
|
||||
await this.inbox.notify({
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title: 'Additional charge paid',
|
||||
body,
|
||||
link: `/bookings/${charge.bookingId}`,
|
||||
data: { bookingId: charge.bookingId, chargeId: charge.id },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async toDtoList(rows: AdditionalCharge[]): Promise<Freight.AdditionalCharge[]> {
|
||||
if (!rows.length) return [];
|
||||
|
||||
const filesByCharge = await this.filesService.findByResourceIdsGrouped(
|
||||
rows.map((r) => r.id),
|
||||
FILE_RESOURCE,
|
||||
);
|
||||
const names = await this.bookingsRepository.resolveStaffNames(
|
||||
rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]),
|
||||
);
|
||||
|
||||
const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id));
|
||||
const invoices = invoiceIds.length
|
||||
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
|
||||
: [];
|
||||
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
|
||||
|
||||
return rows.map((r) => {
|
||||
const file = filesByCharge.get(r.id)?.[0];
|
||||
return {
|
||||
id: r.id,
|
||||
bookingId: r.bookingId,
|
||||
reason: r.reason,
|
||||
status: r.status,
|
||||
amount: Number(r.amount),
|
||||
currency: r.currency,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
invoiceId: r.invoiceId ?? null,
|
||||
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null,
|
||||
paymentReference: r.paymentReference ?? null,
|
||||
createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null,
|
||||
sentAt: r.sentAt?.toISOString() ?? null,
|
||||
paidAt: r.paidAt?.toISOString() ?? null,
|
||||
cancelledAt: r.cancelledAt?.toISOString() ?? null,
|
||||
cancelReason: r.cancelReason ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingClearanceCharge,
|
||||
ClearanceChargeStatus,
|
||||
ClearanceChargeType,
|
||||
} from './entities/booking-clearance-charge.entity';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
@@ -32,13 +34,22 @@ const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
|
||||
MISCELLANEOUS: 'Miscellaneous charges',
|
||||
};
|
||||
|
||||
/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */
|
||||
export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet<ClearanceChargeStatus> =
|
||||
new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
|
||||
|
||||
/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */
|
||||
export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean =>
|
||||
status !== 'ACCEPTED' && status !== 'PAID';
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charges billed to the customer. Two levels per
|
||||
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
|
||||
* (amount + currency) and sends the invoice; once that invoice is paid GL
|
||||
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
|
||||
* through the portal gateway, other currencies through Finance's manual
|
||||
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
|
||||
* Post-finalization clearance charges billed to the customer: one port charge
|
||||
* (document from GL Djibouti, priced by GL Ethiopia) and any number of
|
||||
* miscellaneous charges. GL prices + describes a charge and SENDs it; the
|
||||
* customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues
|
||||
* the payable invoice and locks the charge. ETB invoices are paid through the
|
||||
* portal gateway, other currencies through Finance's manual settlement
|
||||
* worklist — both settle via `clearance_charge.invoice.paid`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingClearanceChargeService {
|
||||
@@ -51,6 +62,7 @@ export class BookingClearanceChargeService {
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
) {}
|
||||
|
||||
private repo() {
|
||||
@@ -104,6 +116,11 @@ export class BookingClearanceChargeService {
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
amount: c.amount != null ? Number(c.amount) : null,
|
||||
currency: c.currency ?? null,
|
||||
description: c.description ?? null,
|
||||
customerNote: c.customerNote ?? null,
|
||||
customerDecidedAt: c.customerDecidedAt
|
||||
? c.customerDecidedAt.toISOString()
|
||||
: null,
|
||||
invoiceId: c.invoiceId ?? null,
|
||||
invoiceNumber: c.invoiceId
|
||||
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
|
||||
@@ -121,6 +138,24 @@ export class BookingClearanceChargeService {
|
||||
});
|
||||
}
|
||||
|
||||
/** The customer's view: only charges GL has sent them. */
|
||||
async listForCustomer(bookingId: string): Promise<Freight.ClearanceCharge[]> {
|
||||
return (await this.list(bookingId)).filter((c) =>
|
||||
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status),
|
||||
);
|
||||
}
|
||||
|
||||
private async findCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
): Promise<BookingClearanceCharge> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
return charge;
|
||||
}
|
||||
|
||||
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
|
||||
async uploadPortDocument(
|
||||
bookingId: string,
|
||||
@@ -180,22 +215,21 @@ export class BookingClearanceChargeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia sets (or, on the customer's request, revises) amount +
|
||||
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
|
||||
* is immutable.
|
||||
* GL Ethiopia sets (or, after a customer rejection, revises) amount +
|
||||
* currency + description. Allowed until the customer accepts: an ACCEPTED
|
||||
* charge already carries an invoice and a PAID one is settled.
|
||||
*/
|
||||
async billCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
input: { amount: number; currency: string },
|
||||
input: { amount: number; currency: string; description?: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status === 'PAID') {
|
||||
throw new ConflictException('A paid charge can no longer be changed.');
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (!canStaffEditCharge(charge.status)) {
|
||||
throw new ConflictException(
|
||||
'The customer has accepted this charge — it can no longer be changed.',
|
||||
);
|
||||
}
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Amount must be greater than zero.');
|
||||
@@ -203,53 +237,117 @@ export class BookingClearanceChargeService {
|
||||
if (!input.currency?.trim()) {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
|
||||
if (charge.status === 'SENT' && charge.invoiceId) {
|
||||
await this.billing.cancelInvoice(charge.invoiceId);
|
||||
const description = (input.description ?? charge.description ?? '').trim();
|
||||
if (charge.type === 'MISCELLANEOUS' && !description) {
|
||||
throw new BadRequestException('Describe what this charge is for.');
|
||||
}
|
||||
const currency = input.currency.trim().toUpperCase();
|
||||
const revised = charge.status === 'SENT' || charge.status === 'REJECTED';
|
||||
|
||||
// Back to draft: the customer's previous decision no longer applies.
|
||||
await this.repo().update(charge.id, {
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
currency,
|
||||
description: description || null,
|
||||
status: 'BILLED',
|
||||
invoiceId: null,
|
||||
customerNote: null,
|
||||
customerDecidedAt: null,
|
||||
customerDecidedBy: null,
|
||||
billedByStaffId: staffId,
|
||||
billedAt: new Date(),
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_BILLED',
|
||||
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
|
||||
label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
|
||||
].toLowerCase()}: ${input.amount} ${currency}${
|
||||
description ? ` — ${description}` : ''
|
||||
}`,
|
||||
actorId: staffId,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
amount: input.amount,
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
revised: charge.status === 'SENT',
|
||||
currency,
|
||||
description: description || null,
|
||||
revised,
|
||||
},
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** GL Ethiopia issues the payable invoice to the customer. */
|
||||
/**
|
||||
* GL Ethiopia proposes the priced charge to the customer. No invoice yet —
|
||||
* that is issued when the customer accepts. Re-sending after a rejection
|
||||
* goes through here too.
|
||||
*/
|
||||
async sendCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
staffId?: string,
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status !== 'BILLED') {
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
|
||||
throw new ConflictException(
|
||||
'Set the amount and currency before sending the charge to the customer.',
|
||||
charge.status === 'DOC_UPLOADED'
|
||||
? 'Set the amount and currency before sending the charge to the customer.'
|
||||
: 'This charge has already been sent to the customer.',
|
||||
);
|
||||
}
|
||||
const revised = charge.status === 'REJECTED';
|
||||
const amount = Number(charge.amount);
|
||||
const currency = charge.currency ?? 'ETB';
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'SENT',
|
||||
customerNote: null,
|
||||
customerDecidedAt: null,
|
||||
customerDecidedBy: null,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_SENT',
|
||||
label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()} to the customer for approval: ${amount} ${currency}`,
|
||||
actorId: staffId ?? null,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
amount,
|
||||
currency,
|
||||
description: charge.description ?? null,
|
||||
revised,
|
||||
},
|
||||
});
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.clearanceChargeProposed(booking, {
|
||||
label: CHARGE_LABEL[charge.type],
|
||||
amount,
|
||||
currency,
|
||||
description: charge.description ?? null,
|
||||
revised,
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** Customer agrees to the price: the payable invoice is issued and the charge locks. */
|
||||
async customerAccept(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
userId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (charge.status !== 'SENT' && charge.status !== 'REJECTED') {
|
||||
throw new ConflictException(
|
||||
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
||||
? 'This charge has already been accepted.'
|
||||
: 'This charge is not awaiting your decision.',
|
||||
);
|
||||
}
|
||||
const amount = Number(charge.amount);
|
||||
const currency = charge.currency ?? 'ETB';
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.ClearanceCharge,
|
||||
// The charge's own id, NOT the booking id — booking-scoped invoice
|
||||
@@ -258,105 +356,156 @@ export class BookingClearanceChargeService {
|
||||
type: charge.type,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: charge.currency ?? 'ETB',
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: charge.type,
|
||||
description: `${CHARGE_LABEL[charge.type]} — ${booking.reference ?? bookingId}`,
|
||||
amount: Number(charge.amount),
|
||||
description: `${CHARGE_LABEL[charge.type]} — ${
|
||||
booking.reference ?? bookingId
|
||||
}${charge.description ? `: ${charge.description}` : ''}`,
|
||||
amount,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'SENT',
|
||||
status: 'ACCEPTED',
|
||||
invoiceId: invoice.id,
|
||||
customerNote: null,
|
||||
customerDecidedAt: new Date(),
|
||||
customerDecidedBy: userId,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_INVOICE_SENT',
|
||||
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
|
||||
actorId: staffId ?? null,
|
||||
action: 'CHARGE_ACCEPTED',
|
||||
label: `Customer accepted ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: userId,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
amount: Number(charge.amount),
|
||||
currency: charge.currency,
|
||||
amount,
|
||||
currency,
|
||||
},
|
||||
});
|
||||
this.notifier.clearanceChargeInvoiceIssued(booking, {
|
||||
label: CHARGE_LABEL[charge.type],
|
||||
amount,
|
||||
currency,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
});
|
||||
this.logger.log(
|
||||
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
|
||||
`Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
return this.list(bookingId);
|
||||
return this.listForCustomer(bookingId);
|
||||
}
|
||||
|
||||
/** Customer declines the price with a reason; GL revises and re-sends. */
|
||||
async customerReject(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
note: string,
|
||||
userId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (charge.status !== 'SENT') {
|
||||
throw new ConflictException(
|
||||
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
||||
? 'This charge has already been accepted.'
|
||||
: 'This charge is not awaiting your decision.',
|
||||
);
|
||||
}
|
||||
if (!note?.trim()) {
|
||||
throw new BadRequestException('Say why you are rejecting this charge.');
|
||||
}
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'REJECTED',
|
||||
customerNote: note.trim(),
|
||||
customerDecidedAt: new Date(),
|
||||
customerDecidedBy: userId,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_REJECTED',
|
||||
label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: userId,
|
||||
metadata: { chargeType: charge.type, note: note.trim() },
|
||||
});
|
||||
this.notifier.clearanceChargeRejectedToStaff(booking, {
|
||||
label: CHARGE_LABEL[charge.type],
|
||||
note: note.trim(),
|
||||
});
|
||||
return this.listForCustomer(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
|
||||
* currency). Second payment level: allowed only once the port charge is paid.
|
||||
* GL Ethiopia creates a miscellaneous charge whole (document + amount +
|
||||
* currency + what it is for). Lands as a BILLED draft; GL sends it next.
|
||||
*/
|
||||
async createMiscellaneous(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
input: { amount: number; currency: string },
|
||||
input: { amount: number; currency: string; description?: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
this.assertClearanceFinalized(booking);
|
||||
|
||||
const port = await this.repo().findOne({
|
||||
where: { bookingId, type: 'PORT_CHARGES' },
|
||||
});
|
||||
if (port?.status !== 'PAID') {
|
||||
throw new ConflictException(
|
||||
'Miscellaneous charges open after the port charge is paid.',
|
||||
);
|
||||
}
|
||||
const existing = await this.repo().findOne({
|
||||
where: { bookingId, type: 'MISCELLANEOUS' },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
'This booking already has a miscellaneous charge — revise it instead.',
|
||||
);
|
||||
}
|
||||
// No ordering and no cap: a miscellaneous charge may be raised before,
|
||||
// after or alongside the port charge, and a booking may carry several.
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Amount must be greater than zero.');
|
||||
}
|
||||
if (!input.currency?.trim()) {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
const description = input.description?.trim() ?? '';
|
||||
if (!description) {
|
||||
throw new BadRequestException('Describe what this charge is for.');
|
||||
}
|
||||
|
||||
const record = await this.filesService.upsertByCode(
|
||||
{
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: CHARGE_FILE_CODE.MISCELLANEOUS,
|
||||
file,
|
||||
},
|
||||
{ userId: staffId },
|
||||
);
|
||||
await this.repo().save(
|
||||
// Save the row first so its id can key the document. A booking may carry
|
||||
// several miscellaneous charges, and `upsertByCode` retires whatever sits
|
||||
// under the same code — a shared code would silently delete the previous
|
||||
// charge's document.
|
||||
const charge = await this.repo().save(
|
||||
this.repo().create({
|
||||
bookingId,
|
||||
type: 'MISCELLANEOUS',
|
||||
status: 'BILLED',
|
||||
fileRecordId: record.id,
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
description,
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
billedByStaffId: staffId,
|
||||
billedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
const record = await this.filesService.upsertByCode(
|
||||
{
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`,
|
||||
file,
|
||||
},
|
||||
{ userId: staffId },
|
||||
);
|
||||
await this.repo().update(charge.id, { fileRecordId: record.id });
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_MISC_CREATED',
|
||||
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
|
||||
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()} — ${description}`,
|
||||
actorId: staffId,
|
||||
metadata: {
|
||||
amount: input.amount,
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
description,
|
||||
fileName: file.originalname,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import {
|
||||
adHocLabel,
|
||||
clearanceCodesForBooking,
|
||||
clearanceDocumentsOpen,
|
||||
} from './clearance.util';
|
||||
@@ -643,6 +644,12 @@ export class BookingTransitionService {
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
documentsOpen: boolean;
|
||||
docRequests: Array<{
|
||||
id: string;
|
||||
note: string;
|
||||
byName: string | null;
|
||||
at: string;
|
||||
}>;
|
||||
phase?: string | null;
|
||||
milestones?: unknown[];
|
||||
nextAction?: unknown;
|
||||
@@ -674,9 +681,14 @@ export class BookingTransitionService {
|
||||
bookingId,
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
|
||||
bookingId,
|
||||
"ADDITIONAL_DOC_REQUEST",
|
||||
);
|
||||
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
||||
...reviews.map((r) => r.reviewedByStaffId),
|
||||
...queryNotes.map((n) => n.authorId),
|
||||
...docRequestNotes.map((n) => n.authorId),
|
||||
]);
|
||||
|
||||
const documents: Awaited<
|
||||
@@ -731,7 +743,9 @@ export class BookingTransitionService {
|
||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: f.code,
|
||||
label: f.name,
|
||||
// What the customer called it, falling back to the filename for rows
|
||||
// uploaded before the name was carried through.
|
||||
label: f.title || adHocLabel(f.code) || f.name,
|
||||
required: false,
|
||||
uploadedBy: "customer",
|
||||
settingCode: "custom",
|
||||
@@ -763,9 +777,51 @@ export class BookingTransitionService {
|
||||
documents,
|
||||
allApproved,
|
||||
documentsOpen: clearanceDocumentsOpen(booking),
|
||||
docRequests: docRequestNotes.map((n) => ({
|
||||
id: n.id,
|
||||
note: n.note,
|
||||
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
|
||||
at: n.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GL asks the customer for additional clearance document(s). Stored as a
|
||||
* review-note thread shown on both the GL clearance page and the customer's
|
||||
* portal; the customer answers with an ad-hoc upload. Allowed for as long as
|
||||
* documents are open (until the shipment is paid).
|
||||
*/
|
||||
async requestAdditionalDocuments(
|
||||
bookingId: string,
|
||||
note: string,
|
||||
staffId: string,
|
||||
): Promise<void> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (!clearanceDocumentsOpen(booking)) {
|
||||
throw new ConflictException(
|
||||
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
||||
);
|
||||
}
|
||||
if (!note?.trim()) {
|
||||
throw new BadRequestException("Describe the document(s) you need.");
|
||||
}
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
note.trim(),
|
||||
"ADDITIONAL_DOC_REQUEST",
|
||||
staffId,
|
||||
);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: "ADDITIONAL_DOCS_REQUESTED",
|
||||
label: "Requested additional document(s) from the customer",
|
||||
actorId: staffId,
|
||||
metadata: { note: note.trim() },
|
||||
});
|
||||
this.notifier.additionalDocsRequested(booking, note.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every REQUIRED field of the booking's customer-input clearance set
|
||||
* has an APPROVED review row. The 100% gate before clearance can be finalized.
|
||||
@@ -837,6 +893,10 @@ export class BookingTransitionService {
|
||||
resource: "bookings",
|
||||
code: file.fieldname,
|
||||
file,
|
||||
// Ad-hoc uploads carry the name the customer typed (fieldname
|
||||
// `custom_<label>_<n>`); it is what GL sees in the review grid instead
|
||||
// of a raw filename like "scan_003.pdf".
|
||||
title: adHocLabel(file.fieldname),
|
||||
});
|
||||
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
||||
const settingCode = file.fieldname.startsWith("custom_")
|
||||
|
||||
@@ -40,8 +40,15 @@ import {
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingPayablesService } from './booking-payables.service';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
import {
|
||||
|
||||
RejectClearanceChargeDto,
|
||||
} from './dto/clearance-charge.dto';
|
||||
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
||||
import { AdditionalChargeService } from './additional-charge.service';
|
||||
import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
@@ -175,7 +182,9 @@ export class BookingsController {
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||
private readonly bookingPayablesService: BookingPayablesService,
|
||||
private readonly clearanceEventService: ClearanceEventService,
|
||||
private readonly additionalChargeService: AdditionalChargeService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -312,6 +321,21 @@ export class BookingsController {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get("my-payables")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Outstanding customer payments per booking — invoices to pay, prices to accept, duty slips to upload",
|
||||
})
|
||||
async findMyPayables(@CurrentUser() user: AuthUserPayload) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return companyId
|
||||
? this.bookingPayablesService.summarizeForCompany(companyId)
|
||||
: [];
|
||||
}
|
||||
|
||||
@Get("my")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
@@ -1083,6 +1107,25 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/doc-requests")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL asks the customer for additional clearance document(s) — shown on the portal with author and time",
|
||||
})
|
||||
async requestAdditionalDocuments(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body("note") note: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
await this.transitionService.requestAdditionalDocuments(
|
||||
id,
|
||||
note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get(":id/clearance/history")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
@@ -1099,15 +1142,63 @@ export class BookingsController {
|
||||
// ── Clearance charges (post-finalization customer billing) ────────────────
|
||||
|
||||
@Get(":id/clearance/charges")
|
||||
@BookingStaff([
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary: "Clearance charges billed to the customer (port + miscellaneous)",
|
||||
summary:
|
||||
"Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them",
|
||||
})
|
||||
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.clearanceChargeService.list(id);
|
||||
async getClearanceCharges(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const isStaff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
if (isStaff) return this.clearanceChargeService.list(id);
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
return this.clearanceChargeService.listForCustomer(id);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/:chargeId/accept")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge",
|
||||
})
|
||||
acceptClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceChargeService.customerAccept(
|
||||
id,
|
||||
chargeId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/:chargeId/reject")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends",
|
||||
})
|
||||
rejectClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@Body() dto: RejectClearanceChargeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceChargeService.customerReject(
|
||||
id,
|
||||
chargeId,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/port-document")
|
||||
@@ -1134,7 +1225,7 @@ export class BookingsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
|
||||
"GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)",
|
||||
})
|
||||
billClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1154,7 +1245,7 @@ export class BookingsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
|
||||
"GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)",
|
||||
})
|
||||
sendClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1174,7 +1265,7 @@ export class BookingsController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
|
||||
"GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send",
|
||||
})
|
||||
createMiscellaneousCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1191,6 +1282,64 @@ export class BookingsController {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Additional charges (ad-hoc finance billing) ────────────────────────────
|
||||
|
||||
@Get(":id/additional-charges")
|
||||
@MixedAudience(FREIGHT_PERMS.additionalCharges.view)
|
||||
@ApiOperation({ summary: "Ad-hoc extra charges finance has raised against this booking" })
|
||||
async getAdditionalCharges(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view);
|
||||
if (!isStaff) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const charges = await this.additionalChargeService.list(id);
|
||||
// A charge finance hasn't sent yet isn't the customer's to see.
|
||||
return isStaff ? charges : charges.filter((c) => c.status !== "DRAFT");
|
||||
}
|
||||
|
||||
@Post(":id/additional-charges")
|
||||
@BookingStaff(FREIGHT_PERMS.additionalCharges.create)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Finance raises a new additional charge — draft, or send to the customer immediately",
|
||||
})
|
||||
createAdditionalCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() dto: CreateAdditionalChargeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.additionalChargeService.create(id, dto, resolveAuthUserId(user), file);
|
||||
}
|
||||
|
||||
@Post(":id/additional-charges/:chargeId/send")
|
||||
@BookingStaff(FREIGHT_PERMS.additionalCharges.send)
|
||||
@ApiOperation({ summary: "Issue the draft charge's payable invoice and notify the customer" })
|
||||
sendAdditionalCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.additionalChargeService.send(id, chargeId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(":id/additional-charges/:chargeId/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.additionalCharges.cancel)
|
||||
@ApiOperation({ summary: "Withdraw a draft or unpaid additional charge" })
|
||||
cancelAdditionalCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@Body() dto: CancelAdditionalChargeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.additionalChargeService.cancel(id, chargeId, resolveAuthUserId(user), dto.reason);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/output-documents")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -38,7 +38,11 @@ import { BookingsService } from './bookings.service';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
|
||||
import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||
import { AdditionalChargeRepository } from './additional-charge.repository';
|
||||
import { AdditionalChargeService } from './additional-charge.service';
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingPayablesService } from './booking-payables.service';
|
||||
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
@@ -82,6 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ConsolidationApproval,
|
||||
BookingClearanceCharge,
|
||||
BookingClearanceEvent,
|
||||
AdditionalCharge,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
@@ -118,7 +123,10 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingClearanceChargeService,
|
||||
BookingPayablesService,
|
||||
ClearanceEventService,
|
||||
AdditionalChargeRepository,
|
||||
AdditionalChargeService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
|
||||
@@ -79,8 +79,10 @@ export interface BookingListFilterOptions {
|
||||
createdTo?: string;
|
||||
scheduledFrom?: string;
|
||||
scheduledTo?: string;
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
/** Any of these origin yards (OR). ANDed with `destinationYardId`. */
|
||||
originYardId?: string[];
|
||||
/** Any of these destination yards (OR). ANDed with `originYardId`. */
|
||||
destinationYardId?: string[];
|
||||
isGovernment?: 'true' | 'false';
|
||||
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
|
||||
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
|
||||
@@ -1151,14 +1153,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
scheduledTo: options.scheduledTo,
|
||||
});
|
||||
}
|
||||
if (options.originYardId) {
|
||||
qb.andWhere('booking.origin_yard_id = :originYardId', {
|
||||
originYardId: options.originYardId,
|
||||
// Each end is its own OR-list, and the two ends AND together — so
|
||||
// "leaving Nagad or DMP" and "leaving Nagad, arriving Gelan" are both
|
||||
// expressible. `?.length` guards the empty array: `IN ()` is a syntax error.
|
||||
if (options.originYardId?.length) {
|
||||
qb.andWhere('booking.origin_yard_id IN (:...originYardIds)', {
|
||||
originYardIds: options.originYardId,
|
||||
});
|
||||
}
|
||||
if (options.destinationYardId) {
|
||||
qb.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||
destinationYardId: options.destinationYardId,
|
||||
if (options.destinationYardId?.length) {
|
||||
qb.andWhere('booking.destination_yard_id IN (:...destinationYardIds)', {
|
||||
destinationYardIds: options.destinationYardId,
|
||||
});
|
||||
}
|
||||
if (options.isGovernment === 'true') {
|
||||
|
||||
@@ -146,3 +146,20 @@ export function clearanceDocumentsOpen(booking: Booking): boolean {
|
||||
if (booking.paymentStatus === 'PAID') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The label the customer typed for an ad-hoc clearance document, recovered from
|
||||
* its file code. The portal encodes it as `custom_<slug>_<n>`; a plain
|
||||
* `custom_<n>` (older uploads, or an unnamed row) yields null so callers fall
|
||||
* back to the filename.
|
||||
*/
|
||||
export function adHocLabel(fileKey: string): string | null {
|
||||
const m = /^custom_(.+)_\d+$/.exec(fileKey);
|
||||
if (!m) return null;
|
||||
// Legacy keys are `custom_<timestamp>_<n>`, which this regex reads as a label
|
||||
// of digits. Those carry no name — reject them so the caller falls back to
|
||||
// the filename instead of showing "1755780000000".
|
||||
if (/^\d+$/.test(m[1])) return null;
|
||||
const label = m[1].replace(/-/g, ' ').trim();
|
||||
return label ? label.charAt(0).toUpperCase() + label.slice(1) : null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
|
||||
import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Length,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BillClearanceChargeDto {
|
||||
@ApiProperty({ example: 12500.5 })
|
||||
@@ -13,4 +20,18 @@ export class BillClearanceChargeDto {
|
||||
@IsString()
|
||||
@Length(3, 8)
|
||||
currency!: string;
|
||||
|
||||
/** What the price is for. Required for miscellaneous charges (checked in the service). */
|
||||
@ApiPropertyOptional({ example: 'Container cleaning and weighbridge fee' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class RejectClearanceChargeDto {
|
||||
@ApiProperty({ example: 'The weighbridge fee was already paid at the port.' })
|
||||
@IsString()
|
||||
@Length(1, 1000)
|
||||
note!: string;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TRADE_DIRECTIONS,
|
||||
} from './create-booking.dto';
|
||||
import { PAYMENT_STATUSES } from '../entities/booking.entity';
|
||||
import { IdListParam } from '../../../common/dto/id-list.transform';
|
||||
|
||||
export class FilterBookingDto {
|
||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||
@@ -96,15 +97,23 @@ export class FilterBookingDto {
|
||||
@IsDateString()
|
||||
scheduledTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Filter by origin yard — one id or a comma-separated list; a booking matches if it leaves ANY of them.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
originYardId?: string[];
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Filter by destination yard — one id or a comma-separated list; a booking matches if it arrives at ANY of them. Combined with originYardId by AND.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
destinationYardId?: string[];
|
||||
|
||||
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const ADDITIONAL_CHARGE_STATUSES = [
|
||||
'DRAFT',
|
||||
'SENT',
|
||||
'PAID',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type AdditionalChargeStatus = (typeof ADDITIONAL_CHARGE_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* An ad-hoc extra charge finance raises against a booking — free-text reason,
|
||||
* any number per booking (unlike `BookingClearanceCharge`, which caps at one
|
||||
* per type). DRAFT until finance sends it; sending issues the payable invoice
|
||||
* and notifies the customer (in-app + SMS + email). PAID via the standard
|
||||
* `additional_charge.invoice.paid` settlement event.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'additional_charge' })
|
||||
@Index(['bookingId'])
|
||||
export class AdditionalCharge extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'reason', type: 'text' })
|
||||
reason!: string;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||
status!: AdditionalChargeStatus;
|
||||
|
||||
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 })
|
||||
amount!: string;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8 })
|
||||
currency!: string;
|
||||
|
||||
/** The supporting attachment (FileRecord), if any. */
|
||||
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||
fileRecordId?: string | null;
|
||||
|
||||
/** The payable invoice issued for this charge (null until SENT). */
|
||||
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
/** CBE bill reference / PNR the customer pays against, once issued. */
|
||||
@Column({ name: 'payment_reference', type: 'varchar', length: 64, nullable: true })
|
||||
paymentReference?: string | null;
|
||||
|
||||
@Column({ name: 'created_by_staff_id', type: 'uuid', nullable: true })
|
||||
createdByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'sent_by_staff_id', type: 'uuid', nullable: true })
|
||||
sentByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
|
||||
sentAt?: Date | null;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'cancelled_by_staff_id', type: 'uuid', nullable: true })
|
||||
cancelledByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||
cancelledAt?: Date | null;
|
||||
|
||||
@Column({ name: 'cancel_reason', type: 'text', nullable: true })
|
||||
cancelReason?: string | null;
|
||||
}
|
||||
@@ -9,20 +9,24 @@ export const CLEARANCE_CHARGE_STATUSES = [
|
||||
'DOC_UPLOADED',
|
||||
'BILLED',
|
||||
'SENT',
|
||||
'REJECTED',
|
||||
'ACCEPTED',
|
||||
'PAID',
|
||||
] as const;
|
||||
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charge billed to the customer — at most one
|
||||
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the
|
||||
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency
|
||||
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid`
|
||||
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only
|
||||
* after the port charge is paid.
|
||||
* Clearance charge billed to the customer. One PORT_CHARGES row per booking
|
||||
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
|
||||
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
|
||||
* sets amount + currency + description (BILLED) and proposes it to the
|
||||
* customer (SENT). The customer either REJECTS with a note (GL revises and
|
||||
* re-sends) or ACCEPTS, which issues the invoice and locks the charge; the
|
||||
* billing `clearance_charge.invoice.paid` event marks it PAID. The two levels
|
||||
* are independent — either may be raised first.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
|
||||
@Index(['bookingId', 'type'], { unique: true })
|
||||
@Index(['bookingId'])
|
||||
export class BookingClearanceCharge extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
@@ -47,6 +51,20 @@ export class BookingClearanceCharge extends BaseEntity {
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
|
||||
currency?: string | null;
|
||||
|
||||
/** What the price is for, written by GL. */
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
/** Customer's reason when REJECTED; cleared when GL revises. */
|
||||
@Column({ name: 'customer_note', type: 'text', nullable: true })
|
||||
customerNote?: string | null;
|
||||
|
||||
@Column({ name: 'customer_decided_at', type: 'timestamptz', nullable: true })
|
||||
customerDecidedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customer_decided_by', type: 'uuid', nullable: true })
|
||||
customerDecidedBy?: string | null;
|
||||
|
||||
/** The payable invoice issued for this charge (null until SENT). */
|
||||
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type RiskAssignmentRecord,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { adHocLabel, clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
@@ -66,6 +66,12 @@ export interface BookingClearanceView {
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
documentsOpen: boolean;
|
||||
docRequests: Array<{
|
||||
id: string;
|
||||
note: string;
|
||||
byName: string | null;
|
||||
at: string;
|
||||
}>;
|
||||
phase?: string | null;
|
||||
milestones?: Array<{
|
||||
id: string;
|
||||
@@ -206,9 +212,14 @@ export class BookingClearanceService {
|
||||
bookingId,
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
|
||||
bookingId,
|
||||
'ADDITIONAL_DOC_REQUEST',
|
||||
);
|
||||
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
||||
...reviews.map((r) => r.reviewedByStaffId),
|
||||
...queryNotes.map((n) => n.authorId),
|
||||
...docRequestNotes.map((n) => n.authorId),
|
||||
]);
|
||||
|
||||
const documents: BookingClearanceView['documents'] = [];
|
||||
@@ -257,7 +268,9 @@ export class BookingClearanceService {
|
||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: f.code,
|
||||
label: f.name,
|
||||
// What the customer called it, falling back to the filename for rows
|
||||
// uploaded before the name was carried through.
|
||||
label: f.title || adHocLabel(f.code) || f.name,
|
||||
required: false,
|
||||
uploadedBy: 'customer',
|
||||
settingCode: 'custom',
|
||||
@@ -334,7 +347,8 @@ export class BookingClearanceService {
|
||||
} catch {
|
||||
train = null;
|
||||
}
|
||||
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
||||
// Removed from the clearance flow — see gl-operations.service.
|
||||
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
|
||||
const bookingMilestone = (code: string) =>
|
||||
milestones.find((m) => m.milestoneCode === code);
|
||||
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
||||
@@ -357,6 +371,12 @@ export class BookingClearanceService {
|
||||
documents,
|
||||
allApproved,
|
||||
documentsOpen: clearanceDocumentsOpen(booking),
|
||||
docRequests: docRequestNotes.map((n) => ({
|
||||
id: n.id,
|
||||
note: n.note,
|
||||
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
|
||||
at: n.createdAt.toISOString(),
|
||||
})),
|
||||
phase,
|
||||
milestones: milestones.map((m) => ({
|
||||
id: m.id,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ArrayMinSize, IsArray, IsUUID } from "class-validator";
|
||||
|
||||
/** `POST invoices/eims/bulk-register` body — see `EimsBulkRegistrationService.registerBulk`. */
|
||||
export class BulkRegisterEimsInvoiceDto {
|
||||
@ApiProperty({ type: [String], description: "Invoice IDs to register with MoR EIMS in one batch." })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID("4", { each: true })
|
||||
invoiceIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
import { buildEimsSeller } from "./eims-invoice-context";
|
||||
|
||||
const SYSTEM_NUMBER = "B0360154BA";
|
||||
const INVOICE_A = "11111111-1111-4111-8111-111111111111";
|
||||
const INVOICE_B = "22222222-2222-4222-8222-222222222222";
|
||||
const CONVERSATION_ID = "2345678901-1735900502800-c04f8dd6-e6e2-4198-b871-c6e504fc14f5";
|
||||
|
||||
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||
({
|
||||
id: INVOICE_A,
|
||||
invoiceNumber: "INV-20260807-00001",
|
||||
currency: "ETB",
|
||||
companyId: "company-1",
|
||||
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
|
||||
totalAmount: "10000.00",
|
||||
eimsStatus: EimsInvoiceStatus.NotSubmitted,
|
||||
eimsIrn: null,
|
||||
eimsDocumentType: "INV",
|
||||
eimsBulkConversationId: null,
|
||||
company: {
|
||||
name: "ABC Trading PLC",
|
||||
tin: "0999930000",
|
||||
vatNumber: "123475885858",
|
||||
phone: "0912345678",
|
||||
region: "13",
|
||||
zone: "SHA",
|
||||
woreda: "574",
|
||||
kebele: "03",
|
||||
houseNo: "NEW",
|
||||
country: "Ethiopia",
|
||||
},
|
||||
...over,
|
||||
}) as unknown as Invoice;
|
||||
|
||||
const LINES = (id: string) => [
|
||||
{
|
||||
invoiceId: id,
|
||||
chargeType: "RAIL_FREIGHT",
|
||||
description: "Addis to Djibouti",
|
||||
quantity: "1.00",
|
||||
unitRate: "10000.00",
|
||||
amount: "10000.00",
|
||||
},
|
||||
];
|
||||
|
||||
/** In-memory stand-in covering the query/manager surface this service actually calls. */
|
||||
class FakeDb {
|
||||
invoices = new Map<string, Invoice>();
|
||||
state: EimsSystemState;
|
||||
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||
|
||||
constructor(invoices: Invoice[], state: Partial<EimsSystemState> = {}) {
|
||||
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||
this.state = {
|
||||
id: "state-1",
|
||||
systemNumber: SYSTEM_NUMBER,
|
||||
nextInvoiceCounter: 1,
|
||||
nextDocumentNumber: 1,
|
||||
previousIrn: null,
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
inFlightDocumentNumber: null,
|
||||
inFlightConversationId: null,
|
||||
blockedReason: null,
|
||||
...state,
|
||||
} as EimsSystemState;
|
||||
}
|
||||
|
||||
private matches(entity: Invoice | EimsSystemState, where: Record<string, unknown>): boolean {
|
||||
return Object.entries(where).every(([key, value]) => (entity as never)[key] === value);
|
||||
}
|
||||
|
||||
private queryBuilder(entityCtor: unknown) {
|
||||
let where: Record<string, unknown> = {};
|
||||
const builder = {
|
||||
setLock: () => builder,
|
||||
where: (_clause: string, params: Record<string, unknown>) => {
|
||||
where = { ...where, ...this.normalizeParams(params) };
|
||||
return builder;
|
||||
},
|
||||
andWhere: (_clause: string, params: Record<string, unknown>) => {
|
||||
where = { ...where, ...this.normalizeParams(params) };
|
||||
return builder;
|
||||
},
|
||||
getOne: async () => this.find(entityCtor, where)[0] ?? null,
|
||||
getMany: async () => this.find(entityCtor, where),
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
private normalizeParams(params: Record<string, unknown>): Record<string, unknown> {
|
||||
// Test-only mapping from the SQL param names used in the service's own queries to entity fields.
|
||||
const map: Record<string, string> = {
|
||||
invoiceId: "id",
|
||||
systemNumber: "systemNumber",
|
||||
id: "eimsBulkConversationId",
|
||||
};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(params)) out[map[k] ?? k] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
private find(entityCtor: unknown, where: Record<string, unknown>): Array<Invoice | EimsSystemState> {
|
||||
const isState = entityCtor === EimsSystemState;
|
||||
const pool: Array<Invoice | EimsSystemState> = isState ? [this.state] : [...this.invoices.values()];
|
||||
return pool.filter((e) => this.matches(e, where));
|
||||
}
|
||||
|
||||
private manager = {
|
||||
createQueryBuilder: (entityCtor: unknown) => this.queryBuilder(entityCtor),
|
||||
query: async () => [],
|
||||
findOne: async (entityCtor: unknown, options: { where: Record<string, unknown> }) =>
|
||||
this.find(entityCtor, options.where)[0] ?? null,
|
||||
update: async (entityCtor: unknown, idOrWhere: string | Record<string, unknown>, patch: Record<string, unknown>) => {
|
||||
const targets =
|
||||
typeof idOrWhere === "string"
|
||||
? this.find(entityCtor, { id: idOrWhere })
|
||||
: this.find(entityCtor, idOrWhere);
|
||||
for (const t of targets) Object.assign(t, patch);
|
||||
return { affected: targets.length };
|
||||
},
|
||||
getRepository: (entityCtor: unknown) => ({
|
||||
findOne: async (options: { where: { id: string } }) => this.find(entityCtor, { id: options.where.id })[0] ?? null,
|
||||
}),
|
||||
};
|
||||
|
||||
asDataSource(): DataSource {
|
||||
return {
|
||||
manager: this.manager,
|
||||
// Routed by SQL text: the lines lookup and sendCompanyChannels' contact lookup share this
|
||||
// one entry point in the real DataSource.
|
||||
query: async (sql: string) => {
|
||||
if (sql.includes("invoice_lines")) {
|
||||
return [...this.invoices.keys()].flatMap((id) => LINES(id));
|
||||
}
|
||||
return this.companyContact ? [this.companyContact] : [];
|
||||
},
|
||||
transaction: async (body: (m: unknown) => Promise<unknown>) => body(this.manager),
|
||||
getRepository: () => ({
|
||||
find: async (options: { where: { id: { value: string[] } } }) => {
|
||||
const ids = options.where.id.value ?? [];
|
||||
return ids.map((id: string) => this.invoices.get(id)).filter(Boolean);
|
||||
},
|
||||
createQueryBuilder: (alias: string) => {
|
||||
void alias;
|
||||
return this.queryBuilder(Invoice);
|
||||
},
|
||||
count: async (options: { where: Record<string, unknown> }) => this.find(Invoice, options.where).length,
|
||||
}),
|
||||
} as unknown as DataSource;
|
||||
}
|
||||
}
|
||||
|
||||
const build = (db: FakeDb, postSigned: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) =>
|
||||
new EimsBulkRegistrationService(
|
||||
db.asDataSource(),
|
||||
{ get: () => eimsConfig({ invoice: eimsInvoiceConfig() }) } as unknown as ConfigService,
|
||||
{ postSigned } as unknown as EimsClientService,
|
||||
{ getSessionContext: async () => ({ systemNumber: SYSTEM_NUMBER, systemType: "SYS" }) } as unknown as EimsAuthService,
|
||||
{ directSend } as unknown as NotificationsService,
|
||||
{ getSellerDetails: (c: unknown) => buildEimsSeller(c as never) } as unknown as EimsSellerCacheService,
|
||||
);
|
||||
|
||||
const accepted = (conversationId = CONVERSATION_ID) => ({ conversationId, status: 202 });
|
||||
|
||||
describe("EimsBulkRegistrationService.registerBulk", () => {
|
||||
it("reserves sequential counters, sends one signed array, and claims MoR's real conversation id", async () => {
|
||||
const db = new FakeDb(
|
||||
[invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })],
|
||||
{ nextInvoiceCounter: 5, nextDocumentNumber: 5, previousIrn: "prev-irn" },
|
||||
);
|
||||
const postSigned = jest.fn().mockResolvedValue(accepted());
|
||||
|
||||
const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]);
|
||||
|
||||
expect(result).toEqual({ conversationId: CONVERSATION_ID, accepted: [INVOICE_A, INVOICE_B], alreadyRegistered: [] });
|
||||
const [, request] = postSigned.mock.calls[0];
|
||||
expect(request).toHaveLength(2);
|
||||
expect(request[0].SourceSystem.InvoiceCounter).toBe(5);
|
||||
expect(request[0].DocumentDetails.DocumentNumber).toBe("5");
|
||||
expect(request[0].ReferenceDetails.PreviousIrn).toBe("prev-irn");
|
||||
expect(request[1].SourceSystem.InvoiceCounter).toBe(6);
|
||||
// Only the first item in a bulk batch chains — the rest have no IRN to reference yet.
|
||||
expect(request[1].ReferenceDetails.PreviousIrn).toBe("");
|
||||
|
||||
expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID });
|
||||
expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID });
|
||||
expect(db.state.nextInvoiceCounter).toBe(7);
|
||||
expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID);
|
||||
});
|
||||
|
||||
it("skips an already-registered invoice, without consuming a counter for it", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ eimsIrn: "already-irn", eimsStatus: EimsInvoiceStatus.Registered }),
|
||||
invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" }),
|
||||
]);
|
||||
const postSigned = jest.fn().mockResolvedValue(accepted());
|
||||
|
||||
const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]);
|
||||
|
||||
expect(result.alreadyRegistered).toEqual([INVOICE_A]);
|
||||
expect(result.accepted).toEqual([INVOICE_B]);
|
||||
const [, request] = postSigned.mock.calls[0];
|
||||
expect(request).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses the whole batch — no reservation, no HTTP call — when a DEB note has no registered original", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ eimsDocumentType: "DEB", relatedInvoice: { eimsIrn: null, invoiceNumber: "INV-orig" } as never }),
|
||||
]);
|
||||
const postSigned = jest.fn();
|
||||
|
||||
await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
expect(db.state.inFlightConversationId).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses when a single-invoice submission is already in flight", async () => {
|
||||
const db = new FakeDb([invoiceRow()], { inFlightInvoiceId: "some-other-invoice" });
|
||||
await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses when another bulk batch is already in flight", async () => {
|
||||
const db = new FakeDb([invoiceRow()], { inFlightConversationId: "other-conversation" });
|
||||
await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("a deterministic rejection rolls back the whole block and clears the in-flight marker", async () => {
|
||||
const db = new FakeDb(
|
||||
[invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })],
|
||||
{ nextInvoiceCounter: 5, nextDocumentNumber: 5 },
|
||||
);
|
||||
const postSigned = jest.fn().mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "bad", 400));
|
||||
|
||||
await expect(build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B])).rejects.toBeInstanceOf(EimsApiException);
|
||||
|
||||
expect(db.state.nextInvoiceCounter).toBe(5);
|
||||
expect(db.state.nextDocumentNumber).toBe(5);
|
||||
expect(db.state.inFlightConversationId).toBeNull();
|
||||
expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Failed);
|
||||
expect(db.invoices.get(INVOICE_A)?.eimsBulkConversationId).toBeNull();
|
||||
});
|
||||
|
||||
it("an ambiguous failure blocks the system number and leaves counters consumed", async () => {
|
||||
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 5, nextDocumentNumber: 5 });
|
||||
const postSigned = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "timed out"));
|
||||
|
||||
await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(EimsApiException);
|
||||
|
||||
expect(db.state.nextInvoiceCounter).toBe(6);
|
||||
expect(db.state.blockedReason).toMatch(/never acknowledged/);
|
||||
expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Unknown);
|
||||
});
|
||||
|
||||
it("refuses an empty invoice list", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
await expect(build(db, jest.fn()).registerBulk([])).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsBulkRegistrationService.handleBulkCallback", () => {
|
||||
const submittingRow = (over: Partial<Invoice>) =>
|
||||
invoiceRow({
|
||||
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||
eimsBulkConversationId: CONVERSATION_ID,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("settles a mixed success/error callback, advancing previousIrn to the last accepted item", async () => {
|
||||
const db = new FakeDb(
|
||||
[
|
||||
submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }),
|
||||
submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }),
|
||||
],
|
||||
{ inFlightConversationId: CONVERSATION_ID },
|
||||
);
|
||||
|
||||
const results = await build(db, jest.fn()).handleBulkCallback([
|
||||
{ irn: "irn-a", status: "A", documentNumber: "5" },
|
||||
{ ruleError: [{ portion: "DocumentDetails", errorMessage: ["bad date"] }], status: "ERROR", docNo: "6" },
|
||||
{ conversionId: CONVERSATION_ID },
|
||||
]);
|
||||
|
||||
expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "irn-a" });
|
||||
expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed });
|
||||
expect(db.state.previousIrn).toBe("irn-a");
|
||||
expect(db.state.inFlightConversationId).toBeNull();
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ invoiceId: INVOICE_A, success: true, irn: "irn-a" }),
|
||||
expect.objectContaining({ invoiceId: INVOICE_B, success: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a callback for an unknown or already-settled conversation", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const results = await build(db, jest.fn()).handleBulkCallback([
|
||||
{ irn: "irn-x", status: "A", documentNumber: "1" },
|
||||
{ conversionId: "no-such-conversation" },
|
||||
]);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not clear the in-flight marker while another invoice in the batch is still submitting", async () => {
|
||||
const db = new FakeDb(
|
||||
[
|
||||
submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }),
|
||||
submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }),
|
||||
],
|
||||
{ inFlightConversationId: CONVERSATION_ID },
|
||||
);
|
||||
|
||||
// Callback only reports on one of the two invoices in this batch.
|
||||
await build(db, jest.fn()).handleBulkCallback([
|
||||
{ irn: "irn-a", status: "A", documentNumber: "5" },
|
||||
{ conversionId: CONVERSATION_ID },
|
||||
]);
|
||||
|
||||
expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID);
|
||||
});
|
||||
|
||||
it("reports the current state without re-settling an invoice that already resolved", async () => {
|
||||
const db = new FakeDb(
|
||||
[
|
||||
invoiceRow({
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
eimsIrn: "irn-a",
|
||||
eimsDocumentNumber: "1",
|
||||
eimsBulkConversationId: CONVERSATION_ID,
|
||||
}),
|
||||
],
|
||||
{ inFlightConversationId: CONVERSATION_ID },
|
||||
);
|
||||
|
||||
const results = await build(db, jest.fn()).handleBulkCallback([
|
||||
{ irn: "irn-a", status: "A", documentNumber: "1" },
|
||||
{ conversionId: CONVERSATION_ID },
|
||||
]);
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ invoiceId: INVOICE_A, success: true, message: expect.stringContaining("Already settled") }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||
import {
|
||||
EimsBulkCallbackItem,
|
||||
EimsBulkRegisterAcceptedResponse,
|
||||
EimsBulkRegisterItemResult,
|
||||
EimsBulkRegisterRequest,
|
||||
EimsInvoiceError,
|
||||
EimsInvoiceStatus,
|
||||
} from "./eims-registration.types";
|
||||
|
||||
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
|
||||
|
||||
interface BulkReservation {
|
||||
stateId: string;
|
||||
invoice: Invoice & { lines: EimsMapperLine[] };
|
||||
documentType: EimsDocumentType;
|
||||
relatedDocument: string | null;
|
||||
invoiceCounter: number;
|
||||
documentNumber: string;
|
||||
previousIrn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers many invoices with MoR EIMS in one call — `POST /v1/bulkRegister`.
|
||||
*
|
||||
* Fundamentally different shape from `EimsInvoiceRegistrationService.registerInvoiceWithEims`:
|
||||
* that endpoint answers synchronously (an IRN or a rejection, in the HTTP response itself). Bulk
|
||||
* does not — it returns only `{conversationId, status:202}` immediately, and the real per-invoice
|
||||
* results (a mix of accepted/rejected in one array, per the collection's own examples) arrive later
|
||||
* as a POST to a webhook MoR was configured with out of band. That means this service has two
|
||||
* halves that don't share a call stack: `registerBulk` reserves and submits; `handleBulkCallback`
|
||||
* — invoked by `EimsWebhookController`, whenever MoR gets around to it — settles.
|
||||
*
|
||||
* Reservation follows the same durable-reservation doctrine as the single-invoice service (counters
|
||||
* consumed and the holder recorded, committed, before the HTTP call leaves the process), extended
|
||||
* to a contiguous block of N counters instead of one. The "something is in flight" marker is
|
||||
* `EimsSystemState.inFlightConversationId`, not `inFlightInvoiceId` — a whole batch is outstanding,
|
||||
* not one invoice — and the two markers block each other: a single registration cannot start while
|
||||
* a bulk batch is pending, and vice versa, because they share the same counter sequence.
|
||||
*
|
||||
* The conversation id is not known until MoR's 202 response arrives, so reservation stamps a
|
||||
* locally-generated placeholder token first (same "commit the reservation before the network call"
|
||||
* reasoning as the single flow), then swaps it for MoR's real conversation id right after — the only
|
||||
* value the webhook callback can actually use to find this batch again.
|
||||
*
|
||||
* Not live-testable from this sandbox (no route to MoR's real gateway) — signing the whole array as
|
||||
* one envelope, the way single `/v1/register` was confirmed live to need despite the collection's
|
||||
* raw example showing no envelope, is the reasonable extension of that confirmed behavior, not a
|
||||
* blind guess, but it has not itself been exercised against the real gateway.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsBulkRegistrationService {
|
||||
private readonly logger = new Logger(EimsBulkRegistrationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly client: EimsClientService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly sellerCache: EimsSellerCacheService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve counters for every eligible invoice and submit them as one `/v1/bulkRegister` call.
|
||||
* An invoice that already has an IRN is silently skipped (idempotent, matching single register);
|
||||
* everything else must pass the same DEB/CRE precondition single register checks, or the whole
|
||||
* call is refused before anything is reserved.
|
||||
*/
|
||||
async registerBulk(
|
||||
invoiceIds: string[],
|
||||
): Promise<{ conversationId: string | null; accepted: string[]; alreadyRegistered: string[] }> {
|
||||
const cfg = this.cfg;
|
||||
assertEimsInvoiceConfig(cfg);
|
||||
|
||||
const ids = [...new Set(invoiceIds)];
|
||||
if (ids.length === 0) {
|
||||
throw new BadRequestException({ code: "EIMS_BULK_EMPTY", message: "No invoice ids given" });
|
||||
}
|
||||
|
||||
const invoices = await this.loadInvoicesForMapping(ids);
|
||||
const alreadyRegistered = invoices.filter((inv) => inv.eimsIrn).map((inv) => inv.id);
|
||||
const pending = invoices.filter((inv) => !inv.eimsIrn);
|
||||
|
||||
// Same DEB/CRE precondition as single register, checked for every pending invoice before any
|
||||
// counter is touched: a bad member must fail the whole batch, not surface mid-submission.
|
||||
const prepared = pending.map((invoice) => {
|
||||
const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV";
|
||||
let relatedDocument: string | null = null;
|
||||
if (documentType !== "INV") {
|
||||
if (!invoice.relatedInvoice) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_RELATED_INVOICE_REQUIRED",
|
||||
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`,
|
||||
});
|
||||
}
|
||||
if (!invoice.relatedInvoice.eimsIrn) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
||||
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`,
|
||||
});
|
||||
}
|
||||
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
||||
}
|
||||
return { invoice, documentType, relatedDocument };
|
||||
});
|
||||
|
||||
if (prepared.length === 0) {
|
||||
return { conversationId: null, accepted: [], alreadyRegistered };
|
||||
}
|
||||
|
||||
const session = await this.auth.getSessionContext();
|
||||
const placeholder = `local:${randomUUID()}`;
|
||||
const reservations = await this.reserveBulk(prepared, session.systemNumber, placeholder);
|
||||
|
||||
let conversationId: string;
|
||||
try {
|
||||
const requests: EimsBulkRegisterRequest = reservations.map((r) =>
|
||||
toEimsInvoice(
|
||||
r.invoice,
|
||||
this.sellerCache.getSellerDetails(cfg),
|
||||
buildEimsContext(cfg, {
|
||||
documentNumber: r.documentNumber,
|
||||
invoiceCounter: r.invoiceCounter,
|
||||
previousIrn: r.previousIrn,
|
||||
session,
|
||||
documentType: r.documentType,
|
||||
reason: r.invoice.eimsReason,
|
||||
relatedDocument: r.relatedDocument,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const response = await this.client.postSigned<EimsBulkRegisterRequest, EimsBulkRegisterAcceptedResponse>(
|
||||
"/v1/bulkRegister",
|
||||
requests,
|
||||
);
|
||||
if (!response?.conversationId) {
|
||||
throw new EimsApiException(
|
||||
"SCHEMA_VALIDATION",
|
||||
"EIMS bulkRegister returned no conversationId",
|
||||
response?.status,
|
||||
);
|
||||
}
|
||||
conversationId = response.conversationId;
|
||||
} catch (err) {
|
||||
await this.settleBulkFailure(reservations, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.claimConversationId(placeholder, conversationId);
|
||||
this.logger.log(
|
||||
`Bulk-registered ${reservations.length} invoice(s) with EIMS (conversation ${conversationId}), awaiting callback`,
|
||||
);
|
||||
return { conversationId, accepted: reservations.map((r) => r.invoice.id), alreadyRegistered };
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a batch's callback, whenever MoR gets around to sending it. Called by
|
||||
* `EimsWebhookController` with the raw parsed array body — no auth on that route (MoR calls it,
|
||||
* not a logged-in user), so the only thing standing between this and a forged callback is the
|
||||
* conversation id itself: an item is only ever applied to an invoice actually holding that exact
|
||||
* id, and an unknown id is logged and ignored rather than touching anything.
|
||||
*/
|
||||
async handleBulkCallback(items: EimsBulkCallbackItem[]): Promise<EimsBulkRegisterItemResult[]> {
|
||||
const settlements = items.filter(
|
||||
(item): item is Exclude<EimsBulkCallbackItem, { conversationId?: string; conversionId?: string }> =>
|
||||
"irn" in item || "ruleError" in item,
|
||||
);
|
||||
|
||||
const conversationId = this.markerFrom(items);
|
||||
const invoices = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.where("invoice.eims_bulk_conversation_id = :id", { id: conversationId })
|
||||
.getMany();
|
||||
|
||||
if (invoices.length === 0) {
|
||||
this.logger.warn(
|
||||
`EIMS bulk callback for an unknown or already-settled conversation — ignored (${settlements.length} item(s))`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const byDocumentNumber = new Map(invoices.map((inv) => [inv.eimsDocumentNumber, inv]));
|
||||
// Process in invoiceCounter order so `previousIrn` ends up as the last-accepted item's IRN —
|
||||
// the same "advance the chain" semantics as single register's settleSuccess.
|
||||
const ordered = [...settlements].sort((a, b) => {
|
||||
const invA = byDocumentNumber.get("documentNumber" in a ? a.documentNumber : a.docNo);
|
||||
const invB = byDocumentNumber.get("documentNumber" in b ? b.documentNumber : b.docNo);
|
||||
return (invA?.eimsInvoiceCounter ?? 0) - (invB?.eimsInvoiceCounter ?? 0);
|
||||
});
|
||||
|
||||
const results: EimsBulkRegisterItemResult[] = [];
|
||||
for (const item of ordered) {
|
||||
const docNumber = "documentNumber" in item ? item.documentNumber : item.docNo;
|
||||
const invoice = byDocumentNumber.get(docNumber);
|
||||
if (!invoice) {
|
||||
this.logger.warn(`EIMS bulk callback item for unknown document number ${docNumber} — ignored`);
|
||||
continue;
|
||||
}
|
||||
if (invoice.eimsStatus !== EimsInvoiceStatus.Submitting) {
|
||||
// Already settled — a duplicate callback delivery. Report the current state, touch nothing.
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
success: invoice.eimsStatus === EimsInvoiceStatus.Registered,
|
||||
message: `Already settled (${invoice.eimsStatus})`,
|
||||
irn: invoice.eimsIrn ?? undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("irn" in item) {
|
||||
await this.settleBulkItemSuccess(invoice, item.irn, conversationId, item.signedQR);
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
success: true,
|
||||
message: `Registered with EIMS (IRN ${item.irn})`,
|
||||
irn: item.irn,
|
||||
});
|
||||
} else {
|
||||
const message = item.ruleError.flatMap((e) => e.errorMessage).join("; ") || "EIMS bulk rule validation error";
|
||||
await this.settleBulkItemFailure(invoice, message);
|
||||
results.push({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, success: false, message });
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the batch's in-flight marker only once nothing submitted under this conversation is
|
||||
// still waiting — a partial/incremental callback (not expected per the collection's docs, but
|
||||
// not ruled out either) must not prematurely unblock the system number.
|
||||
const stillPending = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.count({ where: { eimsBulkConversationId: conversationId, eimsStatus: EimsInvoiceStatus.Submitting } });
|
||||
if (stillPending === 0) {
|
||||
await this.dataSource.manager.update(
|
||||
EimsSystemState,
|
||||
{ inFlightConversationId: conversationId },
|
||||
{ inFlightConversationId: null },
|
||||
);
|
||||
this.logger.log(`EIMS bulk conversation ${conversationId} fully settled (${results.length} item(s))`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── transactions ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
|
||||
private async reserveBulk(
|
||||
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
|
||||
systemNumber: string,
|
||||
placeholder: string,
|
||||
): Promise<BulkReservation[]> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const state = await this.lockSystemState(manager, systemNumber);
|
||||
|
||||
if (state.blockedReason) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_SYSTEM_BLOCKED",
|
||||
message: `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. Resolve the affected invoice before registering anything else.`,
|
||||
});
|
||||
}
|
||||
if (state.inFlightInvoiceId) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_SUBMISSION_IN_FLIGHT",
|
||||
message: `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
|
||||
});
|
||||
}
|
||||
if (state.inFlightConversationId) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_BULK_IN_FLIGHT",
|
||||
message: `A bulk submission (conversation ${state.inFlightConversationId}) is already in flight on system ${systemNumber}. Wait for its callback, or resolve it if the process was interrupted.`,
|
||||
});
|
||||
}
|
||||
|
||||
let counter = Number(state.nextInvoiceCounter);
|
||||
let docNumber = Number(state.nextDocumentNumber);
|
||||
let previousIrn = state.previousIrn ?? "";
|
||||
const reservations: BulkReservation[] = [];
|
||||
|
||||
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
|
||||
// on the opposite lock order.
|
||||
for (const { invoice, documentType, relatedDocument } of prepared) {
|
||||
const locked = await this.lockInvoice(manager, invoice.id);
|
||||
const thisCounter = counter++;
|
||||
const thisDocNumber = String(docNumber++);
|
||||
const thisPreviousIrn = reservations.length === 0 ? previousIrn : "";
|
||||
|
||||
await manager.update(Invoice, invoice.id, {
|
||||
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||
eimsInvoiceCounter: thisCounter,
|
||||
eimsDocumentNumber: thisDocNumber,
|
||||
eimsSubmittedAt: new Date(),
|
||||
eimsLastError: null,
|
||||
eimsBulkConversationId: placeholder,
|
||||
} as QueryDeepPartialEntity<Invoice>);
|
||||
|
||||
reservations.push({
|
||||
stateId: state.id,
|
||||
invoice: Object.assign(locked, { lines: invoice.lines }),
|
||||
documentType,
|
||||
relatedDocument,
|
||||
invoiceCounter: thisCounter,
|
||||
documentNumber: thisDocNumber,
|
||||
previousIrn: thisPreviousIrn,
|
||||
});
|
||||
}
|
||||
|
||||
await manager.update(EimsSystemState, state.id, {
|
||||
nextInvoiceCounter: counter,
|
||||
nextDocumentNumber: docNumber,
|
||||
inFlightConversationId: placeholder,
|
||||
});
|
||||
|
||||
return reservations;
|
||||
});
|
||||
}
|
||||
|
||||
/** Swap the local placeholder for MoR's real conversation id, on both the state row and every invoice. */
|
||||
private async claimConversationId(placeholder: string, conversationId: string): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.update(EimsSystemState, { inFlightConversationId: placeholder }, { inFlightConversationId: conversationId });
|
||||
await manager.update(Invoice, { eimsBulkConversationId: placeholder }, { eimsBulkConversationId: conversationId });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TX2b for the whole batch — the same determinism doctrine as single register's settleFailure,
|
||||
* applied once since `/v1/bulkRegister` either accepts the whole array (202) or fails as one HTTP
|
||||
* call; there is no per-item answer yet at this point, only after the callback.
|
||||
*/
|
||||
private async settleBulkFailure(reservations: BulkReservation[], err: unknown): Promise<void> {
|
||||
const api = err instanceof EimsApiException ? err : null;
|
||||
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
|
||||
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
||||
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
|
||||
const lastError: EimsInvoiceError = {
|
||||
kind: api?.kind ?? localKind,
|
||||
message: (err as Error)?.message ?? "unknown error",
|
||||
httpStatus: api?.httpStatus,
|
||||
details: api?.details,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
const first = reservations[0];
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const r of reservations) {
|
||||
await manager.update(Invoice, r.invoice.id, {
|
||||
eimsStatus: status,
|
||||
eimsLastError: lastError,
|
||||
...(deterministic ? { eimsBulkConversationId: null } : {}),
|
||||
} as QueryDeepPartialEntity<Invoice>);
|
||||
}
|
||||
await manager.update(
|
||||
EimsSystemState,
|
||||
first.stateId,
|
||||
deterministic
|
||||
? {
|
||||
// The whole block returns: MoR never counted a refused batch against either sequence.
|
||||
nextInvoiceCounter: first.invoiceCounter,
|
||||
nextDocumentNumber: Number(first.documentNumber),
|
||||
inFlightConversationId: null,
|
||||
}
|
||||
: {
|
||||
blockedReason:
|
||||
`A bulk submission of ${reservations.length} invoice(s) (starting counter ${first.invoiceCounter}) ` +
|
||||
`was sent but never acknowledged (${lastError.kind}). No further document can be filed until it is resolved.`,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.error(`EIMS bulk submission ${status}: ${lastError.message}`);
|
||||
}
|
||||
|
||||
/** One callback item accepted. */
|
||||
private async settleBulkItemSuccess(
|
||||
invoice: Invoice,
|
||||
irn: string,
|
||||
conversationId: string,
|
||||
signedQR?: string,
|
||||
): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.lockInvoice(manager, invoice.id);
|
||||
await manager.update(Invoice, invoice.id, {
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
eimsIrn: irn,
|
||||
eimsSignedQr: signedQR ?? null,
|
||||
eimsLastError: null,
|
||||
});
|
||||
// Looked up by conversation id, not system number — this batch's state row is whichever one
|
||||
// is holding this conversation, which is exactly what `inFlightConversationId` already tracks.
|
||||
await manager.update(EimsSystemState, { inFlightConversationId: conversationId }, { previousIrn: irn });
|
||||
});
|
||||
this.logger.log(`Invoice ${invoice.invoiceNumber} registered with EIMS via bulk (IRN ${irn})`);
|
||||
|
||||
if (invoice.companyId) {
|
||||
try {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
this.notifications,
|
||||
invoice.companyId,
|
||||
`Invoice ${invoice.invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One callback item rejected. Unlike single register's settleFailure, the counter/document
|
||||
* number are not returned — MoR's own bulk processing already advanced the whole array's
|
||||
* allocation regardless of this item's individual outcome, so there is nothing local to roll back.
|
||||
*/
|
||||
private async settleBulkItemFailure(invoice: Invoice, message: string): Promise<void> {
|
||||
const lastError: EimsInvoiceError = { kind: "RULE_VALIDATION", message, at: new Date().toISOString() };
|
||||
await this.dataSource.manager.update(Invoice, invoice.id, {
|
||||
eimsStatus: EimsInvoiceStatus.Failed,
|
||||
eimsLastError: lastError,
|
||||
} as QueryDeepPartialEntity<Invoice>);
|
||||
this.logger.error(`Invoice ${invoice.invoiceNumber} EIMS bulk registration FAILED: ${message}`);
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private markerFrom(items: EimsBulkCallbackItem[]): string {
|
||||
const marker = items.find((i) => "conversationId" in i || "conversionId" in i) as
|
||||
| { conversationId?: string; conversionId?: string }
|
||||
| undefined;
|
||||
return marker?.conversationId ?? marker?.conversionId ?? "";
|
||||
}
|
||||
|
||||
|
||||
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||
const invoice = await manager
|
||||
.createQueryBuilder(Invoice, "invoice")
|
||||
.setLock("pessimistic_write")
|
||||
.where("invoice.id = :invoiceId", { invoiceId })
|
||||
.getOne();
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private async lockSystemState(manager: EntityManager, systemNumber: string): Promise<EimsSystemState> {
|
||||
const select = () =>
|
||||
manager
|
||||
.createQueryBuilder(EimsSystemState, "state")
|
||||
.setLock("pessimistic_write")
|
||||
.where("state.system_number = :systemNumber", { systemNumber })
|
||||
.getOne();
|
||||
|
||||
const existing = await select();
|
||||
if (existing) return existing;
|
||||
|
||||
await manager.query(
|
||||
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1) ON CONFLICT (system_number) DO NOTHING`,
|
||||
[systemNumber],
|
||||
);
|
||||
const created = await select();
|
||||
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
private async loadInvoicesForMapping(invoiceIds: string[]): Promise<Array<Invoice & { lines: EimsMapperLine[] }>> {
|
||||
const invoices = await this.dataSource.getRepository(Invoice).find({
|
||||
where: { id: In(invoiceIds) },
|
||||
relations: { company: true, companyProfile: true, relatedInvoice: true },
|
||||
});
|
||||
const found = new Set(invoices.map((inv) => inv.id));
|
||||
const missing = invoiceIds.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new NotFoundException(`Invoice(s) not found: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const lines: Array<EimsMapperLine & { invoiceId: string }> = await this.dataSource.query(
|
||||
`SELECT invoice_id AS "invoiceId", charge_type AS "chargeType", description, quantity,
|
||||
unit_rate AS "unitRate", amount, currency, metadata
|
||||
FROM freight.invoice_lines
|
||||
WHERE invoice_id = ANY($1) AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`,
|
||||
[invoiceIds],
|
||||
);
|
||||
const linesByInvoice = new Map<string, EimsMapperLine[]>();
|
||||
for (const line of lines) {
|
||||
const { invoiceId, ...rest } = line;
|
||||
if (!linesByInvoice.has(invoiceId)) linesByInvoice.set(invoiceId, []);
|
||||
linesByInvoice.get(invoiceId)!.push(rest);
|
||||
}
|
||||
|
||||
// Preserve the caller's given order — reservation and result ordering both depend on it.
|
||||
return invoiceIds.map((id) => {
|
||||
const invoice = invoices.find((inv) => inv.id === id)!;
|
||||
return Object.assign(invoice, { lines: linesByInvoice.get(id) ?? [] });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { sendPdf } from "../billing/billing.controller";
|
||||
import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto";
|
||||
import { BulkRegisterEimsInvoiceDto } from "./dto/bulk-register-eims-invoice.dto";
|
||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
||||
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsReceiptService } from "./eims-receipt.service";
|
||||
@@ -39,6 +41,7 @@ import { EimsReceiptService } from "./eims-receipt.service";
|
||||
export class EimsInvoiceController {
|
||||
constructor(
|
||||
private readonly registration: EimsInvoiceRegistrationService,
|
||||
private readonly bulkRegistration: EimsBulkRegistrationService,
|
||||
private readonly cancellation: EimsCancellationService,
|
||||
private readonly receipts: EimsReceiptService,
|
||||
) {}
|
||||
@@ -53,6 +56,18 @@ export class EimsInvoiceController {
|
||||
return this.registration.registerInvoiceWithEims(id);
|
||||
}
|
||||
|
||||
@Post("eims/bulk-register")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR " +
|
||||
"accepted the batch (a conversation id), not the per-invoice outcome. Real results (IRN or " +
|
||||
"rejection per invoice) arrive later via MoR's own callback; poll GET :id/eims/status.",
|
||||
})
|
||||
bulkRegister(@Body() dto: BulkRegisterEimsInvoiceDto) {
|
||||
return this.bulkRegistration.registerBulk(dto.invoiceIds);
|
||||
}
|
||||
|
||||
@Post(":id/eims/verify")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||
@ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||
import { EimsErrorResponse } from "./eims.types";
|
||||
|
||||
/**
|
||||
@@ -129,6 +130,62 @@ export interface EimsBulkCancelItemResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /v1/bulkRegister` — same array-of-full-documents shape as single register (`EimsInvoiceRequest`
|
||||
* from `eims-invoice.mapper.ts`), one element per invoice, sent as one signed envelope.
|
||||
*/
|
||||
export type EimsBulkRegisterRequest = EimsInvoiceRequest[];
|
||||
|
||||
/**
|
||||
* Immediate response to `bulkRegister` — unlike single register, this is not the result, just an
|
||||
* acknowledgement. The real per-invoice outcomes arrive later via `EimsBulkCallbackItem`s pushed to
|
||||
* a webhook MoR was configured with out of band (see `EimsBulkRegistrationService`).
|
||||
*/
|
||||
export interface EimsBulkRegisterAcceptedResponse {
|
||||
conversationId: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/** A settled item in the async callback — `irn` present means MoR accepted this document. */
|
||||
export interface EimsBulkCallbackSuccessItem {
|
||||
irn: string;
|
||||
status: string;
|
||||
documentNumber: string;
|
||||
signedQR?: string;
|
||||
signedInvoice?: string;
|
||||
}
|
||||
|
||||
/** A rejected item in the async callback — `docNo` echoes what we submitted as `DocumentNumber`. */
|
||||
export interface EimsBulkCallbackErrorItem {
|
||||
ruleError: Array<{ portion: string; errorMessage: string[] }>;
|
||||
status: string;
|
||||
docNo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback array's last element, per the collection's own examples — never a settlement result,
|
||||
* just the batch id echoed back. Spelled two different ways across the collection's own docs
|
||||
* ("conversationId" on the initial 202, "conversionId" in the callback examples); accept both.
|
||||
*/
|
||||
export interface EimsBulkCallbackMarker {
|
||||
conversationId?: string;
|
||||
conversionId?: string;
|
||||
}
|
||||
|
||||
export type EimsBulkCallbackItem =
|
||||
| EimsBulkCallbackSuccessItem
|
||||
| EimsBulkCallbackErrorItem
|
||||
| EimsBulkCallbackMarker;
|
||||
|
||||
/** One invoice's outcome once a bulk batch's callback has been processed. */
|
||||
export interface EimsBulkRegisterItemResult {
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
irn?: string;
|
||||
}
|
||||
|
||||
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
||||
export interface EimsInvoiceError {
|
||||
kind: string;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, HttpCode, Post } from "@nestjs/common";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { EimsBulkCallbackItem } from "./eims-registration.types";
|
||||
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||
|
||||
/**
|
||||
* MoR's own callback for `POST /v1/bulkRegister`, not a route a person calls. Public — MoR has no
|
||||
* JWT to send — so the conversation id embedded in the payload is the only thing standing between
|
||||
* this and a forged callback: `EimsBulkRegistrationService.handleBulkCallback` only ever touches
|
||||
* invoices actually holding that exact id, and an unrecognised one is logged and ignored. See the
|
||||
* "Callback Mechanism" section of the collection's own docs for the payload shape.
|
||||
*/
|
||||
@ApiExcludeController()
|
||||
@Controller("eims/webhook")
|
||||
export class EimsWebhookController {
|
||||
constructor(private readonly bulk: EimsBulkRegistrationService) {}
|
||||
|
||||
@Public()
|
||||
@Post("bulk-register")
|
||||
@HttpCode(200)
|
||||
bulkRegisterCallback(@Body() items: EimsBulkCallbackItem[]) {
|
||||
return this.bulk.handleBulkCallback(items);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { NotificationInboxModule } from "../notification-inbox/notification-inbo
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
|
||||
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||
@@ -17,6 +18,7 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
|
||||
import { EimsReceiptService } from "./eims-receipt.service";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
import { EimsWebhookController } from "./eims-webhook.controller";
|
||||
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
|
||||
@@ -40,13 +42,14 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
// so this stays a plain one-directional import, not a new cycle.
|
||||
CompaniesModule,
|
||||
],
|
||||
controllers: [EimsInvoiceController],
|
||||
controllers: [EimsInvoiceController, EimsWebhookController],
|
||||
providers: [
|
||||
EimsCredentialsProvider,
|
||||
EimsSignerService,
|
||||
EimsAuthService,
|
||||
EimsClientService,
|
||||
EimsInvoiceRegistrationService,
|
||||
EimsBulkRegistrationService,
|
||||
EimsAutoSubmitService,
|
||||
EimsCancellationService,
|
||||
EimsReceiptService,
|
||||
@@ -56,6 +59,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
EimsAuthService,
|
||||
EimsClientService,
|
||||
EimsInvoiceRegistrationService,
|
||||
EimsBulkRegistrationService,
|
||||
EimsCancellationService,
|
||||
EimsReceiptService,
|
||||
],
|
||||
|
||||
@@ -54,4 +54,11 @@ export class EimsSystemState extends BaseEntity {
|
||||
*/
|
||||
@Column({ name: "blocked_reason", type: "text", nullable: true })
|
||||
blockedReason?: string | null;
|
||||
|
||||
/**
|
||||
* Bulk equivalent of `in_flight_invoice_id` — a whole batch, not one invoice, is outstanding
|
||||
* while MoR processes `POST /v1/bulkRegister` asynchronously. See `EimsBulkRegistrationService`.
|
||||
*/
|
||||
@Column({ name: "in_flight_conversation_id", type: "text", nullable: true })
|
||||
inFlightConversationId?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
import { Contract } from '../../contracts/entities/contract.entity';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/**
|
||||
* Domain semantics shared with `reports/definitions/bookings-list.report.ts`.
|
||||
* Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
|
||||
* holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
|
||||
* overrides `total_amount`. Getting either wrong misreports money or weight.
|
||||
*/
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
|
||||
'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT',
|
||||
'ARRIVED', 'DELIVERED', 'COMPLETED',
|
||||
].map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
|
||||
|
||||
export const bookingsDataset: ExportDataset = {
|
||||
key: 'bookings',
|
||||
title: 'Bookings',
|
||||
description: 'Every booking, with customer, route, cargo, contract and payment detail',
|
||||
group: 'Commercial',
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
base: { entity: Booking, alias: 'b' },
|
||||
|
||||
// Every join is a LEFT join (see ExportJoin) — ticking a field must never
|
||||
// change which rows come back.
|
||||
joins: [
|
||||
{ alias: 'c', entity: Company, on: 'c.id = b.company_id' },
|
||||
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = b.company_profile_id' },
|
||||
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = b.shipping_line_company_id' },
|
||||
{ alias: 'o', entity: Yard, on: 'o.id = b.origin_yard_id' },
|
||||
{ alias: 'd', entity: Yard, on: 'd.id = b.destination_yard_id' },
|
||||
{ alias: 'cty', entity: CargoType, on: 'cty.id = b.cargo_type_id' },
|
||||
{ alias: 'st', entity: ServiceType, on: 'st.id = b.service_type_id' },
|
||||
{ alias: 'sl', entity: ShippingLine, on: 'sl.id = b.shipping_line_id' },
|
||||
{ alias: 'ct', entity: Contract, on: 'ct.id = b.contract_id' },
|
||||
{ alias: 't', entity: Train, on: 't.id = b.train_id' },
|
||||
// Transitive: the contract's own customer, reachable only once `ct` is in.
|
||||
{ alias: 'ctc', entity: Company, on: 'ctc.id = ct.company_id', requires: ['ct'] },
|
||||
],
|
||||
// `search` matches the customer name, so `c` is always present — which is
|
||||
// also why the count query joins it.
|
||||
alwaysJoin: ['c'],
|
||||
|
||||
groups: [
|
||||
{ id: 'booking', label: 'Booking' },
|
||||
{ id: 'customer', label: 'Customer' },
|
||||
{ id: 'route', label: 'Route' },
|
||||
{ id: 'cargo', label: 'Cargo' },
|
||||
{ id: 'payment', label: 'Payment' },
|
||||
{ id: 'scheduling', label: 'Scheduling' },
|
||||
{ id: 'contract', label: 'Contract' },
|
||||
{ id: 'firstMile', label: 'First mile' },
|
||||
{ id: 'lastMile', label: 'Last mile' },
|
||||
{ id: 'clearance', label: 'Clearance' },
|
||||
],
|
||||
|
||||
fields: [
|
||||
// ---- Booking -------------------------------------------------------
|
||||
{ key: 'reference', label: 'Reference', type: 'string', group: 'booking', default: true, select: 'b.reference', sortExpr: 'b.reference' },
|
||||
{ key: 'status', label: 'Status', type: 'string', group: 'booking', default: true, select: 'b.status', sortExpr: 'b.status' },
|
||||
{ key: 'bookingType', label: 'Booking type', type: 'string', group: 'booking', select: 'b.booking_type' },
|
||||
{ key: 'contractKind', label: 'Contract kind', type: 'string', group: 'booking', select: 'b.contract_kind' },
|
||||
{ key: 'createdAt', label: 'Created', type: 'datetime', group: 'booking', default: true, select: `to_char(b.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.created_at' },
|
||||
{ key: 'updatedAt', label: 'Updated', type: 'datetime', group: 'booking', select: `to_char(b.updated_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'b.updated_at' },
|
||||
{ key: 'expiresAt', label: 'Expires', type: 'date', group: 'booking', select: `to_char(b.expires_at, 'YYYY-MM-DD')` },
|
||||
{ key: 'createdByRole', label: 'Created by role', type: 'string', group: 'booking', select: 'b.created_by_role' },
|
||||
{ key: 'isSplit', label: 'Split booking', type: 'boolean', group: 'booking', select: 'b.is_split' },
|
||||
{ key: 'priorityScore', label: 'Priority score', type: 'number', group: 'booking', select: 'b.priority_score', sortExpr: 'b.priority_score' },
|
||||
{ key: 'versionNumber', label: 'Version', type: 'number', group: 'booking', select: 'b.version_number' },
|
||||
{ key: 'pnrCode', label: 'PNR code', type: 'string', group: 'booking', select: 'b.pnr_code' },
|
||||
|
||||
// ---- Customer (the "more than the UI shows" payload) ----------------
|
||||
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
|
||||
{ key: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' },
|
||||
{ key: 'customerKind', label: 'Customer kind', type: 'string', group: 'customer', requires: ['c'], select: 'c.kind' },
|
||||
{ key: 'customerStatus', label: 'Customer status', type: 'string', group: 'customer', requires: ['c'], select: 'c.status' },
|
||||
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
|
||||
{ key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' },
|
||||
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
|
||||
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
|
||||
{ key: 'customerContact', label: 'Contact person', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_name' },
|
||||
{ key: 'customerContactPhone', label: 'Contact phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.contact_person_phone' },
|
||||
{ key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' },
|
||||
{ key: 'customerCountry', label: 'Customer country', type: 'string', group: 'customer', requires: ['c'], select: 'c.country' },
|
||||
{ key: 'customerRegion', label: 'Customer region', type: 'string', group: 'customer', requires: ['c'], select: 'c.region' },
|
||||
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
|
||||
{ key: 'customerProfileType', label: 'Profile type', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.type' },
|
||||
{ key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'b.is_government' },
|
||||
{ key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'b.government_institution' },
|
||||
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' },
|
||||
|
||||
// ---- Route ----------------------------------------------------------
|
||||
{ key: 'origin', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['o'], select: 'o.label' },
|
||||
{ key: 'originCode', label: 'Origin code', type: 'string', group: 'route', requires: ['o'], select: 'o.code' },
|
||||
{ key: 'destination', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['d'], select: 'd.label' },
|
||||
{ key: 'destinationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['d'], select: 'd.code' },
|
||||
{ key: 'tradeDirection', label: 'Direction', type: 'string', group: 'route', default: true, select: 'b.trade_direction', sortExpr: 'b.trade_direction' },
|
||||
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' },
|
||||
|
||||
// ---- Cargo -----------------------------------------------------------
|
||||
{ key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
|
||||
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
|
||||
{ key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' },
|
||||
{ key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' },
|
||||
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' },
|
||||
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' },
|
||||
{ key: 'shippingLine', label: 'Shipping line', type: 'string', group: 'cargo', requires: ['sl'], select: 'sl.label' },
|
||||
{
|
||||
// One-to-many, so it aggregates in a correlated subquery rather than a
|
||||
// join — a join here would multiply rows and break the count contract.
|
||||
key: 'containerNumbers', label: 'Container numbers', type: 'string', group: 'cargo',
|
||||
select: `(SELECT string_agg(bc.container_number, ' | ' ORDER BY bc.container_number)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL)`,
|
||||
},
|
||||
|
||||
// ---- Payment ----------------------------------------------------------
|
||||
{ key: 'amount', label: 'Amount', type: 'money', group: 'payment', default: true, select: `ROUND(${REVENUE}, 2)::float8`, sortExpr: REVENUE },
|
||||
{ key: 'totalAmount', label: 'Total amount (pre-adjustment)', type: 'money', group: 'payment', select: 'b.total_amount::float8' },
|
||||
{ key: 'adjustedTotalAmount', label: 'Adjusted total', type: 'money', group: 'payment', select: 'b.adjusted_total_amount::float8' },
|
||||
{ key: 'adjustmentReason', label: 'Adjustment reason', type: 'string', group: 'payment', select: 'b.adjustment_reason' },
|
||||
{ key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', default: true, select: 'b.payment_status', sortExpr: 'b.payment_status' },
|
||||
{ key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'payment', select: 'b.payment_currency' },
|
||||
{ key: 'paymentDeadline', label: 'Payment deadline', type: 'datetime', group: 'payment', select: `to_char(b.payment_deadline, 'YYYY-MM-DD HH24:MI')` },
|
||||
|
||||
// ---- Scheduling --------------------------------------------------------
|
||||
{ key: 'scheduledDate', label: 'Scheduled date', type: 'date', group: 'scheduling', default: true, select: `to_char(b.scheduled_date, 'YYYY-MM-DD')`, sortExpr: 'b.scheduled_date' },
|
||||
{ key: 'schedulingStatus', label: 'Scheduling status', type: 'string', group: 'scheduling', select: 'b.scheduling_status' },
|
||||
{ key: 'wagonsRequired', label: 'Wagons required', type: 'number', group: 'scheduling', select: 'b.wagons_required' },
|
||||
{ key: 'trainCode', label: 'Train', type: 'string', group: 'scheduling', requires: ['t'], select: 't.code' },
|
||||
{ key: 'loadedAt', label: 'Loaded at', type: 'datetime', group: 'scheduling', select: `to_char(b.loaded_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'arrivedAt', label: 'Arrived at', type: 'datetime', group: 'scheduling', select: `to_char(b.arrived_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
|
||||
// ---- Contract ----------------------------------------------------------
|
||||
{ key: 'contractReference', label: 'Contract reference', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.reference' },
|
||||
{ key: 'contractStatus', label: 'Contract status', type: 'string', group: 'contract', requires: ['ct'], select: 'ct.status' },
|
||||
{ key: 'contractCustomer', label: 'Contract customer', type: 'string', group: 'contract', requires: ['ctc'], select: 'ctc.name' },
|
||||
{ key: 'contractType', label: 'Contract type', type: 'string', group: 'contract', select: 'b.contract_type' },
|
||||
{ key: 'contractValidFrom', label: 'Contract valid from', type: 'date', group: 'contract', select: `to_char(b.contract_valid_from, 'YYYY-MM-DD')` },
|
||||
{ key: 'contractValidUntil', label: 'Contract valid until', type: 'date', group: 'contract', select: `to_char(b.contract_valid_until, 'YYYY-MM-DD')` },
|
||||
{ key: 'fullyExecutedAt', label: 'Fully executed at', type: 'datetime', group: 'contract', select: `to_char(b.fully_executed_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
|
||||
// ---- First / last mile ---------------------------------------------------
|
||||
{ key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'firstMile', select: 'b.first_mile_pickup_address' },
|
||||
{ key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'lastMile', select: 'b.last_mile_delivery_address' },
|
||||
{ key: 'customerTruckPlate', label: 'Customer truck plate', type: 'string', group: 'lastMile', select: 'b.customer_truck_plate_number' },
|
||||
{ key: 'customerTruckDriver', label: 'Customer truck driver', type: 'string', group: 'lastMile', select: 'b.customer_truck_driver_name' },
|
||||
{ key: 'exportHandoverMode', label: 'Handover mode', type: 'string', group: 'lastMile', select: 'b.export_handover_mode' },
|
||||
|
||||
// ---- Clearance -------------------------------------------------------------
|
||||
{ key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'b.customs_clearing_enabled' },
|
||||
{ key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'b.customs_clearing_agent' },
|
||||
{ key: 'clearancePhase', label: 'Clearance phase', type: 'string', group: 'clearance', select: 'b.clearance_current_phase' },
|
||||
{ key: 'dutyRequired', label: 'Duty required', type: 'boolean', group: 'clearance', select: 'b.duty_required' },
|
||||
{ key: 'vesselArrivalDate', label: 'Vessel arrival', type: 'date', group: 'clearance', select: `to_char(b.vessel_arrival_date, 'YYYY-MM-DD')` },
|
||||
{ key: 'doCollectedDate', label: 'DO collected', type: 'date', group: 'clearance', select: `to_char(b.do_collected_date, 'YYYY-MM-DD')` },
|
||||
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
|
||||
],
|
||||
|
||||
filters: [
|
||||
{ key: 'created', label: 'Created', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
{
|
||||
key: 'tradeDirection', label: 'Direction', type: 'select',
|
||||
options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })),
|
||||
},
|
||||
{
|
||||
key: 'freightType', label: 'Freight type', type: 'select',
|
||||
options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })),
|
||||
},
|
||||
{ key: 'paymentStatus', label: 'Payment status', type: 'select', options: [
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'PNR_GENERATED', label: 'PNR generated' },
|
||||
{ value: 'VERIFICATION_IN_PROGRESS', label: 'Verification in progress' },
|
||||
{ value: 'PAID', label: 'Paid' },
|
||||
{ value: 'FAILED', label: 'Failed' },
|
||||
] },
|
||||
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'createdAt', dir: 'DESC' },
|
||||
|
||||
scope(ctx, qb) {
|
||||
const { params, directions } = ctx;
|
||||
// andWhere, not where: `where()` resets any condition already on the
|
||||
// builder, so scope() would silently drop anything a caller added first.
|
||||
qb.andWhere('b.deleted_at IS NULL');
|
||||
|
||||
if (params.createdFrom) qb.andWhere('b.created_at >= :createdFrom', { createdFrom: params.createdFrom });
|
||||
if (params.createdTo) qb.andWhere('b.created_at < :createdTo', { createdTo: params.createdTo });
|
||||
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses?.length) qb.andWhere('b.status IN (:...statuses)', { statuses });
|
||||
|
||||
if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
|
||||
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
|
||||
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
|
||||
if (params.search) {
|
||||
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
|
||||
}
|
||||
|
||||
// Trade-direction ACL. Without this the export returns rows the user's own
|
||||
// list page would not show them.
|
||||
applyDirectionScope(qb, 'b.trade_direction', directions);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
import { Contract } from '../../contracts/entities/contract.entity';
|
||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
export const contractsDataset: ExportDataset = {
|
||||
key: 'contracts',
|
||||
title: 'Contracts',
|
||||
description: 'Contracts with customer, terms, routes, approval and clearance detail',
|
||||
group: 'Commercial',
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
base: { entity: Contract, alias: 'ct' },
|
||||
|
||||
joins: [
|
||||
{ alias: 'c', entity: Company, on: 'c.id = ct.company_id' },
|
||||
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = ct.company_profile_id' },
|
||||
{ alias: 'st', entity: ServiceType, on: 'st.id = ct.service_type_id' },
|
||||
{ alias: 'ren', entity: Contract, on: 'ren.id = ct.renewal_of_id' },
|
||||
],
|
||||
// `search` matches the customer name.
|
||||
alwaysJoin: ['c'],
|
||||
|
||||
groups: [
|
||||
{ id: 'contract', label: 'Contract' },
|
||||
{ id: 'customer', label: 'Customer' },
|
||||
{ id: 'terms', label: 'Terms' },
|
||||
{ id: 'routes', label: 'Routes & cargo' },
|
||||
{ id: 'approval', label: 'Approval' },
|
||||
{ id: 'clearance', label: 'Clearance' },
|
||||
],
|
||||
|
||||
fields: [
|
||||
{ key: 'reference', label: 'Reference', type: 'string', group: 'contract', default: true, select: 'ct.reference', sortExpr: 'ct.reference' },
|
||||
{ key: 'status', label: 'Status', type: 'string', group: 'contract', default: true, select: 'ct.status', sortExpr: 'ct.status' },
|
||||
{ key: 'contractKind', label: 'Kind', type: 'string', group: 'contract', default: true, select: 'ct.contract_kind' },
|
||||
{ key: 'contractType', label: 'Type', type: 'string', group: 'contract', select: 'ct.contract_type' },
|
||||
{ key: 'createdAt', label: 'Created', type: 'date', group: 'contract', default: true, select: `to_char(ct.created_at, 'YYYY-MM-DD')`, sortExpr: 'ct.created_at' },
|
||||
{ key: 'submittedAt', label: 'Submitted', type: 'datetime', group: 'contract', select: `to_char(ct.submitted_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'versionNumber', label: 'Version', type: 'number', group: 'contract', select: 'ct.version_number' },
|
||||
{ key: 'renewalOf', label: 'Renewal of', type: 'string', group: 'contract', requires: ['ren'], select: 'ren.reference' },
|
||||
{ key: 'statusBeforeSuspension', label: 'Status before suspension', type: 'string', group: 'contract', select: 'ct.status_before_suspension' },
|
||||
|
||||
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
|
||||
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
|
||||
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
|
||||
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
|
||||
{ key: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' },
|
||||
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
|
||||
{ key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'ct.is_government' },
|
||||
{ key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'ct.government_institution' },
|
||||
|
||||
{ key: 'tradeDirection', label: 'Direction', type: 'string', group: 'terms', default: true, select: 'ct.trade_direction', sortExpr: 'ct.trade_direction' },
|
||||
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'terms', default: true, select: 'ct.freight_type' },
|
||||
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'terms', requires: ['st'], select: 'st.service_name' },
|
||||
{ key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'terms', select: 'ct.payment_currency' },
|
||||
{ key: 'validFrom', label: 'Valid from', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, sortExpr: 'ct.contract_valid_from' },
|
||||
{ key: 'validUntil', label: 'Valid until', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_until, 'YYYY-MM-DD')` },
|
||||
{ key: 'validityDays', label: 'Validity (days)', type: 'number', group: 'terms', select: 'ct.contract_validity_days' },
|
||||
{ key: 'expiresAt', label: 'Expires', type: 'date', group: 'terms', select: `to_char(ct.expires_at, 'YYYY-MM-DD')` },
|
||||
{ key: 'estimatedShipmentDate', label: 'Est. shipment date', type: 'date', group: 'terms', select: `to_char(ct.estimated_shipment_date, 'YYYY-MM-DD')` },
|
||||
{ key: 'equipmentReturn', label: 'Equipment return', type: 'string', group: 'terms', select: 'ct.equipment_return' },
|
||||
{ key: 'pricingDisplayMode', label: 'Pricing display mode', type: 'string', group: 'terms', select: 'ct.pricing_display_mode' },
|
||||
|
||||
{
|
||||
key: 'routes', label: 'Routes', type: 'string', group: 'routes',
|
||||
select: `(SELECT string_agg(o.label || ' -> ' || d.label, ' | ' ORDER BY cr.sort_order)
|
||||
FROM freight.contract_routes cr
|
||||
JOIN freight.yards o ON o.id = cr.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = cr.destination_yard_id
|
||||
WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'routeCount', label: 'Route count', type: 'number', group: 'routes',
|
||||
select: `(SELECT COUNT(*)::int FROM freight.contract_routes cr
|
||||
WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'routes',
|
||||
select: `(SELECT COUNT(*)::int FROM freight.bookings b
|
||||
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL)`,
|
||||
},
|
||||
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'routes', select: 'ct.is_hazardous' },
|
||||
{ key: 'hazardClass', label: 'Hazard class', type: 'string', group: 'routes', select: 'ct.hazard_class' },
|
||||
{ key: 'unNumber', label: 'UN number', type: 'string', group: 'routes', select: 'ct.un_number' },
|
||||
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'routes', select: 'ct.is_reefer' },
|
||||
|
||||
{ key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'approval', select: `to_char(ct.approved_by_staff_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'signedByDirectorAt', label: 'Director signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_director_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'signedByCeoAt', label: 'CEO signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_ceo_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'customerSignedAt', label: 'Customer signed', type: 'datetime', group: 'approval', select: `to_char(ct.customer_signed_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'fullyExecutedAt', label: 'Fully executed', type: 'datetime', group: 'approval', default: true, select: `to_char(ct.fully_executed_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'lockedAt', label: 'Locked at', type: 'datetime', group: 'approval', select: `to_char(ct.locked_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'contractGeneratedAt', label: 'Document generated', type: 'datetime', group: 'approval', select: `to_char(ct.contract_generated_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
|
||||
{ key: 'clearanceStatus', label: 'Clearance status', type: 'string', group: 'clearance', select: 'ct.clearance_status' },
|
||||
{ key: 'clearanceCycleNumber', label: 'Clearance cycle', type: 'number', group: 'clearance', select: 'ct.clearance_cycle_number' },
|
||||
{ key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'ct.customs_clearing_enabled' },
|
||||
{ key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'ct.customs_clearing_agent' },
|
||||
{ key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'clearance', select: 'ct.first_mile_pickup_address' },
|
||||
{ key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'clearance', select: 'ct.last_mile_delivery_address' },
|
||||
],
|
||||
|
||||
filters: [
|
||||
{ key: 'created', label: 'Created', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||
{ key: 'contractKind', label: 'Kind', type: 'text' },
|
||||
{ key: 'tradeDirection', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) },
|
||||
{ key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) },
|
||||
{ key: 'paymentCurrency', label: 'Currency', type: 'select', options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
] },
|
||||
{ key: 'serviceTypeId', label: 'Service type', type: 'text' },
|
||||
// Routes are one-to-many on contract_routes, so these filter via EXISTS
|
||||
// rather than a column comparison.
|
||||
{ key: 'originYardId', label: 'Origin', type: 'text' },
|
||||
{ key: 'destinationYardId', label: 'Destination', type: 'text' },
|
||||
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'createdAt', dir: 'DESC' },
|
||||
|
||||
scope(ctx, qb) {
|
||||
const { params, directions } = ctx;
|
||||
qb.andWhere('ct.deleted_at IS NULL');
|
||||
if (params.createdFrom) qb.andWhere('ct.created_at >= :createdFrom', { createdFrom: params.createdFrom });
|
||||
if (params.createdTo) qb.andWhere('ct.created_at < :createdTo', { createdTo: params.createdTo });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses?.length) qb.andWhere('ct.status IN (:...statuses)', { statuses });
|
||||
if (params.contractKind) qb.andWhere('ct.contract_kind = :contractKind', { contractKind: params.contractKind });
|
||||
if (params.tradeDirection) qb.andWhere('ct.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
|
||||
if (params.freightType) qb.andWhere('ct.freight_type = :freightType', { freightType: params.freightType });
|
||||
if (params.paymentCurrency) qb.andWhere('ct.payment_currency = :paymentCurrency', { paymentCurrency: params.paymentCurrency });
|
||||
if (params.serviceTypeId) qb.andWhere('ct.service_type_id = :serviceTypeId', { serviceTypeId: params.serviceTypeId });
|
||||
if (params.originYardId) {
|
||||
qb.andWhere(
|
||||
`EXISTS (SELECT 1 FROM freight.contract_routes cr
|
||||
WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL
|
||||
AND cr.origin_yard_id = :originYardId)`,
|
||||
{ originYardId: params.originYardId },
|
||||
);
|
||||
}
|
||||
if (params.destinationYardId) {
|
||||
qb.andWhere(
|
||||
`EXISTS (SELECT 1 FROM freight.contract_routes cr2
|
||||
WHERE cr2.contract_id = ct.id AND cr2.deleted_at IS NULL
|
||||
AND cr2.destination_yard_id = :destinationYardId)`,
|
||||
{ destinationYardId: params.destinationYardId },
|
||||
);
|
||||
}
|
||||
if (params.companyId) qb.andWhere('ct.company_id = :companyId', { companyId: params.companyId });
|
||||
if (params.search) {
|
||||
qb.andWhere('(ct.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
|
||||
}
|
||||
applyDirectionScope(qb, 'ct.trade_direction', directions);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/**
|
||||
* ONE ROW PER COMPANY. A company has many `company_profiles`, so every
|
||||
* profile-derived field aggregates in a subquery rather than joining — a join
|
||||
* would multiply rows and make the file disagree with the count endpoint.
|
||||
* If per-profile rows are ever wanted, that is a separate `company-profiles`
|
||||
* dataset, not a flag on this one.
|
||||
*/
|
||||
export const customersDataset: ExportDataset = {
|
||||
key: 'customers',
|
||||
title: 'Customers',
|
||||
description: 'Companies with registration, contact, address and activity detail',
|
||||
group: 'Commercial',
|
||||
permission: FREIGHT_PERMS.customers.view,
|
||||
base: { entity: Company, alias: 'c' },
|
||||
joins: [],
|
||||
|
||||
groups: [
|
||||
{ id: 'identity', label: 'Identity' },
|
||||
{ id: 'registration', label: 'Registration' },
|
||||
{ id: 'contact', label: 'Contact' },
|
||||
{ id: 'address', label: 'Address' },
|
||||
{ id: 'profiles', label: 'Profiles' },
|
||||
{ id: 'activity', label: 'Activity' },
|
||||
],
|
||||
|
||||
fields: [
|
||||
{ key: 'name', label: 'Company', type: 'string', group: 'identity', default: true, select: 'c.name', sortExpr: 'c.name' },
|
||||
{ key: 'type', label: 'Type', type: 'string', group: 'identity', default: true, select: 'c.type' },
|
||||
{ key: 'kind', label: 'Kind', type: 'string', group: 'identity', default: true, select: 'c.kind' },
|
||||
{ key: 'status', label: 'Status', type: 'string', group: 'identity', default: true, select: 'c.status', sortExpr: 'c.status' },
|
||||
{ key: 'statusDescription', label: 'Status note', type: 'string', group: 'identity', select: 'c.status_description' },
|
||||
{ key: 'nationality', label: 'Nationality', type: 'string', group: 'identity', select: 'c.nationality' },
|
||||
|
||||
// date_registered / renewal_date / renewed_* are varchar in the schema,
|
||||
// not dates — exported verbatim rather than pushed through to_char.
|
||||
{ key: 'tin', label: 'TIN', type: 'string', group: 'registration', default: true, select: 'c.tin' },
|
||||
{ key: 'vatNumber', label: 'VAT number', type: 'string', group: 'registration', select: 'c.vat_number' },
|
||||
{ key: 'fanNumber', label: 'FAN number', type: 'string', group: 'registration', select: 'c.fan_number' },
|
||||
{ key: 'licenceNumber', label: 'Licence number', type: 'string', group: 'registration', select: 'c.licence_number' },
|
||||
{ key: 'dateRegistered', label: 'Date registered', type: 'string', group: 'registration', select: 'c.date_registered' },
|
||||
{ key: 'renewalDate', label: 'Renewal date', type: 'string', group: 'registration', select: 'c.renewal_date' },
|
||||
{ key: 'renewedFrom', label: 'Renewed from', type: 'string', group: 'registration', select: 'c.renewed_from' },
|
||||
{ key: 'renewedTo', label: 'Renewed to', type: 'string', group: 'registration', select: 'c.renewed_to' },
|
||||
{ key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'registration', select: `to_char(c.approved_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
|
||||
{ key: 'phone', label: 'Phone', type: 'string', group: 'contact', default: true, select: 'c.phone' },
|
||||
{ key: 'email', label: 'Email', type: 'string', group: 'contact', default: true, select: 'c.email' },
|
||||
{ key: 'etradePhone', label: 'eTrade phone', type: 'string', group: 'contact', select: 'c.etrade_phone' },
|
||||
{ key: 'website', label: 'Website', type: 'string', group: 'contact', select: 'c.website' },
|
||||
{ key: 'contactPersonName', label: 'Contact person', type: 'string', group: 'contact', select: 'c.contact_person_name' },
|
||||
{ key: 'contactPersonPhone', label: 'Contact phone', type: 'string', group: 'contact', select: 'c.contact_person_phone' },
|
||||
|
||||
{ key: 'country', label: 'Country', type: 'string', group: 'address', select: 'c.country' },
|
||||
{ key: 'region', label: 'Region', type: 'string', group: 'address', select: 'c.region' },
|
||||
{ key: 'zone', label: 'Zone', type: 'string', group: 'address', select: 'c.zone' },
|
||||
{ key: 'woreda', label: 'Woreda', type: 'string', group: 'address', select: 'c.woreda' },
|
||||
{ key: 'kebele', label: 'Kebele', type: 'string', group: 'address', select: 'c.kebele' },
|
||||
{ key: 'houseNo', label: 'House no.', type: 'string', group: 'address', select: 'c.house_no' },
|
||||
{ key: 'address', label: 'Address', type: 'string', group: 'address', select: 'c.address' },
|
||||
|
||||
{
|
||||
key: 'profileCount', label: 'Profile count', type: 'number', group: 'profiles', default: true,
|
||||
select: `(SELECT COUNT(*)::int FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'profileTypes', label: 'Profile types', type: 'string', group: 'profiles',
|
||||
select: `(SELECT string_agg(DISTINCT cp.type, ' | ') FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'profileReferences', label: 'Profile references', type: 'string', group: 'profiles',
|
||||
select: `(SELECT string_agg(cp.reference, ' | ' ORDER BY cp.reference) FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'profileStatuses', label: 'Profile statuses', type: 'string', group: 'profiles',
|
||||
select: `(SELECT string_agg(DISTINCT cp.status, ' | ') FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`,
|
||||
},
|
||||
|
||||
{ key: 'createdAt', label: 'Registered on', type: 'date', group: 'activity', default: true, select: `to_char(c.created_at, 'YYYY-MM-DD')`, sortExpr: 'c.created_at' },
|
||||
{
|
||||
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'activity',
|
||||
select: `(SELECT COUNT(*)::int FROM freight.bookings b
|
||||
WHERE b.company_id = c.id AND b.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'contractCount', label: 'Contracts', type: 'number', group: 'activity',
|
||||
select: `(SELECT COUNT(*)::int FROM freight.contracts ct
|
||||
WHERE ct.company_id = c.id AND ct.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'invoicedTotal', label: 'Invoiced total', type: 'money', group: 'activity',
|
||||
select: `(SELECT ROUND(COALESCE(SUM(i.total_amount), 0), 2)::float8 FROM freight.invoices i
|
||||
WHERE i.company_id = c.id AND i.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'outstandingBalance', label: 'Outstanding balance', type: 'money', group: 'activity',
|
||||
select: `(SELECT ROUND(COALESCE(SUM(i.balance_amount), 0), 2)::float8 FROM freight.invoices i
|
||||
WHERE i.company_id = c.id AND i.deleted_at IS NULL)`,
|
||||
},
|
||||
],
|
||||
|
||||
filters: [
|
||||
{ key: 'created', label: 'Registered', type: 'daterange' },
|
||||
{ key: 'type', label: 'Type', type: 'text' },
|
||||
{ key: 'kind', label: 'Kind', type: 'select', options: [
|
||||
{ value: 'commercial', label: 'Commercial' },
|
||||
{ value: 'government', label: 'Government' },
|
||||
] },
|
||||
{ key: 'status', label: 'Status', type: 'text' },
|
||||
{ key: 'search', label: 'Search name, TIN or email', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'name', dir: 'ASC' },
|
||||
|
||||
scope(ctx, qb) {
|
||||
const { params } = ctx;
|
||||
qb.andWhere('c.deleted_at IS NULL');
|
||||
if (params.createdFrom) qb.andWhere('c.created_at >= :createdFrom', { createdFrom: params.createdFrom });
|
||||
if (params.createdTo) qb.andWhere('c.created_at < :createdTo', { createdTo: params.createdTo });
|
||||
if (params.type) qb.andWhere('c.type = :type', { type: params.type });
|
||||
if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind });
|
||||
if (params.status) qb.andWhere('c.status = :status', { status: params.status });
|
||||
if (params.search) {
|
||||
qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', {
|
||||
search: `%${params.search as string}%`,
|
||||
});
|
||||
}
|
||||
// Companies carry no trade direction — nothing to scope. Intentional.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/**
|
||||
* Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a
|
||||
* signature blob) and `eims_last_error` (a raw error dump). The
|
||||
* human-meaningful status/IRN/document-number fields are kept.
|
||||
*/
|
||||
export const invoicesDataset: ExportDataset = {
|
||||
key: 'invoices',
|
||||
title: 'Invoices',
|
||||
description: 'Invoices with customer, amounts, payment status and EIMS state',
|
||||
group: 'Finance',
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
base: { entity: Invoice, alias: 'i' },
|
||||
|
||||
joins: [
|
||||
{ alias: 'c', entity: Company, on: 'c.id = i.company_id' },
|
||||
{ alias: 'cp', entity: CompanyProfile, on: 'cp.id = i.company_profile_id' },
|
||||
// No relation object on the entity for this FK — the service hydrates it
|
||||
// with a second query. In a dataset it is just a join by column.
|
||||
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' },
|
||||
{ alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' },
|
||||
],
|
||||
alwaysJoin: ['c'],
|
||||
|
||||
groups: [
|
||||
{ id: 'invoice', label: 'Invoice' },
|
||||
{ id: 'customer', label: 'Customer' },
|
||||
{ id: 'amounts', label: 'Amounts' },
|
||||
{ id: 'payment', label: 'Payment' },
|
||||
{ id: 'lines', label: 'Lines' },
|
||||
{ id: 'eims', label: 'EIMS' },
|
||||
],
|
||||
|
||||
fields: [
|
||||
{ key: 'invoiceNumber', label: 'Invoice no.', type: 'string', group: 'invoice', default: true, select: 'i.invoice_number', sortExpr: 'i.invoice_number' },
|
||||
{ key: 'status', label: 'Status', type: 'string', group: 'invoice', default: true, select: 'i.status', sortExpr: 'i.status' },
|
||||
{ key: 'type', label: 'Type', type: 'string', group: 'invoice', select: 'i.type' },
|
||||
{ key: 'source', label: 'Source', type: 'string', group: 'invoice', default: true, select: 'i.source' },
|
||||
{ key: 'sourceId', label: 'Source reference', type: 'string', group: 'invoice', select: 'i.source_id' },
|
||||
{ key: 'issuedAt', label: 'Issued', type: 'date', group: 'invoice', default: true, select: `to_char(i.issued_at, 'YYYY-MM-DD')`, sortExpr: 'i.issued_at' },
|
||||
{ key: 'dueAt', label: 'Due', type: 'date', group: 'invoice', default: true, select: `to_char(i.due_at, 'YYYY-MM-DD')`, sortExpr: 'i.due_at' },
|
||||
{ key: 'createdAt', label: 'Created', type: 'date', group: 'invoice', select: `to_char(i.created_at, 'YYYY-MM-DD')`, sortExpr: 'i.created_at' },
|
||||
{ key: 'relatedInvoice', label: 'Related invoice', type: 'string', group: 'invoice', requires: ['rel'], select: 'rel.invoice_number' },
|
||||
|
||||
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' },
|
||||
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
|
||||
{ key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' },
|
||||
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
|
||||
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
|
||||
{ key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' },
|
||||
{ key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' },
|
||||
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' },
|
||||
|
||||
{ key: 'subtotalAmount', label: 'Subtotal', type: 'money', group: 'amounts', select: 'i.subtotal_amount::float8' },
|
||||
{ key: 'taxAmount', label: 'Tax', type: 'money', group: 'amounts', select: 'i.tax_amount::float8' },
|
||||
{ key: 'totalAmount', label: 'Total', type: 'money', group: 'amounts', default: true, select: 'i.total_amount::float8', sortExpr: 'i.total_amount' },
|
||||
{ key: 'paidAmount', label: 'Paid', type: 'money', group: 'amounts', default: true, select: 'i.paid_amount::float8' },
|
||||
{ key: 'balanceAmount', label: 'Balance', type: 'money', group: 'amounts', default: true, select: 'i.balance_amount::float8', sortExpr: 'i.balance_amount' },
|
||||
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' },
|
||||
|
||||
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{
|
||||
key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment',
|
||||
select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now()
|
||||
THEN EXTRACT(DAY FROM now() - i.due_at)::int ELSE 0 END`,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'lineCount', label: 'Line count', type: 'number', group: 'lines',
|
||||
select: `(SELECT COUNT(*)::int FROM freight.invoice_lines il
|
||||
WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'lineCharges', label: 'Charges', type: 'string', group: 'lines',
|
||||
select: `(SELECT string_agg(il.charge_type || ': ' || ROUND(il.amount, 2), ' | ' ORDER BY il.charge_type)
|
||||
FROM freight.invoice_lines il
|
||||
WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`,
|
||||
},
|
||||
|
||||
{ key: 'eimsStatus', label: 'EIMS status', type: 'string', group: 'eims', select: 'i.eims_status' },
|
||||
{ key: 'eimsIrn', label: 'EIMS IRN', type: 'string', group: 'eims', select: 'i.eims_irn' },
|
||||
{ key: 'eimsDocumentNumber', label: 'EIMS document no.', type: 'string', group: 'eims', select: 'i.eims_document_number' },
|
||||
{ key: 'eimsDocumentType', label: 'EIMS document type', type: 'string', group: 'eims', select: 'i.eims_document_type' },
|
||||
{ key: 'eimsSubmittedAt', label: 'EIMS submitted', type: 'datetime', group: 'eims', select: `to_char(i.eims_submitted_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
// eims_ack_date is varchar in the schema, not a timestamp.
|
||||
{ key: 'eimsAckDate', label: 'EIMS acknowledged', type: 'string', group: 'eims', select: 'i.eims_ack_date' },
|
||||
{ key: 'eimsCancelledAt', label: 'EIMS cancelled', type: 'datetime', group: 'eims', select: `to_char(i.eims_cancelled_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'eimsCancellationReasonCode', label: 'EIMS cancellation reason', type: 'string', group: 'eims', select: 'i.eims_cancellation_reason_code' },
|
||||
],
|
||||
|
||||
filters: [
|
||||
{ key: 'issued', label: 'Issued', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||
// The invoices list page sends a single `status`; accept both so its
|
||||
// on-screen filter actually carries into the export.
|
||||
{ key: 'status', label: 'Status (single)', type: 'text' },
|
||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
] },
|
||||
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'issuedAt', dir: 'DESC' },
|
||||
|
||||
scope(ctx, qb) {
|
||||
const { params, directions } = ctx;
|
||||
qb.andWhere('i.deleted_at IS NULL');
|
||||
if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom });
|
||||
if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses });
|
||||
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
|
||||
if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency });
|
||||
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
|
||||
if (params.search) {
|
||||
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
|
||||
}
|
||||
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
||||
applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
const STATUS_OPTIONS = ['AVAILABLE', 'IN_USE', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((v) => ({
|
||||
value: v,
|
||||
label: v.replace(/_/g, ' '),
|
||||
}));
|
||||
|
||||
export const locomotivesDataset: ExportDataset = {
|
||||
key: 'locomotives',
|
||||
title: 'Locomotives',
|
||||
description: 'Locomotive fleet with capacity, traction specs and current location',
|
||||
group: 'Fleet',
|
||||
permission: FREIGHT_PERMS.locomotives.view,
|
||||
base: { entity: Locomotive, alias: 'l' },
|
||||
|
||||
joins: [{ alias: 'y', entity: Yard, on: 'y.id = l.current_yard_id' }],
|
||||
|
||||
groups: [
|
||||
{ id: 'identity', label: 'Locomotive' },
|
||||
{ id: 'capacity', label: 'Capacity & traction' },
|
||||
{ id: 'location', label: 'Status & location' },
|
||||
{ id: 'assignment', label: 'Assignment' },
|
||||
],
|
||||
|
||||
fields: [
|
||||
{ key: 'code', label: 'Code', type: 'string', group: 'identity', default: true, select: 'l.code', sortExpr: 'l.code' },
|
||||
{ key: 'name', label: 'Name', type: 'string', group: 'identity', default: true, select: 'l.name' },
|
||||
{ key: 'locomotiveType', label: 'Type', type: 'string', group: 'identity', default: true, select: 'l.locomotive_type' },
|
||||
{ key: 'createdAt', label: 'Added', type: 'date', group: 'identity', select: `to_char(l.created_at, 'YYYY-MM-DD')`, sortExpr: 'l.created_at' },
|
||||
|
||||
{ key: 'maxPullWeightTons', label: 'Max pull weight (t)', type: 'tons', group: 'capacity', default: true, select: 'l.max_pull_weight_tons::float8', sortExpr: 'l.max_pull_weight_tons' },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max train length (m)', type: 'number', group: 'capacity', default: true, select: 'l.max_train_length_meters::float8' },
|
||||
{ key: 'overageToleranceTons', label: 'Overage tolerance (t)', type: 'tons', group: 'capacity', select: 'l.overage_tolerance_tons::float8' },
|
||||
{ key: 'overageToleranceMeters', label: 'Overage tolerance (m)', type: 'number', group: 'capacity', select: 'l.overage_tolerance_meters::float8' },
|
||||
{ key: 'powerKw', label: 'Power (kW)', type: 'number', group: 'capacity', select: 'l.power_kw::float8' },
|
||||
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number', group: 'capacity', select: 'l.traction_force_kn::float8' },
|
||||
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number', group: 'capacity', select: 'l.max_speed_kmh::float8' },
|
||||
|
||||
{ key: 'status', label: 'Status', type: 'string', group: 'location', default: true, select: 'l.status', sortExpr: 'l.status' },
|
||||
{ key: 'availableFrom', label: 'Available from', type: 'date', group: 'location', select: `to_char(l.available_from, 'YYYY-MM-DD')` },
|
||||
{ key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' },
|
||||
{ key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' },
|
||||
{ key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' },
|
||||
|
||||
{
|
||||
// One-to-many -> aggregate in a subquery, never a join.
|
||||
key: 'assignedTrains', label: 'Assigned trains', type: 'string', group: 'assignment',
|
||||
select: `(SELECT string_agg(DISTINCT tr.code, ' | ')
|
||||
FROM freight.train_set_locomotives tsl
|
||||
JOIN freight.train_sets ts ON ts.id = tsl.train_set_id AND ts.deleted_at IS NULL
|
||||
JOIN freight.trains tr ON tr.id = ts.train_id AND tr.deleted_at IS NULL
|
||||
WHERE tsl.locomotive_id = l.id AND tsl.deleted_at IS NULL)`,
|
||||
},
|
||||
],
|
||||
|
||||
filters: [
|
||||
{ key: 'status', label: 'Status', type: 'select', options: STATUS_OPTIONS },
|
||||
{ key: 'locomotiveType', label: 'Type', type: 'text' },
|
||||
{ key: 'currentYardId', label: 'Current yard', type: 'text' },
|
||||
{ key: 'search', label: 'Search code or name', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'code', dir: 'ASC' },
|
||||
|
||||
scope(ctx, qb) {
|
||||
const { params } = ctx;
|
||||
qb.andWhere('l.deleted_at IS NULL');
|
||||
if (params.status) qb.andWhere('l.status = :status', { status: params.status });
|
||||
if (params.locomotiveType) qb.andWhere('l.locomotive_type = :locomotiveType', { locomotiveType: params.locomotiveType });
|
||||
if (params.currentYardId) qb.andWhere('l.current_yard_id = :currentYardId', { currentYardId: params.currentYardId });
|
||||
if (params.search) {
|
||||
qb.andWhere('(l.code ILIKE :search OR l.name ILIKE :search)', { search: `%${params.search as string}%` });
|
||||
}
|
||||
// Locomotives carry no trade direction — nothing to scope. Intentional.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { PaymentEntity } from '../../payment/entities/payment.entity';
|
||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/**
|
||||
* `freight.payments` breaks two conventions this codebase otherwise holds to,
|
||||
* both verified against the live schema:
|
||||
*
|
||||
* 1. It does NOT extend @edr/api-common's BaseEntity — there is no
|
||||
* `updated_at` and no `deleted_at`. A soft-delete guard here is a 42703,
|
||||
* which is why `scope()` below applies the ACL only.
|
||||
* 2. The failure columns are `failer_code` / `failer_message`, not `failure_*`.
|
||||
*
|
||||
* Raw gateway payloads (`raw_initiation`, `client_action`) are deliberately
|
||||
* not exposed as fields.
|
||||
*/
|
||||
export const paymentsDataset: ExportDataset = {
|
||||
key: 'payments',
|
||||
title: 'Payments',
|
||||
description: 'Payment transactions with method, status, and the booking and customer they belong to',
|
||||
group: 'Finance',
|
||||
permission: FREIGHT_PERMS.payments.view,
|
||||
base: { entity: PaymentEntity, alias: 'p' },
|
||||
|
||||
joins: [
|
||||
// ref_id is a varchar pointer at the booking, so the cast is required.
|
||||
{ alias: 'bk', entity: Booking, on: 'bk.id::text = p.ref_id' },
|
||||
{ alias: 'c', entity: Company, on: 'c.id = bk.company_id', requires: ['bk'] },
|
||||
],
|
||||
|
||||
groups: [
|
||||
{ id: 'payment', label: 'Payment' },
|
||||
{ id: 'amounts', label: 'Amounts' },
|
||||
{ id: 'gateway', label: 'Gateway' },
|
||||
{ id: 'booking', label: 'Booking' },
|
||||
{ id: 'customer', label: 'Customer' },
|
||||
],
|
||||
|
||||
fields: [
|
||||
{ key: 'merchantOrderId', label: 'Order ID', type: 'string', group: 'payment', default: true, select: 'p.merchant_order_id', sortExpr: 'p.merchant_order_id' },
|
||||
{ key: 'status', label: 'Status', type: 'string', group: 'payment', default: true, select: 'p.status', sortExpr: 'p.status' },
|
||||
{ key: 'method', label: 'Method', type: 'string', group: 'payment', default: true, select: 'p.method', sortExpr: 'p.method' },
|
||||
{ key: 'type', label: 'Type', type: 'string', group: 'payment', select: 'p.type' },
|
||||
{ key: 'referenceType', label: 'Reference type', type: 'string', group: 'payment', select: 'p.reference_type' },
|
||||
{ key: 'createdAt', label: 'Created', type: 'datetime', group: 'payment', default: true, select: `to_char(p.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.created_at' },
|
||||
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', default: true, select: `to_char(p.paid_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.paid_at' },
|
||||
{ key: 'refundedAt', label: 'Refunded at', type: 'datetime', group: 'payment', select: `to_char(p.refunded_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'expiresAt', label: 'Expires at', type: 'datetime', group: 'payment', select: `to_char(p.expires_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
|
||||
{ key: 'amount', label: 'Amount', type: 'money', group: 'amounts', default: true, select: 'p.amount::float8', sortExpr: 'p.amount' },
|
||||
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'p.currency' },
|
||||
{
|
||||
// payment_refunds stores MINOR units (amount_minor), unlike payments.amount
|
||||
// which is major. Divide, or a 50.00 refund exports as 5000.
|
||||
key: 'refundedTotal', label: 'Refunded total', type: 'money', group: 'amounts',
|
||||
select: `(SELECT ROUND(COALESCE(SUM(pr.amount_minor), 0) / 100.0, 2)::float8
|
||||
FROM freight.payment_refunds pr WHERE pr.payment_id = p.id)`,
|
||||
},
|
||||
|
||||
{ key: 'transactionId', label: 'Transaction ID', type: 'string', group: 'gateway', select: 'p.transaction_id' },
|
||||
{ key: 'failerCode', label: 'Failure code', type: 'string', group: 'gateway', select: 'p.failer_code' },
|
||||
{ key: 'failureMessage', label: 'Failure message', type: 'string', group: 'gateway', select: 'p.failer_message' },
|
||||
{ key: 'reason', label: 'Reason', type: 'string', group: 'gateway', select: 'p.reason' },
|
||||
|
||||
{ key: 'bookingReference', label: 'Booking', type: 'string', group: 'booking', default: true, requires: ['bk'], select: 'bk.reference' },
|
||||
{ key: 'bookingStatus', label: 'Booking status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.status' },
|
||||
{ key: 'bookingPaymentStatus', label: 'Booking payment status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.payment_status' },
|
||||
{ key: 'bookingTradeDirection', label: 'Direction', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.trade_direction' },
|
||||
{ key: 'bookingFreightType', label: 'Freight type', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.freight_type' },
|
||||
{ key: 'bookingPnrCode', label: 'PNR code', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.pnr_code' },
|
||||
|
||||
{ key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name' },
|
||||
{ key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' },
|
||||
{ key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' },
|
||||
{ key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' },
|
||||
],
|
||||
|
||||
filters: [
|
||||
{ key: 'created', label: 'Created', type: 'daterange' },
|
||||
{ key: 'status', label: 'Status', type: 'select', options: [
|
||||
'action-required', 'processing', 'success', 'failed', 'canceled', 'refunded',
|
||||
].map((v) => ({ value: v, label: v })) },
|
||||
{ key: 'method', label: 'Method', type: 'select', options: [
|
||||
'telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill',
|
||||
].map((v) => ({ value: v, label: v })) },
|
||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
] },
|
||||
{ key: 'search', label: 'Search order or transaction ID', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'createdAt', dir: 'DESC' },
|
||||
|
||||
scope(ctx, qb) {
|
||||
const { params, directions } = ctx;
|
||||
// No `p.deleted_at IS NULL` — this table has no soft-delete column.
|
||||
if (params.createdFrom) qb.andWhere('p.created_at >= :createdFrom', { createdFrom: params.createdFrom });
|
||||
if (params.createdTo) qb.andWhere('p.created_at < :createdTo', { createdTo: params.createdTo });
|
||||
if (params.status) qb.andWhere('p.status = :status', { status: params.status });
|
||||
if (params.method) qb.andWhere('p.method = :method', { method: params.method });
|
||||
if (params.currency) qb.andWhere('p.currency = :currency', { currency: params.currency });
|
||||
if (params.search) {
|
||||
qb.andWhere('(p.merchant_order_id ILIKE :search OR p.transaction_id ILIKE :search)', {
|
||||
search: `%${params.search as string}%`,
|
||||
});
|
||||
}
|
||||
applyBookingRefDirectionScope(qb, 'p.ref_id', directions);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Route } from '../../routes/entities/route.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/**
|
||||
* `freightType` is NOT a column on train_schedules — it is derived from the
|
||||
* bookings aboard (the list service does this with an EXISTS subquery). Exposed
|
||||
* here as an aggregate over those bookings, never as `sch.freight_type`.
|
||||
*/
|
||||
export const trainSchedulesDataset: ExportDataset = {
|
||||
key: 'train-schedules',
|
||||
title: 'Train schedules',
|
||||
description: 'Scheduled trains with route, timings, booking window and load',
|
||||
group: 'Operations',
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
base: { entity: TrainSchedule, alias: 'sch' },
|
||||
|
||||
joins: [
|
||||
{ alias: 'rt', entity: Route, on: 'rt.id = sch.route_id' },
|
||||
{ alias: 'os', entity: Yard, on: 'os.id = sch.origin_station_id' },
|
||||
{ alias: 'ds', entity: Yard, on: 'ds.id = sch.destination_station_id' },
|
||||
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = sch.shipping_line_company_id' },
|
||||
],
|
||||
|
||||
groups: [
|
||||
{ id: 'schedule', label: 'Schedule' },
|
||||
{ id: 'route', label: 'Route' },
|
||||
{ id: 'timings', label: 'Timings' },
|
||||
{ id: 'window', label: 'Booking window' },
|
||||
{ id: 'load', label: 'Load' },
|
||||
],
|
||||
|
||||
fields: [
|
||||
{ key: 'reference', label: 'Reference', type: 'string', group: 'schedule', default: true, select: 'sch.reference', sortExpr: 'sch.reference' },
|
||||
{ key: 'status', label: 'Status', type: 'string', group: 'schedule', default: true, select: 'sch.status', sortExpr: 'sch.status' },
|
||||
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'schedule', default: true, select: 'sch.train_number' },
|
||||
{ key: 'voyageNumber', label: 'Voyage number', type: 'string', group: 'schedule', select: 'sch.voyage_number' },
|
||||
{ key: 'direction', label: 'Direction', type: 'string', group: 'schedule', default: true, select: 'sch.direction' },
|
||||
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'schedule', requires: ['slc'], select: 'slc.name' },
|
||||
{ key: 'bookingCycleNo', label: 'Booking cycle', type: 'number', group: 'schedule', select: 'sch.booking_cycle_no' },
|
||||
{ key: 'reverseWagonOrder', label: 'Reverse wagon order', type: 'boolean', group: 'schedule', select: 'sch.reverse_wagon_order' },
|
||||
{ key: 'createdAt', label: 'Created', type: 'date', group: 'schedule', select: `to_char(sch.created_at, 'YYYY-MM-DD')`, sortExpr: 'sch.created_at' },
|
||||
|
||||
{ key: 'originStation', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['os'], select: 'os.label' },
|
||||
{ key: 'originStationCode', label: 'Origin code', type: 'string', group: 'route', requires: ['os'], select: 'os.code' },
|
||||
{ key: 'destinationStation', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['ds'], select: 'ds.label' },
|
||||
{ key: 'destinationStationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['ds'], select: 'ds.code' },
|
||||
{ key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' },
|
||||
|
||||
{ key: 'scheduledDeparture', label: 'Scheduled departure', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'sch.scheduled_departure_date' },
|
||||
{ key: 'scheduledArrival', label: 'Scheduled arrival', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'actualDeparture', label: 'Actual departure', type: 'datetime', group: 'timings', select: `to_char(sch.actual_departure_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'actualArrival', label: 'Actual arrival', type: 'datetime', group: 'timings', select: `to_char(sch.actual_arrival_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{
|
||||
key: 'departureDelayHours', label: 'Departure delay (h)', type: 'number', group: 'timings',
|
||||
select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_departure_at - sch.scheduled_departure_date)) / 3600.0, 2)::float8`,
|
||||
},
|
||||
{
|
||||
key: 'transitHours', label: 'Transit time (h)', type: 'number', group: 'timings',
|
||||
select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_arrival_at - sch.actual_departure_at)) / 3600.0, 2)::float8`,
|
||||
},
|
||||
|
||||
{ key: 'bookingWindowStatus', label: 'Window status', type: 'string', group: 'window', select: 'sch.booking_window_status' },
|
||||
{ key: 'windowPhase', label: 'Window phase', type: 'string', group: 'window', select: 'sch.window_phase' },
|
||||
{ key: 'windowOpensAt', label: 'Window opens', type: 'datetime', group: 'window', select: `to_char(sch.window_opens_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'windowClosesAt', label: 'Window closes', type: 'datetime', group: 'window', select: `to_char(sch.window_closes_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'docReviewEndsAt', label: 'Doc review ends', type: 'datetime', group: 'window', select: `to_char(sch.doc_review_ends_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'paymentPhaseEndsAt', label: 'Payment phase ends', type: 'datetime', group: 'window', select: `to_char(sch.payment_phase_ends_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'windowRuleCustom', label: 'Custom window rules', type: 'boolean', group: 'window', select: 'sch.window_rule_custom' },
|
||||
|
||||
{ key: 'maxWagons', label: 'Max wagons', type: 'number', group: 'load', select: 'sch.max_wagons' },
|
||||
{
|
||||
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'load', default: true,
|
||||
select: `(SELECT COUNT(*)::int FROM freight.bookings b
|
||||
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
// Derived, not a column — see the file header.
|
||||
key: 'freightTypes', label: 'Freight types', type: 'string', group: 'load', default: true,
|
||||
select: `(SELECT string_agg(DISTINCT b.freight_type, ' | ') FROM freight.bookings b
|
||||
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true,
|
||||
select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8
|
||||
FROM freight.bookings b
|
||||
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'assignedWagonCount', label: 'Wagons assigned', type: 'number', group: 'load',
|
||||
select: `(SELECT COUNT(*)::int FROM freight.wagons w
|
||||
WHERE w.current_train_schedule_id = sch.id AND w.deleted_at IS NULL)`,
|
||||
},
|
||||
],
|
||||
|
||||
filters: [
|
||||
{ key: 'departure', label: 'Departure', type: 'daterange' },
|
||||
{ key: 'status', label: 'Status', type: 'text' },
|
||||
{ key: 'direction', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) },
|
||||
// freightType is derived from the bookings aboard, so it filters via
|
||||
// EXISTS — the same shape the list service's scheduleFreightTypeFilter uses.
|
||||
{ key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) },
|
||||
{ key: 'originStationId', label: 'Origin', type: 'text' },
|
||||
{ key: 'destinationStationId', label: 'Destination', type: 'text' },
|
||||
{ key: 'search', label: 'Search reference or train number', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'scheduledDeparture', dir: 'DESC' },
|
||||
|
||||
scope(ctx, qb) {
|
||||
const { params, directions } = ctx;
|
||||
qb.andWhere('sch.deleted_at IS NULL');
|
||||
if (params.departureFrom) qb.andWhere('sch.scheduled_departure_date >= :departureFrom', { departureFrom: params.departureFrom });
|
||||
if (params.departureTo) qb.andWhere('sch.scheduled_departure_date < :departureTo', { departureTo: params.departureTo });
|
||||
if (params.status) qb.andWhere('sch.status = :status', { status: params.status });
|
||||
if (params.direction) qb.andWhere('sch.direction = :direction', { direction: params.direction });
|
||||
if (params.freightType) {
|
||||
qb.andWhere(
|
||||
`EXISTS (SELECT 1 FROM freight.bookings fb
|
||||
WHERE fb.train_schedule_id = sch.id AND fb.deleted_at IS NULL
|
||||
AND fb.freight_type = :freightType)`,
|
||||
{ freightType: params.freightType },
|
||||
);
|
||||
}
|
||||
if (params.originStationId) qb.andWhere('sch.origin_station_id = :originStationId', { originStationId: params.originStationId });
|
||||
if (params.destinationStationId) qb.andWhere('sch.destination_station_id = :destinationStationId', { destinationStationId: params.destinationStationId });
|
||||
if (params.search) {
|
||||
qb.andWhere('(sch.reference ILIKE :search OR sch.train_number ILIKE :search)', { search: `%${params.search as string}%` });
|
||||
}
|
||||
// Schedules carry their own `direction` column, so scope on that directly
|
||||
// rather than through the bookings aboard.
|
||||
applyDirectionScope(qb, 'sch.direction', directions);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Route } from '../../routes/entities/route.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/**
|
||||
* The list endpoint (`trains.service.ts findAll`) loads NO relations, so the
|
||||
* UI shows raw FK uuids where names belong. This dataset resolves them, which
|
||||
* makes it the clearest "the export shows more than the screen" case in the set.
|
||||
*/
|
||||
export const trainsDataset: ExportDataset = {
|
||||
key: 'trains',
|
||||
title: 'Trains',
|
||||
description: 'Trains with route, stations, capacity and current composition',
|
||||
group: 'Fleet',
|
||||
permission: FREIGHT_PERMS.trains.view,
|
||||
base: { entity: Train, alias: 't' },
|
||||
|
||||
joins: [
|
||||
{ alias: 'y', entity: Yard, on: 'y.id = t.current_yard_id' },
|
||||
{ alias: 'rt', entity: Route, on: 'rt.id = t.route_id' },
|
||||
{ alias: 'os', entity: Yard, on: 'os.id = t.origin_station_id' },
|
||||
{ alias: 'ds', entity: Yard, on: 'ds.id = t.destination_station_id' },
|
||||
],
|
||||
|
||||
groups: [
|
||||
{ id: 'train', label: 'Train' },
|
||||
{ id: 'route', label: 'Route & stations' },
|
||||
{ id: 'capacity', label: 'Capacity' },
|
||||
{ id: 'composition', label: 'Composition' },
|
||||
],
|
||||
|
||||
fields: [
|
||||
{ key: 'code', label: 'Code', type: 'string', group: 'train', default: true, select: 't.code', sortExpr: 't.code' },
|
||||
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'train', default: true, select: 't.train_number' },
|
||||
{ key: 'trainName', label: 'Train name', type: 'string', group: 'train', default: true, select: 't.train_name' },
|
||||
{ key: 'status', label: 'Status', type: 'string', group: 'train', default: true, select: 't.status', sortExpr: 't.status' },
|
||||
{ key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'train', select: 't.import_train_number' },
|
||||
{ key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'train', select: 't.export_train_number' },
|
||||
{ key: 'locomotiveNumber', label: 'Locomotive number', type: 'string', group: 'train', select: 't.locomotive_number' },
|
||||
{ key: 'notes', label: 'Notes', type: 'string', group: 'train', select: 't.notes' },
|
||||
{ key: 'remarks', label: 'Remarks', type: 'string', group: 'train', select: 't.remarks' },
|
||||
{ key: 'createdAt', label: 'Added', type: 'date', group: 'train', select: `to_char(t.created_at, 'YYYY-MM-DD')`, sortExpr: 't.created_at' },
|
||||
|
||||
{ key: 'currentYard', label: 'Current yard', type: 'string', group: 'route', default: true, requires: ['y'], select: 'y.label' },
|
||||
{ key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'route', requires: ['y'], select: 'y.code' },
|
||||
{ key: 'routeDirection', label: 'Route direction', type: 'string', group: 'route', requires: ['rt'], select: 'rt.direction' },
|
||||
{ key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' },
|
||||
{ key: 'originStation', label: 'Origin station', type: 'string', group: 'route', requires: ['os'], select: 'os.label' },
|
||||
{ key: 'destinationStation', label: 'Destination station', type: 'string', group: 'route', requires: ['ds'], select: 'ds.label' },
|
||||
{ key: 'departureTime', label: 'Departure time', type: 'string', group: 'route', select: 't.departure_time::text' },
|
||||
{ key: 'arrivalTime', label: 'Arrival time', type: 'string', group: 'route', select: 't.arrival_time::text' },
|
||||
|
||||
{ key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'capacity', default: true, select: 't.capacity_tons::float8', sortExpr: 't.capacity_tons' },
|
||||
|
||||
{
|
||||
key: 'wagonCount', label: 'Wagons attached', type: 'number', group: 'composition', default: true,
|
||||
select: `(SELECT COUNT(*)::int FROM freight.wagons w
|
||||
WHERE w.train_id = t.id AND w.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'wagonNumbers', label: 'Wagon numbers', type: 'string', group: 'composition',
|
||||
select: `(SELECT string_agg(w.wagon_number, ' | ' ORDER BY w.sequence_number NULLS LAST, w.wagon_number)
|
||||
FROM freight.wagons w
|
||||
WHERE w.train_id = t.id AND w.deleted_at IS NULL)`,
|
||||
},
|
||||
],
|
||||
|
||||
filters: [
|
||||
{ key: 'status', label: 'Status', type: 'text' },
|
||||
{ key: 'currentYardId', label: 'Current yard', type: 'text' },
|
||||
{ key: 'search', label: 'Search code, number or name', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'code', dir: 'ASC' },
|
||||
|
||||
scope(ctx, qb) {
|
||||
const { params } = ctx;
|
||||
qb.andWhere('t.deleted_at IS NULL');
|
||||
if (params.status) qb.andWhere('t.status = :status', { status: params.status });
|
||||
if (params.currentYardId) qb.andWhere('t.current_yard_id = :currentYardId', { currentYardId: params.currentYardId });
|
||||
if (params.search) {
|
||||
qb.andWhere('(t.code ILIKE :search OR t.train_number ILIKE :search OR t.train_name ILIKE :search)', {
|
||||
search: `%${params.search as string}%`,
|
||||
});
|
||||
}
|
||||
// Trains carry no trade direction — nothing to scope. Intentional.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/**
|
||||
* Two traps this dataset works around:
|
||||
*
|
||||
* 1. Tare weight, payload and length live on WAGON_TYPE, not wagon — which is
|
||||
* why they carry `requires: ['wt']` and their sortExpr points at `wt`.
|
||||
* 2. `lastMaintenanceAt` / `lastAvailableAt` come from a grouped query over
|
||||
* wagon_status_logs in the service (`attachStatusDates`). Ported here as
|
||||
* correlated subqueries so they compose with everything else and cost
|
||||
* nothing when unticked. Note the log columns are `from_status`/`to_status`,
|
||||
* not `status`.
|
||||
*/
|
||||
export const wagonsDataset: ExportDataset = {
|
||||
key: 'wagons',
|
||||
title: 'Wagons',
|
||||
description: 'Wagon fleet with type specs, location, train assignment and status history',
|
||||
group: 'Fleet',
|
||||
permission: FREIGHT_PERMS.wagons.view,
|
||||
base: { entity: Wagon, alias: 'w' },
|
||||
|
||||
joins: [
|
||||
{ alias: 'wt', entity: WagonType, on: 'wt.id = w.wagon_type_id' },
|
||||
{ alias: 'y', entity: Yard, on: 'y.id = w.current_yard_id' },
|
||||
{ alias: 't', entity: Train, on: 't.id = w.train_id' },
|
||||
{ alias: 'sch', entity: TrainSchedule, on: 'sch.id = w.current_train_schedule_id' },
|
||||
],
|
||||
|
||||
groups: [
|
||||
{ id: 'wagon', label: 'Wagon' },
|
||||
{ id: 'type', label: 'Type & specs' },
|
||||
{ id: 'location', label: 'Location' },
|
||||
{ id: 'assignment', label: 'Assignment' },
|
||||
{ id: 'history', label: 'Status history' },
|
||||
],
|
||||
|
||||
fields: [
|
||||
{ key: 'wagonNumber', label: 'Wagon number', type: 'string', group: 'wagon', default: true, select: 'w.wagon_number', sortExpr: 'w.wagon_number' },
|
||||
{ key: 'status', label: 'Status', type: 'string', group: 'wagon', default: true, select: 'w.status', sortExpr: 'w.status' },
|
||||
{ key: 'sequenceNumber', label: 'Sequence no.', type: 'number', group: 'wagon', select: 'w.sequence_number' },
|
||||
{ key: 'notes', label: 'Notes', type: 'string', group: 'wagon', select: 'w.notes' },
|
||||
{ key: 'createdAt', label: 'Added', type: 'date', group: 'wagon', select: `to_char(w.created_at, 'YYYY-MM-DD')`, sortExpr: 'w.created_at' },
|
||||
|
||||
{ key: 'wagonType', label: 'Type', type: 'string', group: 'type', default: true, requires: ['wt'], select: 'wt.name', sortExpr: 'wt.name' },
|
||||
{ key: 'wagonTypeCode', label: 'Type code', type: 'string', group: 'type', requires: ['wt'], select: 'wt.code' },
|
||||
{ key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'type', default: true, requires: ['wt'], select: 'wt.capacity_tons::float8', sortExpr: 'wt.capacity_tons' },
|
||||
{ key: 'tareWeightTons', label: 'Tare weight (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.tare_weight_tons::float8' },
|
||||
{ key: 'lengthMeters', label: 'Length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.length_meters::float8' },
|
||||
{ key: 'equatedLengthM', label: 'Equated length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.equated_length_m::float8' },
|
||||
{ key: 'maxContainerGrossT', label: 'Max container gross (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.max_container_gross_t::float8' },
|
||||
{ key: 'supportsContainer', label: 'Supports container', type: 'boolean', group: 'type', requires: ['wt'], select: 'wt.supports_container' },
|
||||
{ key: 'supportedLoadTypes', label: 'Supported load types', type: 'string', group: 'type', requires: ['wt'], select: 'wt.supported_load_types::text' },
|
||||
|
||||
{ key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' },
|
||||
{ key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' },
|
||||
{ key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' },
|
||||
|
||||
{ key: 'trainCode', label: 'Train', type: 'string', group: 'assignment', default: true, requires: ['t'], select: 't.code' },
|
||||
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'assignment', requires: ['t'], select: 't.train_number' },
|
||||
{ key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'assignment', select: 'w.export_train_number' },
|
||||
{ key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'assignment', select: 'w.import_train_number' },
|
||||
{ key: 'scheduleReference', label: 'Current schedule', type: 'string', group: 'assignment', requires: ['sch'], select: 'sch.reference' },
|
||||
{ key: 'scheduleDeparture', label: 'Schedule departure', type: 'date', group: 'assignment', requires: ['sch'], select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD')` },
|
||||
|
||||
{
|
||||
key: 'lastMaintenanceAt', label: 'Last maintenance', type: 'datetime', group: 'history',
|
||||
select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI')
|
||||
FROM freight.wagon_status_logs l
|
||||
WHERE l.wagon_id = w.id AND l.to_status = 'MAINTENANCE' AND l.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'lastAvailableAt', label: 'Last available', type: 'datetime', group: 'history',
|
||||
select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI')
|
||||
FROM freight.wagon_status_logs l
|
||||
WHERE l.wagon_id = w.id AND l.to_status = 'AVAILABLE' AND l.deleted_at IS NULL)`,
|
||||
},
|
||||
{
|
||||
key: 'statusChangeCount', label: 'Status changes', type: 'number', group: 'history',
|
||||
select: `(SELECT COUNT(*)::int FROM freight.wagon_status_logs l
|
||||
WHERE l.wagon_id = w.id AND l.deleted_at IS NULL)`,
|
||||
},
|
||||
],
|
||||
|
||||
filters: [
|
||||
{ key: 'status', label: 'Status', type: 'text' },
|
||||
{ key: 'wagonTypeId', label: 'Wagon type', type: 'text' },
|
||||
{ key: 'currentYardId', label: 'Current yard', type: 'text' },
|
||||
{ key: 'trainId', label: 'Train', type: 'text' },
|
||||
{ key: 'search', label: 'Search wagon number', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'wagonNumber', dir: 'ASC' },
|
||||
|
||||
scope(ctx, qb) {
|
||||
const { params } = ctx;
|
||||
qb.andWhere('w.deleted_at IS NULL');
|
||||
if (params.status) qb.andWhere('w.status = :status', { status: params.status });
|
||||
if (params.wagonTypeId) qb.andWhere('w.wagon_type_id = :wagonTypeId', { wagonTypeId: params.wagonTypeId });
|
||||
if (params.currentYardId) qb.andWhere('w.current_yard_id = :currentYardId', { currentYardId: params.currentYardId });
|
||||
if (params.trainId) qb.andWhere('w.train_id = :trainId', { trainId: params.trainId });
|
||||
if (params.search) qb.andWhere('w.wagon_number ILIKE :search', { search: `%${params.search as string}%` });
|
||||
// Wagons carry no trade direction — nothing to scope. Intentional.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
||||
|
||||
export interface ExportFilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ExportFilterDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ExportFilterType;
|
||||
/** Static choices. Mutually exclusive with `optionsQuery`. */
|
||||
options?: ExportFilterOption[];
|
||||
/** Reference-data choices resolved from the DB and cached for the process. */
|
||||
optionsQuery?: (ds: DataSource) => Promise<ExportFilterOption[]>;
|
||||
}
|
||||
|
||||
/** Raw query-string bag. Per-registry filter keys, so `forbidNonWhitelisted` can't police it. */
|
||||
export type RawFilterQuery = Record<string, string | undefined>;
|
||||
|
||||
/**
|
||||
* Coerce raw query strings into typed filter params per a filter declaration
|
||||
* list. Unknown keys are dropped rather than rejected.
|
||||
*
|
||||
* Shared by the report runner and the export runner so the `daterange`
|
||||
* handling in particular cannot drift between them: `To` is pushed forward a
|
||||
* day because callers mean an INCLUSIVE end date while the SQL bound is
|
||||
* exclusive (`created_at < :dateTo`).
|
||||
*/
|
||||
export function coerceFilterParams(
|
||||
filters: ExportFilterDef[],
|
||||
raw: RawFilterQuery,
|
||||
): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const filter of filters) {
|
||||
if (filter.type === 'daterange') {
|
||||
const from = raw[`${filter.key}From`];
|
||||
const to = raw[`${filter.key}To`];
|
||||
params[`${filter.key}From`] = from ? new Date(from).toISOString() : null;
|
||||
params[`${filter.key}To`] = to
|
||||
? new Date(new Date(to).getTime() + DAY_MS).toISOString()
|
||||
: null;
|
||||
} else if (filter.type === 'multiselect') {
|
||||
const csv = raw[filter.key];
|
||||
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
||||
params[filter.key] = items.length ? items : null;
|
||||
} else {
|
||||
params[filter.key] = raw[filter.key]?.trim() || null;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-lifetime cache for `optionsQuery` results — small, rarely-changing
|
||||
* reference lists (23 stations, 18 cargo types) hit on every catalog load.
|
||||
*
|
||||
* ponytail: keyed by filter key alone, so two registries sharing a filter key
|
||||
* share one option list. Key by `${registry}:${filterKey}` if that ever bites.
|
||||
*/
|
||||
const optionsCache = new Map<string, ExportFilterOption[]>();
|
||||
|
||||
export async function resolveFilterOptions(
|
||||
filters: ExportFilterDef[],
|
||||
ds: DataSource,
|
||||
): Promise<ExportFilterDef[]> {
|
||||
return Promise.all(
|
||||
filters.map(async (filter) => {
|
||||
if (!filter.optionsQuery) return filter;
|
||||
const cached = optionsCache.get(filter.key);
|
||||
if (cached) return { ...filter, options: cached, optionsQuery: undefined };
|
||||
const options = await filter.optionsQuery(ds);
|
||||
optionsCache.set(filter.key, options);
|
||||
return { ...filter, options, optionsQuery: undefined };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { resolveJoins } from './export-query.builder';
|
||||
import { ExportDataset, ExportField } from './export.types';
|
||||
|
||||
const field = (key: string, requires?: string[]): ExportField => ({
|
||||
key,
|
||||
label: key,
|
||||
type: 'string',
|
||||
group: 'g',
|
||||
select: `x.${key}`,
|
||||
requires,
|
||||
});
|
||||
|
||||
/** Entities are never dereferenced by resolveJoins — only the alias graph matters. */
|
||||
const entity = {} as ExportDataset['joins'][number]['entity'];
|
||||
|
||||
const dataset = (
|
||||
joins: ExportDataset['joins'],
|
||||
alwaysJoin?: string[],
|
||||
): ExportDataset =>
|
||||
({
|
||||
key: 'test',
|
||||
joins,
|
||||
alwaysJoin,
|
||||
fields: [],
|
||||
}) as unknown as ExportDataset;
|
||||
|
||||
describe('resolveJoins', () => {
|
||||
it('pulls in only the joins the selected fields ask for', () => {
|
||||
const ds = dataset([
|
||||
{ alias: 'a', entity, on: 'a.id = b.a_id' },
|
||||
{ alias: 'z', entity, on: 'z.id = b.z_id' },
|
||||
]);
|
||||
expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('selecting nothing still applies alwaysJoin — the count query relies on this', () => {
|
||||
const ds = dataset(
|
||||
[
|
||||
{ alias: 'a', entity, on: 'a.id = b.a_id' },
|
||||
{ alias: 'z', entity, on: 'z.id = b.z_id' },
|
||||
],
|
||||
['a'],
|
||||
);
|
||||
expect(resolveJoins(ds, []).map((j) => j.alias)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('resolves a transitive dependency, dependency first', () => {
|
||||
const ds = dataset([
|
||||
{ alias: 'ct', entity, on: 'ct.id = b.contract_id' },
|
||||
{ alias: 'ctc', entity, on: 'ctc.id = ct.company_id', requires: ['ct'] },
|
||||
]);
|
||||
expect(resolveJoins(ds, [field('x', ['ctc'])]).map((j) => j.alias)).toEqual(['ct', 'ctc']);
|
||||
});
|
||||
|
||||
it('resolves a multi-hop chain in order', () => {
|
||||
const ds = dataset([
|
||||
{ alias: 'a', entity, on: 'a.id = b.a_id' },
|
||||
{ alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] },
|
||||
{ alias: 'cc', entity, on: 'cc.id = bb.c_id', requires: ['bb'] },
|
||||
]);
|
||||
expect(resolveJoins(ds, [field('x', ['cc'])]).map((j) => j.alias)).toEqual(['a', 'bb', 'cc']);
|
||||
});
|
||||
|
||||
it('emits a shared join once, not per field that needs it', () => {
|
||||
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
|
||||
const joins = resolveJoins(ds, [field('one', ['a']), field('two', ['a'])]);
|
||||
expect(joins.map((j) => j.alias)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('does not duplicate a join already pulled in by alwaysJoin', () => {
|
||||
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }], ['a']);
|
||||
expect(resolveJoins(ds, [field('one', ['a'])]).map((j) => j.alias)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('throws on a cycle rather than looping forever', () => {
|
||||
const ds = dataset([
|
||||
{ alias: 'a', entity, on: 'a.id = bb.a_id', requires: ['bb'] },
|
||||
{ alias: 'bb', entity, on: 'bb.id = a.b_id', requires: ['a'] },
|
||||
]);
|
||||
expect(() => resolveJoins(ds, [field('x', ['a'])])).toThrow(/join cycle/);
|
||||
});
|
||||
|
||||
it('throws on an undeclared alias — a typo must fail loudly, not silently 42P01', () => {
|
||||
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
|
||||
expect(() => resolveJoins(ds, [field('x', ['ghost'])])).toThrow(/unknown join alias "ghost"/);
|
||||
});
|
||||
|
||||
it('a field with no requires pulls in no joins at all', () => {
|
||||
const ds = dataset([{ alias: 'a', entity, on: 'a.id = b.a_id' }]);
|
||||
expect(resolveJoins(ds, [field('plain')])).toEqual([]);
|
||||
});
|
||||
});
|
||||
101
apps/edr-freight-api/src/modules/exports/export-query.builder.ts
Normal file
101
apps/edr-freight-api/src/modules/exports/export-query.builder.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ExportContext, ExportDataset, ExportField, ExportJoin } from './export.types';
|
||||
|
||||
/**
|
||||
* Sort expression fallback: the SELECT alias TypeORM emitted, quoted. TypeORM
|
||||
* double-quotes `addSelect` aliases (preserving case), so ordering by the bare
|
||||
* key lets Postgres fold it to lowercase and 42703 on any camelCase alias.
|
||||
*/
|
||||
export const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`;
|
||||
|
||||
/**
|
||||
* Selected fields -> the joins they need, transitively, dependencies first.
|
||||
* DFS post-order over `requires`, memoized. Deterministic: `alwaysJoin` first,
|
||||
* then fields in the dataset's own declaration order.
|
||||
*/
|
||||
export function resolveJoins(dataset: ExportDataset, fields: ExportField[]): ExportJoin[] {
|
||||
const byAlias = new Map(dataset.joins.map((j) => [j.alias, j]));
|
||||
const out: ExportJoin[] = [];
|
||||
const done = new Set<string>();
|
||||
const onStack = new Set<string>();
|
||||
|
||||
const visit = (alias: string): void => {
|
||||
if (done.has(alias)) return;
|
||||
if (onStack.has(alias)) {
|
||||
throw new Error(`export "${dataset.key}": join cycle at alias "${alias}"`);
|
||||
}
|
||||
const join = byAlias.get(alias);
|
||||
if (!join) {
|
||||
throw new Error(`export "${dataset.key}": unknown join alias "${alias}"`);
|
||||
}
|
||||
onStack.add(alias);
|
||||
for (const dep of join.requires ?? []) visit(dep);
|
||||
onStack.delete(alias);
|
||||
done.add(alias);
|
||||
out.push(join);
|
||||
};
|
||||
|
||||
for (const alias of dataset.alwaysJoin ?? []) visit(alias);
|
||||
for (const field of fields) for (const alias of field.requires ?? []) visit(alias);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The download query: base + only the joins the selected fields need. */
|
||||
export function buildExportQuery(
|
||||
dataset: ExportDataset,
|
||||
fields: ExportField[],
|
||||
ctx: ExportContext,
|
||||
): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = ctx.ds.createQueryBuilder().from(dataset.base.entity, dataset.base.alias);
|
||||
for (const join of resolveJoins(dataset, fields)) {
|
||||
qb.leftJoin(join.entity, join.alias, join.on);
|
||||
}
|
||||
for (const field of fields) qb.addSelect(field.select, field.key);
|
||||
dataset.scope(ctx, qb);
|
||||
return qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* The count query: same base, same `scope()`, same WHERE — but no field joins
|
||||
* and no selects. Exact rather than an estimate, because every lazy join is a
|
||||
* left join to a to-one side and so cannot change the row count.
|
||||
*/
|
||||
export function buildExportCountQuery(
|
||||
dataset: ExportDataset,
|
||||
ctx: ExportContext,
|
||||
): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.select('COUNT(*)::int', 'total')
|
||||
.from(dataset.base.entity, dataset.base.alias);
|
||||
for (const join of resolveJoins(dataset, [])) {
|
||||
qb.leftJoin(join.entity, join.alias, join.on);
|
||||
}
|
||||
dataset.scope(ctx, qb);
|
||||
return qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a requested sort against the SELECTED fields. Restricting to selected
|
||||
* fields means a sort can never pull in a join the projection didn't already
|
||||
* need — which is what keeps the count query's join set correct.
|
||||
*/
|
||||
export function resolveExportSort(
|
||||
dataset: ExportDataset,
|
||||
fields: ExportField[],
|
||||
sortBy?: string,
|
||||
sortOrder?: string,
|
||||
): { expr: string; dir: 'ASC' | 'DESC' } | null {
|
||||
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
const requested = sortBy && fields.find((f) => f.key === sortBy && f.sortExpr);
|
||||
if (requested) return { expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir };
|
||||
|
||||
if (!dataset.defaultSort) return null;
|
||||
const fallback = fields.find((f) => f.key === dataset.defaultSort!.key);
|
||||
if (!fallback) return null;
|
||||
return {
|
||||
expr: fallback.sortExpr ?? aliasSortExpr(fallback.key),
|
||||
dir: dataset.defaultSort.dir,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
EXPORT_MIME,
|
||||
formatRowCap,
|
||||
pickByKey,
|
||||
resolveExportFormat,
|
||||
resolveRowLimit,
|
||||
} from './export-request.util';
|
||||
import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service';
|
||||
|
||||
describe('resolveExportFormat', () => {
|
||||
it('only \'pdf\' exports as pdf', () => {
|
||||
expect(resolveExportFormat('pdf')).toBe('pdf');
|
||||
});
|
||||
|
||||
it('\'csv\' exports as csv', () => {
|
||||
expect(resolveExportFormat('csv')).toBe('csv');
|
||||
});
|
||||
|
||||
it.each([undefined, 'xlsx', 'doc', ''])('%p falls back to xlsx', (raw) => {
|
||||
expect(resolveExportFormat(raw)).toBe('xlsx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRowCap', () => {
|
||||
it('is the format\'s hard ceiling and is not caller-controllable', () => {
|
||||
expect(formatRowCap('xlsx')).toBe(XLSX_ROW_CAP);
|
||||
expect(formatRowCap('csv')).toBe(CSV_ROW_CAP);
|
||||
expect(formatRowCap('pdf')).toBe(PDF_ROW_CAP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRowLimit', () => {
|
||||
it('no limit means "everything, up to the cap"', () => {
|
||||
expect(resolveRowLimit('xlsx', undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('an explicit limit is the caller asking to be truncated — kept as-is', () => {
|
||||
// Distinct from the cap: 100 here must yield 100 rows, not a 400, even
|
||||
// when the unfiltered result is far larger.
|
||||
expect(resolveRowLimit('pdf', '100')).toBe(100);
|
||||
});
|
||||
|
||||
it('an explicit limit over the format cap is clamped down', () => {
|
||||
expect(resolveRowLimit('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP);
|
||||
expect(resolveRowLimit('csv', String(CSV_ROW_CAP + 1))).toBe(CSV_ROW_CAP);
|
||||
});
|
||||
|
||||
it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p means no limit', (raw) => {
|
||||
expect(resolveRowLimit('xlsx', raw)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EXPORT_MIME', () => {
|
||||
it('every format has a content type and a matching extension', () => {
|
||||
expect(EXPORT_MIME.csv.ext).toBe('csv');
|
||||
expect(EXPORT_MIME.xlsx.ext).toBe('xlsx');
|
||||
expect(EXPORT_MIME.pdf.type).toBe('application/pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickByKey', () => {
|
||||
const columns = [
|
||||
{ key: 'a', label: 'A', type: 'string' as const },
|
||||
{ key: 'b', label: 'B', type: 'number' as const },
|
||||
{ key: 'c', label: 'C', type: 'money' as const },
|
||||
];
|
||||
|
||||
it('missing fields returns every column', () => {
|
||||
expect(pickByKey(columns, undefined)).toEqual(columns);
|
||||
});
|
||||
|
||||
it('empty fields string returns every column', () => {
|
||||
expect(pickByKey(columns, '')).toEqual(columns);
|
||||
});
|
||||
|
||||
it('a known subset filters to just those, in the source\'s own order', () => {
|
||||
expect(pickByKey(columns, 'c,a')).toEqual([columns[0], columns[2]]);
|
||||
});
|
||||
|
||||
it('unknown keys are dropped, not passed through', () => {
|
||||
expect(pickByKey(columns, 'a,ghost')).toEqual([columns[0]]);
|
||||
});
|
||||
|
||||
it('all-unknown keys falls back to everything instead of a blank sheet', () => {
|
||||
expect(pickByKey(columns, 'ghost,also-ghost')).toEqual(columns);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { CSV_ROW_CAP, PDF_ROW_CAP, XLSX_ROW_CAP } from './tabular-export.service';
|
||||
|
||||
export type ExportFormat = 'xlsx' | 'csv' | 'pdf';
|
||||
|
||||
/** Anything but the literal 'pdf' or 'csv' exports as xlsx. */
|
||||
export function resolveExportFormat(raw: string | undefined): ExportFormat {
|
||||
if (raw === 'pdf') return 'pdf';
|
||||
if (raw === 'csv') return 'csv';
|
||||
return 'xlsx';
|
||||
}
|
||||
|
||||
/**
|
||||
* The format's hard ceiling. Not caller-controllable: exceeding it is an error,
|
||||
* because a silently short file is worse than a clear failure.
|
||||
*/
|
||||
export function formatRowCap(format: ExportFormat): number {
|
||||
return format === 'pdf' ? PDF_ROW_CAP : format === 'csv' ? CSV_ROW_CAP : XLSX_ROW_CAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's deliberate "just the first N rows", clamped to the format cap.
|
||||
* `undefined` means "everything, up to the cap".
|
||||
*
|
||||
* This is a DIFFERENT thing from the cap and must not share a number with it.
|
||||
* Conflating them (as this code did originally) makes the dialog's
|
||||
* "Records: First 100" option fail outright on any export with more than 100
|
||||
* rows — the user explicitly asked to be truncated, so truncating is the
|
||||
* correct answer, not a 400.
|
||||
*/
|
||||
export function resolveRowLimit(
|
||||
format: ExportFormat,
|
||||
rawLimit: string | undefined,
|
||||
): number | undefined {
|
||||
const requested = Number(rawLimit);
|
||||
return requested > 0 ? Math.min(requested, formatRowCap(format)) : undefined;
|
||||
}
|
||||
|
||||
/** Content type + file extension per format, for the download response headers. */
|
||||
export const EXPORT_MIME: Record<ExportFormat, { type: string; ext: string }> = {
|
||||
xlsx: {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ext: 'xlsx',
|
||||
},
|
||||
csv: { type: 'text/csv; charset=utf-8', ext: 'csv' },
|
||||
pdf: { type: 'application/pdf', ext: 'pdf' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Caller's requested subset, whitelisted against what they're allowed to have.
|
||||
* Missing, empty, or all-unknown `raw` falls back to every entry rather than
|
||||
* shipping a blank sheet. Generic over `{ key }` so it serves both a report's
|
||||
* `columns` and a dataset's `fields`.
|
||||
*/
|
||||
export function pickByKey<T extends { key: string }>(all: T[], raw: string | undefined): T[] {
|
||||
const requested = raw?.split(',').filter(Boolean);
|
||||
const filtered = requested?.length ? all.filter((c) => requested.includes(c.key)) : all;
|
||||
return filtered.length ? filtered : all;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { coerceFilterParams, RawFilterQuery } from './export-filter.util';
|
||||
import {
|
||||
buildExportCountQuery,
|
||||
buildExportQuery,
|
||||
resolveExportSort,
|
||||
} from './export-query.builder';
|
||||
import { ExportDataset, ExportField } from './export.types';
|
||||
|
||||
@Injectable()
|
||||
export class ExportRunnerService {
|
||||
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||
|
||||
private context(dataset: ExportDataset, raw: RawFilterQuery, directions: string[] | null) {
|
||||
return { ds: this.ds, params: coerceFilterParams(dataset.filters, raw), directions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact row count for the current filters. Exact rather than estimated
|
||||
* because lazy joins are all left joins to to-one sides, so the count cannot
|
||||
* depend on which fields the caller picked.
|
||||
*/
|
||||
async count(
|
||||
dataset: ExportDataset,
|
||||
raw: RawFilterQuery,
|
||||
directions: string[] | null,
|
||||
): Promise<number> {
|
||||
const qb = buildExportCountQuery(dataset, this.context(dataset, raw, directions));
|
||||
const row = await qb.getRawOne<{ total: number }>();
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matching rows.
|
||||
*
|
||||
* `limit` is the caller's deliberate "first N" truncation — honoured
|
||||
* silently, because they asked for it. `cap` is the format's hard ceiling —
|
||||
* exceeding it throws, because a silently short file is worse than a clear
|
||||
* error: nothing downstream reveals that rows are missing.
|
||||
*/
|
||||
async run(
|
||||
dataset: ExportDataset,
|
||||
fields: ExportField[],
|
||||
raw: RawFilterQuery,
|
||||
directions: string[] | null,
|
||||
{ cap, limit }: { cap: number; limit?: number },
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const ctx = this.context(dataset, raw, directions);
|
||||
const qb = buildExportQuery(dataset, fields, ctx);
|
||||
|
||||
const sort = resolveExportSort(dataset, fields, raw.sortBy, raw.sortOrder);
|
||||
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||
|
||||
const ceiling = limit ?? cap;
|
||||
// ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are
|
||||
// exactly that many rows" from "there are more".
|
||||
const items = await qb.limit(ceiling + 1).getRawMany();
|
||||
if (items.length <= ceiling) return items;
|
||||
|
||||
// Asked to be truncated -> truncate. Hit the hard cap -> say so.
|
||||
if (limit !== undefined) return items.slice(0, limit);
|
||||
throw new BadRequestException(
|
||||
`This export has more than ${cap.toLocaleString()} rows, the limit for this format. Narrow the filters, or export a smaller number of rows.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
33
apps/edr-freight-api/src/modules/exports/export.registry.ts
Normal file
33
apps/edr-freight-api/src/modules/exports/export.registry.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { bookingsDataset } from './datasets/bookings.dataset';
|
||||
import { contractsDataset } from './datasets/contracts.dataset';
|
||||
import { customersDataset } from './datasets/customers.dataset';
|
||||
import { invoicesDataset } from './datasets/invoices.dataset';
|
||||
import { locomotivesDataset } from './datasets/locomotives.dataset';
|
||||
import { paymentsDataset } from './datasets/payments.dataset';
|
||||
import { trainSchedulesDataset } from './datasets/train-schedules.dataset';
|
||||
import { trainsDataset } from './datasets/trains.dataset';
|
||||
import { wagonsDataset } from './datasets/wagons.dataset';
|
||||
import { ExportDataset } from './export.types';
|
||||
|
||||
/**
|
||||
* Every exportable dataset.
|
||||
*
|
||||
* Adding one = a new file under `datasets/` + an entry here. No frontend edit,
|
||||
* no route, no permission seed — the dialog is driven entirely by the catalog
|
||||
* this registry serves, and a dataset reuses its module's existing `view` key.
|
||||
*/
|
||||
export const DATASETS: ExportDataset[] = [
|
||||
bookingsDataset,
|
||||
contractsDataset,
|
||||
customersDataset,
|
||||
invoicesDataset,
|
||||
paymentsDataset,
|
||||
trainSchedulesDataset,
|
||||
locomotivesDataset,
|
||||
trainsDataset,
|
||||
wagonsDataset,
|
||||
];
|
||||
|
||||
const BY_KEY = new Map(DATASETS.map((d) => [d.key, d]));
|
||||
|
||||
export const getDataset = (key: string): ExportDataset | undefined => BY_KEY.get(key);
|
||||
135
apps/edr-freight-api/src/modules/exports/export.types.ts
Normal file
135
apps/edr-freight-api/src/modules/exports/export.types.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
DataSource,
|
||||
EntityTarget,
|
||||
ObjectLiteral,
|
||||
ObjectType,
|
||||
SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
|
||||
import { ExportFilterDef } from './export-filter.util';
|
||||
import { ExportFieldType } from './tabular-export.service';
|
||||
|
||||
export type { ExportFieldType };
|
||||
|
||||
/**
|
||||
* A lazily-applied relation.
|
||||
*
|
||||
* There is deliberately no `kind: 'inner' | 'left'` here — every join is
|
||||
* emitted as a LEFT JOIN, and the type makes anything else unrepresentable.
|
||||
* An inner join added only because someone ticked a checkbox would silently
|
||||
* change the rowset (ticking "Customer TIN" would drop every booking with a
|
||||
* null company_id), so two exports of the same filters would disagree on their
|
||||
* row count. Anything that genuinely must narrow rows belongs in `scope()`,
|
||||
* where it is unconditional and visible.
|
||||
*
|
||||
* The payoff: because a left join to a to-one side can neither add nor remove
|
||||
* rows, the row count is independent of which fields are selected — which is
|
||||
* what lets the count endpoint be exact rather than an estimate.
|
||||
*/
|
||||
export interface ExportJoin {
|
||||
/** Alias used by field `select` expressions and by `requires`. */
|
||||
alias: string;
|
||||
/** Entity class. Narrower than `EntityTarget` to match TypeORM's join overload. */
|
||||
entity: ObjectType<ObjectLiteral>;
|
||||
/** ON condition; may reference the base alias and any alias in `requires`. */
|
||||
on: string;
|
||||
/** Other join aliases this join's ON clause depends on. Resolved transitively. */
|
||||
requires?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One exportable column.
|
||||
*
|
||||
* `select` must yield exactly ONE value per base row. To surface a one-to-many
|
||||
* relation (a company's profiles, a booking's containers), aggregate inside a
|
||||
* correlated subquery — `(SELECT string_agg(...) FROM ... WHERE ... = base.id)`
|
||||
* — rather than adding a join, which would multiply rows and break the count.
|
||||
*
|
||||
* Sensitive columns are simply never declared: raw gateway payloads
|
||||
* (payments.raw_initiation, client_action), signature/crypto blobs
|
||||
* (invoices.eims_signed_qr), internal error dumps (eims_last_error), raw jsonb
|
||||
* snapshots (pricing_breakdown, document_snapshot, financial_terms,
|
||||
* attributes, business_license_files), bare internal user UUIDs, and internal
|
||||
* review/rejection notes. Fields are opt-in, so omission is the whole
|
||||
* enforcement mechanism.
|
||||
*/
|
||||
export interface ExportField {
|
||||
/** Response key, sheet header id, and the picker's checkbox id. */
|
||||
key: string;
|
||||
label: string;
|
||||
type: ExportFieldType;
|
||||
/** Scalar SQL projected as `key`. */
|
||||
select: string;
|
||||
/** Join aliases `select` references. Omit for base-table-only fields. */
|
||||
requires?: string[];
|
||||
/** Picker group id; must exist in the dataset's `groups`. */
|
||||
group: string;
|
||||
/** Pre-ticked when the dialog opens with no preset. */
|
||||
default?: boolean;
|
||||
/** ORDER BY expression. Presence makes the field sortable. */
|
||||
sortExpr?: string;
|
||||
}
|
||||
|
||||
export interface ExportGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ExportContext {
|
||||
ds: DataSource;
|
||||
/** Filter values, already coerced by `coerceFilterParams`. */
|
||||
params: Record<string, unknown>;
|
||||
/** Trade-scope directions. `null` = unrestricted, `[]` = show nothing. */
|
||||
directions: string[] | null;
|
||||
}
|
||||
|
||||
export interface ExportDataset {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
group: 'Commercial' | 'Operations' | 'Finance' | 'Fleet';
|
||||
/**
|
||||
* Permission to export this dataset. Reuses the module's existing `view`
|
||||
* key — if you may see these rows on their list page, you may export them.
|
||||
* The export never returns a row the list endpoint would not.
|
||||
*/
|
||||
permission: string;
|
||||
base: { entity: EntityTarget<ObjectLiteral>; alias: string };
|
||||
joins: ExportJoin[];
|
||||
/**
|
||||
* Aliases applied unconditionally because `scope()` references them. This is
|
||||
* the only reason a join is eager, and the count query applies exactly these.
|
||||
*/
|
||||
alwaysJoin?: string[];
|
||||
groups: ExportGroup[];
|
||||
fields: ExportField[];
|
||||
filters: ExportFilterDef[];
|
||||
/** Must name a field whose `sortExpr` references only the base alias. */
|
||||
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
|
||||
/**
|
||||
* Base WHERE (soft-delete guard), filter application, and the trade-direction
|
||||
* ACL. Runs identically for the count and download queries, so the row count
|
||||
* the dialog shows is exactly what lands in the file.
|
||||
*
|
||||
* A dataset whose table carries a trade direction MUST apply it here, or the
|
||||
* export leaks rows the user cannot see on the list page.
|
||||
*/
|
||||
scope(ctx: ExportContext, qb: SelectQueryBuilder<ObjectLiteral>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `GET /exports` serves. `select` / `requires` / `sortExpr` are raw SQL
|
||||
* and a map of the schema — they never leave the server.
|
||||
*/
|
||||
export interface ExportCatalogEntry {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
group: ExportDataset['group'];
|
||||
groups: ExportGroup[];
|
||||
fields: Pick<ExportField, 'key' | 'label' | 'type' | 'group' | 'default'>[];
|
||||
filters: ExportFilterDef[];
|
||||
formats: ('csv' | 'xlsx' | 'pdf')[];
|
||||
caps: { csv: number; xlsx: number; pdf: number };
|
||||
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
|
||||
}
|
||||
156
apps/edr-freight-api/src/modules/exports/exports.controller.ts
Normal file
156
apps/edr-freight-api/src/modules/exports/exports.controller.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Controller, Get, NotFoundException, Param, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import type { Response } from 'express';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { resolveFilterOptions } from './export-filter.util';
|
||||
import {
|
||||
EXPORT_MIME,
|
||||
formatRowCap,
|
||||
pickByKey,
|
||||
resolveExportFormat,
|
||||
resolveRowLimit,
|
||||
} from './export-request.util';
|
||||
import { ExportRunnerService } from './export-runner.service';
|
||||
import { DATASETS, getDataset } from './export.registry';
|
||||
import { ExportCatalogEntry, ExportDataset, ExportField } from './export.types';
|
||||
import { CSV_ROW_CAP, PDF_ROW_CAP, TabularExportService, XLSX_ROW_CAP } from './tabular-export.service';
|
||||
|
||||
/** Raw query bag — filter keys are per-dataset, so DTO whitelisting can't police it. */
|
||||
type RawExportQuery = Record<string, string | undefined>;
|
||||
|
||||
const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
|
||||
|
||||
/**
|
||||
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
|
||||
* the schema — they never leave the server.
|
||||
*/
|
||||
const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
|
||||
key: dataset.key,
|
||||
title: dataset.title,
|
||||
description: dataset.description,
|
||||
group: dataset.group,
|
||||
groups: dataset.groups,
|
||||
fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
group,
|
||||
default: isDefault,
|
||||
})),
|
||||
filters: dataset.filters,
|
||||
formats: ['csv', 'xlsx', 'pdf'],
|
||||
caps: CAPS,
|
||||
defaultSort: dataset.defaultSort,
|
||||
});
|
||||
|
||||
/**
|
||||
* Generic table export. One dataset per major table, each describing far more
|
||||
* fields than its list page shows — including related-entity detail.
|
||||
*/
|
||||
@ApiTags('Exports')
|
||||
@ApiBearerAuth()
|
||||
@Controller('exports')
|
||||
@UseGuards(JwtGuard)
|
||||
export class ExportsController {
|
||||
constructor(
|
||||
private readonly runner: ExportRunnerService,
|
||||
private readonly writer: TabularExportService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List datasets the caller has permission to export' })
|
||||
async catalog(@CurrentUser() user: TCurrentUser): Promise<ExportCatalogEntry[]> {
|
||||
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
|
||||
return Promise.all(
|
||||
allowed.map(async (d) => ({
|
||||
...toCatalogEntry(d),
|
||||
filters: await resolveFilterOptions(d.filters, this.dataSource),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':key/count')
|
||||
@ApiOperation({ summary: 'Exact row count for the given filters, plus the per-format caps' })
|
||||
async count(
|
||||
@Param('key') key: string,
|
||||
@Query() query: RawExportQuery,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<{ total: number; caps: typeof CAPS }> {
|
||||
const dataset = this.resolve(key, user);
|
||||
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
const total = await this.runner.count(dataset, query, directions);
|
||||
return { total, caps: CAPS };
|
||||
}
|
||||
|
||||
@Get(':key/download')
|
||||
@ApiOperation({ summary: 'Export a dataset to csv, xlsx or pdf' })
|
||||
async download(
|
||||
@Param('key') key: string,
|
||||
@Query() query: RawExportQuery & { format?: string; fields?: string; limit?: string },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const dataset = this.resolve(key, user);
|
||||
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
const format = resolveExportFormat(query.format);
|
||||
const fields = this.resolveFields(dataset, query.fields);
|
||||
|
||||
const rows = await this.runner.run(dataset, fields, query, directions, {
|
||||
cap: formatRowCap(format),
|
||||
limit: resolveRowLimit(format, query.limit),
|
||||
});
|
||||
const doc = {
|
||||
title: dataset.title,
|
||||
description: dataset.description,
|
||||
label: `export:${dataset.key}`,
|
||||
columns: fields.map(({ key: k, label, type }) => ({ key: k, label, type })),
|
||||
rows,
|
||||
};
|
||||
const buffer =
|
||||
format === 'pdf'
|
||||
? await this.writer.toPdf(doc)
|
||||
: format === 'csv'
|
||||
? await this.writer.toCsv(doc)
|
||||
: await this.writer.toXlsx(doc);
|
||||
|
||||
const mime = EXPORT_MIME[format];
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${dataset.key}-${stamp}.${mime.ext}"`);
|
||||
res.setHeader('Content-Type', mime.type);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requested fields, whitelisted against the dataset. No `fields=` means the
|
||||
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
|
||||
* all of them on an unparameterised call is nobody's intent.
|
||||
*/
|
||||
private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] {
|
||||
if (raw?.trim()) {
|
||||
const picked = pickByKey(dataset.fields, raw);
|
||||
// pickByKey falls back to everything when nothing matched; for a dataset
|
||||
// the safer read of "all keys unknown" is still the default set.
|
||||
if (picked.length !== dataset.fields.length) return picked;
|
||||
}
|
||||
const defaults = dataset.fields.filter((f) => f.default);
|
||||
return defaults.length ? defaults : dataset.fields;
|
||||
}
|
||||
|
||||
private resolve(key: string, user: TCurrentUser): ExportDataset {
|
||||
const dataset = getDataset(key);
|
||||
if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`);
|
||||
// Export rides the dataset's own list-page view permission: if you may see
|
||||
// these rows, you may export them.
|
||||
assertFreightPermission(user, dataset.permission);
|
||||
return dataset;
|
||||
}
|
||||
}
|
||||
23
apps/edr-freight-api/src/modules/exports/exports.module.ts
Normal file
23
apps/edr-freight-api/src/modules/exports/exports.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { ExportRunnerService } from './export-runner.service';
|
||||
import { ExportsController } from './exports.controller';
|
||||
import { TabularExportService } from './tabular-export.service';
|
||||
|
||||
/**
|
||||
* Generic table export: a dataset registry describing far more fields than each
|
||||
* list page shows (related-entity detail included), plus the shared tabular
|
||||
* writer (csv / xlsx / pdf) the reports module also writes through.
|
||||
*
|
||||
* `TabularExportService` is exported so ReportsModule can reuse it without
|
||||
* pulling in the dataset machinery.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DocumentsModule, UserTradeAccessModule],
|
||||
controllers: [ExportsController],
|
||||
providers: [TabularExportService, ExportRunnerService],
|
||||
exports: [TabularExportService],
|
||||
})
|
||||
export class ExportsModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
import { TabularDoc, TabularExportService } from './tabular-export.service';
|
||||
|
||||
/** The PDF path is puppeteer-backed; these specs only cover the sheet writers. */
|
||||
const service = new TabularExportService(null as unknown as PdfRenderService);
|
||||
|
||||
const doc: TabularDoc = {
|
||||
title: 'Bookings',
|
||||
description: 'every booking',
|
||||
label: 'test',
|
||||
columns: [
|
||||
{ key: 'ref', label: 'Reference', type: 'string' },
|
||||
{ key: 'customer', label: 'Customer', type: 'string' },
|
||||
{ key: 'amount', label: 'Amount', type: 'money' },
|
||||
{ key: 'gov', label: 'Government', type: 'boolean' },
|
||||
],
|
||||
rows: [
|
||||
{ ref: 'BK-1', customer: 'Acme, Inc.', amount: 1234.5, gov: true },
|
||||
{ ref: 'BK-2', customer: 'Quote "Q" Ltd', amount: null, gov: false },
|
||||
],
|
||||
kpis: [{ label: 'Bookings', value: 2 }],
|
||||
};
|
||||
|
||||
describe('TabularExportService.toCsv', () => {
|
||||
it('quotes a value containing the delimiter — the reason we do not hand-roll join(",")', async () => {
|
||||
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||
expect(csv).toContain('"Acme, Inc."');
|
||||
});
|
||||
|
||||
it('escapes embedded double quotes by doubling them', async () => {
|
||||
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||
expect(csv).toContain('"Quote ""Q"" Ltd"');
|
||||
});
|
||||
|
||||
it('starts at the header row — no KPI preamble, so the file parses as a plain table', async () => {
|
||||
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||
expect(csv.split('\n')[0]).toBe('Reference,Customer,Amount,Government');
|
||||
expect(csv).not.toContain('Bookings: 2');
|
||||
});
|
||||
|
||||
it('emits one line per row plus the header', async () => {
|
||||
const csv = (await service.toCsv(doc)).toString('utf8');
|
||||
expect(csv.trim().split('\n').filter(Boolean)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('only the selected columns are written, in the order given', async () => {
|
||||
const csv = (
|
||||
await service.toCsv({ ...doc, columns: [doc.columns[2], doc.columns[0]] })
|
||||
).toString('utf8');
|
||||
expect(csv.split('\n')[0]).toBe('Amount,Reference');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabularExportService.toXlsx', () => {
|
||||
it('writes a real xlsx (a zip, so it starts with the PK magic bytes)', async () => {
|
||||
const buffer = await service.toXlsx(doc);
|
||||
expect(buffer.subarray(0, 2).toString('utf8')).toBe('PK');
|
||||
expect(buffer.length).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it('a title longer than Excel\'s 31-char sheet-name limit does not throw', async () => {
|
||||
const longTitle = 'A'.repeat(60);
|
||||
await expect(service.toXlsx({ ...doc, title: longTitle })).resolves.toBeInstanceOf(Buffer);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
||||
|
||||
// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming
|
||||
// WorkbookWriter if an export ever needs to outgrow XLSX_ROW_CAP.
|
||||
export const XLSX_ROW_CAP = 50_000;
|
||||
// ponytail: CSV is buffered through the same Workbook as xlsx, so it shares the
|
||||
// cap. Switch to qb.stream() + res.write() if a dataset needs more than this.
|
||||
export const CSV_ROW_CAP = 50_000;
|
||||
// ponytail: HTML→PDF render cost grows with row count; larger exports must
|
||||
// use XLSX or CSV instead.
|
||||
export const PDF_ROW_CAP = 5_000;
|
||||
|
||||
/**
|
||||
* Value types a tabular export understands. A superset of `ReportColumn['type']`
|
||||
* so a report's own columns are assignable here unchanged.
|
||||
*/
|
||||
export type ExportFieldType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'money'
|
||||
| 'tons'
|
||||
| 'percent'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'boolean';
|
||||
|
||||
/** The minimum a column must describe to be written to a sheet. */
|
||||
export interface ExportColumnLike {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ExportFieldType;
|
||||
}
|
||||
|
||||
/** Headline figures printed above the table. xlsx/pdf only — never in CSV. */
|
||||
export interface ExportKpiLike {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tabular document, independent of where the rows came from. A report and a
|
||||
* dataset export both reduce to this, which is what lets them share one writer.
|
||||
*/
|
||||
export interface TabularDoc {
|
||||
/** Sheet name (truncated to Excel's 31-char limit) and the PDF's <h1>. */
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Log label handed to PdfRenderService, e.g. "report:bookings-list". */
|
||||
label: string;
|
||||
columns: ExportColumnLike[];
|
||||
rows: Record<string, unknown>[];
|
||||
kpis?: ExportKpiLike[];
|
||||
}
|
||||
|
||||
const NUMBER_FORMAT: Partial<Record<ExportFieldType, string>> = {
|
||||
money: '#,##0.00',
|
||||
tons: '#,##0.0',
|
||||
percent: '0"%"',
|
||||
number: '#,##0',
|
||||
};
|
||||
|
||||
function formatCell(value: unknown, type: ExportFieldType): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (type === 'money' || type === 'number') {
|
||||
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
}
|
||||
if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`;
|
||||
if (type === 'percent') return `${value}%`;
|
||||
if (type === 'boolean') return value ? 'Yes' : 'No';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TabularExportService {
|
||||
constructor(private readonly pdfRender: PdfRenderService) {}
|
||||
|
||||
async toXlsx(doc: TabularDoc): Promise<Buffer> {
|
||||
const workbook = this.buildWorkbook(doc, { includeKpis: true });
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV via ExcelJS's own writer, off the same Workbook xlsx builds — it already
|
||||
* handles quoting, embedded commas and embedded newlines. Hand-rolling
|
||||
* `row.join(',')` breaks on the first customer name containing a comma.
|
||||
*
|
||||
* KPIs are deliberately omitted: a preamble row plus a blank row before the
|
||||
* header stops the file parsing as a plain table, and CSV's whole point here
|
||||
* is being machine-readable.
|
||||
*/
|
||||
async toCsv(doc: TabularDoc): Promise<Buffer> {
|
||||
const workbook = this.buildWorkbook(doc, { includeKpis: false });
|
||||
const buffer = await workbook.csv.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
async toPdf(doc: TabularDoc): Promise<Buffer> {
|
||||
const html = this.buildHtml(doc);
|
||||
return this.pdfRender.htmlToPdfBuffer(html, {
|
||||
label: doc.label,
|
||||
landscape: true,
|
||||
// Without this, a box with no Chromium silently degrades to
|
||||
// genericFallbackPdf — a ~900-character text dump instead of a table.
|
||||
// buildTabularFallbackPdf parses exactly the markup buildHtml emits.
|
||||
fallback: buildTabularFallbackPdf,
|
||||
});
|
||||
}
|
||||
|
||||
private buildWorkbook(doc: TabularDoc, opts: { includeKpis: boolean }): ExcelJS.Workbook {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(doc.title.slice(0, 31));
|
||||
const { columns, rows, kpis } = doc;
|
||||
|
||||
if (opts.includeKpis && kpis?.length) {
|
||||
sheet.addRow(
|
||||
kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`),
|
||||
);
|
||||
sheet.addRow([]);
|
||||
}
|
||||
|
||||
const headerRow = sheet.addRow(columns.map((c) => c.label));
|
||||
headerRow.font = { bold: true };
|
||||
|
||||
for (const row of rows) {
|
||||
sheet.addRow(columns.map((c) => row[c.key] ?? null));
|
||||
}
|
||||
|
||||
columns.forEach((col, i) => {
|
||||
const format = NUMBER_FORMAT[col.type];
|
||||
const excelCol = sheet.getColumn(i + 1);
|
||||
excelCol.width = Math.max(col.label.length + 2, 12);
|
||||
if (format) excelCol.numFmt = format;
|
||||
});
|
||||
|
||||
return workbook;
|
||||
}
|
||||
|
||||
private buildHtml(doc: TabularDoc): string {
|
||||
const { title, description, columns, rows, kpis } = doc;
|
||||
const esc = (v: unknown) =>
|
||||
String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const kpiHtml = kpis?.length
|
||||
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
|
||||
.map(
|
||||
(k) =>
|
||||
`<div class="tile"><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
|
||||
)
|
||||
.join('')}</div>`
|
||||
: '';
|
||||
|
||||
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
|
||||
const body = rows
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
|
||||
h1 { font-size: 16px; margin-bottom: 4px; }
|
||||
p.subtitle { color: #666; margin: 0 0 12px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
|
||||
th { background: #f3f3f3; }
|
||||
</style></head><body>
|
||||
<h1>${esc(title)}</h1>
|
||||
<p class="subtitle">${esc(description ?? '')}</p>
|
||||
${kpiHtml}
|
||||
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
|
||||
</body></html>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import {
|
||||
TARGET_DIMENSIONS,
|
||||
TARGET_METRICS,
|
||||
TARGET_PERIOD_TYPES,
|
||||
TargetDimension,
|
||||
TargetMetric,
|
||||
TargetPeriodType,
|
||||
} from '../entities/operations-target.entity';
|
||||
|
||||
const toNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? value : Number(value);
|
||||
|
||||
export class CreateOperationsTargetDto {
|
||||
@ApiProperty({ enum: TARGET_PERIOD_TYPES })
|
||||
@IsIn(TARGET_PERIOD_TYPES as unknown as string[])
|
||||
periodType!: TargetPeriodType;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-08-01',
|
||||
description: 'Any date inside the bucket — normalised to the bucket start on write.',
|
||||
})
|
||||
@IsDateString()
|
||||
periodStart!: string;
|
||||
|
||||
@ApiProperty({ enum: TARGET_METRICS })
|
||||
@IsIn(TARGET_METRICS as unknown as string[])
|
||||
metric!: TargetMetric;
|
||||
|
||||
@ApiProperty({ enum: TARGET_DIMENSIONS })
|
||||
@IsIn(TARGET_DIMENSIONS as unknown as string[])
|
||||
dimension!: TargetDimension;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'CONTAINER_IMPORT_MULTIMODAL',
|
||||
description: 'Category key, container-class key or yard code — not a display label.',
|
||||
})
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
dimensionKey!: string;
|
||||
|
||||
@ApiProperty({ example: 1200 })
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
plannedValue!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.',
|
||||
example: 'CONTAINER_IMPORT_MULTIMODAL',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
cargoCategory?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import {
|
||||
TARGET_DIMENSIONS,
|
||||
TARGET_METRICS,
|
||||
TARGET_PERIOD_TYPES,
|
||||
TargetDimension,
|
||||
TargetMetric,
|
||||
TargetPeriodType,
|
||||
} from '../entities/operations-target.entity';
|
||||
|
||||
export class ListOperationsTargetsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: TARGET_PERIOD_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn(TARGET_PERIOD_TYPES as unknown as string[])
|
||||
periodType?: TargetPeriodType;
|
||||
|
||||
@ApiPropertyOptional({ enum: TARGET_METRICS })
|
||||
@IsOptional()
|
||||
@IsIn(TARGET_METRICS as unknown as string[])
|
||||
metric?: TargetMetric;
|
||||
|
||||
@ApiPropertyOptional({ enum: TARGET_DIMENSIONS })
|
||||
@IsOptional()
|
||||
@IsIn(TARGET_DIMENSIONS as unknown as string[])
|
||||
dimension?: TargetDimension;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsInt, IsNumber, IsOptional, Min } from 'class-validator';
|
||||
|
||||
const toNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? value : Number(value);
|
||||
|
||||
/**
|
||||
* Every field optional — the backoffice form PATCHes only what changed. A
|
||||
* standard of zero is rejected: it would make every implement-rate division
|
||||
* blow up or read as infinite achievement.
|
||||
*/
|
||||
export class UpdateOperationsStandardsDto {
|
||||
@ApiPropertyOptional({ example: 10 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
stationStandardHoursEthiopia?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 13 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
stationStandardHoursDjibouti?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 65 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
cycleStandardHoursContainer?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 88 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
cycleStandardHoursBulkDmp?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 96 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
cycleStandardHoursBulkNagad?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 96 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
cycleStandardHoursBulkBcc?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 21 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
defaultLegStandardHours?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 30 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
delayToleranceMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 20 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
chargedTonsFull20ft?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 40 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
chargedTonsFull40ft?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 2.24 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
chargedTonsEmpty20ft?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3.88 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
chargedTonsEmpty40ft?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 70 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
chargedTonsPerWagonGeneral?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 38 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
chargedTonsPerWagonPerishable?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 50 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
defaultFullTrainsetWagons?: number;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateOperationsTargetDto } from './create-operations-target.dto';
|
||||
|
||||
export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity } from 'typeorm';
|
||||
|
||||
/**
|
||||
* numeric comes back from pg as a string. Every value here is arithmetic in a
|
||||
* report expression, so convert on read rather than making each caller do it.
|
||||
*/
|
||||
const asNumber = {
|
||||
to: (value: number) => value,
|
||||
from: (value: string | null) => (value === null ? null : Number(value)),
|
||||
};
|
||||
|
||||
/**
|
||||
* Single-row table holding the railway's operating standards — the numbers the
|
||||
* operations reports measure actual performance against. Same single-row shape
|
||||
* as `logo_settings` and `exchange_settings`; the app never inserts a second row.
|
||||
*
|
||||
* These live in the database rather than in a constants file because the
|
||||
* business treats them as tunable (the corridor standard is explicitly
|
||||
* described as "flexible"), and a planner must be able to change one without a
|
||||
* deployment.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'operations_standards' })
|
||||
export class OperationsStandard extends BaseEntity {
|
||||
/** Standard time a train may stand at an Ethiopian station, in hours. */
|
||||
@Column({
|
||||
name: 'station_standard_hours_ethiopia',
|
||||
type: 'numeric',
|
||||
precision: 6,
|
||||
scale: 2,
|
||||
default: 10,
|
||||
transformer: asNumber,
|
||||
})
|
||||
stationStandardHoursEthiopia!: number;
|
||||
|
||||
/** Standard time a train may stand at a Djibouti station, in hours. */
|
||||
@Column({
|
||||
name: 'station_standard_hours_djibouti',
|
||||
type: 'numeric',
|
||||
precision: 6,
|
||||
scale: 2,
|
||||
default: 13,
|
||||
transformer: asNumber,
|
||||
})
|
||||
stationStandardHoursDjibouti!: number;
|
||||
|
||||
/** Container turn-around cycle: 10 + 21 + 13 + 21. */
|
||||
@Column({
|
||||
name: 'cycle_standard_hours_container',
|
||||
type: 'numeric',
|
||||
precision: 6,
|
||||
scale: 2,
|
||||
default: 65,
|
||||
transformer: asNumber,
|
||||
})
|
||||
cycleStandardHoursContainer!: number;
|
||||
|
||||
/** Bulk cycle via DMP: 13 + 21 + 33 + 21. */
|
||||
@Column({
|
||||
name: 'cycle_standard_hours_bulk_dmp',
|
||||
type: 'numeric',
|
||||
precision: 6,
|
||||
scale: 2,
|
||||
default: 88,
|
||||
transformer: asNumber,
|
||||
})
|
||||
cycleStandardHoursBulkDmp!: number;
|
||||
|
||||
/** Bulk cycle via Negad freight yard: 13 + 21 + 41 + 21. */
|
||||
@Column({
|
||||
name: 'cycle_standard_hours_bulk_nagad',
|
||||
type: 'numeric',
|
||||
precision: 6,
|
||||
scale: 2,
|
||||
default: 96,
|
||||
transformer: asNumber,
|
||||
})
|
||||
cycleStandardHoursBulkNagad!: number;
|
||||
|
||||
/** Bulk cycle via BCC: 13 + 21 + 41 + 21. */
|
||||
@Column({
|
||||
name: 'cycle_standard_hours_bulk_bcc',
|
||||
type: 'numeric',
|
||||
precision: 6,
|
||||
scale: 2,
|
||||
default: 96,
|
||||
transformer: asNumber,
|
||||
})
|
||||
cycleStandardHoursBulkBcc!: number;
|
||||
|
||||
/**
|
||||
* Standard running time for one corridor leg, used when the yard pair has no
|
||||
* `yard_distances.standard_hours` of its own.
|
||||
*/
|
||||
@Column({
|
||||
name: 'default_leg_standard_hours',
|
||||
type: 'numeric',
|
||||
precision: 6,
|
||||
scale: 2,
|
||||
default: 21,
|
||||
transformer: asNumber,
|
||||
})
|
||||
defaultLegStandardHours!: number;
|
||||
|
||||
/** Grace on top of the leg standard before a train counts as delayed. */
|
||||
@Column({ name: 'delay_tolerance_minutes', type: 'int', default: 30 })
|
||||
delayToleranceMinutes!: number;
|
||||
|
||||
/** Charged tonnage per laden 20ft container. */
|
||||
@Column({
|
||||
name: 'charged_tons_full_20ft',
|
||||
type: 'numeric',
|
||||
precision: 8,
|
||||
scale: 2,
|
||||
default: 20,
|
||||
transformer: asNumber,
|
||||
})
|
||||
chargedTonsFull20ft!: number;
|
||||
|
||||
/** Charged tonnage per laden 40ft container. */
|
||||
@Column({
|
||||
name: 'charged_tons_full_40ft',
|
||||
type: 'numeric',
|
||||
precision: 8,
|
||||
scale: 2,
|
||||
default: 40,
|
||||
transformer: asNumber,
|
||||
})
|
||||
chargedTonsFull40ft!: number;
|
||||
|
||||
/** Charged tonnage per empty 20ft container. */
|
||||
@Column({
|
||||
name: 'charged_tons_empty_20ft',
|
||||
type: 'numeric',
|
||||
precision: 8,
|
||||
scale: 2,
|
||||
default: 2.24,
|
||||
transformer: asNumber,
|
||||
})
|
||||
chargedTonsEmpty20ft!: number;
|
||||
|
||||
/** Charged tonnage per empty 40ft container. */
|
||||
@Column({
|
||||
name: 'charged_tons_empty_40ft',
|
||||
type: 'numeric',
|
||||
precision: 8,
|
||||
scale: 2,
|
||||
default: 3.88,
|
||||
transformer: asNumber,
|
||||
})
|
||||
chargedTonsEmpty40ft!: number;
|
||||
|
||||
/** Charged tonnage per wagon of steel, fertilizer, rice, sugar, livestock. */
|
||||
@Column({
|
||||
name: 'charged_tons_per_wagon_general',
|
||||
type: 'numeric',
|
||||
precision: 8,
|
||||
scale: 2,
|
||||
default: 70,
|
||||
transformer: asNumber,
|
||||
})
|
||||
chargedTonsPerWagonGeneral!: number;
|
||||
|
||||
/** Charged tonnage per wagon of vegetables, milk, meat and other perishables. */
|
||||
@Column({
|
||||
name: 'charged_tons_per_wagon_perishable',
|
||||
type: 'numeric',
|
||||
precision: 8,
|
||||
scale: 2,
|
||||
default: 38,
|
||||
transformer: asNumber,
|
||||
})
|
||||
chargedTonsPerWagonPerishable!: number;
|
||||
|
||||
/**
|
||||
* Wagons in a full trainset when the cargo type has no
|
||||
* `cargo_types.full_trainset_wagons` of its own.
|
||||
*/
|
||||
@Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 })
|
||||
defaultFullTrainsetWagons!: number;
|
||||
|
||||
/** IAM user id of the last operator to change a standard. */
|
||||
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
|
||||
updatedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/** Planning buckets the reports offer. Mirrors the reports' period filter. */
|
||||
export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const;
|
||||
export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number];
|
||||
|
||||
/** What is being planned. */
|
||||
export const TARGET_METRICS = ['TEU', 'TRAINSET', 'VOLUME_TONS'] as const;
|
||||
export type TargetMetric = (typeof TARGET_METRICS)[number];
|
||||
|
||||
/** Which axis `dimensionKey` names. */
|
||||
export const TARGET_DIMENSIONS = ['cargo_category', 'station', 'container_class'] as const;
|
||||
export type TargetDimension = (typeof TARGET_DIMENSIONS)[number];
|
||||
|
||||
/**
|
||||
* How each stored code reads on screen. The columns are enums the reports match
|
||||
* on, so the stored values must stay exactly as they are — these exist for the
|
||||
* admin grid, which otherwise shows `VOLUME_TONS` and `cargo_category` verbatim.
|
||||
*/
|
||||
export const TARGET_METRIC_LABELS: Record<TargetMetric, string> = {
|
||||
TEU: 'TEU',
|
||||
TRAINSET: 'Trainsets',
|
||||
VOLUME_TONS: 'Volume (tons)',
|
||||
};
|
||||
|
||||
export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
|
||||
cargo_category: 'Cargo category',
|
||||
station: 'Station',
|
||||
container_class: 'Container class',
|
||||
};
|
||||
|
||||
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
|
||||
week: 'Weekly',
|
||||
month: 'Monthly',
|
||||
quarter: 'Quarterly',
|
||||
year: 'Yearly',
|
||||
};
|
||||
|
||||
/**
|
||||
* The planned side of every "Plan / Operated / Implement Rate" table in the
|
||||
* operations reporting spec. One row is one planned number: a period, a metric,
|
||||
* and the dimension value it applies to.
|
||||
*
|
||||
* `dimensionKey` holds a category key (not a label) — the same keys
|
||||
* `operations-classification.ts` emits, so a report can join on it directly.
|
||||
*
|
||||
* Uniqueness on the five-column slot is a partial index in the database
|
||||
* (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a soft-deleted
|
||||
* target can be re-created — the same choice `yard_distances` makes.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'operations_targets' })
|
||||
@Index(['metric', 'periodType', 'periodStart'])
|
||||
export class OperationsTarget extends BaseEntity {
|
||||
@Column({ name: 'period_type', type: 'varchar', length: 10 })
|
||||
periodType!: TargetPeriodType;
|
||||
|
||||
/** First day of the bucket, normalised on write (Monday, 1st, quarter start). */
|
||||
@Column({ name: 'period_start', type: 'date' })
|
||||
periodStart!: string;
|
||||
|
||||
@Column({ name: 'metric', type: 'varchar', length: 20 })
|
||||
metric!: TargetMetric;
|
||||
|
||||
@Column({ name: 'dimension', type: 'varchar', length: 20 })
|
||||
dimension!: TargetDimension;
|
||||
|
||||
/** Category key, container-class key, or yard code — never a display label. */
|
||||
@Column({ name: 'dimension_key', type: 'varchar', length: 60 })
|
||||
dimensionKey!: string;
|
||||
|
||||
@Column({
|
||||
name: 'planned_value',
|
||||
type: 'numeric',
|
||||
precision: 14,
|
||||
scale: 3,
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string | null) => (value === null ? null : Number(value)),
|
||||
},
|
||||
})
|
||||
plannedValue!: number;
|
||||
|
||||
/**
|
||||
* Only for `station` targets, where the plan is per station AND per cargo
|
||||
* type — the OCC report plans Nagad–Mojo container and Nagad–Mojo fertilizer
|
||||
* separately. Null on `cargo_category` and `container_class` targets, whose
|
||||
* `dimensionKey` already carries the category.
|
||||
*/
|
||||
@Column({ name: 'cargo_category', type: 'varchar', length: 60, nullable: true })
|
||||
cargoCategory?: string | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { OperationsStandard } from './entities/operations-standard.entity';
|
||||
import { OperationsTarget } from './entities/operations-target.entity';
|
||||
import { OperationsStandardsController } from './operations-standards.controller';
|
||||
import { OperationsStandardsService } from './operations-standards.service';
|
||||
import { OperationsTargetsController } from './operations-targets.controller';
|
||||
import { OperationsTargetsService } from './operations-targets.service';
|
||||
|
||||
/**
|
||||
* Reference data behind the operations reports: the railway's operating
|
||||
* standards (one settings row) and the planned targets the reports compare
|
||||
* actuals against.
|
||||
*
|
||||
* Global because the reports module reads the standards row on every run and
|
||||
* has no other reason to import this.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])],
|
||||
controllers: [OperationsStandardsController, OperationsTargetsController],
|
||||
providers: [OperationsStandardsService, OperationsTargetsService],
|
||||
exports: [OperationsStandardsService, OperationsTargetsService],
|
||||
})
|
||||
export class OperationsReportingModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto';
|
||||
import { OperationsStandardsService } from './operations-standards.service';
|
||||
|
||||
@ApiTags('operations-standards')
|
||||
@ApiBearerAuth()
|
||||
@Controller('operations-standards')
|
||||
export class OperationsStandardsController {
|
||||
constructor(private readonly service: OperationsStandardsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.operationsStandards.view, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Standard times and charged-tonnage factors used by the operations reports' })
|
||||
get() {
|
||||
return this.service.get();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.operationsStandards.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Change one or more operating standards' })
|
||||
update(@Body() dto: UpdateOperationsStandardsDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.update(dto, user?.id ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto';
|
||||
import { OperationsStandard } from './entities/operations-standard.entity';
|
||||
|
||||
/**
|
||||
* Owns the single `operations_standards` row — the times and tonnage factors
|
||||
* every operations report measures actual performance against.
|
||||
*
|
||||
* The migration seeds the row, but `get()` creates it on demand as well: a
|
||||
* report that cannot read a standard would have to fall back to a hardcoded
|
||||
* number, which is exactly what putting these in the database was meant to
|
||||
* avoid.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OperationsStandardsService {
|
||||
constructor(
|
||||
@InjectRepository(OperationsStandard)
|
||||
private readonly repository: Repository<OperationsStandard>,
|
||||
) {}
|
||||
|
||||
async get(): Promise<OperationsStandard> {
|
||||
const existing = await this.repository.findOne({
|
||||
where: { deletedAt: IsNull() },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
// Every column has a database default, so an empty insert is the seed row.
|
||||
return this.repository.save(this.repository.create({}));
|
||||
}
|
||||
|
||||
async update(
|
||||
dto: UpdateOperationsStandardsDto,
|
||||
userId: string | null,
|
||||
): Promise<OperationsStandard> {
|
||||
const current = await this.get();
|
||||
await this.repository.update(current.id, { ...dto, updatedById: userId });
|
||||
return this.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
RuleEngineUpdate,
|
||||
RuleEngineView,
|
||||
} from '../../common/rule-engine-guards';
|
||||
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
|
||||
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
|
||||
import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto';
|
||||
import { OperationsTargetsService } from './operations-targets.service';
|
||||
|
||||
@ApiTags('operations-targets')
|
||||
@Controller('operations-targets')
|
||||
@ApiBearerAuth()
|
||||
export class OperationsTargetsController {
|
||||
constructor(private readonly service: OperationsTargetsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('operations-targets')
|
||||
@ApiOperation({ summary: 'List planned operational targets' })
|
||||
findAll(@Query() query: ListOperationsTargetsQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('operations-targets')
|
||||
@ApiOperation({ summary: 'Get a target by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineCreate('operations-targets')
|
||||
@ApiOperation({ summary: 'Create a planned target' })
|
||||
create(@Body() dto: CreateOperationsTargetDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineUpdate('operations-targets')
|
||||
@ApiOperation({ summary: 'Update a planned target' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateOperationsTargetDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineDelete('operations-targets')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a planned target' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Brackets, IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { paginateQuery } from '../../common/utils/pagination.util';
|
||||
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
|
||||
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
|
||||
import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto';
|
||||
import {
|
||||
OperationsTarget,
|
||||
TARGET_DIMENSION_LABELS,
|
||||
TARGET_METRIC_LABELS,
|
||||
TARGET_PERIOD_LABELS,
|
||||
TargetPeriodType,
|
||||
} from './entities/operations-target.entity';
|
||||
import {
|
||||
CARGO_CATEGORIES,
|
||||
CONTAINER_CLASSES,
|
||||
} from '../reports/operations-classification';
|
||||
|
||||
/**
|
||||
* Normalises any date inside a bucket to the bucket's first day, matching
|
||||
* Postgres `date_trunc` — which is what the reports group by. Week starts
|
||||
* Monday, the same as `date_trunc('week', …)` and ISO week numbering.
|
||||
*
|
||||
* Done in UTC throughout: the stored column is a bare `date`, and running the
|
||||
* arithmetic in local time would shift a 1st-of-month target into the previous
|
||||
* month for anyone east of Greenwich.
|
||||
*/
|
||||
export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string {
|
||||
const d = new Date(`${value.slice(0, 10)}T00:00:00Z`);
|
||||
switch (periodType) {
|
||||
case 'week': {
|
||||
// getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in.
|
||||
const offset = (d.getUTCDay() + 6) % 7;
|
||||
d.setUTCDate(d.getUTCDate() - offset);
|
||||
break;
|
||||
}
|
||||
case 'month':
|
||||
d.setUTCDate(1);
|
||||
break;
|
||||
case 'quarter':
|
||||
d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1);
|
||||
break;
|
||||
case 'year':
|
||||
d.setUTCMonth(0, 1);
|
||||
break;
|
||||
}
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat row shape for the backoffice config grid: the stored codes stay put —
|
||||
* the reports join on them — and readable twins ride alongside, the same way
|
||||
* `YardDistancesService` adds `fromYardLabel`.
|
||||
*/
|
||||
export type OperationsTargetRow = OperationsTarget & {
|
||||
metricLabel: string;
|
||||
dimensionLabel: string;
|
||||
periodLabel: string;
|
||||
appliesToLabel: string;
|
||||
cargoCategoryLabel: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolved per dimension, not from one merged map: `CONTAINER_EXPORT` is in
|
||||
* both vocabularies and reads differently in each ("Export container" as a
|
||||
* cargo category, "Full export container" as a container class). Merging them
|
||||
* silently gave every cargo-category row the container-class wording.
|
||||
*/
|
||||
const LABELS_BY_DIMENSION: Record<string, Map<string, string>> = {
|
||||
cargo_category: new Map(CARGO_CATEGORIES.map((o) => [o.value, o.label])),
|
||||
container_class: new Map(CONTAINER_CLASSES.map((o) => [o.value, o.label])),
|
||||
};
|
||||
|
||||
const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category;
|
||||
|
||||
@Injectable()
|
||||
export class OperationsTargetsService {
|
||||
constructor(
|
||||
@InjectRepository(OperationsTarget)
|
||||
private readonly repository: Repository<OperationsTarget>,
|
||||
) {}
|
||||
|
||||
/** Yard code → label, for station targets. Reference data, read per list. */
|
||||
private async yardLabels(): Promise<Map<string, string>> {
|
||||
const rows = await this.repository.manager.query<Array<{ code: string; label: string }>>(
|
||||
`SELECT code, label FROM freight.yards WHERE deleted_at IS NULL`,
|
||||
);
|
||||
return new Map(rows.map((r) => [r.code, r.label]));
|
||||
}
|
||||
|
||||
private toRow(target: OperationsTarget, yards: Map<string, string>): OperationsTargetRow {
|
||||
const appliesToLabel =
|
||||
target.dimension === 'station'
|
||||
? (yards.get(target.dimensionKey) ?? target.dimensionKey)
|
||||
: (LABELS_BY_DIMENSION[target.dimension]?.get(target.dimensionKey) ??
|
||||
target.dimensionKey);
|
||||
|
||||
return Object.assign(target, {
|
||||
metricLabel: TARGET_METRIC_LABELS[target.metric] ?? target.metric,
|
||||
dimensionLabel: TARGET_DIMENSION_LABELS[target.dimension] ?? target.dimension,
|
||||
periodLabel: TARGET_PERIOD_LABELS[target.periodType] ?? target.periodType,
|
||||
appliesToLabel,
|
||||
// Only station targets carry one, and it is always a cargo category.
|
||||
cargoCategoryLabel: target.cargoCategory
|
||||
? (CARGO_CATEGORY_LABELS.get(target.cargoCategory) ?? target.cargoCategory)
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query: ListOperationsTargetsQueryDto,
|
||||
): Promise<PaginatedResponse<OperationsTargetRow>> {
|
||||
const sortable: Record<string, string> = {
|
||||
periodStart: 'target.period_start',
|
||||
metric: 'target.metric',
|
||||
dimension: 'target.dimension',
|
||||
dimensionKey: 'target.dimension_key',
|
||||
plannedValue: 'target.planned_value',
|
||||
createdAt: 'target.created_at',
|
||||
};
|
||||
const sortBy = sortable[query.sortBy ?? ''] ?? sortable.periodStart;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('target')
|
||||
.orderBy(sortBy, query.sortOrder ?? 'DESC')
|
||||
.addOrderBy('target.dimension_key', 'ASC');
|
||||
|
||||
if (query.periodType) qb.andWhere('target.period_type = :pt', { pt: query.periodType });
|
||||
if (query.metric) qb.andWhere('target.metric = :m', { m: query.metric });
|
||||
if (query.dimension) qb.andWhere('target.dimension = :d', { d: query.dimension });
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
new Brackets((w) =>
|
||||
w
|
||||
.where('target.dimension_key ILIKE :s', { s: `%${query.search}%` })
|
||||
.orWhere('target.note ILIKE :s', { s: `%${query.search}%` }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const [page, yards] = await Promise.all([paginateQuery(qb, query), this.yardLabels()]);
|
||||
return { ...page, items: page.items.map((t) => this.toRow(t, yards)) };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<OperationsTarget> {
|
||||
const found = await this.repository.findOne({ where: { id } });
|
||||
if (!found) throw new NotFoundException(`Operations target ${id} not found`);
|
||||
return found;
|
||||
}
|
||||
|
||||
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
|
||||
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart);
|
||||
const cargoCategory = dto.cargoCategory ?? null;
|
||||
await this.assertSlotFree({ ...dto, periodStart, cargoCategory });
|
||||
return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory }));
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> {
|
||||
const current = await this.findById(id);
|
||||
const periodType = dto.periodType ?? current.periodType;
|
||||
const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart);
|
||||
const next = {
|
||||
periodType,
|
||||
periodStart,
|
||||
metric: dto.metric ?? current.metric,
|
||||
dimension: dto.dimension ?? current.dimension,
|
||||
dimensionKey: dto.dimensionKey ?? current.dimensionKey,
|
||||
cargoCategory:
|
||||
dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null,
|
||||
};
|
||||
await this.assertSlotFree(next, id);
|
||||
|
||||
await this.repository.update(id, {
|
||||
...next,
|
||||
...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}),
|
||||
...(dto.note !== undefined ? { note: dto.note } : {}),
|
||||
});
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* One planned number per (period, metric, dimension value). The database
|
||||
* enforces this too — the check is here to turn a 23505 into a message that
|
||||
* says which slot is taken.
|
||||
*/
|
||||
private async assertSlotFree(
|
||||
slot: Pick<
|
||||
OperationsTarget,
|
||||
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory'
|
||||
>,
|
||||
ignoreId?: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.repository.findOne({
|
||||
where: {
|
||||
periodType: slot.periodType,
|
||||
periodStart: slot.periodStart,
|
||||
metric: slot.metric,
|
||||
dimension: slot.dimension,
|
||||
dimensionKey: slot.dimensionKey,
|
||||
cargoCategory: slot.cargoCategory ?? IsNull(),
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(
|
||||
`A ${slot.metric} target for ${slot.dimensionKey} in the ${slot.periodType} starting ${slot.periodStart} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
ACTUAL_TONS_EXPR,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_FILTER,
|
||||
CATEGORY_LABEL_OF,
|
||||
COUNTRY_FILTER,
|
||||
LOADED_WAGONS_EXPR,
|
||||
OPS_DATE,
|
||||
OPERATIONS_FILTERS,
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
applyCategoryFilter,
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
implementRateExpr,
|
||||
plannedRowsParams,
|
||||
plannedRowsSql,
|
||||
} from '../operations-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
|
||||
|
||||
/** The two sides of the line. Anything else is ignored rather than interpolated. */
|
||||
const COUNTRIES = ['Ethiopia', 'Djibouti'];
|
||||
|
||||
const countryOf = (params: Record<string, unknown>): string | null => {
|
||||
const value = String(params.country ?? '');
|
||||
return COUNTRIES.includes(value) ? value : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which end of the corridor this report calls "the station".
|
||||
*
|
||||
* With a country chosen it is that country's end — the Ethiopian view lists
|
||||
* GMP, Modjo, Adama and the rest; the Djibouti view lists DMP, DCT and Nagad,
|
||||
* which is the second format the spec asks for. With no country chosen it is
|
||||
* the destination, so the report still reads sensibly.
|
||||
*
|
||||
* The country is whitelisted above before it reaches the SQL: it arrives as a
|
||||
* filter value, and a CASE expression cannot take a bound parameter here
|
||||
* because the same expression has to appear verbatim in the GROUP BY.
|
||||
*/
|
||||
const stationExpr = (params: Record<string, unknown>, column: string): string => {
|
||||
const country = countryOf(params);
|
||||
if (!country) return `dy.${column}`;
|
||||
return `CASE WHEN oy.country = '${country}' THEN oy.${column} ELSE dy.${column} END`;
|
||||
};
|
||||
|
||||
/** The other end of the same corridor — the spec's "origination" column. */
|
||||
const originationExpr = (params: Record<string, unknown>, column: string): string => {
|
||||
const country = countryOf(params);
|
||||
if (!country) return `oy.${column}`;
|
||||
return `CASE WHEN oy.country = '${country}' THEN dy.${column} ELSE oy.${column} END`;
|
||||
};
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx);
|
||||
applyCategoryFilter(qb, ctx.params);
|
||||
|
||||
const country = countryOf(ctx.params);
|
||||
// Only corridors that touch the chosen side have a station on it.
|
||||
if (country) {
|
||||
qb.andWhere('(oy.country = :sideCountry OR dy.country = :sideCountry)', {
|
||||
sideCountry: country,
|
||||
});
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const cargoVolumeByStationReport: ReportDefinition = {
|
||||
key: 'cargo-volume-by-station',
|
||||
title: 'Cargo Volume by Station',
|
||||
description:
|
||||
'Tonnage by station and cargo type against plan. Choose a country to switch between ' +
|
||||
'the Ethiopian view (GMP, Modjo, Dire Dawa, Adama, Sebeta) and the Djibouti view ' +
|
||||
'(DMP, DCT, Nagad), which changes which end of the corridor counts as the station and ' +
|
||||
'which counts as the origination. Plan comes from Operational targets, keyed on the ' +
|
||||
'station’s yard code.' +
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
group: 'Operations',
|
||||
filters: [PERIOD_FILTER, COUNTRY_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
||||
{ key: 'origination', label: 'Origination', type: 'string' },
|
||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true },
|
||||
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
|
||||
{ key: 'plan', label: 'Plan', type: 'tons' },
|
||||
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'wagons', label: 'Wagons', type: 'number' },
|
||||
{ key: 'trains', label: 'Trains', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'operated', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'station', y: ['operated'] },
|
||||
query(ctx) {
|
||||
const { params } = ctx;
|
||||
const bucket = periodTruncExprOn(OPS_DATE, params);
|
||||
const stationCode = stationExpr(params, 'code');
|
||||
|
||||
const operated = baseQuery(ctx)
|
||||
.select(periodExprOn(OPS_DATE, params), 'period')
|
||||
.addSelect(stationCode, 'station_code')
|
||||
.addSelect(`COALESCE(${stationExpr(params, 'label')}, ${stationCode}, '?')`, 'station')
|
||||
.addSelect(
|
||||
`COALESCE(${originationExpr(params, 'label')}, ${originationExpr(params, 'code')}, '?')`,
|
||||
'origination',
|
||||
)
|
||||
.addSelect(CARGO_CATEGORY_EXPR, 'category_key')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
|
||||
.groupBy(bucket)
|
||||
.addGroupBy(stationCode)
|
||||
.addGroupBy(stationExpr(params, 'label'))
|
||||
.addGroupBy(originationExpr(params, 'label'))
|
||||
.addGroupBy(originationExpr(params, 'code'))
|
||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||
|
||||
// A station plan is keyed on station AND cargo type, so the join needs
|
||||
// both. Full outer, so a station-and-cargo line that was planned and never
|
||||
// ran still reports its miss — the OCC report is full of those.
|
||||
const combined = `
|
||||
SELECT COALESCE(o.period, p.period) AS period,
|
||||
COALESCE(o.station_code, p.plan_key) AS station_code,
|
||||
COALESCE(o.station,
|
||||
(SELECT y2.label FROM freight.yards y2
|
||||
WHERE y2.code = p.plan_key AND y2.deleted_at IS NULL LIMIT 1),
|
||||
p.plan_key) AS station,
|
||||
COALESCE(o.origination, '—') AS origination,
|
||||
COALESCE(o.category_key, p.plan_category) AS category_key,
|
||||
COALESCE(o.operated, 0) AS operated,
|
||||
COALESCE(o.teu, 0) AS teu,
|
||||
COALESCE(o.wagons, 0) AS wagons,
|
||||
COALESCE(o.trains, 0) AS trains,
|
||||
p.plan_value AS plan
|
||||
FROM (${operated.getQuery()}) o
|
||||
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p
|
||||
ON p.period = o.period
|
||||
AND p.plan_key = o.station_code
|
||||
AND p.plan_category = o.category_key`;
|
||||
|
||||
return ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(`(${combined})`, 'r')
|
||||
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) })
|
||||
.select('r.period', 'period')
|
||||
.addSelect('r.station', 'station')
|
||||
.addSelect('r.origination', 'origination')
|
||||
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
|
||||
.addSelect('r.category_key', 'categoryKey')
|
||||
.addSelect('r.operated::float8', 'operated')
|
||||
.addSelect('r.plan::float8', 'plan')
|
||||
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
|
||||
.addSelect('r.teu::int', 'teu')
|
||||
.addSelect('r.wagons::int', 'wagons')
|
||||
.addSelect('r.trains::int', 'trains');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
|
||||
.addSelect(`COUNT(DISTINCT ${stationExpr(ctx.params, 'code')})::int`, 'stations')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.getRawOne<{ actual: number; stations: number; teu: number }>();
|
||||
|
||||
return [
|
||||
{ label: 'Volume', value: Number(row?.actual ?? 0), unit: 't' },
|
||||
{ label: 'Stations', value: Number(row?.stations ?? 0) },
|
||||
{ label: 'TEU', value: Number(row?.teu ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
ACTUAL_TONS_EXPR,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_FILTER,
|
||||
CATEGORY_LABEL_OF,
|
||||
CHARGED_TONS_EXPR,
|
||||
LOADED_WAGONS_EXPR,
|
||||
OPS_DATE,
|
||||
OPERATIONS_FILTERS,
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
applyCategoryFilter,
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
implementRateExpr,
|
||||
plannedRowsParams,
|
||||
plannedRowsSql,
|
||||
} from '../operations-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx);
|
||||
applyCategoryFilter(qb, ctx.params);
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const cargoVolumePerformanceReport: ReportDefinition = {
|
||||
key: 'cargo-volume-performance',
|
||||
title: 'Cargo Volume Performance',
|
||||
description:
|
||||
'Tonnage moved per cargo category against plan. Operated is the actual loaded weight ' +
|
||||
'from the marshalling record; charged volume is the standard weight capacity the same ' +
|
||||
'cargo is billed on. Plan comes from Operational targets and is measured against the ' +
|
||||
'actual, not the charged, tonnage.' +
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
group: 'Operations',
|
||||
filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'category', label: 'Cargo category', type: 'string', sortable: true },
|
||||
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
|
||||
{ key: 'plan', label: 'Plan', type: 'tons' },
|
||||
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
|
||||
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
|
||||
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
|
||||
{ key: 'trains', label: 'Trains', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'operated', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'category', y: ['operated'] },
|
||||
query(ctx) {
|
||||
const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
|
||||
const operated = baseQuery(ctx)
|
||||
.select(periodExprOn(OPS_DATE, ctx.params), 'period')
|
||||
.addSelect(CARGO_CATEGORY_EXPR, 'category_key')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated')
|
||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged_tons')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
|
||||
.groupBy(bucket)
|
||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||
|
||||
// Full outer join so a planned cargo category that moved nothing still
|
||||
// reports its miss instead of disappearing from the table.
|
||||
const combined = `
|
||||
SELECT COALESCE(o.period, p.period) AS period,
|
||||
COALESCE(o.category_key, p.plan_key) AS category_key,
|
||||
COALESCE(o.operated, 0) AS operated,
|
||||
COALESCE(o.charged_tons, 0) AS charged_tons,
|
||||
COALESCE(o.teu, 0) AS teu,
|
||||
COALESCE(o.wagons, 0) AS wagons,
|
||||
COALESCE(o.trains, 0) AS trains,
|
||||
p.plan_value AS plan
|
||||
FROM (${operated.getQuery()}) o
|
||||
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) p
|
||||
ON p.period = o.period AND p.plan_key = o.category_key`;
|
||||
|
||||
return ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(`(${combined})`, 'r')
|
||||
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
|
||||
.select('r.period', 'period')
|
||||
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
|
||||
.addSelect('r.category_key', 'categoryKey')
|
||||
.addSelect('r.operated::float8', 'operated')
|
||||
.addSelect('r.plan::float8', 'plan')
|
||||
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
|
||||
.addSelect('r.charged_tons::float8', 'chargedTons')
|
||||
.addSelect('r.teu::int', 'teu')
|
||||
.addSelect('r.wagons::int', 'wagons')
|
||||
.addSelect('r.trains::int', 'trains');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
|
||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.getRawOne<{ actual: number; charged: number; teu: number }>();
|
||||
|
||||
return [
|
||||
{ label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' },
|
||||
{ label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' },
|
||||
{ label: 'TEU', value: Number(row?.teu ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
ACTUAL_TONS_EXPR,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_FILTER,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
CHARGED_TONS_EXPR,
|
||||
LOADED_WAGONS_EXPR,
|
||||
OPERATIONS_FILTERS,
|
||||
SCHEDULE_EMPTY_WAGONS,
|
||||
SCHEDULE_KM_EXPR,
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
applyCategoryFilter,
|
||||
} from '../operations-classification';
|
||||
|
||||
/**
|
||||
* Distance and empty-wagon count belong to the departure, so they are constant
|
||||
* within a group that includes `ts.id` — MAX() satisfies Postgres without
|
||||
* dragging a scalar subselect through the GROUP BY.
|
||||
*/
|
||||
const ROUTE_KM = `MAX(${SCHEDULE_KM_EXPR})`;
|
||||
const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
|
||||
|
||||
/**
|
||||
* Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no
|
||||
* configured distance. A missing distance is not a zero distance, and zeroing
|
||||
* it would understate the corridor's work without anyone noticing.
|
||||
*/
|
||||
const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${ROUTE_KM}, 1)::float8`;
|
||||
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${ROUTE_KM}, 1)::float8`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx);
|
||||
applyCategoryFilter(qb, ctx.params);
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
key: 'charged-vs-actual-volume',
|
||||
title: 'Charged and Actual Volumes',
|
||||
description:
|
||||
'Charged versus actual volume per train and cargo type, with Ton/Km and Vehicle-Km. ' +
|
||||
'Charged volume is the standard weight capacity — 20 and 40 tons per laden container, ' +
|
||||
'2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for perishables — ' +
|
||||
'all editable in Operating standards. Actual volume is what the marshalling recorded. ' +
|
||||
'Vehicle-Km counts the empty wagons on that train, so it repeats across the train’s ' +
|
||||
'cargo types rather than being split between them.',
|
||||
group: 'Operations',
|
||||
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
|
||||
{ key: 'station', label: 'Station', type: 'string' },
|
||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
|
||||
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
|
||||
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
|
||||
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
||||
{ key: 'distanceKm', label: 'Distance (km)', type: 'number' },
|
||||
{ key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true },
|
||||
{ key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'departedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
|
||||
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt')
|
||||
.addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station')
|
||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
||||
.addSelect(`${ROUTE_KM}::float8`, 'distanceKm')
|
||||
.addSelect(TON_KM, 'tonKm')
|
||||
.addSelect(VEHICLE_KM, 'vehicleKm')
|
||||
.groupBy('ts.id')
|
||||
.addGroupBy('ts.train_number')
|
||||
.addGroupBy('ts.actual_departure_at')
|
||||
.addGroupBy('ts.scheduled_departure_date')
|
||||
.addGroupBy('oy.label')
|
||||
.addGroupBy('oy.code')
|
||||
.addGroupBy('dy.label')
|
||||
.addGroupBy('dy.code')
|
||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'actual')
|
||||
.addSelect(
|
||||
`COUNT(DISTINCT ts.id) FILTER (WHERE ${SCHEDULE_KM_EXPR} IS NULL)::int`,
|
||||
'unmeasuredTrains',
|
||||
)
|
||||
.getRawOne<{ charged: number; actual: number; unmeasuredTrains: number }>();
|
||||
|
||||
return [
|
||||
{ label: 'Charged volume', value: Number(row?.charged ?? 0), unit: 't' },
|
||||
{ label: 'Actual volume', value: Number(row?.actual ?? 0), unit: 't' },
|
||||
// Always shown, even at zero: a corridor with no configured distance
|
||||
// silently drops out of Ton/Km, and that must be visible.
|
||||
{ label: 'Trains without a configured distance', value: Number(row?.unmeasuredTrains ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
PAID_SHARE,
|
||||
PAYMENT_CLASSES,
|
||||
PAYMENT_CLASS_EXPR,
|
||||
PAYMENT_CLASS_LABEL_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
currencyOf,
|
||||
periodExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = revenueLedgerQb(ctx);
|
||||
const classes = ctx.params.classes as string[] | null;
|
||||
if (classes?.length) {
|
||||
qb.andWhere(`${PAYMENT_CLASS_EXPR} IN (:...classes)`, { classes });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const paymentClassificationReport: ReportDefinition = {
|
||||
key: 'payment-classification',
|
||||
title: 'Payment Classification',
|
||||
description:
|
||||
'What customers actually paid for, per period: rail transport, customs clearance, ' +
|
||||
'first/last mile, overweight, cancellation, demurrage, storage, loading and unloading, ' +
|
||||
'and additional charges. Note there is no dedicated loading/unloading charge type in ' +
|
||||
'the system — handling and double-handling fees stand in for it.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
PERIOD_FILTER,
|
||||
...REVENUE_FILTERS,
|
||||
{ key: 'classes', label: 'Payment class', type: 'multiselect', options: PAYMENT_CLASSES },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'paymentClass', label: 'Payment class', type: 'string', sortable: true },
|
||||
{ key: 'billed', label: 'Billed', type: 'money', sortable: true },
|
||||
{ key: 'settled', label: 'Settled', type: 'money', sortable: true },
|
||||
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
|
||||
{ key: 'lines', label: 'Lines', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'billed', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'paymentClass', y: ['billed'] },
|
||||
query(ctx) {
|
||||
const period = periodExpr(ctx.params);
|
||||
return baseQuery(ctx)
|
||||
.select(period, 'period')
|
||||
.addSelect(PAYMENT_CLASS_LABEL_EXPR, 'paymentClass')
|
||||
.addSelect('ROUND(SUM(il.amount))::float8', 'billed')
|
||||
.addSelect(`ROUND(SUM(${PAID_SHARE}))::float8`, 'settled')
|
||||
.addSelect(`ROUND(SUM(il.amount) - SUM(${PAID_SHARE}))::float8`, 'outstanding')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
.groupBy(period)
|
||||
.addGroupBy(PAYMENT_CLASS_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'billed')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'settled')
|
||||
.getRawOne<{ billed: number; settled: number }>();
|
||||
const currency = currencyOf(ctx.params);
|
||||
const billed = Number(row?.billed ?? 0);
|
||||
const settled = Number(row?.settled ?? 0);
|
||||
return [
|
||||
{ label: 'Billed', value: billed, unit: currency },
|
||||
{ label: 'Settled', value: settled, unit: currency },
|
||||
{ label: 'Outstanding', value: Math.round(billed - settled), unit: currency },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types';
|
||||
import {
|
||||
PAYER_EXPR,
|
||||
REVENUE_DATE,
|
||||
REVENUE_FILTERS,
|
||||
currencyOf,
|
||||
invoiceLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
export const LEDGER_SIDES: ReportFilterOption[] = [
|
||||
{ value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' },
|
||||
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' },
|
||||
{ value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' },
|
||||
{ value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' },
|
||||
{ value: 'SETTLED', label: 'Settled' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Which side of the ledger an invoice sits on.
|
||||
*
|
||||
* Receivable = EDR delivered and is owed money — the shipping-line credit
|
||||
* arrangement, plus any invoice still carrying a balance.
|
||||
* Payable = the customer paid for something EDR did not deliver, so the money
|
||||
* is a refund liability rather than revenue: cancellation fees, and prepaid
|
||||
* invoices whose booking died.
|
||||
*/
|
||||
const SIDE_EXPR = `CASE
|
||||
WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT'
|
||||
THEN 'RECEIVABLE_CREDIT'
|
||||
WHEN i.type = 'WAGON_CANCEL_FEE' THEN 'PAYABLE_CANCELLATION'
|
||||
WHEN i.paid_amount > 0 AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED')
|
||||
THEN 'PAYABLE_UNDELIVERED'
|
||||
WHEN i.balance_amount > 0 THEN 'RECEIVABLE_OPEN'
|
||||
ELSE 'SETTLED'
|
||||
END`;
|
||||
|
||||
const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label]));
|
||||
const SIDE_LABEL_EXPR = `CASE ${SIDE_EXPR}
|
||||
${[...LABELS].map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`).join('\n ')}
|
||||
END`;
|
||||
|
||||
/** Money at stake on this row: what is owed, or what may have to be given back. */
|
||||
const EXPOSURE = `CASE
|
||||
WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount
|
||||
ELSE i.balance_amount
|
||||
END`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = invoiceLedgerQb(ctx);
|
||||
const sides = ctx.params.sides as string[] | null;
|
||||
if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const receivablesPayablesReport: ReportDefinition = {
|
||||
key: 'receivables-payables',
|
||||
title: 'Receivables and Payables',
|
||||
description:
|
||||
'Splits customer money two ways: receivable, where EDR delivered and is owed — ' +
|
||||
'including shipping-line credit services — and payable, where the customer paid but ' +
|
||||
'the service was not delivered, such as cancellation fees and prepayments against ' +
|
||||
'dead bookings. Payable amounts are a refund liability, not revenue.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'),
|
||||
{ key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR },
|
||||
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
|
||||
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
|
||||
{ key: 'bookingRef', label: 'Booking', type: 'string' },
|
||||
{ key: 'bookingStatus', label: 'Booking status', type: 'string' },
|
||||
{ key: 'customer', label: 'Payer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
|
||||
{ key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' },
|
||||
{ key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' },
|
||||
{ key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE },
|
||||
],
|
||||
defaultSort: { key: 'exposure', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'side', y: ['exposure'] },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(SIDE_LABEL_EXPR, 'side')
|
||||
.addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
|
||||
.addSelect('i.invoice_number', 'invoiceNumber')
|
||||
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
||||
.addSelect("COALESCE(b.status, '—')", 'bookingStatus')
|
||||
.addSelect(PAYER_EXPR, 'customer')
|
||||
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced')
|
||||
.addSelect('ROUND(i.paid_amount, 2)::float8', 'paid')
|
||||
.addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(
|
||||
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`,
|
||||
'receivable',
|
||||
)
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`,
|
||||
'payable',
|
||||
)
|
||||
.addSelect('COUNT(*)::int', 'invoices')
|
||||
.getRawOne<{ receivable: number; payable: number; invoices: number }>();
|
||||
const currency = currencyOf(ctx.params);
|
||||
return [
|
||||
{ label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency },
|
||||
{ label: 'Payable', value: Number(row?.payable ?? 0), unit: currency },
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
CATEGORY_LABEL_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
growthPctExpr,
|
||||
periodExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
const REVENUE = 'SUM(il.amount)';
|
||||
|
||||
const THRESHOLDS = [
|
||||
{ value: '10', label: '±10%' },
|
||||
{ value: '25', label: '±25%' },
|
||||
{ value: '50', label: '±50%' },
|
||||
];
|
||||
|
||||
const thresholdOf = (params: Record<string, unknown>): number => {
|
||||
const raw = Number(params.threshold);
|
||||
return THRESHOLDS.some((t) => Number(t.value) === raw) ? raw : 25;
|
||||
};
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueAnomaliesReport: ReportDefinition = {
|
||||
key: 'revenue-anomalies',
|
||||
title: 'Revenue Anomalies',
|
||||
description:
|
||||
'Periods where a revenue category moved more than the chosen threshold against the ' +
|
||||
'previous period. Pull-based on purpose: this surfaces the spikes and drops for review ' +
|
||||
'rather than paging anyone, so the thresholds can be tuned against real numbers first.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
PERIOD_FILTER,
|
||||
{ key: 'threshold', label: 'Threshold', type: 'select', options: THRESHOLDS },
|
||||
...REVENUE_FILTERS,
|
||||
],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true },
|
||||
{ key: 'direction', label: 'Movement', type: 'string' },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'priorRevenue', label: 'Prior period', type: 'money' },
|
||||
{ key: 'growthPct', label: 'Change', type: 'percent', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'period', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
const period = periodExpr(ctx.params);
|
||||
// ORDER BY the same expression this query GROUPs BY — the to_char label, not
|
||||
// the inner date_trunc. Ordering by the unwrapped timestamp raises
|
||||
// "column i.issued_at must appear in the GROUP BY clause". The label formats
|
||||
// are zero-padded, so lexicographic order is chronological order.
|
||||
const prior = `lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`;
|
||||
const change = growthPctExpr(REVENUE, prior);
|
||||
|
||||
const inner = baseQuery(ctx)
|
||||
.select(period, 'period')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect(`ROUND(${REVENUE})::float8`, 'revenue')
|
||||
.addSelect(`ROUND(COALESCE(${prior}, 0))::float8`, 'priorRevenue')
|
||||
.addSelect(change, 'growthPct')
|
||||
.addSelect(
|
||||
`CASE WHEN ${REVENUE} >= COALESCE(${prior}, 0) THEN 'Spike' ELSE 'Drop' END`,
|
||||
'direction',
|
||||
)
|
||||
.groupBy(period)
|
||||
.addGroupBy(REVENUE_CATEGORY_EXPR);
|
||||
|
||||
// The threshold cannot live in WHERE or HAVING — both are evaluated before
|
||||
// window functions, and `growthPct` is one. Wrapping is the only place the
|
||||
// comparison is legal. A period with no predecessor yields NULL, and
|
||||
// `ABS(NULL) >= n` is NULL, so those rows drop out without an extra guard.
|
||||
return ctx.ds
|
||||
.createQueryBuilder()
|
||||
.select('a.*')
|
||||
.from(`(${inner.getQuery()})`, 'a')
|
||||
.setParameters(inner.getParameters())
|
||||
.where('ABS(a."growthPct") >= :threshold', { threshold: thresholdOf(ctx.params) });
|
||||
},
|
||||
|
||||
async summary(ctx) {
|
||||
const [sql, params] = revenueAnomaliesReport.query(ctx).getQueryAndParameters();
|
||||
const rows: Array<{ spikes: number; drops: number }> = await ctx.ds.query(
|
||||
`SELECT COUNT(*) FILTER (WHERE a.direction = 'Spike')::int AS spikes,
|
||||
COUNT(*) FILTER (WHERE a.direction = 'Drop')::int AS drops
|
||||
FROM (${sql}) a`,
|
||||
params,
|
||||
);
|
||||
return [
|
||||
{ label: 'Spikes', value: Number(rows[0]?.spikes ?? 0) },
|
||||
{ label: 'Drops', value: Number(rows[0]?.drops ?? 0) },
|
||||
{ label: 'Threshold', value: thresholdOf(ctx.params), unit: '%' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
AVG_PER_UNIT_EXPR,
|
||||
CATEGORY_LABEL_EXPR,
|
||||
CONTAINERS_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
TEU_EXPR,
|
||||
TONS_EXPR,
|
||||
UNIT_LABEL_EXPR,
|
||||
currencyOf,
|
||||
growthPctExpr,
|
||||
periodExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
const REVENUE = 'SUM(il.amount)';
|
||||
|
||||
/**
|
||||
* Previous period's revenue for the same category.
|
||||
*
|
||||
* Postgres evaluates window functions after GROUP BY, so `lag(SUM(...))` is
|
||||
* legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the
|
||||
* ORDER BY must repeat their grouping expressions verbatim: ordering by the
|
||||
* inner `date_trunc` when the group key is the `to_char` wrapper fails, and
|
||||
* ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window
|
||||
* clause, silently producing an unordered partition.
|
||||
*/
|
||||
const priorRevenue = (period: string): string =>
|
||||
`lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`;
|
||||
|
||||
const growthPct = (period: string): string => growthPctExpr(REVENUE, priorRevenue(period));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueByCategoryReport: ReportDefinition = {
|
||||
key: 'revenue-by-category',
|
||||
title: 'Revenue by Category',
|
||||
description:
|
||||
'Billed revenue in the twelve rail revenue categories, per period, with volume and ' +
|
||||
'period-over-period growth. Growth compares against the previous period inside the ' +
|
||||
'selected date range, so the earliest period always reads zero. ' +
|
||||
'Multimodal means a named sea carrier is on the booking.',
|
||||
group: 'Finance',
|
||||
filters: [PERIOD_FILTER, ...REVENUE_FILTERS],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'priorRevenue', label: 'Prior period', type: 'money' },
|
||||
{ key: 'growthPct', label: 'Growth', type: 'percent' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
|
||||
{ key: 'containers', label: 'Containers', type: 'number' },
|
||||
{ key: 'avgPerUnit', label: 'Avg revenue/unit', type: 'money' },
|
||||
{ key: 'unit', label: 'Unit', type: 'string' },
|
||||
{ key: 'lines', label: 'Lines', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'category', y: ['revenue'] },
|
||||
/**
|
||||
* Row click opens the transaction list for exactly this bucket.
|
||||
*
|
||||
* `period` carries into `period_value` (the bucket, e.g. "2026-08"), NOT into
|
||||
* `period` — that filter is the granularity, and handing it a date string
|
||||
* would silently reset it to monthly. The granularity itself rides along
|
||||
* from the filters already applied.
|
||||
*
|
||||
* `categoryKey` rather than `category`: the visible column holds the business
|
||||
* label, and the target filters on the key.
|
||||
*/
|
||||
drill: {
|
||||
to: 'revenue-transactions',
|
||||
carry: { period: 'period_value', categoryKey: 'categoryKey' },
|
||||
},
|
||||
query(ctx) {
|
||||
const period = periodExpr(ctx.params);
|
||||
return baseQuery(ctx)
|
||||
.select(period, 'period')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect(`ROUND(${REVENUE})::float8`, 'revenue')
|
||||
.addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue')
|
||||
.addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct')
|
||||
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
|
||||
.addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers')
|
||||
.addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit')
|
||||
.addSelect(UNIT_LABEL_EXPR, 'unit')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
.groupBy(period)
|
||||
.addGroupBy(REVENUE_CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${REVENUE_CATEGORY_EXPR} = 'UNCLASSIFIED'), 0))::float8`,
|
||||
'unclassified',
|
||||
)
|
||||
.addSelect(`COUNT(DISTINCT ${REVENUE_CATEGORY_EXPR})::int`, 'categories')
|
||||
// Invoice-level balance is deliberately NOT summed here — the ledger is
|
||||
// at line grain, so a 3-line invoice would count its balance three times.
|
||||
// Outstanding lives on `revenue-reconciliation`, at invoice grain.
|
||||
.getRawOne<{ revenue: number; unclassified: number; categories: number }>();
|
||||
|
||||
const currency = currencyOf(ctx.params);
|
||||
return [
|
||||
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency },
|
||||
{ label: 'Categories', value: Number(row?.categories ?? 0) },
|
||||
// Always shown, even at zero: an audit report must never quietly drop money.
|
||||
{ label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
TEU_EXPR,
|
||||
TONS_EXPR,
|
||||
currencyOf,
|
||||
growthPctExpr,
|
||||
nextPeriodOrdinalExpr,
|
||||
periodExpr,
|
||||
periodOrdinalExpr,
|
||||
periodTruncExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
const REVENUE = 'SUM(il.amount)';
|
||||
|
||||
/**
|
||||
* A six-period rolling linear trend, projected one period ahead.
|
||||
*
|
||||
* `regr_slope`/`regr_intercept` are Postgres built-ins, so this needs no
|
||||
* dependency and no model store. It is a trend line, not a forecast model: no
|
||||
* seasonality, no confidence interval, and meaningless on fewer than about
|
||||
* four points — hence the row-count guard, which returns NULL rather than a
|
||||
* confident-looking number drawn through two dots.
|
||||
*
|
||||
* The x variable is the period's epoch seconds, not `row_number()`: Postgres
|
||||
* rejects a window function nested inside another window function's arguments
|
||||
* ("window function calls cannot be nested"), and the timestamp is already a
|
||||
* monotonic ordinal.
|
||||
*
|
||||
* ponytail: linear trend only. A seasonal model (Holt-Winters/ARIMA) means a
|
||||
* stats dependency, a training story and an owner for model quality — do that
|
||||
* only if the business names a seasonality requirement.
|
||||
*/
|
||||
const forecastNext = (params: Record<string, unknown>): string => {
|
||||
const window = `OVER (ORDER BY ${periodTruncExpr(params)} ROWS BETWEEN 5 PRECEDING AND CURRENT ROW)`;
|
||||
const x = periodOrdinalExpr(params);
|
||||
return `CASE WHEN count(*) ${window} >= 4 THEN GREATEST(0, ROUND(
|
||||
(regr_intercept(${REVENUE}, ${x}) ${window})
|
||||
+ (regr_slope(${REVENUE}, ${x}) ${window}) * ${nextPeriodOrdinalExpr(params)}
|
||||
))::float8 END`;
|
||||
};
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueByPeriodReport: ReportDefinition = {
|
||||
key: 'revenue-by-period',
|
||||
title: 'Revenue Trend',
|
||||
description:
|
||||
'Total billed revenue per period with period-over-period growth and a rolling ' +
|
||||
'six-period linear projection. The projection is a trend line, not a seasonal ' +
|
||||
'forecast, and is blank until six periods of history exist.',
|
||||
group: 'Finance',
|
||||
filters: [PERIOD_FILTER, ...REVENUE_FILTERS],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'priorRevenue', label: 'Prior period', type: 'money' },
|
||||
{ key: 'growthPct', label: 'Growth', type: 'percent' },
|
||||
{ key: 'forecastNext', label: 'Projected next', type: 'money' },
|
||||
{ key: 'categories', label: 'Categories', type: 'number' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons' },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'lines', label: 'Lines', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'period', dir: 'ASC' },
|
||||
chart: { type: 'line', x: 'period', y: ['revenue', 'forecastNext'] },
|
||||
drill: { to: 'revenue-transactions', carry: { period: 'period_value' } },
|
||||
query(ctx) {
|
||||
const period = periodExpr(ctx.params);
|
||||
const trunc = periodTruncExpr(ctx.params);
|
||||
const prior = `lag(${REVENUE}) OVER (ORDER BY ${trunc})`;
|
||||
return baseQuery(ctx)
|
||||
.select(period, 'period')
|
||||
.addSelect(`ROUND(${REVENUE})::float8`, 'revenue')
|
||||
.addSelect(`ROUND(COALESCE(${prior}, 0))::float8`, 'priorRevenue')
|
||||
.addSelect(`COALESCE(${growthPctExpr(REVENUE, prior)}, 0)`, 'growthPct')
|
||||
.addSelect(forecastNext(ctx.params), 'forecastNext')
|
||||
.addSelect(`COUNT(DISTINCT ${REVENUE_CATEGORY_EXPR})::int`, 'categories')
|
||||
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
// Grouped by the period's start timestamp, so the ordering the windows
|
||||
// above use is a grouping key rather than a bare column reference.
|
||||
.groupBy(trunc);
|
||||
},
|
||||
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect(`COUNT(DISTINCT ${periodTruncExpr(ctx.params)})::int`, 'periods')
|
||||
.getRawOne<{ revenue: number; periods: number }>();
|
||||
const periods = Number(row?.periods ?? 0);
|
||||
const revenue = Number(row?.revenue ?? 0);
|
||||
return [
|
||||
{ label: 'Total revenue', value: revenue, unit: currencyOf(ctx.params) },
|
||||
{ label: 'Periods', value: periods },
|
||||
{
|
||||
label: 'Average per period',
|
||||
value: periods ? Math.round(revenue / periods) : 0,
|
||||
unit: currencyOf(ctx.params),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
AVG_PER_UNIT_EXPR,
|
||||
CATEGORY_LABEL_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
TEU_EXPR,
|
||||
TONS_EXPR,
|
||||
UNIT_LABEL_EXPR,
|
||||
currencyOf,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
/**
|
||||
* There is no corridor entity in the schema — a corridor IS an
|
||||
* (origin_yard, destination_yard) pair, which is exactly how rates are scoped.
|
||||
*/
|
||||
const CORRIDOR = `COALESCE(oy.label, 'Unknown') || ' → ' || COALESCE(dy.label, 'Unknown')`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueByRouteReport: ReportDefinition = {
|
||||
key: 'revenue-by-route',
|
||||
title: 'Revenue by Route',
|
||||
description:
|
||||
'Billed revenue per corridor and revenue category. A corridor is an ' +
|
||||
'origin/destination station pair — the schema has no separate corridor entity.',
|
||||
group: 'Finance',
|
||||
filters: [PERIOD_FILTER, ...REVENUE_FILTERS],
|
||||
columns: [
|
||||
{ key: 'corridor', label: 'Corridor', type: 'string', sortable: true, sortExpr: CORRIDOR },
|
||||
{ key: 'origin', label: 'Origin', type: 'string' },
|
||||
{ key: 'destination', label: 'Destination', type: 'string' },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
|
||||
{ key: 'avgPerUnit', label: 'Avg revenue/unit', type: 'money' },
|
||||
{ key: 'unit', label: 'Unit', type: 'string' },
|
||||
{ key: 'lines', label: 'Lines', type: 'number' },
|
||||
],
|
||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'corridor', y: ['revenue'] },
|
||||
drill: { to: 'revenue-transactions', carry: { categoryKey: 'categoryKey' } },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(CORRIDOR, 'corridor')
|
||||
.addSelect("COALESCE(oy.label, 'Unknown')", 'origin')
|
||||
.addSelect("COALESCE(dy.label, 'Unknown')", 'destination')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect('ROUND(SUM(il.amount))::float8', 'revenue')
|
||||
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
|
||||
.addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit')
|
||||
.addSelect(UNIT_LABEL_EXPR, 'unit')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
.groupBy(CORRIDOR)
|
||||
.addGroupBy('oy.label')
|
||||
.addGroupBy('dy.label')
|
||||
.addGroupBy(REVENUE_CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect(`COUNT(DISTINCT ${CORRIDOR})::int`, 'corridors')
|
||||
.getRawOne<{ revenue: number; corridors: number }>();
|
||||
return [
|
||||
{ label: 'Corridors', value: Number(row?.corridors ?? 0) },
|
||||
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
GATEWAY_PAID,
|
||||
PAYER_EXPR,
|
||||
REVENUE_DATE,
|
||||
REVENUE_FILTERS,
|
||||
currencyOf,
|
||||
invoiceLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
/**
|
||||
* The gap between what the invoice says was paid and what the payment gateway
|
||||
* recorded. Non-zero is not automatically wrong — manual settlements are real
|
||||
* — but every one of them should be explainable, which is the point.
|
||||
*/
|
||||
const VARIANCE = `i.paid_amount - ${GATEWAY_PAID}`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = invoiceLedgerQb(ctx);
|
||||
const matched = ctx.params.matched as string | null;
|
||||
if (matched === 'matched') qb.andWhere(`ROUND(${VARIANCE}, 2) = 0`);
|
||||
if (matched === 'unmatched') qb.andWhere(`ROUND(${VARIANCE}, 2) <> 0`);
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const revenueReconciliationReport: ReportDefinition = {
|
||||
key: 'revenue-reconciliation',
|
||||
title: 'Revenue vs Payment Reconciliation',
|
||||
description:
|
||||
'Every invoice with its recorded settlement set against what the payment gateway ' +
|
||||
'actually confirmed. Invoices are matched on the booking id both sides carry — ' +
|
||||
'invoices.payment_id points at a payment-service intent, not a freight payment row. ' +
|
||||
'Invoices with no booking (warehouse, shipping-line credit) have no gateway record to ' +
|
||||
'match against and will show their full paid amount as variance.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'),
|
||||
{
|
||||
key: 'matched',
|
||||
label: 'Reconciliation',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'unmatched', label: 'Variance only' },
|
||||
{ value: 'matched', label: 'Reconciled only' },
|
||||
],
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
|
||||
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
|
||||
{ key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
|
||||
{ key: 'source', label: 'Source', type: 'string', sortable: true, sortExpr: 'i.source' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' },
|
||||
{ key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' },
|
||||
{ key: 'recordedPaid', label: 'Recorded paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' },
|
||||
{ key: 'gatewayPaid', label: 'Gateway paid', type: 'money' },
|
||||
{ key: 'variance', label: 'Variance', type: 'money', sortable: true, sortExpr: `ABS(${VARIANCE})` },
|
||||
{ key: 'balance', label: 'Outstanding', type: 'money', sortable: true, sortExpr: 'i.balance_amount' },
|
||||
],
|
||||
defaultSort: { key: 'variance', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
|
||||
.addSelect('i.invoice_number', 'invoiceNumber')
|
||||
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
||||
.addSelect(PAYER_EXPR, 'customer')
|
||||
.addSelect('i.source', 'source')
|
||||
.addSelect('i.status', 'status')
|
||||
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced')
|
||||
.addSelect('ROUND(i.paid_amount, 2)::float8', 'recordedPaid')
|
||||
.addSelect(`ROUND(${GATEWAY_PAID}, 2)::float8`, 'gatewayPaid')
|
||||
.addSelect(`ROUND(${VARIANCE}, 2)::float8`, 'variance')
|
||||
.addSelect('ROUND(i.balance_amount, 2)::float8', 'balance');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'invoices')
|
||||
.addSelect(`COUNT(*) FILTER (WHERE ROUND(${VARIANCE}, 2) <> 0)::int`, 'withVariance')
|
||||
.addSelect(`ROUND(COALESCE(SUM(ABS(${VARIANCE})), 0))::float8`, 'variance')
|
||||
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
|
||||
.getRawOne<{ invoices: number; withVariance: number; variance: number; outstanding: number }>();
|
||||
const currency = currencyOf(ctx.params);
|
||||
return [
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
{ label: 'With variance', value: Number(row?.withVariance ?? 0) },
|
||||
{ label: 'Total variance', value: Number(row?.variance ?? 0), unit: currency },
|
||||
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: currency },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
CATEGORY_LABEL_EXPR,
|
||||
PAYER_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
TEU_EXPR,
|
||||
TONS_EXPR,
|
||||
currencyOf,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueTopCustomersReport: ReportDefinition = {
|
||||
key: 'revenue-top-customers',
|
||||
title: 'Top Customers by Revenue',
|
||||
description:
|
||||
'Customers ranked by billed revenue, with their category mix and outstanding share. ' +
|
||||
'The payer is the company or, for shipping-line credit invoices, the shipping line — ' +
|
||||
'an invoice carries exactly one of the two.',
|
||||
group: 'Finance',
|
||||
filters: [PERIOD_FILTER, ...REVENUE_FILTERS],
|
||||
columns: [
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{ key: 'sharePct', label: 'Share of total', type: 'percent' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons' },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'invoices', label: 'Invoices', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'customer', y: ['revenue'] },
|
||||
drill: { to: 'revenue-transactions', carry: { customer: 'customer', categoryKey: 'categoryKey' } },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(PAYER_EXPR, 'customer')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect('ROUND(SUM(il.amount))::float8', 'revenue')
|
||||
// Share of the whole filtered set, not of the page — a window over no
|
||||
// partition sees every group the query produced.
|
||||
.addSelect(
|
||||
'ROUND(100 * SUM(il.amount) / NULLIF(SUM(SUM(il.amount)) OVER (), 0), 1)::float8',
|
||||
'sharePct',
|
||||
)
|
||||
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
|
||||
.addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
|
||||
.groupBy(PAYER_EXPR)
|
||||
.addGroupBy(REVENUE_CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
|
||||
.getRawOne<{ revenue: number; customers: number }>();
|
||||
return [
|
||||
{ label: 'Customers', value: Number(row?.customers ?? 0) },
|
||||
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
PAYMENT_CLASS_EXPR,
|
||||
PAYER_EXPR,
|
||||
PERIOD_FILTER,
|
||||
REVENUE_CATEGORIES,
|
||||
REVENUE_CATEGORY_EXPR,
|
||||
REVENUE_DATE,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
currencyOf,
|
||||
periodExpr,
|
||||
revenueLedgerQb,
|
||||
} from '../revenue-classification';
|
||||
|
||||
/**
|
||||
* The gateway payment behind an invoice, for traceability. `invoices.payment_id`
|
||||
* points at a payment-api intent id rather than a `freight.payments` row, so the
|
||||
* reliable link is `payments.ref_id = invoices.source_id` (the booking id).
|
||||
*
|
||||
* Correlated scalar subselects rather than a LATERAL join: TypeORM's query
|
||||
* builder cannot emit LATERAL, and the correlation on `i.source_id` is what
|
||||
* makes this work at all. Successful payments win, then most recent.
|
||||
*
|
||||
* `::text` is not cosmetic — `payments.method` and `payments.status` are real
|
||||
* Postgres enums, so `COALESCE(<enum>, '')` fails with
|
||||
* `invalid input value for enum freight.payments_method_enum: ""`.
|
||||
*/
|
||||
const latestPayment = (column: string): string => `(
|
||||
SELECT p.${column}::text FROM freight.payments p
|
||||
WHERE p.ref_id = i.source_id
|
||||
ORDER BY (p.status = 'success') DESC, p.created_at DESC
|
||||
LIMIT 1
|
||||
)`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = revenueLedgerQb(ctx);
|
||||
|
||||
// Drill-down target: the summary reports hand over the exact period bucket
|
||||
// and category key they were showing.
|
||||
if (params.period_value) {
|
||||
qb.andWhere(`${periodExpr(params)} = :periodValue`, { periodValue: params.period_value });
|
||||
}
|
||||
if (params.categoryKey) {
|
||||
qb.andWhere(`${REVENUE_CATEGORY_EXPR} = :categoryKey`, { categoryKey: params.categoryKey });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const revenueTransactionsReport: ReportDefinition = {
|
||||
key: 'revenue-transactions',
|
||||
title: 'Revenue Transactions',
|
||||
description:
|
||||
'Every billed revenue line, at transaction level — booking reference, invoice number, ' +
|
||||
'charge type, cargo, quantity and the payment reference behind it. This is the ' +
|
||||
'drill-down target for the revenue summaries and the audit trail for an export.',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
PERIOD_FILTER,
|
||||
...REVENUE_FILTERS,
|
||||
{ key: 'period_value', label: 'Period bucket', type: 'text' },
|
||||
{
|
||||
key: 'categoryKey',
|
||||
label: 'Category (exact)',
|
||||
type: 'select',
|
||||
options: REVENUE_CATEGORIES,
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
|
||||
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
|
||||
{ key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
|
||||
{ key: 'bookingId', label: 'Booking ID', type: 'string' },
|
||||
{ key: 'payer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
|
||||
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true, sortExpr: REVENUE_CATEGORY_EXPR },
|
||||
{ key: 'paymentClass', label: 'Payment class', type: 'string' },
|
||||
{ key: 'chargeType', label: 'Charge type', type: 'string', sortable: true, sortExpr: 'il.charge_type' },
|
||||
{ key: 'cargo', label: 'Cargo', type: 'string' },
|
||||
{ key: 'route', label: 'Route', type: 'string' },
|
||||
{ key: 'quantity', label: 'Qty', type: 'number' },
|
||||
{ key: 'unit', label: 'Unit', type: 'string' },
|
||||
{ key: 'unitRate', label: 'Unit rate', type: 'money' },
|
||||
{ key: 'amount', label: 'Amount', type: 'money', sortable: true, sortExpr: 'il.amount' },
|
||||
{ key: 'currency', label: 'Currency', type: 'string' },
|
||||
{ key: 'invoiceStatus', label: 'Invoice status', type: 'string', sortable: true, sortExpr: 'i.status' },
|
||||
{ key: 'paymentRef', label: 'Payment ref', type: 'string' },
|
||||
{ key: 'paymentMethod', label: 'Method', type: 'string' },
|
||||
{ key: 'paymentStatus', label: 'Payment status', type: 'string' },
|
||||
],
|
||||
defaultSort: { key: 'issuedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
|
||||
.addSelect('i.invoice_number', 'invoiceNumber')
|
||||
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
|
||||
.addSelect("COALESCE(b.id::text, '')", 'bookingId')
|
||||
.addSelect(PAYER_EXPR, 'payer')
|
||||
.addSelect(REVENUE_CATEGORY_EXPR, 'category')
|
||||
.addSelect(PAYMENT_CLASS_EXPR, 'paymentClass')
|
||||
.addSelect('il.charge_type', 'chargeType')
|
||||
.addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '—')", 'cargo')
|
||||
.addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", 'route')
|
||||
.addSelect('il.quantity::float8', 'quantity')
|
||||
.addSelect("COALESCE(il.metadata->>'unit', '')", 'unit')
|
||||
.addSelect('ROUND(il.unit_rate, 2)::float8', 'unitRate')
|
||||
.addSelect('ROUND(il.amount, 2)::float8', 'amount')
|
||||
.addSelect('il.currency', 'currency')
|
||||
.addSelect('i.status', 'invoiceStatus')
|
||||
.addSelect(
|
||||
`COALESCE(${latestPayment('transaction_id')}, ${latestPayment('merchant_order_id')}, '')`,
|
||||
'paymentRef',
|
||||
)
|
||||
.addSelect(`COALESCE(${latestPayment('method')}, '')`, 'paymentMethod')
|
||||
.addSelect(`COALESCE(${latestPayment('status')}, '')`, 'paymentStatus');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(REVENUE_SUM, 'revenue')
|
||||
.addSelect('COUNT(*)::int', 'lines')
|
||||
.addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
|
||||
.getRawOne<{ revenue: number; lines: number; invoices: number }>();
|
||||
return [
|
||||
{ label: 'Lines', value: Number(row?.lines ?? 0) },
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.entity';
|
||||
import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
COUNTRY_FILTER,
|
||||
DIRECTION_FILTER,
|
||||
OPS_DATE,
|
||||
STANDARDS_JOIN,
|
||||
STATION_STANDARD_HOURS_EXPR,
|
||||
hoursBetween,
|
||||
} from '../operations-classification';
|
||||
|
||||
/**
|
||||
* A stay is an ARRIVED followed by the next DEPARTED at the same station by the
|
||||
* same physical train — NOT by the same schedule.
|
||||
*
|
||||
* When a train turns around at a station the two halves belong to different
|
||||
* departures: the arrival closes the inbound schedule and the departure opens
|
||||
* the outbound one. Pairing within a schedule finds only pass-through stops and
|
||||
* silently drops every turnaround, which is the longest stay a train makes.
|
||||
*/
|
||||
const TRAIN_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)';
|
||||
const STAY_WINDOW = `PARTITION BY ${TRAIN_KEY}, ev.yard_id ORDER BY ev.occurred_at`;
|
||||
|
||||
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
|
||||
const STANDARD_HOURS = 's.standard_hours';
|
||||
|
||||
/** Every logged stop, with the event that followed it at the same station. */
|
||||
function stopsQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(TrainCheckpointEvent, 'ev')
|
||||
.innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL')
|
||||
.leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL')
|
||||
.innerJoin(Yard, 'y', 'y.id = ev.yard_id')
|
||||
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
|
||||
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
|
||||
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
|
||||
.where('ev.deleted_at IS NULL')
|
||||
.andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')")
|
||||
.select('ts.train_number', 'train_number')
|
||||
.addSelect("COALESCE(y.label, y.code, '—')", 'station')
|
||||
.addSelect("COALESCE(y.country, '—')", 'country')
|
||||
.addSelect('ev.kind', 'kind')
|
||||
.addSelect('ev.occurred_at', 'arrived_at')
|
||||
.addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at')
|
||||
.addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind')
|
||||
.addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours')
|
||||
.addSelect("COALESCE(ev.note, '')", 'note');
|
||||
|
||||
if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo });
|
||||
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
|
||||
if (params.trainNumber) {
|
||||
qb.andWhere('ts.train_number ILIKE :trainNumber', {
|
||||
trainNumber: `%${params.trainNumber as string}%`,
|
||||
});
|
||||
}
|
||||
if (params.station) qb.andWhere('y.code = :station', { station: params.station });
|
||||
if (params.country) qb.andWhere('y.country = :country', { country: params.country });
|
||||
|
||||
applyDirectionScope(qb, 'ts.direction', directions);
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** Only completed stops — an arrival whose departure was also logged. */
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const inner = stopsQuery(ctx);
|
||||
return ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(`(${inner.getQuery()})`, 's')
|
||||
.setParameters(inner.getParameters())
|
||||
.where("s.kind = 'ARRIVED'")
|
||||
.andWhere("s.next_kind = 'DEPARTED'");
|
||||
}
|
||||
|
||||
export const stationStayingTimeReport: ReportDefinition = {
|
||||
key: 'station-staying-time',
|
||||
title: 'Station Staying Time',
|
||||
description:
|
||||
'How long each train stood at each station — the logged arrival to the same train’s ' +
|
||||
'next departure from that station — against the standard for that side of the line ' +
|
||||
'(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' +
|
||||
'standard needs a reason. Loading and unloading times are not split out: nothing in ' +
|
||||
'the system records when they start and end yet.',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||
DIRECTION_FILTER,
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
|
||||
{ key: 'station', label: 'Station', type: 'text' },
|
||||
COUNTRY_FILTER,
|
||||
],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 's.train_number' },
|
||||
{ key: 'station', label: 'Station', type: 'string', sortable: true, sortExpr: 's.station' },
|
||||
{ key: 'country', label: 'Country', type: 'string' },
|
||||
{ key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' },
|
||||
{ key: 'departedAt', label: 'Departed', type: 'date' },
|
||||
{ key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS },
|
||||
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
|
||||
{ key: 'varianceHours', label: 'Variance (hrs)', type: 'number' },
|
||||
{ key: 'verdict', label: 'Verdict', type: 'string' },
|
||||
{ key: 'reason', label: 'Reason', type: 'string' },
|
||||
],
|
||||
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select("COALESCE(s.train_number, '—')", 'trainNumber')
|
||||
.addSelect('s.station', 'station')
|
||||
.addSelect('s.country', 'country')
|
||||
.addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
|
||||
.addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||
.addSelect(STAYING_HOURS, 'stayingHours')
|
||||
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
|
||||
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
|
||||
.addSelect(
|
||||
`CASE WHEN (${STAYING_HOURS})::numeric <= ${STANDARD_HOURS}
|
||||
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||
'verdict',
|
||||
)
|
||||
// The note staff leave on the checkpoint is the only free text on a stop,
|
||||
// so it is where a reason for an over-standard stay is recorded today.
|
||||
.addSelect('s.note', 'reason');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'stops')
|
||||
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
|
||||
'overStandard',
|
||||
)
|
||||
.getRawOne<{ stops: number; avgHours: number; overStandard: number }>();
|
||||
|
||||
return [
|
||||
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
||||
{ label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
||||
{ label: 'Over standard', value: Number(row?.overStandard ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
CONTAINER_CLASSES,
|
||||
CONTAINER_CLASS_EXPR,
|
||||
CONTAINER_CLASS_LABEL_OF,
|
||||
CONTAINERS_EXPR,
|
||||
OPS_DATE,
|
||||
OPERATIONS_FILTERS,
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
implementRateExpr,
|
||||
plannedRowsParams,
|
||||
plannedRowsSql,
|
||||
} from '../operations-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
|
||||
|
||||
const CONTAINERS_20 = `COALESCE(SUM((
|
||||
SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
|
||||
LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id
|
||||
WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL
|
||||
AND cty.size_ft = 20)), 0)::int`;
|
||||
|
||||
const CONTAINERS_40 = `COALESCE(SUM((
|
||||
SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
|
||||
LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id
|
||||
WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL
|
||||
AND cty.size_ft >= 40)), 0)::int`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx).andWhere("wba.load_type = 'CONTAINER'");
|
||||
const classes = ctx.params.classes as string[] | null;
|
||||
if (classes?.length) {
|
||||
qb.andWhere(`${CONTAINER_CLASS_EXPR} IN (:...classes)`, { classes });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const teuPerformanceReport: ReportDefinition = {
|
||||
key: 'teu-performance',
|
||||
title: 'TEU Performance',
|
||||
description:
|
||||
'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' +
|
||||
'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' +
|
||||
'marshalling record — the containers actually allocated to wagons — not from the ' +
|
||||
'billing lines. Plan comes from Operational targets.' +
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
PERIOD_FILTER,
|
||||
...OPERATIONS_FILTERS,
|
||||
{ key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'containerClass', label: 'Container type', type: 'string', sortable: true },
|
||||
{ key: 'containers20', label: '20ft', type: 'number', sortable: true },
|
||||
{ key: 'containers40', label: '40ft', type: 'number', sortable: true },
|
||||
{ key: 'containers', label: 'Containers', type: 'number', sortable: true },
|
||||
{ key: 'operated', label: 'Operated (TEU)', type: 'number', sortable: true },
|
||||
{ key: 'plan', label: 'Plan', type: 'number' },
|
||||
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
|
||||
],
|
||||
defaultSort: { key: 'operated', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'containerClass', y: ['operated'] },
|
||||
query(ctx) {
|
||||
const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
|
||||
const operated = baseQuery(ctx)
|
||||
.select(periodExprOn(OPS_DATE, ctx.params), 'period')
|
||||
.addSelect(CONTAINER_CLASS_EXPR, 'class_key')
|
||||
.addSelect(CONTAINERS_20, 'containers20')
|
||||
.addSelect(CONTAINERS_40, 'containers40')
|
||||
.addSelect(CONTAINERS_EXPR, 'containers')
|
||||
.addSelect(TEU_EXPR, 'operated')
|
||||
.groupBy(bucket)
|
||||
.addGroupBy(CONTAINER_CLASS_EXPR);
|
||||
|
||||
// Full outer join so a planned container class that never moved still
|
||||
// reports, at zero rather than vanishing.
|
||||
const combined = `
|
||||
SELECT COALESCE(o.period, p.period) AS period,
|
||||
COALESCE(o.class_key, p.plan_key) AS class_key,
|
||||
COALESCE(o.containers20, 0) AS containers20,
|
||||
COALESCE(o.containers40, 0) AS containers40,
|
||||
COALESCE(o.containers, 0) AS containers,
|
||||
COALESCE(o.operated, 0) AS operated,
|
||||
p.plan_value AS plan
|
||||
FROM (${operated.getQuery()}) o
|
||||
FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) p
|
||||
ON p.period = o.period AND p.plan_key = o.class_key`;
|
||||
|
||||
return ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(`(${combined})`, 'r')
|
||||
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
|
||||
.select('r.period', 'period')
|
||||
.addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass')
|
||||
.addSelect('r.class_key', 'containerClassKey')
|
||||
.addSelect('r.containers20::int', 'containers20')
|
||||
.addSelect('r.containers40::int', 'containers40')
|
||||
.addSelect('r.containers::int', 'containers')
|
||||
.addSelect('r.operated::int', 'operated')
|
||||
.addSelect('r.plan::float8', 'plan')
|
||||
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(TEU_EXPR, 'teu')
|
||||
.addSelect(CONTAINERS_EXPR, 'containers')
|
||||
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
|
||||
.getRawOne<{ teu: number; containers: number; trains: number }>();
|
||||
|
||||
return [
|
||||
{ label: 'TEU', value: Number(row?.teu ?? 0) },
|
||||
{ label: 'Containers', value: Number(row?.containers ?? 0) },
|
||||
{ label: 'Trains', value: Number(row?.trains ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
DELAY_TOLERANCE_HOURS_EXPR,
|
||||
DIRECTION_FILTER,
|
||||
hoursBetween,
|
||||
legStandardHours,
|
||||
scheduleLedgerQb,
|
||||
} from '../operations-classification';
|
||||
|
||||
const ACTUAL_HOURS = hoursBetween('ts.actual_departure_at', 'ts.actual_arrival_at');
|
||||
const STANDARD_HOURS = legStandardHours('ts.origin_station_id', 'ts.destination_station_id');
|
||||
const DELAY_HOURS = `ROUND((${ACTUAL_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`;
|
||||
const IS_DELAYED = `(${ACTUAL_HOURS})::numeric > ${STANDARD_HOURS} + ${DELAY_TOLERANCE_HOURS_EXPR}`;
|
||||
|
||||
/**
|
||||
* The note staff left when they logged the arrival — the only free text on the
|
||||
* leg, and so the only place a delay reason is recorded today.
|
||||
*/
|
||||
const ARRIVAL_NOTE = `(
|
||||
SELECT e.note FROM freight.train_checkpoint_events e
|
||||
WHERE e.train_schedule_id = ts.id AND e.deleted_at IS NULL
|
||||
AND e.kind = 'ARRIVED' AND e.note IS NOT NULL
|
||||
ORDER BY e.occurred_at DESC LIMIT 1
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Leg running time against the corridor standard.
|
||||
*
|
||||
* The leg measured is the departure's own origin → destination, on actual
|
||||
* timestamps. Per-station legs would be finer, but only the corridor ends carry
|
||||
* a configured standard (`yard_distances.standard_hours`), which is what a
|
||||
* delay is judged against.
|
||||
*/
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = scheduleLedgerQb(ctx)
|
||||
.andWhere('ts.actual_departure_at IS NOT NULL')
|
||||
.andWhere('ts.actual_arrival_at IS NOT NULL');
|
||||
|
||||
if (ctx.params.delayedOnly === 'true') qb.andWhere(IS_DELAYED);
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const trainDelaysReport: ReportDefinition = {
|
||||
key: 'train-delays',
|
||||
title: 'Train Delays',
|
||||
description:
|
||||
'Actual running time per leg against the corridor standard — 21h Negad→GMP and the ' +
|
||||
'per-pair figures configured on Yard Distances, with the default and the tolerance ' +
|
||||
'(30 min) in Operating standards. A leg over standard plus tolerance is flagged and ' +
|
||||
'needs a reason.',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||
DIRECTION_FILTER,
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
|
||||
{
|
||||
key: 'delayedOnly',
|
||||
label: 'Delayed only',
|
||||
type: 'select',
|
||||
options: [{ value: 'true', label: 'Delayed legs only' }],
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'origin', label: 'From', type: 'string' },
|
||||
{ key: 'destination', label: 'To', type: 'string' },
|
||||
{ key: 'departedAt', label: 'Departed', type: 'date', sortable: true, sortExpr: 'ts.actual_departure_at' },
|
||||
{ key: 'arrivedAt', label: 'Arrived', type: 'date' },
|
||||
{ key: 'actualHours', label: 'Actual (hrs)', type: 'number', sortable: true, sortExpr: ACTUAL_HOURS },
|
||||
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
|
||||
{ key: 'delayHours', label: 'Delay (hrs)', type: 'number', sortable: true, sortExpr: DELAY_HOURS },
|
||||
{ key: 'status', label: 'Status', type: 'string' },
|
||||
{ key: 'reason', label: 'Reason', type: 'string' },
|
||||
],
|
||||
defaultSort: { key: 'departedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
|
||||
.addSelect("COALESCE(oy.label, oy.code, '?')", 'origin')
|
||||
.addSelect("COALESCE(dy.label, dy.code, '?')", 'destination')
|
||||
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
|
||||
.addSelect(ACTUAL_HOURS, 'actualHours')
|
||||
.addSelect(`ROUND(${STANDARD_HOURS}, 1)::float8`, 'standardHours')
|
||||
.addSelect(DELAY_HOURS, 'delayHours')
|
||||
.addSelect(`CASE WHEN ${IS_DELAYED} THEN 'Delayed' ELSE 'On time' END`, 'status')
|
||||
.addSelect(`COALESCE(${ARRIVAL_NOTE}, '')`, 'reason');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'legs')
|
||||
.addSelect(`COUNT(*) FILTER (WHERE ${IS_DELAYED})::int`, 'delayed')
|
||||
.addSelect(`ROUND(AVG((${ACTUAL_HOURS})::numeric), 1)::float8`, 'avgHours')
|
||||
.getRawOne<{ legs: number; delayed: number; avgHours: number }>();
|
||||
|
||||
return [
|
||||
{ label: 'Legs', value: Number(row?.legs ?? 0) },
|
||||
{ label: 'Delayed', value: Number(row?.delayed ?? 0) },
|
||||
{ label: 'Average running time', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
||||
];
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user