Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-13 14:14:31 +00:00
219 changed files with 13585 additions and 5559 deletions

View File

@@ -172,14 +172,23 @@ EIMS_SELLER_LOCALITY=
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
# locally, naming the missing variables, until these are set.
# Required, and deliberately unset: the choice is a tax position, not a default.
# Required, and deliberately unset here: the choice is a tax position, not a default.
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env.
EIMS_TAX_CODE=
EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_WITHHOLD_VALUE=0
# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a
# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material.
# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above.
# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types.
EIMS_TAX_CODE_BY_CHARGE_TYPE=
EIMS_TAX_RATE_BY_CHARGE_TYPE=
# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively.
EIMS_EXCISE_BY_CHARGE_TYPE=
EIMS_DISCOUNT_BY_CHARGE_TYPE=
# Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.

View File

@@ -69,6 +69,7 @@
"cross-env": "^10.1.0",
"dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"exceljs": "^4.4.0",
"handlebars": "^4.7.9",
"jose": "^5.10.0",
"libphonenumber-js": "^1.13.6",

View File

@@ -50,6 +50,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { SupportContentModule } from "./modules/support-content/support-content.module";
import { OtpModule } from "./modules/otp/otp.module";
@@ -211,6 +212,7 @@ if (!process.env.APPLICATION_NAME) {
DropdownSettingsModule,
ExchangeSettingsModule,
StampSettingsModule,
LogoSettingsModule,
ContractTemplatesModule,
SupportContentModule,
OtpModule,

View File

@@ -65,10 +65,40 @@ describe("RequestLogMiddleware", () => {
originalUrl: "/api/bookings/1/submit?dry=1",
baseUrl: "/api/bookings",
route: { path: "/:id/submit" },
headers: { "user-agent": "jest", "x-request-id": "req-42" },
headers: {
"user-agent": "jest",
"x-request-id": "req-42",
authorization: "Bearer tok",
"x-client-app": "freight-backoffice",
"current-project-id": "proj-3",
},
ip: "10.0.0.1",
query: { dry: "1" },
user: { id: "u-7" },
user: {
id: "u-7",
sessionId: "sess-9",
userType: "STAFF",
status: "ACTIVE",
username: "nati",
email: "nati@example.com",
phoneNumber: "0911000000",
name: { en: "Nati" },
roles: [{ key: "freight_operations" }],
permissions: [{ key: "a" }, { key: "b" }],
employee: {
id: "emp-1",
organizationId: "org-1",
unitId: "unit-2",
position: {
id: "pos-5",
key: "ops_officer",
employeePositionId: "ep-6",
isDelegate: true,
delegatorId: "pos-1",
positionType: { key: "operations" },
},
},
},
};
const res = {
statusCode: 409,
@@ -105,6 +135,29 @@ describe("RequestLogMiddleware", () => {
bookingId: "b-1",
booking: { outcome: "REJECTED" },
});
expect(JSON.parse(lines[0]).auth).toEqual({
authenticated: true,
hasBearer: true,
clientApp: "freight-backoffice",
userId: "u-7",
sessionId: "sess-9",
userType: "STAFF",
userStatus: "ACTIVE",
roles: ["freight_operations"],
permissionCount: 2,
employeeId: "emp-1",
organizationId: "org-1",
unitId: "unit-2",
positionId: "pos-5",
positionKey: "ops_officer",
positionType: "operations",
employeePositionId: "ep-6",
isDelegate: true,
delegatorId: "pos-1",
projectId: "proj-3",
});
// No personal data reaches the line, whatever the token carried.
expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/);
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
jest.restoreAllMocks();
});

View File

@@ -89,8 +89,30 @@ export interface EimsInvoiceConfig {
buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
/**
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
* `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax
* treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above
* cannot express that. Values are raw strings; the context builder parses/validates them.
*/
taxCodeByChargeType: Record<string, string>;
taxRateByChargeType: Record<string, string>;
/** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge
* types not listed fall back to `exciseTaxValue` / 0 respectively. */
exciseByChargeType: Record<string, string>;
discountByChargeType: Record<string, string>;
cashierName: string | null;
salesPersonName: string | null;
/**
* TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every
* buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be
* one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead
* of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits.
* Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists.
*/
buyerIdType: string | null;
buyerIdNumber: string | null;
}
const REQUIRED_VARS = [
@@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => {
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE),
cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null,
buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null,
},
};

View File

@@ -10,6 +10,7 @@ import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pric
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service';
import { LogoSettingsService } from '../modules/logo-settings/logo-settings.service';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
export interface ContractSignatureView {
@@ -111,6 +112,8 @@ export interface ContractViewModel {
hasCustomerSignature: boolean;
hasStaffSignature: boolean;
dynamicTemplate?: ContractDynamicTemplateView;
/** Company logo for the cover-page header (LogoSettingsService); null renders the "EDR" mark. */
logoImageUrl?: string | null;
}
@Injectable()
@@ -121,6 +124,7 @@ export class ContractViewModelBuilder {
private readonly pricingBuilder: ContractPricingScheduleBuilder,
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
private readonly stampSettings: StampSettingsService,
private readonly logoSettings: LogoSettingsService,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
@@ -138,6 +142,7 @@ export class ContractViewModelBuilder {
template.freight,
);
const signatures = await this.loadSignatures(bookingId);
const logoImageUrl = await this.logoSettings.getLogoImageUrl();
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
@@ -194,6 +199,7 @@ export class ContractViewModelBuilder {
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
logoImageUrl,
};
return { booking, view };

View File

@@ -77,6 +77,12 @@
letter-spacing: 0.08em;
width: 72px;
}
.logo-mark img {
display: block;
max-height: 100%;
max-width: 100%;
object-fit: contain;
}
.kicker {
color: #0e5b45;
font-family: Arial, sans-serif;

View File

@@ -11,7 +11,7 @@
{{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Services</p>

View File

@@ -9,7 +9,7 @@
<main class="contract">
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Contract</p>

View File

@@ -9,6 +9,7 @@
main { padding: 32px 40px; }
.brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; }
.logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; }
.logo-mark img { display: block; max-height: 32px; max-width: 100px; object-fit: contain; }
.kicker { margin: 0; font-weight: 700; }
.muted { margin: 0; color: #666; }
h1 { font-size: 20px; margin: 24px 0 4px; }
@@ -32,7 +33,7 @@
<body>
<main>
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Last-Mile Delivery Contract</p>

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed
* live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as
* `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-`
* prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already
* accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING`
* and the system-wide reservation stuck in-flight with no block/alert (see
* `EimsInvoiceRegistrationService` for the accompanying code fix).
*
* Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or
* length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's
* real production shape — guessing another fixed bound risks the exact same failure again.
*/
export class WidenEimsIrn3450000000000 implements MigrationInterface {
name = "WidenEimsIrn3450000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ALTER COLUMN eims_irn TYPE text
`);
}
/** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ALTER COLUMN eims_irn TYPE varchar(64)
`);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */
export class AddEimsSignedQr3460000000000 implements MigrationInterface {
name = "AddEimsSignedQr3460000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_signed_qr text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_signed_qr
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */
export class EimsCancellation3470000000000 implements MigrationInterface {
name = "EimsCancellation3470000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz,
ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64),
ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8),
ADD COLUMN IF NOT EXISTS eims_cancellation_remark text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_cancelled_at,
DROP COLUMN IF EXISTS eims_cancellation_date,
DROP COLUMN IF EXISTS eims_cancellation_reason_code,
DROP COLUMN IF EXISTS eims_cancellation_remark
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too
* (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same
* varchar(64) on the next successful registration.
*/
export class WidenPreviousIrn3480000000000 implements MigrationInterface {
name = "WidenPreviousIrn3480000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ALTER COLUMN previous_irn TYPE text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ALTER COLUMN previous_irn TYPE varchar(64)
`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/** `freight.eims_receipts` — see `EimsReceipt` entity. */
export class EimsReceipts3490000000000 implements MigrationInterface {
name = "EimsReceipts3490000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.eims_receipts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id uuid NOT NULL REFERENCES freight.invoices(id),
kind varchar(16) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
receipt_number varchar(64) NOT NULL,
rrn text,
qr text,
ack_status varchar(8),
submitted_at timestamptz,
last_error jsonb,
request jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Single-row table holding the one company logo image stamped onto every
* generated document (see LogoSettingsService). Same single-row shape as
* stamp_settings; the app never inserts more than one row.
*/
export class LogoSettings3500000000000 implements MigrationInterface {
name = "LogoSettings3500000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.logo_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
logo_file_id uuid REFERENCES freight.files(id),
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* A train schedule can be dedicated to one shipping line.
*
* NULL = a normal train, visible and bookable to customers as before. Set =
* the departure exists for that shipping line alone: it is excluded from every
* customer-facing read (booking windows, day pools, portal home cards) and
* surfaces only in the assigned line's portal (home page + booking detail).
*/
export class TrainScheduleShippingLine3510000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
REFERENCES freight.shipping_line_companies (id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_shipping_line_company_id
ON freight.train_schedules (shipping_line_company_id)
WHERE shipping_line_company_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_train_schedules_shipping_line_company_id
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS shipping_line_company_id
`);
}
}

View File

@@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
});
@@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
return { service, mg, events };
}
@@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
return { service, mg, events };
}
@@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never,
{} as never,
{} as never, // config
);
return { service, defaultManager, txManager, transaction };
};
@@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never,
{} as never,
{} as never, // config
);
return { service, manager };
};
@@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never,
{} as never,
{} as never,
{} as never, // config
);
return { service, repo };
};
@@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never,
{} as never,
{} as never,
{} as never, // config
);
return { service, repo };
};
@@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
});
});
});
describe("BillingService.document", () => {
const invoiceRow = (over: Record<string, unknown> = {}) => ({
id: "inv-1",
invoiceNumber: "INV-20260812-00001",
source: "booking",
sourceId: "booking-1",
status: Freight.InvoiceStatus.Pending,
type: "freight",
currency: "ETB",
subtotalAmount: 100,
taxAmount: 0,
totalAmount: 100,
paidAmount: 0,
balanceAmount: 100,
issuedAt: new Date(2026, 7, 12),
dueAt: new Date(2026, 7, 19),
eimsIrn: null,
eimsSignedQr: null,
company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" },
...over,
});
const build = (invoice: Record<string, unknown>) => {
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
const service = new BillingService(
{} as never,
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
{ findAll: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{} as never,
{} as never,
{ render } as never,
{} as never,
{
get: (key: string) =>
key === "eims"
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
: undefined,
} as never, // config
);
return { service, render };
};
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined();
expect(model.qrImageUrl).toBeNull();
});
it("shows the buyer's name, TIN and VAT number on every invoice", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" });
expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" });
expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" });
});
it("omits the VAT row when the buyer company has none", async () => {
const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } }));
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined();
});
it("shows EDR's own seller TIN and VAT number from EIMS config", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" });
expect(model.summary).toContainEqual({
label: "Seller VAT No.",
value: "43256663343256663322",
});
});
it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => {
const { service, render } = build(
invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }),
);
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
});
});

View File

@@ -1,4 +1,5 @@
import { Freight, PaymentReferenceType } from "@edr/types";
import { ConfigService } from "@nestjs/config";
import {
BadRequestException,
forwardRef,
@@ -12,6 +13,7 @@ import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
@@ -172,6 +174,7 @@ export class BillingService {
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService,
private readonly config: ConfigService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -395,7 +398,7 @@ export class BillingService {
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
await this.toDocumentModel(invoice, "INVOICE"),
);
}
@@ -408,15 +411,50 @@ export class BillingService {
);
}
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
await this.toDocumentModel(invoice, "RECEIPT"),
);
}
/**
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
*/
private renderEimsQr(signedQr: string): string {
return `data:image/png;base64,${signedQr}`;
}
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
private async bookingSummaryRows(
invoice: Invoice,
): Promise<InvoiceDocumentModel["summary"]> {
if (invoice.source !== Freight.InvoiceSource.Booking) return [];
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
relations: { originYard: true, destinationYard: true },
});
if (!booking) return [];
return [
{
label: "Route",
value:
booking.originYard && booking.destinationYard
? `${booking.originYard.label}${booking.destinationYard.label}`
: null,
},
{
label: "Wagons",
value:
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
},
];
}
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
private toDocumentModel(
private async toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel {
): Promise<InvoiceDocumentModel> {
const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR";
@@ -434,6 +472,44 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
const summary: InvoiceDocumentModel["summary"] = [
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
// filed against, not just the seller. VatNumber shown only when the company has one.
{ label: "Buyer", value: invoice.company?.name ?? null },
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
...(invoice.company?.vatNumber
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
: []),
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
...(await this.bookingSummaryRows(invoice)),
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
];
// Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this
// codebase). Shown only when actually configured, same as the buyer VAT row.
const eimsCfg = this.config.get<EimsConfig>("eims");
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
if (eimsCfg?.invoice?.sellerVatNumber) {
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
}
// MoR EIMS reference — only once actually registered, never a placeholder row.
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
return {
kind,
title,
@@ -441,24 +517,7 @@ export class BillingService {
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
],
summary,
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
@@ -469,6 +528,7 @@ export class BillingService {
currency: l.currency,
})),
totals,
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
};
}

View File

@@ -0,0 +1,46 @@
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
kind: "INVOICE",
title: "Freight",
documentNumber: "INV-20260812-00001",
issuedAt: new Date(2026, 7, 12),
status: "PENDING",
currency: "ETB",
summary: [{ label: "Status", value: "PENDING" }],
lines: [],
totals: [{ label: "Total", amount: 100, grand: true }],
...over,
});
describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
it("renders no QR block when qrImageUrl is unset", () => {
const html = service.buildHtml(model());
expect(html).not.toContain('class="qr"');
});
it("renders the QR image when qrImageUrl is set", () => {
const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
expect(html).toContain('class="qr"');
expect(html).toContain('src="data:image/png;base64,QR"');
});
it("still shows the IRN text row via the ordinary summary grid", () => {
const html = service.buildHtml(
model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }),
);
expect(html).toContain("EIMS IRN");
expect(html).toContain("IRN-123");
});
it("widens the summary's right margin only when a QR is present, to clear the QR block", () => {
// "summary-with-qr" also appears in the always-present <style> rule, so the check has to be
// the actual div's class attribute, not a bare substring match.
expect(service.buildHtml(model())).not.toContain('class="summary summary-with-qr"');
expect(service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }))).toContain(
'class="summary summary-with-qr"',
);
});
});

View File

@@ -1,8 +1,10 @@
import { Injectable } from "@nestjs/common";
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
import { LogoSettingsService } from "../../logo-settings/logo-settings.service";
import { PdfRenderService } from "./pdf-render.service";
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
import { logoImageCss, logoMarkup } from "./logo-markup.util";
import {
PdfColor,
assembleSinglePagePdf,
@@ -62,6 +64,13 @@ export interface InvoiceDocumentModel {
* explicitly only to override that default for one document.
*/
stampImageUrl?: string | null;
logoImageUrl?: string | null;
/**
* MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` —
* see that column's comment). Set only once an invoice is actually registered; the IRN text
* itself goes through the ordinary `summary` rows, not a dedicated field.
*/
qrImageUrl?: string | null;
}
/**
@@ -75,6 +84,7 @@ export class InvoiceDocumentService {
constructor(
private readonly pdf: PdfRenderService,
private readonly stampSettings: StampSettingsService,
private readonly logoSettings: LogoSettingsService,
) {}
async render(
@@ -84,7 +94,11 @@ export class InvoiceDocumentService {
model.stampImageUrl !== undefined
? model.stampImageUrl
: await this.stampSettings.getStampImageUrl();
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl };
const logoImageUrl =
model.logoImageUrl !== undefined
? model.logoImageUrl
: await this.logoSettings.getLogoImageUrl();
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl, logoImageUrl };
const html = this.buildHtml(resolvedModel);
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
@@ -96,9 +110,11 @@ export class InvoiceDocumentService {
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
// ponytail: still draws the plain vector seal, not the uploaded stamp
// image — embedding a raster image needs a new PDF XObject primitive
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
// carry the real stamp too; today it's a rare degraded fallback.
// image, and omits the EIMS QR entirely — embedding a raster image
// needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade
// when the Chromium-less path needs to carry the real stamp/QR too;
// today it's a rare degraded fallback. The IRN text itself still
// comes through (buildFallbackPdf renders model.summary same as HTML).
fallback: () => this.buildFallbackPdf(resolvedModel),
}),
};
@@ -242,6 +258,11 @@ export class InvoiceDocumentService {
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const sealInner = sealMarkup(model.stampImageUrl, sealText);
const sealCssClass = sealClass(model.stampImageUrl);
const logoInner = logoMarkup(model.logoImageUrl);
const qrMarkup = model.qrImageUrl
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
: "";
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
@@ -281,8 +302,20 @@ export class InvoiceDocumentService {
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
${sealImageCss()}
${logoImageCss()}
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
.qr img { width: 90px; height: 90px; }
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
/* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the
150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */
.summary.summary-with-qr { margin-right: 270px; }
/* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long
unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than
honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay
(confirmed by isolating the two: margin-right alone already positioned the box correctly;
the text itself was still escaping the box's own right edge until this was added). */
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
@@ -299,6 +332,7 @@ export class InvoiceDocumentService {
<div class="doc">
<div class="top">
<div>
${logoInner}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
</div>
@@ -309,7 +343,8 @@ export class InvoiceDocumentService {
</div>
</div>
<div class="${sealCssClass}">${sealInner}</div>
<div class="summary">${summaryRows}</div>
${qrMarkup}
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
<table>
<thead>
<tr>

View File

@@ -0,0 +1,35 @@
/**
* The single decision every EDR document makes about its header logo: draw
* the one uploaded company logo when configured (LogoSettingsService), or
* render nothing — the existing "Ethio-Djibouti Railway S.C." text brand next
* to it already covers the no-logo case, so there is no text fallback here
* (contrast seal-markup.util.ts, whose seal has no text of its own).
*/
function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* `<img>` markup for the header logo, or "" when unset. `logoImageUrl` is
* expected to be a data URL from LogoSettingsService.getLogoImageUrl().
* `className` defaults to "doc-logo" — each document supplies that class's
* sizing in its own <style> block (see logoImageCss()).
*/
export function logoMarkup(
logoImageUrl: string | null | undefined,
className = "doc-logo",
): string {
if (!logoImageUrl) return "";
return `<img class="${className}" src="${escapeHtml(logoImageUrl)}" alt="Company logo" />`;
}
/** Default CSS for the header logo — append inside a document's <style> block. */
export function logoImageCss(className = "doc-logo"): string {
return `.${className} { display: block; max-height: 48px; max-width: 180px; margin-bottom: 6px; object-fit: contain; }`;
}

View File

@@ -18,6 +18,8 @@ const PDF_PRINT_STYLES = `
export interface PdfRenderOptions {
/** Label used in logs to identify the document kind. */
label?: string;
/** Landscape A4 instead of the default portrait — wide tables need it. */
landscape?: boolean;
/**
* Degraded renderer used when Chromium is unavailable. Receives the
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
@@ -59,6 +61,7 @@ export class PdfRenderService {
const pdf = await page.pdf({
format: "A4",
landscape: opts.landscape ?? false,
printBackground: true,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
});

View File

@@ -55,7 +55,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
salesPersonName: null,
transactionType: "B2B",
payment: { mode: "CASH", term: "IMMIDIATE" },
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }),
natureOfSupplies: "Service",
unitDefault: "PCS",
incomeWithholdValue: 0,
@@ -115,8 +115,8 @@ describe("toEimsInvoice", () => {
context({
taxForLine: (line) =>
line.chargeType === "RAIL_FREIGHT"
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50, discount: 25 },
}),
);
@@ -130,6 +130,7 @@ describe("toEimsInvoice", () => {
TaxCode: "VAT15",
TaxAmount: 1500,
ExciseTaxValue: 0,
Discount: 0,
TotalLineAmount: 11500,
Unit: "PCS",
NatureOfSupplies: "service",
@@ -141,6 +142,9 @@ describe("toEimsInvoice", () => {
TaxCode: "EXEMPT",
TaxAmount: 0,
ExciseTaxValue: 50,
// Discount is carried on the line but does not (yet) reduce TotalLineAmount — see the
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
Discount: 25,
TotalLineAmount: 1050,
Unit: "CTR",
});
@@ -187,7 +191,17 @@ describe("toEimsInvoice", () => {
toEimsInvoice(
invoice(),
seller,
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0, discount: 0 }) }),
),
).toThrow(/unresolved tax treatment for line 1/);
expect(() =>
toEimsInvoice(
invoice(),
seller,
context({
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: NaN }),
}),
),
).toThrow(/unresolved tax treatment for line 1/);
});

View File

@@ -183,6 +183,12 @@ export interface EimsLineTax {
code: string;
ratePercent: number;
exciseTaxValue: number;
/**
* Line-level `Discount`. Its effect on `TotalLineAmount` has never been observed live (every
* prior test ran it at 0), so the total below still sums PreTax + Tax + Excise only — do not
* start subtracting this without a confirmed MoR example.
*/
discount: number;
}
export interface EimsMapperContext {
@@ -336,7 +342,13 @@ export function toEimsInvoice(
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
const lineNumber = index + 1;
const tax = context.taxForLine(line, lineNumber);
if (!tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue)) {
if (
!tax ||
!tax.code ||
!Number.isFinite(tax.ratePercent) ||
!Number.isFinite(tax.exciseTaxValue) ||
!Number.isFinite(tax.discount)
) {
throw new Error(
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
`on invoice ${invoice.invoiceNumber}`,
@@ -349,7 +361,7 @@ export function toEimsInvoice(
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
return {
Discount: 0,
Discount: round2(tax.discount),
ExciseTaxValue,
HarmonizationCode: null,
NatureOfSupplies: natureOfSupplies,

View File

@@ -126,10 +126,22 @@ export class Invoice extends BaseEntity {
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
eimsStatus!: EimsInvoiceStatus;
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
/**
* Invoice Reference Number returned by EIMS. Unique across invoices (partial index). `text`,
* not a fixed varchar — MoR has never documented an IRN format/length, and a real live value
* (a `test-` prefix + 64 hex chars, 69 chars total) already overflowed a prior varchar(64).
*/
@Column({ name: "eims_irn", type: "text", nullable: true })
eimsIrn?: string | null;
/**
* `signedQR` from the register response — a base64 PNG image, already rendered by MoR (confirmed
* against the Postman collection's saved response: decodes to a PNG magic-byte header). Stored
* verbatim; `BillingService.renderEimsQr` only wraps it in a `data:image/png;base64,` URL.
*/
@Column({ name: "eims_signed_qr", type: "text", nullable: true })
eimsSignedQr?: string | null;
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null;
@@ -148,4 +160,24 @@ export class Invoice extends BaseEntity {
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
eimsLastError?: EimsInvoiceError | null;
/**
* `POST /v1/cancel` — set together, only once `eimsStatus` reaches CANCELLED.
* `eimsCancelledAt` is our own server time (same convention as `eimsSubmittedAt`);
* `eimsCancellationDate` is MoR's own confirmation string, stored verbatim like `eimsAckDate` —
* its format (`"Sun Dec 22 21:55:03 EAT 2024"`, a Java `Date#toString()`) is not reliably
* `Date.parse`-able (the `EAT` zone abbreviation is non-standard), so it is never parsed.
*/
@Column({ name: "eims_cancelled_at", type: "timestamptz", nullable: true })
eimsCancelledAt?: Date | null;
@Column({ name: "eims_cancellation_date", type: "varchar", length: 64, nullable: true })
eimsCancellationDate?: string | null;
/** Numeric string per the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
@Column({ name: "eims_cancellation_reason_code", type: "varchar", length: 8, nullable: true })
eimsCancellationReasonCode?: string | null;
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
eimsCancellationRemark?: string | null;
}

View File

@@ -59,6 +59,7 @@ export interface BookingListFilterOptions {
assignedToSchedule?: 'true' | 'false';
companyId?: string;
companyProfileId?: string;
contractId?: string;
contractType?: string;
serviceTypeId?: string;
cargoTypeId?: string;
@@ -936,6 +937,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
companyProfileId: options.companyProfileId,
});
}
if (options.contractId) {
qb.andWhere('booking.contract_id = :contractId', {
contractId: options.contractId,
});
}
if (options.contractType) {
qb.andWhere('booking.contract_type = :contractType', {
contractType: options.contractType,

View File

@@ -1805,6 +1805,7 @@ export class BookingsService {
// ANDs both, so cross-company access is impossible.
companyId: forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
contractId: filter.contractId,
tradeDirections,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,

View File

@@ -47,6 +47,11 @@ export class FilterBookingDto {
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter bookings drawn down under this contract' })
@IsOptional()
@IsUUID()
contractId?: string;
@ApiPropertyOptional()
@IsOptional()
contractType?: string;

View File

@@ -0,0 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString, Length } from "class-validator";
/** `POST /v1/cancel` body — `ReasonCode`/`Remark` are the collection's own field names. */
export class CancelEimsRegistrationDto {
@ApiProperty({
description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).',
example: "1",
})
@IsString()
@Length(1, 8)
reasonCode!: string;
@ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" })
@IsOptional()
@IsString()
@Length(0, 500)
remark?: string;
}

View File

@@ -0,0 +1,88 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
/**
* `POST /v1/receipt/sales` — invoice/amounts/currency/IRN are derived from the invoice; everything
* here is what this codebase has no source of truth for, so it is asked of the caller rather than
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
*/
export class RegisterSalesReceiptDto {
@ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." })
@IsIn(EIMS_MODE_OF_PAYMENT)
modeOfPayment!: EimsModeOfPayment;
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({ description: "Overrides the amount collected; defaults to the invoice's paidAmount." })
@IsOptional()
@IsNumber()
collectedAmount?: number;
@ApiPropertyOptional({ description: "ETB, USD or CAD. Defaults to the invoice's currency." })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ description: "Required when currency is not ETB." })
@IsOptional()
@IsNumber()
exchangeRate?: number;
@ApiPropertyOptional({ description: 'Defaults to "FULL" if the invoice balance is 0, else "PARTIAL".' })
@IsOptional()
@IsString()
paymentCoverage?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 200)
collectorName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 200)
paymentServiceProvider?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 200)
otherPaymentServiceProviderName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
accountNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
transactionNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
chequeNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
cpoNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
documentNumber?: string;
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNumber, IsOptional, IsString, Length } from "class-validator";
/**
* `POST /v1/receipt/withholding`. `type`/`preTaxAmount`/`withholdingAmount` are real money/tax
* values this codebase has no computed source for (the same "no tax model" gap the invoice mapper
* documents) — required from the caller rather than defaulted or derived.
*/
export class RegisterWithholdingReceiptDto {
@ApiProperty({
description: 'WithholdDetail.Type. Only "TWHT" has been observed; not restricted to it.',
example: "TWHT",
})
@IsString()
@Length(1, 16)
type!: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
rate?: number;
@ApiProperty({ description: "Amount the withholding is calculated against." })
@IsNumber()
preTaxAmount!: number;
@ApiProperty({ description: "The withheld amount itself." })
@IsNumber()
withholdingAmount!: number;
@ApiPropertyOptional({ description: 'Defaults to "Withholding".' })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({ description: "Required when the invoice currency is not ETB." })
@IsOptional()
@IsNumber()
exchangeRate?: number;
}

View File

@@ -10,9 +10,11 @@ export class ResolveEimsRegistrationDto {
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
})
// Not capped at 64: eims_irn is `text` now — a real IRN already overflowed a former
// varchar(64) (69 chars). This bound is a sanity ceiling, not a confirmed MoR format.
@IsOptional()
@IsString()
@Length(1, 64)
@Length(1, 500)
irn?: string;
@ApiPropertyOptional({

View File

@@ -0,0 +1,155 @@
import { BadRequestException, ConflictException } from "@nestjs/common";
import { DataSource } from "typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsInvoiceStatus } from "./eims-registration.types";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
id: INVOICE_ID,
invoiceNumber: "INV-20260807-00042",
companyId: "company-1",
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: IRN,
eimsCancelledAt: null,
eimsCancellationDate: null,
eimsCancellationReasonCode: null,
eimsCancellationRemark: null,
...over,
}) as unknown as Invoice;
/** In-memory stand-in, same shape as the registration spec's FakeDb but with only what cancel needs. */
class FakeDb {
invoices = new Map<string, Invoice>();
companyContact: { phone: string | null; email: string | null } | null = null;
constructor(invoices: Invoice[]) {
for (const inv of invoices) this.invoices.set(inv.id, inv);
}
private manager = {
createQueryBuilder: (entity: unknown) => {
let id: string | undefined;
const builder = {
setLock: () => builder,
where: (_clause: string, params: Record<string, string>) => {
id = params.invoiceId;
return builder;
},
getOne: async () => (entity === Invoice ? (this.invoices.get(id!) ?? null) : null),
};
return builder;
},
findOne: async (_entity: unknown, options: { where: { id: string } }) =>
this.invoices.get(options.where.id) ?? null,
update: async (_entity: unknown, id: string, patch: Record<string, unknown>) => {
Object.assign(this.invoices.get(id)!, patch);
},
};
asDataSource(): DataSource {
return {
manager: this.manager,
query: async () => (this.companyContact ? [this.companyContact] : []),
transaction: async (body: (m: unknown) => Promise<unknown>) => body(this.manager),
} as unknown as DataSource;
}
}
const build = (db: FakeDb, postBearer: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) =>
new EimsCancellationService(
db.asDataSource(),
{ postBearer } as unknown as EimsClientService,
{ directSend } as unknown as NotificationsService,
);
describe("EimsCancellationService.cancelInvoiceWithEims", () => {
it("cancels a registered invoice and persists MoR's confirmation", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest
.fn()
.mockResolvedValue({ statusCode: 200, message: "Success", body: { cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" } });
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1", "Duplicate");
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "Duplicate" });
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(view.eimsCancellationDate).toBe("Sun Dec 22 21:55:03 EAT 2024");
expect(view.eimsCancellationReasonCode).toBe("1");
expect(view.eimsCancellationRemark).toBe("Duplicate");
expect(db.invoices.get(INVOICE_ID)?.eimsCancelledAt).toBeInstanceOf(Date);
});
it("defaults Remark to an empty string, matching the collection's request shape", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
});
it("refuses re-cancelling an already-cancelled invoice, per IRC-N010 — no silent no-op", async () => {
const db = new FakeDb([
invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled, eimsCancellationDate: "Sun Dec 22 2024" }),
]);
const postBearer = jest.fn();
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
ConflictException,
);
expect(postBearer).not.toHaveBeenCalled();
});
it("refuses to cancel an invoice that was never registered", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null })]);
const postBearer = jest.fn();
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
BadRequestException,
);
expect(postBearer).not.toHaveBeenCalled();
});
it("propagates a MoR rejection and leaves the invoice REGISTERED", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest
.fn()
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS cancel failed (406)", 406));
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
EimsApiException,
);
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
});
it("notifies the buyer company on success, without blocking the result", async () => {
const db = new FakeDb([invoiceRow()]);
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
const directSend = jest.fn().mockResolvedValue(undefined);
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("cancelled"));
});
it("does not fail cancellation when the buyer notification itself fails", async () => {
const db = new FakeDb([invoiceRow()]);
db.companyContact = { phone: "+251911000000", email: null };
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
});
});

View File

@@ -0,0 +1,125 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntityManager } from "typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { EimsClientService } from "./eims-client.service";
import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
/**
* `POST /v1/cancel` for one already-registered invoice.
*
* Simpler than registration: cancellation carries no `InvoiceCounter`/`DocumentNumber`, so none of
* `EimsSystemState`'s reservation machinery applies, and — unlike registration — a retried cancel
* is safe: the collection's bulk-cancel example shows MoR itself rejects a second cancel with
* "IRN already Canceled.", so there is no double-filing risk the way an unacknowledged register
* call has. That is what makes the simpler shape below correct: no system-wide block, no in-flight
* marker, just a lock-check-unlock before the call and a fresh lock-check-write after it.
*
* ponytail: the eligibility check (TX1) and the write (TX2) are not one atomic operation, so two
* concurrent cancels on the same invoice could both pass TX1 and both call MoR — wasted, but safe,
* per the paragraph above. Upgrade to a single locked reservation (like registration's) only if
* MoR's cancel endpoint turns out not to be idempotent after all.
*
* Bearer-authenticated but unsigned (`postBearer`), same as `/v1/verify` — the collection's saved
* `/v1/cancel` request carries no `{request,signature,certificate}` envelope.
*/
@Injectable()
export class EimsCancellationService {
private readonly logger = new Logger(EimsCancellationService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly client: EimsClientService,
private readonly notifications: NotificationsService,
) {}
/**
* Refuses an already-cancelled invoice with a 409, rather than a silent no-op — IRC-N010 in
* MoR's Master Compliance Checklist requires "an appropriate error or rejection message" for a
* repeat cancellation, not a quiet success. No HTTP call either way: this is a local check, not
* a retry against MoR. Also refuses an invoice that was never registered — there is no IRN to
* cancel.
*/
async cancelInvoiceWithEims(
invoiceId: string,
reasonCode: string,
remark?: string,
): Promise<EimsInvoiceStatusView> {
const eligible = await this.dataSource.transaction(async (manager) => {
const invoice = await this.lockInvoice(manager, invoiceId);
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) {
throw new ConflictException({
code: "EIMS_ALREADY_CANCELLED",
message: `Invoice ${invoice.invoiceNumber} was already cancelled with EIMS${invoice.eimsCancellationDate ? ` (${invoice.eimsCancellationDate})` : ""}.`,
});
}
if (!invoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_NOT_REGISTERED",
message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
});
}
return invoice;
});
const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" };
// Outside any transaction — no DB lock is held across the wire.
const response = await this.client.postBearer<EimsCancelRequest, EimsCancelResponse>(
"/v1/cancel",
request,
);
const cancellationDate = response?.body?.cancellationDate ?? null;
await this.dataSource.transaction(async (manager) => {
const invoice = await this.lockInvoice(manager, invoiceId);
// Re-checked under lock: a concurrent call may have already recorded this cancellation.
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return;
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Cancelled,
eimsCancelledAt: new Date(),
eimsCancellationDate: cancellationDate,
eimsCancellationReasonCode: reasonCode,
eimsCancellationRemark: remark ?? null,
});
});
this.logger.log(`Invoice ${eligible.invoiceNumber} cancelled with EIMS (IRN ${eligible.eimsIrn})`);
await this.notifyBuyer(eligible);
return this.getEimsCancellationStatus(invoiceId);
}
async getEimsCancellationStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
return toEimsInvoiceStatusView(invoice);
}
/** Best-effort — a notification failure must never mask a cancellation that already succeeded. */
private async notifyBuyer(invoice: Invoice): Promise<void> {
if (!invoice.companyId) return;
try {
await sendCompanyChannels(
this.dataSource,
this.notifications,
invoice.companyId,
`Invoice ${invoice.invoiceNumber} has been cancelled with MoR EIMS.`,
);
} catch (err) {
this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
}
}
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;
}
}

View File

@@ -0,0 +1,118 @@
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import { eimsConfig } from "./eims-test-fixtures";
const SESSION = { systemNumber: "B0360154BA", systemType: "SYS" };
describe("assertEimsInvoiceConfig — charge-type overrides", () => {
it("passes when EIMS_TAX_CODE_BY_CHARGE_TYPE and _RATE_ list the same charge types", () => {
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: { RAIL_FREIGHT: "0" },
},
}),
),
).not.toThrow();
});
it("throws when a charge type has a code but no rate, or a rate but no code", () => {
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: {},
},
}),
),
).toThrow(/mismatched: RAIL_FREIGHT/);
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCodeByChargeType: {},
taxRateByChargeType: { RAIL_FREIGHT: "0" },
},
}),
),
).toThrow(/mismatched: RAIL_FREIGHT/);
});
it("throws when a rate/excise/discount override is not a number", () => {
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: { RAIL_FREIGHT: "not-a-number" },
},
}),
),
).toThrow(/EIMS_TAX_RATE_BY_CHARGE_TYPE\[RAIL_FREIGHT\]/);
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({ invoice: { ...eimsConfig().invoice, discountByChargeType: { X: "abc" } } }),
),
).toThrow(/EIMS_DISCOUNT_BY_CHARGE_TYPE\[X\]/);
});
});
describe("buildEimsContext — taxForLine", () => {
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
it("uses the per-chargeType override when one is configured", () => {
const context = buildEimsContext(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCode: "VAT15",
taxRatePercent: 15,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: { RAIL_FREIGHT: "0" },
exciseByChargeType: { RAIL_FREIGHT: "5" },
discountByChargeType: { RAIL_FREIGHT: "10" },
},
}),
input,
);
expect(context.taxForLine(line("RAIL_FREIGHT"), 1)).toEqual({
code: "VAT0",
ratePercent: 0,
exciseTaxValue: 5,
discount: 10,
});
});
it("falls back to the invoice-wide default for a charge type with no override", () => {
const context = buildEimsContext(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCode: "VAT15",
taxRatePercent: 15,
exciseTaxValue: 0,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: { RAIL_FREIGHT: "0" },
},
}),
input,
);
expect(context.taxForLine(line("HANDLING"), 1)).toEqual({
code: "VAT15",
ratePercent: 15,
exciseTaxValue: 0,
discount: 0,
});
});
});

View File

@@ -59,6 +59,47 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
}
assertSellerFormats(config.invoice);
assertChargeTypeOverrides(config.invoice);
}
/**
* Per-`chargeType` tax overrides must be internally consistent before anything is filed:
* `EIMS_TAX_CODE_BY_CHARGE_TYPE` and `EIMS_TAX_RATE_BY_CHARGE_TYPE` must name the same charge
* types (a code with no rate, or vice versa, is a half-finished override), and every rate/excise/
* discount value must parse as a number — checked once here rather than once per line at
* registration time.
*/
function assertChargeTypeOverrides(invoice: EimsConfig["invoice"]): void {
const codeKeys = Object.keys(invoice.taxCodeByChargeType);
const rateKeys = Object.keys(invoice.taxRateByChargeType);
const mismatched = [...new Set([...codeKeys, ...rateKeys])].filter(
(k) => !(codeKeys.includes(k) && rateKeys.includes(k)),
);
if (mismatched.length > 0) {
throw new BadRequestException({
code: "EIMS_INVOICE_CONFIG_INVALID",
message:
`EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge ` +
`types; mismatched: ${mismatched.join(", ")}`,
});
}
const numericMaps: { env: string; map: Record<string, string> }[] = [
{ env: "EIMS_TAX_RATE_BY_CHARGE_TYPE", map: invoice.taxRateByChargeType },
{ env: "EIMS_EXCISE_BY_CHARGE_TYPE", map: invoice.exciseByChargeType },
{ env: "EIMS_DISCOUNT_BY_CHARGE_TYPE", map: invoice.discountByChargeType },
];
const badNumbers = numericMaps.flatMap(({ env, map }) =>
Object.entries(map)
.filter(([, value]) => !Number.isFinite(Number(value)))
.map(([chargeType]) => `${env}[${chargeType}]`),
);
if (badNumbers.length > 0) {
throw new BadRequestException({
code: "EIMS_INVOICE_CONFIG_INVALID",
message: `EIMS charge-type overrides must be numbers: ${badNumbers.join(", ")}`,
});
}
}
/**
@@ -135,9 +176,25 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
salesPersonName: invoice.salesPersonName,
transactionType: invoice.transactionType,
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
// One treatment for every line today. The mapper resolves tax per line, so a future
// charge-type-specific rule slots in here without touching the mapper.
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }),
// Per-`chargeType` override when one is configured (validated symmetric in
// assertChargeTypeOverrides), else the single invoice-wide default.
taxForLine: (line: EimsMapperLine) => {
const { chargeType } = line;
const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode;
const rate =
chargeType in invoice.taxRateByChargeType
? Number(invoice.taxRateByChargeType[chargeType])
: ratePercent;
const excise =
chargeType in invoice.exciseByChargeType
? Number(invoice.exciseByChargeType[chargeType])
: exciseTaxValue;
const discount =
chargeType in invoice.discountByChargeType
? Number(invoice.discountByChargeType[chargeType])
: 0;
return { code, ratePercent: rate, exciseTaxValue: excise, discount };
},
natureOfSupplies: invoice.natureOfSupplies,
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,
@@ -145,6 +202,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
buyerCountryCode: invoice.buyerCountryCode,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,
exchangeRate: input.exchangeRate ?? null,
};
}

View File

@@ -7,6 +7,7 @@ import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
import { eimsInvoiceConfig } from "./eims-test-fixtures";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
@@ -85,6 +86,8 @@ class FakeDb {
state: EimsSystemState | null = null;
/** Runs before every transaction body, to simulate a concurrent writer. */
onTransaction: (() => void) | null = null;
/** `sendCompanyChannels`'s contact lookup, when a test needs it non-empty. */
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);
@@ -133,10 +136,13 @@ class FakeDb {
return {
manager: this.manager,
getRepository: this.manager.getRepository,
query: async (sql: string) =>
sql.includes("eims_system_state")
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
: LINES,
query: async (sql: string) => {
if (sql.includes("eims_system_state")) {
return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }];
}
if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : [];
return LINES;
},
transaction: async (body: (m: unknown) => Promise<unknown>) => {
this.onTransaction?.();
return body(this.manager);
@@ -155,6 +161,7 @@ const build = (
postBearer: jest.Mock = jest.fn(),
getSessionContext: jest.Mock | undefined = undefined,
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
) =>
new EimsInvoiceRegistrationService(
db.asDataSource(),
@@ -164,6 +171,7 @@ const build = (
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
} as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService,
{ directSend } as unknown as NotificationsService,
);
/**
@@ -196,8 +204,11 @@ const verifyResponse = (over: Record<string, unknown> = {}) => ({
},
});
const okResponse = (irn = IRN) =>
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
const okResponse = (irn = IRN, over: Record<string, unknown> = {}) => ({
statusCode: 200,
message: "SUCCESS",
body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over },
});
const apiError = (kind: string, status?: number) =>
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
@@ -226,6 +237,51 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("persists signedQR alongside the IRN", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse(IRN, { signedQR: "signed-qr-payload" }));
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsSignedQr).toBe("signed-qr-payload");
expect(db.invoices.get(INVOICE_ID)?.eimsSignedQr).toBe("signed-qr-payload");
});
it("leaves eimsSignedQr null when the gateway does not return one", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsSignedQr).toBeNull();
});
it("notifies the buyer company on a successful registration, without blocking the result", async () => {
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
const directSend = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
.registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining(IRN));
expect(directSend).toHaveBeenCalledWith("email", "buyer@abc.et", expect.stringContaining(IRN));
});
it("does not fail registration when the buyer notification itself fails", async () => {
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
db.companyContact = { phone: "+251911000000", email: null };
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
.registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
});
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
const postSigned = jest.fn().mockResolvedValue(okResponse());
@@ -408,7 +464,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
it("returns both the counter and the document number after a refusal, keeps both after an ambiguous result", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest
.fn()
@@ -421,14 +477,47 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
);
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
// The two numbers move differently, because MoR constrains them differently: the counter must
// not skip (it returns), the document number must not repeat (it is burned).
// A deterministic rejection rolls both numbers back — MoR's expected-next-value for either
// sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter).
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
expect(first.SourceSystem.InvoiceCounter).toBe(7);
expect(second.SourceSystem.InvoiceCounter).toBe(7);
expect(first.DocumentDetails.DocumentNumber).toBe("5");
expect(second.DocumentDetails.DocumentNumber).toBe("6");
expect(second.DocumentDetails.DocumentNumber).toBe("5");
});
it("blocks and alerts with the real IRN when MoR accepts but persisting it locally fails", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockResolvedValue(okResponse());
// 1st/2nd calls are the reserve() updates; the 3rd is settleSuccess's invoice update — the one
// that actually failed live (eims_irn too narrow for the real value MoR returned).
let call = 0;
const manager = (db as unknown as { manager: { update: jest.Mock } }).manager;
const realUpdate = manager.update;
manager.update = jest.fn(async (entity: unknown, id: string, patch: Record<string, unknown>) => {
call++;
if (call === 3) throw new Error("value too long for type character varying(64)");
return realUpdate(entity, id, patch);
});
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toThrow(/value too long/);
// The IRN is never lost, even though the normal success path never committed.
expect(db.state?.blockedReason).toContain(IRN);
expect(db.state?.blockedReason).toContain("ACCEPTED");
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Submitting);
expect(notify).toHaveBeenCalledTimes(1);
const sent = notify.mock.calls[0][0];
expect(sent.priority).toBe("HIGH");
expect(sent.body).toContain(IRN);
});
});

View File

@@ -19,6 +19,9 @@ import {
} from "../billing/eims-invoice.mapper";
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
@@ -78,6 +81,7 @@ export class EimsInvoiceRegistrationService {
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
private get cfg(): EimsConfig {
@@ -115,17 +119,28 @@ export class EimsInvoiceRegistrationService {
let irn: string;
let ackDate: string | undefined;
let signedQR: string | undefined;
try {
// Deliberately outside every transaction — no DB lock is held across the wire.
const result = await this.submit(request);
irn = result.irn;
ackDate = result.ackDate;
signedQR = result.signedQR;
} catch (err) {
await this.settleFailure(invoiceId, reservation, err);
throw err;
}
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
try {
await this.settleSuccess(invoiceId, reservation, irn, ackDate, signedQR);
} catch (err) {
// MoR already accepted this document — unlike settleFailure's targets, this is not
// ambiguous, it is a *known* IRN we simply failed to persist (confirmed live 2026-08-12: a
// column too narrow for a real IRN). Losing it here would be worse than the persistence
// bug itself, so it goes straight into the block reason and the alert, not just a log line.
await this.blockOnKnownIrnPersistFailure(invoiceId, reservation, irn, ackDate, err);
throw err;
}
this.logger.log(
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
);
@@ -373,13 +388,19 @@ export class EimsInvoiceRegistrationService {
reservation: Reservation,
irn: string,
ackDate?: string,
signedQR?: string,
): Promise<void> {
let companyId: string | undefined;
let invoiceNumber = invoiceId;
await this.dataSource.transaction(async (manager) => {
await this.lockInvoice(manager, invoiceId);
const invoice = await this.lockInvoice(manager, invoiceId);
companyId = invoice.companyId ?? undefined;
invoiceNumber = invoice.invoiceNumber;
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: irn,
eimsAckDate: ackDate ?? null,
eimsSignedQr: signedQR ?? null,
eimsLastError: null,
});
await manager.update(EimsSystemState, reservation.stateId, {
@@ -390,20 +411,39 @@ export class EimsInvoiceRegistrationService {
blockedReason: null,
});
});
// Best-effort, outside the transaction: MoR checklist ADD-N001 wants the buyer notified of a
// registration event. Never lets a notification failure mask a filing that already succeeded.
if (companyId) {
try {
await sendCompanyChannels(
this.dataSource,
this.notifications,
companyId,
`Invoice ${invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
);
} catch (err) {
this.logger.warn(`EIMS buyer notification failed for invoice ${invoiceId}: ${(err as Error).message}`);
}
}
}
/**
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
* TX2b. A deterministic rejection releases the reservation **and returns both numbers**; an
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document.
*
* The two numbers move differently, because MoR constrains them differently:
* Both `InvoiceCounter` and `DocumentNumber` roll back together on a deterministic rejection —
* MoR's own expected-next-value only advances on acceptance, for both fields:
*
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
* document MoR definitively refused was never counted there, so ours must not advance either.
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
* unique". It is therefore spent by the attempt itself and never handed back, even for a
* refusal.
* - `InvoiceCounter`: "Invoice counter is not correct. expected : 1".
* - `DocumentNumber`: "Document number error. Document number is not in correct sequence
* expected : 1" (rule 7001) — confirmed live 2026-08-12. An earlier design burned
* `DocumentNumber` forward on every attempt, reasoning from a separate "Document number is
* not unique" rejection; that turned out to describe the same constraint from the other side
* (MoR rejects both reuse *and* skipping ahead of its true next value), and burning forward on
* every rejection permanently drifted past what MoR would ever accept again — confirmed live
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
*
* An ambiguous result keeps both: MoR may have counted and stored the document.
*/
@@ -434,9 +474,9 @@ export class EimsInvoiceRegistrationService {
reservation.stateId,
deterministic
? {
// Counter returns (MoR never counted a refused document); the document number does
// not (MoR requires it to be unique, so it is burned by the attempt).
// Both return: MoR never counted a refused document against either sequence.
nextInvoiceCounter: reservation.invoiceCounter,
nextDocumentNumber: Number(reservation.documentNumber),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
@@ -455,6 +495,53 @@ export class EimsInvoiceRegistrationService {
await this.alertStaff(invoiceId, status, lastError, deterministic);
}
/**
* MoR accepted the document (a real IRN came back) but recording that locally failed — the
* reservation is still held from TX1, so the system-wide block goes on regardless of *why*
* `settleSuccess` failed. The IRN and ack date are written straight into `blockedReason` so a
* human resolving this never has to dig through logs for the one thing that must not be lost.
*/
private async blockOnKnownIrnPersistFailure(
invoiceId: string,
reservation: Reservation,
irn: string,
ackDate: string | undefined,
err: unknown,
): Promise<void> {
const reason =
`Invoice ${invoiceId} was ACCEPTED by EIMS (IRN ${irn}${ackDate ? `, ack ${ackDate}` : ""}) ` +
`but recording it locally failed: ${(err as Error)?.message ?? "unknown error"}. Resolve with ` +
`POST /invoices/${invoiceId}/eims/resolve using this IRN once the underlying issue is fixed — ` +
"do not resubmit, the document already exists at MoR.";
try {
await this.dataSource.manager.update(EimsSystemState, reservation.stateId, { blockedReason: reason });
} catch (updateErr) {
// Even the block itself failed to write — last resort is the log, since there is nothing
// left to retry into.
this.logger.error(`Could not record EIMS block for invoice ${invoiceId}: ${reason}`, updateErr as Error);
return;
}
this.logger.error(reason);
// Not alertStaff(): its canned copy for a non-deterministic result always says "the IRN is
// unknown", which is false here — the whole point of this path is that the IRN *is* known.
try {
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
priority: NotificationPriority.HIGH,
title: "EIMS accepted an invoice but it was not recorded — all further filing is blocked",
body: reason,
link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: EimsInvoiceStatus.Unknown, irn, action: "EIMS_PERSIST_FAILED" },
});
} catch (notifyErr) {
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(notifyErr as Error).message}`);
}
}
/**
* Tell the people who can act about a failed filing.
*
@@ -492,7 +579,9 @@ export class EimsInvoiceRegistrationService {
// ── internals ────────────────────────────────────────────────────────────────────────────────
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> {
private async submit(
request: EimsInvoiceRequest,
): Promise<{ irn: string; ackDate?: string; signedQR?: string }> {
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
"/v1/register",
request,
@@ -506,7 +595,7 @@ export class EimsInvoiceRegistrationService {
response?.statusCode,
);
}
return { irn, ackDate: response.body?.ackDate };
return { irn, ackDate: response.body?.ackDate, signedQR: response.body?.signedQR };
}
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
@@ -572,17 +661,6 @@ export class EimsInvoiceRegistrationService {
}
private toView(invoice: Invoice): EimsInvoiceStatusView {
const counter = invoice.eimsInvoiceCounter;
return {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,
eimsLastError: invoice.eimsLastError ?? null,
};
return toEimsInvoiceStatusView(invoice);
}
}

View File

@@ -0,0 +1,27 @@
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
/**
* `Invoice` → `EimsInvoiceStatusView`, shared by every service that mutates EIMS state on an
* invoice (registration, cancellation, receipts) — one place defines what "the current EIMS
* status of an invoice" means to a caller.
*/
export function toEimsInvoiceStatusView(invoice: Invoice): EimsInvoiceStatusView {
const counter = invoice.eimsInvoiceCounter;
return {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,
eimsLastError: invoice.eimsLastError ?? null,
eimsSignedQr: invoice.eimsSignedQr ?? null,
eimsCancelledAt: invoice.eimsCancelledAt ?? null,
eimsCancellationDate: invoice.eimsCancellationDate ?? null,
eimsCancellationReasonCode: invoice.eimsCancellationReasonCode ?? null,
eimsCancellationRemark: invoice.eimsCancellationRemark ?? null,
};
}

View File

@@ -3,8 +3,13 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
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 { EimsCancellationService } from "./eims-cancellation.service";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service";
/**
* Manual EIMS actions on an existing invoice.
@@ -13,10 +18,12 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
* normal production path — they exist for controlled testing and exceptional operations. Automatic
* submission after an invoice is issued is a separate phase; nothing here is called by it.
*
* `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned
* to named admins instead. They are also separate permissions: resolving clears the system-wide
* chain block and can record an IRN against an invoice, which is a supervisor action, not an
* operational one. Only `eims/status` rides on the ordinary `invoices:view`.
* `eims_register`, `eims_resolve`, `eims_cancel` and `eims_receipt_register` are intentionally left
* out of every role preset and assigned to named admins instead. They are also separate
* permissions: resolving clears the system-wide chain block and can record an IRN against an
* invoice, cancelling is its own irreversible-at-MoR action, and filing a receipt is a third —
* none follows from the right to register. Only `eims/status` and `eims/receipts` ride on the
* ordinary `invoices:view`.
*
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
@@ -27,7 +34,11 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
@ApiBearerAuth()
@Controller("invoices")
export class EimsInvoiceController {
constructor(private readonly registration: EimsInvoiceRegistrationService) {}
constructor(
private readonly registration: EimsInvoiceRegistrationService,
private readonly cancellation: EimsCancellationService,
private readonly receipts: EimsReceiptService,
) {}
@Post(":id/eims/register")
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
@@ -65,4 +76,38 @@ export class EimsInvoiceController {
status(@Param("id", ParseUUIDPipe) id: string) {
return this.registration.getEimsStatus(id);
}
@Post(":id/eims/cancel")
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
@ApiOperation({
summary:
"Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled invoice rather than a silent no-op — see IRC-N010.",
})
cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) {
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
}
@Post(":id/eims/receipt/sales")
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })
registerSalesReceipt(@Param("id", ParseUUIDPipe) id: string, @Body() dto: RegisterSalesReceiptDto) {
return this.receipts.registerSalesReceipt(id, dto);
}
@Post(":id/eims/receipt/withholding")
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
@ApiOperation({ summary: "Register a withholding receipt with MoR EIMS against a registered invoice" })
registerWithholdingReceipt(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RegisterWithholdingReceiptDto,
) {
return this.receipts.registerWithholdingReceipt(id, dto);
}
@Get(":id/eims/receipts")
@BookingStaff(FREIGHT_PERMS.invoices.view)
@ApiOperation({ summary: "List every EIMS receipt filed against this invoice, newest first" })
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
return this.receipts.listReceipts(id);
}
}

View File

@@ -0,0 +1,231 @@
import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DataSource } from "typeorm";
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsReceiptStatus } from "./entities/eims-receipt.entity";
import { EimsReceiptService } from "./eims-receipt.service";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const SESSION = { systemNumber: "B0360154BA", systemType: "SYS" };
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
id: INVOICE_ID,
invoiceNumber: "INV-20260807-00042",
companyId: "company-1",
currency: "ETB",
totalAmount: "10000.00",
paidAmount: "10000.00",
balanceAmount: "0.00",
eimsIrn: IRN,
...over,
}) as unknown as Invoice;
/** In-memory stand-in: Invoice lookups + EimsReceipt save/update/find. */
class FakeDb {
invoices = new Map<string, Invoice>();
receipts = new Map<string, Record<string, unknown>>();
companyContact: { phone: string | null; email: string | null } | null = null;
private seq = 0;
constructor(invoices: Invoice[]) {
for (const inv of invoices) this.invoices.set(inv.id, inv);
}
private manager = {
findOne: async (entity: unknown, options: { where: { id: string } }) =>
entity === Invoice
? (this.invoices.get(options.where.id) ?? null)
: (this.receipts.get(options.where.id) ?? null),
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
save: async (_entity: unknown, data: Record<string, unknown>) => {
const id = `receipt-${++this.seq}`;
const row = { id, ...data };
this.receipts.set(id, row);
return row;
},
update: async (_entity: unknown, id: string, patch: Record<string, unknown>) => {
Object.assign(this.receipts.get(id)!, patch);
},
};
asDataSource(): DataSource {
return {
manager: this.manager,
query: async () => (this.companyContact ? [this.companyContact] : []),
} as unknown as DataSource;
}
}
const config = (): EimsConfig => ({ tin: "0053481357" }) as EimsConfig;
const build = (
db: FakeDb,
postBearer: jest.Mock,
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
) =>
new EimsReceiptService(
db.asDataSource(),
{ get: () => config() } as unknown as ConfigService,
{ postBearer } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ directSend } as unknown as NotificationsService,
);
const okResponse = (over: Record<string, unknown> = {}) => ({
statusCode: 200,
message: "Success",
body: { status: "A", rrn: "rrn-value", qr: "iVBORw0KGgo...", ...over },
});
describe("EimsReceiptService.registerSalesReceipt", () => {
it("registers a sales receipt and persists the RRN/QR", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const receipt = await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {
modeOfPayment: "CASH",
} as never);
expect(postBearer).toHaveBeenCalledTimes(1);
expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/sales");
const request = postBearer.mock.calls[0][1];
expect(request.SellerTIN).toBe("0053481357");
expect(request.SourceSystemNumber).toBe("B0360154BA");
expect(request.Invoices[0].InvoiceIRN).toBe(IRN);
expect(request.TransactionDetails.ModeOfPayment).toBe("CASH");
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
expect(receipt.rrn).toBe("rrn-value");
expect(receipt.qr).toBe("iVBORw0KGgo...");
});
it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
await build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
expect(postBearer.mock.calls[0][1].Invoices[0].PaymentCoverage).toBe("PARTIAL");
});
it("rejects an unconfirmed receipt currency locally, with zero HTTP calls", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn();
await expect(
build(db, postBearer).registerSalesReceipt(INVOICE_ID, {
modeOfPayment: "CASH",
currency: "GBP",
} as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(postBearer).not.toHaveBeenCalled();
});
it("refuses to file a receipt against an unregistered invoice", async () => {
const db = new FakeDb([invoiceRow({ eimsIrn: null })]);
const postBearer = jest.fn();
await expect(
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(postBearer).not.toHaveBeenCalled();
});
it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest
.fn()
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406));
await expect(
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
).rejects.toBeInstanceOf(EimsApiException);
const [receipt] = [...db.receipts.values()];
expect(receipt.status).toBe(EimsReceiptStatus.Failed);
});
it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out"));
await expect(
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
).rejects.toBeInstanceOf(EimsApiException);
const [receipt] = [...db.receipts.values()];
expect(receipt.status).toBe(EimsReceiptStatus.Unknown);
});
it("notifies the buyer company on success, without blocking the result", async () => {
const db = new FakeDb([invoiceRow()]);
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
const directSend = jest.fn().mockResolvedValue(undefined);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const receipt = await build(db, postBearer, directSend).registerSalesReceipt(INVOICE_ID, {
modeOfPayment: "CASH",
} as never);
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("sales"));
});
});
describe("EimsReceiptService.registerWithholdingReceipt", () => {
it("registers a withholding receipt and persists the RRN", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const receipt = await build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, {
type: "TWHT",
preTaxAmount: 6000,
withholdingAmount: 120,
} as never);
expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/withholding");
const request = postBearer.mock.calls[0][1];
expect(request.InvoiceDetail.InvoiceIRN).toBe(IRN);
expect(request.WithholdDetail).toMatchObject({ Type: "TWHT", PreTaxAmount: 6000, WithholdingAmount: 120 });
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
expect(receipt.rrn).toBe("rrn-value");
});
it("requires an exchangeRate for a non-ETB invoice", async () => {
const db = new FakeDb([invoiceRow({ currency: "USD" })]);
const postBearer = jest.fn();
await expect(
build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, {
type: "TWHT",
preTaxAmount: 6000,
withholdingAmount: 120,
} as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(postBearer).not.toHaveBeenCalled();
});
});
describe("EimsReceiptService.listReceipts", () => {
it("returns every receipt filed against the invoice", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const service = build(db, postBearer);
await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
await service.registerWithholdingReceipt(INVOICE_ID, {
type: "TWHT",
preTaxAmount: 100,
withholdingAmount: 2,
} as never);
const list = await service.listReceipts(INVOICE_ID);
expect(list).toHaveLength(2);
});
});

View File

@@ -0,0 +1,260 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } 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 { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
import {
EimsReceiptResponse,
EimsSalesReceiptRequest,
EimsWithholdReceiptRequest,
} from "./eims-receipt.types";
import { EimsInvoiceError } from "./eims-registration.types";
/** `ReceiptCurrency` / withholding `InvoiceDetail.Currency` — confirmed by a live rule error. */
const EIMS_RECEIPT_CURRENCIES = ["ETB", "USD", "CAD"] as const;
/** Failure kinds where MoR gave a complete answer — the receipt definitively did not register. */
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
/**
* `POST /v1/receipt/sales` and `POST /v1/receipt/withholding`, both against an already-registered
* invoice.
*
* No counter/reservation machinery: the collection shows no MoR-enforced ordering on
* `ReceiptCounter` the way `InvoiceCounter` has a documented "expected: N" rule, so — unlike
* registration — there is no shared sequence to protect. Each attempt is its own `EimsReceipt` row:
* created before the HTTP call (so a crash mid-flight leaves an UNKNOWN row instead of nothing),
* settled after it. An ambiguous outcome is never auto-retried — receipts have no evidenced
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
* applies as an unacknowledged registration: a human must check the MoR portal first.
*
* Several request fields have no confirmed source in this codebase (payment method, collector,
* withholding rate/amount) and are never guessed — see the two DTOs.
*/
@Injectable()
export class EimsReceiptService {
private readonly logger = new Logger(EimsReceiptService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly notifications: NotificationsService,
) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("eims")!;
}
async registerSalesReceipt(invoiceId: string, dto: RegisterSalesReceiptDto): Promise<EimsReceipt> {
const invoice = await this.loadRegisteredInvoice(invoiceId);
const currency = dto.currency ?? invoice.currency;
this.assertReceiptCurrency(currency);
const session = await this.auth.getSessionContext();
const receiptNumber = this.generateReceiptNumber(invoice);
const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount);
const balance = Number(invoice.balanceAmount);
const request: EimsSalesReceiptRequest = {
ReceiptNumber: receiptNumber,
ReceiptType: "Sales Receipts",
Reason: dto.reason ?? "Payment received",
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
// error for this field was ever observed to confirm which form MoR actually requires.
ReceiptDate: new Date().toISOString(),
ReceiptCounter: String(Date.now()),
ManualReceiptNumber: receiptNumber,
SourceSystemType: session.systemType,
SourceSystemNumber: session.systemNumber,
ReceiptCurrency: currency,
ExchangeRate: dto.exchangeRate ?? null,
CollectedAmount: collectedAmount,
SellerTIN: this.cfg.tin,
Invoices: [
{
InvoiceIRN: invoice.eimsIrn!,
PaymentCoverage: dto.paymentCoverage ?? (balance <= 0 ? "FULL" : "PARTIAL"),
InvoicePaidAmount: collectedAmount,
DiscountAmount: null,
RemainingAmount: balance,
TotalAmount: Number(invoice.totalAmount),
},
],
TransactionDetails: {
ModeOfPayment: dto.modeOfPayment,
ChequeNumber: dto.chequeNumber ?? null,
CPONumber: dto.cpoNumber ?? null,
DocumentNumber: dto.documentNumber ?? null,
CollectorName: dto.collectorName ?? null,
PaymentServiceProvider: dto.paymentServiceProvider ?? null,
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
AccountNumber: dto.accountNumber ?? null,
TransactionNumber: dto.transactionNumber ?? null,
},
};
return this.submit(invoice, "SALES", receiptNumber, request, "/v1/receipt/sales");
}
async registerWithholdingReceipt(
invoiceId: string,
dto: RegisterWithholdingReceiptDto,
): Promise<EimsReceipt> {
const invoice = await this.loadRegisteredInvoice(invoiceId);
if (invoice.currency !== "ETB" && dto.exchangeRate == null) {
throw new BadRequestException({
code: "EIMS_EXCHANGE_RATE_REQUIRED",
message: `Invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`,
});
}
const session = await this.auth.getSessionContext();
const receiptNumber = this.generateReceiptNumber(invoice);
const request: EimsWithholdReceiptRequest = {
ReceiptNumber: receiptNumber,
Reason: dto.reason ?? "Withholding",
ReceiptCounter: String(Date.now()),
ManualReceiptNumber: receiptNumber,
SourceSystemType: session.systemType,
SourceSystemNumber: session.systemNumber,
InvoiceDetail: {
InvoiceIRN: invoice.eimsIrn!,
Currency: invoice.currency,
ExchangeRate: dto.exchangeRate ?? null,
},
WithholdDetail: {
Type: dto.type,
Rate: dto.rate ?? null,
PreTaxAmount: dto.preTaxAmount,
WithholdingAmount: dto.withholdingAmount,
},
};
return this.submit(invoice, "WITHHOLDING", receiptNumber, request, "/v1/receipt/withholding");
}
async listReceipts(invoiceId: string): Promise<EimsReceipt[]> {
return this.dataSource.manager.find(EimsReceipt, {
where: { invoiceId },
order: { createdAt: "DESC" },
});
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
private async submit(
invoice: Invoice,
kind: EimsReceiptKind,
receiptNumber: string,
request: EimsSalesReceiptRequest | EimsWithholdReceiptRequest,
path: "/v1/receipt/sales" | "/v1/receipt/withholding",
): Promise<EimsReceipt> {
// Committed before the HTTP call — a crash mid-flight leaves an UNKNOWN row, not nothing.
const receipt = await this.dataSource.manager.save(EimsReceipt, {
invoiceId: invoice.id,
kind,
status: EimsReceiptStatus.Submitting,
receiptNumber,
submittedAt: new Date(),
request: request as unknown as Record<string, unknown>,
});
try {
const response = await this.client.postBearer<
EimsSalesReceiptRequest | EimsWithholdReceiptRequest,
EimsReceiptResponse
>(path, request);
const rrn = response?.body?.rrn;
if (!rrn) {
throw new EimsApiException(
"SCHEMA_VALIDATION",
"EIMS receipt registration returned no rrn",
response?.statusCode,
);
}
await this.dataSource.manager.update(EimsReceipt, receipt.id, {
status: EimsReceiptStatus.Registered,
rrn,
qr: response.body?.qr ?? null,
ackStatus: response.body?.status ?? null,
});
this.logger.log(`${kind} receipt ${receiptNumber} registered for invoice ${invoice.invoiceNumber} (RRN ${rrn})`);
await this.notifyBuyer(invoice, kind, receiptNumber);
} catch (err) {
const api = err instanceof EimsApiException ? err : null;
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
const lastError: EimsInvoiceError = {
kind: api?.kind ?? "UNKNOWN",
message: (err as Error)?.message ?? "unknown error",
httpStatus: api?.httpStatus,
details: api?.details,
at: new Date().toISOString(),
};
await this.dataSource.manager.update(EimsReceipt, receipt.id, {
status: deterministic ? EimsReceiptStatus.Failed : EimsReceiptStatus.Unknown,
lastError,
} as QueryDeepPartialEntity<EimsReceipt>);
this.logger.error(
`${kind} receipt ${receiptNumber} for invoice ${invoice.invoiceNumber} ${deterministic ? "FAILED" : "UNKNOWN"}: ${lastError.message}`,
);
throw err;
}
const saved = await this.dataSource.manager.findOne(EimsReceipt, { where: { id: receipt.id } });
return saved!;
}
private async notifyBuyer(invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string): Promise<void> {
if (!invoice.companyId) return;
try {
await sendCompanyChannels(
this.dataSource,
this.notifications,
invoice.companyId,
`A ${kind === "SALES" ? "sales" : "withholding"} receipt (${receiptNumber}) has been registered ` +
`with MoR EIMS for invoice ${invoice.invoiceNumber}.`,
);
} catch (err) {
this.logger.warn(`EIMS receipt buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
}
}
private async loadRegisteredInvoice(invoiceId: string): Promise<Invoice> {
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (!invoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_NOT_REGISTERED",
message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — no IRN to file a receipt against.`,
});
}
return invoice;
}
private assertReceiptCurrency(currency: string): void {
if (!EIMS_RECEIPT_CURRENCIES.includes(currency as (typeof EIMS_RECEIPT_CURRENCIES)[number])) {
throw new BadRequestException({
code: "EIMS_RECEIPT_CURRENCY_INVALID",
message: `EIMS receipt currency must be one of ${EIMS_RECEIPT_CURRENCIES.join(", ")}, got "${currency}"`,
});
}
}
private generateReceiptNumber(invoice: Invoice): string {
return `REC-${invoice.invoiceNumber}-${Date.now()}`;
}
}

View File

@@ -0,0 +1,97 @@
/**
* Wire types for `POST /v1/receipt/sales` and `POST /v1/receipt/withholding`, taken verbatim from
* the Postman collection's saved requests/responses. Bearer-authenticated but unsigned — no
* `{request,signature,certificate}` envelope in the collection's saved bodies, same as `/v1/verify`
* and `/v1/cancel`.
*/
/** One entry of `Invoices[]` on a sales receipt. */
export interface EimsSalesReceiptInvoice {
InvoiceIRN: string;
PaymentCoverage: string;
InvoicePaidAmount: number;
DiscountAmount: number | null;
RemainingAmount: number | null;
TotalAmount: number;
}
/**
* `ModeOfPayment` enum confirmed by a live schema error: exactly these eight values, verbatim
* spelling and casing (including the two multi-word ones).
*/
export const EIMS_MODE_OF_PAYMENT = [
"CASH",
"CHEQUE",
"CPO",
"Local Bank Transfer",
"SWIFT",
"Wire Transfer",
"Letter of Credit",
"Card",
] as const;
export type EimsModeOfPayment = (typeof EIMS_MODE_OF_PAYMENT)[number];
export interface EimsSalesReceiptTransactionDetails {
ModeOfPayment: EimsModeOfPayment;
ChequeNumber: string | null;
CPONumber: string | null;
DocumentNumber: string | null;
CollectorName: string | null;
PaymentServiceProvider: string | null;
OtherPaymentServiceProviderName: string | null;
AccountNumber: string | null;
TransactionNumber: string | null;
}
export interface EimsSalesReceiptRequest {
ReceiptNumber: string;
ReceiptType: string;
Reason: string;
ReceiptDate: string;
ReceiptCounter: string;
ManualReceiptNumber: string;
SourceSystemType: string;
SourceSystemNumber: string;
/** Confirmed enum via a live rule error: "Currency should be ETB, USD, CAD". */
ReceiptCurrency: string;
ExchangeRate: number | null;
CollectedAmount: number;
SellerTIN: string;
Invoices: EimsSalesReceiptInvoice[];
TransactionDetails: EimsSalesReceiptTransactionDetails;
}
export interface EimsWithholdReceiptRequest {
ReceiptNumber: string;
Reason: string;
ReceiptCounter: string;
ManualReceiptNumber: string;
SourceSystemType: string;
SourceSystemNumber: string;
InvoiceDetail: {
InvoiceIRN: string;
Currency: string;
ExchangeRate: number | null;
};
WithholdDetail: {
/** Only "TWHT" has ever been observed; not restricted to it, since no fuller enum is confirmed. */
Type: string;
Rate: number | null;
PreTaxAmount: number;
WithholdingAmount: number;
};
}
/** Success body — identical shape for both receipt endpoints. */
export interface EimsReceiptResponseBody {
status: string;
rrn: string;
/** Base64 PNG, same convention as the register response's `signedQR`. */
qr: string;
}
export interface EimsReceiptResponse {
statusCode?: number;
message?: string;
body?: EimsReceiptResponseBody;
}

View File

@@ -13,6 +13,8 @@ export enum EimsInvoiceStatus {
Registered = "REGISTERED",
Failed = "FAILED",
Unknown = "UNKNOWN",
/** Successfully cancelled at MoR via `POST /v1/cancel`. Terminal — never re-registered. */
Cancelled = "CANCELLED",
}
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
@@ -65,6 +67,28 @@ export interface EimsVerifyResponse {
body?: EimsVerifyResponseBody;
}
/**
* `POST /v1/cancel` — bearer-authenticated but unsigned, same shape as `/v1/verify` (no
* `{request,signature,certificate}` envelope in the collection's saved request).
*/
export interface EimsCancelRequest {
Irn: string;
/** Numeric code as a string, e.g. "1" (Duplicate), "6" (Calculation Error) per the collection docs. */
ReasonCode: string;
Remark: string;
}
/** Success body: `{ cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" }` — a Java Date#toString(), not ISO. Stored verbatim, like `eimsAckDate`. */
export interface EimsCancelResponseBody {
cancellationDate: string;
}
export interface EimsCancelResponse {
statusCode?: number;
message?: string;
body?: EimsCancelResponseBody;
}
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
export interface EimsInvoiceError {
kind: string;
@@ -86,4 +110,10 @@ export interface EimsInvoiceStatusView {
eimsSubmittedAt: Date | null;
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
/** Raw base64 PNG from MoR, already rendered on their side — see `Invoice.eimsSignedQr`. */
eimsSignedQr: string | null;
eimsCancelledAt: Date | null;
eimsCancellationDate: string | null;
eimsCancellationReasonCode: string | null;
eimsCancellationRemark: string | null;
}

View File

@@ -8,9 +8,14 @@ import { EimsSignedRequest } from "./eims.types";
*
* 1. compact `JSON.stringify` of the **inner** request object only,
* 2. those exact UTF-8 bytes,
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding),
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding). Confirmed, not
* assumed: MoR's own "Guide to Generating and Using Certificate for E-Invoicing" names
* `SHA512withRSA` explicitly, which is PKCS#1v1.5 in Java (PSS would be named
* `SHA512withRSAandMGF1`) — the same padding `createSign("RSA-SHA512")` uses by default.
* 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key),
* 5. base64 of the certificate file's exact bytes.
* 5. base64 of the certificate file's exact bytes. Also confirmed by the same guide: its own
* worked example certificate is the identical `Subject:`/`Issuer:` header + 3-cert PEM chain
* text-file format ours is, base64'd with no re-encoding.
*
* The outer `{request, signature, certificate}` envelope is never itself signed, and the request
* object is never mutated after serialization.

View File

@@ -35,8 +35,14 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
buyerCountryCode: null,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
taxCodeByChargeType: {},
taxRateByChargeType: {},
exciseByChargeType: {},
discountByChargeType: {},
cashierName: null,
salesPersonName: null,
buyerIdType: null,
buyerIdNumber: null,
...over,
});

View File

@@ -4,13 +4,17 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { EimsAuthService } from "./eims-auth.service";
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsClientService } from "./eims-client.service";
import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsInvoiceController } from "./eims-invoice.controller";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service";
import { EimsSignerService } from "./eims-signer.service";
import { EimsReceipt } from "./entities/eims-receipt.entity";
import { EimsSystemState } from "./entities/eims-system-state.entity";
/**
@@ -22,8 +26,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
@Module({
imports: [
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
NotificationInboxModule,
NotificationsModule,
],
controllers: [EimsInvoiceController],
providers: [
@@ -33,7 +38,15 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
EimsClientService,
EimsInvoiceRegistrationService,
EimsAutoSubmitService,
EimsCancellationService,
EimsReceiptService,
],
exports: [
EimsAuthService,
EimsClientService,
EimsInvoiceRegistrationService,
EimsCancellationService,
EimsReceiptService,
],
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
})
export class EimsModule {}

View File

@@ -0,0 +1,68 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Invoice } from "../../billing/entities/invoice.entity";
import { EimsInvoiceError } from "../eims-registration.types";
export type EimsReceiptKind = "SALES" | "WITHHOLDING";
/** Same shape as `EimsInvoiceStatus`, for a receipt instead of an invoice. */
export enum EimsReceiptStatus {
NotSubmitted = "NOT_SUBMITTED",
Submitting = "SUBMITTING",
Registered = "REGISTERED",
Failed = "FAILED",
Unknown = "UNKNOWN",
}
/**
* One `POST /v1/receipt/sales` or `POST /v1/receipt/withholding` attempt, linked to the invoice it
* was filed against. An invoice can have more than one receipt (partial payments), so this is its
* own table rather than columns on `Invoice`.
*
* No counter/sequence reservation the way invoice registration has: the collection's `/v1/receipt/*`
* examples show no MoR-enforced ordering rule on `ReceiptCounter` (unlike `InvoiceCounter`'s
* documented "expected: N"), so this is a plain row per attempt.
*/
@Entity({ schema: "freight", name: "eims_receipts" })
@Index(["invoiceId"])
export class EimsReceipt extends BaseEntity {
@Column({ name: "invoice_id", type: "uuid" })
invoiceId!: string;
@ManyToOne(() => Invoice)
@JoinColumn({ name: "invoice_id" })
invoice?: Invoice;
@Column({ name: "kind", type: "varchar", length: 16 })
kind!: EimsReceiptKind;
@Column({ name: "status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
status!: EimsReceiptStatus;
/** Our own generated `ReceiptNumber` / `ManualReceiptNumber` (both sent equal — see the service). */
@Column({ name: "receipt_number", type: "varchar", length: 64 })
receiptNumber!: string;
/** MoR's Receipt Reference Number, returned only on success. */
@Column({ name: "rrn", type: "text", nullable: true })
rrn?: string | null;
/** Base64 PNG from MoR, same convention as `Invoice.eimsSignedQr` — embed directly. */
@Column({ name: "qr", type: "text", nullable: true })
qr?: string | null;
/** MoR's own `"A"` (active) / `"F"` (failed) status marker, stored verbatim. */
@Column({ name: "ack_status", type: "varchar", length: 8, nullable: true })
ackStatus?: string | null;
@Column({ name: "submitted_at", type: "timestamptz", nullable: true })
submittedAt?: Date | null;
@Column({ name: "last_error", type: "jsonb", nullable: true })
lastError?: EimsInvoiceError | null;
/** The exact request body sent — receipts are bearer-only/unsigned, so nothing secret is in it. */
@Column({ name: "request", type: "jsonb", nullable: true })
request?: Record<string, unknown> | null;
}

View File

@@ -30,8 +30,11 @@ export class EimsSystemState extends BaseEntity {
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
inFlightDocumentNumber?: number | null;
/** IRN of the last successful registration; null until the first one succeeds. */
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
/**
* IRN of the last successful registration; null until the first one succeeds. `text`, not a
* fixed varchar — same reasoning as `Invoice.eimsIrn`: a real IRN already overflowed varchar(64).
*/
@Column({ name: "previous_irn", type: "text", nullable: true })
previousIrn?: string | null;
/**

View File

@@ -13,6 +13,7 @@ import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { MinioService } from '../minio/minio.service';
import { SignaturesService } from '../signatures/signatures.service';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
@@ -41,6 +42,7 @@ export class LastMileContractService {
private readonly pdfService: ContractPdfService,
private readonly signaturesService: SignaturesService,
private readonly dataSource: DataSource,
private readonly logoSettings: LogoSettingsService,
) {}
async getContractView(id: string, viewerUserId?: string | null) {
@@ -194,6 +196,7 @@ export class LastMileContractService {
return {
companyName: booking.company?.name ?? 'Customer',
bookingReference: booking.reference ?? request.bookingId,
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
containerCount: containers.length || null,
containerList: containers.join(', '),
cargoDescription,

View File

@@ -48,6 +48,19 @@ export class LastMileRequestsController {
return this.requestsService.freeTruckCount().then((count) => ({ count }));
}
// Customer-facing like :id — booking detail (portal + backoffice) lists the
// booking's requests to link the stored LM contract. Ownership-checked in
// the service for portal callers.
@Get('by-booking/:bookingId')
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: "A booking's last-mile requests, newest first — LM contract reference" })
findForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: TCurrentUser,
) {
return this.requestsService.findForBooking(bookingId, user?.id ?? null);
}
@Get(':id/price-estimate')
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({

View File

@@ -221,6 +221,28 @@ export class LastMileRequestsService {
return record;
}
/**
* Every request on a booking, newest first — the booking-detail pages
* (portal + backoffice) use this to surface the LM contract later. Portal
* callers pass their userId and are ownership-checked against the booking's
* company, mirroring findById.
*/
async findForBooking(bookingId: string, userId?: string | null): Promise<LastMileRequest[]> {
if (userId) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
if (companyId) {
const booking = await this.bookingsRepository.findById(bookingId);
if (booking?.companyId && booking.companyId !== companyId) {
throw new BadRequestException('This booking does not belong to your company');
}
}
}
return this.requestsRepository.findAll({
where: { bookingId },
order: { createdAt: 'DESC' },
});
}
/**
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
* delivery point, straight-line) × the LIVE last-mile rate rules against the

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MinLength } from "class-validator";
export class UpdateLogoSettingDto {
@ApiProperty({ description: "Logo image as a base64 data URL (PNG/JPG)." })
@IsString()
@MinLength(1)
logoImageBase64!: string;
}

View File

@@ -0,0 +1,25 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { FileRecord } from "../../files/entities/file.entity";
/**
* Single-row table holding the one company logo image stamped onto every
* generated document (invoices/receipts, contracts, warehouse papers,
* train-scheduling manifests, payment receipts). Same single-row shape as
* stamp_settings — `get()` lazily creates the row, and there is never more
* than one.
*/
@Entity({ schema: "freight", name: "logo_settings" })
export class LogoSetting extends BaseEntity {
@Column({ name: "logo_file_id", type: "uuid", nullable: true })
logoFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: "logo_file_id" })
logoFile?: FileRecord | null;
/** IAM user id of the last operator to set/clear the logo. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}

View File

@@ -0,0 +1,39 @@
import { Body, Controller, Delete, Get, Put } 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 { UpdateLogoSettingDto } from "./dto/update-logo-setting.dto";
import { LogoSettingsService } from "./logo-settings.service";
@ApiTags("logo-settings")
@ApiBearerAuth()
@Controller("logo-settings")
export class LogoSettingsController {
constructor(private readonly service: LogoSettingsService) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.logo.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Current company logo used on every generated document" })
get() {
return this.service.getView();
}
@Put()
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the company logo" })
update(@Body() dto: UpdateLogoSettingDto, @CurrentUser() user: TCurrentUser) {
return this.service.setLogo(dto.logoImageBase64, user?.id ?? null);
}
@Delete()
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Clear the company logo (documents fall back to their text mark)",
})
clear(@CurrentUser() user: TCurrentUser) {
return this.service.clearLogo(user?.id ?? null);
}
}

View File

@@ -0,0 +1,24 @@
import { Global, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { LogoSetting } from "./entities/logo-setting.entity";
import { LogoSettingsController } from "./logo-settings.controller";
import { LogoSettingsRepository } from "./logo-settings.repository";
import { LogoSettingsService } from "./logo-settings.service";
/**
* Global so every document-generating module (billing, contracts,
* warehouses, train-scheduling, payment) can inject {@link LogoSettingsService}
* without pulling in a circular dependency — same reasoning as
* StampSettingsModule.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([LogoSetting]), FilesModule, MinioModule],
controllers: [LogoSettingsController],
providers: [LogoSettingsRepository, LogoSettingsService],
exports: [LogoSettingsService],
})
export class LogoSettingsModule {}

View File

@@ -0,0 +1,21 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { LogoSetting } from "./entities/logo-setting.entity";
@Injectable()
export class LogoSettingsRepository extends BaseRepository<LogoSetting> {
constructor(
@InjectRepository(LogoSetting)
repo: Repository<LogoSetting>,
) {
super(repo);
}
/** The single settings row, with its logo file joined, or null before first upload. */
findSingleton(): Promise<LogoSetting | null> {
return this.repository.findOne({ where: {}, relations: ["logoFile"] });
}
}

View File

@@ -0,0 +1,161 @@
import { Injectable, Logger } from "@nestjs/common";
import { Readable } from "stream";
import { DataSource } from "typeorm";
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { MinioService } from "../minio/minio.service";
import { LogoSettingsRepository } from "./logo-settings.repository";
import { LogoSetting } from "./entities/logo-setting.entity";
export interface LogoSettingView {
logoImageUrl: string | null;
updatedById: string | null;
updatedAt: Date | null;
}
/**
* Owns the single `logo_settings` row: the one company logo image used on
* every generated document. Same single-row shape as StampSettingsService,
* the value is an uploaded image (via FilesService) rather than a scalar.
*/
@Injectable()
export class LogoSettingsService {
private readonly logger = new Logger(LogoSettingsService.name);
constructor(
private readonly repository: LogoSettingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {}
/** The settings row, created empty on first access. */
async get(): Promise<LogoSetting> {
const existing = await this.repository.findSingleton();
if (existing) return existing;
return this.repository.create({ logoFileId: null, updatedById: null });
}
/** Current logo, with the image inlined as a data URL (or null if unset). */
async getView(): Promise<LogoSettingView> {
const setting = await this.get();
return {
logoImageUrl: await this.inlineImageUrl(setting.logoFile?.url),
updatedById: setting.updatedById ?? null,
updatedAt: setting.updatedAt ?? null,
};
}
/**
* The logo image for embedding into generated documents, ALWAYS as a
* `data:` URL or null. Never throws — document generation must succeed even
* if the logo lookup fails; callers render their existing text/mark
* fallback on null (see logo-markup.util.ts).
*/
async getLogoImageUrl(): Promise<string | null> {
try {
const setting = await this.get();
const inlined = await this.inlineImageUrl(setting.logoFile?.url);
if (inlined && !inlined.startsWith("data:")) {
this.logger.warn(
`Company logo could not be inlined for document rendering (falling back to the text mark): ${inlined}`,
);
return null;
}
return inlined;
} catch (err) {
this.logger.warn(
`Could not load company logo for PDF rendering: ${(err as Error).message}`,
);
return null;
}
}
/** Replace the logo image, storing it in MinIO via FilesService. */
async setLogo(
logoImageBase64: string,
updatedById?: string | null,
): Promise<LogoSettingView> {
const current = await this.get();
const previousFileId = current.logoFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: current.id,
resource: "logo_settings",
code: "logo",
file: this.toUploadFile(logoImageBase64),
uploadedByUserId: updatedById ?? null,
});
await this.repository.update(current.id, {
logoFileId: fileRecord.id,
updatedById: updatedById ?? null,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
this.logger.log(`Company logo updated by ${updatedById ?? "unknown user"}`);
return this.getView();
}
/** Clear the logo (documents fall back to their text/mark). */
async clearLogo(updatedById?: string | null): Promise<LogoSettingView> {
const current = await this.get();
const previousFileId = current.logoFileId ?? null;
await this.repository.update(current.id, {
logoFileId: null,
updatedById: updatedById ?? null,
});
if (previousFileId) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
return this.getView();
}
private toUploadFile(base64: string): Express.Multer.File {
const raw = base64.includes(",") ? base64.split(",")[1]! : base64;
const buffer = Buffer.from(raw, "base64");
return {
fieldname: "logo",
originalname: "company-logo.png",
encoding: "7bit",
mimetype: "image/png",
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: "",
filename: "",
path: "",
};
}
private async inlineImageUrl(url?: string | null): Promise<string | null> {
if (!url) return null;
if (url.startsWith("data:")) return url;
try {
const objectName = this.minioService.getObjectNameFromUrl(url);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
return `data:image/png;base64,${buffer.toString("base64")}`;
} catch {
return url;
}
}
private streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
}
}

View File

@@ -0,0 +1,29 @@
import { NotificationsGateway } from "./notifications.gateway";
import { WsAuthService } from "./ws-auth.service";
const gateway = () => new NotificationsGateway({} as WsAuthService);
describe("NotificationsGateway", () => {
it("skips emitNew rather than throwing when no WebSocket server is attached", () => {
const g = gateway();
expect(() => g.emitNew("user-1", { id: "n-1" } as never, 3)).not.toThrow();
});
it("skips emitUnreadCount rather than throwing when no WebSocket server is attached", () => {
const g = gateway();
expect(() => g.emitUnreadCount("user-1", 3)).not.toThrow();
});
it("pushes to the user's room once a server is attached", () => {
const g = gateway();
const emit = jest.fn();
const to = jest.fn().mockReturnValue({ emit });
(g as unknown as { server: { to: typeof to } }).server = { to };
g.emitNew("user-1", { id: "n-1" } as never, 3);
expect(to).toHaveBeenCalledWith("user:user-1");
expect(emit).toHaveBeenCalledWith("notification:new", { id: "n-1" });
expect(emit).toHaveBeenCalledWith("notification:unread-count", 3);
});
});

View File

@@ -27,8 +27,10 @@ import { WsAuthService } from "./ws-auth.service";
export class NotificationsGateway implements OnGatewayConnection {
private readonly logger = new Logger(NotificationsGateway.name);
// Not `!`-asserted: Nest only wires this once the WS adapter attaches to a running HTTP
// listener, which does not happen under `NestFactory.createApplicationContext` — see `skip()`.
@WebSocketServer()
private readonly server!: Server;
private readonly server?: Server;
constructor(private readonly wsAuth: WsAuthService) {}
@@ -45,6 +47,7 @@ export class NotificationsGateway implements OnGatewayConnection {
/** Push a freshly-created notification + the new unread count to a user. */
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
if (!this.server) return this.skip("emitNew");
const room = this.server.to(this.room(userId));
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
@@ -52,11 +55,24 @@ export class NotificationsGateway implements OnGatewayConnection {
/** Push only an updated unread count (e.g. after a read on another tab). */
emitUnreadCount(userId: string, unreadCount: number): void {
if (!this.server) return this.skip("emitUnreadCount");
this.server
.to(this.room(userId))
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
}
/**
* `@WebSocketServer()` only wires `server` once the WS adapter attaches to a running HTTP
* listener — never under `NestFactory.createApplicationContext` (scripts, one-off jobs), and not
* for the brief window before `app.listen()` completes in a real boot either. The notification row
* is already persisted by this point (the caller writes it before pushing), so a missing socket
* server just means "no live push this time" — skip it rather than throw and lose the caller's
* own result (e.g. an EIMS registration outcome that already succeeded or failed for real).
*/
private skip(method: string): void {
this.logger.debug(`${method}: no WebSocket server attached (non-HTTP context?) — push skipped`);
}
private room(userId: string): string {
return `user:${userId}`;
}

View File

@@ -1,6 +1,9 @@
import { DataSource } from 'typeorm';
import { Logger } from '@nestjs/common';
import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationsService } from './notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import {
companyNotifyEmailExpr,
companyNotifyPhoneExpr,
@@ -42,3 +45,41 @@ export async function sendCompanyChannels(
}
}
}
/**
* Tell the customer their export carriage acceptance sheet is ready to
* download from the portal — the sheet itself is generated on demand by
* BookingsService.carriageAcceptanceSheet, never stored, so this is a
* "ready" notice + link, not an attachment (the email pipeline carries text
* only). Shared by every path that makes a booking's handover final: the
* warehouse gate on receive, and direct truck-to-train on load (that cargo
* never sees a warehouse, so its handover moment IS the load).
*/
export async function notifyCarriageAcceptanceReady(
dataSource: DataSource,
notifications: NotificationsService,
inbox: NotificationInboxService,
bookingId: string,
logger: Logger,
): Promise<void> {
try {
const [b]: Array<{ companyId: string | null; reference: string }> = await dataSource.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!b?.companyId) return;
const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`;
await inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Carriage acceptance sheet ready',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, reference: b.reference },
});
await sendCompanyChannels(dataSource, notifications, b.companyId, body);
} catch (err) {
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
}
}

View File

@@ -49,6 +49,7 @@ describe("PaymentService.confirmOtp", () => {
repo as never,
client as never,
billing as never,
{} as never,
);
return { service, repo, billing };
};
@@ -197,6 +198,7 @@ describe("PaymentService.markIntentSucceeded", () => {
repo as never,
{} as never,
billing as never,
{} as never,
);
return { service, repo, billing };
};

View File

@@ -13,6 +13,7 @@ import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service";
import { BillingService } from "../billing/billing.service";
import { LogoSettingsService } from "../logo-settings/logo-settings.service";
import * as fs from "fs";
import * as path from "path";
@@ -104,6 +105,7 @@ export class PaymentService {
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BillingService))
private readonly billing: BillingService,
private readonly logoSettings: LogoSettingsService,
) { }
async getAll(filters: {
@@ -657,6 +659,7 @@ export class PaymentService {
total: payment.amount.toString(),
currency: payment.currency,
reason: payment.reason,
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
}

View File

@@ -45,6 +45,14 @@
margin: 0;
}
.header .doc-logo {
display: block;
margin: 0 auto 10px;
max-height: 48px;
max-width: 180px;
object-fit: contain;
}
.details-table {
width: 100%;
border-collapse: collapse;
@@ -126,6 +134,7 @@
<body>
<div class="receipt-box">
<div class="header">
{{#if logoImageUrl}}<img class="doc-logo" src="{{logoImageUrl}}" alt="Company logo" />{{/if}}
<h1>{{vendorName}}</h1>
<p>{{vendorAddress}}</p>
</div>

View File

@@ -0,0 +1,87 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Company } from '../../companies/entities/company.entity';
import { Invoice } from '../../billing/entities/invoice.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
// "As of" — invoices due after this instant aren't overdue yet. Defaults
// to now() in SQL when the filter is unset (see the COALESCE below).
const asOf = (params.asOf as string | null) ?? null;
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.innerJoin(Company, 'c', 'c.id = i.company_id')
.where('i.deleted_at IS NULL')
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
.andWhere('i.balance_amount > 0')
.setParameter('asOf', asOf);
// ACL: invoices.source_id is a varchar pointer at the originating booking.
// Rows not pointing at a booking (e.g. warehouse fee invoices) stay visible.
return applyBookingRefDirectionScope(qb, 'i.source_id', directions);
}
export const agingReceivablesReport: ReportDefinition = {
key: 'aging-receivables',
title: 'Aging Receivables',
description: 'Outstanding customer balances bucketed by days overdue',
group: 'Finance',
filters: [{ key: 'asOf', label: 'As of', type: 'date' }],
columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'invoices', label: 'Invoices', type: 'number' },
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
{ key: 'current', label: 'Current', type: 'money' },
{ key: 'overdue0to30', label: '0-30d', type: 'money' },
{ key: 'overdue31to60', label: '31-60d', type: 'money' },
{ key: 'overdue61to90', label: '61-90d', type: 'money' },
{ key: 'overdue90plus', label: '90d+', type: 'money' },
],
defaultSort: { key: 'outstanding', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'customer')
.addSelect('COUNT(*)::int', 'invoices')
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE(:asOf::timestamptz, now())), 0))::float8`,
'current',
)
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now())
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '30 days'), 0))::float8`,
'overdue0to30',
)
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '30 days'
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '60 days'), 0))::float8`,
'overdue31to60',
)
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '60 days'
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
'overdue61to90',
)
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
'overdue90plus',
)
.groupBy('c.name');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
.addSelect('COUNT(DISTINCT c.id)::int', 'customers')
.getRawOne();
return [
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' },
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) },
];
},
};

View File

@@ -0,0 +1,109 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { BookingStatus } from '@edr/types';
import { Booking } from '../../bookings/entities/booking.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// One resolver behind "Booking per status, per port/train/date/cargo/contract
// type" — the same breakdown Operation, Marketing, Global Logistics and the
// Operation Report each ask for verbatim. Embed once, reuse everywhere.
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 = [...new Set(Object.values(BookingStatus))].map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.leftJoin(Yard, 'o', 'o.id = b.origin_yard_id')
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id')
.where('b.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('b.status IN (:...statuses)', { statuses });
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const bookingStatusBreakdownReport: ReportDefinition = {
key: 'booking-status-breakdown',
title: 'Bookings by Status',
description: 'Booking counts by status, direction, origin station, cargo and contract type',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
{ key: 'direction', label: 'Direction', type: 'string', sortable: true, sortExpr: 'b.trade_direction' },
{ key: 'originStation', label: 'Origin', type: 'string', sortable: true },
{ key: 'cargoType', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'contractKind', label: 'Contract type', type: 'string', sortable: true },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'bookings', dir: 'DESC' },
chart: { type: 'bar', x: 'status', y: ['bookings'] },
query(ctx) {
return baseQuery(ctx)
.select('b.status', 'status')
.addSelect('b.trade_direction', 'direction')
.addSelect("COALESCE(o.label, 'Unknown')", 'originStation')
.addSelect("COALESCE(cty.cargo_type_name, 'Other')", 'cargoType')
.addSelect("COALESCE(b.contract_kind, 'SPOT')", 'contractKind')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount')
.groupBy('b.status')
.addGroupBy('b.trade_direction')
.addGroupBy('o.label')
.addGroupBy('cty.cargo_type_name')
.addGroupBy('b.contract_kind');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount')
.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
{ label: 'Amount', value: Number(row?.amount ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,129 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order
// (same guard as the retired report-queries.ts).
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
// adjusted_total_amount silently overrides total_amount when set.
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
// them double-counts every child booking.
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function applyFilters(
ctx: ReportContext,
qb: SelectQueryBuilder<ObjectLiteral>,
): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) {
qb.andWhere('b.status IN (:...statuses)', { statuses });
} else {
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
}
if (params.search) {
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', {
search: `%${params.search}%`,
});
}
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
}
export const bookingsListReport: ReportDefinition = {
key: 'bookings-list',
title: 'Bookings',
description: 'Every booking with customer, route, cargo and revenue',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' },
{ key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{ key: 'cargo', label: 'Cargo', type: 'string' },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'created', dir: 'DESC' },
query(ctx) {
const qb = ctx.ds
.createQueryBuilder()
.select('b.reference', 'reference')
.addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created')
.addSelect('c.name', 'customer')
.addSelect('b.status', 'status')
.addSelect('b.trade_direction', 'direction')
.addSelect('o.label', 'origin')
.addSelect('d.label', 'destination')
.addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo')
.addSelect(`ROUND(${TONS})::float8`, 'tons')
.addSelect(`ROUND(${REVENUE})::float8`, 'amount')
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id')
.innerJoin(Yard, 'o', 'o.id = b.origin_yard_id')
.innerJoin(Yard, 'd', 'd.id = b.destination_yard_id')
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id');
return applyFilters(ctx, qb);
},
async summary(ctx) {
const qb = applyFilters(
ctx,
ctx.ds
.createQueryBuilder()
.select('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id'),
);
const row = await qb.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,58 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`)
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const cargoSummaryReport: ReportDefinition = {
key: 'cargo-summary',
title: 'Cargo Summary',
description: 'Cargo tonnage by direction and cargo type',
group: 'Operations',
filters: [{ key: 'date', label: 'Created', type: 'daterange' }],
columns: [
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
{ key: 'freightType', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
],
defaultSort: { key: 'tons', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('b.trade_direction', 'direction')
.addSelect('b.freight_type', 'freightType')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.groupBy('b.trade_direction')
.addGroupBy('b.freight_type');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect('COUNT(*)::int', 'bookings')
.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Total tonnage', value: Number(row?.tons ?? 0), unit: 't' },
];
},
};

View File

@@ -0,0 +1,83 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Contract, 'ct')
.leftJoin(Company, 'c', 'c.id = ct.company_id')
.where('ct.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo });
if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind });
if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses });
if (directions !== null) {
qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const contractLifecycleReport: ReportDefinition = {
key: 'contract-lifecycle',
title: 'Contracts',
description: 'Signed, active and cancelled contracts',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Valid from', type: 'daterange' },
{ key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'kind', label: 'Kind', type: 'string' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'freightType', label: 'Freight type', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' },
{ key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' },
{ key: 'validUntil', label: 'Valid until', type: 'date' },
{ key: 'signedAt', label: 'Signed', type: 'date' },
],
defaultSort: { key: 'validFrom', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ct.reference', 'reference')
.addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer')
.addSelect('ct.contract_kind', 'kind')
.addSelect('ct.trade_direction', 'direction')
.addSelect('ct.freight_type', 'freightType')
.addSelect('ct.status', 'status')
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
.addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed')
.addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled')
.getRawOne();
return [
{ label: 'Contracts', value: Number(row?.total ?? 0) },
{ label: 'Signed', value: Number(row?.signed ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -0,0 +1,121 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Company } from '../../companies/entities/company.entity';
import { Contract } from '../../contracts/entities/contract.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Contract, 'ct')
.leftJoin(Company, 'c', 'c.id = ct.company_id')
.leftJoin(
(sub) =>
sub
.select('s.contract_id', 'contract_id')
.addSelect('COALESCE(SUM(s.quantity_cap), 0)', 'committed')
.from('freight.contract_cargo_scope', 's')
.where('s.deleted_at IS NULL')
.groupBy('s.contract_id'),
'cap',
'cap.contract_id = ct.id',
)
.leftJoin(
(sub) =>
sub
.select('b.contract_id', 'contract_id')
.addSelect(`COALESCE(SUM(${TONS}), 0)`, 'tons')
.addSelect('COUNT(*)::int', 'cnt')
.from('freight.bookings', 'b')
.where('b.deleted_at IS NULL')
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES })
.groupBy('b.contract_id'),
'booked',
'booked.contract_id = ct.id',
)
.where('ct.deleted_at IS NULL')
.andWhere("ct.status <> 'DRAFT'");
if (params.dateFrom) {
qb.andWhere(
"(ct.contract_valid_until IS NULL OR ct.contract_valid_until >= :dateFrom::timestamptz)",
{ dateFrom: params.dateFrom },
);
}
if (params.dateTo) {
qb.andWhere('ct.contract_valid_from < :dateTo::timestamptz', { dateTo: params.dateTo });
}
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses });
if (params.contractId) {
qb.andWhere('ct.id = :contractId', { contractId: params.contractId });
}
if (directions !== null) {
qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
}
export const contractUtilizationReport: ReportDefinition = {
key: 'contract-utilization',
title: 'Contract Utilization',
description: 'Committed volume vs. booked tonnage per contract',
group: 'Commercial',
idKey: { key: 'contractId', label: 'Contract' },
filters: [
{ key: 'date', label: 'Active during', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect' },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'status', label: 'Status', type: 'string' },
{ key: 'kind', label: 'Kind', type: 'string' },
{ key: 'validFrom', label: 'Valid from', type: 'date' },
{ key: 'validUntil', label: 'Valid until', type: 'date' },
{ key: 'committed', label: 'Committed', type: 'tons' },
{ key: 'bookedTons', label: 'Booked', type: 'tons', sortable: true },
{ key: 'bookings', label: 'Bookings', type: 'number' },
{ key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true },
],
defaultSort: { key: 'utilizationPct', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ct.reference', 'reference')
.addSelect('c.name', 'customer')
.addSelect('ct.status', 'status')
.addSelect('ct.contract_kind', 'kind')
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
.addSelect('COALESCE(cap.committed, 0)::float8', 'committed')
.addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons')
.addSelect('COALESCE(booked.cnt, 0)', 'bookings')
.addSelect(
`CASE WHEN COALESCE(cap.committed, 0) > 0
THEN ROUND(COALESCE(booked.tons, 0) / cap.committed * 100)::float8 END`,
'utilizationPct',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'contracts')
.addSelect('COALESCE(SUM(booked.tons), 0)::float8', 'bookedTons')
.addSelect(
`AVG(CASE WHEN COALESCE(cap.committed, 0) > 0
THEN booked.tons / cap.committed * 100 END)::float8`,
'avgUtilization',
)
.getRawOne();
return [
{ label: 'Contracts', value: Number(row?.contracts ?? 0) },
{ label: 'Booked tonnage', value: Number(row?.bookedTons ?? 0), unit: 't' },
{ label: 'Avg utilization', value: Math.round(Number(row?.avgUtilization ?? 0)), unit: '%' },
];
},
};

View File

@@ -0,0 +1,67 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are
// CompanyProfile fields, not Company's — a company can hold several profiles
// (e.g. importer AND exporter), each independently approved/suspended.
const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(CompanyProfile, 'cp')
.innerJoin(Company, 'c', 'c.id = cp.company_id')
.where('cp.deleted_at IS NULL');
if (params.type) qb.andWhere('cp.type = :type', { type: params.type });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses });
return qb;
}
export const customerStatusReport: ReportDefinition = {
key: 'customer-status',
title: 'Customer Profiles',
description: 'Company profiles by role type and approval status',
group: 'Commercial',
filters: [
{ key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' },
{ key: 'reference', label: 'Reference', type: 'string' },
{ key: 'note', label: 'Note', type: 'string' },
{ key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' },
],
defaultSort: { key: 'reviewedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'company')
.addSelect('cp.type', 'type')
.addSelect('cp.status', 'status')
.addSelect("COALESCE(cp.reference, '')", 'reference')
.addSelect("COALESCE(cp.review_note, '')", 'note')
.addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active')
.addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended')
.setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended })
.getRawOne();
return [
{ label: 'Profiles', value: Number(row?.total ?? 0) },
{ label: 'Active', value: Number(row?.active ?? 0) },
{ label: 'Suspended', value: Number(row?.suspended ?? 0) },
];
},
};

View File

@@ -0,0 +1,66 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import {
ClearanceMilestone,
MILESTONE_OWNER_REGIONS,
MILESTONE_STATUSES,
} from '../../contracts/entities/clearance-milestone.entity';
import { ReportContext, ReportDefinition } from '../report.types';
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds.createQueryBuilder().from(ClearanceMilestone, 'cm').where('cm.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('cm.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('cm.created_at < :dateTo', { dateTo: params.dateTo });
if (params.ownerRegion) qb.andWhere('cm.owner_region = :ownerRegion', { ownerRegion: params.ownerRegion });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('cm.status IN (:...statuses)', { statuses });
return qb;
}
export const customsDocumentsReport: ReportDefinition = {
key: 'customs-documents',
title: 'Customs Clearance Milestones',
description: 'Clearance milestone volume by label, owner and status',
group: 'Operations',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'ownerRegion',
label: 'Owner',
type: 'select',
options: MILESTONE_OWNER_REGIONS.map((v) => ({ value: v, label: v })),
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: MILESTONE_STATUSES.map((v) => ({ value: v, label: v })) },
],
columns: [
{ key: 'milestone', label: 'Milestone', type: 'string', sortable: true },
{ key: 'ownerRegion', label: 'Owner', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('cm.milestone_label', 'milestone')
.addSelect("COALESCE(cm.owner_region, 'Unassigned')", 'ownerRegion')
.addSelect('cm.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('cm.milestone_label')
.addGroupBy('cm.owner_region')
.addGroupBy('cm.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect("COUNT(*) FILTER (WHERE cm.status = 'COMPLETED')::int", 'completed')
.addSelect("COUNT(*) FILTER (WHERE cm.status = 'PENDING')::int", 'pending')
.getRawOne();
return [
{ label: 'Milestones', value: Number(row?.total ?? 0) },
{ label: 'Completed', value: Number(row?.completed ?? 0) },
{ label: 'Pending', value: Number(row?.pending ?? 0) },
];
},
};

View File

@@ -0,0 +1,90 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// FirstMile and LastMile are separate tables with an identical shape (status,
// booking, optional vehicle). One resolver, unioned, with a `leg` column —
// beats shipping two near-duplicate reports for the two halves of the trip.
const LEG_UNION = `(
SELECT 'FIRST' AS leg, fm.id AS id, fm.booking_id AS booking_id, fm.status AS status,
fm.vehicle_id AS vehicle_id, fm.created_at AS created_at
FROM freight.first_mile fm WHERE fm.deleted_at IS NULL
UNION ALL
SELECT 'LAST' AS leg, lm.id AS id, lm.booking_id AS booking_id, lm.status AS status,
lm.vehicle_id AS vehicle_id, lm.created_at AS created_at
FROM freight.last_mile lm WHERE lm.deleted_at IS NULL
)`;
const STATUS_OPTIONS = [
{ value: 'PAYMENT_PENDING', label: 'Payment pending' },
{ value: 'READY_TO_TRANSIT', label: 'Ready to transit' },
{ value: 'IN_TRANSIT', label: 'In transit' },
{ value: 'RECEIVED_TO_PORT', label: 'Received to port' },
{ value: 'DELIVERED', label: 'Delivered' },
];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(LEG_UNION, 'fl')
.innerJoin(Booking, 'b', 'b.id = fl.booking_id')
.leftJoin(Company, 'c', 'c.id = b.company_id')
.leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id')
.where('1 = 1');
if (params.leg) qb.andWhere('fl.leg = :leg', { leg: params.leg });
if (params.dateFrom) qb.andWhere('fl.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses });
return qb;
}
export const firstLastMileBookingsReport: ReportDefinition = {
key: 'first-last-mile-bookings',
title: 'First/Last Mile Trucking',
description: 'First- and last-mile bookings by status and truck assignment',
group: 'Operations',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'leg', label: 'Leg', type: 'select', options: [{ value: 'FIRST', label: 'First mile' }, { value: 'LAST', label: 'Last mile' }] },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'leg', label: 'Leg', type: 'string', sortable: true },
{ key: 'booking', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'fl.status' },
{ key: 'truck', label: 'Truck', type: 'string' },
{ key: 'assigned', label: 'Assigned', type: 'string', sortable: true },
{ key: 'createdAt', label: 'Created', type: 'date', sortable: true, sortExpr: 'fl.created_at' },
],
defaultSort: { key: 'createdAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('fl.leg', 'leg')
.addSelect('b.reference', 'booking')
.addSelect("COALESCE(c.name, 'Unknown')", 'customer')
.addSelect('fl.status', 'status')
.addSelect("COALESCE(v.plate_number, '—')", 'truck')
.addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned')
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE fl.vehicle_id IS NOT NULL)::int', 'assigned')
.getRawOne();
const total = Number(row?.total ?? 0);
const assigned = Number(row?.assigned ?? 0);
return [
{ label: 'Trips', value: total },
{ label: 'Assigned', value: assigned },
{ label: 'Unassigned', value: total - assigned },
];
},
};

View File

@@ -0,0 +1,69 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net
// count unchanged) is excluded — it's neither an allocation nor a cancellation.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(ScheduleWagonAdjustmentLog, 'l')
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id')
.where('l.deleted_at IS NULL')
.andWhere("l.action IN ('ADD', 'REMOVE')");
if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
return qb;
}
export const globalLogisticsWagonsReport: ReportDefinition = {
key: 'global-logistics-wagons',
title: 'Wagon Allocations by Day',
description: 'Wagons allocated vs. cancelled per day, by direction',
group: 'Operations',
filters: [
{ key: 'date', label: 'Date', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
],
columns: [
{ key: 'date', label: 'Date', type: 'date', sortable: true, sortExpr: `date_trunc('day', l.occurred_at)` },
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
{ key: 'allocated', label: 'Allocated', type: 'number', sortable: true },
{ key: 'cancelled', label: 'Cancelled', type: 'number', sortable: true },
],
defaultSort: { key: 'date', dir: 'DESC' },
chart: { type: 'line', x: 'date', y: ['allocated', 'cancelled'] },
query(ctx) {
return baseQuery(ctx)
.select(`to_char(date_trunc('day', l.occurred_at), 'YYYY-MM-DD')`, 'date')
.addSelect("COALESCE(ts.direction, 'Unknown')", 'direction')
.addSelect("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated')
.addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled')
.groupBy(`date_trunc('day', l.occurred_at)`)
.addGroupBy('ts.direction');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated')
.addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled')
.getRawOne();
return [
{ label: 'Allocated', value: Number(row?.allocated ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -0,0 +1,72 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.innerJoin(Company, 'c', 'c.id = i.company_id')
.leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id')
.where('i.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
return qb;
}
export const invoicesByStatusReport: ReportDefinition = {
key: 'invoices-by-status',
title: 'Invoices',
description: 'Every invoice with customer, profile type and settlement status',
group: 'Finance',
filters: [
{ key: 'date', label: 'Issued', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'profileType', label: 'Profile', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' },
{ key: 'totalAmount', label: 'Total', type: 'money', sortable: true },
{ key: 'paidAmount', label: 'Paid', type: 'money' },
{ key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true },
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' },
{ key: 'dueAt', label: 'Due', type: 'date' },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('i.invoice_number', 'invoiceNumber')
.addSelect('c.name', 'customer')
.addSelect("COALESCE(cp.type, 'Unknown')", 'profileType')
.addSelect('i.status', 'status')
.addSelect('ROUND(i.total_amount)::float8', 'totalAmount')
.addSelect('ROUND(i.paid_amount)::float8', 'paidAmount')
.addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount')
.addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt')
.addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'invoices')
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total')
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
.getRawOne();
return [
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' },
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,59 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
return qb;
}
export const invoicingPipelineReport: ReportDefinition = {
key: 'invoicing-pipeline',
title: 'Invoicing Pipeline',
description: 'Invoice volume and value by type and status',
group: 'Finance',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'type', label: 'Type', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'invoices', label: 'Invoices', type: 'number', sortable: true },
{ key: 'totalAmount', label: 'Total', type: 'money', sortable: true },
{ key: 'balance', label: 'Outstanding', type: 'money', sortable: true },
],
defaultSort: { key: 'invoices', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('i.type', 'type')
.addSelect('i.status', 'status')
.addSelect('COUNT(*)::int', 'invoices')
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount')
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
.groupBy('i.type')
.addGroupBy('i.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'invoices')
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount')
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
.getRawOne();
return [
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' },
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,78 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// train_set_wagons.assigned_weight_tons is the planned load per slot, already
// maintained by the wagon-allocation flow — no need to re-derive it from
// bulk/container line items.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSetWagon, 'tsw')
.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id')
.leftJoin(WagonType, 'wt', 'wt.id = tsw.wagon_type_id')
.where('tsw.deleted_at IS NULL AND ts.deleted_at IS NULL');
if (params.trainNumber) {
qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` });
}
if (params.dateFrom) {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
return qb;
}
export const loadedCapacityReport: ReportDefinition = {
key: 'loaded-capacity',
title: 'Loaded Capacity',
description: 'Nameplate vs. loaded capacity per train, by wagon type',
group: 'Operations',
filters: [
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'date', label: 'Departure', type: 'daterange' },
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departureDate', label: 'Departure', type: 'date' },
{ key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true },
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
{ key: 'capacityTons', label: 'Capacity', type: 'tons', sortable: true },
{ key: 'loadedTons', label: 'Loaded', type: 'tons', sortable: true },
{ key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true },
],
defaultSort: { key: 'loadedTons', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('COUNT(*)::int', 'wagons')
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
.addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons')
.addSelect(
`CASE WHEN COALESCE(SUM(tsw.capacity_tons), 0) > 0
THEN ROUND(SUM(tsw.assigned_weight_tons) / SUM(tsw.capacity_tons) * 100)::float8 END`,
'utilizationPct',
)
.groupBy('ts.train_number')
.addGroupBy('ts.scheduled_departure_date')
.addGroupBy('wt.name');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'wagons')
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
.addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons')
.getRawOne();
return [
{ label: 'Wagons', value: Number(row?.wagons ?? 0) },
{ label: 'Capacity', value: Number(row?.capacityTons ?? 0), unit: 't' },
{ label: 'Loaded', value: Number(row?.loadedTons ?? 0), unit: 't' },
];
},
};

View File

@@ -0,0 +1,68 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Locomotive, LOCOMOTIVE_STATUSES } from '../../locomotives/entities/locomotive.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = LOCOMOTIVE_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Locomotive, 'l')
.leftJoin(Yard, 'y', 'y.id = l.current_yard_id')
.where('l.deleted_at IS NULL');
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses });
return qb;
}
export const locomotiveFleetStatusReport: ReportDefinition = {
key: 'locomotive-fleet-status',
title: 'Locomotive Fleet Status',
description: 'Locomotive counts by type, station and status',
group: 'Operations',
filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }],
columns: [
{ key: 'locomotiveType', label: 'Type', type: 'string', sortable: true },
{ key: 'station', label: 'Station', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
chart: { type: 'bar', x: 'status', y: ['count'] },
query(ctx) {
return baseQuery(ctx)
.select('l.locomotive_type', 'locomotiveType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('l.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('l.locomotive_type')
.addGroupBy('y.label')
.addGroupBy('l.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE l.status = :available)::int', 'available')
.addSelect('COUNT(*) FILTER (WHERE l.status = :assigned)::int', 'assigned')
.addSelect('COUNT(*) FILTER (WHERE l.status = :maintenance)::int', 'maintenance')
.addSelect('COUNT(*) FILTER (WHERE l.status = :outOfService)::int', 'outOfService')
.setParameters({
available: 'AVAILABLE',
assigned: 'ASSIGNED',
maintenance: 'MAINTENANCE',
outOfService: 'OUT_OF_SERVICE',
})
.getRawOne();
return [
{ label: 'Total locomotives', value: Number(row?.total ?? 0) },
{ label: 'Available', value: Number(row?.available ?? 0) },
{ label: 'Assigned', value: Number(row?.assigned ?? 0) },
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
{ label: 'Out of service', value: Number(row?.outOfService ?? 0) },
];
},
};

View File

@@ -0,0 +1,73 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { PaymentEntity } from '../../payment/entities/payment.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// No direct company link on payments (refId points at whatever the intent was
// for — booking, demurrage, ...); breakdown stops at status/method/currency.
const STATUS_OPTIONS = [
{ value: 'action-required', label: 'Action required' },
{ value: 'processing', label: 'Processing' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'canceled', label: 'Canceled' },
{ value: 'refunded', label: 'Refunded' },
];
const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map(
(v) => ({ value: v, label: v }),
);
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
// payments carries no deleted_at column (unlike the rest of the schema) —
// confirmed against the live DB, not assumed from BaseEntity.
const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1');
if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo });
if (params.method) qb.andWhere('p.method = :method', { method: params.method });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses });
return qb;
}
export const paymentsByStatusReport: ReportDefinition = {
key: 'payments-by-status',
title: 'Payments by Status',
description: 'Payment volume and value by status, method and currency',
group: 'Finance',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'method', label: 'Method', type: 'string', sortable: true },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'payments', label: 'Payments', type: 'number', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'amount', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('p.status', 'status')
.addSelect('p.method', 'method')
.addSelect('p.currency', 'currency')
.addSelect('COUNT(*)::int', 'payments')
.addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount')
.groupBy('p.status')
.addGroupBy('p.method')
.addGroupBy('p.currency');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'payments')
.addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid')
.getRawOne();
return [
{ label: 'Payments', value: Number(row?.payments ?? 0) },
{ label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,91 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id')
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) {
qb.andWhere('b.status IN (:...statuses)', { statuses });
} else {
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
}
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
}
export const revenueByCustomerReport: ReportDefinition = {
key: 'revenue-by-customer',
title: 'Revenue by Customer',
description: 'Ranked customers by booking revenue',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect' },
],
columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
],
defaultSort: { key: 'revenue', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'customer')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.groupBy('c.name');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(DISTINCT c.name)::int', 'customers')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.getRawOne();
return [
{ label: 'Customers', value: Number(row?.customers ?? 0) },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,62 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`)
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const revenueSummaryReport: ReportDefinition = {
key: 'revenue-summary',
title: 'Revenue Summary',
description: 'Booking revenue by direction, cargo type and currency',
group: 'Finance',
filters: [{ key: 'date', label: 'Created', type: 'daterange' }],
columns: [
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
{ key: 'freightType', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
],
defaultSort: { key: 'revenue', dir: 'DESC' },
chart: { type: 'bar', x: 'direction', y: ['revenue'] },
query(ctx) {
return baseQuery(ctx)
.select('b.trade_direction', 'direction')
.addSelect('b.freight_type', 'freightType')
.addSelect('b.payment_currency', 'currency')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.groupBy('b.trade_direction')
.addGroupBy('b.freight_type')
.addGroupBy('b.payment_currency');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.addSelect('COUNT(*)::int', 'bookings')
.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,100 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSchedule, TRAIN_SCHEDULE_STATUSES } from '../../train-schedules/entities/train-schedule.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// ITLMS's spec lists Scheduled/Dispatched/In Transit/Arrived/Cancelled as the
// train lifecycle. The platform tracks DRAFT/SCHEDULED/DISPATCHED/ARRIVED/
// CANCELLED — no separate "in transit" status exists (a dispatched schedule
// with no actual_arrival_at yet *is* in transit; reported as DISPATCHED).
const STATUS_OPTIONS = TRAIN_SCHEDULE_STATUSES.map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSchedule, 'ts')
.leftJoin(Yard, 'o', 'o.id = ts.origin_station_id')
.leftJoin(Yard, 'd', 'd.id = ts.destination_station_id')
.where('ts.deleted_at IS NULL');
if (params.dateFrom) {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) {
qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
}
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ts.status IN (:...statuses)', { statuses });
return qb;
}
export const trainScheduleStatusReport: ReportDefinition = {
key: 'train-schedule-status',
title: 'Train Schedules',
description: 'Scheduled, dispatched, arrived and cancelled train departures',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departure', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'reference', label: 'Reference', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ts.status' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{
key: 'scheduledDeparture',
label: 'Scheduled dep.',
type: 'date',
sortable: true,
sortExpr: 'ts.scheduled_departure_date',
},
{ key: 'actualDeparture', label: 'Actual dep.', type: 'date' },
{ key: 'actualArrival', label: 'Actual arr.', type: 'date' },
],
defaultSort: { key: 'scheduledDeparture', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect('ts.reference', 'reference')
.addSelect('ts.status', 'status')
.addSelect('ts.direction', 'direction')
.addSelect("COALESCE(o.label, 'Unknown')", 'origin')
.addSelect("COALESCE(d.label, 'Unknown')", 'destination')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'scheduledDeparture')
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :scheduled)::int', 'scheduled')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :dispatched)::int', 'dispatched')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :arrived)::int', 'arrived')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :cancelled)::int', 'cancelled')
.setParameters({ scheduled: 'SCHEDULED', dispatched: 'DISPATCHED', arrived: 'ARRIVED', cancelled: 'CANCELLED' })
.getRawOne();
return [
{ label: 'Total', value: Number(row?.total ?? 0) },
{ label: 'Scheduled', value: Number(row?.scheduled ?? 0) },
{ label: 'Dispatched', value: Number(row?.dispatched ?? 0) },
{ label: 'Arrived', value: Number(row?.arrived ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -0,0 +1,86 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// "Turnaround" here is departure-to-arrival transit time on the actual (not
// scheduled) timestamps. Station dwell time (arrival -> the SAME train's next
// departure) would need pairing consecutive schedules by physical train,
// which isn't tracked directly — deferred, not modeled as a shortcut.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSchedule, 'ts')
.leftJoin(Yard, 'o', 'o.id = ts.origin_station_id')
.leftJoin(Yard, 'd', 'd.id = ts.destination_station_id')
.where('ts.deleted_at IS NULL')
.andWhere('ts.actual_departure_at IS NOT NULL')
.andWhere('ts.actual_arrival_at IS NOT NULL');
if (params.dateFrom) qb.andWhere('ts.actual_departure_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('ts.actual_departure_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
return qb;
}
export const trainTurnaroundReport: ReportDefinition = {
key: 'train-turnaround',
title: 'Train Turnaround',
description: 'Actual departure-to-arrival transit time per schedule',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departed', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{
key: 'actualDeparture',
label: 'Departed',
type: 'date',
sortable: true,
sortExpr: 'ts.actual_departure_at',
},
{ key: 'actualArrival', label: 'Arrived', type: 'date' },
{ key: 'transitHours', label: 'Transit (hrs)', type: 'number', sortable: true },
],
defaultSort: { key: 'actualDeparture', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect("COALESCE(o.label, 'Unknown')", 'origin')
.addSelect("COALESCE(d.label, 'Unknown')", 'destination')
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival')
.addSelect(
`ROUND(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at))::numeric / 3600, 1)::float8`,
'transitHours',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'trips')
.addSelect(
`ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at)))::numeric / 3600, 1)::float8`,
'avgHours',
)
.getRawOne();
return [
{ label: 'Trips', value: Number(row?.trips ?? 0) },
{ label: 'Avg transit', value: Number(row?.avgHours ?? 0), unit: 'h' },
];
},
};

View File

@@ -0,0 +1,77 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WagonStatus } from '@edr/types';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(WagonStatus).map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Yard, 'y', 'y.id = w.current_yard_id')
.where('w.deleted_at IS NULL');
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('w.status IN (:...statuses)', { statuses });
return qb;
}
export const wagonFleetStatusReport: ReportDefinition = {
key: 'wagon-fleet-status',
title: 'Wagon Fleet Status',
description: 'Wagon counts by type, station and status',
group: 'Operations',
filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }],
columns: [
{ key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true },
{ key: 'station', label: 'Station', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
chart: { type: 'bar', x: 'status', y: ['count'] },
query(ctx) {
return baseQuery(ctx)
.select('COALESCE(wt.name, \'Unknown\')', 'wagonType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('w.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('wt.name')
.addGroupBy('y.label')
.addGroupBy('w.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE w.status = :available)::int', 'available')
.addSelect('COUNT(*) FILTER (WHERE w.status = :assigned)::int', 'assigned')
.addSelect('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance')
.addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained')
.addSelect('COUNT(*) FILTER (WHERE w.status = :outOfService)::int', 'outOfService')
.setParameters({
available: WagonStatus.Available,
assigned: WagonStatus.Assigned,
maintenance: WagonStatus.Maintenance,
detained: WagonStatus.Detained,
outOfService: WagonStatus.OutOfService,
})
.getRawOne();
return [
{ label: 'Total wagons', value: Number(row?.total ?? 0) },
{ label: 'Available', value: Number(row?.available ?? 0) },
{ label: 'Assigned', value: Number(row?.assigned ?? 0) },
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
{ label: 'Detained', value: Number(row?.detained ?? 0) },
{ label: 'Out of service', value: Number(row?.outOfService ?? 0) },
];
},
};

View File

@@ -0,0 +1,85 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WagonTransferRequestStatus } from '@edr/types';
import { WagonTransferRequest } from '../../wagons/entities/wagon-transfer-request.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(WagonTransferRequestStatus).map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(WagonTransferRequest, 'r')
.leftJoin(Yard, 'fy', 'fy.id = r.from_yard_id')
.leftJoin(Yard, 'ty', 'ty.id = r.to_yard_id')
.leftJoin(WagonType, 'wt', 'wt.id = r.wagon_type_id')
.where('r.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('r.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('r.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('r.status IN (:...statuses)', { statuses });
return qb;
}
export const wagonRequestsReport: ReportDefinition = {
key: 'wagon-requests',
title: 'Wagon Requests',
description: 'Inter-yard wagon transfer requests and fulfilment delay',
group: 'Operations',
filters: [
{ key: 'date', label: 'Requested', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'fromYard', label: 'From', type: 'string', sortable: true, sortExpr: 'fy.label' },
{ key: 'toYard', label: 'To', type: 'string', sortable: true, sortExpr: 'ty.label' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'quantity', label: 'Requested', type: 'number' },
{ key: 'fulfilledQuantity', label: 'Fulfilled', type: 'number' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'r.status' },
{ key: 'requestedAt', label: 'Requested at', type: 'date', sortable: true, sortExpr: 'r.created_at' },
{ key: 'fulfilledAt', label: 'Fulfilled at', type: 'date' },
{ key: 'delayDays', label: 'Delay (days)', type: 'number', sortable: true },
],
defaultSort: { key: 'requestedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('fy.label', 'fromYard')
.addSelect('ty.label', 'toYard')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('r.quantity', 'quantity')
.addSelect('r.fulfilled_quantity', 'fulfilledQuantity')
.addSelect('r.status', 'status')
.addSelect(`to_char(r.created_at, 'YYYY-MM-DD')`, 'requestedAt')
.addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD')`, 'fulfilledAt')
.addSelect(
`ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`,
'delayDays',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'requests')
.addSelect('COUNT(*) FILTER (WHERE r.status IN (:...openStatuses))::int', 'open')
.addSelect(
`ROUND(AVG(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400), 1)::float8`,
'avgDelayDays',
)
.setParameters({
openStatuses: [WagonTransferRequestStatus.Pending, WagonTransferRequestStatus.PartiallyFulfilled],
})
.getRawOne();
return [
{ label: 'Requests', value: Number(row?.requests ?? 0) },
{ label: 'Still open', value: Number(row?.open ?? 0) },
{ label: 'Avg delay', value: Number(row?.avgDelayDays ?? 0), unit: 'd' },
];
},
};

View File

@@ -0,0 +1,94 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WagonStatus } from '@edr/types';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// Only these two statuses have an operational "how long has it been stuck
// here" question — everything else (Available, Assigned, ...) turns over too
// fast for a days-in-status view to matter.
const TRACKED_STATUSES = [WagonStatus.Maintenance, WagonStatus.Detained];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Yard, 'y', 'y.id = w.current_yard_id')
// Latest time each wagon flipped INTO its current status, per (wagon, status)
// pair — a plain (non-correlated) derived table, joined on both columns, so
// it stays a normal JOIN rather than needing a LATERAL correlated subquery.
.leftJoin(
(sub) =>
sub
.select('l.wagon_id', 'wagon_id')
.addSelect('l.to_status', 'to_status')
.addSelect('MAX(l.created_at)', 'since')
.from('freight.wagon_status_logs', 'l')
.groupBy('l.wagon_id')
.addGroupBy('l.to_status'),
'log',
'log.wagon_id = w.id AND log.to_status = w.status',
)
.where('w.deleted_at IS NULL')
.andWhere('w.status IN (:...trackedStatuses)', { trackedStatuses: TRACKED_STATUSES });
const status = params.status as string | null;
if (status) qb.andWhere('w.status = :status', { status });
return qb;
}
export const wagonStatusDurationReport: ReportDefinition = {
key: 'wagon-status-duration',
title: 'Wagon Status Duration',
description: 'How long each wagon has sat in Maintenance or Detained',
group: 'Operations',
filters: [
{
key: 'status',
label: 'Status',
type: 'select',
options: TRACKED_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })),
},
],
columns: [
{ key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'station', label: 'Station', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'w.status' },
{ key: 'since', label: 'Since', type: 'date', sortable: true },
{ key: 'daysInStatus', label: 'Days in status', type: 'number', sortable: true },
],
defaultSort: { key: 'daysInStatus', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('w.wagon_number', 'wagonNumber')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('w.status', 'status')
.addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD')`, 'since')
.addSelect(
`FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`,
'daysInStatus',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance')
.addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained')
.addSelect(
`MAX(FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400))::int`,
'longest',
)
.setParameters({ maintenance: WagonStatus.Maintenance, detained: WagonStatus.Detained })
.getRawOne();
return [
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
{ label: 'Detained', value: Number(row?.detained ?? 0) },
{ label: 'Longest days in status', value: Number(row?.longest ?? 0), unit: 'd' },
];
},
};

View File

@@ -0,0 +1,76 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { Container } from '../../container-management/entities/container.entity';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// TEU = container size in feet / 20 (20ft -> 1 TEU, 40ft -> 2 TEU). Scoped to
// each wagon's CURRENT schedule pin — a live-state view, not a historical one.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.innerJoin(TrainSchedule, 'ts', 'ts.id = w.current_train_schedule_id')
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Container, 'c', 'c.wagon_id = w.id AND c.deleted_at IS NULL')
.leftJoin(ContainerType, 'ct', 'ct.id = c.container_type_id')
.where('w.deleted_at IS NULL');
if (params.trainNumber) {
qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` });
}
if (params.dateFrom) {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
return qb;
}
export const wagonTeuUtilizationReport: ReportDefinition = {
key: 'wagon-teu-utilization',
title: 'Wagon TEU Utilization',
description: 'TEU loaded per wagon on its currently assigned train',
group: 'Operations',
filters: [
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'date', label: 'Departure', type: 'daterange' },
],
columns: [
{ key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departureDate', label: 'Departure', type: 'date' },
{ key: 'containers', label: 'Containers', type: 'number', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
],
defaultSort: { key: 'teu', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('w.wagon_number', 'wagonNumber')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
.addSelect('COUNT(c.id)::int', 'containers')
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
.groupBy('w.wagon_number')
.addGroupBy('wt.name')
.addGroupBy('ts.train_number')
.addGroupBy('ts.scheduled_departure_date');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(DISTINCT w.id)::int', 'wagons')
.addSelect('COUNT(c.id)::int', 'containers')
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
.getRawOne();
return [
{ label: 'Wagons', value: Number(row?.wagons ?? 0) },
{ label: 'Containers', value: Number(row?.containers ?? 0) },
{ label: 'Total TEU', value: Number(row?.teu ?? 0) },
];
},
};

View File

@@ -1,54 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';
export class ReportQueryDto {
@ApiPropertyOptional({ description: 'Inclusive start date (YYYY-MM-DD). Default: 30 days ago.' })
@IsOptional()
@IsString()
dateFrom?: string;
@ApiPropertyOptional({ description: 'Inclusive end date (YYYY-MM-DD). Default: today.' })
@IsOptional()
@IsString()
dateTo?: string;
@ApiPropertyOptional({ enum: ['day', 'week', 'month'], default: 'day' })
@IsOptional()
@IsIn(['day', 'week', 'month'])
granularity?: 'day' | 'week' | 'month';
@ApiPropertyOptional({ description: 'Comma-separated company UUIDs' })
@IsOptional()
@IsString()
companyIds?: string;
@ApiPropertyOptional({ description: 'Comma-separated route UUIDs' })
@IsOptional()
@IsString()
routeIds?: string;
@ApiPropertyOptional({ description: 'Comma-separated yard UUIDs (matches origin or destination)' })
@IsOptional()
@IsString()
yardIds?: string;
@ApiPropertyOptional({ description: 'Comma-separated cargo type UUIDs' })
@IsOptional()
@IsString()
cargoTypeIds?: string;
@ApiPropertyOptional({ description: 'Comma-separated status values (report-specific)' })
@IsOptional()
@IsString()
statuses?: string;
@ApiPropertyOptional({ description: 'Trade direction filter' })
@IsOptional()
@IsString()
direction?: string;
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
@IsOptional()
@IsIn(['CONTAINER', 'BULK'])
freightType?: string;
}

View File

@@ -1,24 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ReportKpiDto {
@ApiProperty()
label!: string;
@ApiProperty()
value!: number;
@ApiPropertyOptional()
unit?: string;
}
export class ReportResultDto {
@ApiProperty({ type: [ReportKpiDto] })
kpis!: ReportKpiDto[];
@ApiProperty({
type: 'array',
items: { type: 'object', additionalProperties: true },
description: 'Report rows; columns vary per report key',
})
rows!: Record<string, unknown>[];
}

View File

@@ -0,0 +1,62 @@
import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service';
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
import { ReportColumn } from './report.types';
describe('resolveExportFormat', () => {
it('only \'pdf\' exports as pdf', () => {
expect(resolveExportFormat('pdf')).toBe('pdf');
});
it.each([undefined, 'xlsx', 'csv', ''])('%p falls back to xlsx', (raw) => {
expect(resolveExportFormat(raw)).toBe('xlsx');
});
});
describe('resolveExportCap', () => {
it('missing limit uses the full format cap', () => {
expect(resolveExportCap('xlsx', undefined)).toBe(XLSX_ROW_CAP);
expect(resolveExportCap('pdf', undefined)).toBe(PDF_ROW_CAP);
});
it('a limit under the cap is used as-is', () => {
expect(resolveExportCap('pdf', '100')).toBe(100);
});
it('a limit over the cap is clamped down', () => {
expect(resolveExportCap('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP);
expect(resolveExportCap('xlsx', String(XLSX_ROW_CAP + 1))).toBe(XLSX_ROW_CAP);
});
it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p falls back to the cap', (raw) => {
expect(resolveExportCap('xlsx', raw)).toBe(XLSX_ROW_CAP);
});
});
describe('resolveExportColumns', () => {
const columns: ReportColumn[] = [
{ key: 'a', label: 'A', type: 'string' },
{ key: 'b', label: 'B', type: 'number' },
{ key: 'c', label: 'C', type: 'money' },
];
const def = { columns };
it('missing fields returns every column', () => {
expect(resolveExportColumns(def, undefined)).toEqual(columns);
});
it('empty fields string returns every column', () => {
expect(resolveExportColumns(def, '')).toEqual(columns);
});
it('a known subset filters to just those columns, in the report\'s own order', () => {
expect(resolveExportColumns(def, 'c,a')).toEqual([columns[0], columns[2]]);
});
it('unknown keys are dropped, not passed through', () => {
expect(resolveExportColumns(def, 'a,ghost')).toEqual([columns[0]]);
});
it('all-unknown keys falls back to every column instead of a blank sheet', () => {
expect(resolveExportColumns(def, 'ghost,also-ghost')).toEqual(columns);
});
});

View File

@@ -0,0 +1,29 @@
import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service';
import { ReportColumn, ReportDefinition } from './report.types';
export type ExportFormat = 'xlsx' | 'pdf';
/** Anything but the literal string 'pdf' exports as xlsx. */
export function resolveExportFormat(raw: string | undefined): ExportFormat {
return raw === 'pdf' ? 'pdf' : 'xlsx';
}
/** Caller's requested row limit, clamped to the format's hard cap. A
* missing/non-positive/non-numeric limit means "as many as the format allows". */
export function resolveExportCap(format: ExportFormat, rawLimit: string | undefined): number {
const formatCap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
const requested = Number(rawLimit);
return requested > 0 ? Math.min(requested, formatCap) : formatCap;
}
/** Caller's requested column subset, whitelisted against the report's own
* columns. Missing, empty, or all-unknown `rawFields` falls back to every
* column rather than shipping a blank sheet. */
export function resolveExportColumns(
def: Pick<ReportDefinition, 'columns'>,
rawFields: string | undefined,
): ReportColumn[] {
const requested = rawFields?.split(',').filter(Boolean);
const filtered = requested?.length ? def.columns.filter((c) => requested.includes(c.key)) : def.columns;
return filtered.length ? filtered : def.columns;
}

View File

@@ -0,0 +1,117 @@
import { Injectable } from '@nestjs/common';
import ExcelJS from 'exceljs';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { ReportColumn, ReportDefinition, ReportKpi } from './report.types';
// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming
// WorkbookWriter if a report ever needs to outgrow XLSX_ROW_CAP.
export const XLSX_ROW_CAP = 50_000;
// ponytail: HTML→PDF render cost grows with row count; larger exports must
// use XLSX instead.
export const PDF_ROW_CAP = 5_000;
const NUMBER_FORMAT: Partial<Record<ReportColumn['type'], string>> = {
money: '#,##0.00',
tons: '#,##0.0',
percent: '0"%"',
number: '#,##0',
};
function formatCell(value: unknown, type: ReportColumn['type']): string {
if (value === null || value === undefined) return '';
if (type === 'money' || type === 'number') {
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 });
}
if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`;
if (type === 'percent') return `${value}%`;
return String(value);
}
@Injectable()
export class ReportExportService {
constructor(private readonly pdfRender: PdfRenderService) {}
async toXlsx(
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[] = def.columns,
): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(def.title.slice(0, 31));
if (kpis.length) {
sheet.addRow(kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`));
sheet.addRow([]);
}
const headerRow = sheet.addRow(columns.map((c) => c.label));
headerRow.font = { bold: true };
for (const row of rows) {
sheet.addRow(columns.map((c) => row[c.key] ?? null));
}
columns.forEach((col, i) => {
const format = NUMBER_FORMAT[col.type];
const excelCol = sheet.getColumn(i + 1);
excelCol.width = Math.max(col.label.length + 2, 12);
if (format) excelCol.numFmt = format;
});
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
async toPdf(
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[] = def.columns,
): Promise<Buffer> {
const html = this.buildHtml(def, rows, kpis, columns);
return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true });
}
private buildHtml(
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[],
): string {
const esc = (v: unknown) =>
String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kpiHtml = kpis.length
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
.map(
(k) =>
`<div><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
)
.join('')}</div>`
: '';
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const body = rows
.map(
(row) =>
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
)
.join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
h1 { font-size: 16px; margin-bottom: 4px; }
p.desc { color: #666; margin-top: 0 0 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
th { background: #f3f3f3; }
</style></head><body>
<h1>${esc(def.title)}</h1>
<p class="desc">${esc(def.description)}</p>
${kpiHtml}
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
</body></html>`;
}
}

View File

@@ -1,669 +0,0 @@
import { DataSource } from 'typeorm';
export interface ReportFilters {
/** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */
dateFrom: string | null;
/** ISO timestamp, exclusive upper bound. null = no upper bound. */
dateTo: string | null;
granularity: 'day' | 'week' | 'month';
companyIds: string[] | null;
routeIds: string[] | null;
yardIds: string[] | null;
cargoTypeIds: string[] | null;
statuses: string[] | null;
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
directions: string[] | null;
freightType: string | null;
}
export interface ReportKpi {
label: string;
value: number;
unit?: string;
}
export interface ReportResult {
kpis: ReportKpi[];
rows: Record<string, unknown>[];
}
type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise<ReportResult>;
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order.
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
// adjusted_total_amount silently overrides total_amount when set.
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
// them double-counts every child booking (same guard as overview.repository).
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'";
const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v));
const sum = (rows: Record<string, unknown>[], col: string): number =>
rows.reduce((acc, r) => acc + num(r[col]), 0);
/**
* Shared WHERE for booking-based reports (alias `b`).
* Params occupy $1..$8 in this fixed order; report SQL continues at $9.
*/
function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } {
return {
where: `
b.deleted_at IS NULL
AND ${NOT_UMBRELLA}
AND ($1::timestamptz IS NULL OR b.created_at >= $1)
AND ($2::timestamptz IS NULL OR b.created_at < $2)
AND ($3::uuid[] IS NULL OR b.company_id = ANY($3))
AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4))
AND ($5::text[] IS NULL OR b.trade_direction = ANY($5))
AND ($6::text IS NULL OR b.freight_type = $6)
AND (CASE WHEN $7::text[] IS NULL
THEN b.status NOT IN (${DEAD_STATUSES})
ELSE b.status = ANY($7) END)
AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`,
params: [
f.dateFrom,
f.dateTo,
f.companyIds,
f.cargoTypeIds,
f.directions,
f.freightType,
f.statuses,
f.yardIds,
],
};
}
/**
* Direction scope for rows that reference a booking through a varchar id
* column (invoices.source_id, payments.ref_id). Rows not pointing at a
* booking stay visible — they carry no direction to scope by.
* (Positional-param port of trade-scope.util's bookingRefScopeSql.)
*/
const refDirScope = (refColumn: string, param: string): string => `
(${param}::text[] IS NULL OR NOT EXISTS (
SELECT 1 FROM freight.bookings sb
WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`;
const bookingsTrend: ReportQuery = async (ds, f) => {
const { where, params } = bookingWhere(f);
const rows = await ds.query(
`SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period,
COUNT(*)::int AS bookings,
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
FROM freight.bookings b
WHERE ${where}
GROUP BY 1 ORDER BY 1`,
[...params, f.granularity],
);
return {
kpis: [
{ label: 'Bookings', value: sum(rows, 'bookings') },
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
],
rows,
};
};
const revenueByCustomer: ReportQuery = async (ds, f) => {
const { where, params } = bookingWhere(f);
const rows = await ds.query(
`SELECT c.name AS customer,
COUNT(*)::int AS bookings,
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
FROM freight.bookings b
JOIN freight.companies c ON c.id = b.company_id
WHERE ${where}
GROUP BY c.name ORDER BY revenue DESC LIMIT 100`,
params,
);
const total = sum(rows, 'revenue');
return {
kpis: [
{ label: 'Customers', value: rows.length },
{ label: 'Revenue', value: total, unit: 'ETB' },
{
label: 'Top customer share',
value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0,
unit: '%',
},
],
rows,
};
};
const revenueByLane: ReportQuery = async (ds, f) => {
const { where, params } = bookingWhere(f);
const rows = await ds.query(
`SELECT o.label AS origin, d.label AS destination,
COUNT(*)::int AS bookings,
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
FROM freight.bookings b
JOIN freight.yards o ON o.id = b.origin_yard_id
JOIN freight.yards d ON d.id = b.destination_yard_id
WHERE ${where}
GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`,
params,
);
return {
kpis: [
{ label: 'Lanes', value: rows.length },
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
],
rows,
};
};
const contractUtilization: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind,
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
cap.committed::float8 AS committed,
booked.tons::float8 AS booked_tons,
booked.cnt AS bookings,
CASE WHEN cap.committed > 0
THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct
FROM freight.contracts ct
LEFT JOIN freight.companies c ON c.id = ct.company_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed
FROM freight.contract_cargo_scope s
WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt
FROM freight.bookings b
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
AND b.status NOT IN (${DEAD_STATUSES})) booked ON true
WHERE ct.deleted_at IS NULL
AND ct.status NOT IN ('DRAFT')
AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity')
AND (ct.contract_valid_until IS NULL
OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity'))
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
AND ($5::text[] IS NULL OR ct.status = ANY($5))
ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`,
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
);
const capped = rows.filter((r: Record<string, unknown>) => num(r.committed) > 0);
return {
kpis: [
{ label: 'Contracts', value: rows.length },
{
label: 'Avg utilization',
value: capped.length
? Math.round(sum(capped, 'utilization_pct') / capped.length)
: 0,
unit: '%',
},
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
],
rows,
};
};
// ponytail: 60-min departure grace is a constant; make it a query param if ops
// ever wants a configurable threshold.
const trainOnTime: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT o.label AS origin, d.label AS destination,
COUNT(*)::int AS trips,
COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed,
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60)
FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min,
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60)
FILTER (WHERE ts.actual_arrival_at IS NOT NULL
AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min,
ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at
<= ts.scheduled_departure_date + interval '60 minutes')
/ NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id
JOIN freight.yards d ON d.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DISPATCHED', 'ARRIVED')
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
GROUP BY 1, 2 ORDER BY trips DESC`,
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
);
const departed = sum(rows, 'departed');
const weighted = rows.reduce(
(acc: number, r: Record<string, unknown>) =>
acc + (num(r.on_time_pct) * num(r.departed)) / 100,
0,
);
return {
kpis: [
{ label: 'Trips', value: sum(rows, 'trips') },
{
label: 'On-time departures',
value: departed > 0 ? Math.round((weighted / departed) * 100) : 0,
unit: '%',
},
{
label: 'Avg departure delay',
value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0,
unit: 'min',
},
],
rows,
};
};
const scheduleFillRate: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT ts.train_number, ts.reference,
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure,
o.label AS origin, d.label AS destination, ts.direction, ts.status,
ts.max_wagons, tset.wagon_count,
ROUND(w.cap_tons)::float8 AS capacity_tons,
ROUND(w.booked_tons)::float8 AS booked_tons,
CASE WHEN w.cap_tons > 0
THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id
JOIN freight.yards d ON d.id = ts.destination_station_id
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons,
COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
FROM freight.train_set_wagons tw
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
WHERE ts.deleted_at IS NULL
AND ts.status <> 'CANCELLED'
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
ORDER BY ts.scheduled_departure_date DESC LIMIT 200`,
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
);
const withCap = rows.filter((r: Record<string, unknown>) => num(r.capacity_tons) > 0);
const capTons = sum(withCap, 'capacity_tons');
return {
kpis: [
{ label: 'Schedules', value: rows.length },
{
label: 'Avg fill rate',
value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0,
unit: '%',
},
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
],
rows,
};
};
const tripsPerRoute: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT o.label AS origin, d.label AS destination, ts.direction,
COUNT(*)::int AS trips,
ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled,
ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id
JOIN freight.yards d ON d.id = ts.destination_station_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
FROM freight.train_set_wagons tw
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DISPATCHED', 'ARRIVED')
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
GROUP BY 1, 2, 3 ORDER BY trips DESC`,
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
);
return {
kpis: [
{ label: 'Trips', value: sum(rows, 'trips') },
{ label: 'Routes served', value: rows.length },
{ label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' },
],
rows,
};
};
const invoicedVsCollected: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period,
COUNT(*)::int AS invoices,
ROUND(SUM(i.total_amount))::float8 AS invoiced,
ROUND(SUM(i.paid_amount))::float8 AS collected,
ROUND(SUM(i.balance_amount))::float8 AS outstanding
FROM freight.invoices i
WHERE i.deleted_at IS NULL
AND i.status NOT IN ('DRAFT', 'CANCELLED')
AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1)
AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2)
AND ($3::uuid[] IS NULL OR i.company_id = ANY($3))
AND ${refDirScope('i.source_id', '$4')}
GROUP BY 1 ORDER BY 1`,
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity],
);
const invoiced = sum(rows, 'invoiced');
const collected = sum(rows, 'collected');
return {
kpis: [
{ label: 'Invoiced', value: invoiced, unit: 'ETB' },
{ label: 'Collected', value: collected, unit: 'ETB' },
{
label: 'Collection rate',
value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0,
unit: '%',
},
{ label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' },
],
rows,
};
};
// Aging is an as-of snapshot: dateTo is the as-of moment (default now),
// dateFrom is ignored.
const agingReceivables: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT c.name AS customer,
COUNT(*)::int AS invoices,
ROUND(SUM(i.balance_amount))::float8 AS outstanding,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now())
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days'
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days'
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus
FROM freight.invoices i
JOIN freight.companies c ON c.id = i.company_id
WHERE i.deleted_at IS NULL
AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE')
AND i.balance_amount > 0
AND ($1::timestamptz IS NULL OR i.created_at < $1)
AND ($2::uuid[] IS NULL OR i.company_id = ANY($2))
AND ${refDirScope('i.source_id', '$3')}
GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`,
[f.dateTo, f.companyIds, f.directions],
);
const outstanding = sum(rows, 'outstanding');
return {
kpis: [
{ label: 'Outstanding', value: outstanding, unit: 'ETB' },
{ label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' },
{ label: 'Customers with balance', value: rows.length },
],
rows,
};
};
const revenueByPaymentMethod: ReportQuery = async (ds, f) => {
// payments.status values are lowercase-hyphenated ('success'), unlike every
// other status enum in the schema. No deleted_at on this table.
const rows = await ds.query(
`SELECT p.method::text AS method,
COUNT(*)::int AS payments,
ROUND(SUM(p.amount))::float8 AS amount
FROM freight.payments p
WHERE p.status = 'success'
AND ($1::timestamptz IS NULL OR p.created_at >= $1)
AND ($2::timestamptz IS NULL OR p.created_at < $2)
AND ${refDirScope('p.ref_id', '$3')}
GROUP BY 1 ORDER BY amount DESC`,
[f.dateFrom, f.dateTo, f.directions],
);
const total = sum(rows, 'amount');
return {
kpis: [
{ label: 'Collected', value: total, unit: 'ETB' },
{ label: 'Payments', value: sum(rows, 'payments') },
{
label: 'Top method share',
value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0,
unit: '%',
},
],
rows,
};
};
// ---------------------------------------------------------------------------
// Record-level list exports. Same engine, raw rows instead of aggregates.
// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table
// ever outgrows that.
const LIST_LIMIT = 5000;
const bookingsList: ReportQuery = async (ds, f) => {
const { where, params } = bookingWhere(f);
const rows = await ds.query(
`SELECT b.reference,
to_char(b.created_at, 'YYYY-MM-DD') AS created,
c.name AS customer, b.status, b.freight_type,
b.trade_direction AS direction,
o.label AS origin, d.label AS destination,
COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo,
ROUND(${TONS})::float8 AS tons,
ROUND(${REVENUE})::float8 AS amount,
b.payment_status, b.scheduling_status
FROM freight.bookings b
JOIN freight.companies c ON c.id = b.company_id
JOIN freight.yards o ON o.id = b.origin_yard_id
JOIN freight.yards d ON d.id = b.destination_yard_id
LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id
WHERE ${where}
ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`,
params,
);
return {
kpis: [
{ label: 'Bookings', value: rows.length },
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
{ label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' },
],
rows,
};
};
const contractsList: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind,
ct.status, ct.trade_direction AS direction, ct.freight_type,
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
to_char(ct.created_at, 'YYYY-MM-DD') AS created
FROM freight.contracts ct
LEFT JOIN freight.companies c ON c.id = ct.company_id
WHERE ct.deleted_at IS NULL
AND ($1::timestamptz IS NULL OR ct.created_at >= $1)
AND ($2::timestamptz IS NULL OR ct.created_at < $2)
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
AND ($5::text[] IS NULL OR ct.status = ANY($5))
ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`,
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
);
const active = rows.filter((r: Record<string, unknown>) =>
['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)),
).length;
return {
kpis: [
{ label: 'Contracts', value: rows.length },
{ label: 'Active', value: active },
],
rows,
};
};
const schedulesList: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT ts.train_number, ts.reference, ts.direction, ts.status,
o.label AS origin, d.label AS destination,
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure,
to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure,
to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival,
to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival,
ts.max_wagons, tset.wagon_count
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id
JOIN freight.yards d ON d.id = ts.destination_station_id
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE ts.deleted_at IS NULL
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
AND ($3::text[] IS NULL OR ts.direction = ANY($3))
AND ($4::text[] IS NULL OR ts.status = ANY($4))
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`,
[f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds],
);
const count = (s: string) =>
rows.filter((r: Record<string, unknown>) => r.status === s).length;
return {
kpis: [
{ label: 'Schedules', value: rows.length },
{ label: 'Dispatched', value: count('DISPATCHED') },
{ label: 'Arrived', value: count('ARRIVED') },
],
rows,
};
};
const fleetWagons: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT w.wagon_number, wt.name AS type,
wt.capacity_tons::float8 AS capacity_tons,
w.status, y.label AS current_yard
FROM freight.wagons w
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
LEFT JOIN freight.yards y ON y.id = w.current_yard_id
WHERE w.deleted_at IS NULL
AND ($1::text[] IS NULL OR w.status = ANY($1))
AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2))
ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`,
[f.statuses, f.yardIds],
);
const count = (s: string) =>
rows.filter((r: Record<string, unknown>) => r.status === s).length;
return {
kpis: [
{ label: 'Wagons', value: rows.length },
{ label: 'Available', value: count('AVAILABLE') },
{ label: 'Assigned', value: count('ASSIGNED') },
{ label: 'Maintenance', value: count('MAINTENANCE') },
],
rows,
};
};
const fleetLocomotives: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT l.code, l.name, l.locomotive_type,
l.max_pull_weight_tons::float8 AS max_pull_tons,
l.status, y.label AS current_yard
FROM freight.locomotives l
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
WHERE l.deleted_at IS NULL
AND ($1::text[] IS NULL OR l.status = ANY($1))
AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2))
ORDER BY l.code LIMIT ${LIST_LIMIT}`,
[f.statuses, f.yardIds],
);
const available = rows.filter(
(r: Record<string, unknown>) => r.status === 'AVAILABLE',
).length;
return {
kpis: [
{ label: 'Locomotives', value: rows.length },
{ label: 'Available', value: available },
],
rows,
};
};
const customersList: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT c.name, c.type, c.kind, c.status, c.tin,
to_char(c.approved_at, 'YYYY-MM-DD') AS approved,
to_char(c.created_at, 'YYYY-MM-DD') AS created
FROM freight.companies c
WHERE c.deleted_at IS NULL
AND ($1::timestamptz IS NULL OR c.created_at >= $1)
AND ($2::timestamptz IS NULL OR c.created_at < $2)
AND ($3::text[] IS NULL OR c.status = ANY($3))
ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`,
[f.dateFrom, f.dateTo, f.statuses],
);
const active = rows.filter(
(r: Record<string, unknown>) => r.status === 'active',
).length;
return {
kpis: [
{ label: 'Customers', value: rows.length },
{ label: 'Active', value: active },
],
rows,
};
};
const paymentsList: ReportQuery = async (ds, f) => {
// No deleted_at on freight.payments; statuses are lowercase-hyphenated.
const rows = await ds.query(
`SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created,
p.method::text AS method, p.status::text AS status,
p.currency::text AS currency,
ROUND(p.amount)::float8 AS amount,
p.transaction_id, p.merchant_order_id,
to_char(p.paid_at, 'YYYY-MM-DD') AS paid
FROM freight.payments p
WHERE ($1::timestamptz IS NULL OR p.created_at >= $1)
AND ($2::timestamptz IS NULL OR p.created_at < $2)
AND ($3::text[] IS NULL OR p.status::text = ANY($3))
AND ${refDirScope('p.ref_id', '$4')}
ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`,
[f.dateFrom, f.dateTo, f.statuses, f.directions],
);
const success = rows.filter(
(r: Record<string, unknown>) => r.status === 'success',
);
return {
kpis: [
{ label: 'Payments', value: rows.length },
{ label: 'Successful', value: success.length },
{ label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' },
],
rows,
};
};
export const REPORT_QUERIES: Record<string, ReportQuery> = {
'bookings-list': bookingsList,
'contracts-list': contractsList,
'schedules-list': schedulesList,
'fleet-wagons': fleetWagons,
'fleet-locomotives': fleetLocomotives,
'customers-list': customersList,
'payments-list': paymentsList,
'bookings-trend': bookingsTrend,
'revenue-by-customer': revenueByCustomer,
'revenue-by-lane': revenueByLane,
'contract-utilization': contractUtilization,
'train-on-time': trainOnTime,
'schedule-fill-rate': scheduleFillRate,
'trips-per-route': tripsPerRoute,
'invoiced-vs-collected': invoicedVsCollected,
'aging-receivables': agingReceivables,
'revenue-by-payment-method': revenueByPaymentMethod,
};

View File

@@ -0,0 +1,152 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util';
import { ReportDefinition, ReportRunResult } from './report.types';
const DAY_MS = 24 * 60 * 60 * 1000;
/** Raw query params, minus the pagination/sort keys the runner owns. */
export type RawReportQuery = Record<string, string | undefined>;
/**
* Coerce raw query strings into typed filter params per the report's own
* filter declarations. Unknown filter keys are ignored — `forbidNonWhitelisted`
* can't police a per-report bag, so extras are just dropped, not rejected.
*/
function coerceParams(
def: ReportDefinition,
raw: RawReportQuery,
): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const filter of def.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;
// Inclusive end date, exclusive bound in SQL.
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;
}
}
// idKey, when the report declares one, is a plain string param.
if (def.idKey) {
params[def.idKey.key] = raw[def.idKey.key]?.trim() || null;
}
return params;
}
/**
* Sort expression for a column with no explicit `sortExpr`: the SELECT alias
* TypeORM emitted for it, quoted. TypeORM always double-quotes `addSelect`
* aliases in the generated SQL (preserving case) — ordering by the bare,
* unquoted key instead lets Postgres fold it to lowercase and 42703 on any
* camelCase alias (e.g. "utilizationPct" -> unquoted "utilizationpct").
*/
const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`;
/** Resolve a client-requested sort column against the report's own whitelist. */
function resolveSort(
def: ReportDefinition,
sortBy?: string,
sortOrder?: string,
): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null {
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable);
if (requested) {
return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir };
}
if (!def.defaultSort) return null;
const fallback = def.columns.find((c) => c.key === def.defaultSort!.key);
if (!fallback) return null;
return {
key: fallback.key,
expr: fallback.sortExpr ?? aliasSortExpr(fallback.key),
dir: def.defaultSort.dir,
};
}
@Injectable()
export class ReportRunnerService {
constructor(@InjectDataSource() private readonly ds: DataSource) {}
async run(
def: ReportDefinition,
raw: RawReportQuery,
directions: string[] | null,
): Promise<ReportRunResult> {
const params = coerceParams(def, raw);
const ctx = { ds: this.ds, params, directions };
const qb = def.query(ctx);
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
if (sort) qb.orderBy(sort.expr, sort.dir);
const { page: pageNum, pageSize, skip, take } = normalizePagination({
page: raw.page ? Number(raw.page) : undefined,
pageSize: raw.pageSize ? Number(raw.pageSize) : undefined,
});
const [sql, sqlParams] = qb.getQueryAndParameters();
// getCount() re-derives its own (wrong) select list for GROUP BY queries —
// wrapping the real query as a subquery counts exactly what will be paged.
const countRow = await this.ds.query(
`SELECT COUNT(*)::int AS c FROM (${sql}) report_count`,
sqlParams,
);
const total = Number(countRow[0]?.c ?? 0);
// .offset()/.limit(), not .skip()/.take() — skip/take route raw & grouped
// selects through TypeORM's DISTINCT-id subquery path, which is wrong here.
const items = await qb.offset(skip).limit(take).getRawMany();
const kpis = def.summary ? await def.summary(ctx) : [];
return {
columns: def.columns,
items,
meta: buildPaginationMeta(total, pageNum, pageSize),
kpis,
};
}
/** Same query, no paging — used by the export path. */
async runAll(
def: ReportDefinition,
raw: RawReportQuery,
directions: string[] | null,
limit: number,
): Promise<{ columns: typeof def.columns; items: Record<string, unknown>[]; kpis: ReportRunResult['kpis'] }> {
const params = coerceParams(def, raw);
const ctx = { ds: this.ds, params, directions };
const qb = def.query(ctx);
// Same sort the on-screen table is using, not always the default — an
// export is supposed to match what the user is looking at.
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
if (sort) qb.orderBy(sort.expr, sort.dir);
const items = await qb.limit(limit).getRawMany();
if (items.length >= limit) {
throw new BadRequestException(
`Export exceeds the ${limit}-row cap for this format. Narrow the filters.`,
);
}
const kpis = def.summary ? await def.summary(ctx) : [];
return { columns: def.columns, items, kpis };
}
}
// Re-exported so definitions can scope ACL columns without importing the
// trade-scope module directly.
export { applyBookingRefDirectionScope };

View File

@@ -0,0 +1,52 @@
import { REPORT_KEYS } from '../../seed/freight-permissions.registry';
import { REPORTS, getReport } from './report.registry';
describe('REPORTS', () => {
it('has exactly one definition per seeded REPORT_KEYS entry', () => {
const defKeys = REPORTS.map((r) => r.key).sort();
expect(defKeys).toEqual([...REPORT_KEYS].sort());
});
it('has no duplicate keys', () => {
const keys = REPORTS.map((r) => r.key);
expect(new Set(keys).size).toBe(keys.length);
});
it('resolves every key via getReport', () => {
for (const key of REPORT_KEYS) {
expect(getReport(key)?.key).toBe(key);
}
});
it('every sortable column and defaultSort point at a real column key', () => {
for (const def of REPORTS) {
const columnKeys = new Set(def.columns.map((c) => c.key));
if (def.defaultSort) {
expect(columnKeys.has(def.defaultSort.key)).toBe(true);
}
// Every column marked sortable must have a resolvable key (itself, since
// the runner falls back to `key` when `sortExpr` is absent).
for (const col of def.columns.filter((c) => c.sortable)) {
expect(col.key.length).toBeGreaterThan(0);
}
}
});
it('idKey, when declared, is not also listed as a user-facing filter', () => {
for (const def of REPORTS) {
if (!def.idKey) continue;
expect(def.filters.some((f) => f.key === def.idKey!.key)).toBe(false);
}
});
it('chart.x and chart.y, when declared, point at real column keys', () => {
for (const def of REPORTS) {
if (!def.chart) continue;
const columnKeys = new Set(def.columns.map((c) => c.key));
expect(columnKeys.has(def.chart.x)).toBe(true);
for (const y of def.chart.y) {
expect(columnKeys.has(y)).toBe(true);
}
}
});
});

View File

@@ -0,0 +1,62 @@
import { ReportKey } from '../../seed/freight-permissions.registry';
import { bookingsListReport } from './definitions/bookings-list.report';
import { revenueByCustomerReport } from './definitions/revenue-by-customer.report';
import { agingReceivablesReport } from './definitions/aging-receivables.report';
import { contractUtilizationReport } from './definitions/contract-utilization.report';
import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report';
import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report';
import { wagonRequestsReport } from './definitions/wagon-requests.report';
import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report';
import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report';
import { trainScheduleStatusReport } from './definitions/train-schedule-status.report';
import { trainTurnaroundReport } from './definitions/train-turnaround.report';
import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report';
import { loadedCapacityReport } from './definitions/loaded-capacity.report';
import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report';
import { customerStatusReport } from './definitions/customer-status.report';
import { contractLifecycleReport } from './definitions/contract-lifecycle.report';
import { customsDocumentsReport } from './definitions/customs-documents.report';
import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report';
import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report';
import { invoicesByStatusReport } from './definitions/invoices-by-status.report';
import { paymentsByStatusReport } from './definitions/payments-by-status.report';
import { revenueSummaryReport } from './definitions/revenue-summary.report';
import { cargoSummaryReport } from './definitions/cargo-summary.report';
import { ReportDefinition } from './report.types';
/**
* Every report the platform knows about. Adding one = a new file under
* definitions/ + a key in REPORT_KEYS (freight-permissions.registry.ts) +
* an entry here. Nothing else — no frontend edit, no route, no sidebar edit.
*/
export const REPORTS: ReportDefinition[] = [
bookingsListReport,
revenueByCustomerReport,
agingReceivablesReport,
contractUtilizationReport,
wagonFleetStatusReport,
wagonStatusDurationReport,
wagonRequestsReport,
locomotiveFleetStatusReport,
bookingStatusBreakdownReport,
trainScheduleStatusReport,
trainTurnaroundReport,
wagonTeuUtilizationReport,
loadedCapacityReport,
globalLogisticsWagonsReport,
customerStatusReport,
contractLifecycleReport,
customsDocumentsReport,
invoicingPipelineReport,
firstLastMileBookingsReport,
invoicesByStatusReport,
paymentsByStatusReport,
revenueSummaryReport,
cargoSummaryReport,
];
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
export function getReport(key: string): ReportDefinition | undefined {
return BY_KEY.get(key as ReportKey);
}

View File

@@ -0,0 +1,112 @@
import { DataSource, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportKey } from '../../seed/freight-permissions.registry';
export type { ReportKey };
export type ReportColumnType =
| 'string'
| 'number'
| 'money'
| 'tons'
| 'percent'
| 'date';
export interface ReportColumn {
key: string;
label: string;
type: ReportColumnType;
sortable?: boolean;
/** SQL to ORDER BY when this column is sorted, if different from `key`. */
sortExpr?: string;
}
export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
export interface ReportFilterOption {
value: string;
label: string;
}
export interface ReportFilterDef {
key: string;
label: string;
type: ReportFilterType;
/** Static option list for select/multiselect. */
options?: ReportFilterOption[];
}
export interface ReportKpi {
label: string;
value: number;
unit?: string;
}
export type ReportChartType = 'line' | 'bar';
/**
* Plots the SAME rows the table gets — no separate query. `x` and `y` are
* column keys from `columns`. A report whose group-by has dimensions beyond
* `x` will render one mark per row (e.g. two rows sharing a date because they
* differ by direction), which is a busier chart, not a wrong one. Pivoting
* rows into one-per-x series is a later add if a report actually needs it.
*/
export interface ReportChartDef {
type: ReportChartType;
x: string;
y: string[];
}
/**
* Optional entity scope a report can be embedded against — e.g. a
* contract-utilization report shown on a single contract's detail page.
* Purely descriptive; `query()` reads the resolved value off `ctx.params`
* like any other filter.
*/
export interface ReportIdKey {
key: string;
label: string;
}
export interface ReportContext {
ds: DataSource;
/** Filter values, already coerced against `def.filters` (CSV → array, etc). */
params: Record<string, unknown>;
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
directions: string[] | null;
}
export interface ReportDefinition {
key: ReportKey;
title: string;
description: string;
group: 'Commercial' | 'Operations' | 'Finance';
idKey?: ReportIdKey;
filters: ReportFilterDef[];
columns: ReportColumn[];
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
query(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral>;
/** KPIs over the same filtered set; shown above the table and in exports. */
summary?(ctx: ReportContext): Promise<ReportKpi[]>;
/** Optional chart view of the same rows. Table remains the default view. */
chart?: ReportChartDef;
}
/** Catalog shape served by GET /reports — metadata only, no rows. */
export type ReportCatalogEntry = Omit<ReportDefinition, 'query' | 'summary'> & {
hasSummary: boolean;
};
export interface ReportRunResult {
columns: ReportColumn[];
items: Record<string, unknown>[];
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
kpis: ReportKpi[];
}

View File

@@ -1,34 +1,92 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { Response } from 'express';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { ReportQueryDto } from './dto/report-query.dto';
import { ReportResultDto } from './dto/report-result.dto';
import { ReportsService } from './reports.service';
import { ReportExportService } from './report-export.service';
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
import { REPORTS, getReport } from './report.registry';
import { ReportCatalogEntry, ReportDefinition } from './report.types';
const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => {
const { query: _query, summary, ...meta } = def;
return { ...meta, hasSummary: Boolean(summary) };
};
@ApiTags('Reports')
@ApiBearerAuth()
@Controller('reports')
@BookingStaff(FREIGHT_PERMS.reports.view)
export class ReportsController {
constructor(
private readonly reportsService: ReportsService,
private readonly runner: ReportRunnerService,
private readonly exportService: ReportExportService,
private readonly userTradeAccessService: UserTradeAccessService,
) {}
@Get()
@ApiOperation({ summary: 'List reports the caller has permission to run' })
async catalog(@CurrentUser() user: TCurrentUser): Promise<ReportCatalogEntry[]> {
return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map(
toCatalogEntry,
);
}
@Get(':key')
@BookingStaff(FREIGHT_PERMS.reports.view)
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
@ApiOkResponse({ type: ReportResultDto })
@ApiOperation({ summary: 'Run a report by key, paginated/sorted/filtered' })
async run(
@Param('key') key: string,
@Query() query: ReportQueryDto,
@Query() query: RawReportQuery,
@CurrentUser() user: TCurrentUser,
): Promise<ReportResultDto> {
const allowed = await this.userTradeAccessService.resolveAllowedDirections(user);
return this.reportsService.run(key, query, allowed);
) {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
return this.runner.run(def, query, directions);
}
@Get(':key/export')
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
async export(
@Param('key') key: string,
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format);
const cap = resolveExportCap(format, query.limit);
const exportColumns = resolveExportColumns(def, query.fields);
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
const buffer =
format === 'pdf'
? await this.exportService.toPdf(def, items, kpis, exportColumns)
: await this.exportService.toXlsx(def, items, kpis, exportColumns);
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader(
'Content-Type',
format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.send(buffer);
}
private resolve(key: string, user: TCurrentUser): ReportDefinition {
const def = getReport(key);
if (!def) throw new NotFoundException(`Unknown report: ${key}`);
// Exact-match on purpose — unlike FreightPermissionGuard's :view/:read
// fallback, a report's own key is the only thing that opens it.
assertFreightPermission(user, reportPermissionKey(def.key));
return def;
}
}

View File

@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { DocumentsModule } from '../billing/documents/documents.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { ReportExportService } from './report-export.service';
import { ReportRunnerService } from './report-runner.service';
import { ReportsController } from './reports.controller';
import { ReportsRepository } from './reports.repository';
import { ReportsService } from './reports.service';
@Module({
imports: [UserTradeAccessModule],
imports: [UserTradeAccessModule, DocumentsModule],
controllers: [ReportsController],
providers: [ReportsService, ReportsRepository],
providers: [ReportRunnerService, ReportExportService],
})
export class ReportsModule {}

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