Contracts — Analítica de clínica (solo lectura)

Sin tablas nuevas. Un endpoint de lectura + firmas puras + página. Zona Europe/Madrid, textos es-ES, dinero en céntimos. Toda agregación acotada a clinicaId de la sesión (FR-001); redefinir tiempo fuera de 006 es defecto (006 FR-010).

1. GET /api/analitica (solo lectura)

Auth: misma cookie de panel que agenda (clinicaDeLaPeticion / exigirClinica). Sin clave ⇒ 401 SIN_ACCESO («Entra con la clave de la clínica para continuar»).

GET /api/analitica HTTP/1.1
Cookie: citaclara_clinica=<sesión>

200 OK — application/json (forma AnaliticaClinica de data-model.md §3)
{
  "clinica": { "id": "uuid", "nombre": "Eleva", "jornada": "09:00–20:00, Lun–Vie" },
  "ventana": { "semanas": [{ "n": 8, "etiqueta": "S-8: 03/08/2026–07/08/2026" }], "generadaCon": "2026-09-30T10:00:00.000Z" },
  "tasas": [{ "profesionalId": "uuid", "nombre": "María", "tasa": 7.3, "texto": "7,3 %", "partes": { "noAsistidas": 11, "total": 150 } }],
  "ocupaciones": [{ "profesionalId": "uuid", "nombre": "María", "ocupacion": 25.1, "minutos": 6615, "denominador": 26400 }],
  "ingresos": { "porServicio": [{ "servicioId": "uuid", "nombre": "sesión fisio", "citas": 126, "centimos": 504000, "texto": "5.040,00 €" }], "totalCentimos": 1435500, "totalTexto": "14.355,00 €" },
  "evolucion": [{ "n": 8, "etiqueta": "S-8: 03/08/2026–07/08/2026", "citas": 48, "completadas": 41, "noAsistidas": 2, "canceladas": 5, "ingresosCentimos": 175000, "ingresosTexto": "1.750,00 €" }],
  "sinDesenlace": 0
}

Errores: 401 SIN_ACCESO sin sesión; 405 METODO_NO_PERMITIDO para POST/PATCH/PUT/DELETE («Esta página es de solo lectura»); nunca 500 por clínica sin historia (devuelve ceros + «Aún no hay datos suficientes» en UI).

2. lib/analitica.ts (puro, testeable sin BD)

import type { SemanaEtiquetada } from './ventanas';

export type CitaAnalitica = {
  profesionalId: string;
  servicioId: string;
  inicio: Date;
  fin: Date;
  estado: 'reservada' | 'completada' | 'cancelada' | 'no_asistida';
  precioCongeladoCentimos: number;
};
export type ProfesionalAnalitica = { id: string; nombre: string };
export type ServicioAnalitica = { id: string; nombre: string };

/** FR-002. null = sin citas («—», no 0 %). Reservadas siempre fuera. */
export function tasaNoAsistencia(citas: CitaAnalitica[]): {
  tasa: number | null;
  texto: string;
  noAsistidas: number;
  total: number;
};

/** FR-003. Minutos reales fin−inicio de completadas / denominador vigente (006 FR-008). */
export function ocupacionSemanal(
  citas: CitaAnalitica[],
  denominadorMin: number,
): { ocupacion: number; minutos: number };

/** FR-004. Solo completadas × precio congelado; resto 0 €. Céntimos enteros. */
export function ingresosPorServicio(citas: CitaAnalitica[]): { citas: number; centimos: number };

/** FR-005. 8 filas S-8…S-1; curso y futuras fuera; etiquetas derivadas. */
export function evolucionSemanal(
  citas: CitaAnalitica[],
  semanas: SemanaEtiquetada[],
): {
  n: number;
  etiqueta: string;
  citas: number;
  completadas: number;
  noAsistidas: number;
  canceladas: number;
  ingresosCentimos: number;
}[];

/** Foto coherente: tasas + ocupaciones + ingresos + evolución + sinDesenlace. Cero escritura. */
export function calcularAnalitica(entrada: {
  citas: CitaAnalitica[];
  profesionales: ProfesionalAnalitica[];
  servicios: ServicioAnalitica[];
  semanas: SemanaEtiquetada[];
  denominadorMin: number;
}): {
  tasas: {
    profesionalId: string;
    nombre: string;
    tasa: number | null;
    texto: string;
    noAsistidas: number;
    total: number;
  }[];
  ocupaciones: {
    profesionalId: string;
    nombre: string;
    ocupacion: number;
    minutos: number;
    denominador: number;
  }[];
  ingresos: {
    porServicio: { servicioId: string; nombre: string; citas: number; centimos: number }[];
    totalCentimos: number;
  };
  evolucion: {
    n: number;
    etiqueta: string;
    citas: number;
    completadas: number;
    noAsistidas: number;
    canceladas: number;
    ingresosCentimos: number;
  }[];
  sinDesenlace: number;
};

Reglas vinculantes: (1) ahora único por cálculo (capturarAhora, 006 FR-001); (2) denominador siempre vía denominadorOcupacion vigente, prohibido 26.400 literal fuera de tests-oráculo; (3) importes solo con sumarCentimos/formatearEuros; (4) etiquetas S-N derivadas de semanasAnalitica, nunca constantes; (5) cero prisma.$executeRaw de escritura en este módulo.

3. Página app/(panel)/analitica/page.tsx

Server Component force-dynamic; sin sesión ⇒ redirect('/acceso'). Muestra las 4 métricas como gráficos SVG (components/grafico-barras.tsx) con eje + valores, letra ≥ 16 px, contraste verificado, cero jerga, todo es-ES. Añade enlace en app/(panel)/layout.tsx («Analítica»). Sin formularios ni botones que muten.

Trazabilidad

Firma / superficie Spec Tests (quickstart)
GET /api/analitica FR-001, FR-006, FR-010 QS-1, QC-6, QC-7
tasaNoAsistencia FR-002 QS-1
ocupacionSemanal + denominadorOcupacion (006) FR-003, FR-008 QS-2
ingresosPorServicio + precioCongeladoCentimos FR-004, FR-007 QS-3
evolucionSemanal + semanasAnalitica (006) FR-005, FR-008 QS-4
página + grafico-barras FR-009 QS-5
aislamiento clinicaId FR-001, SC-006 QC-6
cero escritura FR-006, SC-004 QC-7