Data Model — 004 Panel de analítica

Fuente: spec.md FR-001…FR-010 + research R1…R7. Esta feature NO crea tablas (FR-006/S-07); el modelo describe (a) lecturas agregadas, (b) la enmienda S-05 pendiente a Cita (propiedad 001) y (c) la forma del agregado. Dinero en céntimos enteros, tiempo en timestamptz zona Europe/Madrid, textos es-ES.

1. Enmienda S-05 a Cita (001, prerrequisito bloqueante)

Propiedad de la 001 (001 FR-006); aquí solo migración + escritura al reservar + semilla. La página 004 solo lee.

Campo Tipo Reglas
precioCongeladoCentimos Int NOT NULL NUEVO copia de Servicio.precioCentimos vigente al reservar; inmutable después; fuente única de FR-004. Escritura en POST /api/citas (transacción de alta, junto a fin = inicio + duracionMin) y en prisma/seed.ts (cada cita congela el precio de su servicio). Migración con retrolleno: UPDATE "Cita" SET "precioCongeladoCentimos" = (SELECT "precioCentimos" FROM "Servicio" WHERE id) para filas previas (en semilla v1 coincide con oráculos)
inicio / fin existentes fin − inicio real = minutos ocupados si completada (FR-003 usa el tramo real, no duracionMin vigente)
estado reservada\|completada\|cancelada\|no_asistida completada genera ingreso; no_asistida alimenta tasa; cancelada libera tramo; reservada futura o con fin pasado («sin desenlace», 001 FR-011) fuera de tasa/ocupación/ingresos/evolución, reportada aparte
clinicaId / profesionalId / servicioId existentes todo agregado filtra por clinicaId de la sesión (FR-001); profesionales/servicios se listan por clinicaId incluyendo activo=false (las bajas siguen contando)

Prisma: precioCongeladoCentimos Int. Sin índice nuevo (el existente [clinicaId, inicio] cubre la ventana).

2. Lecturas agregadas (efímeras, sin persistencia)

Una sola foto coherente por carga: ahora = capturarAhora() → semanas = semanasAnalitica(ahora) → una lectura de citas con clinicaId + inicio ∈ [S-8.inicio, S-1.fin] + profesionales/servicios de la clínica. Nunca mezcla mitades de dos estados (edge spec).

Agregado Fórmula (precisión minuto / céntimos) Incluye / excluye
Tasa por profesional (FR-002) no_asistidas / (completada+no_asistida+cancelada) en ventana; 1 decimal, «7,3 %»; clínica = mismo cociente global incluye bajas; excluye toda reservada (futura y sin desenlace, aparte); sin citas ⇒ «—»
Ocupación por profesional (FR-003) UN número = Σ min(completadas en ventana) / denominadorOcupacion(vigente); 1 decimal cancelada/no_asistida/reservada = 0 min; denominador = minJornada × nºDías × 8 vigente (006 FR-008), declarado con etiquetarJornada
Ingresos por servicio (FR-004) solo completada: Σ precioCongeladoCentimos; formatearEuros («5.040,00 €»); total = sumarCentimos cancelada/no_asistida = 0 €; incluye servicios dados de baja; cambio de tarifa posterior no mueve lo ya cerrado
Evolución 8 semanas (FR-005) por semana S-8…S-1: citas, completadas, no_asistidas, canceladas, ingresos(completadas); etiqueta S-N: DD/MM/AAAA–DD/MM/AAAA derivada semana en curso y futuras reservada fuera; suma = 409 (335+41+33) e 14.355,00 € solo en ventana de referencia
Sin historia cada gráfico «Aún no hay datos suficientes»; ingresos «0,00 €»; sin división por cero nunca rellena con otra clínica (FR-001)

3. Forma del agregado (contrato contracts/analitica.md)

AnaliticaClinica {
  clinica: { id, nombre, jornada: "09:00–20:00, Lun–Vie" (etiquetarJornada vigente) }
  ventana: { semanas: [S-8 … S-1 con etiqueta + rango], generadaCon: ahora ISO }
  tasas: [{ profesionalId, nombre, tasa: 7.3|null, texto: "7,3 %"|"—", partes: {noAsistidas, total} }]
  ocupaciones: [{ profesionalId, nombre, ocupacion: 25.1, minutos: 6615, denominador: 26400 }]
  ingresos: { porServicio: [{ servicioId, nombre, citas: 126, centimos: 504000, texto: "5.040,00 €" }], totalCentimos: 1435500, totalTexto: "14.355,00 €" }
  evolucion: [{ n: 8…1, etiqueta: "S-8: 03/08/2026–07/08/2026", citas, completadas, noAsistidas, canceladas, ingresosCentimos, ingresosTexto }]
  sinDesenlace: number  // reservadas con fin pasado, solo informativa
}

4. Diagrama (texto)

sesión (misma clave agenda, FR-001) ──► clinicaId
ahora = capturarAhora() ──► semanasAnalitica(ahora) [S-8…S-1, curso fuera]
clinica(vigente: jornada+dias) ──► denominadorOcupacion ──► ocupación (declara jornada)
citas[clinicaId + ventana] ──┬──► tasa (no_asistida/total, con canceladas)
                             ├──► ocupación (min completadas / denominador)
                             ├──► ingresos (Σ precioCongelado de completadas, al céntimo)
                             └──► evolución (8 filas, suma = historia)
profesionales/servicios[clinicaId, activos+inactivos] ──► dimensiones (bajas cuentan)
sin escritura: cero POST/PATCH/DELETE; diff BD antes/después = 0 (SC-004)