# Parte 7 — Brechas de instrumentación, habilitadores y riesgos

> Esta parte existe para que nadie prometa un dashboard imposible. Dice con honestidad brutal qué se puede medir hoy, qué falta capturar, y cuál es la arquitectura analítica que esta plataforma concreta puede sostener.

---

## 7.1 Semáforo de factibilidad por dominio analítico

| Dominio | Estado | Justificación con tablas y columnas concretas |
|---|---|---|
| **Reservas (volumen, estados, ciclo)** | 🟡 | El hecho existe y está bien indexado. Tres correcciones obligatorias: universo `citas ∪ citas_canceladas`, grano franja≠reserva, y que el cron corra para que `Estado=4` y `Estado=5` se generen |
| **Aprobación y SLA** | 🟢 | `AprobadoPorUserId`/`AprobadoFecha` y `NegadoPorUserId`/`NegadoFecha` permiten medir hoy tiempo de decisión, backlog y su antigüedad. Sólo falta separar rechazo de expiración y parametrizar el umbral |
| **Cancelación** | 🟡 | El contenido es excelente (`CanceladaFecha`, `CanceladaPorCanal`, `CanceladaIp`); la forma es venenosa (fila movida fuera de `citas`). Y los motivos son texto libre |
| **No-show y puntualidad** | 🔴 | El no-show es 🟡 (depende del cron). **La puntualidad es 🔴 e inválida hoy**: en préstamo inmediato y práctica libre `HoraEntrega := HoraInicio` por construcción, y en handover manual se autocompleta con `NOW()`. No existe check-in |
| **Ocupación de espacios** | 🔴 | Falta el denominador. La capacidad teórica vive sólo en 8 columnas `mediumtext` con JSON (`agendas.HorarioTotal` + `HorarioLunes..HorarioDomingo`) |
| **Capacidad instalada** | 🔴 | No hay edificio (columna eliminada por migración), no hay piso ni salón, **no hay m² en ninguna tabla**, `activos` está vacía |
| **Uso de equipos** | 🔴 | Dos inventarios paralelos y desconectados; `agendas_recursos` sin serial ni auditoría; `activos` con 0 filas; entrega/devolución en `time` sin fecha y a nivel de cita, no de activo |
| **Escritorio remoto** | 🔴 | 0 filas de historial, servidor inalcanzable, atribución de persona imposible (cuenta de servicio compartida), mapeo equipo↔conexión al 6,8 % |
| **Población / facultades / programas** | 🔴 | `areas_universitarias` vacía y mal mapeada; `clientes.Programa` texto libre con 3 semánticas mezcladas; facultad, semestre y asignatura no existen |
| **Incidencias** | 🔴 | `clientes_tickets` vacía, ciclo truncado, sin `FechaCierre` ni referencia al recurso. La historia real está en `clientes_bloqueos.Motivo`, en texto libre |
| **Mantenimiento** | 🔴 | No existe: cero tablas, cero columnas `Marca`/`Modelo`/`Placa`/`Garantia`. Sólo un estado manual y *sniffing* de texto |
| **Cumplimiento (proceso y dato)** | 🟢 | Es el dominio con mejor factibilidad inmediata y el menos explotado: adherencia al proceso, reservas sin cliente, sin código, huérfanos por dimensión — todo calculable hoy |
| **Demanda vs. oferta** | 🔴 | Los intentos de reserva rechazados **no se persisten en ninguna parte**; la lista de espera es código sin tabla |
| **Comunicación** | 🟡 | La cola es medible; la atribución correo↔reserva no (`alertas_email` sin `IdCita`) |
| **Predicción** | 🔴 | No hay tubería de ML ni ninguna librería numérica. Precondición dura: **dos semestres de datos limpios** |
| **Adopción del sistema y auditoría** | 🔴 | 0 filas en `log_urls` y `log_modules`, 3 en `log_acceso` con `CierreSesion` NULL al 100 %. Los flags `LOG_*` están en `true`: **el problema es de código, no de configuración** |
| **Uso del asistente IA** | 🟢 | 12 conversaciones, 52 mensajes, 168.528 + 31.746 tokens ya registrados. Dashboard construible hoy sin cambio de esquema |

---

## 7.2 Los 14 eventos de negocio que hoy no se registran

Cada fila indica **dónde se dispararía en el código** (los puntos ya están identificados) y **el registro mínimo propuesto**.

| # | Evento | Por qué importa | Dónde se dispara | Registro mínimo |
|---|---|---|---|---|
| **E1** | **Intento de reserva fallido, con motivo** | Es la única fuente de **demanda insatisfecha** — la métrica que justifica ampliar capacidad. Hoy los 12 rechazos de validación producen un mensaje volátil en pantalla | cada retorno de `validateReservationPayload` ([AgendasController.php:1255](../../app/controllers/AgendasController.php#L1255)) y de `ensureClientCanReserve` | `intentos_reserva (FechaHora, IdCliente, IdAgenda, IdAgendaRecurso, Fecha, HoraInicio, HoraFin, Canal, Resultado ENUM, MotivoDetalle, IpOrigen)` |
| **E2** | **Consulta de disponibilidad** | Mide interés real: cuántas veces alguien miró un espacio y no reservó. Es el techo del embudo | `buildSlotAvailability` y `availabilityEvents` del portal | `consultas_disponibilidad (FechaHora, IdCliente, IdAgenda, FechaConsultada, SlotsDisponibles, Convirtio)` — se puede muestrear, no hace falta el 100 % |
| **E3** | **Búsqueda sin resultados** | Indica oferta invisible o inexistente | buscador del portal y `QuickSearchModel` | `busquedas (FechaHora, IdCliente, Termino, Resultados)` |
| **E4** | **Login del cliente al portal** | Sin esto **no hay embudo de conversión del portal ni adopción del autoservicio**. `log_acceso` sólo cubre staff | `PortalController::authenticateAction` | reutilizar `log_acceso` con un discriminador de población, o `log_acceso_clientes` |
| **E5** | **Check-in / llegada real** | Habilita puntualidad, asistencia auditable y la evaluación de `BloqueoLlegadaTarde`, hoy decorativa | nueva acción (QR ya se genera) + mostrador | columnas en `citas`: `LlegadaFecha datetime`, `LlegadaPorUserId`, `Asistentes smallint` |
| **E6** | **No-show confirmado con autor** | Distinguir el no-show detectado por gestión del barrido automático del cron | `ReservationExpirationService::markNoShow` y `reservationHandover` | `citas.NoShowFecha`, `citas.NoShowOrigen ENUM('cron','staff')` |
| **E7** | **Rechazo con motivo tipificado** | Hoy `NegadoObservacion` es prosa. Sin catálogo no hay Pareto de causas | `reservationDecision` | `citas.MotivoRechazoId` + catálogo |
| **E8** | **Cierre por expiración, distinguible del rechazo** | Comparten `Estado=5`, `NegadoFecha` y `NegadoObservacion`. **Es la causa del defecto del reporte RES002** | `expirePending` | `citas.MotivoCierreTipo ENUM('cancel_cliente','cancel_admin','rechazo','expiracion','no_show')` |
| **E9** | **Intento de conexión remota, con resultado** | Cierra el embudo del módulo remoto, da la tasa de fallo y **permite atribuir sesiones a personas** mientras se comparta la cuenta de servicio | `reservationRemoteConnect` ([CitasController.php:2030](../../app/controllers/CitasController.php#L2030)), el flujo equivalente en préstamo inmediato, y `GuacdProbe::test` | `intentos_conexion_remota (FechaHora, Origen, IdCita, IdCliente, IdAgendaRecurso, GuacConnectionId, Resultado ENUM, CodigoEstadoGuacd, LatenciaMs, IdSesionRemota)` |
| **E10** | **Sondeo de disponibilidad de equipo** | Habilita uptime, MTBF, MTTR y la alerta de mayor valor del módulo. **El motor ya existe y no persiste nada** | nuevo comando en `bin/` recorriendo las conexiones con `GuacdProbe` | `equipos_sondeos (IdAgendaRecurso, GuacConnectionId, FechaHora, Alcanzable, CodigoEstado, LatenciaMs)` |
| **E11** | **Cambio de estado de activo o de recurso** | **Es la pieza crítica del mantenimiento**: sin ella no hay downtime ni disponibilidad, para nada. Y `agendas_recursos` no tiene ninguna columna de auditoría | `ActivosController` y `resourceToggleBlock` | `activos_estado_historial (IdActivo | IdAgendaRecurso, EstadoAnterior, EstadoNuevo, Motivo, FechaHora, IdUsuario)` |
| **E12** | **Reporte de falla / incidencia con recurso afectado** | Convierte incidencias dispersas en serie gestionable | botón nuevo en el tablero de préstamos y en la ficha del activo | `activos_mantenimiento_ordenes` reducida (ver DB-25) |
| **E13** | **Apertura / cierre efectivo de laboratorio** | Distingue «cerrado» de «sin demanda»; corrige el denominador de ocupación | registro del monitor o inferido del bloqueo | `aperturas_laboratorio (IdAgenda, Fecha, HoraApertura, HoraCierre, IdUsuario, Motivo)` |
| **E14** | **Canal de origen de la reserva** | El KPI de adopción del autoservicio. Hoy vive dentro de un JSON y sólo para dos flujos | `normalizeReservationPayload`, todos los canales | `citas.CanalOrigen ENUM('portal','mostrador','quick','prestamo_inmediato','practica_libre','recurrencia','api')` |

**Nota de diseño transversal:** cuatro de estos catorce (E5, E6, E8, E14) son **columnas nuevas en `citas`**, no tablas. Son los más baratos y los que desbloquean más KPIs.

---

## 7.3 El problema de la dimensión académica, cuantificado

**Impacto medido:** de los 30 dashboards solicitados, **5 son imposibles** hoy (DB-08 facultades, DB-09 programas, DB-21 estudiantes, DB-22 docentes, DB-23 dependencias) y **3 más quedan mutilados** (DB-01 ejecutivo sin cobertura poblacional, DB-27 comparativo sin periodo académico, DB-11 capacidad sin responsable). De los 147 indicadores, **19 dependen directamente** de esta dimensión.

**Estado exacto verificado:**
- `areas_universitarias`: **0 filas**. `agendas.IdAreaUni` contiene `1101–1108`, que son los códigos de `agendas_categoria`, no ids de área → el join devuelve **16/16 huérfanas**. Y semánticamente no es una dimensión académica: sus valores son tipos de espacio.
- `clientes.Programa`: `varchar(180)` libre, **19 valores distintos en 20 filas**, mezclando programa académico, cargo de monitoría y dependencia administrativa.
- **No existe** `facultades`, `programas`, `periodos_academicos` ni `asignaturas`.
- En la generación anterior existía `programas` con `Facultad`, `Tipo` y `Metodologia`, **y el cliente nunca la alimentó** (3 filas), y `clientes.Programa` no hacía join contra ella ni por código ni por nombre. **La relación cliente→programa nunca estuvo normalizada.**

**Modelo mínimo propuesto (4 pasos, sin tocar `citas`):**

```sql
-- 1. Catálogos
facultades          (Id, Codigo UNIQUE, Nombre, Estado, TenantId)
programas           (Id, Codigo UNIQUE, Nombre, IdFacultad, Nivel ENUM('Pregrado','Posgrado','Tecnico'),
                     Metodologia, IdSede, Estado, TenantId)
dependencias        (Id, Codigo UNIQUE, Nombre, Tipo ENUM('Academica','Administrativa'), Estado, TenantId)

-- 2. Vínculo, conservando el origen
ALTER TABLE clientes ADD IdPrograma int NULL,      -- poblada por mapeo asistido
                     ADD IdDependencia int NULL,   -- separa la 3.ª semántica del campo actual
                     ADD IdVinculo tinyint NULL;   -- catálogo de 5 filas para el actual `Tipo`
-- `clientes.Programa` se conserva como campo de origen, nunca se borra

-- 3. Periodo académico — se resuelve por join de rango, sin tocar `citas`
periodos_academicos (Id, Codigo 'AAAA-1', Nombre, FechaInicio, FechaFin, Activo, TenantId)

-- 4. Snapshot en la reserva, para que la historia no cambie retroactivamente
ALTER TABLE citas ADD IdProgramaSnapshot int NULL, ADD IdFacultadSnapshot int NULL;
```

**Cómo poblarlas.** Hoy: carga manual o CSV (19 valores; a escala real, unas pocas centenas — es trabajo de horas con revisión humana, no de meses). Mañana: por la sincronización académica, que hoy **es un stub que reporta «ok» con un mensaje hardcodeado** y no importa nada.

**El paso 4 es el que casi siempre se olvida y es el que evita el desastre:** sin snapshot, cuando un estudiante cambia de programa **todo su historial de reservas se reatribuye al programa nuevo**, y las series de semestres anteriores cambian sin aviso.

---

## 7.4 La cadena espacio → equipo → conexión → reserva → persona

Sin esta cadena no hay dashboard de utilización de equipos ni de escritorio remoto. **Dicho sin rodeos: hoy está rota, y es el segundo habilitador más importante después de la dimensión académica.**

### Estado actual

```
citas.IdAgendaRecurso ──► agendas_recursos.Id
                             │
                             └─ CodigoGuacamole = base64("{connection_id}\0c\0mysql")
                                  ✗ ROTO: base64 opaco, no indexable, no unible en SQL
                                  ✗ ROTO: 51 de 59 vacías; 4 válidas; 4 con literal inválido
                                  ▼
                          guacamole_connection.connection_id
                             │
                             ├─ guacamole_connection_parameter (hostname) ← el equipo FÍSICO
                             │
                             └─ guacamole_connection_history
                                  ✗ ROTO: guarda connection_NAME (snapshot de texto), no el id
                                  ✗ ROTO: username = 'guacadmin' siempre
                                  ✗ ROTO: sin llave de reserva ni de persona
```

### Cambios exactos necesarios

```sql
-- 1. Llave entera para poder unir en SQL (rellenada al guardar, decodificando lo que ya se decodifica)
ALTER TABLE agendas_recursos
  ADD GuacConnectionId int NULL,
  ADD GuacDataSource varchar(32) NULL DEFAULT 'mysql',
  ADD INDEX idx_recursos_guac (GuacDataSource, GuacConnectionId);
-- CodigoGuacamole se conserva intacto: es lo que construye la URL del cliente

-- 2. Dimensión materializada con SCD2 — resuelve el renombrado, el hostname ausente
--    y la desaparición de la llave al borrar una conexión
dim_equipo_remoto (Id, TenantId, GuacDataSource, GuacConnectionId, GuacConnectionName,
                   Protocolo, HostnameDestino, PuertoDestino, GrupoConexionId, GrupoConexionNombre,
                   MaxConexiones, IdAgenda, IdAgendaRecurso, IdActivo, IdSede, IdAreaUni,
                   VigenteDesde, VigenteHasta, EsVigente)
-- UNIQUE (GuacDataSource, GuacConnectionId, VigenteDesde)
-- Se alimenta con lo que el módulo ya sabe hacer: GET connections,
-- GET connections/{id}/parameters (hostname/port) y GET connectionGroups

-- 3. Unificar los dos inventarios (o declarar el puente mientras convivan)
ALTER TABLE activos ADD IdAgendaRecurso int NULL;   -- o el inverso, según el modelo canónico elegido

-- 4. Hecho de sesión remota con atribución explícita y auditable
fct_sesion_remota (..., IdCita, IdCliente,
                   MetodoAtribucion ENUM('USUARIO_GUAC','VENTANA_CITA','SIN_ATRIBUIR'),
                   ConfianzaAtribucion tinyint,
                   DentroDeVentanaReserva, MinutosDesdeInicioReserva, MinutosSobrepasoReserva)
```

### La atribución de la persona: dos caminos, en este orden

1. **Ahora (esfuerzo BAJO): atribución por ventana de cita.** Casar cada fila de historial con la cita cuyo `IdAgendaRecurso` apunta a esa conexión y cuya ventana `[inicio − 5 min, fin + 10 min]` contiene el `start_date`, con `Estado IN (1,3)`. **Es unívoco y defendible porque el sistema impide reservas solapadas del mismo recurso** (índices `idx_citas_overlap_*`). Se marca cada hecho con `MetodoAtribucion='VENTANA_CITA'` y su confianza.
2. **Después (esfuerzo MEDIO): un usuario Guacamole por cliente.** Las piezas ya existen (`GuacUsuariosModel::create`, `patchAccessPermissions`). Ganancia adicional: `guacamole_user.access_window_start` / `valid_until` permiten que **Guacamole mismo** haga cumplir la ventana horaria, y se cierra el hallazgo de seguridad IDOR.

### Dos cuidados que hunden el proyecto si se ignoran

- **Zona horaria.** `guacamole_connection_history` está en la zona de la JVM (UTC en las imágenes oficiales); `citas.Fecha` + `HoraInicio` en hora local, **sin zona declarada en ninguna columna de la base**. Normalizar **una sola vez, en el ETL**, no en cada consulta.
- **Transporte.** Regla práctica: **SQL directo (usuario de solo lectura o réplica) para los hechos, API REST para el catálogo.** La API no admite filtro por fechas, así que un ETL vía API traería todo el histórico en cada ejecución — que es exactamente lo que hace hoy el dashboard.

---

## 7.5 La dimensión tiempo con calendario académico

Sin ella, «comparativo por semestre», «tendencias desestacionalizadas» y el denominador de días hábiles son imposibles. Es el habilitador más barato de todos.

```sql
dim_tiempo (
  Fecha date PRIMARY KEY,
  Anio, Mes, Dia, DiaSemana, NombreDia, SemanaIso, Trimestre,
  IdPeriodoAcademico,          -- FK a periodos_academicos
  SemanaDelPeriodo tinyint,    -- ← CLAVE: permite alinear semestres por semana, no por fecha
  EsHabil tinyint(1),
  EsFestivo tinyint(1),        -- desde `festivos`
  TipoNoLaborable varchar(40), -- nacional | receso institucional | cierre administrativo
  EsReceso tinyint(1),
  EsSemanaParciales tinyint(1),
  EsSemanaFinales tinyint(1),
  IndiceEstacionalidad decimal(5,2)  -- calculado del histórico
)
```

**Insumos y arreglos previos:**
- `festivos` ya existe pero: sólo tiene **2026**, `Fecha` **no es UNIQUE** (un festivo duplicado excluiría el día dos veces del denominador) y **no tiene tipo ni sede** (no se pueden declarar recesos regionales).
- `SemanaDelPeriodo` es la columna que hace posible DB-27: **comparar la semana 3 del semestre con la semana 3 del semestre anterior**, en lugar de marzo con octubre.
- El `IndiceEstacionalidad` debe calibrarse con la relación pico/valle real, que en producción fue de **17:1**.

Y una `dim_franja_horaria` de grano 30 min (el `Intervalo` típico de las agendas), **definida por rangos y no por igualdad**: hay horas no canónicas (`00:00:01`, `22:21:49`) provenientes de `NOW()` en el préstamo inmediato, y una dimensión por clave exacta las dejaría sin dimensión — precisamente las del canal de autoservicio.

---

## 7.6 La capa analítica recomendada

### Evaluación de opciones frente a las restricciones reales del proyecto

| Opción | Veredicto para esta plataforma |
|---|---|
| **Consultas directas sobre el OLTP** | Es lo que se hace hoy. Funciona con 40 filas; **el dashboard de Guacamole ya demuestra el modo de fallo** (descarga el histórico completo y filtra en memoria). No escala y no puede resolver el denominador de capacidad, que exige parsear JSON |
| **Vistas SQL** | Insuficiente. Las cuatro vistas actuales son de presentación CRUD; y **la capacidad ofertada requiere expandir JSON, imposible en SQL puro** |
| **Esquema estrella separado con ETL** | Correcto en teoría, sobredimensionado aquí: no hay ETL, ni Python, ni orquestador, ni equipo de datos. Introducir Airflow/dbt en un proyecto PHP sin framework añade una dependencia operativa que el cliente no puede mantener |
| **✅ Tablas de agregado precalculadas por cron, en la misma base** | **Recomendada.** Aprovecha exactamente lo que ya existe: el patrón de comando CLI autónomo repetido 8 veces en `bin/`, PDO directo, `core/Cache.php` con namespaces (construido y prácticamente sin usar), y las ~54 consultas de agregación ya escritas en `GraficasModel` y `ReportesModel` |

### Arquitectura concreta

```
┌──────────────────────────────────────────────────────────────────────┐
│  OLTP  (timeklee2)                    guacamole_db (RO / réplica)     │
│  citas · citas_canceladas · agendas   guacamole_connection_history    │
└───────────────┬──────────────────────────────┬───────────────────────┘
                │                              │
                ▼   bin/analytics-refresh (cron nocturno, con lock)
┌──────────────────────────────────────────────────────────────────────┐
│  CAPA SEMÁNTICA (vistas)                                              │
│  vista_reservas_historico   ← UNION ALL de citas ∪ citas_canceladas   │
│                               con EstadoFinal y MotivoCierreTipo      │
│                               normalizados. FUENTE ÚNICA DE BI.       │
│  vista_capacidad_ofertada   ← materializada desde los JSON de horario │
└───────────────┬──────────────────────────────────────────────────────┘
                ▼
┌──────────────────────────────────────────────────────────────────────┐
│  AGREGADOS (tablas físicas, con FechaCalculo y EstadoRefresco)        │
│  kpis_diarios            (Fecha, IdSede, IdAgenda, IdCategoria,       │
│                           Estado, reservas, horas, personas_hora,     │
│                           no_shows, cancelaciones, minutos_reales)    │
│  agg_ocupacion_franja    (Fecha, IdAgenda, Franja, ofertados,         │
│                           reservados, usados, ocupacion_pct)          │
│  agg_uso_equipo_dia      (Fecha, IdAgendaRecurso, sesiones, horas,    │
│                           usuarios_unicos, horas_disponibles)         │
│  agg_concurrencia_15min  (FechaHora, IdAgenda, sesiones_activas)      │
│  fct_sesion_remota       (grano: una sesión, con atribución)          │
│  fct_intento_conexion    (grano: un intento, con resultado)           │
│  clientes_riesgo         (IdCliente, score_no_show, factores)          │
│  recomendaciones         (regla, objeto, impacto, estado)              │
│  dim_tiempo · dim_franja_horaria · dim_equipo_remoto                  │
└───────────────┬──────────────────────────────────────────────────────┘
                ▼
      Dashboards · Reportes Excel · Asistente IA · API de datos
      (ninguno consulta el OLTP en caliente)
```

### Modelo de refresco

| Agregado | Frecuencia | Ventana recalculada |
|---|---|---|
| `kpis_diarios`, `agg_ocupacion_franja` | nocturno 02:00 | últimos 7 días (los datos cambian de forma retroactiva por aprobaciones y cancelaciones) |
| `agg_uso_equipo_dia`, `agg_concurrencia_15min` | nocturno | últimos 7 días |
| `fct_sesion_remota` | cada 30 min | incremental por `SourceHistoryId`, idempotente por `UNIQUE (SourceDataSource, SourceHistoryId)` |
| `fct_intento_conexion` | en línea | lo escribe la aplicación en el momento del intento |
| `vista_capacidad_ofertada` | nocturno + al guardar una agenda | horizonte de +90 días |
| `clientes_riesgo`, `recomendaciones` | nocturno | recálculo completo (volumen pequeño) |
| Panel del día | en vivo sobre OLTP | son conteos de hoy con índices ya existentes: es correcto no precalcularlos |

**Cuatro requisitos no negociables de esta capa:**
1. **Lock de ejecución** (tabla o fichero) para evitar dobles corridas.
2. **Registro de última ejecución y resultado** por agregado, expuesto en la UI como «datos al …». Un dashboard sin marca de frescura no es auditable.
3. **Control de reconciliación**: alerta automática cuando la suma por dimensión ≠ total general. Es el detector de huérfanos y de dimensiones rotas — el que habría descubierto en el primer día que el 100 % de las reservas cae en «Sin área».
4. **Idempotencia**: cada refresco debe poder repetirse sin duplicar.

**Lo que hay que reutilizar y no reescribir:** las ~19 agregaciones de `GraficasModel`, las ~35 de `ReportesModel`, el helper `delta()`, el patrón puro y testeable de `GuacHistorialStats`, y el catálogo formal de reportes con despacho dinámico. **La capa analítica de esta plataforma no hay que construirla desde cero: hay que reubicarla, corregirle cuatro supuestos y darle un denominador.**

---

## 7.7 Los defectos de calidad de dato más peligrosos, y cómo mienten

La lista completa (16) está en la Parte 2 §2.9. Aquí sólo los que **se manifestarían como una mentira concreta en un dashboard**, que es lo que decide la prioridad:

| Defecto | Cómo miente el dashboard | Corrección |
|---|---|---|
| Cancelaciones movidas fuera de `citas` | «Tasa de cancelación: 0 %» cuando la real es 12 % | Universo unificado |
| Rechazo y expiración con el mismo estado | «SLA de respuesta: 3 h» incluyendo casos donde nadie respondió nunca | `MotivoCierreTipo` |
| Sin denominador de capacidad | «Ocupación» que en realidad son horas absolutas: un auditorio y un cubículo se comparan como iguales | `vista_capacidad_ofertada` |
| Franja ≠ reserva | «1.200 reservas» cuando fueron 400 solicitudes de 3 franjas | `IdSolicitud` |
| `areas_universitarias` vacía y mal mapeada | Un gráfico con una sola barra: «Sin área, 100 %» | Poblar y re-mapear |
| `time` sin fecha en entrega/devolución | Duración de préstamo **negativa** al cruzar medianoche | `datetime` |
| Nulos masivos con `COALESCE(...,0)` | «0 % de no-show» derivado de 92 % de nulos. **Un decano puede recortar personal con esa cifra** | Denominador explícito + cobertura visible |
| Texto libre en programa y motivos | 40 variantes de «Ingeniería de Sistemas» como 40 programas distintos | Catálogos + tabla de mapeo |
| Sin auditoría temporal en maestras | El histórico de ocupación **cambia retroactivamente** al editar un aforo, sin aviso | Fechas + SCD2 |
| Colisión de collations | Power BI / Metabase **fallan con error de servidor**, no con dato degradado | Unificar collation |
| Sin FK y con borrado físico | Los totales por dimensión no suman el total general y nadie sabe por qué | FK + borrado lógico + fila «Desconocido» |
| Horas no canónicas | Huecos artificiales en el heatmap, justo en el canal que más interesa medir | Franja por rangos |

---

## 7.8 Los cinco habilitadores, y qué desbloquea cada uno

| # | Habilitador | Esfuerzo | Desbloquea |
|---|---|---|---|
| **1** | **Encender el sistema** (3 crons + healthcheck + runbook) y **unificar el universo de reservas** (vista `vista_reservas_historico`) | **Días** | 14 KPIs · corrige 5 reportes y 2 dashboards existentes · hace que el no-show exista |
| **2** | **`dim_tiempo` + `periodos_academicos` + arreglar `festivos`** | **Días** | 9 KPIs · DB-07, DB-26, DB-27, DB-28 · el denominador de días hábiles |
| **3** | **Dimensión académica** (`facultades`, `programas`, `IdPrograma`, snapshot en la reserva) | **1–2 semanas** | 19 KPIs · DB-08, DB-09, DB-21, DB-22, DB-23 · **la conversación con decanos** |
| **4** | **Capacidad ofertada** (materializar los JSON de horario menos bloqueos y festivos) | **1–2 semanas** | 11 KPIs · convierte 8 dashboards de descriptivos a diagnósticos · **el KPI que financia el sistema** |
| **5** | **Cadena equipo↔conexión↔reserva** (`GuacConnectionId`, `dim_equipo_remoto`, atribución por ventana, tabla de intentos, sondeos) | **3–4 semanas** | 22 KPIs · DB-04, DB-05, DB-15, DB-16 y los 8 dashboards remotos · **el diferenciador comercial** |

**Cobertura conjunta (los habilitadores se solapan entre sí, por eso la suma de la columna anterior es mayor): 41 de los 60 indicadores en rojo y 19 de los 23 dashboards en rojo.** Los 19 KPIs restantes dependen de tres módulos nuevos que son proyectos en sí mismos: mantenimiento, check-in con asistentes, y jerarquía física del campus.

---

## 7.9 Veinte riesgos de la iniciativa analítica

### Técnicos
1. **El dashboard cae en la primera demo con volumen real** (patrón actual: descargar el histórico y filtrar en memoria) → mover la agregación a SQL y precalcular **antes** de cualquier demo.
2. **Los joins de BI fallan por collation**, no con dato malo sino con error 1267 → unificar la collation antes de conectar cualquier herramienta externa.
3. **El refresco nocturno se solapa consigo mismo** y duplica agregados → lock de ejecución obligatorio.
4. **Las series históricas cambian retroactivamente** al editar una maestra sin auditoría → SCD2 y fechas en maestras, y congelar snapshots en el hecho.
5. **El desalineamiento de zona horaria** hace que sesiones remotas y reservas no se alineen → normalizar en el ETL, una sola vez.
6. **Aplicar las migraciones pendientes rompe algo**: la de `clientes_bloqueos` ya está rota en bases frescas → probar la cadena completa en una base limpia antes de tocar producción.
7. **`agendas.Cupos` es `tinyint`** y el cupo efectivo se topa silenciosamente al número de recursos activos → un cambio de inventario **altera retroactivamente el significado de la capacidad histórica**.

### De datos
8. **Se publican KPIs sobre tablas vacías** y el cliente concluye que el sistema no sirve → bandera de bloqueo por artefacto hasta que su fuente tenga datos.
9. **Se presenta un colapso de adopción como éxito de eficiencia** (el patrón real de la generación anterior: la caída de 2024 no fue mejora de gestión, fue abandono) → cruzar toda caída con la actividad de operadores y la cobertura del dato.
10. **Datos sintéticos o anonimizados se cuelan en un comité** → marca de origen obligatoria (`OrigenDato`) y aviso visible en modo demo.
11. **Se siembran datos falsos en `guacamole_connection_history`**, que es una tabla de auditoría → sólo en una base espejo de demostración, **nunca en la instancia productiva del cliente**.
12. **El mapeo manual de programas se degrada** con el tiempo y nadie lo mantiene → tabla de mapeo con dueño nominal y un KPI de «% de clientes sin programa mapeado» en el propio dashboard.
13. **La calidad del dato poblacional depende al 100 % de digitación manual** (la sincronización es un stub) y **las bajas no se gestionan**: un estudiante graduado sigue activo indefinidamente → todo indicador de «población activa» está sobreestimado en magnitud desconocida.

### Organizativos y de adopción
14. **Se construyen 51 dashboards y se usan 3.** Es el modo de fallo más común de estas iniciativas → empezar por los 12 KPIs prioritarios en un solo tablero, medir su uso real, y expandir con evidencia.
15. **Nadie es dueño de los números.** Sin un dueño por dominio, las discrepancias no se resuelven y el sistema pierde credibilidad → asignar dueño por KPI en el propio catálogo.
16. **Fatiga de alertas**: nada impide hoy enviar la misma alerta 288 veces al día → deduplicación y frecuencia máxima por regla **desde la primera versión**, no después.
17. **El check-in no se hace.** La generación anterior tenía los campos y estuvieron 91–100 % vacíos → si no es un gesto único (un escaneo), no se hará, y todos los KPIs de asistencia seguirán vacíos.
18. **Dos analistas producen cifras distintas** eligiendo fuentes distintas del mismo hecho (solicitante duplicado, `IdActivo` vs. `citas_activos`) → declarar la fuente canónica y **prohibir el acceso directo al OLTP desde BI**.

### Legales y de privacidad
19. **Los dashboards nominativos exponen datos personales** (`clientes` contiene documento, correo, teléfono y el hash de contraseña en la misma tabla dimensional) → excluir credenciales del warehouse, agregación mínima obligatoria en dashboards poblacionales, y acceso por rol. Un ranking de «top 50 usuarios» es un dato personal, no una métrica.
20. **La grabación de sesión remota**, si alguna vez se activa para medir actividad, **requiere consentimiento y política de retención explícitas**. Y `remote_host` es un dato personal: seudonimizar con hash estable si se importa de otra instancia.

---

## 7.10 Siguientes pasos

Esta auditoría termina aquí, tal como se acordó: **primero el diagnóstico, después el plan**.

Lo que queda por decidir antes de armar el plan de implementación, y que conviene resolver con el cliente y no en solitario:

1. **¿Cuál es el modelo canónico de equipo?** Unificar en `activos`, en `agendas_recursos`, o mantener ambos con un puente. Es la decisión de la que dependen 27 indicadores y cuatro dashboards, y no es reversible sin coste.
2. **¿Se implementa el check-in?** Sin él, siete indicadores de asistencia y puntualidad quedan permanentemente en rojo — y la evidencia histórica dice que el campo solo no basta: hay que diseñar el gesto.
3. **¿Se recupera la jerarquía física del campus?** Es la puerta de entrada al Director de Infraestructura, y hay datos reales recuperables de la generación anterior.
4. **¿Cuál es la estrategia de datos del módulo remoto** para poder demostrarlo: fixtures detrás del repositorio, base espejo de demostración, o Guacamole real generando sesiones orgánicas.
5. **¿Se prioriza la dimensión académica ahora?** Es el habilitador con mayor retorno comercial (abre la conversación con decanos y con acreditación) y depende de un dato que sólo el cliente puede aportar.

Con esas cinco respuestas, el plan de implementación se puede secuenciar en fases con criterio, y no como una lista de dashboards.

---

*Fin de la auditoría. Índice general en [README.md](README.md).*
