feat(freight-api): replace canned reports with a generic report engine

Nuke the 17 hand-written raw-SQL reports (no pagination, hard LIMITs) and
the reports module built around them. Replace with a resolver contract:
a report declares columns/filters/permission and a TypeORM QueryBuilder;
ReportRunnerService applies filtering, a whitelisted sort, offset/limit
paging, and a COUNT(*) FROM (query) wrapper for the total (getCount() is
wrong for GROUP BY). ReportExportService re-runs the same resolver
unpaginated for xlsx (exceljs) and pdf (existing PdfRenderService, now
landscape-capable) exports.

Ships with 4 reports: bookings-list, revenue-by-customer,
aging-receivables, contract-utilization. Catalog + per-report permission
checks live in the controller; adding a report is one new definitions/
file plus a REPORT_KEYS entry, no frontend change.
This commit is contained in:
Nathnael
2026-08-13 07:52:20 +00:00
parent 08804c84e9
commit b7583df426
19 changed files with 976 additions and 1056 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
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[],
): 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(def.columns.map((c) => c.label));
headerRow.font = { bold: true };
for (const row of rows) {
sheet.addRow(def.columns.map((c) => row[c.key] ?? null));
}
def.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[],
): Promise<Buffer> {
const html = this.buildHtml(def, rows, kpis);
return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true });
}
private buildHtml(
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
): string {
const esc = (v: unknown) =>
String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kpiHtml = kpis.length
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
.map(
(k) =>
`<div><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
)
.join('')}</div>`
: '';
const head = def.columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const body = rows
.map(
(row) =>
`<tr>${def.columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
)
.join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
h1 { font-size: 16px; margin-bottom: 4px; }
p.desc { color: #666; margin-top: 0 0 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
th { background: #f3f3f3; }
</style></head><body>
<h1>${esc(def.title)}</h1>
<p class="desc">${esc(def.description)}</p>
${kpiHtml}
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
</body></html>`;
}
}

View File

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

View File

@@ -0,0 +1,141 @@
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;
}
/** 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 ?? 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 ?? 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);
const sort = resolveSort(def, undefined, undefined);
if (sort) qb.orderBy(sort.expr, sort.dir);
const items = await qb.limit(limit).getRawMany();
if (items.length >= limit) {
throw new BadRequestException(
`Export exceeds the ${limit}-row cap for this format. Narrow the filters.`,
);
}
const kpis = def.summary ? await def.summary(ctx) : [];
return { columns: def.columns, items, kpis };
}
}
// Re-exported so definitions can scope ACL columns without importing the
// trade-scope module directly.
export { applyBookingRefDirectionScope };

View File

@@ -0,0 +1,41 @@
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);
}
});
});

View File

@@ -0,0 +1,24 @@
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 { 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,
];
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
export function getReport(key: string): ReportDefinition | undefined {
return BY_KEY.get(key as ReportKey);
}

View File

@@ -0,0 +1,95 @@
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;
}
/**
* 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[]>;
}
/** Catalog shape served by GET /reports — metadata only, no rows. */
export type ReportCatalogEntry = Omit<ReportDefinition, 'query' | 'summary'> & {
hasSummary: boolean;
};
export interface ReportRunResult {
columns: ReportColumn[];
items: Record<string, unknown>[];
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
kpis: ReportKpi[];
}

View File

@@ -1,34 +1,90 @@
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 { PDF_ROW_CAP, ReportExportService, XLSX_ROW_CAP } from './report-export.service';
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 },
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = query.format === 'pdf' ? 'pdf' : 'xlsx';
const cap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
const buffer =
format === 'pdf'
? await this.exportService.toPdf(def, items, kpis)
: await this.exportService.toXlsx(def, items, kpis);
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader(
'Content-Type',
format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.send(buffer);
}
private resolve(key: string, user: TCurrentUser): ReportDefinition {
const def = getReport(key);
if (!def) throw new NotFoundException(`Unknown report: ${key}`);
// Exact-match on purpose — unlike FreightPermissionGuard's :view/:read
// fallback, a report's own key is the only thing that opens it.
assertFreightPermission(user, reportPermissionKey(def.key));
return def;
}
}

View File

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

View File

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

View File

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