add reports module with controller, service, and repository

This commit is contained in:
Marshal
2026-08-03 21:43:23 +00:00
parent e68bdb7a1a
commit c3979b08b6
26 changed files with 1771 additions and 136 deletions

View File

@@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
export class OverviewBookingKpisDto {
@ApiProperty() total!: number;
@ApiProperty() totalActive!: number;
@ApiProperty() needsAction!: number;
@ApiProperty() urgent!: number;
@@ -9,6 +10,7 @@ export class OverviewBookingKpisDto {
}
export class OverviewContractKpisDto {
@ApiProperty() total!: number;
@ApiProperty() totalActive!: number;
@ApiProperty() needsAction!: number;
@ApiProperty() inApproval!: number;

View File

@@ -39,6 +39,7 @@ const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
export type OverviewBookingKpisRow = {
total: number;
totalActive: number;
needsAction: number;
urgent: number;
@@ -58,6 +59,7 @@ export type OverviewRecentBookingRow = {
};
export type OverviewContractKpisRow = {
total: number;
totalActive: number;
needsAction: number;
inApproval: number;
@@ -108,7 +110,8 @@ export class OverviewRepository {
const scope = directionScopeSql("booking.trade_direction", dirs);
const row = await this.bookingRepository
.createQueryBuilder("booking")
.select(
.select("COUNT(*)::int", "total")
.addSelect(
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
"totalActive",
)
@@ -140,6 +143,7 @@ export class OverviewRepository {
.getRawOne<Record<string, string>>();
return {
total: Number(row?.total ?? 0),
totalActive: Number(row?.totalActive ?? 0),
needsAction: Number(row?.needsAction ?? 0),
urgent: Number(row?.urgent ?? 0),
@@ -845,7 +849,8 @@ export class OverviewRepository {
const scope = directionScopeSql("contract.trade_direction", dirs);
const row = await this.contractRepository
.createQueryBuilder("contract")
.select(
.select("COUNT(*)::int", "total")
.addSelect(
`COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`,
"totalActive",
)
@@ -876,6 +881,7 @@ export class OverviewRepository {
.getRawOne<Record<string, string>>();
return {
total: Number(row?.total ?? 0),
totalActive: Number(row?.totalActive ?? 0),
needsAction: Number(row?.needsAction ?? 0),
inApproval: Number(row?.inApproval ?? 0),

View File

@@ -0,0 +1,54 @@
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

@@ -0,0 +1,24 @@
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,450 @@
import { DataSource } from 'typeorm';
export interface ReportFilters {
/** ISO timestamp, inclusive lower bound. */
dateFrom: string;
/** ISO timestamp, exclusive upper bound. */
dateTo: string;
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 b.created_at >= $1::timestamptz AND b.created_at < $2::timestamptz
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 < $2::timestamptz
AND (ct.contract_valid_until IS NULL OR ct.contract_valid_until >= $1::timestamptz)
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 ts.scheduled_departure_date >= $1::timestamptz
AND ts.scheduled_departure_date < $2::timestamptz
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 ts.scheduled_departure_date >= $1::timestamptz
AND ts.scheduled_departure_date < $2::timestamptz
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 ts.scheduled_departure_date >= $1::timestamptz
AND ts.scheduled_departure_date < $2::timestamptz
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 COALESCE(i.issued_at, i.created_at) >= $1::timestamptz
AND COALESCE(i.issued_at, i.created_at) < $2::timestamptz
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, 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 >= $1::timestamptz), 0))::float8 AS current,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz
AND i.due_at >= $1::timestamptz - interval '30 days'), 0))::float8 AS overdue_0_30,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '30 days'
AND i.due_at >= $1::timestamptz - interval '60 days'), 0))::float8 AS overdue_31_60,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '60 days'
AND i.due_at >= $1::timestamptz - interval '90 days'), 0))::float8 AS overdue_61_90,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - 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 i.created_at < $1::timestamptz
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 p.created_at >= $1::timestamptz AND p.created_at < $2::timestamptz
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,
};
};
export const REPORT_QUERIES: Record<string, ReportQuery> = {
'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,33 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, 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 { BookingView } from '../../common/booking-guards';
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';
@ApiTags('Reports')
@ApiBearerAuth()
@Controller('reports')
export class ReportsController {
constructor(
private readonly reportsService: ReportsService,
private readonly userTradeAccessService: UserTradeAccessService,
) {}
@Get(':key')
@BookingView()
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
@ApiOkResponse({ type: ReportResultDto })
async run(
@Param('key') key: string,
@Query() query: ReportQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<ReportResultDto> {
const allowed = await this.userTradeAccessService.resolveAllowedDirections(user);
return this.reportsService.run(key, query, allowed);
}
}

View File

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

View File

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,45 @@
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}`);
}
const to = dto.dateTo ? new Date(dto.dateTo) : new Date();
const from = dto.dateFrom ? new Date(dto.dateFrom) : new Date(to.getTime() - 30 * DAY_MS);
const filters: ReportFilters = {
dateFrom: from.toISOString(),
// dateTo is inclusive in the API; queries treat the bound as exclusive.
dateTo: new Date(to.getTime() + DAY_MS).toISOString(),
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);
}
}