diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index f3ba897a0..e10e5dcb7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index 447bc2516..e8c3de792 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -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" }, }); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts new file mode 100644 index 000000000..d5709508c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts new file mode 100644 index 000000000..ca476cea3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts @@ -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, +): SelectQueryBuilder { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts new file mode 100644 index 000000000..03f98d306 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -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 { + 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: '%' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts new file mode 100644 index 000000000..31b9951b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts deleted file mode 100644 index 82b1a76ef..000000000 --- a/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts deleted file mode 100644 index cc1c215af..000000000 --- a/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/edr-freight-api/src/modules/reports/report-export.service.ts b/apps/edr-freight-api/src/modules/reports/report-export.service.ts new file mode 100644 index 000000000..f07317134 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-export.service.ts @@ -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> = { + 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[], + kpis: ReportKpi[], + ): Promise { + 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[], + kpis: ReportKpi[], + ): Promise { + const html = this.buildHtml(def, rows, kpis); + return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true }); + } + + private buildHtml( + def: ReportDefinition, + rows: Record[], + kpis: ReportKpi[], + ): string { + const esc = (v: unknown) => + String(v ?? '').replace(/&/g, '&').replace(//g, '>'); + + const kpiHtml = kpis.length + ? `
${kpis + .map( + (k) => + `
${esc(k.label)}
${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}
`, + ) + .join('')}
` + : ''; + + const head = def.columns.map((c) => `${esc(c.label)}`).join(''); + const body = rows + .map( + (row) => + `${def.columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, + ) + .join(''); + + return ` +

${esc(def.title)}

+

${esc(def.description)}

+ ${kpiHtml} + ${head}${body}
+ `; + } +} diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts deleted file mode 100644 index 9e4a6f617..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-queries.ts +++ /dev/null @@ -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[]; -} - -type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise; - -// 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[], 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) => 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) => - 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) => 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) => - ['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) => 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) => 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) => 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) => 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) => 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 = { - '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, -}; diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts new file mode 100644 index 000000000..5df9ba2e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -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; + +/** + * 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 { + const params: Record = {}; + 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 { + 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[]; 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 }; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts new file mode 100644 index 000000000..84bbfb27a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts @@ -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); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts new file mode 100644 index 000000000..26474d025 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -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(REPORTS.map((r) => [r.key, r])); + +export function getReport(key: string): ReportDefinition | undefined { + return BY_KEY.get(key as ReportKey); +} diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts new file mode 100644 index 000000000..85b73040c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -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; + /** 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; + /** KPIs over the same filtered set; shown above the table and in exports. */ + summary?(ctx: ReportContext): Promise; +} + +/** Catalog shape served by GET /reports — metadata only, no rows. */ +export type ReportCatalogEntry = Omit & { + hasSummary: boolean; +}; + +export interface ReportRunResult { + columns: ReportColumn[]; + items: Record[]; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + kpis: ReportKpi[]; +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index dc64773d8..49d0307eb 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -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 { + 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 { - 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 { + 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; } } diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts index a7fe792a5..2f98e9e04 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.module.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/reports/reports.repository.ts b/apps/edr-freight-api/src/modules/reports/reports.repository.ts deleted file mode 100644 index 65f154b22..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.repository.ts +++ /dev/null @@ -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 { - return REPORT_QUERIES[key](this.dataSource, filters); - } -} diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts deleted file mode 100644 index 04e6e9a60..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.service.ts +++ /dev/null @@ -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 { - 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); - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0a9a550e..8e8768819 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: dotenv-cli: specifier: ^11.0.0 version: 11.0.0 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 handlebars: specifier: ^4.7.9 version: 4.7.9 @@ -601,7 +604,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -13056,11 +13059,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -13095,7 +13098,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -13104,14 +13107,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13126,9 +13122,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -13143,13 +13139,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13302,18 +13298,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -13798,7 +13782,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -13964,7 +13948,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -14124,7 +14108,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -15723,7 +15707,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -17797,7 +17781,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -18084,130 +18068,6 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': - dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) - '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) - '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) - '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf/renderer': 4.5.1(react@19.2.6) - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) - '@tabler/icons-react': 3.44.0(react@19.2.6) - '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@tanstack/react-query': 5.101.0(react@19.2.6) - '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) - '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) - '@types/dompurify': 3.2.0 - '@types/node': 24.13.1 - '@types/tinymce': 4.6.9 - axios: 1.17.0 - class-variance-authority: 0.7.1 - clsx: 2.1.1 - cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - date-fns: 3.6.0 - dayjs: 1.11.21 - dompurify: 3.4.8 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-calendar-new: 1.1.0 - file-type: 18.7.0 - framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - html2canvas: 1.4.1 - i18next: 25.10.10(typescript@5.9.3) - i18next-browser-languagedetector: 8.2.1 - jquery: 3.7.1 - js-cookie: 3.0.8 - jspdf: 3.0.4 - lodash: 4.18.1 - lucide-react: 0.513.0(react@19.2.6) - mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - path: 0.12.7 - pdf-lib: 1.17.1 - qs: 6.15.2 - react: 19.2.6 - react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) - react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-dropzone: 14.4.1(react@19.2.6) - react-hook-form: 7.77.0(react@19.2.6) - react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - react-icons: 5.6.0(react@19.2.6) - react-image-crop: 11.0.10(react@19.2.6) - react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) - react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) - rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) - socket.io-client: 4.8.3 - sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: 3.6.0 - tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) - tinymce: 7.9.3 - url: 0.11.4 - vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - xlsx: 0.18.5 - zod: 3.25.76 - transitivePeerDependencies: - - '@babel/core' - - '@emotion/is-prop-valid' - - '@mui/icons-material' - - '@mui/material' - - '@mui/x-date-pickers' - - '@types/prop-types' - - '@types/react' - - '@types/react-dom' - - bufferutil - - debug - - pdfjs-dist - - prop-types - - react-is - - react-native - - redux - - rolldown - - rollup - - supports-color - - typescript - - utf-8-validate - - vite - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -18605,7 +18465,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -18615,7 +18475,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18634,7 +18494,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -18649,7 +18509,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -18938,7 +18798,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -19447,16 +19307,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): - dependencies: - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - picomatch: 4.0.4 - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - supports-color - babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -19612,7 +19462,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -20661,7 +20511,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -20681,7 +20531,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -20912,7 +20762,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -21040,7 +20890,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -21277,7 +21127,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -21330,7 +21180,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -21485,7 +21335,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -21733,7 +21583,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22042,7 +21892,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22055,14 +21905,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22502,7 +22352,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -23162,7 +23012,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -23849,7 +23699,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -24375,7 +24225,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -24725,7 +24575,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -24754,7 +24604,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -25007,15 +24857,6 @@ snapshots: - '@babel/core' - react-is - react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - react-is - react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -25626,7 +25467,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -25748,7 +25589,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -25964,7 +25805,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -25974,7 +25815,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -25985,7 +25826,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -25994,7 +25835,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -26006,7 +25847,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -26301,24 +26142,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@emotion/is-prop-valid': 1.4.0 - '@emotion/stylis': 0.8.5 - '@emotion/unitless': 0.7.5 - babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) - css-to-react-native: 3.2.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 19.2.7 - shallowequal: 1.1.0 - supports-color: 5.5.0 - transitivePeerDependencies: - - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -26344,7 +26167,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -26857,7 +26680,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -26881,7 +26704,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -27242,7 +27065,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27260,7 +27083,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27307,7 +27130,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -27343,7 +27166,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2