Portada ejecutiva
Empresa, período, FARO Score, variación semanal, estado, riesgo, fecha de generación y responsable del reporte. Lo que se ve antes de abrir nada.
Primer reporte canónico del MVP de FARO Connect. Lectura ejecutiva de la semana, no pila de datos: 12 bloques estructurados, 5 outputs, data contract estable, snapshot inmutable para auditoría e IA limitada a slots controlados con audit trail. Acompaña a reportes-ejecutivos.html, donde figuran los 10 reportes oficiales.
Este documento es la especificación técnica completa del primer reporte ejecutivo semanal del MVP de FARO Connect (FARO-TPL-002). Es la pieza canónica que cierra el ciclo: datos → KPIs → tensiones → acciones → evidencia → score → reporte. Sin esta pieza, FARO ejecuta pero no consolida.
FARO Connect no debe entregarle a dirección un dashboard con 40 widgets que cada quien interpreta como puede. Debe entregar una lectura semanal clara, trazable y accionable que responda siete preguntas concretas:
El reporte MVP cumple cuatro reglas duras: (1) tiene 12 bloques fijos en el mismo orden, siempre; (2) genera 5 outputs sincronizados (HTML, PDF, email, JSON snapshot, Markdown) desde un único content JSON; (3) queda snapshotteado de forma inmutable en faro.reports.content (JSONB) para auditoría posterior; (4) la IA solo llena tres slots controlados (headline, executive_summary, recomendaciones) con audit trail completo y fallback obligatorio sin IA. La IA jamás redacta números: los números los pone el motor.
El reporte se calcula sobre Empresa Demo Cuyo S.A. con Score 74 → 66 (Δ -8) aplicado por la decisión D7 del pack. Esa caída se explica por crecimiento no rentable (TNS-001), venta sin conversión a caja (TNS-004) y stock crítico en alta rotación (TNS-006). El mockup visual de la sección 12 muestra exactamente cómo se ve esa semana renderizada.
Lo que parece un detalle técnico es gobierno de producto. Cuando FARO emita un reporte semanal, todos los stakeholders deben recibir la misma lectura, con los mismos números, citando los mismos códigos canónicos. Sin contrato fijo, el reporte se vuelve relato. Y FARO ya tuvo suficientes Excels en su vida.
Todo reporte semanal MVP renderiza exactamente estos 12 bloques en este orden. Bloques sin datos se renderizan con su empty state honesto, jamás se omiten silenciosamente. La estructura fija es lo que permite a dirección leer cualquier semana en menos de 3 minutos.
Empresa, período, FARO Score, variación semanal, estado, riesgo, fecha de generación y responsable del reporte. Lo que se ve antes de abrir nada.
Lectura de 1 minuto: headline, párrafo de contexto, nivel de riesgo y foco de gestión. Máximo 3 párrafos por regla anti-novela corporativa.
Score actual y anterior, variación numérica, estado (warning/critical/healthy), recuperación potencial estimada si se cierran acciones críticas con evidencia.
Listado de tensiones activas ordenadas por severidad/prioridad con código canónico, diagnóstico ejecutivo, impacto Score y conteo de acciones asociadas.
Métricas globales (totales, abiertas, cerradas, vencidas, bloqueadas, sin evidencia) más tabla de acciones críticas con responsable, vencimiento y recuperación esperada.
Qué evidencia falta para cerrar, qué fue aprobada, qué fue rechazada, qué cierres están bloqueados. Sin evidencia no hay cierre: hay relato.
Solo lo abierto: nivel L1-L4, motivo, entidad escalada (acción/tensión), destinatario, antigüedad. Lo cerrado vive en el timeline, no acá.
Comercial, Finanzas, Stock, Compras, Dirección. Tensiones activas, acciones abiertas, vencidas, impacto Score y riesgo agregado por área.
No es para castigar gente, es para gestión. Pero si la misma persona aparece siempre con vencidas, el sistema no debe mirar para otro lado.
Decisiones que dirección debe tomar esta semana, con motivo (escalamiento, bloqueo, tensión crítica sin avance), impacto en Score y dueño sugerido.
Entre 3 y 5 recomendaciones ejecutivas concretas, derivadas de tensiones, acciones y evidencia. Cero genéricas. Cero "se está trabajando".
Top 5 focos priorizados con resultado esperado concreto. Sin foco, el lunes vuelve a ser improvisado.
Regla anti-humo. El reporte rechaza frases vagas sin dato: "se avanzó", "se está trabajando", "hay temas pendientes", "se revisará", "está controlado", "mejoró". El renderer reemplaza cada una por su equivalente con datos: "se cerraron X acciones", "acción ACT-X está en progreso, vence tal fecha", "hay X acciones vencidas", "decisión requerida para tal responsable", "evidencia aprobada / no aprobada", "Score subió X puntos por Y".
content JSONEl reporte se genera una sola vez y se renderiza a cinco formatos. Todos derivan del mismo content JSON inmutable. Si los formatos divergen en datos, la fuente es el JSON: lo demás es bug de renderer.
Vista web del reporte para consumo en navegador (Next.js page app/faro/reports/[id]/page.tsx). Permite navegación entre bloques, drill-down a tensión/acción/evidencia individual y compartir vía link.
renderWeeklyReportHtml()PDF A4 generado por Playwright HTML-to-PDF (recomendado MVP) o Puppeteer. Se guarda en storage privado y se descarga vía endpoint controlado por permisos.
playwright.chromium.pdf() · jspdf · puppeteerNo manda el PDF pegado. Manda resumen ejecutivo corto + link al reporte completo + adjunto PDF opcional. Asunto: [FARO] Reporte Semanal Ejecutivo · {{company_name}} · {{period_label}}.
provider · Resend / SESPayload completo del reporte como JSON estructurado. Es la fuente única de verdad de todos los demás formatos. Queda persistido en faro.reports.content (JSONB).
content jsonbVersión Markdown del reporte para debug, exportación a Notion/Slack/Linear, copia rápida o auditoría textual. Generado con renderWeeklyReportMarkdown().
markdown_content textexport async function generateAllOutputs(content: WeeklyReportContent) { // 1) JSON snapshot (fuente única) const jsonSnapshot = JSON.stringify(content); // 2) Markdown (debug / export) const markdown = renderWeeklyReportMarkdown(content); // 3) HTML (web + base para PDF y email) const html = renderWeeklyReportHtml(content); // 4) PDF (Playwright headless desde el HTML) const pdfBuffer = await renderPdfFromHtml(html); const pdfStorageUri = await storePdfPrivate(pdfBuffer); // 5) Email resumen (corto + link + PDF adjunto) const emailPayload = buildEmailSummary(content, pdfStorageUri); return { jsonSnapshot, markdown, html, pdfStorageUri, emailPayload }; }
Este es el JSON canónico que cierra el contrato entre motor de reportes y renderers. Cualquier campo agregado en el futuro va con bump de payload_version. Cualquier campo removido rompe contratos: prohibido en MVP.
{
"report": {
"report_id": "rep_2026_w22",
"report_code": "REP-WEEKLY-2026-W22",
"company_id": "10000000-0000-0000-0000-000000000001",
"company_name": "Empresa Demo Cuyo S.A.",
"period_start": "2026-05-25",
"period_end": "2026-05-31",
"period_label": "Semana 22 · Mayo 2026",
"generated_at": "2026-05-31T18:00:00-03:00",
"generated_by": "Sistema FARO",
"status": "generated",
"payload_version": 1
},
"score": {
"current": 66,
"previous": 74,
"delta": -8,
"status": "warning",
"main_drivers": ["TNS-001", "TNS-004", "TNS-006"],
"potential_recovery": 16,
"locked_by_evidence": 5
},
"executive_summary": {
"headline": "La empresa creció en ventas, pero deterioró rentabilidad y caja.",
"summary": "FARO detectó 6 tensiones activas, 2 críticas y 4 altas. El principal deterioro proviene de crecimiento no rentable, cobranza más lenta y stock crítico en alta rotación.",
"risk_level": "high",
"management_focus": "Recuperar margen, acelerar cobranza y resolver quiebres de stock.",
"ai_audit": {
"used_ai": true,
"model": "sonnet-4-7",
"slot_outputs": ["headline", "summary"],
"fallback_used": false,
"prompt_hash": "sha256:..."
}
},
"tensions": {
"total": 6,
"critical": 2,
"high": 4,
"items": [
{
"tension_code": "TNS-001",
"title": "Crecimiento no rentable",
"severity": "critical",
"score_impact": -8.5,
"actions_open": 3,
"actions_expired": 1
}
]
},
"actions": {
"total": 14,
"open": 9,
"closed": 5,
"expired": 3,
"blocked": 2,
"without_evidence": 4,
"items": []
},
"evidence": {
"required": 12,
"submitted": 6,
"approved": 4,
"rejected": 1,
"missing": 7
},
"escalations": {
"open": 2,
"critical": 1,
"items": []
},
"areas": [],
"responsibles": [],
"decisions_required": [],
"recommendations": [],
"next_week_focus": []
}
Los slots ai_audit, main_drivers, locked_by_evidence y payload_version son extensiones del payload base de FARO-TPL-002 para soportar D5 (audit trail de IA) y compatibilidad futura del schema.
El MVP entrega únicamente el reporte semanal ejecutivo. Diario operativo y mensual directorio quedan fuera del MVP y se construyen sobre el mismo data contract en fases posteriores.
Generado todos los lunes 07:00 (timezone America/Argentina/Mendoza) para la semana anterior (lunes a domingo). Audiencia: dirección operativa, gerencia general, comité ejecutivo.
Snapshot 1-pager AM con tensiones críticas, acciones vencidas y movimiento del Score vs ayer. Audiencia: responsables operativos. Fuera del scope MVP.
Visión integral del mes para gobierno y estrategia: todos los KPIs, todas las tensiones evaluadas, comparativa vs mes anterior y objetivo. Fuera del scope MVP.
Reporte evento por evento al cerrar una tensión crítica: auditoría y aprendizaje. Fuera del scope MVP.
El reporte se dispara por dos caminos: job semanal automático (cron, lunes 07:00) y generación on-demand desde la UI (gerente general / director). Ambos llaman al mismo servicio generateWeeklyExecutiveReport() y producen exactamente el mismo output.
import { CronJob } from "cron"; import { generateWeeklyExecutiveReport } from "@/src/reports/generateWeeklyExecutiveReport"; // Todos los lunes a las 07:00 hora Mendoza new CronJob( "0 7 * * 1", async () => { const companies = await db.query("SELECT company_id FROM faro.companies WHERE active = true"); const { periodStart, periodEnd } = previousWeekRange("America/Argentina/Mendoza"); for (const { company_id } of companies.rows) { try { await generateWeeklyExecutiveReport({ client: await db.connect(), companyId: company_id, periodStart, periodEnd, generatedBy: null // system }); } catch (error) { logger.error({ company_id, error }, "weekly_report_failed"); } } }, null, true, "America/Argentina/Mendoza" ).start();
// Desde botón "Generar reporte" en la UI del gerente async function handleGenerateClick() { setLoading(true); try { const { report_id } = await generateWeeklyReport({ periodStart: "2026-05-25", periodEnd: "2026-05-31" }); router.push(`/faro/reports/${report_id}`); } catch (error) { toast.error("No se pudo generar el reporte. Reintentar."); } finally { setLoading(false); } }
faro.reports con snapshot_payload JSONBUna vez generado, el content del reporte queda snapshotteado en una columna JSONB inmutable. Si dirección revisa el reporte de la semana 22 dentro de seis meses, debe ver exactamente los mismos números que vio cuando lo recibió. Sin snapshot, el reporte se vuelve volátil y la auditoría se vuelve imposible.
Por qué inmutable. Los datos del sistema cambian todo el tiempo (tensiones se cierran, acciones se completan, evidencias se aprueban). Si el reporte se recalculara cada vez que se abre, perdería trazabilidad histórica. El snapshot congela la foto del momento exacto en que se generó el reporte, con timestamp y firma del usuario que lo lanzó.
CREATE TABLE IF NOT EXISTS faro.reports ( report_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NOT NULL, report_code text NOT NULL, report_type text NOT NULL CHECK ( report_type IN ( 'weekly_executive', 'daily_operational', 'monthly_board', 'tension_closure', 'custom' ) ), title text NOT NULL, period_start date NOT NULL, period_end date NOT NULL, period_label text NOT NULL, status text NOT NULL DEFAULT 'draft' CHECK ( status IN ('draft', 'generated', 'sent', 'archived', 'failed') ), score_current numeric(9,2) NULL, score_previous numeric(9,2) NULL, score_delta numeric(9,2) NULL, headline text NULL, executive_summary text NULL, risk_level text NULL CHECK ( risk_level IS NULL OR risk_level IN ('low', 'medium', 'high', 'critical') ), -- snapshot inmutable del reporte completo snapshot_payload jsonb NOT NULL DEFAULT '{}'::jsonb, metrics jsonb NOT NULL DEFAULT '{}'::jsonb, -- audit trail de IA (D5) ai_audit jsonb NOT NULL DEFAULT '{}'::jsonb, html_content text NULL, markdown_content text NULL, pdf_storage_uri text NULL, generated_by uuid NULL, generated_at timestamptz NULL, sent_by uuid NULL, sent_at timestamptz NULL, payload_version integer NOT NULL DEFAULT 1, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(company_id, report_code) ); CREATE INDEX IF NOT EXISTS idx_reports_company_period ON faro.reports(company_id, report_type, period_start DESC, period_end DESC); CREATE INDEX IF NOT EXISTS idx_reports_snapshot ON faro.reports USING gin(snapshot_payload);
ALTER TABLE faro.reports ENABLE ROW LEVEL SECURITY; CREATE POLICY reports_company_isolation ON faro.reports USING ( company_id::text = current_setting('app.company_id', true) );
La plantilla base del reporte es un HTML auto-contenido (estilos inline, sin dependencias externas) que sirve para tres usos: vista web, conversión a PDF vía Playwright y email HTML para clientes de correo conservadores.
<!doctype html> <html lang="es"> <head> <meta charset="utf-8" /> <title>{{report_title}}</title> <style> :root { --faro-blue: #243F4A; --faro-gold: #C9A668; --faro-sand: #F6F1E8; --faro-border: rgba(36,63,74,.14); --faro-muted: rgba(36,63,74,.68); } body { margin: 0; font-family: Inter, Arial, sans-serif; color: var(--faro-blue); background: var(--faro-sand); } .page { max-width: 1040px; margin: 0 auto; padding: 42px; } .card { background: rgba(255,255,255,.78); border: 1px solid var(--faro-border); border-radius: 24px; padding: 28px; margin-bottom: 18px; } .eyebrow { color: var(--faro-gold); font-size: 11px; text-transform: uppercase; letter-spacing: .18em; font-weight: 700; } h1 { font-size: 40px; line-height: 1.05; margin: 12px 0 0; letter-spacing: -0.03em; } h2 { font-size: 24px; margin-bottom: 14px; letter-spacing: -0.03em; } .grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } .metric { background: #F8F4EC; border: 1px solid var(--faro-border); border-radius: 18px; padding: 16px; } .metric-label { font-size: 11px; color: var(--faro-muted); text-transform: uppercase; letter-spacing: .12em; font-weight: 700; } .metric-value { font-size: 30px; font-weight: 700; margin-top: 8px; } table { width: 100%; border-collapse: collapse; font-size: 13px; } th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--faro-muted); border-bottom: 1px solid var(--faro-border); padding: 10px 8px; } td { border-bottom: 1px solid rgba(36,63,74,.08); padding: 12px 8px; vertical-align: top; } .badge { display: inline-block; border-radius: 999px; padding: 4px 9px; font-size: 10px; text-transform: uppercase; font-weight: 800; border: 1px solid var(--faro-border); } .critical { background: #FEE2E2; color: #991B1B; } .high { background: #FEF3C7; color: #92400E; } .footer { color: var(--faro-muted); font-size: 11px; margin-top: 28px; text-align: center; } </style> </head> <body> <main class="page"> {{content}} </main> </body> </html>
export function renderWeeklyReportHtml(content: WeeklyReportContent): string { return ` <section class="card"> <div class="eyebrow">FARO Connect · Reporte Semanal Ejecutivo</div> <h1>${escapeHtml(content.report.company_name)}</h1> <p>${escapeHtml(content.report.period_label)}</p> <div class="grid"> <div class="metric"> <div class="metric-label">FARO Score</div> <div class="metric-value">${content.score.current}</div> </div> <div class="metric"> <div class="metric-label">Variación</div> <div class="metric-value">${formatSigned(content.score.delta)}</div> </div> <div class="metric"> <div class="metric-label">Riesgo</div> <div class="metric-value">${escapeHtml(content.executive_summary.risk_level)}</div> </div> <div class="metric"> <div class="metric-label">Recuperación</div> <div class="metric-value">+${content.score.potential_recovery}</div> </div> </div> </section> `; }
execution_eventsCada acción sobre un reporte (generación, envío, fallo) queda registrada en la tabla faro.execution_events vía la función faro.log_execution_event(). Eso permite reconstruir la historia completa del reporte: quién lo generó, cuándo, quién lo envió, a quién, si llegó o falló.
CREATE TABLE IF NOT EXISTS faro.report_recipients ( report_recipient_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NOT NULL, report_id uuid NOT NULL, user_id uuid NULL, role_code text NULL, email text NULL, delivery_channel text NOT NULL CHECK ( delivery_channel IN ('in_app', 'email', 'download') ), status text NOT NULL DEFAULT 'pending' CHECK ( status IN ('pending', 'sent', 'read', 'failed', 'cancelled') ), sent_at timestamptz NULL, read_at timestamptz NULL, failure_reason text NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_report_recipients_report ON faro.report_recipients(company_id, report_id, status);
-- Al generar SELECT faro.log_execution_event( $1, -- company_id 'report', -- entity_type $2, -- report_id 'weekly_report_generated', -- event_type 'report', -- event_family 'Reporte semanal generado', -- title $3, -- description $4, -- generated_by CASE WHEN $4 IS NULL THEN 'system' ELSE 'user' END, NULL, NULL, NULL, NULL, 'generated', NULL, NULL, NULL, NULL, 'report_engine', $5, $6::jsonb ); -- Al enviar SELECT faro.log_execution_event( $1, 'report', $2, 'weekly_report_sent', 'report', 'Reporte semanal enviado', 'El reporte fue enviado por email.', $3, 'user', NULL, NULL, NULL, 'generated', 'sent', NULL, NULL, NULL, NULL, 'report_engine', NULL, $4::jsonb );
Cuatro endpoints REST exponen el ciclo completo del reporte: generar, obtener, descargar PDF, enviar por email. Todos pasan por getSessionContext() y aplican RLS por company_id.
/api/v1/reports/weekly/generate
Genera un nuevo reporte semanal para el período indicado. Retorna report_id y content.
/api/v1/reports/:id
Obtiene un reporte ya generado con metadata + content + html_content + markdown_content.
/api/v1/reports/:id/pdf
Devuelve el PDF binario (Content-Type: application/pdf). Si no existe, lo genera al vuelo.
/api/v1/reports/:id/send
Envía el reporte por email a un array de recipients. Registra evento y actualiza status='sent'.
export async function POST(request: NextRequest) { const session = await getSessionContext(); if (!session?.companyId || !session?.userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const body = await request.json().catch(() => ({})); const periodStart = String(body.period_start ?? ""); const periodEnd = String(body.period_end ?? ""); if (!periodStart || !periodEnd) { return NextResponse.json({ error: "PERIOD_REQUIRED" }, { status: 400 }); } const client = await db.connect(); try { await client.query("BEGIN"); await client.query(`SELECT set_config('app.company_id', $1, true)`, [session.companyId]); await client.query(`SELECT set_config('app.user_id', $1, true)`, [session.userId]); const result = await generateWeeklyExecutiveReport({ client, companyId: session.companyId, periodStart, periodEnd, generatedBy: session.userId }); await client.query("COMMIT"); return NextResponse.json({ ok: true, report_id: result.reportId, content: result.content }); } catch (error: any) { await client.query("ROLLBACK"); return NextResponse.json({ error: error.message ?? "Could not generate weekly report" }, { status: 500 }); } finally { client.release(); } }
La decisión D5 del pack establece que la IA en el reporte semanal MVP no redacta libremente. Llena tres slots controlados: headline, executive_summary y recommendations. Todo lo demás (números, métricas, listados, conteos) lo pone el motor con datos reales. Cada uso de IA queda registrado en ai_audit y existe un fallback obligatorio sin IA que produce un reporte aceptable usando solo reglas determinísticas.
Por qué slots y no redacción libre. Cuando la IA redacta todo, dirección no puede confiar en lo que lee. La IA puede inventar tendencias, suavizar problemas, exagerar avances o citar tensiones que no existen. La regla del MVP es simple: la IA explica con palabras lo que los datos ya dicen con números, en slots acotados, con audit trail y con fallback. Si el modelo está caído, el reporte sale igual usando reglas; solo cambia la prosa.
La IA recibe Score, delta, top tensión y conteos. Genera una frase ejecutiva tipo "La empresa creció en ventas, pero deterioró rentabilidad y caja". No inventa hechos: parafrasea lo que el payload ya contiene.
buildWeeklyHeadline() determinístico.La IA estructura el contexto en máximo 3 párrafos: qué cambió, por qué, cuál es el foco. Recibe el payload completo y un prompt que prohíbe inventar números o citar tensiones fuera del listado.
La IA propone entre 3 y 5 recomendaciones ejecutivas, derivadas exclusivamente de tensiones, acciones y evidencia del payload. Cada recomendación debe poder mapearse a un código canónico.
Score actual, anterior, delta, conteos de tensiones, acciones, evidencias, escalamientos: todo viene del motor SQL con datos reales. Prohibido para la IA tocar estos campos.
Calculado por classifyWeeklyRisk() determinístico. Si Score < 60 o tensiones críticas ≥ 3 o escalamientos ≥ 3 → critical. Reglas duras, no opinión de modelo.
Derivado de decisiones requeridas + acciones críticas vencidas + tensiones críticas sin avance. La IA puede sugerir texto, pero la lista de focos viene de datos.
export interface AIAuditTrail { used_ai: boolean; model: "sonnet-4-7" | "haiku-4-7" | null; slot_outputs: Array<"headline" | "summary" | "recommendations">; fallback_used: boolean; prompt_hash: string; // SHA-256 del prompt usado response_hash: string; // SHA-256 del raw response generated_at: string; cost_usd: number | null; refused_fields: string[]; // campos que el guard rechazó } // Si el modelo está caído, audit trail registra fallback if (!aiAvailable) { content.executive_summary.headline = buildWeeklyHeadline(input); content.executive_summary.summary = buildExecutiveSummary(base, tensions, actions); content.executive_summary.ai_audit = { used_ai: false, model: null, slot_outputs: [], fallback_used: true, prompt_hash: "", response_hash: "", generated_at: new Date().toISOString(), cost_usd: null, refused_fields: [] }; }
export function buildWeeklyHeadline(input: { scoreCurrent: number; scoreDelta: number; criticalTensions: number; expiredActions: number; topTensionTitle?: string | null; }): string { if (input.scoreDelta <= -8 && input.criticalTensions > 0) { return `La empresa deterioró su salud ejecutiva por tensiones críticas no resueltas.`; } if (input.topTensionTitle) { return `El foco semanal debe estar en ${input.topTensionTitle}.`; } if (input.expiredActions > 0) { return `La principal alerta semanal está en acciones vencidas.`; } if (input.scoreDelta > 0) { return `La empresa mejora su Score por avance en ejecución y cierre de acciones.`; } return `La empresa mantiene estabilidad, con focos operativos pendientes.`; }
| Frase prohibida (IA) | Reemplazo FARO (con datos) |
|---|---|
| Se avanzó | Se cerraron X acciones con evidencia aprobada |
| Se está trabajando | Acción ACT-COM-001 está en progreso, vence 06/06 |
| Hay temas pendientes | Hay 3 acciones vencidas en Comercial |
| Se revisará | Decisión requerida: aprobar política de descuentos (Gerente General) |
| Está controlado | Evidencia aprobada en 4 de 7 acciones críticas |
| Mejoró | Score subió +X puntos por cierre de TNS-005 |
El siguiente bloque es el render inline de cómo se ve el reporte semanal cuando se aplica la decisión D7 (Score 74 → 66 en Empresa Demo Cuyo). No es un screenshot: es HTML real renderizado con la plantilla canónica.
Semana 22 · Mayo 2026 · REP-WEEKLY-2026-W22 · generado 31/05/2026 18:00
FARO detectó 6 tensiones activas (2 críticas y 4 altas). El principal deterioro proviene de crecimiento no rentable (TNS-001), venta sin conversión a caja (TNS-004) y stock crítico en alta rotación (TNS-006). Hay 14 acciones asociadas: 3 vencidas, 2 bloqueadas, 4 sin evidencia suficiente.
Foco de gestión: recuperar margen, acelerar cobranza y resolver quiebres de stock antes del cierre de junio.
| Código | Tensión | Severidad | Impacto | Estado |
|---|---|---|---|---|
| TNS-001 | Crecimiento no rentable | Critical | -8.5 | En ejecución |
| TNS-004 | Venta sin conversión a caja | Critical | -6.0 | En ejecución |
| TNS-006 | Stock crítico en alta rotación | High | -4.5 | Nueva |
| TNS-005 | Mora crítica por cliente | High | -3.5 | En ejecución |
| Acción | Tensión | Responsable | Vence | Evidencia | Recup. |
|---|---|---|---|---|---|
| ACT-COM-001 | TNS-001 | Comercial | 06/06 | Pendiente | +8 |
| ACT-FIN-001 | TNS-004 | Finanzas | 29/05 | Falta | +6 |
| ACT-STK-001 | TNS-006 | Stock | 03/06 | En revisión | +4 |
| Nivel | Motivo | Entidad | Escalado a | Antigüedad |
|---|---|---|---|---|
| L3 | Acción crítica vencida | ACT-FIN-001 | Gerente General | 2 días |
| L2 | Acción bloqueada | ACT-STK-001 | Gerente de área | 1 día |
| # | Decisión | Motivo | Impacto | Responsable |
|---|---|---|---|---|
| 1 | Aprobar nueva política de descuentos | Margen cayó de 28% a 21% | +8 Score | Gerente General |
| 2 | Definir plan de cobranza prioritaria | Días de cobranza 32 → 43 | +6 Score | Finanzas |
| 3 | Autorizar compra urgente stock crítico | Quiebre en alta rotación | +4 Score | Compras/Stock |
| Prioridad | Foco | Resultado esperado |
|---|---|---|
| 1 | Recuperar margen comercial | Política de descuentos aprobada |
| 2 | Acelerar cobranza | Plan de cobranza activo |
| 3 | Resolver stock crítico | Orden de reposición emitida |
| 4 | Cerrar acciones vencidas | Evidencia aprobada en ACT-FIN-001 |
| 5 | Reducir escalamientos | Bloqueos destrabados |
rep_2026_w22
REP-WEEKLY-2026-W22
sha256:5b1c…d4af · 12 bloques · 6 tensiones · 14 acciones · 12 evidencias · 2 escalamientos
used_ai: true · model: sonnet-4-7 · slot_outputs: [headline, summary] · fallback_used: false
El reporte semanal MVP no vive solo. Consume catálogos, dispara eventos, escribe en timeline y se publica desde el módulo de reportes. Estos son los puntos de cruce.
30 tensiones canónicas TNS-001..TNS-030. El reporte cita códigos canónicos en bloques 04, 07, 10 y 11.
Catálogo de los 10 reportes oficiales. Esta spec corresponde a REP-02 · Reporte ejecutivo semanal.
Motor FARO Score MVP. El reporte consume score_snapshots y muestra Score actual, anterior y delta.
Gateway de IA controlada con guardrails. Los 3 slots de IA del reporte pasan por acá con audit trail.
Sistema de alertas. El reporte genera notificación al gerente general cuando se publica.
Observabilidad y operaciones. El job semanal emite métricas de generación, latencia y fallos.
Catálogo MVP de acciones ACT-*. El bloque 05 del reporte cita estos códigos con responsable, vencimiento y evidencia.
Catálogo MVP de evidencias EVD-*. El bloque 06 del reporte cita evidencia aprobada, rechazada o faltante.
DDL completo del sistema FARO Connect. Incluye faro.reports, faro.report_recipients y v_weekly_report_base.
Row Level Security por company_id. Garantiza que ningún reporte cruce empresas.
El servicio generateWeeklyExecutiveReport es el punto único de entrada para producir un reporte. Se invoca tanto desde el job cron como desde el endpoint API on-demand. Encadena cinco pasos: lectura de la vista base, cálculo de reglas, llamada controlada a IA, render multi-formato y persistencia inmutable con timeline.
import type pg from "pg"; import { buildWeeklyHeadline, classifyWeeklyRisk } from "./weeklyReportRules"; import { renderWeeklyReportHtml } from "./renderWeeklyReportHtml"; import { renderWeeklyReportMarkdown } from "./renderWeeklyReportMarkdown"; import { callAiGatewayForSlot } from "@/src/ai/gateway"; export async function generateWeeklyExecutiveReport(params: { client: pg.PoolClient; companyId: string; periodStart: string; periodEnd: string; generatedBy?: string | null; }) { // 1) Lectura base · todos los datos vienen del motor const base = await getWeeklyBase(params.client, params.companyId, params.periodStart, params.periodEnd); const tensions = await getTopTensions(params.client, params.companyId); const actions = await getCriticalActions(params.client, params.companyId); const escalations = await getOpenEscalations(params.client, params.companyId); const decisions = await getDecisionsRequired(params.client, params.companyId); const events = await getWeeklyEvents(params.client, params.companyId, params.periodStart, params.periodEnd); // 2) Reglas determinísticas const topTension = tensions[0] ?? null; const headlineFallback = buildWeeklyHeadline({ scoreCurrent: Number(base.score_current ?? 0), scoreDelta: Number(base.score_delta ?? 0), criticalTensions: Number(base.critical_tensions ?? 0), expiredActions: Number(base.expired_actions ?? 0), topTensionTitle: topTension?.title ?? null }); const riskLevel = classifyWeeklyRisk({ scoreCurrent: Number(base.score_current ?? 0), criticalTensions: Number(base.critical_tensions ?? 0), expiredActions: Number(base.expired_actions ?? 0), openEscalations: Number(base.open_escalations ?? 0) }); // 3) IA controlada · slots con audit trail (D5) const aiResult = await callAiGatewayForSlot({ slots: ["headline", "summary", "recommendations"], payloadFingerprint: { base, tensions, actions, escalations, decisions }, fallback: { headline: headlineFallback } }); // 4) Compilación del content (fuente única) const content = { report: { /* metadata + UUID + payload_version */ }, score: { /* current, previous, delta, status, recovery */ }, executive_summary: { headline: aiResult.headline, summary: aiResult.summary, risk_level: riskLevel, management_focus: buildManagementFocus(tensions), ai_audit: aiResult.audit }, tensions, actions, escalations, decisions_required: decisions, events }; // 5) Render multi-formato + persistencia inmutable const markdown = renderWeeklyReportMarkdown(content); const html = renderWeeklyReportHtml(content); const reportId = await persistReportSnapshot(params.client, content, html, markdown, params.generatedBy); // 6) Timeline event await logWeeklyReportGenerated(params.client, params.companyId, reportId, content); return { reportId, content, html, markdown }; }
async function getWeeklyBase(client: pg.PoolClient, companyId: string, periodStart: string, periodEnd: string) { const result = await client.query( `SELECT * FROM faro.v_weekly_report_base WHERE company_id = $1 AND period_start = $2::date AND period_end = $3::date LIMIT 1`, [companyId, periodStart, periodEnd] ); if (!result.rows[0]) throw new Error("WEEKLY_REPORT_BASE_NOT_FOUND"); return result.rows[0]; } async function getTopTensions(client: pg.PoolClient, companyId: string) { const result = await client.query( `SELECT t.tension_id, t.tension_code, COALESCE(t.title, td.name) AS title, t.severity, t.priority_score, t.confidence_score, t.status, t.score_impact, td.business_question, td.executive_diagnosis, td.area_code, td.module_code, COUNT(a.action_id) AS actions_total, COUNT(a.action_id) FILTER (WHERE a.status NOT IN ('closed','cancelled','rejected')) AS actions_open, COUNT(a.action_id) FILTER (WHERE a.status = 'closed') AS actions_closed, COUNT(a.action_id) FILTER (WHERE a.status NOT IN ('closed','cancelled','rejected') AND a.due_date < CURRENT_DATE) AS actions_expired FROM faro.tensions t LEFT JOIN faro.tension_definitions td ON td.tension_code = t.tension_code AND td.status = 'active' LEFT JOIN faro.actions a ON a.company_id = t.company_id AND a.tension_id = t.tension_id WHERE t.company_id = $1 AND t.status NOT IN ('closed','rejected') GROUP BY t.tension_id, t.tension_code, t.title, td.name, t.severity, t.priority_score, t.confidence_score, t.status, t.score_impact, td.business_question, td.executive_diagnosis, td.area_code, td.module_code ORDER BY CASE t.severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 ELSE 5 END, t.priority_score DESC LIMIT 10`, [companyId] ); return result.rows; }
export function classifyWeeklyRisk(input: { scoreCurrent: number; criticalTensions: number; expiredActions: number; openEscalations: number; }): "low" | "medium" | "high" | "critical" { if ( input.scoreCurrent < 60 || input.criticalTensions >= 3 || input.openEscalations >= 3 ) return "critical"; if ( input.scoreCurrent < 75 || input.criticalTensions >= 1 || input.expiredActions >= 2 ) return "high"; if (input.scoreCurrent < 85) return "medium"; return "low"; }
Los bloques sin datos no se omiten silenciosamente. Se renderizan con un mensaje claro que aclara la razón y orienta a dirección. Omitir un bloque es peor que decir "no hay nada": le hace creer al lector que todo está bien cuando puede no estarlo.
| Bloque | Condición de empty | Mensaje canónico |
|---|---|---|
| 04 · Tensiones críticas | Sin tensiones severity=critical activas |
No se registran tensiones críticas activas para el período. Se recomienda mantener seguimiento sobre tensiones altas y calidad de datos. |
| 05 · Acciones | Sin acciones vencidas (expired = 0) |
No se registran acciones vencidas. El foco debe mantenerse en cierre con evidencia y prevención de reincidencias. |
| 06 · Evidencias | Sin evidencias cargadas en el período | No hay evidencias cargadas para el período. Esto puede indicar falta de ejecución comprobable o ausencia de acciones que requieran respaldo. |
| 03 · FARO Score | score_current IS NULL |
No hay Score calculado para el período. El reporte queda marcado como incompleto hasta ejecutar el motor de Score. |
| 07 · Escalamientos | Sin escalamientos status=open |
No hay escalamientos abiertos. Las decisiones requeridas se concentran en bloqueos y tensiones críticas sin avance. |
| 10 · Decisiones requeridas | Sin decisiones derivadas de escalamiento/bloqueo | No se identifican decisiones requeridas esta semana. Esto suele indicar buena ejecución operativa o falta de seguimiento. |
Regla del empty state honesto. Si todos los bloques operativos quedan en empty con mensaje optimista, el reporte se convierte en publicidad. El renderer debe agregar un banner al inicio cuando hay tres o más bloques vacíos consecutivos: "El reporte de esta semana tiene baja densidad de señal. Verificar conexiones de datos y calidad de fuentes."
company_id · permisos por rolEl reporte semanal contiene información ejecutiva sensible (score, tensiones críticas, decisiones requeridas, responsables). El acceso está controlado por dos capas: Row Level Security a nivel base y validación de rol en cada endpoint.
| Acción | Roles autorizados | Validación |
|---|---|---|
| Ver reporte semanal | Gerente, Director, roles autorizados | RLS + rol |
| Generar reporte | Gerente General, Director, sistema (cron) | RLS + rol |
| Enviar reporte por email | Gerente General, Director | Rol + audit |
| Descargar PDF | Gerente, Director | Rol + signed URL |
| Ver reporte de otra empresa | Nunca, salvo multiempresa autorizado | RLS bloquea |
| Editar contenido | Ninguno · fuera del scope MVP | Inmutable |
ALTER TABLE faro.reports ENABLE ROW LEVEL SECURITY; -- Aislamiento estricto por company CREATE POLICY reports_company_isolation ON faro.reports USING ( company_id::text = current_setting('app.company_id', true) ); -- Solo roles de dirección pueden insertar reportes manuales CREATE POLICY reports_insert_by_role ON faro.reports FOR INSERT WITH CHECK ( company_id::text = current_setting('app.company_id', true) AND ( current_setting('app.role_codes', true) LIKE '%general_manager%' OR current_setting('app.role_codes', true) LIKE '%director%' OR current_setting('app.user_id', true) = '' -- sistema (cron) ) ); -- Reportes archivados son solo lectura CREATE POLICY reports_no_update_when_archived ON faro.reports FOR UPDATE USING (status <> 'archived');
Sin tests el reporte se vuelve adivinanza. Estos son los tests mínimos que bloquean el merge si fallan, separados en SQL, backend y frontend.
| Capa | Test | Esperado |
|---|---|---|
| SQL | Crear reporte | OK |
| SQL | report_code único por empresa | OK |
| SQL | Reporte aislado por company_id | OK · RLS bloquea |
| SQL | Vista v_weekly_report_base devuelve métricas | OK |
| SQL | Timeline registra weekly_report_generated | OK |
| Backend | POST /weekly/generate con período válido | 200 |
| Backend | POST /weekly/generate sin período | 400 · PERIOD_REQUIRED |
| Backend | Base semanal ausente | error controlado · WEEKLY_REPORT_BASE_NOT_FOUND |
| Backend | Render Markdown contiene FARO Score | OK |
| Backend | Render HTML contiene tensiones | OK |
| Backend | POST /:id/send cambia status a sent | OK |
| Backend | POST /:id/send sin recipients | 400 · RECIPIENTS_REQUIRED |
| Backend | RLS bloquea reporte de otra empresa | 404 / vacío |
| Backend | IA caída · fallback determinístico | OK · ai_audit.fallback_used = true |
| Frontend | Header muestra empresa y período | OK |
| Frontend | Score card muestra delta con signo | OK |
| Frontend | Resumen ejecutivo visible | OK |
| Frontend | Tabla tensiones visible | OK |
| Frontend | Tabla acciones visible | OK |
| Frontend | Escalamientos visibles | OK |
| Frontend | Empty states honestos | OK · render con mensaje |
import { describe, expect, it } from "vitest"; import { withTestDbContext } from "../src/helpers/dbTestContext"; import { generateWeeklyExecutiveReport } from "../src/reports/generateWeeklyExecutiveReport"; describe("Weekly executive report", () => { it("generates weekly report with score and tensions", async () => { await withTestDbContext( { companyId: "10000000-0000-0000-0000-000000000001", userId: "12000000-0000-0000-0000-000000000001", roleCodes: ["general_manager"] }, async (client) => { const result = await generateWeeklyExecutiveReport({ client, companyId: "10000000-0000-0000-0000-000000000001", periodStart: "2026-05-25", periodEnd: "2026-05-31", generatedBy: "12000000-0000-0000-0000-000000000001" }); expect(result.reportId).toBeTruthy(); expect(result.content.score.current).toBeGreaterThanOrEqual(0); expect(result.markdown).toContain("Reporte Semanal Ejecutivo"); expect(result.content.executive_summary.ai_audit).toBeDefined(); } ); }); it("falls back without AI when gateway is down", async () => { process.env.AI_GATEWAY_FORCE_FALLBACK = "true"; await withTestDbContext({ /* ... */ }, async (client) => { const result = await generateWeeklyExecutiveReport({ client, /* ... */ } as any); expect(result.content.executive_summary.ai_audit.fallback_used).toBe(true); expect(result.content.executive_summary.headline).toBeTruthy(); }); }); });
La vista web del reporte vive en Next.js App Router. Componentes desacoplados por bloque para permitir tests granulares y render selectivo (un componente vacío renderiza su empty state, no rompe la página).
app/
faro/
reports/
weekly/
page.tsx // listado + botón "Generar"
[id]/
page.tsx // detalle del reporte
components/reports/
WeeklyReportPage.tsx // orquestador
WeeklyReportHeader.tsx // bloque 01
WeeklyReportExecutiveSummary.tsx // bloque 02
WeeklyReportScoreCard.tsx // bloque 03
WeeklyReportTensionsTable.tsx // bloque 04
WeeklyReportActionsTable.tsx // bloque 05
WeeklyReportEvidenceBlock.tsx // bloque 06
WeeklyReportEscalationsTable.tsx // bloque 07
WeeklyReportAreasTable.tsx // bloque 08
WeeklyReportResponsiblesTable.tsx // bloque 09
WeeklyReportDecisions.tsx // bloque 10
WeeklyReportRecommendations.tsx // bloque 11
WeeklyReportNextFocus.tsx // bloque 12
WeeklyReportActionsBar.tsx // botones generar/enviar/descargar PDF
WeeklyReportEmptyState.tsx // helper reutilizable
lib/faro/
reports.types.ts // tipos compartidos
reports.api.ts // client REST
export type WeeklyReportResponse = { report_id: string; report_code: string; report_type: "weekly_executive"; title: string; period_start: string; period_end: string; period_label: string; status: "draft" | "generated" | "sent" | "archived" | "failed"; score_current: number; score_previous: number; score_delta: number; headline: string; executive_summary: string; risk_level: "low" | "medium" | "high" | "critical"; content: WeeklyReportContent; html_content: string | null; markdown_content: string | null; generated_at: string | null; sent_at: string | null; }; export type WeeklyReportContent = { report: { report_code: string; company_name: string; period_label: string; generated_at: string }; score: { current: number; previous: number; delta: number; status: string; potential_recovery: number }; executive_summary: { headline: string; summary: string; risk_level: "low" | "medium" | "high" | "critical"; management_focus: string; ai_audit: AIAuditTrail; }; tensions: WeeklyReportTension[]; actions: WeeklyReportAction[]; escalations: WeeklyReportEscalation[]; decisions_required: WeeklyReportDecision[]; events: unknown[]; };
"use client"; import type { WeeklyReportResponse } from "@/lib/faro/reports.types"; import { WeeklyReportHeader } from "./WeeklyReportHeader"; import { WeeklyReportExecutiveSummary } from "./WeeklyReportExecutiveSummary"; import { WeeklyReportScoreCard } from "./WeeklyReportScoreCard"; import { WeeklyReportTensionsTable } from "./WeeklyReportTensionsTable"; import { WeeklyReportActionsTable } from "./WeeklyReportActionsTable"; import { WeeklyReportEscalationsTable } from "./WeeklyReportEscalationsTable"; import { WeeklyReportDecisions } from "./WeeklyReportDecisions"; import { WeeklyReportRecommendations } from "./WeeklyReportRecommendations"; import { WeeklyReportNextFocus } from "./WeeklyReportNextFocus"; export function WeeklyReportPage({ report }: { report: WeeklyReportResponse }) { return ( <main className="min-h-screen bg-[#F6F1E8] px-4 py-6 text-[#243F4A] md:px-8"> <div className="mx-auto flex max-w-6xl flex-col gap-5"> <WeeklyReportHeader report={report} /> <div className="grid gap-5 lg:grid-cols-[.9fr_1.1fr]"> <WeeklyReportScoreCard score={report.content.score} /> <WeeklyReportExecutiveSummary summary={report.content.executive_summary} /> </div> <WeeklyReportTensionsTable tensions={report.content.tensions} /> <WeeklyReportActionsTable actions={report.content.actions} /> <WeeklyReportEscalationsTable escalations={report.content.escalations} /> <WeeklyReportDecisions decisions={report.content.decisions_required} /> <WeeklyReportRecommendations content={report.content} /> <WeeklyReportNextFocus content={report.content} /> </div> </main> ); }
El reporte semanal MVP queda aceptado únicamente cuando cumple los criterios funcionales y técnicos enumerados abajo. La columna de rechazo lista los casos que bloquean el merge incluso si el resto funciona.
| Criterio | Estado esperado |
|---|---|
| Genera reporte semanal por empresa | Sí |
| Muestra FARO Score actual | Sí |
| Muestra Score anterior y variación | Sí |
| Muestra resumen ejecutivo con headline + 2-3 párrafos | Sí |
| Muestra tensiones críticas con código canónico | Sí |
| Muestra acciones abiertas/vencidas con responsable | Sí |
| Muestra evidencias pendientes y rechazadas | Sí |
| Muestra escalamientos abiertos con nivel L1-L4 | Sí |
| Muestra decisiones requeridas con dueño sugerido | Sí |
| Muestra entre 3 y 5 recomendaciones | Sí |
| Muestra foco próxima semana priorizado | Sí |
| Genera HTML web visualizable | Sí |
| Puede generar PDF descargable | Sí |
| Puede enviarse por email | Sí |
| Registra evento en timeline | Sí |
| Criterio | Estado esperado |
|---|---|
Crea tabla faro.reports | Sí |
Crea tabla faro.report_recipients | Sí |
Usa vista v_weekly_report_base | Sí |
| Usa datos reales del sistema (no hardcodea demo en prod) | Sí |
Genera content JSON canónico | Sí |
Genera html_content | Sí |
Genera markdown_content | Sí |
Persiste snapshot_payload inmutable | Sí |
Persiste ai_audit con prompt + response hash | Sí |
Aplica RLS por company_id | Sí |
Expone POST /weekly/generate | Sí |
Expone GET /:id + GET /:id/pdf | Sí |
Expone POST /:id/send | Sí |
| Registra timeline event en cada acción | Sí |
| Tests básicos pasan (SQL · backend · frontend) | Sí |
| Caso | Severidad |
|---|---|
| Reporte sin Score | Alta |
| Reporte sin tensiones cuando existen tensiones activas | Alta |
| Reporte con datos hardcodeados en producción | Crítica |
| Reporte cruza empresas (falla RLS) | Crítica |
| Reporte usa frases vagas sin datos | Alta |
| No muestra acciones vencidas cuando existen | Alta |
| No muestra evidencias pendientes cuando existen | Alta |
| No muestra escalamientos cuando existen | Alta |
| No registra evento de generación en timeline | Media/Alta |
Email se envía sin guardar estado en report_recipients | Alta |
| PDF público sin control de acceso | Crítica |
IA llena slots sin ai_audit registrado | Crítica |
| IA caída no activa fallback determinístico | Crítica |
Esta spec es la base del reporte semanal MVP. Para cerrar el módulo completo y dejar el sistema listo para que dirección reciba reportes en producción, faltan estos pasos.
Este documento es la base para construir faro.reports, el servicio generateWeeklyExecutiveReport(), los endpoints API y la integración con el módulo de IA controlada. Pasá al hub para ver el resto del pack o al catálogo de reportes para entender dónde encaja esta pieza.