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; columns_config: any[]; grouping_options: string[]; totals_config: Record; 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; 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 | null; output_files: any[]; error_message: string | null; error_details: Record | 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; 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; 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; columns_config?: any[]; export_formats?: string[]; required_permissions?: string[]; } export interface ExecuteReportDto { definition_id: string; parameters: Record; } 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( `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 { const definition = await queryOne( `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 { return queryOne( `SELECT * FROM reports.report_definitions WHERE code = $1 AND tenant_id = $2`, [code, tenantId] ); } async createDefinition( dto: CreateReportDefinitionDto, tenantId: string, userId: string ): Promise { const definition = await queryOne( `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 { 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( `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, tenantId: string ): Promise { 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, 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, 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): Record { if (!totalsConfig.show_totals || !totalsConfig.total_columns) { return {}; } const summary: Record = {}; 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, schema: Record): 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 { const execution = await queryOne( `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 { 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(sql, params); } // ==================== SCHEDULES ==================== async findAllSchedules(tenantId: string): Promise { return query( `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; company_id?: string; timezone?: string; delivery_method?: DeliveryMethod; delivery_config?: Record; }, tenantId: string, userId: string ): Promise { // Verificar que la definición existe await this.findDefinitionById(data.definition_id, tenantId); const schedule = await queryOne( `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 { const schedule = await queryOne( `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 { 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 { 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 { return query( `SELECT * FROM reports.generate_general_ledger($1, $2, $3, $4, $5)`, [tenantId, companyId, accountId, dateFrom, dateTo] ); } } export const reportsService = new ReportsService();