Merge pull request #1284 from Tria-plc/staging

Staging
This commit is contained in:
Abubeker Yasin
2026-08-13 23:46:16 +03:00
committed by GitHub
357 changed files with 30529 additions and 7245 deletions

View File

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

View File

@@ -40,6 +40,8 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { ShippingLineBookingCompletionModule } from "./modules/shipping-lines/shipping-line-booking-completion.module";
import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
@@ -49,6 +51,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";
@@ -200,6 +203,8 @@ if (!process.env.APPLICATION_NAME) {
TrainSchedulingModule,
SchedulingRescheduleModule,
CompaniesModule,
ShippingLineCompaniesModule,
ShippingLineBookingCompletionModule,
TrackingModule,
BillingModule,
NotificationsModule,
@@ -209,6 +214,7 @@ if (!process.env.APPLICATION_NAME) {
DropdownSettingsModule,
ExchangeSettingsModule,
StampSettingsModule,
LogoSettingsModule,
ContractTemplatesModule,
SupportContentModule,
OtpModule,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Shipping lines — carriers registered by backoffice staff who sign in to the
* portal directly.
*
* Separate from `freight.companies` on purpose: a shipping line has no TIN,
* business licence, eTrade record, operational profile or onboarding state, so
* it shares none of the customer columns. `user_id` sits on the company row
* itself because the company IS the account — there is no contact-person row.
*
* No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which
* this API reads but never owns.
*/
export class ShippingLineCompany3440000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_companies_status_enum
AS ENUM ('active', 'suspended');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.shipping_line_companies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
name varchar(200) NOT NULL,
scac_code varchar(4),
imo_number varchar(20),
bic_code varchar(20),
email varchar(150) NOT NULL,
phone_number varchar(30),
status freight.shipping_line_companies_status_enum
NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// One login per shipping line. Partial so a soft-deleted row frees its
// account for re-registration rather than blocking it forever.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user"
ON freight.shipping_line_companies (user_id)
WHERE deleted_at IS NULL
`);
// SCAC identifies the carrier globally — two live lines cannot share one.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac"
ON freight.shipping_line_companies (scac_code)
WHERE scac_code IS NOT NULL AND deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email"
ON freight.shipping_line_companies (lower(email))
WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status"
ON freight.shipping_line_companies (status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.shipping_line_companies`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`,
);
}
}

View File

@@ -0,0 +1,117 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Shipping lines book rail capacity directly, without a contract.
*
* A booking has always been owned by `company_id` (a customer `companies` row),
* but a shipping line is a `shipping_line_companies` row and deliberately NOT a
* company — it carries no TIN, licence or operational profiles. So it gets its
* own nullable owner column rather than a synthetic company row.
*
* Exactly one of the two is set: `company_id` for a customer booking,
* `shipping_line_company_id` for a shipping-line one. Existing rows keep
* `company_id` and a NULL `shipping_line_company_id`, so nothing needs
* backfilling and every customer query filtering on `company_id` behaves
* exactly as before. Government bookings already bill to a seeded government
* company, so they satisfy the CHECK unchanged.
*
* NOTE: not to be confused with the existing `bookings.shipping_line_id`, which
* is cargo metadata naming the carrier line that moves the goods
* (`freight.shipping_lines`, reference data). This column points at
* `freight.shipping_line_companies` — the portal account — and is unrelated.
*/
export class BookingShippingLine3450000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_shipping_line_company_id
ON freight.bookings (shipping_line_company_id)
`);
// `company_id` / `company_profile_id` are NOT NULL and point at the customer
// tables, so a shipping-line booking could not be inserted at all. Relax
// them to nullable; their foreign keys are left in place and keep validating
// every non-NULL value, so a customer booking is constrained exactly as
// before. The CHECK below is what now guarantees an owner is present.
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN company_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN company_profile_id DROP NOT NULL
`);
// Route and service are inherited from the contract on a customer booking.
// A shipping line initiates before any of that is known — the bare booking
// exists only to hang documents off — so these are relaxed too and filled
// in when the booking is completed. Existing rows all have values, and the
// customer paths still always set them.
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN origin_yard_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN destination_yard_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN service_type_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN freight_type DROP NOT NULL
`);
// No FK: kept consistent with how the column is populated at the service
// layer, and avoids a lock on shipping_line_companies during deploy.
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD CONSTRAINT chk_bookings_single_owner
CHECK (
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
`);
// Only reinstate NOT NULL if no shipping-line booking exists; those rows
// have a NULL company_id by design and would make the ALTER fail. Leaving
// the columns nullable is the safe outcome — the constraint is additive.
const [{ count }] = (await queryRunner.query(`
SELECT COUNT(*)::int AS count FROM freight.bookings
WHERE shipping_line_company_id IS NOT NULL
`)) as Array<{ count: number }>;
if (count === 0) {
for (const column of [
"company_id",
"company_profile_id",
"origin_yard_id",
"destination_yard_id",
"service_type_id",
"freight_type",
]) {
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN ${column} SET NOT NULL
`);
}
}
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_bookings_shipping_line_company_id
`);
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS shipping_line_company_id
`);
}
}

View File

@@ -0,0 +1,201 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Shipping lines consume services before paying for them.
*
* A shipping line books rail capacity and the booking proceeds with no payment
* gate at all — unlike a customer booking, which cannot advance until its
* PREPAID invoice settles. What the line owes is instead recorded here as a
* credit: one row per booking, priced once and never recalculated. Finance
* later selects a batch of unbilled credits, generates a single invoice for
* them, and the line pays that invoice through the normal CBE flow. When the
* invoice settles, its credits are marked paid and stop counting as debt.
*
* This is deliberately NOT a wallet or a stored balance. There is no money in
* the system to draw down: a credit is a debt the line already incurred, so
* the outstanding figure is always derived (`SUM(amount) WHERE status <>
* 'PAID'`) rather than kept in a column that UPDATEs can drift out of sync.
*
* `invoices.company_id` / `company_profile_id` are relaxed to nullable for the
* same reason `bookings` was in {@link BookingShippingLine3450000000000}: a
* shipping line is not a `companies` row and never will be, so an invoice
* billed to one has no customer to point at. Both FKs stay in place and keep
* validating every non-NULL value, so a customer invoice is constrained
* exactly as before; the CHECK below is what now guarantees a payer exists.
*/
export class ShippingLineCredits3460000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Invoices: allow a shipping-line payer ────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_invoices_shipping_line_company_id
ON freight.invoices (shipping_line_company_id)
`);
await queryRunner.query(`
ALTER TABLE freight.invoices ALTER COLUMN company_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id DROP NOT NULL
`);
// Exactly one payer. Mirrors chk_bookings_single_owner so the two tables
// answer "who owes this?" the same way. Existing rows all have company_id
// and a NULL shipping_line_company_id, so nothing needs backfilling.
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD CONSTRAINT chk_invoices_single_payer
CHECK (
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
)
`);
// ── The credit ledger ────────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_credits_status_enum AS ENUM (
'UNBILLED', 'BILLED', 'PAID', 'CANCELLED'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.shipping_line_credits (
id uuid DEFAULT gen_random_uuid() NOT NULL,
shipping_line_company_id uuid NOT NULL,
booking_id uuid NOT NULL,
amount numeric(14,2) NOT NULL,
currency character varying(8) DEFAULT 'ETB'::character varying NOT NULL,
status freight.shipping_line_credits_status_enum
DEFAULT 'UNBILLED'::freight.shipping_line_credits_status_enum NOT NULL,
description character varying(255),
invoice_id uuid,
billed_at timestamp with time zone,
paid_at timestamp with time zone,
cancelled_at timestamp with time zone,
cancellation_reason character varying(255),
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
deleted_at timestamp with time zone,
CONSTRAINT pk_shipping_line_credits PRIMARY KEY (id),
CONSTRAINT chk_shipping_line_credits_amount CHECK (amount >= 0),
-- The state machine, enforced in the DB rather than trusted to the
-- service: an UNBILLED credit has no invoice, and anything past
-- UNBILLED must name the invoice it was billed on. Without this a
-- half-applied batch could leave BILLED rows with a NULL invoice_id
-- and silently vanish from both the unbilled list and the invoice.
CONSTRAINT chk_shipping_line_credits_invoice_link CHECK (
(status = 'UNBILLED' AND invoice_id IS NULL)
OR (status IN ('BILLED', 'PAID') AND invoice_id IS NOT NULL)
OR status = 'CANCELLED'
)
)
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_shipping_line
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
ADD CONSTRAINT fk_shipping_line_credits_shipping_line
FOREIGN KEY (shipping_line_company_id)
REFERENCES freight.shipping_line_companies(id) ON DELETE RESTRICT
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_booking
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
ADD CONSTRAINT fk_shipping_line_credits_booking
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id) ON DELETE RESTRICT
`);
// SET NULL rather than CASCADE: deleting an invoice must never delete the
// record of what was owed. The row would then violate the link CHECK, so a
// credit whose invoice is removed has to be walked back to UNBILLED
// explicitly — which is the correct, visible outcome.
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_invoice
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
ADD CONSTRAINT fk_shipping_line_credits_invoice
FOREIGN KEY (invoice_id)
REFERENCES freight.invoices(id) ON DELETE SET NULL
`);
// One live credit per booking. Partial so a soft-deleted or cancelled row
// does not block re-pricing a booking that was voided and rebooked.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_line_credits_booking
ON freight.shipping_line_credits (booking_id)
WHERE deleted_at IS NULL AND status <> 'CANCELLED'
`);
// Drives the two hot reads: finance's unbilled worklist per line, and the
// outstanding total on the shipping-line detail page.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_line_status
ON freight.shipping_line_credits (shipping_line_company_id, status)
WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_invoice_id
ON freight.shipping_line_credits (invoice_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TABLE IF EXISTS freight.shipping_line_credits
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.shipping_line_credits_status_enum
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
`);
// Only reinstate NOT NULL if no shipping-line invoice exists; those rows
// have a NULL company_id by design and would make the ALTER fail. Leaving
// the columns nullable is the safe outcome — the constraint is additive.
const [{ count }] = (await queryRunner.query(`
SELECT COUNT(*)::int AS count FROM freight.invoices
WHERE shipping_line_company_id IS NOT NULL
`)) as Array<{ count: number }>;
if (count === 0) {
await queryRunner.query(`
ALTER TABLE freight.invoices ALTER COLUMN company_id SET NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id SET NOT NULL
`);
}
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_invoices_shipping_line_company_id
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS shipping_line_company_id
`);
}
}

View File

@@ -0,0 +1,127 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-shipping-line rates.
*
* A shipping line books rail capacity directly (see BookingShippingLine3450000000000)
* and negotiates its own prices, so the rate table gains an owner column:
* `shipping_line_company_id` NULL = the standard rate every customer pays,
* NOT NULL = a rate that only that line's bookings resolve.
*
* Points at `freight.shipping_line_companies` (the portal account that owns the
* booking), NOT `freight.shipping_lines` — the latter is carrier reference data
* naming who physically moves the goods, and the existing SHIPPING_LINE trigger
* already keys off it. Both stay independent.
*
* Line rates OVERRIDE rather than stack: a booking owned by a line prices off
* that line's rate for the lane, and is hard-blocked when none exists (the
* standard rate is deliberately not a fallback — see RuleEngineService).
*
* Every existing row keeps a NULL owner, so nothing needs backfilling and the
* standard-rate lookups behave exactly as before.
*/
export class ShippingLineRates3470000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.rates
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
`);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
`);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "FK_rates_shipping_line_company"
FOREIGN KEY (shipping_line_company_id)
REFERENCES freight.shipping_line_companies (id)
ON DELETE RESTRICT
`);
// Rate resolution always filters by owner, so the lookups this column
// participates in are (owner, lane) — indexed together with rate_type,
// which every lookup also pins.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_company_id
ON freight.rates (shipping_line_company_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_lane
ON freight.rates (shipping_line_company_id, rate_type, origin_yard_id, destination_yard_id)
WHERE shipping_line_company_id IS NOT NULL
`);
// A shipping line sells import freight only — the export leg is contracted
// through the customer, not the carrier. Enforced here so a line rate can
// never be filed against an export lane regardless of which API path wrote
// it. Surcharges carry no direction and are unaffected.
await queryRunner.query(`
ALTER TABLE freight.rates
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
`);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_shipping_line_import_only" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
shipping_line_company_id IS NULL OR
trade_direction IS NULL OR trade_direction = 'IMPORT'
)
`);
// The owner joins the rate's identity. Without it MSC's 20ft Djibouti→Modjo
// rate collides with the standard rate for the same lane — same rate_type,
// same scope, same unit — and the insert fails on UQ_rates_pattern. NULL
// (the standard rate) collapses to the zero uuid like every other nullable
// scope column, so existing rows keep their current uniqueness exactly.
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
rate_type,
COALESCE(shipping_line_company_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''::character varying),
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
rate_unit,
COALESCE(min_km, '-1'::numeric)
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Restore the pre-owner pattern index (as left by LastMileRateBands).
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''::character varying),
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
rate_unit,
COALESCE(min_km, '-1'::numeric)
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
`);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_lane`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_company_id`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
`);
await queryRunner.query(`
ALTER TABLE freight.rates DROP COLUMN IF EXISTS shipping_line_company_id
`);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Default the daily booking desk to 24 hours: window_close_hour equal to
* window_open_hour means the desk never pauses overnight. Aligns the column
* default and the existing global-rules row; per-schedule overrides keep
* whatever staff set on them.
*/
export class DefaultDeskHours24h3520000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_close_hour SET DEFAULT 8
`);
await queryRunner.query(`
UPDATE freight.train_scheduling_global_rules
SET window_close_hour = window_open_hour
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_close_hour SET DEFAULT 17
`);
await queryRunner.query(`
UPDATE freight.train_scheduling_global_rules
SET window_close_hour = 17
`);
}
}

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Makerchecker for manual actions on shipping-line credit invoices.
*
* A shipping-line credit invoice is normally settled by the CBE webhook. Two
* manual paths exist for finance: recording an offline payment (MARK_PAID)
* and voiding an invoice raised in error (CANCEL, which releases its credits
* back to the unbilled pool). Both erase or move real debt, so neither is a
* single-person action: one permission raises the request, a different
* permission — held by a chief, and never the requester themselves — approves
* or rejects it. Rows are never deleted; decided requests are the audit trail.
*
* One PENDING row per invoice at a time (partial unique index): a second
* request while one is undecided is a coordination failure, not a workflow.
*/
export class ShippingLineInvoiceApprovals3530000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_invoice_approvals_action_enum
AS ENUM ('MARK_PAID', 'CANCEL');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_invoice_approvals_status_enum
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.shipping_line_invoice_approvals (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL REFERENCES freight.invoices (id),
action freight.shipping_line_invoice_approvals_action_enum NOT NULL,
status freight.shipping_line_invoice_approvals_status_enum NOT NULL DEFAULT 'PENDING',
requested_by uuid NOT NULL,
reason varchar(500) NOT NULL,
payment_reference varchar(255),
decided_by uuid,
decided_at timestamptz,
decision_note varchar(500),
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_sl_invoice_approvals_invoice_status
ON freight.shipping_line_invoice_approvals (invoice_id, status)
`);
// The workflow invariant, enforced where it cannot race: at most one
// undecided request per invoice.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_sl_invoice_approvals_one_pending
ON freight.shipping_line_invoice_approvals (invoice_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.shipping_line_invoice_approvals`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_status_enum`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_action_enum`,
);
}
}

View File

@@ -432,11 +432,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
// Shipping Line
// Shipping Line (rule-engine lookup list — a code/label bookings reference,
// not an account)
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
// Shipping Line Company (carrier with a portal login, registered by staff)
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
// Signature
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
@@ -86,7 +87,66 @@ export class CustomerResetService {
const resolved = await this.resolvePrimaryContactUser(companyId);
if (!resolved) return null;
const { user, userId } = resolved;
return this.sendResetLinkToUser(resolved.userId, channel, {
scope: `company ${companyId}`,
});
}
/**
* Mint and deliver a reset link to a specific IAM account.
*
* The delivery half of {@link sendResetLinkToCustomer}, split out so callers
* that resolve their target differently can reuse it: a customer is found via
* the company's primary contact, while a shipping line has no contact row at
* all and resolves straight off its own record. Everything below the lookup —
* active-account gating, the domestic-SMS rule, mint-before-send, the
* undelivered-link diagnostic — is identical for both and must stay that way.
*
* `scope` only labels the log line with whatever the caller resolved from.
*
* `allowWithoutCredential` relaxes the lookup for first-time activation:
* the default gate requires an existing active credential (so a reset cannot
* revive a suspended account), but an account that has never set a password
* has no credential row yet and would be excluded from its own activation
* link. Callers pass it only when the account is expected to be
* password-less — see ShippingLineCompaniesService.
*/
async sendResetLinkToUser(
userId: string,
channel: ResetChannel,
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink | null> {
const user = options?.allowWithoutCredential
? await this.forgotPasswordService.resolveActivatableUserById(userId)
: await this.forgotPasswordService.resolveActiveUserById(userId);
if (!user?.id) {
this.logger.warn(
`User ${userId} is not an active account${
options?.allowWithoutCredential
? ""
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
}`,
);
return null;
}
return this.deliverResetLink(user, user.id, channel, options?.scope);
}
/**
* Shared tail: target selection → SMS reachability → mint → send → report.
* Callers have already resolved `user` to an active account.
*/
private async deliverResetLink(
user: User,
userId: string,
channel: ResetChannel,
scope?: string,
): Promise<SentResetLink | null> {
this.logger.log(
`Staff-triggered shipping line ${"link"}`,
);
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
@@ -110,6 +170,9 @@ export class CustomerResetService {
);
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
this.logger.log(
`Staff-triggered shipping line ${link}`,
);
const { queued } = target.email
? await this.emailClient.sendEmail({
@@ -127,7 +190,22 @@ export class CustomerResetService {
});
this.logger.log(
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
`Staff-triggered shipping line ${channel} reset link sent to user ${userId}${
scope ? ` (${scope})` : ""
} queued=${queued}`,
);
// SECURITY: logs a live password-reset credential in cleartext. Anyone with
// read access to the log stream can set the password for the account named
// on the same line — including on sends that succeeded, not just failures.
// Kept deliberately: log aggregation is the debugging path for flaky
// email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If
// that is ever revisited, gate this on an env flag rather than deleting it,
// so dev keeps its workflow.
this.logger.warn(
`reset-link.cleartext channel=${channel} user=${userId}${
scope ? ` (${scope})` : ""
} link=${link}`,
);
if (!queued) {

View File

@@ -89,6 +89,28 @@ export class ForgotPasswordService {
.getOne();
}
/**
* Active account by id, WITHOUT requiring an existing credential.
*
* {@link activeUserQuery} inner-joins an active `user_credentials` row, which
* is right for a *reset*: it stops a staff-triggered link from reactivating a
* suspended account. But an account that has never set a password has no
* credential row yet, so that join excludes exactly the accounts a first-time
* *activation* link is for — shipping lines are created deliberately without
* one (see ShippingLineCompaniesService.register).
*
* The `isActive` gate is kept; only the credential requirement is dropped.
*/
async resolveActivatableUserById(userId: string): Promise<User | null> {
if (!userId) return null;
return await this.userRepository
.createQueryBuilder("u")
.where("u.isActive = true")
.andWhere("u.id = :userId", { userId })
.orderBy("u.createdAt", "DESC")
.getOne();
}
/**
* Base query for accounts eligible to reset. `.where()` is claimed here so
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
@@ -233,9 +255,22 @@ export class ForgotPasswordService {
"This password-reset link is invalid or has expired. Request a new one.",
);
const user = await this.resolveActiveUserById(userId);
// Credential-less on purpose: this resolves links for *setting* a password,
// which includes first-time activation of an account that has never had one
// (shipping lines are created without a credential row). Requiring one here
// rejected a perfectly valid activation link before its token was ever
// checked. The ticket checks below are what actually authorise the reset.
const user = await this.resolveActivatableUserById(userId);
const identifier = user && this.identifierFor(user);
if (!user || !identifier) throw invalid;
if (!user || !identifier) {
// Logged because the early return above bypasses the rejection warning
// below — without this, an account that fails the lookup produces no
// diagnostic at all and looks identical to a bad token.
this.logger.warn(
`Reset link rejected for user ${userId} — no active account or no usable identifier`,
);
throw invalid;
}
const verification = await this.dataSource
.getRepository(UserVerification)

View File

@@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service';
ForgotPasswordService,
CustomerResetService,
],
// Shipping-line registration mints activation links through the same
// staff-triggered reset path customers use.
exports: [CustomerResetService],
})
export class FreightAuthModule {}

View File

@@ -19,7 +19,8 @@ import { FilesModule } from "../files/files.module";
imports: [
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
// Cycles back via ShippingLineCompaniesModule, which imports this module.
forwardRef(() => CompaniesModule),
DocumentsModule,
UserTradeAccessModule,
FilesModule,
@@ -29,3 +30,4 @@ import { FilesModule } from "../files/files.module";
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -13,6 +13,9 @@ import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
@@ -114,8 +117,16 @@ export interface GenerateInvoiceInput {
sourceId: string;
/** What the invoice is for (e.g. "prepaid", "credit"). */
type: string;
companyId: string;
companyProfileId: string;
/** The customer billed. Omit only when billing a shipping line instead. */
companyId?: string | null;
companyProfileId?: string | null;
/**
* The shipping line billed, for an invoice covering batched shipping-line
* credits. Mutually exclusive with `companyId` — the DB enforces this via
* `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload
* setting both or neither before it ever reaches the constraint.
*/
shippingLineCompanyId?: string | null;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
@@ -141,8 +152,11 @@ export interface InvoiceEventPayload {
source: Freight.InvoiceSource;
sourceId: string;
type: string;
companyId: string;
companyProfileId: string;
/** Null when the payer is a shipping line rather than a customer company. */
companyId: string | null;
companyProfileId: string | null;
/** Set only on shipping-line invoices; mutually exclusive with `companyId`. */
shippingLineCompanyId?: string | null;
totalAmount: number;
currency: string;
status: Freight.InvoiceStatus;
@@ -413,6 +427,32 @@ export class BillingService {
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 async toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
@@ -446,6 +486,7 @@ export class BillingService {
{ 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",
@@ -536,23 +577,61 @@ export class BillingService {
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
/**
* Resolve a shipping-line company from the signed-in user (null for ordinary
* customers). Queried straight off the entity rather than through
* ShippingLineCompaniesService — that module already imports billing, so a
* service edge back would deepen the forwardRef cycle for one lookup.
*/
private async resolveShippingLineCompanyId(
userId: string,
): Promise<string | null> {
const line = await this.dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { userId } });
return line?.id ?? null;
}
/**
* Invoices for the signed-in portal user; empty when they have no company.
* A payer is either a customer company or a shipping line (enforced by the
* DB's single-payer check), so the two lookups cannot both match.
*/
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId, filter) : [];
if (companyId) return this.findByCompany(companyId, filter);
const shippingLineCompanyId =
await this.resolveShippingLineCompanyId(userId);
if (!shippingLineCompanyId) return [];
return this.invoices.findAll({
where: {
shippingLineCompanyId,
...(filter.source ? { source: filter.source } : {}),
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
},
order: { createdAt: "DESC" },
});
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
/** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */
async findByIdForUser(
id: string,
userId: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const companyId = await this.resolveCompanyId(userId);
const invoice = await this.findById(id);
if (!companyId || invoice.companyId !== companyId) {
const ownedByCompany =
invoice.companyId != null &&
invoice.companyId === (await this.resolveCompanyId(userId));
const ownedByShippingLine =
!ownedByCompany &&
invoice.shippingLineCompanyId != null &&
invoice.shippingLineCompanyId ===
(await this.resolveShippingLineCompanyId(userId));
if (!ownedByCompany && !ownedByShippingLine) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;
@@ -642,7 +721,6 @@ export class BillingService {
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
console.log("oooooooooo", input);
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
@@ -655,6 +733,21 @@ export class BillingService {
const status = input.status ?? Freight.InvoiceStatus.Pending;
const issued = status !== Freight.InvoiceStatus.Draft;
// Exactly one payer, checked here so a bad payload fails with a clear
// message instead of a raw `chk_invoices_single_payer` violation.
const billsCompany = Boolean(input.companyId);
const billsShippingLine = Boolean(input.shippingLineCompanyId);
if (billsCompany === billsShippingLine) {
throw new BadRequestException(
"An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.",
);
}
if (billsCompany && !input.companyProfileId) {
throw new BadRequestException(
"companyProfileId is required when billing a company.",
);
}
const lines = input.lines.map((l) => {
const quantity = l.quantity ?? 1;
const unitRate = l.unitRate ?? 0;
@@ -690,8 +783,9 @@ export class BillingService {
source: input.source,
sourceId: input.sourceId,
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
companyId: input.companyId ?? null,
companyProfileId: input.companyProfileId ?? null,
shippingLineCompanyId: input.shippingLineCompanyId ?? null,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
@@ -1021,6 +1115,7 @@ export class BillingService {
type: invoice.type,
companyId: invoice.companyId,
companyProfileId: invoice.companyProfileId,
shippingLineCompanyId: invoice.shippingLineCompanyId ?? null,
totalAmount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,

View File

@@ -14,7 +14,7 @@ const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel =
});
describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
const service = new InvoiceDocumentService({} as never, {} as never);
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
it("renders no QR block when qrImageUrl is unset", () => {
const html = service.buildHtml(model());

View File

@@ -1,8 +1,10 @@
import { Injectable } from "@nestjs/common";
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
import { LogoSettingsService } from "../../logo-settings/logo-settings.service";
import { PdfRenderService } from "./pdf-render.service";
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
import { logoImageCss, logoMarkup } from "./logo-markup.util";
import {
PdfColor,
assembleSinglePagePdf,
@@ -62,6 +64,7 @@ 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
@@ -81,6 +84,7 @@ export class InvoiceDocumentService {
constructor(
private readonly pdf: PdfRenderService,
private readonly stampSettings: StampSettingsService,
private readonly logoSettings: LogoSettingsService,
) {}
async render(
@@ -90,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";
@@ -250,6 +258,7 @@ 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>`
@@ -293,6 +302,7 @@ 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; }
@@ -322,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>

View File

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

View File

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

View File

@@ -23,22 +23,37 @@ export class Invoice extends BaseEntity {
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
invoiceNumber!: string;
/** The customer (company) this invoice is billed to. */
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
/**
* The customer (company) this invoice is billed to. Null on a shipping-line
* invoice, which is billed to `shippingLineCompanyId` instead — a shipping
* line is deliberately not a `companies` row. A DB CHECK
* (`chk_invoices_single_payer`) guarantees exactly one of the two is set.
*/
@Column({ name: "company_id", type: "uuid", nullable: true })
companyId!: string | null;
@ManyToOne(() => Company)
@JoinColumn({ name: "company_id" })
company?: Company;
/** The specific company profile (importer/exporter/forwarder/...) billed. */
@Column({ name: "company_profile_id", type: "uuid" })
companyProfileId!: string;
@Column({ name: "company_profile_id", type: "uuid", nullable: true })
companyProfileId!: string | null;
@ManyToOne(() => CompanyProfile)
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
/**
* The shipping line billed, when this invoice bills batched shipping-line
* credits rather than a customer booking. Mutually exclusive with
* `companyId`. No relation is declared: `ShippingLineCredit` already owns
* that edge, and importing the shipping-lines module here would close an
* import cycle (shipping-lines already depends on billing).
*/
@Column({ name: "shipping_line_company_id", type: "uuid", nullable: true })
shippingLineCompanyId?: string | null;
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;

View File

@@ -11,6 +11,7 @@ import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
@@ -58,10 +59,16 @@ export class BookingLifecycleNotifierService {
// Both channels come from the same resolver: the company row's own columns
// are only half the story (see companyNotifyEmailExpr), and reading them off
// the loaded entity silently dropped every mail to a company whose address
// lives in `attributes`.
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
// lives in `attributes`. A shipping-line booking has NO company — its
// contact lives on the shipping_line_companies row itself.
const { phone, email } = b.shippingLineCompanyId
? await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId,
)
: b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) {
try {
@@ -82,13 +89,44 @@ export class BookingLifecycleNotifierService {
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
/**
* Persist + push an in-app item to the booking's portal owner: every portal
* user of the company, or — for a shipping-line booking — the line's own
* account, deep-linked into the shipping-line app rather than the customer
* one (its routes live under /shipping-line/*).
*/
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (b.shippingLineCompanyId) {
void (async () => {
const { userId } = await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId!,
);
if (!userId) return;
void this.inbox.notify({
recipients: { userIds: [userId] },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title,
body,
data: { bookingId: b.id, reference: b.reference },
...overrides,
// After the spread: overrides carry customer links — the bell must
// land a shipping line on ITS booking page.
link: `/shipping-line/bookings/${b.id}`,
});
})().catch((err) =>
this.logger.warn(
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
),
);
return;
}
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
@@ -176,13 +214,23 @@ export class BookingLifecycleNotifierService {
/** Document approval finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void {
const msg =
`Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
// A shipping line's next move is BOOKING (cargo + shipment day), not the
// customer's operation-request step — say so, or the message points at a
// flow their portal does not have.
const msg = b.shippingLineCompanyId
? `Documents for booking ${b.reference} are approved. ` +
`You can now book your shipment — enter the cargo and shipment day from the portal.`
: `Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
this.inApp(b, 'Document approval finalized', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
this.inApp(
b,
b.shippingLineCompanyId
? 'Documents approved — book your shipment'
: 'Document approval finalized',
msg,
{ type: NotificationType.CLEARANCE_DECISION },
);
}
/** Intercity documents approved → booking waits in the ride-along pool. */
@@ -234,11 +282,19 @@ export class BookingLifecycleNotifierService {
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
const msg =
`Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
// No invoice and no pay window for a shipping line — the charge sits on
// its credit account and the booking boards its dedicated train directly.
const msg = b.shippingLineCompanyId
? `Your booking ${b.reference} has been accepted. The charge has been ` +
`recorded on your credit account and your shipment is being placed on its train.`
: `Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
this.inApp(b, 'Operation request accepted', msg);
this.inApp(
b,
b.shippingLineCompanyId ? 'Booking accepted' : 'Operation request accepted',
msg,
);
}
/**

View File

@@ -165,7 +165,7 @@ export class BookingPricingService {
total += line.amount;
}
const liveRates = await this.ratesService.findLiveRates();
const liveRates = await this.liveRatesForBooking(booking);
const rateById = new Map(liveRates.map((r) => [r.id, r]));
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
@@ -414,6 +414,9 @@ export class BookingPricingService {
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,
// A shipping line's own booking prices off that line's negotiated rates
// instead of the standard customer ones (see RuleEngineService.ratesForOwner).
shippingLineCompanyId: booking.shippingLineCompanyId,
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
totalWagons,
@@ -428,6 +431,22 @@ export class BookingPricingService {
};
}
/**
* LIVE rates this booking may price off.
*
* A shipping-line booking sees only its own line's rates; a customer booking
* only the standard ones. Line rates override rather than stack, and the
* standard rate is not a fallback — a lane the line has no rate for falls
* through to the existing "no rate configured" hard block, which is the
* intended outcome rather than silently billing the customer price.
*/
private async liveRatesForBooking(booking: Booking): Promise<Rate[]> {
const rates = await this.ratesService.findLiveRates();
return booking.shippingLineCompanyId
? rates.filter((r) => r.shippingLineCompanyId === booking.shippingLineCompanyId)
: rates.filter((r) => !r.shippingLineCompanyId);
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
@@ -512,7 +531,7 @@ export class BookingPricingService {
warnings: string[];
blocked: string[];
}> {
const liveRates = await this.ratesService.findLiveRates();
const liveRates = await this.liveRatesForBooking(booking);
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
@@ -713,7 +732,7 @@ export class BookingPricingService {
return { lineItems: [], usedRates: [] };
}
const liveRates = await this.ratesService.findLiveRates();
const liveRates = await this.liveRatesForBooking(booking);
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;

View File

@@ -39,6 +39,8 @@ import { ClearanceWorkflowService } from '../contracts/clearance-workflow.servic
import { ContractDocPhase } from '@edr/types';
import { BookingInvoiceService } from "./booking-invoice.service";
// Type-only: the DI edge stays event-based to keep the module graph acyclic.
import type { ShippingLineBookingAcceptedPayload } from "../shipping-lines/shipping-line-credits.service";
@Injectable()
export class BookingTransitionService {
@@ -944,6 +946,15 @@ export class BookingTransitionService {
bookingId: string,
scheduledDate: string,
requestedTrainScheduleId?: string | null,
opts?: {
/**
* Skip the customer day-pool departure/compatibility gate. Used ONLY by
* the shipping-line completion path, which has already validated the day
* against the line's own dedicated train (those trains are excluded from
* the customer pools, so the gate here would wrongly reject them).
*/
bypassDayPool?: boolean;
},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -971,20 +982,22 @@ export class BookingTransitionService {
// gate; quantity never blocks — oversized bookings get a partial split
// offer). The batch engine assigns the specific train within that
// (route, day) pool later.
const { hasDeparture, hasCompatible } =
await this.bookingsService.checkDayCompatibilityForBooking(
booking,
eatDay(date),
);
if (!hasDeparture) {
throw new BadRequestException(
"No departures available on the selected day for this route",
);
}
if (!hasCompatible) {
throw new BadRequestException(
"No wagon on the selected day can carry this cargo type — please choose another day",
);
if (!opts?.bypassDayPool) {
const { hasDeparture, hasCompatible } =
await this.bookingsService.checkDayCompatibilityForBooking(
booking,
eatDay(date),
);
if (!hasDeparture) {
throw new BadRequestException(
"No departures available on the selected day for this route",
);
}
if (!hasCompatible) {
throw new BadRequestException(
"No wagon on the selected day can carry this cargo type — please choose another day",
);
}
}
// Export is FCFS and never splits — a booking must ride one train whole. So
@@ -1001,7 +1014,14 @@ export class BookingTransitionService {
// The customer's train pick only exists for export rail; it rides the
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
// Shipping-line completions (bypassDayPool) pick among the line's own
// dedicated trains — already validated by the caller, so the pick is
// persisted here the same way an export pick is. Customer import/domestic
// bookings still never carry one (the batch engine assigns their train).
const requestedId =
isExportTrain || opts?.bypassDayPool
? (requestedTrainScheduleId ?? null)
: null;
// Export rail rides the exact train the customer picked — never an
// auto-assigned one. Both portal flows (clearance + contract completion)
// surface a picker, so a missing id is an invalid submission, not a
@@ -1205,10 +1225,22 @@ export class BookingTransitionService {
// booking page correctly still showed it as not payable. The batch engine
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
// and the real deadline are created — matching the portal's `canPay` gate.
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
//
// Shipping-line bookings mint NO invoice at all: they have no company row
// to bill (the invoices FK requires one) and they pay on the credit ledger
// — the charge was recorded at completion, and Finance bills a batch of
// credits later through ShippingLineCreditsService.generateInvoice.
if (booking.shippingLineCompanyId) {
this.logger.log(
`Skipping invoice for shipping-line booking ${booking.reference}:${booking.id} — billed later from the credit ledger`,
);
} else {
const invoice =
await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
}
// TODO: road (truck) orders are an incomplete feature — they stop at the
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
@@ -1222,6 +1254,7 @@ export class BookingTransitionService {
lockedAt: booking.lockedAt ?? now,
} as never);
const roadFresh = await this.bookingsService.findById(booking.id);
this.emitShippingLineAccepted(roadFresh);
this.notifier.operationAccepted(roadFresh);
return roadFresh;
}
@@ -1258,11 +1291,44 @@ export class BookingTransitionService {
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.)
//
// EXCEPT shipping-line bookings: they pay later on the credit ledger, so
// no pay window exists to wait for — accept places them straight onto
// their company's dedicated train and its wagons. Non-fatal on purpose:
// the accept has committed; an allocation hiccup leaves the booking in
// the day pool for the batch engine / staff instead of failing the accept.
if (booking.shippingLineCompanyId) {
try {
await this.bookingBatchService.allocateShippingLineAccepted(booking.id);
} catch (err) {
this.logger.warn(
`Auto-allocation failed for shipping-line booking ${booking.reference}:${booking.id} — left in the day pool: ${(err as Error).message}`,
);
}
}
const trainFresh = await this.bookingsService.findById(booking.id);
this.emitShippingLineAccepted(trainFresh);
this.notifier.operationAccepted(trainFresh);
return trainFresh;
}
/**
* A shipping-line booking becomes debt at THIS moment — Operations accepted
* it — not at completion/pricing. Event, not a service call:
* ShippingLineCreditsService listens (`shipping_line_booking.accepted`), and
* importing its module here would close a module cycle. Emitted after the
* accept has fully committed (including the export-capacity path, which can
* still revert the status above), so a failed accept never creates debt.
*/
private emitShippingLineAccepted(booking: Booking): void {
if (!booking.shippingLineCompanyId) return;
this.events.emit("shipping_line_booking.accepted", {
bookingId: booking.id,
reference: booking.reference,
amount: Number(booking.totalAmount),
} satisfies ShippingLineBookingAcceptedPayload);
}
async enrichBookingResponse(booking: Booking): Promise<
Booking & {
latestChangeRequestNote?: string | null;

View File

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

View File

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

View File

@@ -16,6 +16,16 @@ type Freight = 'container' | 'bulk';
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
/**
* The document set a shipping line uploads on a booking it initiated.
*
* Shipping lines book without a contract, so none of the trade-direction /
* freight / customs matrix below applies to them — this one admin-configured
* set is what Operations reviews before the booking may be completed.
*/
export const SHIPPING_LINE_DOCUMENTS_SETTING_CODE =
'shipping_line_booking_documents';
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
function operationFor(tradeDirection: string): Op | null {
if (tradeDirection === 'IMPORT') return 'import';
@@ -67,6 +77,20 @@ export function clearanceCodesForBooking(booking: Booking): {
outputCode: string | null;
includesCustoms: boolean;
} {
// Shipping-line bookings resolve to their own single set and never reach the
// matrix below: they have no contract, and their trade direction / freight
// type are placeholders until the booking is completed, so the customer codes
// would resolve to a set that was never meant for them. Keyed off the owner
// column, which is NULL on every customer booking — so no customer booking
// can take this branch.
if (booking.shippingLineCompanyId) {
return {
inputCode: SHIPPING_LINE_DOCUMENTS_SETTING_CODE,
outputCode: null,
includesCustoms: false,
};
}
// Customs applies when EITHER the service type bundles it OR the booking was
// created with customsClearingEnabled (copied from the contract). Contract
// bookings carry customsClearingEnabled even when the serviceType relation

View File

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

View File

@@ -108,15 +108,34 @@ export class Booking extends BaseEntity {
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
// Every booking is billed to a company — government bookings bill to a seeded
// government company (companies.kind = 'government'). Enforced NOT NULL.
@Column({ name: 'company_id', type: 'uuid' })
// Every CUSTOMER booking is billed to a company — government bookings bill to
// a seeded government company (companies.kind = 'government'). NULL only on a
// shipping-line booking, owned by `shippingLineCompanyId` instead; a DB CHECK
// enforces that exactly one of the two is set.
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId!: string;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
company?: Company | null;
/**
* The shipping-line ACCOUNT that owns this booking, when it is not a
* customer's. Shipping lines book without a contract and are not `companies`
* rows (no TIN, licence or operational profiles), so they get their own owner
* column rather than a synthetic company. NULL on every customer booking.
*
* Deliberately NOT `shippingLineId` above: that is cargo metadata naming the
* carrier line that moves the goods (`freight.shipping_lines`, reference data
* set on customer bookings too). This points at `shipping_line_companies` —
* the portal account — and the two are unrelated.
*
* No relation is declared: `ShippingLineCompany` lives in its own module and
* the column is read by id, matching how the migration leaves it FK-free.
*/
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
shippingLineCompanyId?: string | null;
/**
* The operational profile (importer/exporter/forwarder) this booking belongs
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
@@ -125,7 +144,9 @@ export class Booking extends BaseEntity {
* commercial bookings resolve it from trade direction / active mode;
* government bookings carry the explicitly-picked government profile.
*/
@Column({ name: 'company_profile_id', type: 'uuid' })
// NULL only on a shipping-line booking — shipping lines have no operational
// profiles. Always set on a customer booking, as before.
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
companyProfileId!: string;
@ManyToOne(() => CompanyProfile, { nullable: true })
@@ -259,7 +280,7 @@ export class Booking extends BaseEntity {
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
contractType!: string;
@Column({ name: 'service_type_id', type: 'uuid' })
@Column({ name: 'service_type_id', type: 'uuid', nullable: true })
serviceTypeId!: string;
@ManyToOne(() => ServiceType)
@@ -337,14 +358,14 @@ export class Booking extends BaseEntity {
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
equipmentReturn!: string;
@Column({ name: 'origin_yard_id', type: 'uuid' })
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
originYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: 'destination_yard_id', type: 'uuid' })
@Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
destinationYardId!: string;
@ManyToOne(() => Yard)
@@ -354,7 +375,7 @@ export class Booking extends BaseEntity {
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
tradeDirection!: string;
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
freightType!: string;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })

View File

@@ -52,6 +52,11 @@ import {
} from "./entities/company-profile.entity";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import {
AccountInfoResponse,
ShippingLineInfoResponseDto,
} from "./dto/account-info-response.dto";
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
@@ -96,6 +101,7 @@ export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
) { }
/**
@@ -119,16 +125,31 @@ export class CompaniesController {
@Get("getInfo")
@PortalCustomer()
@ApiOperation({ summary: "Get company info for the current user" })
@ApiOperation({
summary: "Get account info for the current user (customer or shipping line)",
})
async getInfo(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
): Promise<AccountInfoResponse> {
// A shipping line has no company and no external profile, so the customer
// lookup below would 404. Checked first, and reported with an explicit
// `accountKind` so the portal can skip onboarding for shipping lines
// without inferring it from a missing company.
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
user.id,
);
if (shippingLine) {
return new ShippingLineInfoResponseDto(shippingLine);
}
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
const review = await this.companiesService.getOpenChangeRequestForCompany(
company.id,
);
return new CompanyInfoResponseDto(profile, company, review);
return Object.assign(new CompanyInfoResponseDto(profile, company, review), {
accountKind: "customer" as const,
});
}
@Get("profile")

View File

@@ -17,6 +17,7 @@ import { CompanyProfile } from "./entities/company-profile.entity";
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
import { CompanyRevision } from "./entities/company-revision.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { CompanyRevisionRepository } from "./company-revision.repository";
@@ -44,6 +45,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
forwardRef(() => NotificationInboxModule),
// Fayda identity verification for the company's owner and PoA.
VerifaydaModule,
// `GET /companies/getInfo` serves both portal audiences: it must recognise a
// shipping-line session, which has no company row to look up. forwardRef
// because that module imports BillingModule, which imports this one.
forwardRef(() => ShippingLineCompaniesModule),
],
controllers: [CompaniesController],
providers: [

View File

@@ -0,0 +1,66 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
import { CompanyInfoResponseDto } from "./company-info-response.dto";
/**
* What kind of account is signed in to the portal.
*
* The portal keys its onboarding gate off this rather than off "is `company`
* missing?": a failed or slow company fetch also leaves `company` empty, and
* treating that as "no onboarding needed" would let customers skip onboarding
* whenever the request failed. A shipping line is identified positively, and
* anything else defaults to `customer`.
*/
export type AccountKind = "customer" | "shipping_line";
/** The signed-in shipping line. No company, no profile, no onboarding. */
export class ShippingLineInfoResponseDto {
@ApiProperty({ enum: ["shipping_line"] })
accountKind: "shipping_line" = "shipping_line";
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty()
email: string;
@ApiPropertyOptional()
phoneNumber?: string | null;
@ApiPropertyOptional()
scacCode?: string | null;
@ApiProperty()
status: string;
/**
* Always null. Present so the portal can read `company` / `profile` off either
* payload shape without narrowing the union first — the fields a customer
* session carries simply have no shipping-line equivalent.
*/
@ApiProperty({ nullable: true })
company: null = null;
@ApiProperty({ nullable: true })
profile: null = null;
@ApiProperty({ nullable: true })
review: null = null;
constructor(entity: ShippingLineCompany) {
this.id = entity.id;
this.name = entity.name;
this.email = entity.email;
this.phoneNumber = entity.phoneNumber ?? null;
this.scacCode = entity.scacCode ?? null;
this.status = entity.status;
}
}
export type AccountInfoResponse =
| (CompanyInfoResponseDto & { accountKind: "customer" })
| ShippingLineInfoResponseDto;

View File

@@ -1,4 +1,4 @@
import { BadRequestException } from "@nestjs/common";
import { BadRequestException, ConflictException } from "@nestjs/common";
import { DataSource } from "typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
@@ -96,14 +96,16 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => {
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
});
it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
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();
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
ConflictException,
);
expect(postBearer).not.toHaveBeenCalled();
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
});
it("refuses to cancel an invoice that was never registered", async () => {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntityManager } from "typeorm";
@@ -38,8 +38,11 @@ export class EimsCancellationService {
) {}
/**
* Idempotent: an already-cancelled invoice returns unchanged, no HTTP call. Refuses an invoice
* that was never registered — there is no IRN to cancel.
* 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,
@@ -48,7 +51,12 @@ export class EimsCancellationService {
): Promise<EimsInvoiceStatusView> {
const eligible = await this.dataSource.transaction(async (manager) => {
const invoice = await this.lockInvoice(manager, invoiceId);
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return null;
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",
@@ -57,7 +65,6 @@ export class EimsCancellationService {
}
return invoice;
});
if (!eligible) return this.getEimsCancellationStatus(invoiceId);
const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" };
// Outside any transaction — no DB lock is held across the wire.
@@ -93,6 +100,7 @@ export class EimsCancellationService {
/** 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,

View File

@@ -394,7 +394,7 @@ export class EimsInvoiceRegistrationService {
let invoiceNumber = invoiceId;
await this.dataSource.transaction(async (manager) => {
const invoice = await this.lockInvoice(manager, invoiceId);
companyId = invoice.companyId;
companyId = invoice.companyId ?? undefined;
invoiceNumber = invoice.invoiceNumber;
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Registered,

View File

@@ -81,7 +81,7 @@ export class EimsInvoiceController {
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
@ApiOperation({
summary:
"Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.",
"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);

View File

@@ -219,6 +219,7 @@ export class EimsReceiptService {
}
private async notifyBuyer(invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string): Promise<void> {
if (!invoice.companyId) return;
try {
await sendCompanyChannels(
this.dataSource,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { Body, Controller, Delete, Get, Put } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateLogoSettingDto } from "./dto/update-logo-setting.dto";
import { LogoSettingsService } from "./logo-settings.service";
@ApiTags("logo-settings")
@ApiBearerAuth()
@Controller("logo-settings")
export class LogoSettingsController {
constructor(private readonly service: LogoSettingsService) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.logo.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Current company logo used on every generated document" })
get() {
return this.service.getView();
}
@Put()
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the company logo" })
update(@Body() dto: UpdateLogoSettingDto, @CurrentUser() user: TCurrentUser) {
return this.service.setLogo(dto.logoImageBase64, user?.id ?? null);
}
@Delete()
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Clear the company logo (documents fall back to their text mark)",
})
clear(@CurrentUser() user: TCurrentUser) {
return this.service.clearLogo(user?.id ?? null);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import { DataSource } from "typeorm";
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
/**
* Notification target for a shipping-line booking.
*
* A shipping line is NOT a `companies` row: the company IS the account — one
* IAM user (`userId`), and the contact details live on the
* `shipping_line_companies` row itself. So the customer resolvers
* (external_profiles fan-out, company attributes email) never apply; this is
* the one lookup every shipping-line notification routes through.
*
* Entity-only import — safe from any module graph: notifiers already own a
* DataSource and need no service from the shipping-lines module.
*/
export async function resolveShippingLineNotifyTarget(
dataSource: DataSource,
shippingLineCompanyId: string,
): Promise<{
userId: string | null;
phone: string | null;
email: string | null;
}> {
const line = await dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { id: shippingLineCompanyId } });
return {
userId: line?.userId ?? null,
phone: line?.phoneNumber ?? null,
email: line?.email ?? null,
};
}

View File

@@ -21,6 +21,7 @@ export class OverviewContractKpisDto {
export class OverviewOperationsKpisDto {
@ApiProperty() trainsActive!: number;
@ApiProperty() wagonsAvailable!: number;
@ApiProperty() wagonsTotal!: number;
@ApiProperty() containersInTransit!: number;
@ApiProperty() cargoesLoaded!: number;
@ApiProperty() schedulesUpcoming!: number;
@@ -108,6 +109,41 @@ export class OverviewRecentContractDto {
@ApiProperty() createdAt!: string;
}
export class OverviewPeriodTotalsDto {
@ApiProperty() bookingsCreated!: number;
@ApiProperty() revenueEtb!: number;
@ApiProperty() revenueUsd!: number;
@ApiProperty() tons!: number;
}
export class OverviewRevenueSliceDto {
@ApiProperty() label!: string;
@ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number;
}
export class OverviewTonsTrendPointDto {
@ApiProperty({ example: '2026-06-01' }) date!: string;
@ApiProperty() tons!: number;
}
export class OverviewRevenueFlowDto {
@ApiProperty() direction!: string;
@ApiProperty() freightType!: string;
@ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number;
}
export class OverviewHeatmapCellDto {
@ApiProperty({ description: 'ISO weekday, 1 = Monday … 7 = Sunday' })
dow!: number;
@ApiProperty({ description: '3-hour block, 0 = 0003 … 7 = 2124' })
block!: number;
@ApiProperty() count!: number;
}
export class OverviewResponseDto {
@ApiProperty({ type: OverviewKpisDto })
kpis!: OverviewKpisDto;
@@ -124,8 +160,29 @@ export class OverviewResponseDto {
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
paymentTrend!: OverviewPaymentTrendPointDto[];
@ApiProperty({ type: [OverviewRecentBookingDto] })
recentBookings!: OverviewRecentBookingDto[];
@ApiProperty({ type: OverviewPeriodTotalsDto })
current!: OverviewPeriodTotalsDto;
@ApiProperty({ type: OverviewPeriodTotalsDto })
previous!: OverviewPeriodTotalsDto;
@ApiProperty({ type: [OverviewRevenueSliceDto] })
revenueByDirection!: OverviewRevenueSliceDto[];
@ApiProperty({ type: [OverviewRevenueSliceDto] })
revenueByFreightType!: OverviewRevenueSliceDto[];
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
previousPaymentTrend!: OverviewPaymentTrendPointDto[];
@ApiProperty({ type: [OverviewTonsTrendPointDto] })
tonsTrend!: OverviewTonsTrendPointDto[];
@ApiProperty({ type: [OverviewRevenueFlowDto] })
revenueFlows!: OverviewRevenueFlowDto[];
@ApiProperty({ type: [OverviewHeatmapCellDto] })
bookingHeatmap!: OverviewHeatmapCellDto[];
@ApiProperty() generatedAt!: string;
}

View File

@@ -116,6 +116,28 @@ export class OverviewTonnagePointDto {
@ApiProperty() tons!: number;
}
/** One bucket × series cell of a two-dimensional breakdown. */
export class OverviewMatrixCellDto {
@ApiProperty({ example: 'Flat wagon' }) group!: string;
@ApiProperty({ example: 'AVAILABLE' }) series!: string;
@ApiProperty() count!: number;
}
export class OverviewTrainLoadDto {
@ApiProperty() scheduleId!: string;
@ApiProperty({ example: '8001' }) trainNumber!: string;
@ApiProperty({ example: '2026-08-13' }) date!: string;
@ApiProperty({ example: 'EXPORT' }) direction!: string;
@ApiProperty() wagonsAllocated!: number;
@ApiProperty() wagonsTotal!: number;
@ApiProperty() tons!: number;
}
export class OverviewTurnaroundDto {
@ApiProperty({ example: '8001' }) trainSet!: string;
@ApiProperty({ example: 26.5 }) hours!: number;
}
export class OverviewOperationsTabDto {
@ApiProperty({ type: OverviewOperationsKpisDto })
kpis!: OverviewOperationsKpisDto;
@@ -150,6 +172,64 @@ export class OverviewOperationsTabDto {
@ApiProperty({ type: [OverviewStatusCountDto] })
cargoStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
bookingsByPort!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
bookingStatusByPort!: OverviewMatrixCellDto[];
@ApiProperty({ type: [OverviewTrainLoadDto] })
trainLoads!: OverviewTrainLoadDto[];
@ApiProperty()
generatedAt!: string;
}
export class OverviewFleetTabDto {
@ApiProperty({ type: [OverviewStatusCountDto] })
wagonStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
wagonStatusByType!: OverviewMatrixCellDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
wagonStatusByYard!: OverviewMatrixCellDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
locomotiveStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
locomotivesByYard!: OverviewMatrixCellDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
locomotivesByType!: OverviewLabelCountDto[];
@ApiProperty({ nullable: true, example: 26.5 })
avgTurnaroundHours!: number | null;
@ApiProperty({ type: [OverviewTurnaroundDto] })
turnaroundByTrain!: OverviewTurnaroundDto[];
@ApiProperty()
generatedAt!: string;
}
export class OverviewClearanceTabDto {
@ApiProperty({ type: [OverviewStatusCountDto] })
bookingDocumentsByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
contractDocumentsByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
invoicesByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
invoicesByType!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
handoversByMile!: OverviewMatrixCellDto[];
@ApiProperty()
generatedAt!: string;
}
@@ -167,6 +247,9 @@ export class OverviewCustomersTabDto {
@ApiProperty({ type: [OverviewLabelCountDto] })
topCustomersByBookings!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
profilesByTypeStatus!: OverviewMatrixCellDto[];
@ApiProperty()
generatedAt!: string;
}

View File

@@ -15,8 +15,10 @@ import { OverviewResponseDto } from './dto/overview-response.dto';
import {
OverviewBillingTabDto,
OverviewBookingsTabDto,
OverviewClearanceTabDto,
OverviewContractsTabDto,
OverviewCustomersTabDto,
OverviewFleetTabDto,
OverviewOperationsTabDto,
OverviewStaffTabDto,
} from './dto/overview-tab-response.dto';
@@ -106,6 +108,22 @@ export class OverviewController {
return this.overviewService.getOperationsTab(query.range ?? '30d');
}
@Get('fleet')
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Wagon and locomotive fleet detail, plus turnaround' })
@ApiOkResponse({ type: OverviewFleetTabDto })
getFleetTab(@Query() query: OverviewQueryDto): Promise<OverviewFleetTabDto> {
return this.overviewService.getFleetTab(query.range ?? '30d');
}
@Get('clearance')
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Document review and invoice queues' })
@ApiOkResponse({ type: OverviewClearanceTabDto })
getClearanceTab(): Promise<OverviewClearanceTabDto> {
return this.overviewService.getClearanceTab();
}
@Get('customers')
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Customers tab metrics and charts' })

View File

@@ -58,6 +58,21 @@ export type OverviewRecentBookingRow = {
createdAt: Date;
};
/** One cell of a two-dimensional breakdown (bucket × stacked series). */
export type MatrixCell = { group: string; series: string; count: number };
export type TurnaroundRow = { trainSet: string; hours: number };
export type TrainLoadRow = {
scheduleId: string;
trainNumber: string;
date: string;
direction: string;
wagonsAllocated: number;
wagonsTotal: number;
tons: number;
};
export type OverviewContractKpisRow = {
total: number;
totalActive: number;
@@ -155,6 +170,7 @@ export class OverviewRepository {
async getOperationsKpis(): Promise<{
trainsActive: number;
wagonsAvailable: number;
wagonsTotal: number;
containersInTransit: number;
cargoesLoaded: number;
schedulesUpcoming: number;
@@ -163,6 +179,7 @@ export class OverviewRepository {
const [
trainsActive,
wagonsAvailable,
wagonsTotal,
containersInTransit,
cargoesLoaded,
schedulesUpcoming,
@@ -185,6 +202,10 @@ export class OverviewRepository {
status: Freight.WagonStatus.Available,
})
.getCount(),
this.wagonRepository
.createQueryBuilder("wagon")
.where("wagon.deleted_at IS NULL")
.getCount(),
this.containerRepository
.createQueryBuilder("container")
.where("container.deleted_at IS NULL")
@@ -218,6 +239,7 @@ export class OverviewRepository {
return {
trainsActive,
wagonsAvailable,
wagonsTotal,
containersInTransit,
cargoesLoaded,
schedulesUpcoming,
@@ -345,9 +367,16 @@ export class OverviewRepository {
);
}
/**
* Daily successful-payment revenue for a `days`-wide window shifted back by
* `offsetDays` — `0` (default) is the current window ending today,
* `offsetDays: days` is the immediately preceding window (the ghost-line
* comparison series on the overview chart).
*/
async getPaymentTrend(
days: number,
dirs?: string[],
offsetDays = 0,
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
@@ -366,8 +395,8 @@ export class OverviewRepository {
)
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`,
{ days, offsetDays },
)
.andWhere(scope.sql, scope.params)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
@@ -546,6 +575,247 @@ export class OverviewRepository {
}));
}
/**
* Bookings created, revenue and tonnage for one `days`-wide window, shifted
* back by `offsetDays`. Called twice by the service — `offsetDays: 0` for
* the current period, `offsetDays: days` for the immediately preceding
* one-of-the-same-length period — so the page can show a real vs-prior-period
* delta instead of a bare count.
*/
async getPeriodTotals(
days: number,
offsetDays: number,
dirs?: string[],
): Promise<{
bookingsCreated: number;
revenueEtb: number;
revenueUsd: number;
tons: number;
}> {
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
const paymentScope = bookingRefScopeSql("payment.ref_id", dirs);
const cargoScope = directionScopeSql("booking.trade_direction", dirs);
const windowSql = (column: string) =>
`${column} >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND ${column} < CURRENT_DATE - :offsetDays::int + 1`;
const [bookingsCreated, revenueRow, tonsRow] = await Promise.all([
this.bookingRepository
.createQueryBuilder("booking")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(bookingScope.sql, bookingScope.params)
.andWhere(windowSql("booking.created_at"), { days, offsetDays })
.getCount(),
this.paymentRepository
.createQueryBuilder("payment")
.select(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
"revenueEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"revenueUsd",
)
.where("payment.status = :status", { status: "success" })
.andWhere(
windowSql("COALESCE(payment.paid_at, payment.created_at)"),
{ days, offsetDays },
)
.andWhere(paymentScope.sql, paymentScope.params)
.getRawOne<{ revenueEtb: string; revenueUsd: string }>(),
this.cargoRepository
.createQueryBuilder("cargo")
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
.select(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons")
.where("cargo.deleted_at IS NULL")
.andWhere(windowSql("cargo.created_at"), { days, offsetDays })
.andWhere(cargoScope.sql, cargoScope.params)
.getRawOne<{ tons: string }>(),
]);
return {
bookingsCreated,
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
tons: Number(tonsRow?.tons ?? 0),
};
}
/** Revenue for the selected range, split by booking trade direction. */
async getRevenueByDirection(
days: number,
dirs?: string[],
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
.select("booking.trade_direction", "label")
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
"amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd",
)
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.andWhere(scope.sql, scope.params)
.andWhere("booking.trade_direction IS NOT NULL")
.groupBy("booking.trade_direction")
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
return rows.map((row) => ({
label: row.label,
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
}));
}
/** Revenue for the selected range, split by booking freight type. */
async getRevenueByFreightType(
days: number,
dirs?: string[],
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
.select("booking.freight_type", "label")
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
"amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd",
)
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.andWhere(scope.sql, scope.params)
.andWhere("booking.freight_type IS NOT NULL")
.groupBy("booking.freight_type")
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
return rows.map((row) => ({
label: row.label,
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
}));
}
/** Daily cargo tonnage for the selected range — hero sparkline series. */
async getTonsTrend(
days: number,
dirs?: string[],
): Promise<{ date: string; tons: number }[]> {
const scope = directionScopeSql("booking.trade_direction", dirs);
const rows = await this.cargoRepository
.createQueryBuilder("cargo")
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
.select(`to_char(cargo.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons")
.where("cargo.deleted_at IS NULL")
.andWhere(scope.sql, scope.params)
.andWhere(`cargo.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("cargo.created_at::date")
.orderBy("cargo.created_at::date", "ASC")
.getRawMany<{ date: string; tons: string }>();
return rows.map((row) => ({ date: row.date, tons: Number(row.tons) }));
}
/**
* Revenue for the selected range as direction → freight-type flows — the
* Sankey on the overview. One row per (direction, freight type) pair.
*/
async getRevenueFlows(
days: number,
dirs?: string[],
): Promise<
{
direction: string;
freightType: string;
amountEtb: number;
amountUsd: number;
}[]
> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
.select("booking.trade_direction", "direction")
.addSelect("booking.freight_type", "freightType")
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
"amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd",
)
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.andWhere(scope.sql, scope.params)
.andWhere("booking.trade_direction IS NOT NULL")
.andWhere("booking.freight_type IS NOT NULL")
.groupBy("booking.trade_direction")
.addGroupBy("booking.freight_type")
.getRawMany<{
direction: string;
freightType: string;
amountEtb: string;
amountUsd: string;
}>();
return rows.map((row) => ({
direction: row.direction,
freightType: row.freightType,
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
}));
}
/**
* Booking arrivals bucketed by ISO weekday (1 = Mon … 7 = Sun) and 3-hour
* block (0 = 0003 … 7 = 2124) — the demand-rhythm heatmap. Buckets use
* the database server's timezone, same as every ::date grouping here.
*/
async getBookingHeatmap(
days: number,
dirs?: string[],
): Promise<{ dow: number; block: number; count: number }[]> {
const scope = directionScopeSql("booking.trade_direction", dirs);
const rows = await this.bookingRepository
.createQueryBuilder("booking")
.select("EXTRACT(ISODOW FROM booking.created_at)::int", "dow")
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
.addGroupBy("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int")
.getRawMany<{ dow: string; block: string; count: string }>();
return rows.map((row) => ({
dow: Number(row.dow),
block: Number(row.block),
count: Number(row.count),
}));
}
async getTrainStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
@@ -1006,4 +1276,334 @@ export class OverviewRepository {
createdAt: row.createdAt,
}));
}
// ---------------------------------------------------------------------------
// Fleet / operations detail. These read tables that have no repository
// injected here (locomotives, train_set_wagons, document reviews, …), so they
// go through the shared entity manager with plain SQL instead of adding six
// more constructor arguments for one query each.
// ---------------------------------------------------------------------------
private get sql() {
return this.wagonRepository.manager;
}
private async matrix(
statement: string,
params: unknown[] = [],
): Promise<MatrixCell[]> {
const rows = await this.sql.query<
{ group: string; series: string; count: string }[]
>(statement, params);
return rows.map((row) => ({
group: row.group,
series: row.series,
count: Number(row.count),
}));
}
private async labelCounts(
statement: string,
params: unknown[] = [],
): Promise<{ label: string; count: number }[]> {
const rows = await this.sql.query<{ label: string; count: string }[]>(
statement,
params,
);
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
}
private async statusCounts(
statement: string,
params: unknown[] = [],
): Promise<{ status: string; count: number }[]> {
const rows = await this.sql.query<{ status: string; count: string }[]>(
statement,
params,
);
return rows.map((row) => ({ status: row.status, count: Number(row.count) }));
}
/** Wagon lifecycle state crossed with wagon type — "how many flat wagons are detained". */
getWagonStatusByType(): Promise<MatrixCell[]> {
return this.matrix(`
SELECT COALESCE(wt.name, 'Unknown') AS "group",
w.status AS series,
COUNT(*)::int AS count
FROM freight.wagons w
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
WHERE w.deleted_at IS NULL
GROUP BY 1, 2
ORDER BY 1, 2
`);
}
/** Same lifecycle state, per yard the wagon currently sits in. */
getWagonStatusByYard(): Promise<MatrixCell[]> {
return this.matrix(`
SELECT y.label AS "group",
w.status AS series,
COUNT(*)::int AS count
FROM freight.wagons w
INNER JOIN freight.yards y ON y.id = w.current_yard_id
WHERE w.deleted_at IS NULL
GROUP BY 1, 2
ORDER BY 1, 2
`);
}
getLocomotiveStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusCounts(`
SELECT status, COUNT(*)::int AS count
FROM freight.locomotives
WHERE deleted_at IS NULL
GROUP BY status
ORDER BY count DESC
`);
}
/** Locomotives per station, split by status — the OCC's first question. */
getLocomotivesByYard(): Promise<MatrixCell[]> {
return this.matrix(`
SELECT COALESCE(y.label, 'Unassigned') AS "group",
l.status AS series,
COUNT(*)::int AS count
FROM freight.locomotives l
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
WHERE l.deleted_at IS NULL
GROUP BY 1, 2
ORDER BY 1, 2
`);
}
getLocomotivesByType(): Promise<{ label: string; count: number }[]> {
return this.labelCounts(`
SELECT COALESCE(locomotive_type, 'Unknown') AS label,
COUNT(*)::int AS count
FROM freight.locomotives
WHERE deleted_at IS NULL
GROUP BY 1
ORDER BY count DESC
`);
}
/**
* Departure-to-departure gap per train set over the range. Only consecutive
* *actual* departures count — a schedule that never left says nothing about
* how fast the set turned around.
*/
async getTrainTurnaround(
days: number,
limit: number,
): Promise<{ avgHours: number | null; rows: TurnaroundRow[] }> {
const rows = await this.sql.query<
{ trainSet: string; hours: string }[]
>(
`
WITH departures AS (
SELECT s.train_set_id,
s.actual_departure_at,
LAG(s.actual_departure_at) OVER (
PARTITION BY s.train_set_id ORDER BY s.actual_departure_at
) AS previous_departure
FROM freight.train_schedules s
WHERE s.deleted_at IS NULL
AND s.actual_departure_at IS NOT NULL
AND s.actual_departure_at >= NOW() - make_interval(days => $1::int)
)
SELECT COALESCE(t.train_number, t.code, 'Train set') AS "trainSet",
ROUND(
AVG(
EXTRACT(EPOCH FROM (d.actual_departure_at - d.previous_departure)) / 3600
)::numeric,
1
) AS hours
FROM departures d
LEFT JOIN freight.train_sets ts ON ts.id = d.train_set_id
LEFT JOIN freight.trains t ON t.id = ts.train_id
WHERE d.previous_departure IS NOT NULL
GROUP BY 1
ORDER BY hours ASC
LIMIT $2::int
`,
[days, limit],
);
const mapped = rows.map((row) => ({
trainSet: row.trainSet,
hours: Number(row.hours),
}));
const avgHours = mapped.length
? Number(
(
mapped.reduce((sum, row) => sum + row.hours, 0) / mapped.length
).toFixed(1),
)
: null;
return { avgHours, rows: mapped };
}
/**
* Bookings per port yard. Import cargo enters at its origin yard, export
* cargo leaves from its destination yard — anything else is counted at origin.
*/
getBookingsByPort(days: number): Promise<{ label: string; count: number }[]> {
return this.labelCounts(
`
SELECT y.label AS label, COUNT(*)::int AS count
FROM freight.bookings b
INNER JOIN freight.yards y
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
THEN b.destination_yard_id ELSE b.origin_yard_id END
WHERE b.deleted_at IS NULL
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
AND b.created_at >= NOW() - make_interval(days => $1::int)
GROUP BY 1
ORDER BY count DESC
`,
[days],
);
}
getBookingStatusByPort(days: number): Promise<MatrixCell[]> {
return this.matrix(
`
SELECT y.label AS "group", b.status AS series, COUNT(*)::int AS count
FROM freight.bookings b
INNER JOIN freight.yards y
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
THEN b.destination_yard_id ELSE b.origin_yard_id END
WHERE b.deleted_at IS NULL
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
AND b.created_at >= NOW() - make_interval(days => $1::int)
GROUP BY 1, 2
ORDER BY 1, 2
`,
[days],
);
}
/**
* Wagon fill and tonnage per scheduled train. Slots come from the train set's
* wagon list, the load from confirmed booking allocations against those slots.
*/
async getTrainLoads(limit: number): Promise<TrainLoadRow[]> {
const rows = await this.sql.query<
{
scheduleId: string;
trainNumber: string;
date: string;
direction: string;
wagonsTotal: string;
wagonsAllocated: string;
tons: string;
}[]
>(
`
SELECT s.id AS "scheduleId",
COALESCE(s.train_number, s.reference, '—') AS "trainNumber",
to_char(s.scheduled_departure_date, 'YYYY-MM-DD') AS date,
COALESCE(s.direction, 'DOMESTIC') AS direction,
COALESCE(slots.total, 0)::int AS "wagonsTotal",
COALESCE(load.wagons, 0)::int AS "wagonsAllocated",
COALESCE(load.tons, 0)::float AS tons
FROM freight.train_schedules s
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS total
FROM freight.train_set_wagons tsw
WHERE tsw.train_set_id = s.train_set_id
AND tsw.deleted_at IS NULL
) slots ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(DISTINCT wba.train_set_wagon_id)::int AS wagons,
COALESCE(SUM(wba.allocated_weight_tons), 0) AS tons
FROM freight.wagon_booking_allocations wba
INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
WHERE tsw.train_set_id = s.train_set_id
AND wba.deleted_at IS NULL
AND tsw.deleted_at IS NULL
) load ON TRUE
WHERE s.deleted_at IS NULL
AND s.status <> 'DRAFT'
ORDER BY s.scheduled_departure_date DESC
LIMIT $1::int
`,
[limit],
);
return rows.map((row) => ({
scheduleId: row.scheduleId,
trainNumber: row.trainNumber,
date: row.date,
direction: row.direction,
wagonsTotal: Number(row.wagonsTotal),
wagonsAllocated: Number(row.wagonsAllocated),
tons: Number(row.tons),
}));
}
getBookingDocumentsByStatus(): Promise<{ status: string; count: number }[]> {
return this.statusCounts(`
SELECT status, COUNT(*)::int AS count
FROM freight.booking_document_review
WHERE deleted_at IS NULL
GROUP BY status
ORDER BY count DESC
`);
}
getContractDocumentsByStatus(): Promise<{ status: string; count: number }[]> {
return this.statusCounts(`
SELECT status, COUNT(*)::int AS count
FROM freight.contract_document_review
WHERE deleted_at IS NULL
GROUP BY status
ORDER BY count DESC
`);
}
getInvoicesByStatus(): Promise<{ status: string; count: number }[]> {
return this.statusCounts(`
SELECT status, COUNT(*)::int AS count
FROM freight.invoices
WHERE deleted_at IS NULL
GROUP BY status
ORDER BY count DESC
`);
}
getInvoicesByType(): Promise<{ label: string; count: number }[]> {
return this.labelCounts(`
SELECT COALESCE(type, 'Other') AS label, COUNT(*)::int AS count
FROM freight.invoices
WHERE deleted_at IS NULL
GROUP BY 1
ORDER BY count DESC
`);
}
/** Handover papers per mile, split into signed and awaiting signature. */
getHandoversByMile(): Promise<MatrixCell[]> {
return this.matrix(`
SELECT COALESCE(mile_type, 'Unknown') AS "group",
CASE WHEN signed_at IS NULL THEN 'PENDING' ELSE 'SIGNED' END AS series,
COUNT(*)::int AS count
FROM freight.booking_handovers
WHERE deleted_at IS NULL
GROUP BY 1, 2
ORDER BY 1, 2
`);
}
/** Company profiles per type × status — active, pending and suspended per trade role. */
getProfilesByTypeStatus(): Promise<MatrixCell[]> {
return this.matrix(`
SELECT type AS "group", status AS series, COUNT(*)::int AS count
FROM freight.company_profiles
WHERE deleted_at IS NULL
GROUP BY 1, 2
ORDER BY 1, 2
`);
}
}

View File

@@ -9,8 +9,10 @@ import type { OverviewResponseDto } from './dto/overview-response.dto';
import type {
OverviewBillingTabDto,
OverviewBookingsTabDto,
OverviewClearanceTabDto,
OverviewContractsTabDto,
OverviewCustomersTabDto,
OverviewFleetTabDto,
OverviewOperationsTabDto,
OverviewStaffTabDto,
} from './dto/overview-tab-response.dto';
@@ -56,7 +58,14 @@ export class OverviewService {
bookingTrend,
statusCounts,
paymentTrend,
recentBookings,
current,
previous,
revenueByDirection,
revenueByFreightType,
previousPaymentTrend,
tonsTrend,
revenueFlows,
bookingHeatmap,
] = await Promise.all([
this.overviewRepository.getBookingKpis(dirs),
this.overviewRepository.getContractKpis(dirs),
@@ -67,7 +76,14 @@ export class OverviewService {
this.overviewRepository.getBookingTrend(days, dirs),
this.overviewRepository.getStatusCounts(dirs),
this.overviewRepository.getPaymentTrend(days, dirs),
this.overviewRepository.getRecentBookings(8, dirs),
this.overviewRepository.getPeriodTotals(days, 0, dirs),
this.overviewRepository.getPeriodTotals(days, days, dirs),
this.overviewRepository.getRevenueByDirection(days, dirs),
this.overviewRepository.getRevenueByFreightType(days, dirs),
this.overviewRepository.getPaymentTrend(days, dirs, days),
this.overviewRepository.getTonsTrend(days, dirs),
this.overviewRepository.getRevenueFlows(days, dirs),
this.overviewRepository.getBookingHeatmap(days, dirs),
]);
const { bookingsByPipeline, bookingsByStatus } =
@@ -86,10 +102,14 @@ export class OverviewService {
bookingsByStatus,
bookingsByPipeline,
paymentTrend,
recentBookings: recentBookings.map((row) => ({
...row,
createdAt: row.createdAt.toISOString(),
})),
current,
previous,
revenueByDirection,
revenueByFreightType,
previousPaymentTrend,
tonsTrend,
revenueFlows,
bookingHeatmap,
generatedAt: new Date().toISOString(),
};
}
@@ -219,6 +239,9 @@ export class OverviewService {
wagonStatusBreakdown,
containerStatusBreakdown,
cargoStatusBreakdown,
bookingsByPort,
bookingStatusByPort,
trainLoads,
] = await Promise.all([
this.overviewRepository.getOperationsKpis(),
this.overviewRepository.getDepartureTrend(days),
@@ -231,6 +254,9 @@ export class OverviewService {
this.overviewRepository.getWagonStatusBreakdown(),
this.overviewRepository.getContainerStatusBreakdown(),
this.overviewRepository.getCargoStatusBreakdown(),
this.overviewRepository.getBookingsByPort(days),
this.overviewRepository.getBookingStatusByPort(days),
this.overviewRepository.getTrainLoads(8),
]);
return {
@@ -245,6 +271,76 @@ export class OverviewService {
wagonStatusBreakdown,
containerStatusBreakdown,
cargoStatusBreakdown,
bookingsByPort,
bookingStatusByPort,
trainLoads,
generatedAt: new Date().toISOString(),
};
}
/**
* Rolling stock in depth: wagons and locomotives crossed with type and yard,
* plus how fast each train set turns around. Feeds the operations and control
* centre dashboards.
*/
async getFleetTab(
range: OverviewRangeQuery = '30d',
): Promise<OverviewFleetTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
wagonStatusBreakdown,
wagonStatusByType,
wagonStatusByYard,
locomotiveStatusBreakdown,
locomotivesByYard,
locomotivesByType,
turnaround,
] = await Promise.all([
this.overviewRepository.getWagonStatusBreakdown(),
this.overviewRepository.getWagonStatusByType(),
this.overviewRepository.getWagonStatusByYard(),
this.overviewRepository.getLocomotiveStatusBreakdown(),
this.overviewRepository.getLocomotivesByYard(),
this.overviewRepository.getLocomotivesByType(),
this.overviewRepository.getTrainTurnaround(days, 8),
]);
return {
wagonStatusBreakdown,
wagonStatusByType,
wagonStatusByYard,
locomotiveStatusBreakdown,
locomotivesByYard,
locomotivesByType,
avgTurnaroundHours: turnaround.avgHours,
turnaroundByTrain: turnaround.rows,
generatedAt: new Date().toISOString(),
};
}
/** Document and invoice queues — the GL desks' and marketing's work in progress. */
async getClearanceTab(): Promise<OverviewClearanceTabDto> {
const [
bookingDocumentsByStatus,
contractDocumentsByStatus,
invoicesByStatus,
invoicesByType,
handoversByMile,
] = await Promise.all([
this.overviewRepository.getBookingDocumentsByStatus(),
this.overviewRepository.getContractDocumentsByStatus(),
this.overviewRepository.getInvoicesByStatus(),
this.overviewRepository.getInvoicesByType(),
this.overviewRepository.getHandoversByMile(),
]);
return {
bookingDocumentsByStatus,
contractDocumentsByStatus,
invoicesByStatus,
invoicesByType,
handoversByMile,
generatedAt: new Date().toISOString(),
};
}
@@ -255,19 +351,26 @@ export class OverviewService {
): Promise<OverviewCustomersTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
await Promise.all([
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getCustomerGrowthTrend(days),
this.overviewRepository.getCustomersByType(),
this.overviewRepository.getTopCustomersByBookings(8, dirs),
]);
const [
kpis,
customerGrowthTrend,
customersByType,
topCustomersByBookings,
profilesByTypeStatus,
] = await Promise.all([
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getCustomerGrowthTrend(days),
this.overviewRepository.getCustomersByType(),
this.overviewRepository.getTopCustomersByBookings(8, dirs),
this.overviewRepository.getProfilesByTypeStatus(),
]);
return {
kpis,
customerGrowthTrend,
customersByType,
topCustomersByBookings,
profilesByTypeStatus,
generatedAt: new Date().toISOString(),
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
@Injectable()
export class ReportsRepository {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
run(key: keyof typeof REPORT_QUERIES, filters: ReportFilters): Promise<ReportResult> {
return REPORT_QUERIES[key](this.dataSource, filters);
}
}

View File

@@ -1,46 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { scopedDirections } from '../user-trade-access/trade-scope.util';
import { ReportQueryDto } from './dto/report-query.dto';
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
import { ReportsRepository } from './reports.repository';
import type { Freight } from '@edr/types';
const DAY_MS = 24 * 60 * 60 * 1000;
const list = (csv?: string): string[] | null => {
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
return items.length ? items : null;
};
@Injectable()
export class ReportsService {
constructor(private readonly repository: ReportsRepository) {}
run(
key: string,
dto: ReportQueryDto,
allowedDirections: Freight.ScheduleTradeDirection[] | null,
): Promise<ReportResult> {
if (!(key in REPORT_QUERIES)) {
throw new NotFoundException(`Unknown report: ${key}`);
}
// No default range: absent dates mean all time, so exports cover everything.
const to = dto.dateTo ? new Date(dto.dateTo) : null;
const from = dto.dateFrom ? new Date(dto.dateFrom) : null;
const filters: ReportFilters = {
dateFrom: from ? from.toISOString() : null,
// dateTo is inclusive in the API; queries treat the bound as exclusive.
dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null,
granularity: dto.granularity ?? 'day',
companyIds: list(dto.companyIds),
routeIds: list(dto.routeIds),
yardIds: list(dto.yardIds),
cargoTypeIds: list(dto.cargoTypeIds),
statuses: list(dto.statuses),
directions: scopedDirections(allowedDirections, dto.direction),
freightType: dto.freightType ?? null,
};
return this.repository.run(key, filters);
}
}

View File

@@ -75,6 +75,14 @@ export class CreateRateDto {
@IsUUID()
destinationYardId?: string;
@ApiPropertyOptional({
description:
'FK to shipping_line_companies.id — set to price this rate for one shipping line only. Omitted/null = the standard rate every customer pays. A line rate overrides the standard one for that line\'s bookings.',
})
@IsOptional()
@IsUUID()
shippingLineCompanyId?: string;
@ApiPropertyOptional({ enum: CURRENCIES })
@IsOptional()
@IsIn([...CURRENCIES])

View File

@@ -141,6 +141,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
@IsString()
@MaxLength(200)
trigger?: string;
@ApiPropertyOptional({
description: 'Filter to one shipping line\'s rates.',
})
@IsOptional()
@IsUUID()
shippingLineCompanyId?: string;
@ApiPropertyOptional({
description:
'true = only shipping-line rates (any line), false = only standard customer rates. Omitted = both. Powers the Shipping line tab.',
})
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
isShippingLineRate?: boolean;
}
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {

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