# SPEC-SCHEDULER-REPORTES ## Metadatos | Campo | Valor | |-------|-------| | **Código** | SPEC-TRANS-016 | | **Versión** | 1.0.0 | | **Fecha** | 2025-01-15 | | **Autor** | Requirements-Analyst Agent | | **Estado** | DRAFT | | **Prioridad** | P0 | | **Módulos Afectados** | MGN-012 (Reportería) | | **Gaps Cubiertos** | GAP-MGN-012-001 | ## Resumen Ejecutivo Esta especificación define el sistema de envío programado de reportes para ERP Core: 1. **Scheduled Actions**: Configuración de tareas programadas (cron jobs) 2. **Report Rendering**: Generación de reportes en PDF/Excel 3. **Email Delivery**: Envío automático con adjuntos 4. **Digest Emails**: KPIs y resúmenes periódicos 5. **Queue Processing**: Gestión de cola para envíos masivos 6. **Recipient Management**: Configuración flexible de destinatarios ### Referencia Odoo 18 Basado en análisis del sistema de reportes programados de Odoo 18: - **ir.cron**: Acciones programadas - **digest.digest**: Emails de resumen con KPIs - **mail.template**: Plantillas de email con reportes adjuntos - **ir.actions.report**: Renderizado de reportes --- ## Parte 1: Arquitectura del Sistema ### 1.1 Visión General ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ SISTEMA DE REPORTES PROGRAMADOS │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Usuario │───▶│ Configura │───▶│ Scheduled │ │ │ │ │ │ Schedule │ │ Report │ │ │ └──────────────┘ └──────────────┘ └──────┬───────┘ │ │ │ │ │ ┌──────────────┴──────────────┐ │ │ │ CRON JOB │ │ │ │ (ejecuta periódico) │ │ │ └──────────────┬──────────────┘ │ │ │ │ │ ┌────────────────┬───────────────────────┼──────────┐ │ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │ │ Render │ │ Create │ │ Queue │ │ Send │ │ │ │ Report │ │ Attachment │ │ Email │ │ Deliver │ │ │ │ (PDF/XLSX) │ │ │ │ │ │ │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └────────────┘ │ │ │ │ │ │ │ └────────────────┴────────────────────┘ │ │ │ │ │ ▼ │ │ ┌───────────────────────┐ │ │ │ Recipients │ │ │ │ (email inbox) │ │ │ └───────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 1.2 Flujo de Ejecución ``` 1. CONFIGURAR REPORTE PROGRAMADO ├─ Seleccionar reporte a enviar ├─ Definir frecuencia (diario, semanal, mensual) ├─ Configurar destinatarios └─ Activar schedule │ ▼ 2. CRON JOB EJECUTA ├─ Verifica next_run <= now() ├─ Obtiene datos para el reporte └─ Inicia proceso de generación │ ▼ 3. RENDERIZAR REPORTE ├─ Ejecutar query de datos ├─ Aplicar template (QWeb/Excel) └─ Generar archivo (PDF/XLSX) │ ▼ 4. CREAR EMAIL ├─ Aplicar template de email ├─ Adjuntar reporte generado └─ Encolar en mail_queue │ ▼ 5. ENVIAR ├─ Mail queue processor envía ├─ Registrar estado de entrega └─ Actualizar next_run del schedule ``` --- ## Parte 2: Modelo de Datos ### 2.1 Diagrama Entidad-Relación ``` ┌─────────────────────────┐ ┌─────────────────────────┐ │ scheduled_reports │ │ report_definitions │ │─────────────────────────│ │─────────────────────────│ │ id (PK) │ │ id (PK) │ │ name │ │ code │ │ report_definition_id │──────▶│ name │ │ frequency │ │ model_name │ │ interval │ │ report_type │ │ next_run │ │ template_path │ │ last_run │ │ query_method │ │ email_template_id │ └─────────────────────────┘ │ active │ └─────────┬───────────────┘ │ │ N:M ▼ ┌─────────────────────────┐ ┌─────────────────────────┐ │ scheduled_report_ │ │ mail_queue │ │ recipients │ │─────────────────────────│ │─────────────────────────│ │ id (PK) │ │ scheduled_report_id │ │ scheduled_report_id │ │ recipient_type │ │ state │ │ user_id / email │ │ recipient_email │ │ │ │ attachment_id │ └─────────────────────────┘ │ error_message │ │ sent_at │ ┌─────────────────────────┐ └─────────────────────────┘ │ digest_configs │ │─────────────────────────│ │ id (PK) │ │ name │ │ frequency │ │ kpi_definitions (JSON) │ │ recipients │ │ active │ └─────────────────────────┘ ``` ### 2.2 Definición de Tablas #### report_definitions (Definiciones de Reportes) ```sql -- Catálogo de reportes disponibles para programar CREATE TABLE report_definitions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación code VARCHAR(50) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, description TEXT, -- Configuración técnica model_name VARCHAR(100) NOT NULL, -- ej: 'sale.order' report_type report_type NOT NULL DEFAULT 'pdf', template_path VARCHAR(255), -- Ruta del template QWeb/Excel query_method VARCHAR(100), -- Método que obtiene los datos -- Parámetros del reporte default_params JSONB DEFAULT '{}', -- ej: {"date_from": "start_of_month", "date_to": "end_of_month"} -- Categoría category report_category NOT NULL DEFAULT 'general', -- Estado active BOOLEAN NOT NULL DEFAULT true, company_id UUID REFERENCES companies(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TYPE report_type AS ENUM ('pdf', 'xlsx', 'csv', 'html'); CREATE TYPE report_category AS ENUM ( 'sales', 'purchases', 'inventory', 'accounting', 'hr', 'projects', 'crm', 'general' ); -- Reportes predefinidos INSERT INTO report_definitions (code, name, model_name, report_type, category) VALUES ('RPT-SALES-DAILY', 'Resumen Ventas Diario', 'sale.order', 'pdf', 'sales'), ('RPT-SALES-WEEKLY', 'Ventas Semanal', 'sale.order', 'xlsx', 'sales'), ('RPT-INV-STOCK', 'Niveles de Stock', 'stock.quant', 'xlsx', 'inventory'), ('RPT-ACC-AGING', 'Antigüedad de Saldos', 'account.move', 'pdf', 'accounting'), ('RPT-HR-ATTENDANCE', 'Asistencia Semanal', 'hr.attendance', 'xlsx', 'hr'); ``` #### scheduled_reports (Reportes Programados) ```sql -- Configuración de envíos programados CREATE TABLE scheduled_reports ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(255) NOT NULL, -- Reporte a generar report_definition_id UUID NOT NULL REFERENCES report_definitions(id), -- Parámetros específicos (override de defaults) report_params JSONB DEFAULT '{}', -- Programación frequency schedule_frequency NOT NULL DEFAULT 'daily', interval_value INTEGER NOT NULL DEFAULT 1, -- ej: frequency=weekly, interval=2 → cada 2 semanas -- Días específicos (para weekly) weekdays SMALLINT DEFAULT 0, -- Bitmask: Lun=2, Mar=4... -- Día del mes (para monthly) day_of_month INTEGER CHECK (day_of_month BETWEEN 1 AND 28), -- Hora de envío send_time TIME NOT NULL DEFAULT '08:00:00', timezone VARCHAR(50) NOT NULL DEFAULT 'America/Mexico_City', -- Control de ejecución next_run TIMESTAMPTZ NOT NULL, last_run TIMESTAMPTZ, last_status schedule_status DEFAULT 'pending', last_error TEXT, -- Email email_template_id UUID REFERENCES email_templates(id), email_subject VARCHAR(255), email_body TEXT, -- Estado active BOOLEAN NOT NULL DEFAULT true, company_id UUID NOT NULL REFERENCES companies(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL REFERENCES users(id), updated_by UUID NOT NULL REFERENCES users(id) ); CREATE TYPE schedule_frequency AS ENUM ('daily', 'weekly', 'monthly', 'quarterly', 'yearly'); CREATE TYPE schedule_status AS ENUM ('pending', 'running', 'success', 'failed'); CREATE INDEX idx_scheduled_reports_next_run ON scheduled_reports(next_run) WHERE active = true; CREATE INDEX idx_scheduled_reports_company ON scheduled_reports(company_id); ``` #### scheduled_report_recipients (Destinatarios) ```sql -- Destinatarios de reportes programados CREATE TABLE scheduled_report_recipients ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), scheduled_report_id UUID NOT NULL REFERENCES scheduled_reports(id) ON DELETE CASCADE, -- Tipo de destinatario recipient_type recipient_type NOT NULL, -- Para type = 'user' user_id UUID REFERENCES users(id), -- Para type = 'email' email VARCHAR(255), -- Para type = 'role' role_id UUID REFERENCES roles(id), -- Para type = 'dynamic' dynamic_expression TEXT, -- ej: "record.user_id.email" -- Configuración send_empty_report BOOLEAN NOT NULL DEFAULT false, active BOOLEAN NOT NULL DEFAULT true, CONSTRAINT valid_recipient CHECK ( (recipient_type = 'user' AND user_id IS NOT NULL) OR (recipient_type = 'email' AND email IS NOT NULL) OR (recipient_type = 'role' AND role_id IS NOT NULL) OR (recipient_type = 'dynamic' AND dynamic_expression IS NOT NULL) ) ); CREATE TYPE recipient_type AS ENUM ('user', 'email', 'role', 'dynamic'); CREATE INDEX idx_report_recipients_schedule ON scheduled_report_recipients(scheduled_report_id); ``` #### report_execution_log (Historial de Ejecuciones) ```sql -- Log de ejecuciones de reportes CREATE TABLE report_execution_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), scheduled_report_id UUID NOT NULL REFERENCES scheduled_reports(id), -- Ejecución started_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, status schedule_status NOT NULL DEFAULT 'running', -- Resultado records_processed INTEGER, attachment_id UUID REFERENCES attachments(id), file_size_bytes INTEGER, -- Envío recipients_count INTEGER DEFAULT 0, sent_count INTEGER DEFAULT 0, failed_count INTEGER DEFAULT 0, -- Errores error_message TEXT, error_stack TEXT ); CREATE INDEX idx_report_execution_schedule ON report_execution_log(scheduled_report_id); CREATE INDEX idx_report_execution_date ON report_execution_log(started_at); ``` #### digest_configs (Configuración de Digests) ```sql -- Configuración de emails de resumen (KPIs) CREATE TABLE digest_configs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, -- Frecuencia frequency schedule_frequency NOT NULL DEFAULT 'daily', next_run TIMESTAMPTZ NOT NULL, -- KPIs a incluir kpi_definitions JSONB NOT NULL DEFAULT '[]', -- [{"code": "sales_total", "label": "Ventas Totales", "query": "..."}] -- Plantilla de email email_template_id UUID REFERENCES email_templates(id), -- Estado active BOOLEAN NOT NULL DEFAULT true, company_id UUID NOT NULL REFERENCES companies(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Suscripciones a digest CREATE TABLE digest_subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), digest_config_id UUID NOT NULL REFERENCES digest_configs(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id), active BOOLEAN NOT NULL DEFAULT true, UNIQUE(digest_config_id, user_id) ); ``` --- ## Parte 3: Servicios de Aplicación ### 3.1 ScheduledReportService ```typescript // src/modules/reporting/services/scheduled-report.service.ts import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, LessThanOrEqual } from 'typeorm'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ScheduledReport, ScheduleStatus } from '../entities/scheduled-report.entity'; import { ReportExecutionLog } from '../entities/report-execution-log.entity'; @Injectable() export class ScheduledReportService { private readonly logger = new Logger(ScheduledReportService.name); constructor( @InjectRepository(ScheduledReport) private readonly scheduleRepo: Repository, @InjectRepository(ReportExecutionLog) private readonly executionLogRepo: Repository, private readonly dataSource: DataSource, private readonly reportRenderer: ReportRendererService, private readonly emailService: EmailService, ) {} /** * Crear nuevo reporte programado */ async createScheduledReport( dto: CreateScheduledReportDto, userId: string ): Promise { // Calcular próxima ejecución const nextRun = this.calculateNextRun(dto); const schedule = this.scheduleRepo.create({ name: dto.name, reportDefinitionId: dto.reportDefinitionId, reportParams: dto.reportParams || {}, frequency: dto.frequency, intervalValue: dto.intervalValue || 1, weekdays: dto.weekdays, dayOfMonth: dto.dayOfMonth, sendTime: dto.sendTime || '08:00:00', timezone: dto.timezone || 'America/Mexico_City', nextRun, emailSubject: dto.emailSubject, emailBody: dto.emailBody, companyId: dto.companyId, createdBy: userId, updatedBy: userId, }); const saved = await this.scheduleRepo.save(schedule); // Agregar destinatarios if (dto.recipients?.length > 0) { await this.addRecipients(saved.id, dto.recipients); } return this.findOneWithRelations(saved.id); } /** * Calcular próxima fecha de ejecución */ calculateNextRun(config: ScheduleConfig): Date { const now = new Date(); const [hours, minutes] = (config.sendTime || '08:00').split(':').map(Number); let nextRun = new Date(); nextRun.setHours(hours, minutes, 0, 0); // Si ya pasó la hora hoy, empezar mañana if (nextRun <= now) { nextRun.setDate(nextRun.getDate() + 1); } switch (config.frequency) { case 'daily': // Ya está configurado para mañana break; case 'weekly': // Encontrar próximo día habilitado if (config.weekdays) { while (!this.isDayEnabled(nextRun, config.weekdays)) { nextRun.setDate(nextRun.getDate() + 1); } } else { // Default: mismo día de la semana const daysUntilNext = (7 - now.getDay() + now.getDay()) % 7 || 7; nextRun.setDate(now.getDate() + daysUntilNext * config.intervalValue); } break; case 'monthly': const targetDay = config.dayOfMonth || 1; nextRun.setDate(targetDay); if (nextRun <= now) { nextRun.setMonth(nextRun.getMonth() + config.intervalValue); } break; case 'quarterly': // Primer día del próximo trimestre const currentQuarter = Math.floor(now.getMonth() / 3); const nextQuarter = currentQuarter + 1; nextRun.setMonth(nextQuarter * 3); nextRun.setDate(1); break; case 'yearly': nextRun.setMonth(0); nextRun.setDate(config.dayOfMonth || 1); if (nextRun <= now) { nextRun.setFullYear(nextRun.getFullYear() + 1); } break; } return nextRun; } /** * Job principal: procesar reportes programados */ @Cron(CronExpression.EVERY_5_MINUTES) async processScheduledReports(): Promise { const now = new Date(); // Buscar reportes pendientes const dueReports = await this.scheduleRepo.find({ where: { active: true, nextRun: LessThanOrEqual(now), }, relations: ['reportDefinition', 'recipients'], }); this.logger.log(`Procesando ${dueReports.length} reportes programados`); for (const schedule of dueReports) { await this.executeScheduledReport(schedule); } } /** * Ejecutar un reporte programado */ async executeScheduledReport(schedule: ScheduledReport): Promise { // Crear log de ejecución const execution = await this.executionLogRepo.save({ scheduledReportId: schedule.id, status: ScheduleStatus.RUNNING, }); try { // Marcar como en ejecución await this.scheduleRepo.update(schedule.id, { lastStatus: ScheduleStatus.RUNNING, }); // 1. Obtener datos del reporte const reportData = await this.getReportData(schedule); // Si no hay datos y no se envían vacíos, saltar if (!reportData.hasData && !this.shouldSendEmpty(schedule)) { await this.markExecutionComplete(execution.id, { status: ScheduleStatus.SUCCESS, recordsProcessed: 0, }); await this.scheduleNextRun(schedule); return; } // 2. Renderizar reporte const { content, filename, mimeType } = await this.reportRenderer.render({ definition: schedule.reportDefinition, data: reportData.data, params: { ...schedule.reportDefinition.defaultParams, ...schedule.reportParams }, }); // 3. Crear attachment const attachment = await this.createAttachment(content, filename, mimeType); // 4. Obtener destinatarios const recipients = await this.resolveRecipients(schedule.recipients); // 5. Enviar emails const sendResults = await this.sendReportEmails( schedule, attachment, recipients ); // 6. Actualizar log await this.markExecutionComplete(execution.id, { status: ScheduleStatus.SUCCESS, recordsProcessed: reportData.count, attachmentId: attachment.id, fileSizeBytes: content.length, recipientsCount: recipients.length, sentCount: sendResults.sent, failedCount: sendResults.failed, }); // 7. Programar próxima ejecución await this.scheduleNextRun(schedule); } catch (error) { this.logger.error(`Error ejecutando reporte ${schedule.id}:`, error); await this.markExecutionComplete(execution.id, { status: ScheduleStatus.FAILED, errorMessage: error.message, errorStack: error.stack, }); await this.scheduleRepo.update(schedule.id, { lastStatus: ScheduleStatus.FAILED, lastError: error.message, }); } } /** * Obtener datos para el reporte */ private async getReportData(schedule: ScheduledReport): Promise<{ data: any[]; count: number; hasData: boolean; }> { const definition = schedule.reportDefinition; const params = { ...definition.defaultParams, ...schedule.reportParams }; // Resolver parámetros dinámicos const resolvedParams = this.resolveParams(params); // Ejecutar query const service = this.getServiceForModel(definition.modelName); const queryMethod = definition.queryMethod || 'findForReport'; const data = await service[queryMethod](resolvedParams); return { data: Array.isArray(data) ? data : [data], count: Array.isArray(data) ? data.length : 1, hasData: Array.isArray(data) ? data.length > 0 : !!data, }; } /** * Resolver parámetros dinámicos */ private resolveParams(params: Record): Record { const resolved: Record = {}; const now = new Date(); for (const [key, value] of Object.entries(params)) { if (typeof value === 'string') { switch (value) { case 'start_of_day': resolved[key] = new Date(now.setHours(0, 0, 0, 0)); break; case 'end_of_day': resolved[key] = new Date(now.setHours(23, 59, 59, 999)); break; case 'start_of_week': const dayOfWeek = now.getDay(); resolved[key] = new Date(now.setDate(now.getDate() - dayOfWeek)); break; case 'start_of_month': resolved[key] = new Date(now.getFullYear(), now.getMonth(), 1); break; case 'end_of_month': resolved[key] = new Date(now.getFullYear(), now.getMonth() + 1, 0); break; case 'start_of_year': resolved[key] = new Date(now.getFullYear(), 0, 1); break; default: resolved[key] = value; } } else { resolved[key] = value; } } return resolved; } /** * Resolver destinatarios */ private async resolveRecipients( recipients: ScheduledReportRecipient[] ): Promise { const emails: Set = new Set(); for (const recipient of recipients) { if (!recipient.active) continue; switch (recipient.recipientType) { case 'user': const user = await this.userRepo.findOne({ where: { id: recipient.userId } }); if (user?.email) emails.add(user.email); break; case 'email': if (recipient.email) emails.add(recipient.email); break; case 'role': const usersWithRole = await this.userRepo.find({ where: { roles: { id: recipient.roleId } } }); usersWithRole.forEach(u => u.email && emails.add(u.email)); break; case 'dynamic': // Implementar evaluación de expresión dinámica break; } } return Array.from(emails); } /** * Enviar emails con el reporte */ private async sendReportEmails( schedule: ScheduledReport, attachment: Attachment, recipients: string[] ): Promise<{ sent: number; failed: number }> { let sent = 0; let failed = 0; for (const email of recipients) { try { await this.emailService.send({ to: email, subject: this.interpolateSubject(schedule), body: schedule.emailBody || 'Adjunto encontrará el reporte solicitado.', attachments: [attachment], }); sent++; } catch (error) { this.logger.error(`Error enviando a ${email}:`, error); failed++; } } return { sent, failed }; } /** * Programar próxima ejecución */ private async scheduleNextRun(schedule: ScheduledReport): Promise { const nextRun = this.calculateNextRun({ frequency: schedule.frequency, intervalValue: schedule.intervalValue, weekdays: schedule.weekdays, dayOfMonth: schedule.dayOfMonth, sendTime: schedule.sendTime, }); await this.scheduleRepo.update(schedule.id, { lastRun: new Date(), nextRun, lastStatus: ScheduleStatus.SUCCESS, lastError: null, }); } } ``` ### 3.2 ReportRendererService ```typescript // src/modules/reporting/services/report-renderer.service.ts import { Injectable } from '@nestjs/common'; import * as PDFDocument from 'pdfkit'; import * as ExcelJS from 'exceljs'; @Injectable() export class ReportRendererService { /** * Renderizar reporte según tipo */ async render(options: RenderOptions): Promise { const { definition, data, params } = options; switch (definition.reportType) { case 'pdf': return this.renderPDF(definition, data, params); case 'xlsx': return this.renderExcel(definition, data, params); case 'csv': return this.renderCSV(definition, data, params); default: throw new Error(`Tipo de reporte no soportado: ${definition.reportType}`); } } /** * Renderizar PDF */ private async renderPDF( definition: ReportDefinition, data: any[], params: Record ): Promise { const doc = new PDFDocument(); const chunks: Buffer[] = []; return new Promise((resolve, reject) => { doc.on('data', chunk => chunks.push(chunk)); doc.on('end', () => { const buffer = Buffer.concat(chunks); resolve({ content: buffer, filename: `${definition.code}_${this.formatDate(new Date())}.pdf`, mimeType: 'application/pdf', }); }); doc.on('error', reject); // Encabezado doc.fontSize(18).text(definition.name, { align: 'center' }); doc.moveDown(); doc.fontSize(10).text(`Generado: ${new Date().toLocaleString()}`, { align: 'right' }); doc.moveDown(2); // Contenido del reporte this.renderPDFContent(doc, definition, data, params); doc.end(); }); } /** * Renderizar Excel */ private async renderExcel( definition: ReportDefinition, data: any[], params: Record ): Promise { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet(definition.name); // Configurar columnas desde definición o inferir de datos if (data.length > 0) { const columns = Object.keys(data[0]).map(key => ({ header: this.formatHeader(key), key, width: 15, })); sheet.columns = columns; // Agregar datos data.forEach(row => sheet.addRow(row)); // Estilo de encabezados sheet.getRow(1).font = { bold: true }; sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' }, }; } const buffer = await workbook.xlsx.writeBuffer(); return { content: Buffer.from(buffer), filename: `${definition.code}_${this.formatDate(new Date())}.xlsx`, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }; } /** * Renderizar CSV */ private async renderCSV( definition: ReportDefinition, data: any[], params: Record ): Promise { if (data.length === 0) { return { content: Buffer.from(''), filename: `${definition.code}_${this.formatDate(new Date())}.csv`, mimeType: 'text/csv', }; } const headers = Object.keys(data[0]); const rows = [ headers.join(','), ...data.map(row => headers.map(h => this.escapeCSV(row[h])).join(',') ), ]; return { content: Buffer.from(rows.join('\n')), filename: `${definition.code}_${this.formatDate(new Date())}.csv`, mimeType: 'text/csv', }; } private escapeCSV(value: any): string { if (value == null) return ''; const str = String(value); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"`; } return str; } private formatDate(date: Date): string { return date.toISOString().split('T')[0]; } private formatHeader(key: string): string { return key .replace(/_/g, ' ') .replace(/([A-Z])/g, ' $1') .trim() .split(' ') .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join(' '); } } ``` ### 3.3 DigestService ```typescript // src/modules/reporting/services/digest.service.ts import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { DigestConfig } from '../entities/digest-config.entity'; @Injectable() export class DigestService { constructor( @InjectRepository(DigestConfig) private readonly digestRepo: Repository, private readonly emailService: EmailService, ) {} /** * Procesar digest emails */ @Cron(CronExpression.EVERY_DAY_AT_8AM) async processDigests(): Promise { const now = new Date(); const dueDigests = await this.digestRepo.find({ where: { active: true, nextRun: LessThanOrEqual(now), }, relations: ['subscriptions', 'subscriptions.user'], }); for (const digest of dueDigests) { await this.sendDigest(digest); } } /** * Enviar digest a suscriptores */ async sendDigest(digest: DigestConfig): Promise { // Calcular KPIs const kpis = await this.calculateKPIs(digest.kpiDefinitions); // Obtener suscriptores activos const subscribers = digest.subscriptions .filter(s => s.active) .map(s => s.user.email) .filter(Boolean); if (subscribers.length === 0) return; // Generar HTML del digest const html = this.generateDigestHTML(digest, kpis); // Enviar a cada suscriptor for (const email of subscribers) { await this.emailService.send({ to: email, subject: `${digest.name} - ${new Date().toLocaleDateString()}`, html, }); } // Programar próximo envío await this.scheduleNextDigest(digest); } /** * Calcular KPIs definidos */ private async calculateKPIs(definitions: KPIDefinition[]): Promise { const results: KPIResult[] = []; for (const def of definitions) { try { const value = await this.executeKPIQuery(def); const previousValue = await this.getPreviousValue(def); const trend = this.calculateTrend(value, previousValue); results.push({ code: def.code, label: def.label, value, previousValue, trend, format: def.format || 'number', }); } catch (error) { results.push({ code: def.code, label: def.label, value: null, error: error.message, }); } } return results; } /** * Generar HTML del digest */ private generateDigestHTML(digest: DigestConfig, kpis: KPIResult[]): string { return `

${digest.name}

${new Date().toLocaleDateString()}

${kpis.map(kpi => `
${kpi.label}
${this.formatKPIValue(kpi)}
${kpi.trend ? `
${kpi.trend > 0 ? '↑' : kpi.trend < 0 ? '↓' : '→'} ${Math.abs(kpi.trend).toFixed(1)}%
` : ''}
`).join('')}
`; } private formatKPIValue(kpi: KPIResult): string { if (kpi.value == null) return 'N/A'; switch (kpi.format) { case 'currency': return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(kpi.value); case 'percent': return `${(kpi.value * 100).toFixed(1)}%`; default: return new Intl.NumberFormat('es-MX').format(kpi.value); } } } ``` --- ## Parte 4: API REST ### 4.1 Endpoints ```typescript // src/modules/reporting/controllers/scheduled-report.controller.ts @Controller('api/v1/scheduled-reports') @UseGuards(JwtAuthGuard) @ApiTags('Scheduled Reports') export class ScheduledReportController { constructor( private readonly scheduledReportService: ScheduledReportService, ) {} @Post() @Roles('report.schedule.create') @ApiOperation({ summary: 'Crear reporte programado' }) async create( @Body() dto: CreateScheduledReportDto, @CurrentUser() user: User, ): Promise { return this.scheduledReportService.createScheduledReport(dto, user.id); } @Get() @ApiOperation({ summary: 'Listar reportes programados' }) async list( @Query() query: ScheduledReportQueryDto, @CurrentUser() user: User, ): Promise> { return this.scheduledReportService.findAll(query, user.companyId); } @Get(':id') @ApiOperation({ summary: 'Obtener reporte programado' }) async findOne( @Param('id', ParseUUIDPipe) id: string, ): Promise { return this.scheduledReportService.findOne(id); } @Patch(':id') @Roles('report.schedule.update') @ApiOperation({ summary: 'Actualizar reporte programado' }) async update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateScheduledReportDto, @CurrentUser() user: User, ): Promise { return this.scheduledReportService.update(id, dto, user.id); } @Delete(':id') @Roles('report.schedule.delete') @ApiOperation({ summary: 'Eliminar reporte programado' }) async delete( @Param('id', ParseUUIDPipe) id: string, ): Promise { return this.scheduledReportService.delete(id); } @Post(':id/toggle') @ApiOperation({ summary: 'Activar/desactivar reporte' }) async toggle( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { return this.scheduledReportService.toggle(id, user.id); } @Post(':id/run-now') @Roles('report.schedule.execute') @ApiOperation({ summary: 'Ejecutar reporte inmediatamente' }) async runNow( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise<{ executionId: string }> { const execution = await this.scheduledReportService.executeNow(id, user.id); return { executionId: execution.id }; } @Get(':id/executions') @ApiOperation({ summary: 'Historial de ejecuciones' }) async getExecutions( @Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationDto, ): Promise> { return this.scheduledReportService.getExecutionHistory(id, query); } // === DESTINATARIOS === @Post(':id/recipients') @ApiOperation({ summary: 'Agregar destinatario' }) async addRecipient( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AddRecipientDto, ): Promise { return this.scheduledReportService.addRecipient(id, dto); } @Delete(':id/recipients/:recipientId') @ApiOperation({ summary: 'Eliminar destinatario' }) async removeRecipient( @Param('id', ParseUUIDPipe) id: string, @Param('recipientId', ParseUUIDPipe) recipientId: string, ): Promise { return this.scheduledReportService.removeRecipient(id, recipientId); } } // === DIGEST CONTROLLER === @Controller('api/v1/digests') @UseGuards(JwtAuthGuard) @ApiTags('Digest Emails') export class DigestController { @Get() @ApiOperation({ summary: 'Listar digests disponibles' }) async listDigests(@CurrentUser() user: User) { return this.digestService.findAll(user.companyId); } @Post(':id/subscribe') @ApiOperation({ summary: 'Suscribirse a digest' }) async subscribe( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { return this.digestService.subscribe(id, user.id); } @Post(':id/unsubscribe') @ApiOperation({ summary: 'Cancelar suscripción' }) async unsubscribe( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { return this.digestService.unsubscribe(id, user.id); } } ``` --- ## Parte 5: KPIs Predefinidos ### 5.1 Definiciones de KPIs ```typescript // src/modules/reporting/constants/default-kpis.ts export const DEFAULT_KPIS: KPIDefinition[] = [ // Ventas { code: 'sales_total', label: 'Ventas Totales', category: 'sales', query: ` SELECT COALESCE(SUM(amount_total), 0) as value FROM sale_orders WHERE state = 'sale' AND date_order >= :dateFrom AND date_order < :dateTo `, format: 'currency', }, { code: 'sales_count', label: 'Órdenes de Venta', category: 'sales', query: ` SELECT COUNT(*) as value FROM sale_orders WHERE state = 'sale' AND date_order >= :dateFrom AND date_order < :dateTo `, format: 'number', }, { code: 'new_customers', label: 'Nuevos Clientes', category: 'sales', query: ` SELECT COUNT(DISTINCT partner_id) as value FROM sale_orders WHERE state = 'sale' AND partner_id NOT IN ( SELECT partner_id FROM sale_orders WHERE date_order < :dateFrom AND state = 'sale' ) AND date_order >= :dateFrom AND date_order < :dateTo `, format: 'number', }, // Inventario { code: 'low_stock_products', label: 'Productos Bajo Stock', category: 'inventory', query: ` SELECT COUNT(*) as value FROM products p JOIN stock_quants sq ON p.id = sq.product_id WHERE sq.quantity <= p.reorder_point AND p.type = 'product' `, format: 'number', }, // Contabilidad { code: 'receivables', label: 'Cuentas por Cobrar', category: 'accounting', query: ` SELECT COALESCE(SUM(amount_residual), 0) as value FROM account_moves WHERE move_type IN ('out_invoice', 'out_refund') AND state = 'posted' AND amount_residual > 0 `, format: 'currency', }, { code: 'invoiced_amount', label: 'Facturado', category: 'accounting', query: ` SELECT COALESCE(SUM(amount_total), 0) as value FROM account_moves WHERE move_type = 'out_invoice' AND state = 'posted' AND invoice_date >= :dateFrom AND invoice_date < :dateTo `, format: 'currency', }, // RRHH { code: 'active_employees', label: 'Empleados Activos', category: 'hr', query: ` SELECT COUNT(*) as value FROM employees WHERE active = true `, format: 'number', }, ]; ``` --- ## Parte 6: Configuración y Seguridad ### 6.1 Configuración del Módulo ```typescript // src/modules/reporting/reporting.module.ts @Module({ imports: [ TypeOrmModule.forFeature([ ReportDefinition, ScheduledReport, ScheduledReportRecipient, ReportExecutionLog, DigestConfig, DigestSubscription, ]), ScheduleModule.forRoot(), MailModule, ], controllers: [ ScheduledReportController, DigestController, ReportDefinitionController, ], providers: [ ScheduledReportService, ReportRendererService, DigestService, // Job para procesar reportes { provide: 'SCHEDULED_REPORTS_PROCESSOR', useFactory: (service: ScheduledReportService) => { return service; }, inject: [ScheduledReportService], }, ], exports: [ScheduledReportService, DigestService], }) export class ReportingModule {} ``` ### 6.2 Permisos ```typescript export const REPORTING_PERMISSIONS = { 'report.view': 'Ver reportes', 'report.execute': 'Ejecutar reportes', 'report.schedule.create': 'Crear reportes programados', 'report.schedule.update': 'Modificar reportes programados', 'report.schedule.delete': 'Eliminar reportes programados', 'report.schedule.execute': 'Ejecutar reportes manualmente', 'digest.manage': 'Gestionar digests', }; ``` --- ## Apéndice A: Reportes Predefinidos por Módulo | Módulo | Código | Nombre | Frecuencia Sugerida | |--------|--------|--------|---------------------| | Ventas | RPT-SALES-DAILY | Resumen Ventas Diario | Diario | | Ventas | RPT-SALES-WEEKLY | Ventas Semanal | Semanal | | Inventario | RPT-INV-STOCK | Niveles de Stock | Semanal | | Inventario | RPT-INV-LOW | Productos Bajo Mínimo | Diario | | Contabilidad | RPT-ACC-AGING | Antigüedad de Saldos | Semanal | | Contabilidad | RPT-ACC-CASHFLOW | Flujo de Caja | Mensual | | RRHH | RPT-HR-ATTENDANCE | Asistencia | Semanal | | RRHH | RPT-HR-PAYROLL | Nómina Mensual | Mensual | | Proyectos | RPT-PRJ-STATUS | Estado de Proyectos | Semanal | --- ## Apéndice B: Checklist de Implementación - [ ] Modelo report_definitions - [ ] Modelo scheduled_reports - [ ] Modelo report_execution_log - [ ] Modelo digest_configs - [ ] ScheduledReportService - [ ] ReportRendererService (PDF, Excel, CSV) - [ ] DigestService - [ ] Cron jobs - [ ] API REST - [ ] DTOs y validaciones - [ ] Seed de reportes predefinidos - [ ] Seed de KPIs - [ ] UI: Lista de reportes programados - [ ] UI: Configurador de schedule - [ ] UI: Suscripción a digests - [ ] Tests unitarios - [ ] Tests de integración --- *Documento generado como parte del análisis de gaps ERP Core vs Odoo 18* *Referencia: GAP-MGN-012-001 - Scheduler de Reportes*