581 lines
17 KiB
TypeScript
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();
|