01 · Resumen ejecutivo

El primer reporte semanal MVP · lectura ejecutiva, no pila de datos

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:

  • ¿Qué pasó esta semana? Score actual, variación, drivers.
  • ¿Qué cambió? Tensiones nuevas, cerradas, escaladas.
  • ¿Qué preocupa? Tensiones críticas y acciones vencidas.
  • ¿Qué se resolvió? Acciones cerradas con evidencia aprobada.
  • ¿Qué está trabado? Bloqueos, escalamientos, evidencias pendientes.
  • ¿Qué debe decidir dirección? Decisiones requeridas con dueño sugerido.
  • ¿Qué debe pasar la semana próxima? Foco priorizado y resultado esperado.

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.

02 · 12 bloques canónicos del reporte

Estructura fija, no opcional

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.

Bloque 01 Portada

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.

company_name period_label generated_at
Bloque 02 Resumen

Resumen ejecutivo

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.

headline summary risk_level
Bloque 03 FARO Score

FARO Score

Score actual y anterior, variación numérica, estado (warning/critical/healthy), recuperación potencial estimada si se cierran acciones críticas con evidencia.

score.current score.delta potential_recovery
Bloque 04 Tensiones

Tensiones críticas

Listado de tensiones activas ordenadas por severidad/prioridad con código canónico, diagnóstico ejecutivo, impacto Score y conteo de acciones asociadas.

TNS-* severity score_impact
Bloque 05 Acciones

Acciones y ejecución

Métricas globales (totales, abiertas, cerradas, vencidas, bloqueadas, sin evidencia) más tabla de acciones críticas con responsable, vencimiento y recuperación esperada.

ACT-* due_date responsible
Bloque 06 Evidencias

Evidencias

Qué evidencia falta para cerrar, qué fue aprobada, qué fue rechazada, qué cierres están bloqueados. Sin evidencia no hay cierre: hay relato.

EVD-* submitted approved
Bloque 07 Escalamiento

Escalamientos

Solo lo abierto: nivel L1-L4, motivo, entidad escalada (acción/tensión), destinatario, antigüedad. Lo cerrado vive en el timeline, no acá.

L1..L4 to_role age_days
Bloque 08 Áreas

Resumen por área

Comercial, Finanzas, Stock, Compras, Dirección. Tensiones activas, acciones abiertas, vencidas, impacto Score y riesgo agregado por área.

area_code area_risk
Bloque 09 Responsables

Resumen por responsable

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.

responsible open expired
Bloque 10 Decisión

Decisiones requeridas

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.

decision_source to_role
Bloque 11 Recomendación

Recomendaciones

Entre 3 y 5 recomendaciones ejecutivas concretas, derivadas de tensiones, acciones y evidencia. Cero genéricas. Cero "se está trabajando".

recommendations min:3 max:5
Bloque 12 Próxima semana

Foco próxima semana

Top 5 focos priorizados con resultado esperado concreto. Sin foco, el lunes vuelve a ser improvisado.

next_week_focus priority

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

03 · Outputs múltiples

5 formatos desde un único content JSON

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

Output 01 · WEB

HTML interactivo

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.

Stack: renderWeeklyReportHtml()
Output 02 · ARCHIVO

PDF imprimible

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.

Stack: playwright.chromium.pdf() · jspdf · puppeteer
Output 03 · COMUNICACIÓN

Email resumen

No manda el PDF pegado. Manda resumen ejecutivo corto + link al reporte completo + adjunto PDF opcional. Asunto: [FARO] Reporte Semanal Ejecutivo · {{company_name}} · {{period_label}}.

Stack: provider · Resend / SES
Output 04 · BASE TÉCNICA

JSON snapshot

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

Stack: content jsonb
Output 05 · DEBUG / EXPORT

Markdown interno

Versión Markdown del reporte para debug, exportación a Notion/Slack/Linear, copia rápida o auditoría textual. Generado con renderWeeklyReportMarkdown().

Stack: markdown_content text
▸ TypeScript · pipeline de outputs
export 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 };
}
04 · Data contract

Payload completo del reporte

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.

▸ JSON · WeeklyReportContent (Empresa Demo Cuyo · semana 22)
{
  "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.

05 · Frecuencia

Semanal ejecutivo · único MVP

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.

MVP
Frecuencia 01

Semanal ejecutivo

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.

Post-MVP
Frecuencia 02

Diario operativo

Snapshot 1-pager AM con tensiones críticas, acciones vencidas y movimiento del Score vs ayer. Audiencia: responsables operativos. Fuera del scope MVP.

Post-MVP
Frecuencia 03

Mensual directorio

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.

Post-MVP
Frecuencia 04

Cierre de tensión

Reporte evento por evento al cerrar una tensión crítica: auditoría y aprendizaje. Fuera del scope MVP.

06 · Generación

Jobs scheduled + on-demand

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.

▸ Job scheduled · weekly-report-generator.ts
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();
▸ On-demand · UI invocation
// 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);
  }
}
07 · Snapshot inmutable

Tabla faro.reports con snapshot_payload JSONB

Una 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ó.

▸ SQL · faro.reports (V059)
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);
▸ SQL · RLS por company_id
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)
);
08 · Plantilla HTML+PDF

Template ejecutivo HTML email

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.

▸ HTML · template base del reporte
<!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>
▸ TypeScript · render HTML del reporte
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>
`;
}
09 · Envío email + registro timeline

Integración con execution_events

Cada 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ó.

▸ SQL · faro.report_recipients
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);
▸ SQL · evento timeline al generar / enviar
-- 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
);
10 · Endpoints API

Contrato HTTP del módulo de reportes

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.

POST /api/v1/reports/weekly/generate Genera un nuevo reporte semanal para el período indicado. Retorna report_id y content.
GET /api/v1/reports/:id Obtiene un reporte ya generado con metadata + content + html_content + markdown_content.
GET /api/v1/reports/:id/pdf Devuelve el PDF binario (Content-Type: application/pdf). Si no existe, lo genera al vuelo.
POST /api/v1/reports/:id/send Envía el reporte por email a un array de recipients. Registra evento y actualiza status='sent'.
▸ TypeScript · POST /api/v1/reports/weekly/generate
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();
  }
}
11 · IA en reporte (decisión D5)

3 slots controlados · audit trail · fallback obligatorio

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.

slot.headline IA

Headline ejecutivo (1 frase)

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.

Fallback (sin IA): buildWeeklyHeadline() determinístico.
slot.executive_summary IA

Resumen ejecutivo (2-3 párrafos)

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.

Fallback (sin IA): plantilla por nivel de riesgo + datos reales.
slot.recommendations IA

Recomendaciones (3-5 ítems)

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.

Fallback (sin IA): top tensiones críticas → recomendación canónica.
slot.score · * Motor

Números, métricas, listados

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.

slot.risk_level Regla

Nivel de riesgo

Calculado por classifyWeeklyRisk() determinístico. Si Score < 60 o tensiones críticas ≥ 3 o escalamientos ≥ 3 → critical. Reglas duras, no opinión de modelo.

slot.next_week_focus Datos

Foco próxima semana

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.

▸ TypeScript · audit trail de IA en el payload
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: []
  };
}
▸ TypeScript · regla de headline (fallback sin IA)
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.`;
}

Reglas anti-humo aplicadas a los slots de IA

Frase prohibida (IA) Reemplazo FARO (con datos)
Se avanzóSe cerraron X acciones con evidencia aprobada
Se está trabajandoAcción ACT-COM-001 está en progreso, vence 06/06
Hay temas pendientesHay 3 acciones vencidas en Comercial
Se revisaráDecisión requerida: aprobar política de descuentos (Gerente General)
Está controladoEvidencia aprobada en 4 de 7 acciones críticas
MejoróScore subió +X puntos por cierre de TNS-005
12 · Mockup visual del reporte

Cómo se ve · Empresa Demo Cuyo S.A. · Semana 22 · Score 74→66 (D7)

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.

DEMO · D7
FARO Connect · Reporte Semanal Ejecutivo

Empresa Demo Cuyo S.A.

Semana 22 · Mayo 2026 · REP-WEEKLY-2026-W22 · generado 31/05/2026 18:00

FARO Score
66
Variación
-8
Riesgo
High
Recuperación
+16
Resumen ejecutivo

La empresa creció en ventas, pero deterioró rentabilidad y caja.

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.

Tensiones que requieren atención

Tensiones críticas

CódigoTensiónSeveridadImpactoEstado
TNS-001Crecimiento no rentableCritical-8.5En ejecución
TNS-004Venta sin conversión a cajaCritical-6.0En ejecución
TNS-006Stock crítico en alta rotaciónHigh-4.5Nueva
TNS-005Mora crítica por clienteHigh-3.5En ejecución
Ejecución

Acciones críticas y vencidas

AcciónTensiónResponsableVenceEvidenciaRecup.
ACT-COM-001TNS-001Comercial06/06Pendiente+8
ACT-FIN-001TNS-004Finanzas29/05Falta+6
ACT-STK-001TNS-006Stock03/06En revisión+4
Escalamiento

Decisiones y bloqueos abiertos

NivelMotivoEntidadEscalado aAntigüedad
L3Acción crítica vencidaACT-FIN-001Gerente General2 días
L2Acción bloqueadaACT-STK-001Gerente de área1 día
Decisiones requeridas esta semana

Lo que dirección debe decidir

#DecisiónMotivoImpactoResponsable
1Aprobar nueva política de descuentosMargen cayó de 28% a 21%+8 ScoreGerente General
2Definir plan de cobranza prioritariaDías de cobranza 32 → 43+6 ScoreFinanzas
3Autorizar compra urgente stock críticoQuiebre en alta rotación+4 ScoreCompras/Stock
Próxima semana

Foco recomendado · top 5

PrioridadFocoResultado esperado
1Recuperar margen comercialPolítica de descuentos aprobada
2Acelerar cobranzaPlan de cobranza activo
3Resolver stock críticoOrden de reposición emitida
4Cerrar acciones vencidasEvidencia aprobada en ACT-FIN-001
5Reducir escalamientosBloqueos destrabados

Snapshot inmutable de este reporte

report_id rep_2026_w22
report_code REP-WEEKLY-2026-W22
snapshot_payload (sha) sha256:5b1c…d4af · 12 bloques · 6 tensiones · 14 acciones · 12 evidencias · 2 escalamientos
ai_audit used_ai: true · model: sonnet-4-7 · slot_outputs: [headline, summary] · fallback_used: false
13 · Cross-references

Dónde se cruza esta spec con el resto del pack

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.

14 · Servicio generador

generateWeeklyExecutiveReport · orquestación completa

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.

▸ TypeScript · servicio generador (orquestación)
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 };
}
▸ TypeScript · helpers de datos (queries SQL canónicas)
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;
}
▸ TypeScript · regla de riesgo (fallback determinístico)
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";
}
15 · Empty states honestos

Qué decir cuando no hay datos

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

16 · Seguridad y permisos

RLS por company_id · permisos por rol

El 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 semanalGerente, Director, roles autorizadosRLS + rol
Generar reporteGerente General, Director, sistema (cron)RLS + rol
Enviar reporte por emailGerente General, DirectorRol + audit
Descargar PDFGerente, DirectorRol + signed URL
Ver reporte de otra empresaNunca, salvo multiempresa autorizadoRLS bloquea
Editar contenidoNinguno · fuera del scope MVPInmutable
▸ SQL · políticas RLS sobre faro.reports
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');
17 · Tests mínimos

Qué prueba el MVP antes de salir

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
SQLCrear reporteOK
SQLreport_code único por empresaOK
SQLReporte aislado por company_idOK · RLS bloquea
SQLVista v_weekly_report_base devuelve métricasOK
SQLTimeline registra weekly_report_generatedOK
BackendPOST /weekly/generate con período válido200
BackendPOST /weekly/generate sin período400 · PERIOD_REQUIRED
BackendBase semanal ausenteerror controlado · WEEKLY_REPORT_BASE_NOT_FOUND
BackendRender Markdown contiene FARO ScoreOK
BackendRender HTML contiene tensionesOK
BackendPOST /:id/send cambia status a sentOK
BackendPOST /:id/send sin recipients400 · RECIPIENTS_REQUIRED
BackendRLS bloquea reporte de otra empresa404 / vacío
BackendIA caída · fallback determinísticoOK · ai_audit.fallback_used = true
FrontendHeader muestra empresa y períodoOK
FrontendScore card muestra delta con signoOK
FrontendResumen ejecutivo visibleOK
FrontendTabla tensiones visibleOK
FrontendTabla acciones visibleOK
FrontendEscalamientos visiblesOK
FrontendEmpty states honestosOK · render con mensaje
▸ TypeScript · test backend (vitest)
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();
    });
  });
});
18 · Frontend · estructura

Páginas, componentes y client API

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

▸ TS · árbol de archivos del módulo reports
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
▸ TS · tipos compartidos (reports.types.ts)
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[];
};
▸ TSX · WeeklyReportPage (orquestador)
"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>
  );
}
19 · Criterios de aceptación

Funcional, técnico y de rechazo

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.

Criterios funcionales

CriterioEstado esperado
Genera reporte semanal por empresa
Muestra FARO Score actual
Muestra Score anterior y variación
Muestra resumen ejecutivo con headline + 2-3 párrafos
Muestra tensiones críticas con código canónico
Muestra acciones abiertas/vencidas con responsable
Muestra evidencias pendientes y rechazadas
Muestra escalamientos abiertos con nivel L1-L4
Muestra decisiones requeridas con dueño sugerido
Muestra entre 3 y 5 recomendaciones
Muestra foco próxima semana priorizado
Genera HTML web visualizable
Puede generar PDF descargable
Puede enviarse por email
Registra evento en timeline

Criterios técnicos

CriterioEstado esperado
Crea tabla faro.reports
Crea tabla faro.report_recipients
Usa vista v_weekly_report_base
Usa datos reales del sistema (no hardcodea demo en prod)
Genera content JSON canónico
Genera html_content
Genera markdown_content
Persiste snapshot_payload inmutable
Persiste ai_audit con prompt + response hash
Aplica RLS por company_id
Expone POST /weekly/generate
Expone GET /:id + GET /:id/pdf
Expone POST /:id/send
Registra timeline event en cada acción
Tests básicos pasan (SQL · backend · frontend)

Criterios de rechazo (bloquean merge)

CasoSeveridad
Reporte sin ScoreAlta
Reporte sin tensiones cuando existen tensiones activasAlta
Reporte con datos hardcodeados en producciónCrítica
Reporte cruza empresas (falla RLS)Crítica
Reporte usa frases vagas sin datosAlta
No muestra acciones vencidas cuando existenAlta
No muestra evidencias pendientes cuando existenAlta
No muestra escalamientos cuando existenAlta
No registra evento de generación en timelineMedia/Alta
Email se envía sin guardar estado en report_recipientsAlta
PDF público sin control de accesoCrítica
IA llena slots sin ai_audit registradoCrítica
IA caída no activa fallback determinísticoCrítica
20 · Próximos pasos

Qué falta para cerrar el módulo de reportes

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.

  1. FARO-SCORE-001 · Motor FARO Score MVP. Cerrar el modelo técnico del Score (KPIs → tensiones → severidad → confianza → acciones → evidencia → vencimientos → escalamientos → recuperación → Score final). Sin Score formal, el reporte muestra un número decorativo.
  2. FARO-AI-001 · AI Gateway MVP. Construir el gateway de IA con guardrails que valida prompt + response, calcula hash SHA-256 de ambos y registra ai_audit. Sin gateway, los tres slots de IA del reporte no pueden cumplir D5.
  3. FARO-PDF-001 · Pipeline HTML-to-PDF. Integrar Playwright headless en un job worker dedicado para convertir el HTML del reporte a PDF A4 y subirlo a storage privado con signed URLs.
  4. FARO-EMAIL-001 · Email transactional. Configurar provider (Resend recomendado), templates HTML compatibles con clientes conservadores (Outlook, Apple Mail) y manejo de bounces/complaints.
  5. FARO-JOB-001 · Job scheduler. Configurar cron worker que lance generateWeeklyExecutiveReport() todos los lunes 07:00 para cada empresa activa, con retry exponencial y dead-letter queue.
  6. FARO-OBS-001 · Observabilidad ops. Métricas Prometheus de generación (latencia P50/P95, fallos, costo de IA por reporte), dashboards Grafana y alertas a Slack si el job semanal falla.
  7. FARO-UI-006 · Vista web del reporte. Implementar los 13 componentes React listados en sección 18, con tests unitarios por componente y test E2E del flujo completo (generar → ver → descargar PDF → enviar email).