44 KiB
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:
- Scheduled Actions: Configuración de tareas programadas (cron jobs)
- Report Rendering: Generación de reportes en PDF/Excel
- Email Delivery: Envío automático con adjuntos
- Digest Emails: KPIs y resúmenes periódicos
- Queue Processing: Gestión de cola para envíos masivos
- 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