erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-SCHEDULER-REPORTES.md

44 KiB

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)

-- 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)

-- 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)

-- 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)

-- 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)

-- 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

// 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<ScheduledReport>,
    @InjectRepository(ReportExecutionLog)
    private readonly executionLogRepo: Repository<ReportExecutionLog>,
    private readonly dataSource: DataSource,
    private readonly reportRenderer: ReportRendererService,
    private readonly emailService: EmailService,
  ) {}

  /**
   * Crear nuevo reporte programado
   */
  async createScheduledReport(
    dto: CreateScheduledReportDto,
    userId: string
  ): Promise<ScheduledReport> {
    // 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<void> {
    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<void> {
    // 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<string, any>): Record<string, any> {
    const resolved: Record<string, any> = {};
    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<string[]> {
    const emails: Set<string> = 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<void> {
    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

// 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<RenderResult> {
    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<string, any>
  ): Promise<RenderResult> {
    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<string, any>
  ): Promise<RenderResult> {
    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<string, any>
  ): Promise<RenderResult> {
    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

// 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<DigestConfig>,
    private readonly emailService: EmailService,
  ) {}

  /**
   * Procesar digest emails
   */
  @Cron(CronExpression.EVERY_DAY_AT_8AM)
  async processDigests(): Promise<void> {
    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<void> {
    // 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<KPIResult[]> {
    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 `
      <!DOCTYPE html>
      <html>
      <head>
        <style>
          body { font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; }
          .header { background: #4A90A4; color: white; padding: 20px; text-align: center; }
          .kpi-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; padding: 20px; }
          .kpi-card { border: 1px solid #ddd; border-radius: 8px; padding: 15px; }
          .kpi-label { color: #666; font-size: 12px; margin-bottom: 5px; }
          .kpi-value { font-size: 24px; font-weight: bold; color: #333; }
          .trend-up { color: #22c55e; }
          .trend-down { color: #ef4444; }
          .trend-neutral { color: #666; }
        </style>
      </head>
      <body>
        <div class="header">
          <h1>${digest.name}</h1>
          <p>${new Date().toLocaleDateString()}</p>
        </div>
        <div class="kpi-grid">
          ${kpis.map(kpi => `
            <div class="kpi-card">
              <div class="kpi-label">${kpi.label}</div>
              <div class="kpi-value">${this.formatKPIValue(kpi)}</div>
              ${kpi.trend ? `
                <div class="trend-${kpi.trend > 0 ? 'up' : kpi.trend < 0 ? 'down' : 'neutral'}">
                  ${kpi.trend > 0 ? '↑' : kpi.trend < 0 ? '↓' : '→'}
                  ${Math.abs(kpi.trend).toFixed(1)}%
                </div>
              ` : ''}
            </div>
          `).join('')}
        </div>
      </body>
      </html>
    `;
  }

  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

// 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<ScheduledReportResponseDto> {
    return this.scheduledReportService.createScheduledReport(dto, user.id);
  }

  @Get()
  @ApiOperation({ summary: 'Listar reportes programados' })
  async list(
    @Query() query: ScheduledReportQueryDto,
    @CurrentUser() user: User,
  ): Promise<PaginatedResponse<ScheduledReportResponseDto>> {
    return this.scheduledReportService.findAll(query, user.companyId);
  }

  @Get(':id')
  @ApiOperation({ summary: 'Obtener reporte programado' })
  async findOne(
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<ScheduledReportResponseDto> {
    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<ScheduledReportResponseDto> {
    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<void> {
    return this.scheduledReportService.delete(id);
  }

  @Post(':id/toggle')
  @ApiOperation({ summary: 'Activar/desactivar reporte' })
  async toggle(
    @Param('id', ParseUUIDPipe) id: string,
    @CurrentUser() user: User,
  ): Promise<ScheduledReportResponseDto> {
    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<PaginatedResponse<ReportExecutionLogDto>> {
    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<void> {
    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<void> {
    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<void> {
    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<void> {
    return this.digestService.unsubscribe(id, user.id);
  }
}

Parte 5: KPIs Predefinidos

5.1 Definiciones de KPIs

// 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

// 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

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