erp-core-backend-v2/src/modules/reports/reports.service.ts

581 lines
17 KiB
TypeScript

import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export type ReportType = 'financial' | 'accounting' | 'tax' | 'management' | 'custom';
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
export type DeliveryMethod = 'none' | 'email' | 'storage' | 'webhook';
export interface ReportDefinition {
id: string;
tenant_id: string;
code: string;
name: string;
description: string | null;
report_type: ReportType;
category: string | null;
base_query: string | null;
query_function: string | null;
parameters_schema: Record<string, any>;
columns_config: any[];
grouping_options: string[];
totals_config: Record<string, any>;
export_formats: string[];
pdf_template: string | null;
xlsx_template: string | null;
is_system: boolean;
is_active: boolean;
required_permissions: string[];
version: number;
created_at: Date;
}
export interface ReportExecution {
id: string;
tenant_id: string;
definition_id: string;
definition_name?: string;
definition_code?: string;
parameters: Record<string, any>;
status: ExecutionStatus;
started_at: Date | null;
completed_at: Date | null;
execution_time_ms: number | null;
row_count: number | null;
result_data: any;
result_summary: Record<string, any> | null;
output_files: any[];
error_message: string | null;
error_details: Record<string, any> | null;
requested_by: string;
requested_by_name?: string;
created_at: Date;
}
export interface ReportSchedule {
id: string;
tenant_id: string;
definition_id: string;
definition_name?: string;
company_id: string | null;
name: string;
default_parameters: Record<string, any>;
cron_expression: string;
timezone: string;
is_active: boolean;
last_execution_id: string | null;
last_run_at: Date | null;
next_run_at: Date | null;
delivery_method: DeliveryMethod;
delivery_config: Record<string, any>;
created_at: Date;
}
export interface CreateReportDefinitionDto {
code: string;
name: string;
description?: string;
report_type?: ReportType;
category?: string;
base_query?: string;
query_function?: string;
parameters_schema?: Record<string, any>;
columns_config?: any[];
export_formats?: string[];
required_permissions?: string[];
}
export interface ExecuteReportDto {
definition_id: string;
parameters: Record<string, any>;
}
export interface ReportFilters {
report_type?: ReportType;
category?: string;
is_system?: boolean;
search?: string;
page?: number;
limit?: number;
}
// ============================================================================
// SERVICE
// ============================================================================
class ReportsService {
// ==================== DEFINITIONS ====================
async findAllDefinitions(
tenantId: string,
filters: ReportFilters = {}
): Promise<{ data: ReportDefinition[]; total: number }> {
const { report_type, category, is_system, search, page = 1, limit = 20 } = filters;
const conditions: string[] = ['tenant_id = $1', 'is_active = true'];
const params: any[] = [tenantId];
let idx = 2;
if (report_type) {
conditions.push(`report_type = $${idx++}`);
params.push(report_type);
}
if (category) {
conditions.push(`category = $${idx++}`);
params.push(category);
}
if (is_system !== undefined) {
conditions.push(`is_system = $${idx++}`);
params.push(is_system);
}
if (search) {
conditions.push(`(name ILIKE $${idx} OR code ILIKE $${idx} OR description ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const whereClause = conditions.join(' AND ');
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM reports.report_definitions WHERE ${whereClause}`,
params
);
const offset = (page - 1) * limit;
params.push(limit, offset);
const data = await query<ReportDefinition>(
`SELECT * FROM reports.report_definitions
WHERE ${whereClause}
ORDER BY is_system DESC, name ASC
LIMIT $${idx} OFFSET $${idx + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
async findDefinitionById(id: string, tenantId: string): Promise<ReportDefinition> {
const definition = await queryOne<ReportDefinition>(
`SELECT * FROM reports.report_definitions WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!definition) {
throw new NotFoundError('Definición de reporte no encontrada');
}
return definition;
}
async findDefinitionByCode(code: string, tenantId: string): Promise<ReportDefinition | null> {
return queryOne<ReportDefinition>(
`SELECT * FROM reports.report_definitions WHERE code = $1 AND tenant_id = $2`,
[code, tenantId]
);
}
async createDefinition(
dto: CreateReportDefinitionDto,
tenantId: string,
userId: string
): Promise<ReportDefinition> {
const definition = await queryOne<ReportDefinition>(
`INSERT INTO reports.report_definitions (
tenant_id, code, name, description, report_type, category,
base_query, query_function, parameters_schema, columns_config,
export_formats, required_permissions, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
tenantId,
dto.code,
dto.name,
dto.description || null,
dto.report_type || 'custom',
dto.category || null,
dto.base_query || null,
dto.query_function || null,
JSON.stringify(dto.parameters_schema || {}),
JSON.stringify(dto.columns_config || []),
JSON.stringify(dto.export_formats || ['pdf', 'xlsx', 'csv']),
JSON.stringify(dto.required_permissions || []),
userId,
]
);
logger.info('Report definition created', { definitionId: definition?.id, code: dto.code });
return definition!;
}
// ==================== EXECUTIONS ====================
async executeReport(
dto: ExecuteReportDto,
tenantId: string,
userId: string
): Promise<ReportExecution> {
const definition = await this.findDefinitionById(dto.definition_id, tenantId);
// Validar parámetros contra el schema
this.validateParameters(dto.parameters, definition.parameters_schema);
// Crear registro de ejecución
const execution = await queryOne<ReportExecution>(
`INSERT INTO reports.report_executions (
tenant_id, definition_id, parameters, status, requested_by
) VALUES ($1, $2, $3, 'pending', $4)
RETURNING *`,
[tenantId, dto.definition_id, JSON.stringify(dto.parameters), userId]
);
// Ejecutar el reporte de forma asíncrona
this.runReportExecution(execution!.id, definition, dto.parameters, tenantId)
.catch(err => logger.error('Report execution failed', { executionId: execution!.id, error: err }));
return execution!;
}
private async runReportExecution(
executionId: string,
definition: ReportDefinition,
parameters: Record<string, any>,
tenantId: string
): Promise<void> {
const startTime = Date.now();
try {
// Marcar como ejecutando
await query(
`UPDATE reports.report_executions SET status = 'running', started_at = NOW() WHERE id = $1`,
[executionId]
);
let resultData: any;
let rowCount = 0;
if (definition.query_function) {
// Ejecutar función PostgreSQL
const funcParams = this.buildFunctionParams(definition.query_function, parameters, tenantId);
resultData = await query(
`SELECT * FROM ${definition.query_function}(${funcParams.placeholders})`,
funcParams.values
);
rowCount = resultData.length;
} else if (definition.base_query) {
// Ejecutar query base con parámetros sustituidos
// IMPORTANTE: Sanitizar los parámetros para evitar SQL injection
const sanitizedQuery = this.buildSafeQuery(definition.base_query, parameters, tenantId);
resultData = await query(sanitizedQuery.sql, sanitizedQuery.values);
rowCount = resultData.length;
} else {
throw new Error('La definición del reporte no tiene query ni función definida');
}
const executionTime = Date.now() - startTime;
// Calcular resumen si hay config de totales
const resultSummary = this.calculateSummary(resultData, definition.totals_config);
// Actualizar con resultados
await query(
`UPDATE reports.report_executions
SET status = 'completed',
completed_at = NOW(),
execution_time_ms = $2,
row_count = $3,
result_data = $4,
result_summary = $5
WHERE id = $1`,
[executionId, executionTime, rowCount, JSON.stringify(resultData), JSON.stringify(resultSummary)]
);
logger.info('Report execution completed', { executionId, rowCount, executionTime });
} catch (error: any) {
const executionTime = Date.now() - startTime;
await query(
`UPDATE reports.report_executions
SET status = 'failed',
completed_at = NOW(),
execution_time_ms = $2,
error_message = $3,
error_details = $4
WHERE id = $1`,
[
executionId,
executionTime,
error.message,
JSON.stringify({ stack: error.stack }),
]
);
logger.error('Report execution failed', { executionId, error: error.message });
}
}
private buildFunctionParams(
functionName: string,
parameters: Record<string, any>,
tenantId: string
): { placeholders: string; values: any[] } {
// Construir parámetros para funciones conocidas
const values: any[] = [tenantId];
let idx = 2;
if (functionName.includes('trial_balance')) {
values.push(
parameters.company_id || null,
parameters.date_from,
parameters.date_to,
parameters.include_zero || false
);
return { placeholders: '$1, $2, $3, $4, $5', values };
}
if (functionName.includes('general_ledger')) {
values.push(
parameters.company_id || null,
parameters.account_id,
parameters.date_from,
parameters.date_to
);
return { placeholders: '$1, $2, $3, $4, $5', values };
}
// Default: solo tenant_id
return { placeholders: '$1', values };
}
private buildSafeQuery(
baseQuery: string,
parameters: Record<string, any>,
tenantId: string
): { sql: string; values: any[] } {
// Reemplazar placeholders de forma segura
let sql = baseQuery;
const values: any[] = [tenantId];
let idx = 2;
// Reemplazar {{tenant_id}} con $1
sql = sql.replace(/\{\{tenant_id\}\}/g, '$1');
// Reemplazar otros parámetros
for (const [key, value] of Object.entries(parameters)) {
const placeholder = `{{${key}}}`;
if (sql.includes(placeholder)) {
sql = sql.replace(new RegExp(placeholder, 'g'), `$${idx}`);
values.push(value);
idx++;
}
}
return { sql, values };
}
private calculateSummary(data: any[], totalsConfig: Record<string, any>): Record<string, any> {
if (!totalsConfig.show_totals || !totalsConfig.total_columns) {
return {};
}
const summary: Record<string, number> = {};
for (const column of totalsConfig.total_columns) {
summary[column] = data.reduce((sum, row) => sum + (parseFloat(row[column]) || 0), 0);
}
return summary;
}
private validateParameters(params: Record<string, any>, schema: Record<string, any>): void {
for (const [key, config] of Object.entries(schema)) {
const paramConfig = config as { required?: boolean; type?: string };
if (paramConfig.required && (params[key] === undefined || params[key] === null)) {
throw new ValidationError(`Parámetro requerido: ${key}`);
}
}
}
async findExecutionById(id: string, tenantId: string): Promise<ReportExecution> {
const execution = await queryOne<ReportExecution>(
`SELECT re.*,
rd.name as definition_name,
rd.code as definition_code,
u.full_name as requested_by_name
FROM reports.report_executions re
JOIN reports.report_definitions rd ON re.definition_id = rd.id
JOIN auth.users u ON re.requested_by = u.id
WHERE re.id = $1 AND re.tenant_id = $2`,
[id, tenantId]
);
if (!execution) {
throw new NotFoundError('Ejecución de reporte no encontrada');
}
return execution;
}
async findRecentExecutions(
tenantId: string,
definitionId?: string,
limit: number = 20
): Promise<ReportExecution[]> {
let sql = `
SELECT re.*,
rd.name as definition_name,
rd.code as definition_code,
u.full_name as requested_by_name
FROM reports.report_executions re
JOIN reports.report_definitions rd ON re.definition_id = rd.id
JOIN auth.users u ON re.requested_by = u.id
WHERE re.tenant_id = $1
`;
const params: any[] = [tenantId];
if (definitionId) {
sql += ` AND re.definition_id = $2`;
params.push(definitionId);
}
sql += ` ORDER BY re.created_at DESC LIMIT $${params.length + 1}`;
params.push(limit);
return query<ReportExecution>(sql, params);
}
// ==================== SCHEDULES ====================
async findAllSchedules(tenantId: string): Promise<ReportSchedule[]> {
return query<ReportSchedule>(
`SELECT rs.*,
rd.name as definition_name
FROM reports.report_schedules rs
JOIN reports.report_definitions rd ON rs.definition_id = rd.id
WHERE rs.tenant_id = $1
ORDER BY rs.name`,
[tenantId]
);
}
async createSchedule(
data: {
definition_id: string;
name: string;
cron_expression: string;
default_parameters?: Record<string, any>;
company_id?: string;
timezone?: string;
delivery_method?: DeliveryMethod;
delivery_config?: Record<string, any>;
},
tenantId: string,
userId: string
): Promise<ReportSchedule> {
// Verificar que la definición existe
await this.findDefinitionById(data.definition_id, tenantId);
const schedule = await queryOne<ReportSchedule>(
`INSERT INTO reports.report_schedules (
tenant_id, definition_id, name, cron_expression,
default_parameters, company_id, timezone,
delivery_method, delivery_config, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
tenantId,
data.definition_id,
data.name,
data.cron_expression,
JSON.stringify(data.default_parameters || {}),
data.company_id || null,
data.timezone || 'America/Mexico_City',
data.delivery_method || 'none',
JSON.stringify(data.delivery_config || {}),
userId,
]
);
logger.info('Report schedule created', { scheduleId: schedule?.id, name: data.name });
return schedule!;
}
async toggleSchedule(id: string, tenantId: string, isActive: boolean): Promise<ReportSchedule> {
const schedule = await queryOne<ReportSchedule>(
`UPDATE reports.report_schedules
SET is_active = $3, updated_at = NOW()
WHERE id = $1 AND tenant_id = $2
RETURNING *`,
[id, tenantId, isActive]
);
if (!schedule) {
throw new NotFoundError('Programación no encontrada');
}
return schedule;
}
async deleteSchedule(id: string, tenantId: string): Promise<void> {
const result = await query(
`DELETE FROM reports.report_schedules WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
// Check if any row was deleted
if (!result || result.length === 0) {
// Try to verify it existed
const exists = await queryOne<{ id: string }>(
`SELECT id FROM reports.report_schedules WHERE id = $1`,
[id]
);
if (!exists) {
throw new NotFoundError('Programación no encontrada');
}
}
}
// ==================== QUICK REPORTS ====================
async generateTrialBalance(
tenantId: string,
companyId: string | null,
dateFrom: string,
dateTo: string,
includeZero: boolean = false
): Promise<any[]> {
return query(
`SELECT * FROM reports.generate_trial_balance($1, $2, $3, $4, $5)`,
[tenantId, companyId, dateFrom, dateTo, includeZero]
);
}
async generateGeneralLedger(
tenantId: string,
companyId: string | null,
accountId: string,
dateFrom: string,
dateTo: string
): Promise<any[]> {
return query(
`SELECT * FROM reports.generate_general_ledger($1, $2, $3, $4, $5)`,
[tenantId, companyId, accountId, dateFrom, dateTo]
);
}
}
export const reportsService = new ReportsService();