mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pric
|
||||
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service';
|
||||
import { LogoSettingsService } from '../modules/logo-settings/logo-settings.service';
|
||||
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
|
||||
|
||||
export interface ContractSignatureView {
|
||||
@@ -111,6 +112,8 @@ export interface ContractViewModel {
|
||||
hasCustomerSignature: boolean;
|
||||
hasStaffSignature: boolean;
|
||||
dynamicTemplate?: ContractDynamicTemplateView;
|
||||
/** Company logo for the cover-page header (LogoSettingsService); null renders the "EDR" mark. */
|
||||
logoImageUrl?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -121,6 +124,7 @@ export class ContractViewModelBuilder {
|
||||
private readonly pricingBuilder: ContractPricingScheduleBuilder,
|
||||
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) {}
|
||||
|
||||
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
|
||||
@@ -138,6 +142,7 @@ export class ContractViewModelBuilder {
|
||||
template.freight,
|
||||
);
|
||||
const signatures = await this.loadSignatures(bookingId);
|
||||
const logoImageUrl = await this.logoSettings.getLogoImageUrl();
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||
@@ -194,6 +199,7 @@ export class ContractViewModelBuilder {
|
||||
hasContractDocument: hasContractFile,
|
||||
hasCustomerSignature: hasCustomer,
|
||||
hasStaffSignature: hasStaff,
|
||||
logoImageUrl,
|
||||
};
|
||||
|
||||
return { booking, view };
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
letter-spacing: 0.08em;
|
||||
width: 72px;
|
||||
}
|
||||
.logo-mark img {
|
||||
display: block;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.kicker {
|
||||
color: #0e5b45;
|
||||
font-family: Arial, sans-serif;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
|
||||
<section class="cover page-section">
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Freight Transport Services</p>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<main class="contract">
|
||||
<section class="cover page-section">
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Freight Transport Contract</p>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
main { padding: 32px 40px; }
|
||||
.brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; }
|
||||
.logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; }
|
||||
.logo-mark img { display: block; max-height: 32px; max-width: 100px; object-fit: contain; }
|
||||
.kicker { margin: 0; font-weight: 700; }
|
||||
.muted { margin: 0; color: #666; }
|
||||
h1 { font-size: 20px; margin: 24px 0 4px; }
|
||||
@@ -32,7 +33,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Last-Mile Delivery Contract</p>
|
||||
|
||||
@@ -0,0 +1,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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Single-row table holding the one company logo image stamped onto every
|
||||
* generated document (see LogoSettingsService). Same single-row shape as
|
||||
* stamp_settings; the app never inserts more than one row.
|
||||
*/
|
||||
export class LogoSettings3500000000000 implements MigrationInterface {
|
||||
name = "LogoSettings3500000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.logo_settings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
logo_file_id uuid REFERENCES freight.files(id),
|
||||
updated_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* A train schedule can be dedicated to one shipping line.
|
||||
*
|
||||
* NULL = a normal train, visible and bookable to customers as before. Set =
|
||||
* the departure exists for that shipping line alone: it is excluded from every
|
||||
* customer-facing read (booking windows, day pools, portal home cards) and
|
||||
* surfaces only in the assigned line's portal (home page + booking detail).
|
||||
*/
|
||||
export class TrainScheduleShippingLine3510000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
REFERENCES freight.shipping_line_companies (id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_shipping_line_company_id
|
||||
ON freight.train_schedules (shipping_line_company_id)
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_train_schedules_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Maker–checker 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The single decision every EDR document makes about its header logo: draw
|
||||
* the one uploaded company logo when configured (LogoSettingsService), or
|
||||
* render nothing — the existing "Ethio-Djibouti Railway S.C." text brand next
|
||||
* to it already covers the no-logo case, so there is no text fallback here
|
||||
* (contrast seal-markup.util.ts, whose seal has no text of its own).
|
||||
*/
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* `<img>` markup for the header logo, or "" when unset. `logoImageUrl` is
|
||||
* expected to be a data URL from LogoSettingsService.getLogoImageUrl().
|
||||
* `className` defaults to "doc-logo" — each document supplies that class's
|
||||
* sizing in its own <style> block (see logoImageCss()).
|
||||
*/
|
||||
export function logoMarkup(
|
||||
logoImageUrl: string | null | undefined,
|
||||
className = "doc-logo",
|
||||
): string {
|
||||
if (!logoImageUrl) return "";
|
||||
return `<img class="${className}" src="${escapeHtml(logoImageUrl)}" alt="Company logo" />`;
|
||||
}
|
||||
|
||||
/** Default CSS for the header logo — append inside a document's <style> block. */
|
||||
export function logoImageCss(className = "doc-logo"): string {
|
||||
return `.${className} { display: block; max-height: 48px; max-width: 180px; margin-bottom: 6px; object-fit: contain; }`;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ const PDF_PRINT_STYLES = `
|
||||
export interface PdfRenderOptions {
|
||||
/** Label used in logs to identify the document kind. */
|
||||
label?: string;
|
||||
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
||||
landscape?: boolean;
|
||||
/**
|
||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||
@@ -59,6 +61,7 @@ export class PdfRenderService {
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
landscape: opts.landscape ?? false,
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface BookingListFilterOptions {
|
||||
assignedToSchedule?: 'true' | 'false';
|
||||
companyId?: string;
|
||||
companyProfileId?: string;
|
||||
contractId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
@@ -936,6 +937,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
companyProfileId: options.companyProfileId,
|
||||
});
|
||||
}
|
||||
if (options.contractId) {
|
||||
qb.andWhere('booking.contract_id = :contractId', {
|
||||
contractId: options.contractId,
|
||||
});
|
||||
}
|
||||
if (options.contractType) {
|
||||
qb.andWhere('booking.contract_type = :contractType', {
|
||||
contractType: options.contractType,
|
||||
|
||||
@@ -1805,6 +1805,7 @@ export class BookingsService {
|
||||
// ANDs both, so cross-company access is impossible.
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
||||
contractId: filter.contractId,
|
||||
tradeDirections,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FilesService } from '../files/files.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
|
||||
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
||||
@@ -41,6 +42,7 @@ export class LastMileContractService {
|
||||
private readonly pdfService: ContractPdfService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) {}
|
||||
|
||||
async getContractView(id: string, viewerUserId?: string | null) {
|
||||
@@ -194,6 +196,7 @@ export class LastMileContractService {
|
||||
return {
|
||||
companyName: booking.company?.name ?? 'Customer',
|
||||
bookingReference: booking.reference ?? request.bookingId,
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
containerCount: containers.length || null,
|
||||
containerList: containers.join(', '),
|
||||
cargoDescription,
|
||||
|
||||
@@ -48,6 +48,19 @@ export class LastMileRequestsController {
|
||||
return this.requestsService.freeTruckCount().then((count) => ({ count }));
|
||||
}
|
||||
|
||||
// Customer-facing like :id — booking detail (portal + backoffice) lists the
|
||||
// booking's requests to link the stored LM contract. Ownership-checked in
|
||||
// the service for portal callers.
|
||||
@Get('by-booking/:bookingId')
|
||||
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({ summary: "A booking's last-mile requests, newest first — LM contract reference" })
|
||||
findForBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.requestsService.findForBooking(bookingId, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Get(':id/price-estimate')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -221,6 +221,28 @@ export class LastMileRequestsService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every request on a booking, newest first — the booking-detail pages
|
||||
* (portal + backoffice) use this to surface the LM contract later. Portal
|
||||
* callers pass their userId and are ownership-checked against the booking's
|
||||
* company, mirroring findById.
|
||||
*/
|
||||
async findForBooking(bookingId: string, userId?: string | null): Promise<LastMileRequest[]> {
|
||||
if (userId) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
if (companyId) {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (booking?.companyId && booking.companyId !== companyId) {
|
||||
throw new BadRequestException('This booking does not belong to your company');
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.requestsRepository.findAll({
|
||||
where: { bookingId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
|
||||
* delivery point, straight-line) × the LIVE last-mile rate rules against the
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateLogoSettingDto {
|
||||
@ApiProperty({ description: "Logo image as a base64 data URL (PNG/JPG)." })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
logoImageBase64!: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { FileRecord } from "../../files/entities/file.entity";
|
||||
|
||||
/**
|
||||
* Single-row table holding the one company logo image stamped onto every
|
||||
* generated document (invoices/receipts, contracts, warehouse papers,
|
||||
* train-scheduling manifests, payment receipts). Same single-row shape as
|
||||
* stamp_settings — `get()` lazily creates the row, and there is never more
|
||||
* than one.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "logo_settings" })
|
||||
export class LogoSetting extends BaseEntity {
|
||||
@Column({ name: "logo_file_id", type: "uuid", nullable: true })
|
||||
logoFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: "logo_file_id" })
|
||||
logoFile?: FileRecord | null;
|
||||
|
||||
/** IAM user id of the last operator to set/clear the logo. */
|
||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||
updatedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Delete, Get, Put } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { UpdateLogoSettingDto } from "./dto/update-logo-setting.dto";
|
||||
import { LogoSettingsService } from "./logo-settings.service";
|
||||
|
||||
@ApiTags("logo-settings")
|
||||
@ApiBearerAuth()
|
||||
@Controller("logo-settings")
|
||||
export class LogoSettingsController {
|
||||
constructor(private readonly service: LogoSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.logo.view, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Current company logo used on every generated document" })
|
||||
get() {
|
||||
return this.service.getView();
|
||||
}
|
||||
|
||||
@Put()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Replace the company logo" })
|
||||
update(@Body() dto: UpdateLogoSettingDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.setLogo(dto.logoImageBase64, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({
|
||||
summary: "Clear the company logo (documents fall back to their text mark)",
|
||||
})
|
||||
clear(@CurrentUser() user: TCurrentUser) {
|
||||
return this.service.clearLogo(user?.id ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { LogoSetting } from "./entities/logo-setting.entity";
|
||||
import { LogoSettingsController } from "./logo-settings.controller";
|
||||
import { LogoSettingsRepository } from "./logo-settings.repository";
|
||||
import { LogoSettingsService } from "./logo-settings.service";
|
||||
|
||||
/**
|
||||
* Global so every document-generating module (billing, contracts,
|
||||
* warehouses, train-scheduling, payment) can inject {@link LogoSettingsService}
|
||||
* without pulling in a circular dependency — same reasoning as
|
||||
* StampSettingsModule.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LogoSetting]), FilesModule, MinioModule],
|
||||
controllers: [LogoSettingsController],
|
||||
providers: [LogoSettingsRepository, LogoSettingsService],
|
||||
exports: [LogoSettingsService],
|
||||
})
|
||||
export class LogoSettingsModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
|
||||
import { LogoSetting } from "./entities/logo-setting.entity";
|
||||
|
||||
@Injectable()
|
||||
export class LogoSettingsRepository extends BaseRepository<LogoSetting> {
|
||||
constructor(
|
||||
@InjectRepository(LogoSetting)
|
||||
repo: Repository<LogoSetting>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The single settings row, with its logo file joined, or null before first upload. */
|
||||
findSingleton(): Promise<LogoSetting | null> {
|
||||
return this.repository.findOne({ where: {}, relations: ["logoFile"] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Readable } from "stream";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { LogoSettingsRepository } from "./logo-settings.repository";
|
||||
import { LogoSetting } from "./entities/logo-setting.entity";
|
||||
|
||||
export interface LogoSettingView {
|
||||
logoImageUrl: string | null;
|
||||
updatedById: string | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the single `logo_settings` row: the one company logo image used on
|
||||
* every generated document. Same single-row shape as StampSettingsService,
|
||||
* the value is an uploaded image (via FilesService) rather than a scalar.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LogoSettingsService {
|
||||
private readonly logger = new Logger(LogoSettingsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: LogoSettingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** The settings row, created empty on first access. */
|
||||
async get(): Promise<LogoSetting> {
|
||||
const existing = await this.repository.findSingleton();
|
||||
if (existing) return existing;
|
||||
return this.repository.create({ logoFileId: null, updatedById: null });
|
||||
}
|
||||
|
||||
/** Current logo, with the image inlined as a data URL (or null if unset). */
|
||||
async getView(): Promise<LogoSettingView> {
|
||||
const setting = await this.get();
|
||||
return {
|
||||
logoImageUrl: await this.inlineImageUrl(setting.logoFile?.url),
|
||||
updatedById: setting.updatedById ?? null,
|
||||
updatedAt: setting.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo image for embedding into generated documents, ALWAYS as a
|
||||
* `data:` URL or null. Never throws — document generation must succeed even
|
||||
* if the logo lookup fails; callers render their existing text/mark
|
||||
* fallback on null (see logo-markup.util.ts).
|
||||
*/
|
||||
async getLogoImageUrl(): Promise<string | null> {
|
||||
try {
|
||||
const setting = await this.get();
|
||||
const inlined = await this.inlineImageUrl(setting.logoFile?.url);
|
||||
if (inlined && !inlined.startsWith("data:")) {
|
||||
this.logger.warn(
|
||||
`Company logo could not be inlined for document rendering (falling back to the text mark): ${inlined}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return inlined;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not load company logo for PDF rendering: ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the logo image, storing it in MinIO via FilesService. */
|
||||
async setLogo(
|
||||
logoImageBase64: string,
|
||||
updatedById?: string | null,
|
||||
): Promise<LogoSettingView> {
|
||||
const current = await this.get();
|
||||
const previousFileId = current.logoFileId ?? null;
|
||||
|
||||
const fileRecord = await this.filesService.upload({
|
||||
resourceId: current.id,
|
||||
resource: "logo_settings",
|
||||
code: "logo",
|
||||
file: this.toUploadFile(logoImageBase64),
|
||||
uploadedByUserId: updatedById ?? null,
|
||||
});
|
||||
|
||||
await this.repository.update(current.id, {
|
||||
logoFileId: fileRecord.id,
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
|
||||
if (previousFileId && previousFileId !== fileRecord.id) {
|
||||
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
||||
}
|
||||
|
||||
this.logger.log(`Company logo updated by ${updatedById ?? "unknown user"}`);
|
||||
return this.getView();
|
||||
}
|
||||
|
||||
/** Clear the logo (documents fall back to their text/mark). */
|
||||
async clearLogo(updatedById?: string | null): Promise<LogoSettingView> {
|
||||
const current = await this.get();
|
||||
const previousFileId = current.logoFileId ?? null;
|
||||
|
||||
await this.repository.update(current.id, {
|
||||
logoFileId: null,
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
|
||||
if (previousFileId) {
|
||||
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
||||
}
|
||||
|
||||
return this.getView();
|
||||
}
|
||||
|
||||
private toUploadFile(base64: string): Express.Multer.File {
|
||||
const raw = base64.includes(",") ? base64.split(",")[1]! : base64;
|
||||
const buffer = Buffer.from(raw, "base64");
|
||||
return {
|
||||
fieldname: "logo",
|
||||
originalname: "company-logo.png",
|
||||
encoding: "7bit",
|
||||
mimetype: "image/png",
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: "",
|
||||
filename: "",
|
||||
path: "",
|
||||
};
|
||||
}
|
||||
|
||||
private async inlineImageUrl(url?: string | null): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
if (url.startsWith("data:")) return url;
|
||||
try {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
return `data:image/png;base64,${buffer.toString("base64")}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 = 00–03 … 7 = 21–24' })
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 = 00–03 … 7 = 21–24) — 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
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ describe("PaymentService.confirmOtp", () => {
|
||||
repo as never,
|
||||
client as never,
|
||||
billing as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo, billing };
|
||||
};
|
||||
@@ -197,6 +198,7 @@ describe("PaymentService.markIntentSucceeded", () => {
|
||||
repo as never,
|
||||
{} as never,
|
||||
billing as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo, billing };
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { LogoSettingsService } from "../logo-settings/logo-settings.service";
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
@@ -104,6 +105,7 @@ export class PaymentService {
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billing: BillingService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) { }
|
||||
|
||||
async getAll(filters: {
|
||||
@@ -657,6 +659,7 @@ export class PaymentService {
|
||||
total: payment.amount.toString(),
|
||||
currency: payment.currency,
|
||||
reason: payment.reason,
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header .doc-logo {
|
||||
display: block;
|
||||
margin: 0 auto 10px;
|
||||
max-height: 48px;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -126,6 +134,7 @@
|
||||
<body>
|
||||
<div class="receipt-box">
|
||||
<div class="header">
|
||||
{{#if logoImageUrl}}<img class="doc-logo" src="{{logoImageUrl}}" alt="Company logo" />{{/if}}
|
||||
<h1>{{vendorName}}</h1>
|
||||
<p>{{vendorAddress}}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
// "As of" — invoices due after this instant aren't overdue yet. Defaults
|
||||
// to now() in SQL when the filter is unset (see the COALESCE below).
|
||||
const asOf = (params.asOf as string | null) ?? null;
|
||||
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Invoice, 'i')
|
||||
.innerJoin(Company, 'c', 'c.id = i.company_id')
|
||||
.where('i.deleted_at IS NULL')
|
||||
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
|
||||
.andWhere('i.balance_amount > 0')
|
||||
.setParameter('asOf', asOf);
|
||||
|
||||
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
||||
// Rows not pointing at a booking (e.g. warehouse fee invoices) stay visible.
|
||||
return applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||
}
|
||||
|
||||
export const agingReceivablesReport: ReportDefinition = {
|
||||
key: 'aging-receivables',
|
||||
title: 'Aging Receivables',
|
||||
description: 'Outstanding customer balances bucketed by days overdue',
|
||||
group: 'Finance',
|
||||
filters: [{ key: 'asOf', label: 'As of', type: 'date' }],
|
||||
columns: [
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{ key: 'invoices', label: 'Invoices', type: 'number' },
|
||||
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
|
||||
{ key: 'current', label: 'Current', type: 'money' },
|
||||
{ key: 'overdue0to30', label: '0-30d', type: 'money' },
|
||||
{ key: 'overdue31to60', label: '31-60d', type: 'money' },
|
||||
{ key: 'overdue61to90', label: '61-90d', type: 'money' },
|
||||
{ key: 'overdue90plus', label: '90d+', type: 'money' },
|
||||
],
|
||||
defaultSort: { key: 'outstanding', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('c.name', 'customer')
|
||||
.addSelect('COUNT(*)::int', 'invoices')
|
||||
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE(:asOf::timestamptz, now())), 0))::float8`,
|
||||
'current',
|
||||
)
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now())
|
||||
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '30 days'), 0))::float8`,
|
||||
'overdue0to30',
|
||||
)
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '30 days'
|
||||
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '60 days'), 0))::float8`,
|
||||
'overdue31to60',
|
||||
)
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '60 days'
|
||||
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
|
||||
'overdue61to90',
|
||||
)
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
|
||||
'overdue90plus',
|
||||
)
|
||||
.groupBy('c.name');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
|
||||
.addSelect('COUNT(DISTINCT c.id)::int', 'customers')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' },
|
||||
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BookingStatus } from '@edr/types';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// One resolver behind "Booking per status, per port/train/date/cargo/contract
|
||||
// type" — the same breakdown Operation, Marketing, Global Logistics and the
|
||||
// Operation Report each ask for verbatim. Embed once, reuse everywhere.
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
|
||||
const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({
|
||||
value: v,
|
||||
label: v.replace(/_/g, ' '),
|
||||
}));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Booking, 'b')
|
||||
.leftJoin(Yard, 'o', 'o.id = b.origin_yard_id')
|
||||
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id')
|
||||
.where('b.deleted_at IS NULL');
|
||||
|
||||
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
|
||||
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('b.status IN (:...statuses)', { statuses });
|
||||
if (directions !== null) {
|
||||
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const bookingStatusBreakdownReport: ReportDefinition = {
|
||||
key: 'booking-status-breakdown',
|
||||
title: 'Bookings by Status',
|
||||
description: 'Booking counts by status, direction, origin station, cargo and contract type',
|
||||
group: 'Commercial',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{
|
||||
key: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'freightType',
|
||||
label: 'Freight type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
{ value: 'BULK', label: 'Bulk' },
|
||||
],
|
||||
},
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
|
||||
{ key: 'direction', label: 'Direction', type: 'string', sortable: true, sortExpr: 'b.trade_direction' },
|
||||
{ key: 'originStation', label: 'Origin', type: 'string', sortable: true },
|
||||
{ key: 'cargoType', label: 'Cargo type', type: 'string', sortable: true },
|
||||
{ key: 'contractKind', label: 'Contract type', type: 'string', sortable: true },
|
||||
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'bookings', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'status', y: ['bookings'] },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('b.status', 'status')
|
||||
.addSelect('b.trade_direction', 'direction')
|
||||
.addSelect("COALESCE(o.label, 'Unknown')", 'originStation')
|
||||
.addSelect("COALESCE(cty.cargo_type_name, 'Other')", 'cargoType')
|
||||
.addSelect("COALESCE(b.contract_kind, 'SPOT')", 'contractKind')
|
||||
.addSelect('COUNT(*)::int', 'bookings')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount')
|
||||
.groupBy('b.status')
|
||||
.addGroupBy('b.trade_direction')
|
||||
.addGroupBy('o.label')
|
||||
.addGroupBy('cty.cargo_type_name')
|
||||
.addGroupBy('b.contract_kind');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'bookings')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
|
||||
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
|
||||
{ label: 'Amount', value: Number(row?.amount ?? 0), unit: 'ETB' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
|
||||
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order
|
||||
// (same guard as the retired report-queries.ts).
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
// adjusted_total_amount silently overrides total_amount when set.
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
|
||||
// them double-counts every child booking.
|
||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
function applyFilters(
|
||||
ctx: ReportContext,
|
||||
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||
): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
|
||||
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
|
||||
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) {
|
||||
qb.andWhere('b.status IN (:...statuses)', { statuses });
|
||||
} else {
|
||||
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
|
||||
}
|
||||
if (params.search) {
|
||||
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', {
|
||||
search: `%${params.search}%`,
|
||||
});
|
||||
}
|
||||
if (directions !== null) {
|
||||
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
|
||||
directions,
|
||||
});
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const bookingsListReport: ReportDefinition = {
|
||||
key: 'bookings-list',
|
||||
title: 'Bookings',
|
||||
description: 'Every booking with customer, route, cargo and revenue',
|
||||
group: 'Commercial',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{
|
||||
key: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'freightType',
|
||||
label: 'Freight type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
{ value: 'BULK', label: 'Bulk' },
|
||||
],
|
||||
},
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' },
|
||||
{ key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' },
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
|
||||
{ key: 'direction', label: 'Direction', type: 'string' },
|
||||
{ key: 'origin', label: 'Origin', type: 'string' },
|
||||
{ key: 'destination', label: 'Destination', type: 'string' },
|
||||
{ key: 'cargo', label: 'Cargo', type: 'string' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'created', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.select('b.reference', 'reference')
|
||||
.addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created')
|
||||
.addSelect('c.name', 'customer')
|
||||
.addSelect('b.status', 'status')
|
||||
.addSelect('b.trade_direction', 'direction')
|
||||
.addSelect('o.label', 'origin')
|
||||
.addSelect('d.label', 'destination')
|
||||
.addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo')
|
||||
.addSelect(`ROUND(${TONS})::float8`, 'tons')
|
||||
.addSelect(`ROUND(${REVENUE})::float8`, 'amount')
|
||||
.from(Booking, 'b')
|
||||
.innerJoin(Company, 'c', 'c.id = b.company_id')
|
||||
.innerJoin(Yard, 'o', 'o.id = b.origin_yard_id')
|
||||
.innerJoin(Yard, 'd', 'd.id = b.destination_yard_id')
|
||||
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id');
|
||||
return applyFilters(ctx, qb);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const qb = applyFilters(
|
||||
ctx,
|
||||
ctx.ds
|
||||
.createQueryBuilder()
|
||||
.select('COUNT(*)::int', 'bookings')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||
.from(Booking, 'b')
|
||||
.innerJoin(Company, 'c', 'c.id = b.company_id'),
|
||||
);
|
||||
const row = await qb.getRawOne();
|
||||
return [
|
||||
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
|
||||
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
|
||||
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Booking, 'b')
|
||||
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`)
|
||||
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
|
||||
|
||||
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (directions !== null) {
|
||||
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const cargoSummaryReport: ReportDefinition = {
|
||||
key: 'cargo-summary',
|
||||
title: 'Cargo Summary',
|
||||
description: 'Cargo tonnage by direction and cargo type',
|
||||
group: 'Operations',
|
||||
filters: [{ key: 'date', label: 'Created', type: 'daterange' }],
|
||||
columns: [
|
||||
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
|
||||
{ key: 'freightType', label: 'Cargo type', type: 'string', sortable: true },
|
||||
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'tons', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('b.trade_direction', 'direction')
|
||||
.addSelect('b.freight_type', 'freightType')
|
||||
.addSelect('COUNT(*)::int', 'bookings')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
|
||||
.groupBy('b.trade_direction')
|
||||
.addGroupBy('b.freight_type');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
|
||||
.addSelect('COUNT(*)::int', 'bookings')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
|
||||
{ label: 'Total tonnage', value: Number(row?.tons ?? 0), unit: 't' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Contract, 'ct')
|
||||
.leftJoin(Company, 'c', 'c.id = ct.company_id')
|
||||
.where('ct.deleted_at IS NULL');
|
||||
|
||||
if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo });
|
||||
if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind });
|
||||
if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses });
|
||||
if (directions !== null) {
|
||||
qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const contractLifecycleReport: ReportDefinition = {
|
||||
key: 'contract-lifecycle',
|
||||
title: 'Contracts',
|
||||
description: 'Signed, active and cancelled contracts',
|
||||
group: 'Commercial',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Valid from', type: 'daterange' },
|
||||
{ key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) },
|
||||
{
|
||||
key: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
],
|
||||
},
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' },
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{ key: 'kind', label: 'Kind', type: 'string' },
|
||||
{ key: 'direction', label: 'Direction', type: 'string' },
|
||||
{ key: 'freightType', label: 'Freight type', type: 'string' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' },
|
||||
{ key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' },
|
||||
{ key: 'validUntil', label: 'Valid until', type: 'date' },
|
||||
{ key: 'signedAt', label: 'Signed', type: 'date' },
|
||||
],
|
||||
defaultSort: { key: 'validFrom', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('ct.reference', 'reference')
|
||||
.addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer')
|
||||
.addSelect('ct.contract_kind', 'kind')
|
||||
.addSelect('ct.trade_direction', 'direction')
|
||||
.addSelect('ct.freight_type', 'freightType')
|
||||
.addSelect('ct.status', 'status')
|
||||
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
|
||||
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
|
||||
.addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'total')
|
||||
.addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed')
|
||||
.addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Contracts', value: Number(row?.total ?? 0) },
|
||||
{ label: 'Signed', value: Number(row?.signed ?? 0) },
|
||||
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { Contract } from '../../contracts/entities/contract.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Contract, 'ct')
|
||||
.leftJoin(Company, 'c', 'c.id = ct.company_id')
|
||||
.leftJoin(
|
||||
(sub) =>
|
||||
sub
|
||||
.select('s.contract_id', 'contract_id')
|
||||
.addSelect('COALESCE(SUM(s.quantity_cap), 0)', 'committed')
|
||||
.from('freight.contract_cargo_scope', 's')
|
||||
.where('s.deleted_at IS NULL')
|
||||
.groupBy('s.contract_id'),
|
||||
'cap',
|
||||
'cap.contract_id = ct.id',
|
||||
)
|
||||
.leftJoin(
|
||||
(sub) =>
|
||||
sub
|
||||
.select('b.contract_id', 'contract_id')
|
||||
.addSelect(`COALESCE(SUM(${TONS}), 0)`, 'tons')
|
||||
.addSelect('COUNT(*)::int', 'cnt')
|
||||
.from('freight.bookings', 'b')
|
||||
.where('b.deleted_at IS NULL')
|
||||
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES })
|
||||
.groupBy('b.contract_id'),
|
||||
'booked',
|
||||
'booked.contract_id = ct.id',
|
||||
)
|
||||
.where('ct.deleted_at IS NULL')
|
||||
.andWhere("ct.status <> 'DRAFT'");
|
||||
|
||||
if (params.dateFrom) {
|
||||
qb.andWhere(
|
||||
"(ct.contract_valid_until IS NULL OR ct.contract_valid_until >= :dateFrom::timestamptz)",
|
||||
{ dateFrom: params.dateFrom },
|
||||
);
|
||||
}
|
||||
if (params.dateTo) {
|
||||
qb.andWhere('ct.contract_valid_from < :dateTo::timestamptz', { dateTo: params.dateTo });
|
||||
}
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses });
|
||||
if (params.contractId) {
|
||||
qb.andWhere('ct.id = :contractId', { contractId: params.contractId });
|
||||
}
|
||||
if (directions !== null) {
|
||||
qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', {
|
||||
directions,
|
||||
});
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const contractUtilizationReport: ReportDefinition = {
|
||||
key: 'contract-utilization',
|
||||
title: 'Contract Utilization',
|
||||
description: 'Committed volume vs. booked tonnage per contract',
|
||||
group: 'Commercial',
|
||||
idKey: { key: 'contractId', label: 'Contract' },
|
||||
filters: [
|
||||
{ key: 'date', label: 'Active during', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' },
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{ key: 'status', label: 'Status', type: 'string' },
|
||||
{ key: 'kind', label: 'Kind', type: 'string' },
|
||||
{ key: 'validFrom', label: 'Valid from', type: 'date' },
|
||||
{ key: 'validUntil', label: 'Valid until', type: 'date' },
|
||||
{ key: 'committed', label: 'Committed', type: 'tons' },
|
||||
{ key: 'bookedTons', label: 'Booked', type: 'tons', sortable: true },
|
||||
{ key: 'bookings', label: 'Bookings', type: 'number' },
|
||||
{ key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'utilizationPct', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('ct.reference', 'reference')
|
||||
.addSelect('c.name', 'customer')
|
||||
.addSelect('ct.status', 'status')
|
||||
.addSelect('ct.contract_kind', 'kind')
|
||||
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
|
||||
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
|
||||
.addSelect('COALESCE(cap.committed, 0)::float8', 'committed')
|
||||
.addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons')
|
||||
.addSelect('COALESCE(booked.cnt, 0)', 'bookings')
|
||||
.addSelect(
|
||||
`CASE WHEN COALESCE(cap.committed, 0) > 0
|
||||
THEN ROUND(COALESCE(booked.tons, 0) / cap.committed * 100)::float8 END`,
|
||||
'utilizationPct',
|
||||
);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'contracts')
|
||||
.addSelect('COALESCE(SUM(booked.tons), 0)::float8', 'bookedTons')
|
||||
.addSelect(
|
||||
`AVG(CASE WHEN COALESCE(cap.committed, 0) > 0
|
||||
THEN booked.tons / cap.committed * 100 END)::float8`,
|
||||
'avgUtilization',
|
||||
)
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Contracts', value: Number(row?.contracts ?? 0) },
|
||||
{ label: 'Booked tonnage', value: Number(row?.bookedTons ?? 0), unit: 't' },
|
||||
{ label: 'Avg utilization', value: Math.round(Number(row?.avgUtilization ?? 0)), unit: '%' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are
|
||||
// CompanyProfile fields, not Company's — a company can hold several profiles
|
||||
// (e.g. importer AND exporter), each independently approved/suspended.
|
||||
const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
|
||||
const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v }));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(CompanyProfile, 'cp')
|
||||
.innerJoin(Company, 'c', 'c.id = cp.company_id')
|
||||
.where('cp.deleted_at IS NULL');
|
||||
|
||||
if (params.type) qb.andWhere('cp.type = :type', { type: params.type });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const customerStatusReport: ReportDefinition = {
|
||||
key: 'customer-status',
|
||||
title: 'Customer Profiles',
|
||||
description: 'Company profiles by role type and approval status',
|
||||
group: 'Commercial',
|
||||
filters: [
|
||||
{ key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{ key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' },
|
||||
{ key: 'reference', label: 'Reference', type: 'string' },
|
||||
{ key: 'note', label: 'Note', type: 'string' },
|
||||
{ key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' },
|
||||
],
|
||||
defaultSort: { key: 'reviewedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('c.name', 'company')
|
||||
.addSelect('cp.type', 'type')
|
||||
.addSelect('cp.status', 'status')
|
||||
.addSelect("COALESCE(cp.reference, '')", 'reference')
|
||||
.addSelect("COALESCE(cp.review_note, '')", 'note')
|
||||
.addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'total')
|
||||
.addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active')
|
||||
.addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended')
|
||||
.setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended })
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Profiles', value: Number(row?.total ?? 0) },
|
||||
{ label: 'Active', value: Number(row?.active ?? 0) },
|
||||
{ label: 'Suspended', value: Number(row?.suspended ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
MILESTONE_OWNER_REGIONS,
|
||||
MILESTONE_STATUSES,
|
||||
} from '../../contracts/entities/clearance-milestone.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds.createQueryBuilder().from(ClearanceMilestone, 'cm').where('cm.deleted_at IS NULL');
|
||||
|
||||
if (params.dateFrom) qb.andWhere('cm.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('cm.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (params.ownerRegion) qb.andWhere('cm.owner_region = :ownerRegion', { ownerRegion: params.ownerRegion });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('cm.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const customsDocumentsReport: ReportDefinition = {
|
||||
key: 'customs-documents',
|
||||
title: 'Customs Clearance Milestones',
|
||||
description: 'Clearance milestone volume by label, owner and status',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{
|
||||
key: 'ownerRegion',
|
||||
label: 'Owner',
|
||||
type: 'select',
|
||||
options: MILESTONE_OWNER_REGIONS.map((v) => ({ value: v, label: v })),
|
||||
},
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: MILESTONE_STATUSES.map((v) => ({ value: v, label: v })) },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'milestone', label: 'Milestone', type: 'string', sortable: true },
|
||||
{ key: 'ownerRegion', label: 'Owner', type: 'string', sortable: true },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true },
|
||||
{ key: 'count', label: 'Count', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'count', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('cm.milestone_label', 'milestone')
|
||||
.addSelect("COALESCE(cm.owner_region, 'Unassigned')", 'ownerRegion')
|
||||
.addSelect('cm.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.groupBy('cm.milestone_label')
|
||||
.addGroupBy('cm.owner_region')
|
||||
.addGroupBy('cm.status');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'total')
|
||||
.addSelect("COUNT(*) FILTER (WHERE cm.status = 'COMPLETED')::int", 'completed')
|
||||
.addSelect("COUNT(*) FILTER (WHERE cm.status = 'PENDING')::int", 'pending')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Milestones', value: Number(row?.total ?? 0) },
|
||||
{ label: 'Completed', value: Number(row?.completed ?? 0) },
|
||||
{ label: 'Pending', value: Number(row?.pending ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// FirstMile and LastMile are separate tables with an identical shape (status,
|
||||
// booking, optional vehicle). One resolver, unioned, with a `leg` column —
|
||||
// beats shipping two near-duplicate reports for the two halves of the trip.
|
||||
const LEG_UNION = `(
|
||||
SELECT 'FIRST' AS leg, fm.id AS id, fm.booking_id AS booking_id, fm.status AS status,
|
||||
fm.vehicle_id AS vehicle_id, fm.created_at AS created_at
|
||||
FROM freight.first_mile fm WHERE fm.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'LAST' AS leg, lm.id AS id, lm.booking_id AS booking_id, lm.status AS status,
|
||||
lm.vehicle_id AS vehicle_id, lm.created_at AS created_at
|
||||
FROM freight.last_mile lm WHERE lm.deleted_at IS NULL
|
||||
)`;
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'PAYMENT_PENDING', label: 'Payment pending' },
|
||||
{ value: 'READY_TO_TRANSIT', label: 'Ready to transit' },
|
||||
{ value: 'IN_TRANSIT', label: 'In transit' },
|
||||
{ value: 'RECEIVED_TO_PORT', label: 'Received to port' },
|
||||
{ value: 'DELIVERED', label: 'Delivered' },
|
||||
];
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(LEG_UNION, 'fl')
|
||||
.innerJoin(Booking, 'b', 'b.id = fl.booking_id')
|
||||
.leftJoin(Company, 'c', 'c.id = b.company_id')
|
||||
.leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id')
|
||||
.where('1 = 1');
|
||||
|
||||
if (params.leg) qb.andWhere('fl.leg = :leg', { leg: params.leg });
|
||||
if (params.dateFrom) qb.andWhere('fl.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const firstLastMileBookingsReport: ReportDefinition = {
|
||||
key: 'first-last-mile-bookings',
|
||||
title: 'First/Last Mile Trucking',
|
||||
description: 'First- and last-mile bookings by status and truck assignment',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{ key: 'leg', label: 'Leg', type: 'select', options: [{ value: 'FIRST', label: 'First mile' }, { value: 'LAST', label: 'Last mile' }] },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'leg', label: 'Leg', type: 'string', sortable: true },
|
||||
{ key: 'booking', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'fl.status' },
|
||||
{ key: 'truck', label: 'Truck', type: 'string' },
|
||||
{ key: 'assigned', label: 'Assigned', type: 'string', sortable: true },
|
||||
{ key: 'createdAt', label: 'Created', type: 'date', sortable: true, sortExpr: 'fl.created_at' },
|
||||
],
|
||||
defaultSort: { key: 'createdAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('fl.leg', 'leg')
|
||||
.addSelect('b.reference', 'booking')
|
||||
.addSelect("COALESCE(c.name, 'Unknown')", 'customer')
|
||||
.addSelect('fl.status', 'status')
|
||||
.addSelect("COALESCE(v.plate_number, '—')", 'truck')
|
||||
.addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned')
|
||||
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'total')
|
||||
.addSelect('COUNT(*) FILTER (WHERE fl.vehicle_id IS NOT NULL)::int', 'assigned')
|
||||
.getRawOne();
|
||||
const total = Number(row?.total ?? 0);
|
||||
const assigned = Number(row?.assigned ?? 0);
|
||||
return [
|
||||
{ label: 'Trips', value: total },
|
||||
{ label: 'Assigned', value: assigned },
|
||||
{ label: 'Unassigned', value: total - assigned },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net
|
||||
// count unchanged) is excluded — it's neither an allocation nor a cancellation.
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(ScheduleWagonAdjustmentLog, 'l')
|
||||
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id')
|
||||
.where('l.deleted_at IS NULL')
|
||||
.andWhere("l.action IN ('ADD', 'REMOVE')");
|
||||
|
||||
if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const globalLogisticsWagonsReport: ReportDefinition = {
|
||||
key: 'global-logistics-wagons',
|
||||
title: 'Wagon Allocations by Day',
|
||||
description: 'Wagons allocated vs. cancelled per day, by direction',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Date', type: 'daterange' },
|
||||
{
|
||||
key: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
],
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'date', label: 'Date', type: 'date', sortable: true, sortExpr: `date_trunc('day', l.occurred_at)` },
|
||||
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
|
||||
{ key: 'allocated', label: 'Allocated', type: 'number', sortable: true },
|
||||
{ key: 'cancelled', label: 'Cancelled', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'date', dir: 'DESC' },
|
||||
chart: { type: 'line', x: 'date', y: ['allocated', 'cancelled'] },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select(`to_char(date_trunc('day', l.occurred_at), 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect("COALESCE(ts.direction, 'Unknown')", 'direction')
|
||||
.addSelect("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated')
|
||||
.addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled')
|
||||
.groupBy(`date_trunc('day', l.occurred_at)`)
|
||||
.addGroupBy('ts.direction');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated')
|
||||
.addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Allocated', value: Number(row?.allocated ?? 0) },
|
||||
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Freight } from '@edr/types';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Invoice, 'i')
|
||||
.innerJoin(Company, 'c', 'c.id = i.company_id')
|
||||
.leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id')
|
||||
.where('i.deleted_at IS NULL');
|
||||
|
||||
if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const invoicesByStatusReport: ReportDefinition = {
|
||||
key: 'invoices-by-status',
|
||||
title: 'Invoices',
|
||||
description: 'Every invoice with customer, profile type and settlement status',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Issued', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{ key: 'profileType', label: 'Profile', type: 'string' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' },
|
||||
{ key: 'totalAmount', label: 'Total', type: 'money', sortable: true },
|
||||
{ key: 'paidAmount', label: 'Paid', type: 'money' },
|
||||
{ key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true },
|
||||
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' },
|
||||
{ key: 'dueAt', label: 'Due', type: 'date' },
|
||||
],
|
||||
defaultSort: { key: 'issuedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('i.invoice_number', 'invoiceNumber')
|
||||
.addSelect('c.name', 'customer')
|
||||
.addSelect("COALESCE(cp.type, 'Unknown')", 'profileType')
|
||||
.addSelect('i.status', 'status')
|
||||
.addSelect('ROUND(i.total_amount)::float8', 'totalAmount')
|
||||
.addSelect('ROUND(i.paid_amount)::float8', 'paidAmount')
|
||||
.addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount')
|
||||
.addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt')
|
||||
.addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'invoices')
|
||||
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total')
|
||||
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
{ label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' },
|
||||
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Freight } from '@edr/types';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL');
|
||||
|
||||
if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const invoicingPipelineReport: ReportDefinition = {
|
||||
key: 'invoicing-pipeline',
|
||||
title: 'Invoicing Pipeline',
|
||||
description: 'Invoice volume and value by type and status',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'type', label: 'Type', type: 'string', sortable: true },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true },
|
||||
{ key: 'invoices', label: 'Invoices', type: 'number', sortable: true },
|
||||
{ key: 'totalAmount', label: 'Total', type: 'money', sortable: true },
|
||||
{ key: 'balance', label: 'Outstanding', type: 'money', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'invoices', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('i.type', 'type')
|
||||
.addSelect('i.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'invoices')
|
||||
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount')
|
||||
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
|
||||
.groupBy('i.type')
|
||||
.addGroupBy('i.status');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'invoices')
|
||||
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount')
|
||||
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
{ label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' },
|
||||
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// train_set_wagons.assigned_weight_tons is the planned load per slot, already
|
||||
// maintained by the wagon-allocation flow — no need to re-derive it from
|
||||
// bulk/container line items.
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(TrainSetWagon, 'tsw')
|
||||
.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id')
|
||||
.leftJoin(WagonType, 'wt', 'wt.id = tsw.wagon_type_id')
|
||||
.where('tsw.deleted_at IS NULL AND ts.deleted_at IS NULL');
|
||||
|
||||
if (params.trainNumber) {
|
||||
qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` });
|
||||
}
|
||||
if (params.dateFrom) {
|
||||
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
}
|
||||
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const loadedCapacityReport: ReportDefinition = {
|
||||
key: 'loaded-capacity',
|
||||
title: 'Loaded Capacity',
|
||||
description: 'Nameplate vs. loaded capacity per train, by wagon type',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
|
||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'departureDate', label: 'Departure', type: 'date' },
|
||||
{ key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true },
|
||||
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
|
||||
{ key: 'capacityTons', label: 'Capacity', type: 'tons', sortable: true },
|
||||
{ key: 'loadedTons', label: 'Loaded', type: 'tons', sortable: true },
|
||||
{ key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'loadedTons', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('ts.train_number', 'trainNumber')
|
||||
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
|
||||
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
|
||||
.addSelect('COUNT(*)::int', 'wagons')
|
||||
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
|
||||
.addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons')
|
||||
.addSelect(
|
||||
`CASE WHEN COALESCE(SUM(tsw.capacity_tons), 0) > 0
|
||||
THEN ROUND(SUM(tsw.assigned_weight_tons) / SUM(tsw.capacity_tons) * 100)::float8 END`,
|
||||
'utilizationPct',
|
||||
)
|
||||
.groupBy('ts.train_number')
|
||||
.addGroupBy('ts.scheduled_departure_date')
|
||||
.addGroupBy('wt.name');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'wagons')
|
||||
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
|
||||
.addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Wagons', value: Number(row?.wagons ?? 0) },
|
||||
{ label: 'Capacity', value: Number(row?.capacityTons ?? 0), unit: 't' },
|
||||
{ label: 'Loaded', value: Number(row?.loadedTons ?? 0), unit: 't' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Locomotive, LOCOMOTIVE_STATUSES } from '../../locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const STATUS_OPTIONS = LOCOMOTIVE_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Locomotive, 'l')
|
||||
.leftJoin(Yard, 'y', 'y.id = l.current_yard_id')
|
||||
.where('l.deleted_at IS NULL');
|
||||
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const locomotiveFleetStatusReport: ReportDefinition = {
|
||||
key: 'locomotive-fleet-status',
|
||||
title: 'Locomotive Fleet Status',
|
||||
description: 'Locomotive counts by type, station and status',
|
||||
group: 'Operations',
|
||||
filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }],
|
||||
columns: [
|
||||
{ key: 'locomotiveType', label: 'Type', type: 'string', sortable: true },
|
||||
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true },
|
||||
{ key: 'count', label: 'Count', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'count', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'status', y: ['count'] },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('l.locomotive_type', 'locomotiveType')
|
||||
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
|
||||
.addSelect('l.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.groupBy('l.locomotive_type')
|
||||
.addGroupBy('y.label')
|
||||
.addGroupBy('l.status');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'total')
|
||||
.addSelect('COUNT(*) FILTER (WHERE l.status = :available)::int', 'available')
|
||||
.addSelect('COUNT(*) FILTER (WHERE l.status = :assigned)::int', 'assigned')
|
||||
.addSelect('COUNT(*) FILTER (WHERE l.status = :maintenance)::int', 'maintenance')
|
||||
.addSelect('COUNT(*) FILTER (WHERE l.status = :outOfService)::int', 'outOfService')
|
||||
.setParameters({
|
||||
available: 'AVAILABLE',
|
||||
assigned: 'ASSIGNED',
|
||||
maintenance: 'MAINTENANCE',
|
||||
outOfService: 'OUT_OF_SERVICE',
|
||||
})
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Total locomotives', value: Number(row?.total ?? 0) },
|
||||
{ label: 'Available', value: Number(row?.available ?? 0) },
|
||||
{ label: 'Assigned', value: Number(row?.assigned ?? 0) },
|
||||
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
|
||||
{ label: 'Out of service', value: Number(row?.outOfService ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { PaymentEntity } from '../../payment/entities/payment.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// No direct company link on payments (refId points at whatever the intent was
|
||||
// for — booking, demurrage, ...); breakdown stops at status/method/currency.
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'action-required', label: 'Action required' },
|
||||
{ value: 'processing', label: 'Processing' },
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'canceled', label: 'Canceled' },
|
||||
{ value: 'refunded', label: 'Refunded' },
|
||||
];
|
||||
const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map(
|
||||
(v) => ({ value: v, label: v }),
|
||||
);
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
// payments carries no deleted_at column (unlike the rest of the schema) —
|
||||
// confirmed against the live DB, not assumed from BaseEntity.
|
||||
const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1');
|
||||
|
||||
if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (params.method) qb.andWhere('p.method = :method', { method: params.method });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const paymentsByStatusReport: ReportDefinition = {
|
||||
key: 'payments-by-status',
|
||||
title: 'Payments by Status',
|
||||
description: 'Payment volume and value by status, method and currency',
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{ key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true },
|
||||
{ key: 'method', label: 'Method', type: 'string', sortable: true },
|
||||
{ key: 'currency', label: 'Currency', type: 'string' },
|
||||
{ key: 'payments', label: 'Payments', type: 'number', sortable: true },
|
||||
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'amount', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('p.status', 'status')
|
||||
.addSelect('p.method', 'method')
|
||||
.addSelect('p.currency', 'currency')
|
||||
.addSelect('COUNT(*)::int', 'payments')
|
||||
.addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount')
|
||||
.groupBy('p.status')
|
||||
.addGroupBy('p.method')
|
||||
.addGroupBy('p.currency');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'payments')
|
||||
.addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Payments', value: Number(row?.payments ?? 0) },
|
||||
{ label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Booking, 'b')
|
||||
.innerJoin(Company, 'c', 'c.id = b.company_id')
|
||||
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
|
||||
|
||||
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
|
||||
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) {
|
||||
qb.andWhere('b.status IN (:...statuses)', { statuses });
|
||||
} else {
|
||||
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
|
||||
}
|
||||
if (directions !== null) {
|
||||
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
|
||||
directions,
|
||||
});
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const revenueByCustomerReport: ReportDefinition = {
|
||||
key: 'revenue-by-customer',
|
||||
title: 'Revenue by Customer',
|
||||
description: 'Ranked customers by booking revenue',
|
||||
group: 'Commercial',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{
|
||||
key: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'freightType',
|
||||
label: 'Freight type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
{ value: 'BULK', label: 'Bulk' },
|
||||
],
|
||||
},
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('c.name', 'customer')
|
||||
.addSelect('COUNT(*)::int', 'bookings')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||
.groupBy('c.name');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(DISTINCT c.name)::int', 'customers')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Customers', value: Number(row?.customers ?? 0) },
|
||||
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Booking, 'b')
|
||||
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`)
|
||||
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
|
||||
|
||||
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (directions !== null) {
|
||||
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const revenueSummaryReport: ReportDefinition = {
|
||||
key: 'revenue-summary',
|
||||
title: 'Revenue Summary',
|
||||
description: 'Booking revenue by direction, cargo type and currency',
|
||||
group: 'Finance',
|
||||
filters: [{ key: 'date', label: 'Created', type: 'daterange' }],
|
||||
columns: [
|
||||
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
|
||||
{ key: 'freightType', label: 'Cargo type', type: 'string', sortable: true },
|
||||
{ key: 'currency', label: 'Currency', type: 'string' },
|
||||
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'direction', y: ['revenue'] },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('b.trade_direction', 'direction')
|
||||
.addSelect('b.freight_type', 'freightType')
|
||||
.addSelect('b.payment_currency', 'currency')
|
||||
.addSelect('COUNT(*)::int', 'bookings')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||
.groupBy('b.trade_direction')
|
||||
.addGroupBy('b.freight_type')
|
||||
.addGroupBy('b.payment_currency');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||
.addSelect('COUNT(*)::int', 'bookings')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
|
||||
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { TrainSchedule, TRAIN_SCHEDULE_STATUSES } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// ITLMS's spec lists Scheduled/Dispatched/In Transit/Arrived/Cancelled as the
|
||||
// train lifecycle. The platform tracks DRAFT/SCHEDULED/DISPATCHED/ARRIVED/
|
||||
// CANCELLED — no separate "in transit" status exists (a dispatched schedule
|
||||
// with no actual_arrival_at yet *is* in transit; reported as DISPATCHED).
|
||||
const STATUS_OPTIONS = TRAIN_SCHEDULE_STATUSES.map((v) => ({ value: v, label: v }));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(TrainSchedule, 'ts')
|
||||
.leftJoin(Yard, 'o', 'o.id = ts.origin_station_id')
|
||||
.leftJoin(Yard, 'd', 'd.id = ts.destination_station_id')
|
||||
.where('ts.deleted_at IS NULL');
|
||||
|
||||
if (params.dateFrom) {
|
||||
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
}
|
||||
if (params.dateTo) {
|
||||
qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
|
||||
}
|
||||
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('ts.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const trainScheduleStatusReport: ReportDefinition = {
|
||||
key: 'train-schedule-status',
|
||||
title: 'Train Schedules',
|
||||
description: 'Scheduled, dispatched, arrived and cancelled train departures',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||
{
|
||||
key: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
],
|
||||
},
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'reference', label: 'Reference', type: 'string' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ts.status' },
|
||||
{ key: 'direction', label: 'Direction', type: 'string' },
|
||||
{ key: 'origin', label: 'Origin', type: 'string' },
|
||||
{ key: 'destination', label: 'Destination', type: 'string' },
|
||||
{
|
||||
key: 'scheduledDeparture',
|
||||
label: 'Scheduled dep.',
|
||||
type: 'date',
|
||||
sortable: true,
|
||||
sortExpr: 'ts.scheduled_departure_date',
|
||||
},
|
||||
{ key: 'actualDeparture', label: 'Actual dep.', type: 'date' },
|
||||
{ key: 'actualArrival', label: 'Actual arr.', type: 'date' },
|
||||
],
|
||||
defaultSort: { key: 'scheduledDeparture', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('ts.train_number', 'trainNumber')
|
||||
.addSelect('ts.reference', 'reference')
|
||||
.addSelect('ts.status', 'status')
|
||||
.addSelect('ts.direction', 'direction')
|
||||
.addSelect("COALESCE(o.label, 'Unknown')", 'origin')
|
||||
.addSelect("COALESCE(d.label, 'Unknown')", 'destination')
|
||||
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'scheduledDeparture')
|
||||
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
|
||||
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'total')
|
||||
.addSelect('COUNT(*) FILTER (WHERE ts.status = :scheduled)::int', 'scheduled')
|
||||
.addSelect('COUNT(*) FILTER (WHERE ts.status = :dispatched)::int', 'dispatched')
|
||||
.addSelect('COUNT(*) FILTER (WHERE ts.status = :arrived)::int', 'arrived')
|
||||
.addSelect('COUNT(*) FILTER (WHERE ts.status = :cancelled)::int', 'cancelled')
|
||||
.setParameters({ scheduled: 'SCHEDULED', dispatched: 'DISPATCHED', arrived: 'ARRIVED', cancelled: 'CANCELLED' })
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Total', value: Number(row?.total ?? 0) },
|
||||
{ label: 'Scheduled', value: Number(row?.scheduled ?? 0) },
|
||||
{ label: 'Dispatched', value: Number(row?.dispatched ?? 0) },
|
||||
{ label: 'Arrived', value: Number(row?.arrived ?? 0) },
|
||||
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// "Turnaround" here is departure-to-arrival transit time on the actual (not
|
||||
// scheduled) timestamps. Station dwell time (arrival -> the SAME train's next
|
||||
// departure) would need pairing consecutive schedules by physical train,
|
||||
// which isn't tracked directly — deferred, not modeled as a shortcut.
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(TrainSchedule, 'ts')
|
||||
.leftJoin(Yard, 'o', 'o.id = ts.origin_station_id')
|
||||
.leftJoin(Yard, 'd', 'd.id = ts.destination_station_id')
|
||||
.where('ts.deleted_at IS NULL')
|
||||
.andWhere('ts.actual_departure_at IS NOT NULL')
|
||||
.andWhere('ts.actual_arrival_at IS NOT NULL');
|
||||
|
||||
if (params.dateFrom) qb.andWhere('ts.actual_departure_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('ts.actual_departure_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const trainTurnaroundReport: ReportDefinition = {
|
||||
key: 'train-turnaround',
|
||||
title: 'Train Turnaround',
|
||||
description: 'Actual departure-to-arrival transit time per schedule',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Departed', type: 'daterange' },
|
||||
{
|
||||
key: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
],
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'origin', label: 'Origin', type: 'string' },
|
||||
{ key: 'destination', label: 'Destination', type: 'string' },
|
||||
{
|
||||
key: 'actualDeparture',
|
||||
label: 'Departed',
|
||||
type: 'date',
|
||||
sortable: true,
|
||||
sortExpr: 'ts.actual_departure_at',
|
||||
},
|
||||
{ key: 'actualArrival', label: 'Arrived', type: 'date' },
|
||||
{ key: 'transitHours', label: 'Transit (hrs)', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'actualDeparture', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('ts.train_number', 'trainNumber')
|
||||
.addSelect("COALESCE(o.label, 'Unknown')", 'origin')
|
||||
.addSelect("COALESCE(d.label, 'Unknown')", 'destination')
|
||||
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
|
||||
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival')
|
||||
.addSelect(
|
||||
`ROUND(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at))::numeric / 3600, 1)::float8`,
|
||||
'transitHours',
|
||||
);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'trips')
|
||||
.addSelect(
|
||||
`ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at)))::numeric / 3600, 1)::float8`,
|
||||
'avgHours',
|
||||
)
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Trips', value: Number(row?.trips ?? 0) },
|
||||
{ label: 'Avg transit', value: Number(row?.avgHours ?? 0), unit: 'h' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const STATUS_OPTIONS = Object.values(WagonStatus).map((v) => ({
|
||||
value: v,
|
||||
label: v.replace(/_/g, ' '),
|
||||
}));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Wagon, 'w')
|
||||
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
|
||||
.leftJoin(Yard, 'y', 'y.id = w.current_yard_id')
|
||||
.where('w.deleted_at IS NULL');
|
||||
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('w.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const wagonFleetStatusReport: ReportDefinition = {
|
||||
key: 'wagon-fleet-status',
|
||||
title: 'Wagon Fleet Status',
|
||||
description: 'Wagon counts by type, station and status',
|
||||
group: 'Operations',
|
||||
filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }],
|
||||
columns: [
|
||||
{ key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true },
|
||||
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true },
|
||||
{ key: 'count', label: 'Count', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'count', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'status', y: ['count'] },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('COALESCE(wt.name, \'Unknown\')', 'wagonType')
|
||||
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
|
||||
.addSelect('w.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.groupBy('wt.name')
|
||||
.addGroupBy('y.label')
|
||||
.addGroupBy('w.status');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'total')
|
||||
.addSelect('COUNT(*) FILTER (WHERE w.status = :available)::int', 'available')
|
||||
.addSelect('COUNT(*) FILTER (WHERE w.status = :assigned)::int', 'assigned')
|
||||
.addSelect('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance')
|
||||
.addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained')
|
||||
.addSelect('COUNT(*) FILTER (WHERE w.status = :outOfService)::int', 'outOfService')
|
||||
.setParameters({
|
||||
available: WagonStatus.Available,
|
||||
assigned: WagonStatus.Assigned,
|
||||
maintenance: WagonStatus.Maintenance,
|
||||
detained: WagonStatus.Detained,
|
||||
outOfService: WagonStatus.OutOfService,
|
||||
})
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Total wagons', value: Number(row?.total ?? 0) },
|
||||
{ label: 'Available', value: Number(row?.available ?? 0) },
|
||||
{ label: 'Assigned', value: Number(row?.assigned ?? 0) },
|
||||
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
|
||||
{ label: 'Detained', value: Number(row?.detained ?? 0) },
|
||||
{ label: 'Out of service', value: Number(row?.outOfService ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { WagonTransferRequest } from '../../wagons/entities/wagon-transfer-request.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const STATUS_OPTIONS = Object.values(WagonTransferRequestStatus).map((v) => ({
|
||||
value: v,
|
||||
label: v.replace(/_/g, ' '),
|
||||
}));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(WagonTransferRequest, 'r')
|
||||
.leftJoin(Yard, 'fy', 'fy.id = r.from_yard_id')
|
||||
.leftJoin(Yard, 'ty', 'ty.id = r.to_yard_id')
|
||||
.leftJoin(WagonType, 'wt', 'wt.id = r.wagon_type_id')
|
||||
.where('r.deleted_at IS NULL');
|
||||
|
||||
if (params.dateFrom) qb.andWhere('r.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('r.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('r.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const wagonRequestsReport: ReportDefinition = {
|
||||
key: 'wagon-requests',
|
||||
title: 'Wagon Requests',
|
||||
description: 'Inter-yard wagon transfer requests and fulfilment delay',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Requested', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'fromYard', label: 'From', type: 'string', sortable: true, sortExpr: 'fy.label' },
|
||||
{ key: 'toYard', label: 'To', type: 'string', sortable: true, sortExpr: 'ty.label' },
|
||||
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
|
||||
{ key: 'quantity', label: 'Requested', type: 'number' },
|
||||
{ key: 'fulfilledQuantity', label: 'Fulfilled', type: 'number' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'r.status' },
|
||||
{ key: 'requestedAt', label: 'Requested at', type: 'date', sortable: true, sortExpr: 'r.created_at' },
|
||||
{ key: 'fulfilledAt', label: 'Fulfilled at', type: 'date' },
|
||||
{ key: 'delayDays', label: 'Delay (days)', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'requestedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('fy.label', 'fromYard')
|
||||
.addSelect('ty.label', 'toYard')
|
||||
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
|
||||
.addSelect('r.quantity', 'quantity')
|
||||
.addSelect('r.fulfilled_quantity', 'fulfilledQuantity')
|
||||
.addSelect('r.status', 'status')
|
||||
.addSelect(`to_char(r.created_at, 'YYYY-MM-DD')`, 'requestedAt')
|
||||
.addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD')`, 'fulfilledAt')
|
||||
.addSelect(
|
||||
`ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`,
|
||||
'delayDays',
|
||||
);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'requests')
|
||||
.addSelect('COUNT(*) FILTER (WHERE r.status IN (:...openStatuses))::int', 'open')
|
||||
.addSelect(
|
||||
`ROUND(AVG(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400), 1)::float8`,
|
||||
'avgDelayDays',
|
||||
)
|
||||
.setParameters({
|
||||
openStatuses: [WagonTransferRequestStatus.Pending, WagonTransferRequestStatus.PartiallyFulfilled],
|
||||
})
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Requests', value: Number(row?.requests ?? 0) },
|
||||
{ label: 'Still open', value: Number(row?.open ?? 0) },
|
||||
{ label: 'Avg delay', value: Number(row?.avgDelayDays ?? 0), unit: 'd' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// Only these two statuses have an operational "how long has it been stuck
|
||||
// here" question — everything else (Available, Assigned, ...) turns over too
|
||||
// fast for a days-in-status view to matter.
|
||||
const TRACKED_STATUSES = [WagonStatus.Maintenance, WagonStatus.Detained];
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Wagon, 'w')
|
||||
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
|
||||
.leftJoin(Yard, 'y', 'y.id = w.current_yard_id')
|
||||
// Latest time each wagon flipped INTO its current status, per (wagon, status)
|
||||
// pair — a plain (non-correlated) derived table, joined on both columns, so
|
||||
// it stays a normal JOIN rather than needing a LATERAL correlated subquery.
|
||||
.leftJoin(
|
||||
(sub) =>
|
||||
sub
|
||||
.select('l.wagon_id', 'wagon_id')
|
||||
.addSelect('l.to_status', 'to_status')
|
||||
.addSelect('MAX(l.created_at)', 'since')
|
||||
.from('freight.wagon_status_logs', 'l')
|
||||
.groupBy('l.wagon_id')
|
||||
.addGroupBy('l.to_status'),
|
||||
'log',
|
||||
'log.wagon_id = w.id AND log.to_status = w.status',
|
||||
)
|
||||
.where('w.deleted_at IS NULL')
|
||||
.andWhere('w.status IN (:...trackedStatuses)', { trackedStatuses: TRACKED_STATUSES });
|
||||
|
||||
const status = params.status as string | null;
|
||||
if (status) qb.andWhere('w.status = :status', { status });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const wagonStatusDurationReport: ReportDefinition = {
|
||||
key: 'wagon-status-duration',
|
||||
title: 'Wagon Status Duration',
|
||||
description: 'How long each wagon has sat in Maintenance or Detained',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: TRACKED_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })),
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' },
|
||||
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
|
||||
{ key: 'station', label: 'Station', type: 'string' },
|
||||
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'w.status' },
|
||||
{ key: 'since', label: 'Since', type: 'date', sortable: true },
|
||||
{ key: 'daysInStatus', label: 'Days in status', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'daysInStatus', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('w.wagon_number', 'wagonNumber')
|
||||
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
|
||||
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
|
||||
.addSelect('w.status', 'status')
|
||||
.addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD')`, 'since')
|
||||
.addSelect(
|
||||
`FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`,
|
||||
'daysInStatus',
|
||||
);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance')
|
||||
.addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained')
|
||||
.addSelect(
|
||||
`MAX(FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400))::int`,
|
||||
'longest',
|
||||
)
|
||||
.setParameters({ maintenance: WagonStatus.Maintenance, detained: WagonStatus.Detained })
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
|
||||
{ label: 'Detained', value: Number(row?.detained ?? 0) },
|
||||
{ label: 'Longest days in status', value: Number(row?.longest ?? 0), unit: 'd' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { Container } from '../../container-management/entities/container.entity';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
// TEU = container size in feet / 20 (20ft -> 1 TEU, 40ft -> 2 TEU). Scoped to
|
||||
// each wagon's CURRENT schedule pin — a live-state view, not a historical one.
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Wagon, 'w')
|
||||
.innerJoin(TrainSchedule, 'ts', 'ts.id = w.current_train_schedule_id')
|
||||
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
|
||||
.leftJoin(Container, 'c', 'c.wagon_id = w.id AND c.deleted_at IS NULL')
|
||||
.leftJoin(ContainerType, 'ct', 'ct.id = c.container_type_id')
|
||||
.where('w.deleted_at IS NULL');
|
||||
|
||||
if (params.trainNumber) {
|
||||
qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` });
|
||||
}
|
||||
if (params.dateFrom) {
|
||||
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
}
|
||||
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
|
||||
return qb;
|
||||
}
|
||||
|
||||
export const wagonTeuUtilizationReport: ReportDefinition = {
|
||||
key: 'wagon-teu-utilization',
|
||||
title: 'Wagon TEU Utilization',
|
||||
description: 'TEU loaded per wagon on its currently assigned train',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
|
||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' },
|
||||
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'departureDate', label: 'Departure', type: 'date' },
|
||||
{ key: 'containers', label: 'Containers', type: 'number', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'teu', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('w.wagon_number', 'wagonNumber')
|
||||
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
|
||||
.addSelect('ts.train_number', 'trainNumber')
|
||||
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
|
||||
.addSelect('COUNT(c.id)::int', 'containers')
|
||||
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
|
||||
.groupBy('w.wagon_number')
|
||||
.addGroupBy('wt.name')
|
||||
.addGroupBy('ts.train_number')
|
||||
.addGroupBy('ts.scheduled_departure_date');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(DISTINCT w.id)::int', 'wagons')
|
||||
.addSelect('COUNT(c.id)::int', 'containers')
|
||||
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Wagons', value: Number(row?.wagons ?? 0) },
|
||||
{ label: 'Containers', value: Number(row?.containers ?? 0) },
|
||||
{ label: 'Total TEU', value: Number(row?.teu ?? 0) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class ReportQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Inclusive start date (YYYY-MM-DD). Default: 30 days ago.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Inclusive end date (YYYY-MM-DD). Default: today.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['day', 'week', 'month'], default: 'day' })
|
||||
@IsOptional()
|
||||
@IsIn(['day', 'week', 'month'])
|
||||
granularity?: 'day' | 'week' | 'month';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated company UUIDs' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated route UUIDs' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
routeIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated yard UUIDs (matches origin or destination)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
yardIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated cargo type UUIDs' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated status values (report-specific)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
statuses?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Trade direction filter' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
direction?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
|
||||
@IsOptional()
|
||||
@IsIn(['CONTAINER', 'BULK'])
|
||||
freightType?: string;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ReportKpiDto {
|
||||
@ApiProperty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty()
|
||||
value!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export class ReportResultDto {
|
||||
@ApiProperty({ type: [ReportKpiDto] })
|
||||
kpis!: ReportKpiDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
description: 'Report rows; columns vary per report key',
|
||||
})
|
||||
rows!: Record<string, unknown>[];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service';
|
||||
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
|
||||
import { ReportColumn } from './report.types';
|
||||
|
||||
describe('resolveExportFormat', () => {
|
||||
it('only \'pdf\' exports as pdf', () => {
|
||||
expect(resolveExportFormat('pdf')).toBe('pdf');
|
||||
});
|
||||
|
||||
it.each([undefined, 'xlsx', 'csv', ''])('%p falls back to xlsx', (raw) => {
|
||||
expect(resolveExportFormat(raw)).toBe('xlsx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveExportCap', () => {
|
||||
it('missing limit uses the full format cap', () => {
|
||||
expect(resolveExportCap('xlsx', undefined)).toBe(XLSX_ROW_CAP);
|
||||
expect(resolveExportCap('pdf', undefined)).toBe(PDF_ROW_CAP);
|
||||
});
|
||||
|
||||
it('a limit under the cap is used as-is', () => {
|
||||
expect(resolveExportCap('pdf', '100')).toBe(100);
|
||||
});
|
||||
|
||||
it('a limit over the cap is clamped down', () => {
|
||||
expect(resolveExportCap('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP);
|
||||
expect(resolveExportCap('xlsx', String(XLSX_ROW_CAP + 1))).toBe(XLSX_ROW_CAP);
|
||||
});
|
||||
|
||||
it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p falls back to the cap', (raw) => {
|
||||
expect(resolveExportCap('xlsx', raw)).toBe(XLSX_ROW_CAP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveExportColumns', () => {
|
||||
const columns: ReportColumn[] = [
|
||||
{ key: 'a', label: 'A', type: 'string' },
|
||||
{ key: 'b', label: 'B', type: 'number' },
|
||||
{ key: 'c', label: 'C', type: 'money' },
|
||||
];
|
||||
const def = { columns };
|
||||
|
||||
it('missing fields returns every column', () => {
|
||||
expect(resolveExportColumns(def, undefined)).toEqual(columns);
|
||||
});
|
||||
|
||||
it('empty fields string returns every column', () => {
|
||||
expect(resolveExportColumns(def, '')).toEqual(columns);
|
||||
});
|
||||
|
||||
it('a known subset filters to just those columns, in the report\'s own order', () => {
|
||||
expect(resolveExportColumns(def, 'c,a')).toEqual([columns[0], columns[2]]);
|
||||
});
|
||||
|
||||
it('unknown keys are dropped, not passed through', () => {
|
||||
expect(resolveExportColumns(def, 'a,ghost')).toEqual([columns[0]]);
|
||||
});
|
||||
|
||||
it('all-unknown keys falls back to every column instead of a blank sheet', () => {
|
||||
expect(resolveExportColumns(def, 'ghost,also-ghost')).toEqual(columns);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service';
|
||||
import { ReportColumn, ReportDefinition } from './report.types';
|
||||
|
||||
export type ExportFormat = 'xlsx' | 'pdf';
|
||||
|
||||
/** Anything but the literal string 'pdf' exports as xlsx. */
|
||||
export function resolveExportFormat(raw: string | undefined): ExportFormat {
|
||||
return raw === 'pdf' ? 'pdf' : 'xlsx';
|
||||
}
|
||||
|
||||
/** Caller's requested row limit, clamped to the format's hard cap. A
|
||||
* missing/non-positive/non-numeric limit means "as many as the format allows". */
|
||||
export function resolveExportCap(format: ExportFormat, rawLimit: string | undefined): number {
|
||||
const formatCap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
|
||||
const requested = Number(rawLimit);
|
||||
return requested > 0 ? Math.min(requested, formatCap) : formatCap;
|
||||
}
|
||||
|
||||
/** Caller's requested column subset, whitelisted against the report's own
|
||||
* columns. Missing, empty, or all-unknown `rawFields` falls back to every
|
||||
* column rather than shipping a blank sheet. */
|
||||
export function resolveExportColumns(
|
||||
def: Pick<ReportDefinition, 'columns'>,
|
||||
rawFields: string | undefined,
|
||||
): ReportColumn[] {
|
||||
const requested = rawFields?.split(',').filter(Boolean);
|
||||
const filtered = requested?.length ? def.columns.filter((c) => requested.includes(c.key)) : def.columns;
|
||||
return filtered.length ? filtered : def.columns;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
import { ReportColumn, ReportDefinition, ReportKpi } from './report.types';
|
||||
|
||||
// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming
|
||||
// WorkbookWriter if a report ever needs to outgrow XLSX_ROW_CAP.
|
||||
export const XLSX_ROW_CAP = 50_000;
|
||||
// ponytail: HTML→PDF render cost grows with row count; larger exports must
|
||||
// use XLSX instead.
|
||||
export const PDF_ROW_CAP = 5_000;
|
||||
|
||||
const NUMBER_FORMAT: Partial<Record<ReportColumn['type'], string>> = {
|
||||
money: '#,##0.00',
|
||||
tons: '#,##0.0',
|
||||
percent: '0"%"',
|
||||
number: '#,##0',
|
||||
};
|
||||
|
||||
function formatCell(value: unknown, type: ReportColumn['type']): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (type === 'money' || type === 'number') {
|
||||
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
}
|
||||
if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`;
|
||||
if (type === 'percent') return `${value}%`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReportExportService {
|
||||
constructor(private readonly pdfRender: PdfRenderService) {}
|
||||
|
||||
async toXlsx(
|
||||
def: ReportDefinition,
|
||||
rows: Record<string, unknown>[],
|
||||
kpis: ReportKpi[],
|
||||
columns: ReportColumn[] = def.columns,
|
||||
): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(def.title.slice(0, 31));
|
||||
|
||||
if (kpis.length) {
|
||||
sheet.addRow(kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`));
|
||||
sheet.addRow([]);
|
||||
}
|
||||
|
||||
const headerRow = sheet.addRow(columns.map((c) => c.label));
|
||||
headerRow.font = { bold: true };
|
||||
|
||||
for (const row of rows) {
|
||||
sheet.addRow(columns.map((c) => row[c.key] ?? null));
|
||||
}
|
||||
|
||||
columns.forEach((col, i) => {
|
||||
const format = NUMBER_FORMAT[col.type];
|
||||
const excelCol = sheet.getColumn(i + 1);
|
||||
excelCol.width = Math.max(col.label.length + 2, 12);
|
||||
if (format) excelCol.numFmt = format;
|
||||
});
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
async toPdf(
|
||||
def: ReportDefinition,
|
||||
rows: Record<string, unknown>[],
|
||||
kpis: ReportKpi[],
|
||||
columns: ReportColumn[] = def.columns,
|
||||
): Promise<Buffer> {
|
||||
const html = this.buildHtml(def, rows, kpis, columns);
|
||||
return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true });
|
||||
}
|
||||
|
||||
private buildHtml(
|
||||
def: ReportDefinition,
|
||||
rows: Record<string, unknown>[],
|
||||
kpis: ReportKpi[],
|
||||
columns: ReportColumn[],
|
||||
): string {
|
||||
const esc = (v: unknown) =>
|
||||
String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const kpiHtml = kpis.length
|
||||
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
|
||||
.map(
|
||||
(k) =>
|
||||
`<div><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
|
||||
)
|
||||
.join('')}</div>`
|
||||
: '';
|
||||
|
||||
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
|
||||
const body = rows
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
|
||||
h1 { font-size: 16px; margin-bottom: 4px; }
|
||||
p.desc { color: #666; margin-top: 0 0 12px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
|
||||
th { background: #f3f3f3; }
|
||||
</style></head><body>
|
||||
<h1>${esc(def.title)}</h1>
|
||||
<p class="desc">${esc(def.description)}</p>
|
||||
${kpiHtml}
|
||||
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
|
||||
</body></html>`;
|
||||
}
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export interface ReportFilters {
|
||||
/** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */
|
||||
dateFrom: string | null;
|
||||
/** ISO timestamp, exclusive upper bound. null = no upper bound. */
|
||||
dateTo: string | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
companyIds: string[] | null;
|
||||
routeIds: string[] | null;
|
||||
yardIds: string[] | null;
|
||||
cargoTypeIds: string[] | null;
|
||||
statuses: string[] | null;
|
||||
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
|
||||
directions: string[] | null;
|
||||
freightType: string | null;
|
||||
}
|
||||
|
||||
export interface ReportKpi {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface ReportResult {
|
||||
kpis: ReportKpi[];
|
||||
rows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise<ReportResult>;
|
||||
|
||||
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
|
||||
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order.
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
// adjusted_total_amount silently overrides total_amount when set.
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
|
||||
// them double-counts every child booking (same guard as overview.repository).
|
||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||
const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'";
|
||||
|
||||
const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v));
|
||||
const sum = (rows: Record<string, unknown>[], col: string): number =>
|
||||
rows.reduce((acc, r) => acc + num(r[col]), 0);
|
||||
|
||||
/**
|
||||
* Shared WHERE for booking-based reports (alias `b`).
|
||||
* Params occupy $1..$8 in this fixed order; report SQL continues at $9.
|
||||
*/
|
||||
function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } {
|
||||
return {
|
||||
where: `
|
||||
b.deleted_at IS NULL
|
||||
AND ${NOT_UMBRELLA}
|
||||
AND ($1::timestamptz IS NULL OR b.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR b.created_at < $2)
|
||||
AND ($3::uuid[] IS NULL OR b.company_id = ANY($3))
|
||||
AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4))
|
||||
AND ($5::text[] IS NULL OR b.trade_direction = ANY($5))
|
||||
AND ($6::text IS NULL OR b.freight_type = $6)
|
||||
AND (CASE WHEN $7::text[] IS NULL
|
||||
THEN b.status NOT IN (${DEAD_STATUSES})
|
||||
ELSE b.status = ANY($7) END)
|
||||
AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`,
|
||||
params: [
|
||||
f.dateFrom,
|
||||
f.dateTo,
|
||||
f.companyIds,
|
||||
f.cargoTypeIds,
|
||||
f.directions,
|
||||
f.freightType,
|
||||
f.statuses,
|
||||
f.yardIds,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction scope for rows that reference a booking through a varchar id
|
||||
* column (invoices.source_id, payments.ref_id). Rows not pointing at a
|
||||
* booking stay visible — they carry no direction to scope by.
|
||||
* (Positional-param port of trade-scope.util's bookingRefScopeSql.)
|
||||
*/
|
||||
const refDirScope = (refColumn: string, param: string): string => `
|
||||
(${param}::text[] IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings sb
|
||||
WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`;
|
||||
|
||||
const bookingsTrend: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period,
|
||||
COUNT(*)::int AS bookings,
|
||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||
FROM freight.bookings b
|
||||
WHERE ${where}
|
||||
GROUP BY 1 ORDER BY 1`,
|
||||
[...params, f.granularity],
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Bookings', value: sum(rows, 'bookings') },
|
||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const revenueByCustomer: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT c.name AS customer,
|
||||
COUNT(*)::int AS bookings,
|
||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||
FROM freight.bookings b
|
||||
JOIN freight.companies c ON c.id = b.company_id
|
||||
WHERE ${where}
|
||||
GROUP BY c.name ORDER BY revenue DESC LIMIT 100`,
|
||||
params,
|
||||
);
|
||||
const total = sum(rows, 'revenue');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Customers', value: rows.length },
|
||||
{ label: 'Revenue', value: total, unit: 'ETB' },
|
||||
{
|
||||
label: 'Top customer share',
|
||||
value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const revenueByLane: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT o.label AS origin, d.label AS destination,
|
||||
COUNT(*)::int AS bookings,
|
||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||
FROM freight.bookings b
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
WHERE ${where}
|
||||
GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Lanes', value: rows.length },
|
||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const contractUtilization: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind,
|
||||
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
||||
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
||||
cap.committed::float8 AS committed,
|
||||
booked.tons::float8 AS booked_tons,
|
||||
booked.cnt AS bookings,
|
||||
CASE WHEN cap.committed > 0
|
||||
THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct
|
||||
FROM freight.contracts ct
|
||||
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed
|
||||
FROM freight.contract_cargo_scope s
|
||||
WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt
|
||||
FROM freight.bookings b
|
||||
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
|
||||
AND b.status NOT IN (${DEAD_STATUSES})) booked ON true
|
||||
WHERE ct.deleted_at IS NULL
|
||||
AND ct.status NOT IN ('DRAFT')
|
||||
AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity')
|
||||
AND (ct.contract_valid_until IS NULL
|
||||
OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity'))
|
||||
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
||||
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
||||
ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`,
|
||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
||||
);
|
||||
const capped = rows.filter((r: Record<string, unknown>) => num(r.committed) > 0);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Contracts', value: rows.length },
|
||||
{
|
||||
label: 'Avg utilization',
|
||||
value: capped.length
|
||||
? Math.round(sum(capped, 'utilization_pct') / capped.length)
|
||||
: 0,
|
||||
unit: '%',
|
||||
},
|
||||
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
// ponytail: 60-min departure grace is a constant; make it a query param if ops
|
||||
// ever wants a configurable threshold.
|
||||
const trainOnTime: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT o.label AS origin, d.label AS destination,
|
||||
COUNT(*)::int AS trips,
|
||||
COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60)
|
||||
FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60)
|
||||
FILTER (WHERE ts.actual_arrival_at IS NOT NULL
|
||||
AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min,
|
||||
ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at
|
||||
<= ts.scheduled_departure_date + interval '60 minutes')
|
||||
/ NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
GROUP BY 1, 2 ORDER BY trips DESC`,
|
||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||
);
|
||||
const departed = sum(rows, 'departed');
|
||||
const weighted = rows.reduce(
|
||||
(acc: number, r: Record<string, unknown>) =>
|
||||
acc + (num(r.on_time_pct) * num(r.departed)) / 100,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Trips', value: sum(rows, 'trips') },
|
||||
{
|
||||
label: 'On-time departures',
|
||||
value: departed > 0 ? Math.round((weighted / departed) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
{
|
||||
label: 'Avg departure delay',
|
||||
value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0,
|
||||
unit: 'min',
|
||||
},
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleFillRate: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ts.train_number, ts.reference,
|
||||
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure,
|
||||
o.label AS origin, d.label AS destination, ts.direction, ts.status,
|
||||
ts.max_wagons, tset.wagon_count,
|
||||
ROUND(w.cap_tons)::float8 AS capacity_tons,
|
||||
ROUND(w.booked_tons)::float8 AS booked_tons,
|
||||
CASE WHEN w.cap_tons > 0
|
||||
THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons,
|
||||
COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
||||
FROM freight.train_set_wagons tw
|
||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status <> 'CANCELLED'
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
ORDER BY ts.scheduled_departure_date DESC LIMIT 200`,
|
||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||
);
|
||||
const withCap = rows.filter((r: Record<string, unknown>) => num(r.capacity_tons) > 0);
|
||||
const capTons = sum(withCap, 'capacity_tons');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Schedules', value: rows.length },
|
||||
{
|
||||
label: 'Avg fill rate',
|
||||
value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const tripsPerRoute: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT o.label AS origin, d.label AS destination, ts.direction,
|
||||
COUNT(*)::int AS trips,
|
||||
ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled,
|
||||
ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
||||
FROM freight.train_set_wagons tw
|
||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
GROUP BY 1, 2, 3 ORDER BY trips DESC`,
|
||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Trips', value: sum(rows, 'trips') },
|
||||
{ label: 'Routes served', value: rows.length },
|
||||
{ label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const invoicedVsCollected: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period,
|
||||
COUNT(*)::int AS invoices,
|
||||
ROUND(SUM(i.total_amount))::float8 AS invoiced,
|
||||
ROUND(SUM(i.paid_amount))::float8 AS collected,
|
||||
ROUND(SUM(i.balance_amount))::float8 AS outstanding
|
||||
FROM freight.invoices i
|
||||
WHERE i.deleted_at IS NULL
|
||||
AND i.status NOT IN ('DRAFT', 'CANCELLED')
|
||||
AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1)
|
||||
AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2)
|
||||
AND ($3::uuid[] IS NULL OR i.company_id = ANY($3))
|
||||
AND ${refDirScope('i.source_id', '$4')}
|
||||
GROUP BY 1 ORDER BY 1`,
|
||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity],
|
||||
);
|
||||
const invoiced = sum(rows, 'invoiced');
|
||||
const collected = sum(rows, 'collected');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Invoiced', value: invoiced, unit: 'ETB' },
|
||||
{ label: 'Collected', value: collected, unit: 'ETB' },
|
||||
{
|
||||
label: 'Collection rate',
|
||||
value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
{ label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
// Aging is an as-of snapshot: dateTo is the as-of moment (default now),
|
||||
// dateFrom is ignored.
|
||||
const agingReceivables: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT c.name AS customer,
|
||||
COUNT(*)::int AS invoices,
|
||||
ROUND(SUM(i.balance_amount))::float8 AS outstanding,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now())
|
||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days'
|
||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days'
|
||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus
|
||||
FROM freight.invoices i
|
||||
JOIN freight.companies c ON c.id = i.company_id
|
||||
WHERE i.deleted_at IS NULL
|
||||
AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE')
|
||||
AND i.balance_amount > 0
|
||||
AND ($1::timestamptz IS NULL OR i.created_at < $1)
|
||||
AND ($2::uuid[] IS NULL OR i.company_id = ANY($2))
|
||||
AND ${refDirScope('i.source_id', '$3')}
|
||||
GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`,
|
||||
[f.dateTo, f.companyIds, f.directions],
|
||||
);
|
||||
const outstanding = sum(rows, 'outstanding');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Outstanding', value: outstanding, unit: 'ETB' },
|
||||
{ label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' },
|
||||
{ label: 'Customers with balance', value: rows.length },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const revenueByPaymentMethod: ReportQuery = async (ds, f) => {
|
||||
// payments.status values are lowercase-hyphenated ('success'), unlike every
|
||||
// other status enum in the schema. No deleted_at on this table.
|
||||
const rows = await ds.query(
|
||||
`SELECT p.method::text AS method,
|
||||
COUNT(*)::int AS payments,
|
||||
ROUND(SUM(p.amount))::float8 AS amount
|
||||
FROM freight.payments p
|
||||
WHERE p.status = 'success'
|
||||
AND ($1::timestamptz IS NULL OR p.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
||||
AND ${refDirScope('p.ref_id', '$3')}
|
||||
GROUP BY 1 ORDER BY amount DESC`,
|
||||
[f.dateFrom, f.dateTo, f.directions],
|
||||
);
|
||||
const total = sum(rows, 'amount');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Collected', value: total, unit: 'ETB' },
|
||||
{ label: 'Payments', value: sum(rows, 'payments') },
|
||||
{
|
||||
label: 'Top method share',
|
||||
value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record-level list exports. Same engine, raw rows instead of aggregates.
|
||||
// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table
|
||||
// ever outgrows that.
|
||||
const LIST_LIMIT = 5000;
|
||||
|
||||
const bookingsList: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT b.reference,
|
||||
to_char(b.created_at, 'YYYY-MM-DD') AS created,
|
||||
c.name AS customer, b.status, b.freight_type,
|
||||
b.trade_direction AS direction,
|
||||
o.label AS origin, d.label AS destination,
|
||||
COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo,
|
||||
ROUND(${TONS})::float8 AS tons,
|
||||
ROUND(${REVENUE})::float8 AS amount,
|
||||
b.payment_status, b.scheduling_status
|
||||
FROM freight.bookings b
|
||||
JOIN freight.companies c ON c.id = b.company_id
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id
|
||||
WHERE ${where}
|
||||
ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Bookings', value: rows.length },
|
||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||
{ label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const contractsList: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind,
|
||||
ct.status, ct.trade_direction AS direction, ct.freight_type,
|
||||
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
||||
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
||||
to_char(ct.created_at, 'YYYY-MM-DD') AS created
|
||||
FROM freight.contracts ct
|
||||
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
||||
WHERE ct.deleted_at IS NULL
|
||||
AND ($1::timestamptz IS NULL OR ct.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ct.created_at < $2)
|
||||
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
||||
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
||||
ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
||||
);
|
||||
const active = rows.filter((r: Record<string, unknown>) =>
|
||||
['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)),
|
||||
).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Contracts', value: rows.length },
|
||||
{ label: 'Active', value: active },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const schedulesList: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ts.train_number, ts.reference, ts.direction, ts.status,
|
||||
o.label AS origin, d.label AS destination,
|
||||
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure,
|
||||
to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure,
|
||||
to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival,
|
||||
to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival,
|
||||
ts.max_wagons, tset.wagon_count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::text[] IS NULL OR ts.direction = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.status = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds],
|
||||
);
|
||||
const count = (s: string) =>
|
||||
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Schedules', value: rows.length },
|
||||
{ label: 'Dispatched', value: count('DISPATCHED') },
|
||||
{ label: 'Arrived', value: count('ARRIVED') },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const fleetWagons: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT w.wagon_number, wt.name AS type,
|
||||
wt.capacity_tons::float8 AS capacity_tons,
|
||||
w.status, y.label AS current_yard
|
||||
FROM freight.wagons w
|
||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
LEFT JOIN freight.yards y ON y.id = w.current_yard_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
AND ($1::text[] IS NULL OR w.status = ANY($1))
|
||||
AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2))
|
||||
ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`,
|
||||
[f.statuses, f.yardIds],
|
||||
);
|
||||
const count = (s: string) =>
|
||||
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Wagons', value: rows.length },
|
||||
{ label: 'Available', value: count('AVAILABLE') },
|
||||
{ label: 'Assigned', value: count('ASSIGNED') },
|
||||
{ label: 'Maintenance', value: count('MAINTENANCE') },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const fleetLocomotives: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT l.code, l.name, l.locomotive_type,
|
||||
l.max_pull_weight_tons::float8 AS max_pull_tons,
|
||||
l.status, y.label AS current_yard
|
||||
FROM freight.locomotives l
|
||||
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND ($1::text[] IS NULL OR l.status = ANY($1))
|
||||
AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2))
|
||||
ORDER BY l.code LIMIT ${LIST_LIMIT}`,
|
||||
[f.statuses, f.yardIds],
|
||||
);
|
||||
const available = rows.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'AVAILABLE',
|
||||
).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Locomotives', value: rows.length },
|
||||
{ label: 'Available', value: available },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const customersList: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT c.name, c.type, c.kind, c.status, c.tin,
|
||||
to_char(c.approved_at, 'YYYY-MM-DD') AS approved,
|
||||
to_char(c.created_at, 'YYYY-MM-DD') AS created
|
||||
FROM freight.companies c
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND ($1::timestamptz IS NULL OR c.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR c.created_at < $2)
|
||||
AND ($3::text[] IS NULL OR c.status = ANY($3))
|
||||
ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.statuses],
|
||||
);
|
||||
const active = rows.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'active',
|
||||
).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Customers', value: rows.length },
|
||||
{ label: 'Active', value: active },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const paymentsList: ReportQuery = async (ds, f) => {
|
||||
// No deleted_at on freight.payments; statuses are lowercase-hyphenated.
|
||||
const rows = await ds.query(
|
||||
`SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created,
|
||||
p.method::text AS method, p.status::text AS status,
|
||||
p.currency::text AS currency,
|
||||
ROUND(p.amount)::float8 AS amount,
|
||||
p.transaction_id, p.merchant_order_id,
|
||||
to_char(p.paid_at, 'YYYY-MM-DD') AS paid
|
||||
FROM freight.payments p
|
||||
WHERE ($1::timestamptz IS NULL OR p.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
||||
AND ($3::text[] IS NULL OR p.status::text = ANY($3))
|
||||
AND ${refDirScope('p.ref_id', '$4')}
|
||||
ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.statuses, f.directions],
|
||||
);
|
||||
const success = rows.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'success',
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Payments', value: rows.length },
|
||||
{ label: 'Successful', value: success.length },
|
||||
{ label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
export const REPORT_QUERIES: Record<string, ReportQuery> = {
|
||||
'bookings-list': bookingsList,
|
||||
'contracts-list': contractsList,
|
||||
'schedules-list': schedulesList,
|
||||
'fleet-wagons': fleetWagons,
|
||||
'fleet-locomotives': fleetLocomotives,
|
||||
'customers-list': customersList,
|
||||
'payments-list': paymentsList,
|
||||
'bookings-trend': bookingsTrend,
|
||||
'revenue-by-customer': revenueByCustomer,
|
||||
'revenue-by-lane': revenueByLane,
|
||||
'contract-utilization': contractUtilization,
|
||||
'train-on-time': trainOnTime,
|
||||
'schedule-fill-rate': scheduleFillRate,
|
||||
'trips-per-route': tripsPerRoute,
|
||||
'invoiced-vs-collected': invoicedVsCollected,
|
||||
'aging-receivables': agingReceivables,
|
||||
'revenue-by-payment-method': revenueByPaymentMethod,
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import { ReportDefinition, ReportRunResult } from './report.types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Raw query params, minus the pagination/sort keys the runner owns. */
|
||||
export type RawReportQuery = Record<string, string | undefined>;
|
||||
|
||||
/**
|
||||
* Coerce raw query strings into typed filter params per the report's own
|
||||
* filter declarations. Unknown filter keys are ignored — `forbidNonWhitelisted`
|
||||
* can't police a per-report bag, so extras are just dropped, not rejected.
|
||||
*/
|
||||
function coerceParams(
|
||||
def: ReportDefinition,
|
||||
raw: RawReportQuery,
|
||||
): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const filter of def.filters) {
|
||||
if (filter.type === 'daterange') {
|
||||
const from = raw[`${filter.key}From`];
|
||||
const to = raw[`${filter.key}To`];
|
||||
params[`${filter.key}From`] = from ? new Date(from).toISOString() : null;
|
||||
// Inclusive end date, exclusive bound in SQL.
|
||||
params[`${filter.key}To`] = to
|
||||
? new Date(new Date(to).getTime() + DAY_MS).toISOString()
|
||||
: null;
|
||||
} else if (filter.type === 'multiselect') {
|
||||
const csv = raw[filter.key];
|
||||
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
||||
params[filter.key] = items.length ? items : null;
|
||||
} else {
|
||||
params[filter.key] = raw[filter.key]?.trim() || null;
|
||||
}
|
||||
}
|
||||
// idKey, when the report declares one, is a plain string param.
|
||||
if (def.idKey) {
|
||||
params[def.idKey.key] = raw[def.idKey.key]?.trim() || null;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort expression for a column with no explicit `sortExpr`: the SELECT alias
|
||||
* TypeORM emitted for it, quoted. TypeORM always double-quotes `addSelect`
|
||||
* aliases in the generated SQL (preserving case) — ordering by the bare,
|
||||
* unquoted key instead lets Postgres fold it to lowercase and 42703 on any
|
||||
* camelCase alias (e.g. "utilizationPct" -> unquoted "utilizationpct").
|
||||
*/
|
||||
const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`;
|
||||
|
||||
/** Resolve a client-requested sort column against the report's own whitelist. */
|
||||
function resolveSort(
|
||||
def: ReportDefinition,
|
||||
sortBy?: string,
|
||||
sortOrder?: string,
|
||||
): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null {
|
||||
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable);
|
||||
if (requested) {
|
||||
return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir };
|
||||
}
|
||||
if (!def.defaultSort) return null;
|
||||
const fallback = def.columns.find((c) => c.key === def.defaultSort!.key);
|
||||
if (!fallback) return null;
|
||||
return {
|
||||
key: fallback.key,
|
||||
expr: fallback.sortExpr ?? aliasSortExpr(fallback.key),
|
||||
dir: def.defaultSort.dir,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReportRunnerService {
|
||||
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||
|
||||
async run(
|
||||
def: ReportDefinition,
|
||||
raw: RawReportQuery,
|
||||
directions: string[] | null,
|
||||
): Promise<ReportRunResult> {
|
||||
const params = coerceParams(def, raw);
|
||||
const ctx = { ds: this.ds, params, directions };
|
||||
|
||||
const qb = def.query(ctx);
|
||||
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
|
||||
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||
|
||||
const { page: pageNum, pageSize, skip, take } = normalizePagination({
|
||||
page: raw.page ? Number(raw.page) : undefined,
|
||||
pageSize: raw.pageSize ? Number(raw.pageSize) : undefined,
|
||||
});
|
||||
|
||||
const [sql, sqlParams] = qb.getQueryAndParameters();
|
||||
// getCount() re-derives its own (wrong) select list for GROUP BY queries —
|
||||
// wrapping the real query as a subquery counts exactly what will be paged.
|
||||
const countRow = await this.ds.query(
|
||||
`SELECT COUNT(*)::int AS c FROM (${sql}) report_count`,
|
||||
sqlParams,
|
||||
);
|
||||
const total = Number(countRow[0]?.c ?? 0);
|
||||
|
||||
// .offset()/.limit(), not .skip()/.take() — skip/take route raw & grouped
|
||||
// selects through TypeORM's DISTINCT-id subquery path, which is wrong here.
|
||||
const items = await qb.offset(skip).limit(take).getRawMany();
|
||||
|
||||
const kpis = def.summary ? await def.summary(ctx) : [];
|
||||
|
||||
return {
|
||||
columns: def.columns,
|
||||
items,
|
||||
meta: buildPaginationMeta(total, pageNum, pageSize),
|
||||
kpis,
|
||||
};
|
||||
}
|
||||
|
||||
/** Same query, no paging — used by the export path. */
|
||||
async runAll(
|
||||
def: ReportDefinition,
|
||||
raw: RawReportQuery,
|
||||
directions: string[] | null,
|
||||
limit: number,
|
||||
): Promise<{ columns: typeof def.columns; items: Record<string, unknown>[]; kpis: ReportRunResult['kpis'] }> {
|
||||
const params = coerceParams(def, raw);
|
||||
const ctx = { ds: this.ds, params, directions };
|
||||
const qb = def.query(ctx);
|
||||
// Same sort the on-screen table is using, not always the default — an
|
||||
// export is supposed to match what the user is looking at.
|
||||
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
|
||||
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||
const items = await qb.limit(limit).getRawMany();
|
||||
if (items.length >= limit) {
|
||||
throw new BadRequestException(
|
||||
`Export exceeds the ${limit}-row cap for this format. Narrow the filters.`,
|
||||
);
|
||||
}
|
||||
const kpis = def.summary ? await def.summary(ctx) : [];
|
||||
return { columns: def.columns, items, kpis };
|
||||
}
|
||||
}
|
||||
|
||||
// Re-exported so definitions can scope ACL columns without importing the
|
||||
// trade-scope module directly.
|
||||
export { applyBookingRefDirectionScope };
|
||||
@@ -0,0 +1,52 @@
|
||||
import { REPORT_KEYS } from '../../seed/freight-permissions.registry';
|
||||
import { REPORTS, getReport } from './report.registry';
|
||||
|
||||
describe('REPORTS', () => {
|
||||
it('has exactly one definition per seeded REPORT_KEYS entry', () => {
|
||||
const defKeys = REPORTS.map((r) => r.key).sort();
|
||||
expect(defKeys).toEqual([...REPORT_KEYS].sort());
|
||||
});
|
||||
|
||||
it('has no duplicate keys', () => {
|
||||
const keys = REPORTS.map((r) => r.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('resolves every key via getReport', () => {
|
||||
for (const key of REPORT_KEYS) {
|
||||
expect(getReport(key)?.key).toBe(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('every sortable column and defaultSort point at a real column key', () => {
|
||||
for (const def of REPORTS) {
|
||||
const columnKeys = new Set(def.columns.map((c) => c.key));
|
||||
if (def.defaultSort) {
|
||||
expect(columnKeys.has(def.defaultSort.key)).toBe(true);
|
||||
}
|
||||
// Every column marked sortable must have a resolvable key (itself, since
|
||||
// the runner falls back to `key` when `sortExpr` is absent).
|
||||
for (const col of def.columns.filter((c) => c.sortable)) {
|
||||
expect(col.key.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('idKey, when declared, is not also listed as a user-facing filter', () => {
|
||||
for (const def of REPORTS) {
|
||||
if (!def.idKey) continue;
|
||||
expect(def.filters.some((f) => f.key === def.idKey!.key)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('chart.x and chart.y, when declared, point at real column keys', () => {
|
||||
for (const def of REPORTS) {
|
||||
if (!def.chart) continue;
|
||||
const columnKeys = new Set(def.columns.map((c) => c.key));
|
||||
expect(columnKeys.has(def.chart.x)).toBe(true);
|
||||
for (const y of def.chart.y) {
|
||||
expect(columnKeys.has(y)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
62
apps/edr-freight-api/src/modules/reports/report.registry.ts
Normal file
62
apps/edr-freight-api/src/modules/reports/report.registry.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ReportKey } from '../../seed/freight-permissions.registry';
|
||||
import { bookingsListReport } from './definitions/bookings-list.report';
|
||||
import { revenueByCustomerReport } from './definitions/revenue-by-customer.report';
|
||||
import { agingReceivablesReport } from './definitions/aging-receivables.report';
|
||||
import { contractUtilizationReport } from './definitions/contract-utilization.report';
|
||||
import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report';
|
||||
import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report';
|
||||
import { wagonRequestsReport } from './definitions/wagon-requests.report';
|
||||
import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report';
|
||||
import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report';
|
||||
import { trainScheduleStatusReport } from './definitions/train-schedule-status.report';
|
||||
import { trainTurnaroundReport } from './definitions/train-turnaround.report';
|
||||
import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report';
|
||||
import { loadedCapacityReport } from './definitions/loaded-capacity.report';
|
||||
import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report';
|
||||
import { customerStatusReport } from './definitions/customer-status.report';
|
||||
import { contractLifecycleReport } from './definitions/contract-lifecycle.report';
|
||||
import { customsDocumentsReport } from './definitions/customs-documents.report';
|
||||
import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report';
|
||||
import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report';
|
||||
import { invoicesByStatusReport } from './definitions/invoices-by-status.report';
|
||||
import { paymentsByStatusReport } from './definitions/payments-by-status.report';
|
||||
import { revenueSummaryReport } from './definitions/revenue-summary.report';
|
||||
import { cargoSummaryReport } from './definitions/cargo-summary.report';
|
||||
import { ReportDefinition } from './report.types';
|
||||
|
||||
/**
|
||||
* Every report the platform knows about. Adding one = a new file under
|
||||
* definitions/ + a key in REPORT_KEYS (freight-permissions.registry.ts) +
|
||||
* an entry here. Nothing else — no frontend edit, no route, no sidebar edit.
|
||||
*/
|
||||
export const REPORTS: ReportDefinition[] = [
|
||||
bookingsListReport,
|
||||
revenueByCustomerReport,
|
||||
agingReceivablesReport,
|
||||
contractUtilizationReport,
|
||||
wagonFleetStatusReport,
|
||||
wagonStatusDurationReport,
|
||||
wagonRequestsReport,
|
||||
locomotiveFleetStatusReport,
|
||||
bookingStatusBreakdownReport,
|
||||
trainScheduleStatusReport,
|
||||
trainTurnaroundReport,
|
||||
wagonTeuUtilizationReport,
|
||||
loadedCapacityReport,
|
||||
globalLogisticsWagonsReport,
|
||||
customerStatusReport,
|
||||
contractLifecycleReport,
|
||||
customsDocumentsReport,
|
||||
invoicingPipelineReport,
|
||||
firstLastMileBookingsReport,
|
||||
invoicesByStatusReport,
|
||||
paymentsByStatusReport,
|
||||
revenueSummaryReport,
|
||||
cargoSummaryReport,
|
||||
];
|
||||
|
||||
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
|
||||
|
||||
export function getReport(key: string): ReportDefinition | undefined {
|
||||
return BY_KEY.get(key as ReportKey);
|
||||
}
|
||||
112
apps/edr-freight-api/src/modules/reports/report.types.ts
Normal file
112
apps/edr-freight-api/src/modules/reports/report.types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { DataSource, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ReportKey } from '../../seed/freight-permissions.registry';
|
||||
|
||||
export type { ReportKey };
|
||||
|
||||
export type ReportColumnType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'money'
|
||||
| 'tons'
|
||||
| 'percent'
|
||||
| 'date';
|
||||
|
||||
export interface ReportColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ReportColumnType;
|
||||
sortable?: boolean;
|
||||
/** SQL to ORDER BY when this column is sorted, if different from `key`. */
|
||||
sortExpr?: string;
|
||||
}
|
||||
|
||||
export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
||||
|
||||
export interface ReportFilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ReportFilterDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ReportFilterType;
|
||||
/** Static option list for select/multiselect. */
|
||||
options?: ReportFilterOption[];
|
||||
}
|
||||
|
||||
export interface ReportKpi {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export type ReportChartType = 'line' | 'bar';
|
||||
|
||||
/**
|
||||
* Plots the SAME rows the table gets — no separate query. `x` and `y` are
|
||||
* column keys from `columns`. A report whose group-by has dimensions beyond
|
||||
* `x` will render one mark per row (e.g. two rows sharing a date because they
|
||||
* differ by direction), which is a busier chart, not a wrong one. Pivoting
|
||||
* rows into one-per-x series is a later add if a report actually needs it.
|
||||
*/
|
||||
export interface ReportChartDef {
|
||||
type: ReportChartType;
|
||||
x: string;
|
||||
y: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional entity scope a report can be embedded against — e.g. a
|
||||
* contract-utilization report shown on a single contract's detail page.
|
||||
* Purely descriptive; `query()` reads the resolved value off `ctx.params`
|
||||
* like any other filter.
|
||||
*/
|
||||
export interface ReportIdKey {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ReportContext {
|
||||
ds: DataSource;
|
||||
/** Filter values, already coerced against `def.filters` (CSV → array, etc). */
|
||||
params: Record<string, unknown>;
|
||||
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
|
||||
directions: string[] | null;
|
||||
}
|
||||
|
||||
export interface ReportDefinition {
|
||||
key: ReportKey;
|
||||
title: string;
|
||||
description: string;
|
||||
group: 'Commercial' | 'Operations' | 'Finance';
|
||||
idKey?: ReportIdKey;
|
||||
filters: ReportFilterDef[];
|
||||
columns: ReportColumn[];
|
||||
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
|
||||
query(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral>;
|
||||
/** KPIs over the same filtered set; shown above the table and in exports. */
|
||||
summary?(ctx: ReportContext): Promise<ReportKpi[]>;
|
||||
/** Optional chart view of the same rows. Table remains the default view. */
|
||||
chart?: ReportChartDef;
|
||||
}
|
||||
|
||||
/** Catalog shape served by GET /reports — metadata only, no rows. */
|
||||
export type ReportCatalogEntry = Omit<ReportDefinition, 'query' | 'summary'> & {
|
||||
hasSummary: boolean;
|
||||
};
|
||||
|
||||
export interface ReportRunResult {
|
||||
columns: ReportColumn[];
|
||||
items: Record<string, unknown>[];
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
kpis: ReportKpi[];
|
||||
}
|
||||
@@ -1,34 +1,92 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { Response } from 'express';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { ReportQueryDto } from './dto/report-query.dto';
|
||||
import { ReportResultDto } from './dto/report-result.dto';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { ReportExportService } from './report-export.service';
|
||||
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
|
||||
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
|
||||
import { REPORTS, getReport } from './report.registry';
|
||||
import { ReportCatalogEntry, ReportDefinition } from './report.types';
|
||||
|
||||
const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => {
|
||||
const { query: _query, summary, ...meta } = def;
|
||||
return { ...meta, hasSummary: Boolean(summary) };
|
||||
};
|
||||
|
||||
@ApiTags('Reports')
|
||||
@ApiBearerAuth()
|
||||
@Controller('reports')
|
||||
@BookingStaff(FREIGHT_PERMS.reports.view)
|
||||
export class ReportsController {
|
||||
constructor(
|
||||
private readonly reportsService: ReportsService,
|
||||
private readonly runner: ReportRunnerService,
|
||||
private readonly exportService: ReportExportService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List reports the caller has permission to run' })
|
||||
async catalog(@CurrentUser() user: TCurrentUser): Promise<ReportCatalogEntry[]> {
|
||||
return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map(
|
||||
toCatalogEntry,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':key')
|
||||
@BookingStaff(FREIGHT_PERMS.reports.view)
|
||||
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
|
||||
@ApiOkResponse({ type: ReportResultDto })
|
||||
@ApiOperation({ summary: 'Run a report by key, paginated/sorted/filtered' })
|
||||
async run(
|
||||
@Param('key') key: string,
|
||||
@Query() query: ReportQueryDto,
|
||||
@Query() query: RawReportQuery,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<ReportResultDto> {
|
||||
const allowed = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.reportsService.run(key, query, allowed);
|
||||
) {
|
||||
const def = this.resolve(key, user);
|
||||
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.runner.run(def, query, directions);
|
||||
}
|
||||
|
||||
@Get(':key/export')
|
||||
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
|
||||
async export(
|
||||
@Param('key') key: string,
|
||||
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const def = this.resolve(key, user);
|
||||
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
const format = resolveExportFormat(query.format);
|
||||
const cap = resolveExportCap(format, query.limit);
|
||||
const exportColumns = resolveExportColumns(def, query.fields);
|
||||
|
||||
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
|
||||
const buffer =
|
||||
format === 'pdf'
|
||||
? await this.exportService.toPdf(def, items, kpis, exportColumns)
|
||||
: await this.exportService.toXlsx(def, items, kpis, exportColumns);
|
||||
|
||||
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
format === 'pdf'
|
||||
? 'application/pdf'
|
||||
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
private resolve(key: string, user: TCurrentUser): ReportDefinition {
|
||||
const def = getReport(key);
|
||||
if (!def) throw new NotFoundException(`Unknown report: ${key}`);
|
||||
// Exact-match on purpose — unlike FreightPermissionGuard's :view/:read
|
||||
// fallback, a report's own key is the only thing that opens it.
|
||||
assertFreightPermission(user, reportPermissionKey(def.key));
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { ReportExportService } from './report-export.service';
|
||||
import { ReportRunnerService } from './report-runner.service';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsRepository } from './reports.repository';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
imports: [UserTradeAccessModule],
|
||||
imports: [UserTradeAccessModule, DocumentsModule],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService, ReportsRepository],
|
||||
providers: [ReportRunnerService, ReportExportService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user