mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -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'.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* `<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; }`;
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
260
apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts
Normal file
260
apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts
Normal 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()}`;
|
||||
}
|
||||
}
|
||||
97
apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts
Normal file
97
apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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"] });
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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: '%' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>[];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const kpiHtml = kpis.length
|
||||
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
|
||||
.map(
|
||||
(k) =>
|
||||
`<div><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
|
||||
)
|
||||
.join('')}</div>`
|
||||
: '';
|
||||
|
||||
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
|
||||
const body = rows
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
|
||||
h1 { font-size: 16px; margin-bottom: 4px; }
|
||||
p.desc { color: #666; margin-top: 0 0 12px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
|
||||
th { background: #f3f3f3; }
|
||||
</style></head><body>
|
||||
<h1>${esc(def.title)}</h1>
|
||||
<p class="desc">${esc(def.description)}</p>
|
||||
${kpiHtml}
|
||||
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
|
||||
</body></html>`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
62
apps/edr-freight-api/src/modules/reports/report.registry.ts
Normal file
62
apps/edr-freight-api/src/modules/reports/report.registry.ts
Normal 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);
|
||||
}
|
||||
112
apps/edr-freight-api/src/modules/reports/report.types.ts
Normal file
112
apps/edr-freight-api/src/modules/reports/report.types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user