# Parte 2 — Inventario de la información disponible (FASE 2)

> Objetivo: dejar un inventario a nivel columna que permita a un equipo de BI construir un modelo dimensional sin volver a mirar el código.

**Hallazgo estructural transversal, verificado:** la base **no tiene ni una sola llave foránea real**. La consulta a `information_schema.KEY_COLUMN_USAGE` con `REFERENCED_TABLE_NAME IS NOT NULL` devuelve **0 filas**. La única restricción declarativa de todo el esquema es un CHECK: `chk_citas_horas_validas (HoraInicio < HoraFin)`. **Todas las relaciones descritas aquí son lógicas, sostenidas únicamente en código PHP.**

---

## 2.1 Panorama: 32 tablas base + 4 vistas

| Dominio | Tablas | Volumen vivo (dev) | Valor analítico |
|---|---|---|---|
| **Reservas (hechos)** | `citas`, `citas_canceladas`, `citas_activos`, `citas_activos_canceladas` | 40 / 1 / 0 / 0 | **MÁXIMO** |
| **Oferta (dimensiones)** | `agendas`, `agendas_categoria`, `agendas_recursos`, `agendas_software`, `agendas_bloqueo`, `festivos`, `software_catalogo` | 16 / 8 / 59 / 4 / 3 / 13 / 6 | ALTO |
| **Población** | `clientes`, `clientes_bloqueos`, `clientes_tickets` | 20 / 0 / 0 | ALTO |
| **Inventario patrimonial** | `activos`, `activos_tipos` | 0 / 0 | ALTO en potencial, NULO hoy |
| **Organización** | `sedes`, `areas_universitarias` | 3 / **0** | ALTO / **ROTO** |
| **Identidad y permisos** | `usuarios`, `roles`, `permisos` | 1 / 2 / 36 | MEDIO |
| **Comunicación** | `alertas`, `alertas_email` | 0 / 19 | MEDIO |
| **Auditoría** | `log_acceso`, `log_modules`, `log_urls` | **3 / 0 / 0** | ALTO en potencial, **NULO hoy** |
| **Asistente IA** | `asistente_ia_conversaciones`, `_mensajes`, `_acciones`, `_reportes` | 12 / 52 / 0 / 0 | MEDIO-ALTO |
| **Sistema** | `configuraciones`, `migrations` | 6 / 27 | Contexto |

**Tres tablas que el código usa y que NO EXISTEN en la base:** `citas_lista_espera`, `citas_recurrencias` y la columna `citas.IdRecurrencia`. Las migraciones `2026_04_20_120001/130001/140001` **nunca se aplicaron** (la última registrada es `110001`), porque la migración de extensión de `clientes_bloqueos` está rota en bases frescas y bloquea la cadena. Efecto: **lista de espera y reservas recurrentes fallan en runtime**.

---

## 2.2 La tabla de hechos principal: `citas`

**Grano: una franja reservada, no una reserva.** Una petición de 4 horas con `Duracion=60` produce **4 filas**, y no existe ninguna columna que las agrupe. Esto tiene una consecuencia que hay que decir en voz alta: **el KPI «número de reservas» está inflado por un factor variable de 1× a 4× que depende de la configuración de cada agenda** — y ese factor cambia si alguien edita `agendas`, sin dejar rastro (`agendas` no tiene `FechaModificacion`).

### Bloques de columnas y su significado

| Bloque | Columnas | Notas de negocio |
|---|---|---|
| **Qué se reserva** | `IdAgenda` (NOT NULL), `IdAgendaRecurso`, `IdActivo`, `TipoReserva` ('espacio'/'activo'), `CodigoRecurso`, `RecursoEntregado` | Los dos últimos son **copias textuales denormalizadas**: son snapshot histórico, pero no está documentado como tal. Las reservas de activo tienen `IdAgenda=0` → **rompen todo `INNER JOIN agendas`** |
| **Quién reserva** | `IdCliente` (nullable) **+** `NombreSolicitante` (NOT NULL), `DocumentoSolicitante`, `EmailSolicitante`, `TelefonoSolicitante` | Cuarteto duplicado. Útil para reservas anónimas, fuente de doble verdad para BI |
| **Cuándo** | `Fecha` (date) + `HoraInicio` (time) + `HoraFin` (time) | **Split clásico en 3 columnas.** Requiere `TIMESTAMP(Fecha, HoraInicio)` para cualquier orden cronológico correcto |
| **Aprobación** | `AprobadoPorUserId`, `AprobadoFecha`, `AprobadoObservacion`, `NegadoPorUserId`, `NegadoFecha`, `NegadoObservacion` | Par simétrico mutuamente excluyente en 6 columnas, en lugar de una transición modelada. **Permite medir el SLA de aprobación hoy** |
| **Entrega / devolución** | `EstadoEntrega`, `HoraEntrega` (**time**), `HoraDevolucion` (**time**), `ComentariosEntrega` | **`time` sin fecha**: un préstamo entregado a las 22:00 y devuelto a la 01:00 da `TIMESTAMPDIFF` de **−21 horas**. Toda la métrica de duración real está corrupta para operaciones que cruzan medianoche |
| **Otros** | `CodigoQr`, `CupoConsumido` (smallint, default 1), `InformacionAdicional` (longtext JSON), `Comentarios`, `TenantId` | `CupoConsumido` es **la única medida aditiva nativa de personas** y nadie la usa. El **canal de origen vive dentro del JSON** (`InformacionAdicional.__origen`), no en una columna |

### Estados observados en la base viva (40 filas)
- `Estado`: 0→4, 1→23, 2→3, 3→7, 4→3. El valor **5 (Negada) existe en código y no en el COMMENT** de la columna.
- `EstadoEntrega`: 0→32 (**80 %**), 1→6, 2→2. Es decir, el **78 % de las reservas no tiene dato de uso real**.
- `TipoReserva`: `'espacio'`→37 y **`''` cadena vacía→3** (el default no se aplicó; son justamente las de práctica libre).
- **Contaminación de granularidad horaria:** `HoraInicio` contiene tanto horas canónicas de slot (07:00, 08:00…) como **marcas de reloj** (`00:00:01`, `12:11:00`, `22:21:49`) originadas en `NOW()` del préstamo inmediato. **16 valores distintos en 40 filas.** Cualquier dimensión de franja horaria con claves exactas dejará esas filas sin dimensión — y son precisamente las del canal de autoservicio.
- **Integridad ya rota con 40 filas**: 2 filas apuntan a un `IdCliente` inexistente; 1 a un `IdAgendaRecurso` inexistente; ~12 % tienen `IdCliente` NULL; **68 % no tienen `CodigoQr`** (los seeders y el servicio de recurrencias no lo generan) → el código de reserva no sirve como identificador operativo.

### Fortaleza a reconocer
13 índices secundarios, incluidos dos compuestos anti-solape, motor InnoDB y el CHECK de horas. **La tabla está bien indexada para el operativo y protegida contra overbooking.**

---

## 2.3 `citas_canceladas`: contenido valioso, forma venenosa

Replica 32 de las 35 columnas de `citas` y añade el bloque de cancelación: `IdCitaOriginal` (UNIQUE), `EstadoOriginal` + `EstadoOriginalTexto`, `CanceladaPorCanal` ('portal'/'admin'), `CanceladaPorUserId`, `CanceladaPorClienteId`, `CanceladaPorNombre`, `CanceladaMotivo` (text libre), **`CanceladaFecha` (datetime NOT NULL)**, `CanceladaIp`.

**Lo que aporta y nadie explota:** es la **única** fuente de canal, actor, motivo, IP y **antelación de cancelación** (`TIMESTAMPDIFF(CanceladaFecha, TIMESTAMP(Fecha,HoraInicio))`) — la métrica que decide si la política `DiasCancelar`/`HorasCancelar` funciona.

**Lo que rompe:** al **mover la fila fuera de `citas`**, toda consulta de demanda total, embudo o tendencia que sólo lea `citas` **subcuenta en silencio**. Sin error, sin NULL: sólo un número más bajo. Y las dos vistas **no son unificables con un `UNION ALL` directo** porque difieren en nombres y en columnas presentes (`vista_citas_canceladas` omite `Sede`, `IdAreaUni`, `Area` y `EstadoEntregaTexto`).

> Corrección recomendada: borrado **lógico**, no movimiento físico. Dejar las canceladas en `citas` con `Estado=2` y mover el bloque `Cancelada*` a columnas de `citas` o a una tabla satélite 1:1. Interinamente: crear `vista_reservas_historico` con el `UNION ALL` y mapeo explícito, y **prohibir en BI el acceso directo a `citas`**.

---

## 2.4 La dimensión de oferta: `agendas` y su ecosistema

### `agendas` — 68 columnas, la tabla más importante y más problemática

Mezcla en una sola fila **identidad del espacio + reglas de negocio + horario semanal serializado**. Sólo ~15 de sus 68 columnas son atributos analíticos; el resto es configuración de aplicación.

**Lo que sirve como dimensión:** `Nombre` (varchar(50), corto para nombres reales), `Codigo` (UNIQUE, y también fallback de conexión remota), `IdCategoria`, `Sede` (**tinyint**, contra `sedes.Id` smallint), `IdAreaUni` (**roto**), `CapacidadPersonas` (aforo declarado, 0–500), `Cupos` (**tinyint**, capacidad transaccional), `Estado`, y las siete banderas de canal (`VisiblePortal`, `PortalPracticaLibre`, `PermitePrestamoInmediato`, `PermiteConexionRemota`, `RequiereConfirmacion`, `Externo`, `Adicional`).

**Lo que bloquea la analítica:**
- **La capacidad teórica vive en 8 columnas `mediumtext` con JSON** (`HorarioTotal` + `HorarioLunes..HorarioDomingo`). `HorarioTotal` tiene formato `{version:3, range:{start,end,interval}, slots:[], days:{}, ranges:{}}`. **No es consultable por SQL.** Es la razón raíz de que no exista un KPI de ocupación real.
- **No tiene `FechaRegistro` ni `FechaModificacion`.** Es la única tabla maestra del núcleo sin auditoría temporal → **SCD tipo 2 imposible**. Si la capacidad de un auditorio pasa de 200 a 150, todo el histórico se recalcula contra 150 y **las series de años anteriores cambian retroactivamente sin aviso**. En la generación anterior sí existían esas columnas.
- **Tipos laxos graves**: `IdUsuario` es `varchar(250)` para un CSV de ids (y `vista_agendas` hace `a.IdUsuario = u.Id`, comparando varchar contra int fila a fila, sin índice); `Duracion` es `varchar(3)` con COMMENT 'minutos' → aritmética sobre texto con techo implícito de 999.
- **`Area` varchar(25) duplica literalmente `agendas_categoria.Nombre`**, truncado, y con *fallback* `'Area ' . IdAreaUni` → basura previsible en la dimensión.
- **Aforo y capacidad transaccional no se validan entre sí**: «Zona Verde Eventos» tiene `CapacidadPersonas=500` y `Cupos=1`.

### Lo que falta para hablar de capacidad instalada

| Falta | Evidencia | Consecuencia |
|---|---|---|
| **Edificio** | La columna `agendas.Edificio` fue **eliminada deliberadamente** por la migración `2026_03_07_100001:87` (`ALTER TABLE agendas DROP COLUMN Edificio`). En producción anterior hay **21 edificios** | Sin mapa de calor de campus, sin presupuesto de espacio por módulo |
| **Piso y salón** | En producción anterior: **46 salones** con `Piso`, `Capacidad` (1–73 puestos, total 858), `EsAula`, `PracticaLibre`, `EsVirtual`, `Programacion` | El «salón» y la «agenda» se fusionaron en una fila. Sin `Capacidad` por espacio físico no hay % de aforo utilizado |
| **Superficie (m²)** | `grep` de `m2|AreaM2|metros` → **0 resultados** en todo el dominio | **La ocupación en m² y la densidad personas/m² son hoy imposibles** |
| **Puestos separados del aforo** | `CapacidadPersonas` es un único número declarativo | No se distingue puesto fijo de silla móvil |
| **Vigencia de la oferta** | En producción existía `FinCalendario`; se perdió | El horario de una agenda es perpetuo; no hay agendas caducadas |

> Nota sobre nomenclatura engañosa: la migración llamada `add_space_fields_to_agendas` **no agrega campos de espacio**: sólo agrega `IdCategoria` y `CapacidadPersonas`.

### `agendas_recursos` — el inventario que sí se usa (59 filas)

Cada fila es una unidad física asignable. **`CodigoGuacamole` es el único puente al mundo del acceso remoto.**

- **No hay cantidades**: el stock es el conteo de filas con `Estado=1` (55 de 59).
- **No hay serial, marca, modelo, fecha de compra, valor, garantía, proveedor, ubicación ni estado operativo** más allá de activo/inactivo binario. El estado «en mantenimiento» se codifica **en prosa dentro de `Descripcion`** o abusando de `Tipo`.
- **`Tipo` es texto libre**: 12 valores en 59 filas, con `'Computador'` (1) vs `'Computador de mesa'` (18) y la anomalía `'Mantenimiento preventivo'` (un estado disfrazado de tipo).
- **Sin columnas de auditoría** → imposible medir cuándo entró o salió un equipo del inventario, ni la antigüedad de un bloqueo.
- **Sincronización destructiva**: `syncForAgenda` hace upsert y **borra físicamente** todo lo no incluido. Borrar un recurso destruye la trazabilidad de las reservas que lo usaron. Y el sync escribe con PDO directo, **saltándose el modelo → no genera registro de auditoría**.
- **No hay compartición entre agendas**: un portátil pertenece a exactamente una agenda; un equipo usado por dos laboratorios debe duplicarse.

### `agendas_bloqueo` (3 filas) — el único registro de tiempo no disponible
Insumo obligatorio del denominador de ocupación. `Motivo` es varchar(250) de texto libre **sin catálogo ni tipología** → clasificar bloqueos por causa (mantenimiento vs. evento vs. obra) exige NLP. **Split de fecha y hora en 4 columnas.** Dos defectos de negocio: el bloqueo global (`IdAgenda=-1`) **no se filtra por sede**, y el rango horario se aplica igual a todos los días del rango. Y `ReportesModel::getBloqueos` usa `INNER JOIN agendas` → **excluye todos los bloqueos globales**: el impacto de los bloqueos masivos es invisible en el reporte oficial.

### `festivos` (13 filas, **todas de 2026**)
`Fecha` **no es UNIQUE** → un festivo duplicado excluiría el día dos veces de cualquier cálculo de días hábiles. **No hay campo de tipo** (festivo nacional vs. receso institucional vs. cierre administrativo) aunque los datos lo requieren. **No tiene columna `Sede`** → no se pueden declarar recesos regionales. Cualquier cálculo de días hábiles fuera de 2026 es incorrecto.

### `software_catalogo` (6) + `agendas_software` (4)
Dimensión limpia **sin hecho que la use**: se declara el software *disponible a nivel agenda*, y **no se registra qué software usó realmente cada sesión**. Falta `Version` (la producción anterior la tenía, con 157 registros), número de licencias, tipo, costo y vencimiento. Y el vínculo es **agenda↔software, no recurso↔software**: no se puede declarar que sólo 5 de los 30 PCs tienen AutoCAD → **la demanda real de licencias concurrentes es inestimable**. Estado real: sólo **1 de 16 agendas** tiene software vinculado.

---

## 2.5 La dimensión poblacional: `clientes`

20 filas, todas de seeder. Es la dimensión con mayor poder explicativo del negocio y la más sucia.

| Columna | Problema analítico |
|---|---|
| `Tipo` varchar(120) | 5 valores de negocio (Estudiante 8, Docente 5, Monitor 3, Administrativo 3, Externo 1) **definidos en código, no en BD**. Debería ser FK a un catálogo de 5 filas. **No se usa en ningún reporte ni gráfica** |
| `Programa` varchar(180) | **Texto libre. 19 valores distintos en 20 filas** — cardinalidad 1:1, no es una dimensión, es una nota. Y **mezcla tres semánticas**: programa académico ('Ingeniería de Sistemas'), cargo de monitoría ('Monitor laboratorio informática') y dependencia administrativa ('Bienestar Universitario', 'Decanatura Ingeniería') |
| `Documento` varchar(80) | **NO es UNIQUE** → la misma persona puede existir N veces. El conteo de «clientes únicos» no es fiable |
| `IdSede` **varchar(20)** | Apunta a `sedes.Id` que es smallint. Desalineación de tipo |
| `CategoriasAgendas` text | CSV de ids → viola 1FN. Vacío en 18 de 20 |
| `Contrasena` | Hash en la misma tabla dimensional. **Y sin flujo de alta**: no hay campo en el formulario ni autoservicio |
| — | **No hay `TenantId`** (rompe el patrón multi-tenant), **no hay fecha de vinculación ni de último acceso** → **no se pueden construir cohortes ni medir retención** |

### La estructura académica: veredicto sin ambigüedad

- **Facultad: NO EXISTE.** Ninguna tabla, ninguna columna.
- **Programa: NO EXISTE como entidad.** Sólo el texto libre.
- **Semestre / periodo académico: NO EXISTE.**
- **Asignatura: NO EXISTE.**
- **`areas_universitarias`: existe el catálogo y tiene 0 filas**, mientras `agendas.IdAreaUni` contiene los pseudo-códigos `1101–1108`. Verificado: **16 de 16 agendas huérfanas en el join**. Y no es una dimensión académica: sus valores son tipos de espacio («Aulas de cómputo», «Casilleros»), no unidades académicas.

En la generación anterior existía una tabla `programas` con `Nombre`, `Codigo` UNIQUE, `Facultad`, `Tipo` (Pregrado/Posgrado), `Metodologia` y `Sede`. **Se perdió.** Y ya entonces era parcial: sólo 3 filas, con `Facultad` como id numérico sin tabla `facultades`, y `clientes.Programa` **no hacía join contra `programas` ni por código ni por nombre** (0 coincidencias). **La relación cliente→programa nunca estuvo normalizada, ni antes ni ahora.**

### Sanciones y tickets

**`clientes_bloqueos` (0 filas) — esquema bien diseñado, sin interfaz.** `Tipo` ('no_show'/'manual'), `Motivo`, `Activo`, `VencimientoFecha`, `CreadoUserId`/`CreadoFecha`, `ResueltoUserId`/`ResueltoFecha`/`ResueltoMotivo`, todas datetime. Único consumidor: `ClientReservationPolicyService`. **No existe modelo ni módulo de administración.** Y el vencimiento **no lo procesa un cron: se resuelve de forma perezosa** cuando alguien consulta → cualquier conteo con `WHERE Activo=1` está inflado; hay que usar `Activo=1 AND (VencimientoFecha IS NULL OR VencimientoFecha >= NOW())`.

**`clientes_tickets` (0 filas) — ciclo de vida truncado.** El cliente puede crear; **no existe controlador, modelo administrativo ni vista para atender**. Las transiciones 0→1→2→3 y el campo `Respuesta` no son alcanzables por ninguna interfaz. **No hay `FechaCierre` ni `FechaPrimeraRespuesta`** → el SLA de resolución no es medible ni estructuralmente. Y **no hay `IdCita`, `IdAgenda` ni `IdActivo`**: una incidencia «el proyector del aula 302 no enciende» no es atribuible a ningún recurso.

> **Hallazgo importante para el dashboard de incidentes (nº 24 solicitado):** la historia real de incidencias de la institución **no vive en `clientes_tickets`, vive en `clientes_bloqueos.Motivo`**. En producción, de 1.243 bloqueos: 903 (72,6 %) por devolución tardía automática, **226 (18,2 %) por devolución con daño** («Retornan cabeza de luz Arri 650w con fresnel quebrado») y **101 (8,1 %) por elemento no devuelto**. Eso es un registro de incidencias patrimoniales, atrapado en texto libre.

---

## 2.6 El esquema de Apache Guacamole y su potencial analítico

23 tablas. La única que importa como hecho es `guacamole_connection_history`.

### `guacamole_connection_history` columna por columna

| Columna | Métrica que habilita | Límite |
|---|---|---|
| `history_id` | Conteo de sesiones | — |
| `user_id` (FK, **ON DELETE SET NULL**) | Vínculo al usuario vivo | Se anula al borrar el usuario |
| `username` | Usuarios únicos, ranking de usuarios | **Hoy siempre `guacadmin`** → métrica inservible |
| `remote_host` | Sesiones desde fuera del campus | Contaminado por proxy: las 9 filas existentes tienen `127.0.0.1` |
| `connection_id` (FK, SET NULL) | **La única llave estable** para unir con el inventario | **La API REST no lo expone en el historial** y el modelo no lo lee |
| `connection_name` | Ranking de equipos más usados | Es un **snapshot de texto**: renombrar la conexión huérfana todo el histórico anterior |
| `sharing_profile_id` / `_name` | Sesiones compartidas / acompañamiento docente | 0 perfiles configurados |
| `start_date` (indexado) | Actividad por día/hora/día de semana, heatmap, pico | — |
| `end_date` (indexado) | Duración, sesiones en curso | **NULL se interpreta como «en curso»**. Ya hay 8 de 9 filas de logins con NULL aunque terminaron |

### Lo que se puede y no se puede medir

**SÍ:** duración de sesiones cerradas, concurrencia (derivable con barrido de eventos, **no implementada**), conexión usada, host de origen nominal, patrón horario, días sin uso por conexión, rebotes (<60 s, el mejor proxy de fallo disponible), sesiones huérfanas, sesiones compartidas.

**NO, y hay que decirlo antes de prometerlo:**
- **Bytes / ancho de banda**: no existe columna. Guacamole no contabiliza tráfico en BD.
- **CPU, RAM, disco del equipo remoto**: Guacamole es un proxy de píxeles, sin agente.
- **Aplicaciones o procesos usados**: sólo con grabación de sesión, que esta consola no configura.
- **Inactividad dentro de la sesión**: la BD sólo conoce el sobre (inicio/fin).
- **Conexiones fallidas**: la fila se escribe **cuando la sesión ya arrancó**. Un equipo apagado, una IP mal escrita o credenciales rechazadas **no dejan ninguna fila**. **La tasa de fallo de conexión es estructuralmente inmedible desde la BD de Guacamole.**

### La cadena de llaves: dónde se rompe exactamente

```
citas.Id
  ├─ IdAgendaRecurso ──► agendas_recursos.Id                        [equipo lógico]
  │     └─ CodigoGuacamole = base64("{connection_id}\0c\0mysql")    ◄── ROTURA 1: base64 opaco,
  │          └─ guacamole_connection.connection_id                       imposible de indexar o unir en SQL
  │               └─ guacamole_connection_parameter (hostname)      [equipo FÍSICO]
  └─ IdAgenda ──► agendas.Id ──► agendas.Sede ──► sedes.Id
```

| # | Rotura | Gravedad |
|---|---|---|
| 1 | `guacamole_connection_history` **no tiene ninguna llave de reserva ni de persona**, y `username` es la cuenta de servicio compartida | **Es imposible afirmar quién usó el escritorio remoto usando sólo datos de Guacamole** |
| 2 | La llave del historial es un **nombre**, no un id; el `connection_id` existe en la tabla pero la API no lo expone | Renombrar una conexión huérfana el histórico en silencio |
| 3 | `CodigoGuacamole` es texto libre escrito a mano: de 59 filas, **51 vacías, 4 con base64 válido y 4 con el literal `client:PI-PC-05`** que produce una URL muerta. Sin validación, sin unicidad | **Cobertura real del mapeo: 6,8 %** |
| 4 | *Fallback* a `agendas.Codigo` cuando el recurso no tiene código | La agenda 10 tiene `PermiteConexionRemota=1` y código `DEMO-RES-10` → **botón «Conectar» que siempre falla** |
| 5 | `citas.IdAgendaRecurso` es nulable y hay punteros rotos | Reservas con remoto habilitado y sin equipo |
| 6 | `agendas_recursos` no es el inventario físico; el hostname vive dentro de Guacamole | El mismo PC puede existir como dos conexiones (RDP + VNC) o mudarse de IP sin que nada lo detecte |

### La llave que hay que crear (mínimo cambio, máximo efecto)

Añadir a `agendas_recursos` dos columnas derivadas, rellenadas al guardar (decodificando lo que ya sabe decodificar `resolveGuacamoleClientIdentifier`):

```sql
GuacConnectionId  int NULL
GuacDataSource    varchar(32) NULL DEFAULT 'mysql'
INDEX idx_recursos_guac (GuacDataSource, GuacConnectionId)
```

Con eso el join pasa a ser **entero contra entero**. `CodigoGuacamole` se conserva intacto porque es lo que construye la URL del cliente.

### Atribución de la persona: las dos únicas salidas

1. **Camino correcto (objetivo): un usuario Guacamole por cliente.** Las piezas ya existen (`GuacUsuariosModel::create`, `patchAccessPermissions` para dar `READ` sólo sobre la conexión reservada). Ganancia adicional: se pueden usar `access_window_start`/`valid_until` de `guacamole_user` para que **Guacamole mismo** haga cumplir la ventana. Esfuerzo **MEDIO** (ciclo de vida de credenciales).
2. **Camino puente (inmediato): atribución por ventana de cita.** Casar cada fila del historial con la cita cuyo `IdAgendaRecurso` apunta a esa conexión y cuya ventana `[inicio − 5 min, fin + 10 min]` contiene el `start_date`. Es **defendible y unívoco** porque el sistema impide reservas solapadas del mismo recurso. Esfuerzo **BAJO**. Recomendación: implementarlo ahora, marcando cada hecho con `MetodoAtribucion` y `ConfianzaAtribucion`, y migrar al camino correcto después.

---

## 2.7 Las 4 vistas: veredicto como capa semántica

| Vista | ¿Sirve para BI? |
|---|---|
| `vista_agendas` | **No.** 56 columnas, casi todas configuración (los 8 `Horario*` incluidos). Es una vista de formulario CRUD. Y su join `a.IdUsuario = u.Id` compara varchar contra int |
| `vista_agendas_bloqueo` | **Parcialmente.** La más limpia (15 columnas). Traduce el centinela `-1` a 'Todas'. Pero mantiene el split de fecha/hora y no resuelve la sede |
| `vista_citas` | **Casi, y es el mejor punto de partida que existe.** Aporta traducción de estados (los 6, incluido el 5), denormalización de agenda/recurso/activo, y preserva el grano 1:1. Tres carencias bloqueantes: **no incorpora `citas_canceladas`**, no resuelve `Sede` a nombre ni el cliente, y no compone timestamps. Y su `ELSE → 'Pendiente de aprobacion'` **enmascara valores de estado inválidos** en lugar de exponerlos |
| `vista_citas_canceladas` | **Sí para el análisis aislado de cancelaciones. No como mitad de la serie histórica**: omite `Sede`, `IdAreaUni`, `Area` y `EstadoEntregaTexto`. Su `COALESCE(ar.Codigo, cc.CodigoRecurso)` es un **reconocimiento explícito, en el propio esquema, de que los ids del histórico se rompen** |

**Veredicto global:** son vistas de presentación CRUD, no una capa semántica. Ninguna resuelve la sede, ninguna resuelve el cliente, ninguna compone timestamps, ninguna unifica el universo de reservas. **Se requiere una capa nueva.**

---

## 2.8 Modelo dimensional propuesto

### Tablas de hechos candidatas

| # | Hecho | Grano | Fuente | Medidas | Estado |
|---|---|---|---|---|---|
| **F1** | `fct_reserva_franja` | una franja reservada | `citas` ∪ `citas_canceladas` | `franjas`, `minutos_programados`, `cupos_consumidos`, `minutos_reales`, `minutos_retraso_llegada`, flags `es_confirmada`/`es_noshow`/`es_cancelada`/`es_negada`, `dias_anticipacion`, `horas_antelacion_cancelacion` | 🟢 **Viable hoy** |
| **F2** | `fct_reserva_solicitud` | una solicitud original | — | `franjas_solicitadas`, `minutos_totales` | 🔴 **No construible: no existe id de solicitud** |
| **F3** | `fct_capacidad_ofrecida` | agenda × recurso × fecha × slot | derivada de `agendas.Horario*` − `agendas_bloqueo` − `festivos` | `slots_ofrecidos`, `minutos_ofrecidos` | 🔴 **Hay que construirla. Es el denominador obligatorio de toda ocupación** |
| **F4** | `fct_bloqueo` | un intervalo de indisponibilidad | `agendas_bloqueo` | `minutos_bloqueados` | 🟢 Viable |
| **F5** | `fct_sesion_remota` | una sesión de túnel | `guacamole_connection_history` | `segundos_conexion`, `sesiones`, `es_rebote`, `minutos_sobrepaso` | 🟡 Está en otra base, consumida por REST |
| **F6** | `fct_intento_conexion` | un clic en «Conectar» | **no existe** | `intentos`, `es_exito`, `latencia_ms` | 🔴 **Es la que cierra el embudo del módulo remoto** |
| **F7** | `fct_prestamo` | una entrega/devolución | `citas` filtrando origen | `minutos_en_poder`, `minutos_mora` | 🟡 Frágil: el canal está dentro de un JSON |
| **F8** | `fct_login` | un evento de sesión de staff | `log_acceso` | `sesiones`, `minutos_sesion` | 🔴 3 filas, `CierreSesion` NULL al 100 %, y **no cubre logins de clientes** |
| **F9** | `fct_notificacion` | un envío de correo | `alertas_email` | `envios`, `destinatarios`, `es_exito` | 🟡 Grano = lote, y sin `IdCita` |
| **F10** | `fct_uso_asistente_ia` | un turno de conversación | `asistente_ia_mensajes` + `_conversaciones` | `mensajes`, `tokens_entrada`, `tokens_salida` | 🟢 **Viable hoy con datos reales** |
| **F11** | `fct_auditoria_cambio` | un cambio de dato | `log_modules` | `cambios` | 🔴 Tabla vacía → bloquea todo SCD tipo 2 |
| **F12** | `fct_sondeo_equipo` | un sondeo de disponibilidad | **no existe** (motor `GuacdProbe` ya construido) | `alcanzable`, `latencia_ms`, `codigo_estado` | 🔴 Habilita disponibilidad, MTBF y MTTR |

**Recomendación de arquitectura:** un solo hecho central `fct_reserva_franja` que absorba cancelaciones y préstamos como atributos y banderas, más `fct_capacidad_ofrecida` como hecho de denominador, más `fct_bloqueo`. Todo lo demás es secundario.

### Dimensiones: 21 necesarias, 12 con problema

| Dimensión | Estado |
|---|---|
| `dim_tiempo` | 🔴 **CREAR.** Con `es_habil`, `es_festivo`, `es_receso`, semana ISO y **periodo académico** |
| `dim_franja_horaria` | 🔴 **CREAR.** Grano 30 min. **Debe tolerar horas no canónicas** → definir por rangos, nunca por igualdad |
| `dim_agenda` | 🟡 Existe sucia. Extraer ~15 de 68 columnas. Sin fechas → no admite SCD2 |
| `dim_recurso` | 🟡 Existe sucia. Catalogar `Tipo` (12 valores libres). Sin fechas |
| `dim_categoria` | 🟢 **Limpia.** Es hoy la única jerarquía funcional utilizable |
| `dim_sede` | 🟢 Limpia. Falta jerarquía (edificio/piso) y **normalizar los 5 tipos de dato que la referencian** |
| `dim_area_universitaria` / `dim_facultad` | 🔴 **Existe vacía Y con las referencias mal mapeadas.** Poblar + re-mapear |
| `dim_cliente` | 🟡 Existe sucia. Excluir `Contrasena`, deduplicar por documento |
| `dim_programa_academico` | 🔴 **CREAR.** Separar las tres semánticas hoy mezcladas. Vincular programa→facultad |
| `dim_vinculo_cliente` | 🔴 **CREAR.** 5 filas. Es el segmento de demanda más importante y hoy es varchar(120) |
| `dim_activo` / `dim_tipo_activo` | 🟡 Existen vacías. Añadir fecha de adquisición y de baja |
| `dim_software` | 🟢 Limpia, **sin hecho que la conecte**. Añadir versión y licenciamiento |
| `dim_usuario_interno` | 🟡 `Sede` y `AreasUniversitarias` son **CSV** → requiere tablas puente que no existen |
| `dim_estado_reserva` / `dim_estado_entrega` | 🔴 **CREAR.** Hoy hardcodeadas en PHP y replicadas en 3 CASE SQL divergentes |
| `dim_canal_origen` | 🔴 **CREAR y promover a columna.** Hoy vive dentro del JSON `InformacionAdicional.__origen`. Sin esto no se responde «¿cuánta demanda viene del autoservicio?», la pregunta de adopción más importante |
| `dim_motivo_cancelacion` / `dim_motivo_bloqueo` | 🔴 **CREAR.** Hoy texto libre |
| `dim_equipo_remoto` | 🔴 **CREAR** como dimensión materializada con SCD2 (resuelve el renombrado de conexiones, la ausencia de hostname y el borrado de conexiones) |
| `dim_serie_recurrencia` | 🔴 La tabla no existe en la base aunque el código la usa |

**Resumen: de 21 dimensiones necesarias, 10 no existen, 3 existen vacías, 1 existe vacía y mal mapeada, y 2 requieren tablas puente inexistentes.**

---

## 2.9 Los 16 defectos de calidad de dato, con su impacto de negocio

| # | Defecto | Impacto en un dashboard | Corrección |
|---|---|---|---|
| D1 | **Tabla y columna usadas por el código y ausentes del esquema** (`citas_recurrencias`, `citas.IdRecurrencia`) | Un curso semanal de 16 semanas produce 16 filas indistinguibles de 16 reservas espontáneas: **la demanda institucional y la espontánea son estadísticamente indistinguibles**. Es la distinción más importante para planear capacidad | Aplicar las 3 migraciones pendientes |
| D2 | **Grano confundido**: la franja no es la reserva, y no hay id de solicitud | «Número de reservas» inflado 1×–4× según agenda. «Reservas por usuario» incomparable entre espacios. Un no-show de 2 h cuenta doble | Añadir `IdSolicitud` (UUID por acto de reserva) |
| D3 | **Mover filas a `*_canceladas`** | Subconteo silencioso, sin error y sin NULL. **Y doble verdad confirmada**: `Estado=2` tiene 3 filas mientras `citas_canceladas` tiene 1 | Borrado lógico + vista unificada |
| D4 | **Fecha y hora separadas en 4 tablas** | `HoraEntrega`/`HoraDevolucion` son `time` sin fecha → **duración negativa si cruza medianoche**. `log_acceso` igual. `log_urls.Hora` **no tiene fecha en absoluto** | Columnas `datetime` calculadas e indexadas |
| D5 | **Sin zona horaria en ninguna parte** | Al federar con `guacamole_db` (que registra en UTC), **las sesiones remotas y las reservas no se pueden alinear con certeza** — precisamente el cruce de mayor valor | Fijar `time_zone` en la conexión; almacenar UTC en el warehouse |
| D6 | **Sin auditoría temporal en 11 tablas maestras** (`agendas`, `agendas_recursos`, `agendas_categoria`, `sedes`, `roles`, `permisos`, `configuraciones`, `festivos`…) | **SCD2 imposible.** Cambiar el aforo de un auditorio **reescribe retroactivamente todas las series pasadas**. En `permisos` y `roles` es además un hallazgo de cumplimiento | Añadir `FechaRegistro`/`FechaModificacion`; activar `log_modules` |
| D7 | **Cero llaves foráneas → huérfanos con 40 filas** | Joins que pierden filas en silencio, o que producen «Sin área» leído como categoría real. **Los totales por dimensión no suman el total general.** Y hay borrado físico masivo: `sedes` AUTO_INCREMENT 28 vs 3 filas; `clientes` 201 vs 20; `software_catalogo` 61 vs 6 | FK con `ON DELETE RESTRICT`; sustituir borrado físico por `Estado=0`; filas «Desconocido» en cada dimensión |
| D8 | **Texto libre donde debe haber catálogo** (`clientes.Programa`, `clientes.Tipo`, `agendas_recursos.Tipo`, `agendas.Area`, motivos de cancelación y de bloqueo, `usuarios.Cargo`/`Dependencia`) | **Con 20 clientes ya hay 19 programas. A escala real habrá cientos de valores casi-duplicados** y el eje «Programa» será inutilizable sin limpieza manual permanente | Tablas catálogo + FK; tabla de mapeo `crudo→canónico` en el interín |
| D9 | **Colisión de collations** (7 tablas `unicode_ci` vs 25 `general_ci`) | Verificado: `JOIN citas ON DocumentoSolicitante = clientes.Documento` **falla con ERROR 1267**. Power BI / Metabase / Tableau **fallarán con error de servidor**, no con resultado degradado | Unificar toda la base a una collation |
| D10 | **Tipos laxos** (sede referenciada con **5 tipos distintos**; `Duracion` varchar(3); `IpConexion` char(15) sin IPv6) | Joins sin índice, truncamiento silencioso, y **la imposibilidad de que una herramienta de BI infiera automáticamente las relaciones** | Normalizar a los tipos de las PK |
| D11 | **Unicidad ausente** (`clientes.Documento`, `festivos.Fecha`, `activos_tipos.Codigo`; `citas_activos_canceladas` **perdió el UNIQUE** que sí tiene su origen) | Doble conteo de clientes únicos, días hábiles subestimados (denominador de ocupación) | UNIQUE por `(campo, TenantId)` |
| D12 | **Violaciones de 1FN: listas y JSON en columnas escalares** | **La capacidad teórica vive sólo en JSON** → el denominador de ocupación no es accesible por SQL. **El canal de origen vive en JSON** → la pregunta de adopción requiere parsear. Las respuestas al formulario dinámico son **invisibles para BI** | Tablas puente reales; promover `__origen` a columna |
| D13 | **Estados sin catálogo y con definiciones divergentes** | La lógica de estado está replicada en **al menos 4 lugares**; un dashboard nuevo crearía una quinta. Dos informes que dicen «Completada» y «Finalizada» se leerán como métricas distintas. Y omitir el 5 hace que **las negadas se cuenten como pendientes** | Tablas `estados_cita`/`estados_entrega` + una sola fuente de etiquetas |
| D14 | **Doble verdad** (solicitante duplicado, recurso duplicado, `agendas.Area`, `IdActivo` vs `citas_activos`) | Dos analistas eligen fuentes distintas y producen cifras que no se reconcilian en comité | Declarar qué copia es canónica y qué copia es snapshot (`*_Snapshot`) |
| D15 | **Multi-tenancy incompleto** (13 tablas sin `TenantId`, incluidas `clientes` y `sedes`) | Un modelo de BI con filtro por tenant sobre los hechos, unido a dimensiones sin tenant, **expondrá clientes y sedes de otros inquilinos en los selectores**. Es seguridad de datos, no sólo modelado | Añadir `TenantId` e incluirlo en todos los UNIQUE |
| D16 | **Granularidad temporal contaminada** (relojes de pared mezclados con slots) | Cualquier `dim_franja_horaria` con claves exactas deja sin dimensión precisamente las filas del canal de autoservicio. El heatmap mostrará huecos artificiales | Dimensión por rangos; o normalizar el préstamo inmediato al slot contenedor |

---

## 2.10 Tablas y columnas listas y desaprovechadas (la fruta baja)

### Tablas con esquema listo y **cero filas** — habilitación = poblar, no desarrollar
1. **`activos` + `activos_tipos`** — hay **4 consultas ya escritas** que devuelven vacío. `activos.Valor` es la **única medida monetaria de toda la base**: dashboard inmediato de valor del parque por sede, área y estado.
2. **`clientes_bloqueos`** — esquema bien hecho. Habilitaría sanciones activas, duración media, reincidencia y **efectividad de la sanción** (¿bajó el no-show tras bloquear?).
3. `clientes_tickets` — backlog y mix por prioridad (SLA bloqueado por falta de `FechaCierre`).
4. `alertas` — canal in-app construido y sin uso; requiere `FechaVisto`.
5. `citas_activos` / `citas_activos_canceladas` — reservas multi-activo.
6. **`asistente_ia_acciones`** — la métrica de **gobernanza de IA** (tasa de aceptación de sugerencias, tiempo de confirmación) está esperando datos.
7. `asistente_ia_reportes` — registro de auditoría de qué información salió del sistema.

### Datos que ya existen y nadie explota
8. **`asistente_ia_conversaciones` + `_mensajes`** (12 + 52 filas, **168.528 tokens de entrada / 31.746 de salida**) → **dashboard de costo y adopción de IA construible HOY sin ningún cambio de esquema**.
9. **`alertas_email`** (19 filas) → y un hallazgo de negocio detectable con SQL de dos líneas: **sólo 3 plantillas están en uso** (`res_creada_sol`, `res_creada_rsp`, `res_cancelada`), **todas de reserva**. No hay recordatorio, ni aviso de no-show, ni notificación de aprobación/rechazo enviada, pese a que los campos de configuración existen.
10. **`agendas_bloqueo`** → dashboard de horas perdidas por mantenimiento/evento.
11. **`festivos`** → insumo de `dim_tiempo`, hoy sin usar en ningún cálculo de días hábiles.
12. **`migrations.executed_at`** → permite fechar **desde cuándo cada métrica es medible**. Metadato de linaje gratis.

### Columnas pobladas y desaprovechadas
13. **`citas.CupoConsumido`** — la única medida aditiva de personas. Permitiría pasar de «número de reservas» (transacciones) a **«personas-hora atendidas»** (servicio real). Nadie la usa.
14. **`citas.HoraEntrega`/`HoraDevolucion`** — el único dato de uso **real** vs. programado. Habilita la brecha «reservado pero no usado», el desperdicio de capacidad más caro.
15. **`citas.AprobadoFecha`/`NegadoFecha`** — **tiempo de ciclo de aprobación medible hoy**. Ningún reporte lo expone como KPI con umbral.
16. **`citas_canceladas.CanceladaFecha` + `CanceladaPorCanal` + `CanceladaIp`** — antelación de cancelación y quién cancela (portal vs. administrador).
17. **`agendas.BloqueoLlegadaTarde` + `TiempoLlegadaTarde`** — política anti-no-show configurada por espacio; cruzada con la tasa real responde **«¿la política funciona?»**. Ningún reporte lo cruza.
18. **`agendas.CapacidadPersonas`** cruzado con `SUM(CupoConsumido)` → **% de aforo utilizado**, el KPI de subutilización de espacios grandes.
19. **`clientes.Tipo`** — el eje de segmentación más valioso del negocio, disponible hoy, y **no se usa en ninguna gráfica**.

---

## 2.11 Arqueología de la producción anterior: qué dicen los datos reales

`timeklee_tadeo` es la generación anterior del producto, en uso real en una universidad, con datos 2022–2025. Es la mejor evidencia disponible de **cómo se usa realmente el sistema**.

> **Advertencia de integridad:** el volcado está **parcialmente anonimizado**. `clientes` (20 filas sintéticas), `usuarios` (10), `alertas` y `alertas_email` fueron reemplazados. Las tablas transaccionales (`citas`, `citas_canceladas`, `practica_libre`, `ordenes_salida`, `clientes_bloqueos`, `productos`, `equipos`, `salones`, `agendas`, `software`) **conservan datos reales**. Todo cruce persona↔transacción está roto.

### Volumetría real

| Tabla | Filas | Rango |
|---|---|---|
| `citas_canceladas` | **6.786** | 2022-01-01 → 2025-12-26 |
| `citas` | **3.717** | 2022-01-24 → 2022-07-01 |
| `ordenes_salida` | **2.559** | 2022-08 → 2024-12 |
| `practica_libre` | **2.531** | 2022-03-10 → 2025-02-27 |
| `productos` | 1.405 | — |
| `agendas` | 1.396 | — |
| `clientes_bloqueos` | **1.243** | 2022-08 → 2024-03 |
| `equipos` | 764 | 40 salones, 14 grupos |
| `software` | 157 | 117 títulos distintos |
| `salones` | 46 | 858 puestos totales |
| `edificios` | 21 | — |

### Comportamiento real del usuario — los diez hallazgos que deben gobernar el diseño

1. **La curva horaria no es demanda, es la malla académica.** 62,2 % de las reservas empiezan **a las 07:00 o a las 09:00**; 19,1 % a partir de las 18:00; la franja 14:00–17:00 concentra sólo el **3,9 %**. → Cualquier dashboard que presente esto como «horas pico de demanda de usuarios» está describiendo el horario de clases.
2. **El sábado es una jornada real**: 499 reservas (13,4 %). Un dashboard que asuma semana de 5 días subestima la operación en un octavo. Jueves y miércoles son los días más cargados.
3. **La tasa de cancelación obvia es una mentira.** 6.786 canceladas vs 3.717 vivas = 64,6 %… pero el denominador fue purgado: hay 3.969 cancelaciones en periodos sin ninguna reserva viva. **En el único periodo con denominador válido: 12,6 %.** Esa es la cifra defendible.
4. **La antelación de reserva es inservible en `citas`**: las 3.717 filas se registraron en **13 días de marzo de 2022** (carga masiva retroactiva) → 44,6 % con antelación **negativa**. En `citas_canceladas`, donde el dato sí está distribuido: **media de 18,5 días** — coherente con planificación de curso, no con uso oportunista.
5. **`Duracion` no es `HoraFin − Hora`.** 94 % declaran 120 minutos con horas a 60 minutos de distancia. **Sumar `Duracion` duplica las horas reservadas.**
6. **No existe medición de asistencia.** `Asistencia`: **91,7 % NULL**; `Llegada`: **100 % NULL**; `Asistentes`: 100 % vacío. Y el valor `2` (indocumentado) es el más frecuente de los informados. **Cualquier tasa de no-show sobre estos datos es ruido.** Es el hallazgo más importante para la analítica: el proceso operativo nunca cerró el ciclo de la reserva.
7. **El recurso más disputado del campus no es un aula: es un carro con equipos.** Las cuatro «Aulas Móviles» concentran **1.096 reservas (29,5 %)**.
8. **10 equipos de 764 concentran el 80 % de los préstamos.** La asignación no se balancea: se entrega siempre el mismo portátil hasta que muere. Hallazgo de mantenimiento con impacto de compra directo.
9. **Estacionalidad bisemestral con relación pico/valle de 17:1** (septiembre 702 vs diciembre 41). Dimensionar personal de mostrador con el promedio anual garantiza colapso en marzo y ociosidad en diciembre.
10. **El préstamo de portátiles es un servicio de adquisición masiva y retención baja**: 941 estudiantes distintos, de los cuales **54,2 % lo usó una única vez**. Si el objetivo es fidelizar, ahí está el problema; si es cobertura, es un éxito.

### Y dos cifras que justifican un módulo entero
- **50,1 % de las órdenes de salida se devolvieron tarde** (1.282 de 2.558), con retraso medio de **1.985 minutos = 33 horas**. Duración media del préstamo: 33,6 h (**multi-día por diseño**).
- **48,6 % de las órdenes derivaron en sanción** (1.243 bloqueos), de los cuales 226 por daño y 101 por elemento no devuelto.

### Las 15 trampas de datos que un dashboard debe manejar para no mentir

1. Más cancelaciones que reservas por purga del denominador → restringir el ratio al rango solapado y **avisar explícitamente**.
2. Series truncadas asimétricamente → **cada widget declara el rango efectivo de *su* fuente**, no el del filtro global. Si el rango solicitado tiene <30 % de cobertura, mostrar cobertura en lugar de gráfico.
3. Nulos masivos presentados como cero → **prohibido `COALESCE(campo,0)` en métricas de tasa**. Un «0 % de no-show» derivado de 91,7 % de nulos es una mentira que un decano puede usar para recortar personal.
4. **Constantes disfrazadas de variables** (`Sede=1` en el 100 %, `UrlAnyDesk='#'` en 2.531 filas) → test de cardinalidad que **suprima automáticamente** cualquier dimensión con una sola clase.
5. Fechas fuera de rango operativo → winsorizar y separar un panel de «registros fuera de rango» en lugar de dejar que 2 filas estiren el eje X.
6. Antelación negativa por carga retroactiva → si `MAX(FechaRegistro) − MIN(FechaRegistro)` es órdenes de magnitud menor que el rango de `Fecha`, **deshabilitar el KPI, no corregirlo**.
7. `Duracion` no confiable → calcular siempre por `TIMESTAMPDIFF` y filtrar rango plausible reportando cuántas filas se excluyeron.
8. FK sin integridad → **`LEFT JOIN` exclusivamente, y el conteo de huérfanos como métrica de primera clase**. Un `INNER JOIN` a `clientes` aquí borra tres cuartas partes de la evidencia sin que nadie lo note.
9. Datos anonimizados indistinguibles de reales → marcar tablas maestras sospechosas y **bloquear reportes nominativos**.
10. Estados no booleanos ni documentados → tabla de mapeo explícita; los no mapeados van a «Sin clasificar» **visible**.
11. **El catálogo es mucho más grande que el uso** (1.396 agendas, 405 usadas) → todo «% de ocupación del catálogo» contra el catálogo **con uso**, y publicar aparte el conteo de recursos configurados sin uso.
12. Motivos atrapados en texto libre (55,8 % de cancelaciones sin comentario) → clasificación por patrones a categorías estables, y **exponer el % sin motivo como el hallazgo principal**. Filtrar explícitamente los registros de prueba (95 cancelaciones con motivo «prueba» en producción).
13. Vistas heredadas con joins incorrectos → nunca construir un KPI sobre una vista heredada sin auditar sus `ON`.
14. **Autoría concentrada en un usuario técnico** (`UsuarioRegistro=1` en 99,8 %) → separar actores humanos de actores sistema antes de cualquier análisis de productividad.
15. **Ausencia del sello temporal del evento que se quiere medir** → nombrar los ejes con precisión quirúrgica («por fecha de la cita», no «por mes de cancelación»).

### Qué debería recuperar Timeklee2, priorizado

**ALTA** — 1) `edificios` + `salones` con `Capacidad`, `Piso`, `EsAula`. 2) Módulo de órdenes de salida con fecha+hora de salida/compromiso/regreso y doble firma. 3) **Convertir `HoraEntrega`/`HoraDevolucion` a `datetime`** (sin esto es matemáticamente imposible medir el retraso de 33 h). 4) Sanción automática por devolución tardía (`BloqueoDevolucionTarde`/`TiempoDevolucionTarde`). 5) **Poblar `areas_universitarias`**. 6) Captura de uso efectivo: hora real de inicio/fin y registro de asistencia con autor.

**MEDIA** — 7) Anclaje de activos a espacio físico. 8) Serial entregado en la sesión de práctica libre. 9) Parámetro de capacidad de sala (`EquiposSala`). 10) `clientes_permisos`: perfiles de cliente × categorías (10 filas frente a texto replicado por persona). 11) Libro de movimientos de inventario. 12) Destinatarios por etapa en `agendas_categoria` y `Grupo` (facultad). 13) **Concepto de reagendamiento** — sin él toda cancelación se cuenta como pérdida de demanda cuando parte es simple movimiento de fecha.

**BAJA** — 14) Catálogo `programas` (alto valor conceptual, pero el cliente nunca lo alimentó: 3 filas). 15) `software.Version`. 16) Roles «Administrador Sede» y «Administrador Recursos». 17) Activar `log_acceso`/`log_modules` (0 filas en ambas generaciones: es deuda de gobierno, no una recuperación).

---

*Continúa en la [Parte 3 — Auditoría de reportes y dashboards](03_AUDITORIA_REPORTES.md).*
