# 16 — Diagnóstico y Resolución de Problemas

> Síntomas concretos observables en este sistema, con su causa raíz verificada en el código.

---

## 0. Cómo diagnosticar en general

```
1. ¿Qué dice la URL?               → ?c=… &a=…  identifica el controlador y la acción
2. ¿Hay redirección a home o a login? → problema de AUTORIZACIÓN (ver §2)
3. ¿Hay pantalla en blanco?        → error fatal (ver §1)
4. ¿Hay traza en pantalla?         → el catch global de AutoLoad.php (ver §1.2)
5. ¿Faltan datos que sí están en BD? → filtros de sesión (ver §3)
6. ¿Falta un ítem de menú?         → permisos congelados en sesión (ver §2.3)
7. Revisar logs/  y  el error_log de Apache
8. Con APP_DEBUG=true, la consola del navegador muestra cada SQL ejecutado
```

### Activar el modo depuración

```ini
# .env
APP_DEBUG=true
```

Efectos:
- Se carga `core/ErrorHandler.php` (manejadores de error y de apagado).
- `MysqlPDO` vuelca **cada SQL** a `LogsConsole`, que el layout imprime en el `console.log` del navegador.
- `Atributo::receiveData()` emite `<script>console.log("Campo no valido: …")</script>` para cada campo que falle la validación.

> 🔴 **Nunca dejarlo activo en producción.** Expone la estructura completa de la base de datos a cualquier usuario con acceso a la consola del navegador.

---

## 1. Errores fatales y pantallas en blanco

### 1.1 Pantalla completamente en blanco

| Causa | Diagnóstico | Solución |
|---|---|---|
| Error de sintaxis PHP | `php -l <fichero>` | Corregir |
| `ob_start()` sin `ob_flush()` por un `exit()` prematuro | Buscar `exit()` en la acción | Usar `ROUTER::redirect_to_action()`, que ya hace `exit()` tras el header |
| Fatal antes de que arranque el búfer | `error_log` de Apache | Revisar el log del servidor |
| `storage/cache/classmap.php` con rutas obsoletas | `cat storage/cache/classmap.php \| grep <Clase>` | `rm -f storage/cache/classmap.php` |

### 1.2 Se muestra una traza de excepción en la página

`core/AutoLoad.php` captura toda excepción del despacho y hace:

```php
} catch (Error $exception)     { echo " {$exception}"; }
} catch (Exception $exception) { echo " {$exception}"; }
```

> 🔴 **Esto no depende de `APP_DEBUG`.** En producción, cualquier excepción no capturada muestra al usuario la traza completa con rutas absolutas del servidor. Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md) § HR-LEAK-1.

**Trazas más frecuentes:**

| Mensaje | Causa | Solución |
|---|---|---|
| `Class "XxxModel" not found` | La clase no existe, o el classmap está obsoleto | Ver §1.3 |
| `No existe la configuración de conexión` | `Config::$DB_CONNECTIONS['klee']` no está definida → falta `DB_NAME` en el `.env` | Rellenar `DB_NAME`, `DB_USER`, `DB_PASSWORD` |
| `SQLSTATE[42S22]: Column not found` | El modelo declara una columna que la tabla no tiene, o se filtra por un alias inexistente en la vista | Comparar `getOptionsAttributes()` con `DESCRIBE <tabla>` |
| `SQLSTATE[42S02]: Base table or view not found` | Migración sin aplicar, o vista no recreada tras un `ALTER TABLE` | `php bin/migrate status` |
| `The user specified as a definer does not exist` | Volcado restaurado en otro servidor: las 62 vistas tienen `DEFINER = root@localhost` | Ver §6.4 |

### 1.3 `Class "XxxModel" not found`

```bash
# 1. ¿Existe el fichero?
ls app/models/XxxModel.php

# 2. ¿El nombre del fichero coincide EXACTAMENTE con el de la clase? (sensible a mayúsculas)
grep -n "^class" app/models/XxxModel.php

# 3. ¿Está en una de las rutas de autoload?
grep -n "autoloadSearchPaths" -A 12 core/AutoLoad.php

# 4. Invalidar el classmap
rm -f storage/cache/classmap.php
```

Rutas de autoload: `core/attributes/`, `app/models/`, `app/controllers/`, `core/helpers/`, `app/config/`, `services/`, `services/llm/`, `services/prompt/`, `services/asistente_ia/`.

**Si creaste un subdirectorio nuevo bajo `services/`, debes añadirlo al array `$autoloadSearchPaths` de `core/AutoLoad.php`.**

### 1.4 🔴 Clases que faltan a propósito

Hay **20 clases `*Model` referenciadas en el código que no existen**, restos de un producto anterior de evaluación docente. Si el error menciona una de estas, **la ruta que la alcanza es código muerto**:

```
AsignaturasModel · DebugModel · DirectoresEvaluacionesModel · DirectoresModel
DirectorioActivoModel · DocentesAsignaturasModel · DocentesEvaluacionesModel
DocentesModel · EstudiantesAsignaturasModel · EstudiantesEvaluacionesModel
EstudiantesModel · EstudiantesRespuestasModel · FacultadesModel · LaborDocenteModel
OdsDocentesModel · PostulacionesModel · ProduccionIntelectualModel · ProgramasModel
ProyectosDocentesModel · ResumenEvaluacionModel
```

**Rutas afectadas:** `?c=sincronizacion&a=*`, `?c=ajax&a=getAll|update|getResumen|getDocente*|getProductosIntelectuales`, `?c=alertas&a=view`, `?c=notificaciones&a=estudiantes|docentes|directores`, `?c=test&a=log`.

**No crees la clase.** Comprueba primero si la funcionalidad tiene sentido en Kuorum; casi siempre la respuesta correcta es eliminar el código. Ver [12_INTEGRATIONS.md](12_INTEGRATIONS.md) §12.

**Caso especial — `DirectorioActivoModel`:** se invoca desde `UsuariosModel::validateUser()` cuando `DIRECTORIO_ACTIVO=true` y la contraseña local falla. **Mantén `DIRECTORIO_ACTIVO=false`.**

---

## 2. Problemas de acceso y permisos

### 2.1 "Me redirige a la página de inicio y no puedo entrar a la acción"

**Es el síntoma más frecuente del sistema.** `Controller::validateAccess()` devuelve un motivo, pero **los cinco resultados producen la misma redirección silenciosa** a `DIR_INDEX`.

Árbol de causas, en orden de probabilidad:

```mermaid
flowchart TD
    A["Redirección inesperada a home"] --> B{"¿La acción está en\nloadAccessControl()?"}
    B -- no --> S1["ERROR_ACCESS.\nAñádela al mapa.\nEs la causa nº 1"]
    B -- sí --> C{"¿Hay sesión activa?"}
    C -- no --> S2["NO_LOG_IN.\nLa sesión expiró"]
    C -- sí --> D{"¿Existe el método\naccionAction()?"}
    D -- no --> S3["NO_ACTION.\nRevisa el nombre: camelCase + 'Action'"]
    D -- sí --> E{"¿Sesión de portal\n(ColaboradorPublic)?"}
    E -- sí --> F{"¿Clase+acción en\n\$PUBLIC_COLLABORATOR_ROUTES?"}
    F -- no --> S4["NO_PERMISSIONS.\nAñádela a la lista blanca\nde core/Controller.php"]
    F -- sí --> G
    E -- no --> G{"¿Permission[accion] > 0\nen la SESIÓN?"}
    G -- no --> H{"¿Módulo en \$classesGeneral\no acción en \$actionsGeneral\no usuario == 1?"}
    H -- no --> S5["NO_PERMISSIONS.\nFalta la fila en permisos,\nO no has vuelto a iniciar sesión"]
    H -- sí --> OK["Debería pasar"]
    G -- sí --> OK
```

**Diagnóstico rápido:**

```php
// Insertar temporalmente al principio de la acción problemática
var_dump($this->AccessControl);
var_dump($_SESSION[Controller::getAppId()]['Permissions'][static::$MODULE_NAME] ?? 'SIN PERMISOS');
var_dump($this->validateAccess('nombreDeLaAccion'));
exit;
```

### 2.2 "Cambié los permisos en la base de datos y no pasa nada"

**Causa:** los permisos se cargan **una sola vez, en el login** (`UsuariosModel::loadPermissions()`) y viven en `$_SESSION[APP_ID]['Permissions']`.

**Solución:** cerrar sesión y volver a entrar.
**Para forzarlo a todos los usuarios:** cambiar `APP_ID` en el `.env` (invalida todas las sesiones de golpe).

### 2.3 "No aparece el ítem en el menú"

El menú también se congela en sesión. Además, `UsuariosModel::validarMenuItems()` descarta cualquier ítem cuya URL sea `'#'`, y `ROUTER::create_action_url()` devuelve `'#'` cuando `PermisosModel::hasAccess()` es falso.

Lista de comprobación:

```
1. ¿El ítem está en Menu::$principal (backoffice) o Menu::$public (portal)?
2. ¿"Controller" coincide exactamente con el nombre de la clase sin 'Controller'?
3. ¿"Action" está declarada en loadAccessControl() de ese controlador?
4. ¿Hay fila en `permisos` para el rol del usuario y ese ModuleName?
5. ¿El JSON de Permission incluye esa acción con valor > 0?
6. ¿Has cerrado sesión y vuelto a entrar?
7. Si es un grupo con SubMenus: ¿sobrevive al menos un hijo? Si no, el grupo entero desaparece
8. En el portal: ¿el ítem tiene "Condicion"? (colaborador_lider_equipo exige LiderEquipo = 1)
```

### 2.4 "El colaborador no puede entrar a una pantalla del portal"

Además de los permisos, existe la lista blanca `Controller::$PUBLIC_COLLABORATOR_ROUTES` en `core/Controller.php`, que se comprueba **por nombre de clase**.

```php
// core/Controller.php
private static $PUBLIC_COLLABORATOR_ROUTES = array(
    'MiControlador' => array('accion1', 'accion2'),
    // …
);
```

> ⚠️ **`AusenciasController` usa layout `metronicPublic` y aparece en el menú del portal, pero NO está en la lista blanca.** Un colaborador recibirá `NO_PERMISSIONS`. Si el módulo debe estar disponible en el portal, hay que añadirlo.

### 2.5 "El botón de eliminar no funciona"

`Controller::process()` exige **POST + `_csrf_token`** para la acción `remove`, en **todos** los controladores:

```php
if ($action === 'remove'
    && (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST'
        || !self::validateCsrfTokenNoRotate($_POST['_csrf_token'] ?? null))) {
    UserFlash::setFlash('Error', 'Solicitud de eliminación inválida.');
    ROUTER::redirect_to_action($this->Module, 'list');
}
```

**Si has escrito un `<a href="?c=X&a=remove&Id=N">`, no funcionará.** `ListaAjax` genera un formulario POST precisamente por esto. Copia ese patrón:

```html
<form method="post" action="<?php echo ROUTER::create_action_url($controllerName,'remove',array('Id'=>$id)); ?>" class="d-inline">
    <input type="hidden" name="_csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
    <button type="submit" class="btn btn-light-danger">Eliminar</button>
</form>
```

### 2.6 "Token de seguridad inválido"

| Causa | Solución |
|---|---|
| Falta `<input name="_csrf_token">` en el formulario | Añadirlo |
| No se pasó `$parameters['csrfToken'] = Controller::generateCsrfToken()` a la vista | Añadirlo en el controlador |
| **El token ya se consumió.** `validateCsrfToken()` **rota** el token al validarlo. Dos envíos con el mismo token: el segundo falla | Usar `validateCsrfTokenNoRotate()` en endpoints AJAX repetibles |
| La sesión expiró | Volver a entrar |
| Cookie `SameSite=Strict` bloqueada al venir de un enlace externo | Navegar dentro de la aplicación |

---

## 3. Los datos no aparecen

### 3.1 🔴 Filtros globales de sesión (causa número 1)

`Model::addFiltersSesion()` inyecta condiciones en **toda** consulta de modelo:

| Filtro | Condición añadida | Cuándo se aplica |
|---|---|---|
| `Estado` | `FIND_IN_SET('1', Estado)` | Si el modelo tiene columna `Estado` o `IdEstado` |
| `Periodo` | `FIND_IN_SET('<periodo activo>', IdPeriodo)` | Si el modelo tiene columna `Periodo` o `IdPeriodo` |

**Síntoma:** la fila está en la tabla, `SELECT` directo la devuelve, pero el listado sale vacío.

**Solución:** pasar `array()` como tercer argumento.

```php
MiModeloModel::getAll($fields, $criteria);          // ← con filtros de sesión
MiModeloModel::getAll($fields, $criteria, array()); // ← sin filtros  ✅
```

En un `ListaAjax`:

```php
$table->setFiltersSesion(array());
```

### 3.2 El periodo de sesión es el equivocado

```php
PeriodosModel::getSesionId();   // $_SESSION[APP_ID]['filtersSesion']['Periodo']
```

Se fija en el login (`Controller::filtersSesionValuesDefault()`) y se cambia con el `<select>` de la barra superior.

**Valor por defecto:** `Config::$FILTERS_SESION['Periodo']['valueDefault'] = 29`, **un Id concreto codificado a fuego** que puede no existir en una instalación nueva.

Si `PeriodosModel::getAllFiltersSesion()` devuelve al menos una opción, se usa la primera (`$data[0]['Id']`); si no, se usa el 29.

**Verificar:**
```sql
SELECT Id, Nombre, Mostrar, Estado FROM periodos ORDER BY Id;
```
Debe existir al menos uno con `Mostrar = 1`.

### 3.3 El modelo lee de una vista obsoleta

Los modelos leen de `$VIEW_NAME` en `getAllView()`, `getByIdView()`, `getByCriteriaView()` y en `ListaAjax`. Si un `ALTER TABLE` no fue acompañado de `CREATE OR REPLACE VIEW`, la vista sigue con el esquema antiguo.

```sql
SHOW CREATE VIEW vista_mi_tabla\G
```

### 3.4 Se filtra por una columna que la vista no expone

Caso real: el docblock de `ColaboradoresModel::getAll()` afirma que `vista_colaboradores` incluye alias `Nombre`, `Apellido`, `NoIdentificacion`, `Cargo` y `Dependencia`. **No es cierto:** la vista solo añade `NombreCompleto`.

```sql
SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'vista_colaboradores';
```

Mismo caso con `LiterEquipo`: `esLiderEquipo()` consulta la **tabla base** porque la vista no expone `LiderEquipo`.

### 3.5 El permiso vale 2 ("solo mis registros")

`ListaAjax` interpreta el permiso con tres valores. Si `Permission['view'] == 2`, añade:

```sql
WHERE UsuarioRegistro = <usuario de la sesión>
```

**Síntoma:** el usuario solo ve sus propios registros y cree que faltan datos.

```sql
SELECT ModuleName, Permission FROM permisos WHERE IdRol = <rol>;
```

---

## 4. Problemas de base de datos

### 4.1 "Opps tuvimos un problema, conectandonos a la base de datos"

Mensaje literal de `DB::createConnection()` seguido de `exit()`.

```bash
mysql -h <DB_HOST> -u <DB_USER> -p <DB_NAME> -e "SELECT 1;"
grep -E "^DB_" .env
```

| Causa | Solución |
|---|---|
| Credenciales incorrectas | Revisar `.env` |
| MySQL parado | `systemctl status mysql` |
| Base inexistente | `CREATE DATABASE …` |
| Sin privilegios | `GRANT ALL ON kuorum.* TO …` |
| `.env` no legible por el usuario del servidor web | `ls -l .env` |

### 4.2 "No existe la configuración de conexión"

Lo lanza `Config::getConnectionConfig()`. Significa que `Config::$DB_CONNECTIONS['klee']` **no se definió**, y eso ocurre cuando **`DB_NAME` está vacía o ausente**:

```php
$dbName = Env::get('DB_NAME', null);
if ($dbName !== null) {          // ← si es null, la conexión no se registra
    self::$DB_CONNECTIONS['klee'] = array(…);
}
```

### 4.3 `bin/migrate` no conecta pero la web sí

**Causa conocida.** `bin/migrate`:
- **No llama a `Env::load()` ni a `Config::syncConfigFromEnv()`** (a diferencia de `bin/seed`, que sí lo hace).
- Su respaldo lee **`DB_PASS`**, mientras que la clave del `.env` es **`DB_PASSWORD`**.

**Solución:**

```bash
export DB_HOST=localhost DB_NAME=kuorum DB_USER=kuorum DB_PASS='<contraseña>'
php bin/migrate up
```

### 4.4 Vistas rotas tras restaurar un volcado

```
ERROR 1449: The user specified as a definer ('root'@'localhost') does not exist
```

Las 62 vistas se crean con `SQL SECURITY DEFINER` y `DEFINER = root@localhost`.

```bash
sed -E 's/DEFINER=`[^`]+`@`[^`]+`//g' volcado.sql > volcado_sin_definer.sql
mysql -u kuorum -p kuorum < volcado_sin_definer.sql
```

### 4.5 Caracteres mal codificados (tildes, ñ)

Hay tres migraciones dedicadas a esto, **y las tres están sin aplicar** en la base de desarrollo actual:

```
2026_06_23_000094_fix_text_encoding_competencias_festivos
2026_06_24_000095_fix_text_encoding_planes_desarrollo_colaboradores
2026_06_24_000096_fix_text_encoding_planes_desarrollo_explicit_map
```

```bash
php bin/migrate status
```

Comprobar también que la conexión use `charset=utf8mb4` (lo hace `DB::createConnection()`) y que la tabla sea `utf8mb4_0900_ai_ci`.

### 4.6 Registros huérfanos tras eliminar un colaborador

Los dominios de metas, evaluaciones, vacaciones, nómina, reclutamiento y auditoría **no tienen claves foráneas declaradas**. Un `DELETE` directo sobre `colaboradores` deja huérfanas decenas de filas.

**Usa siempre `ColaboradoresModel::deleteCascade($id)`**, que borra en cascada sobre ~15 tablas **en transacción**.

---

## 5. Problemas de listados (DataTables)

### 5.1 La tabla se queda en "Processing…" indefinidamente

El endpoint `dataListAjax` devolvió algo que no es JSON válido — típicamente HTML de un error de PHP.

```
Abrir DevTools → Network → localizar la petición POST a ?c=X&a=dataListAjax
→ mirar la pestaña Response
```

**Solución preventiva:** envolver `dataListAjaxAction()` en `try/catch` que devuelva JSON válido (ver [06_BACKEND.md](06_BACKEND.md) §3).

### 5.2 "recordsTotal: 0" pero la tabla tiene filas

`ListaAjax::generateDataListAjax()` cuenta con `getQuantityView()`, que **lee de la vista** y **aplica los filtros de sesión**.

```
1. ¿Existe vista_<tabla>?
2. ¿$table->setFiltersSesion(array()) está puesto?
3. ¿El periodo activo de sesión coincide con el de los datos?
```

### 5.3 Las columnas salen vacías

`$fieldsShow` debe contener **nombres de columna que la vista realmente devuelva**. `ListaAjax` genera `columns: [{data: '<nombre>'}]` y DataTables busca esa clave exacta en cada fila.

```
- ¿Los nombres de $fieldsShow coinciden con los de la vista? (sensible a mayúsculas)
- ¿count($titles) == count($fieldsShow)?
- ¿Las columnas están también en $fields? (es lo que se consulta)
```

### 5.4 El listado sale en inglés

`ListaAjax` carga el idioma desde `https://cdn.datatables.net/plug-ins/1.11.3/i18n/es-mx.json`. Sin acceso a Internet, o si la CSP lo bloquea, DataTables usa el inglés por defecto.

### 5.5 Los botones de exportación no aparecen

Los botones (print, copy, excel, csv, pdf) requieren las extensiones de DataTables Buttons, incluidas en `datatables.bundle.js`. Verifica que el fichero cargue (DevTools → Network).

---

## 6. Problemas de formularios

### 6.1 "Verifique sus datos por favor"

Mensaje de `Model::saveOnCreate()` / `saveOnUpdate()` cuando `beforeCreate()`/`beforeUpdate()` devuelve `false`.

**Con `APP_DEBUG=true`**, la consola del navegador muestra qué campo falló:

```
Campo no valido: NombreDelCampo => valorRecibido
```

Causas:

| Causa | Comprobación |
|---|---|
| Campo `Required` vacío | `getOptionsAttributes()` |
| Longitud fuera de `[MinLength, MaxLength]` | Por defecto `MaxLength = 50` en `Atributo` |
| `validateData()` del tipo falla (email, fecha, decimal…) | `core/attributes/<Tipo>.php` |
| **El `name` del input no sigue el patrón `Modelo[Campo]`** | Ver §6.2 |
| Un hook `beforeCreate()`/`beforeUpdate()` propio devuelve `false` | `ColaboradoresModel` valida mayoría de edad |

### 6.2 El formulario no guarda nada y no da error

**Causa casi segura:** los `name` de los inputs no siguen el patrón.

`Atributo::receiveData()` lee **exactamente**:

```php
$_POST[$this->ModelName][$this->Name]
```

donde `$ModelName` es el **nombre de la clase del modelo**.

```html
<!-- ❌ MAL -->
<input name="nombre">
<input name="Nombre">

<!-- ✅ BIEN -->
<input name="<?php echo get_class($model); ?>[<?php echo $model->Nombre->getName(); ?>]">
<!-- se renderiza como: name="CompetenciasModel[Nombre]" -->
```

Además, el controlador detecta el envío con `isset($_POST[get_class($this->Model)])`. Sin ese índice, la acción **ni siquiera intenta guardar**.

### 6.3 La edición crea un registro nuevo en vez de actualizar

**Causa:** falta `loadById()` antes de `save()`.

```php
// ❌ MAL — Id es null → saveOnCreate() → INSERT
$this->Model = new MiModeloModel();
$this->Model->save();

// ✅ BIEN
$this->Model = new MiModeloModel();
$this->Model->loadById($_POST[get_class($this->Model)]['Id']);
$this->Model->save();
```

### 6.4 La contraseña no se guarda

`core/attributes/Password.php` tiene `protected $CanEditOnUpdate = false`. En un `save()` normal **la contraseña se ignora**.

```php
$this->Model->save(array('Contrasena'));   // ← hay que pedirla explícitamente
```

Es lo que hace `UsuariosController::editPassAction()`.

Además, el atributo exige:
- Confirmación: `$_POST[Modelo][Contrasena1]` debe coincidir con `$_POST[Modelo][Contrasena]`.
- Política: mínimo 8 caracteres, una minúscula, una mayúscula y un dígito (`PasswordPolicy::validate()`).

### 6.5 Se pierden campos en formularios grandes

`public/.htaccess` fija `php_value max_input_vars 5000`. Si el hosting ignora `php_value` en `.htaccess` (habitual con PHP-FPM), hay que ponerlo en `php.ini` o en el pool de FPM.

**Síntoma:** en cargas masivas, los últimos campos llegan vacíos sin ningún error.

---

## 7. Problemas de correo

### 7.1 No sale ningún correo

```
1. ¿configuraciones['DetenerEnvioNotificaciones'] está vacío?
   → si no lo está, sendCron NO envía nada. Es el interruptor de emergencia
2. ¿Hay filas en alertas_email con EstadoEnvio = 1 y Destinatarios != ''?
3. ¿MAIL_HOST, MAIL_USER, MAIL_PASSWORD están rellenos en .env?
4. ¿MAIL_ENCRYPTION es coherente con MAIL_PORT?  tls→587, ssl→465
5. Probar el envío manual: ?c=AlertasEmail&a=send
```

### 7.2 Solo salen 2 correos por ejecución

`configuraciones['EnvioMaximoNotificaciones']` **no es numérico o es ≤ 0** → se usa el respaldo de **2**.

```sql
UPDATE configuraciones SET Valor = '200' WHERE Id = 'EnvioMaximoNotificaciones';
```

### 7.3 El cron devuelve 403 o 503

| Respuesta | Causa | Solución |
|---|---|---|
| `503 CRON_TOKEN no configurado.` | `CRON_TOKEN` vacío en el `.env` | Generarlo con `php -r "echo bin2hex(random_bytes(32));"` |
| `403 Token invalido.` | El token enviado no coincide | Revisar la cabecera `X-Cron-Token` o el parámetro `?token=` |
| `403 No tienes permiso para ejecutar envios programados.` | Hay sesión activa **sin** el permiso `AlertasEmail.send` | Ejecutarlo sin sesión y con token, o conceder el permiso |

### 7.4 El envío es muy lento

`sendCronAction()` hace `sleep(3)` entre correos. Con `EnvioMaximoNotificaciones = 200`, la ejecución tarda ~10 minutos. Ajusta la frecuencia del cron en consecuencia.

---

## 8. Problemas de ficheros

### 8.1 No se suben adjuntos

```bash
ls -ld files/ files/<subcarpeta>/
# debe ser escribible por el usuario del servidor web
chown -R www-data:www-data files/
chmod -R 775 files/
```

Comprobar también `upload_max_filesize` y `post_max_size` en `php.ini`.

### 8.2 No se ven el logotipo ni el favicon

El layout los referencia como:

```php
URL::base_url() . '/../files/' . $favicon
```

Es decir, **`files/` tiene que ser accesible por HTTP**. Si el `DocumentRoot` apunta a `public/`, hace falta:

```apache
Alias /files /var/www/kuorum/files
```

### 8.3 elFinder devuelve 503

```
"El gestor de archivos no está disponible."
```

Falta alguno de los cuatro ficheros de la librería:

```bash
ls vendor/studio-42/elfinder/php/elFinder{Connector,,VolumeDriver,VolumeLocalFileSystem}.class.php
composer install
```

### 8.4 El certificado de capacitación sale en HTML, no en PDF

`CapacitacionPortalController::descargarCertificadoAction()` cae al HTML si mPDF no está disponible o `WriteHTML()` lanza. **La degradación es silenciosa**: no hay mensaje ni log.

```bash
ls vendor/mpdf/mpdf/
php -m | grep -E "gd|mbstring"
```

---

## 9. Problemas del Asistente IA

| Síntoma | Causa | Solución |
|---|---|---|
| El widget no aparece | `IA_ENABLED=false` | Activarlo en `.env` |
| `"No fue posible procesar la consulta. Verifica la disponibilidad de Ollama o la validez del SQL generado."` | Ollama inalcanzable o error de SQL | `curl <IA_OLLAMA_ENDPOINT>/api/tags` |
| `"La consulta fue bloqueada por politicas de seguridad (solo lectura)."` | El SQL generado no pasó `isReadOnlySelect()` | Es el comportamiento correcto. Reformular la pregunta |
| `"No pude construir una consulta segura para esa pregunta."` | El modelo no supo generar SQL | Reformular; el motivo va en `reason` |
| Timeout | `IA_TIMEOUT_SECONDS` (45) insuficiente para el modelo | Subirlo o usar un modelo más pequeño |
| Respuestas desactualizadas | Caché de `IA_QUERY_CACHE_TTL` (180 s) | Esperar o vaciar `storage/cache/` |
| 403 en `?c=AsistenteIA&a=ask` | Token CSRF | Recargar la página |

Los bloqueos de SQL se registran: `Logger::warning('AsistenteIA bloqueo SQL inseguro', ['sql' => …])`.

---

## 10. Problemas de sesión

### 10.1 La sesión se cierra sola

| Causa | Solución |
|---|---|
| `session.gc_maxlifetime` del sistema | Ajustar en `php.ini` |
| **`APP_ID` cambió** | Cambiar `APP_ID` invalida **todas** las sesiones. Verificar el `.env` |
| Varias instancias sin almacenamiento de sesión compartido | Configurar un manejador de sesión común |
| Cookie `Secure` sin HTTPS efectivo | Detrás de un proxy, `SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on` |

### 10.2 "La sesión ha expirado" en un bucle

`core/check_sessions.js` consulta `public/check_session.php`; si la respuesta no es exactamente `'1'`, alerta y redirige a `index.php`.

Comprobar que `public/check_session.php` sea accesible y devuelva `1` con sesión activa.

### 10.3 Al cerrar sesión sigo autenticado en otro contexto

`LoginController::logoutAction()` hace `unset($_SESSION[Controller::getAppId()])` — **no llama a `session_destroy()`** ni invalida la cookie. La rama `$_SESSION['public']` (usada por `Model::log()`) sobrevive.

---

## 11. Problemas de rendimiento

### 11.1 El dashboard tarda mucho

`HomeController::indexAction()` ejecuta **14 métodos de agregación**, cada uno con varias consultas, **sin caché**.

Mitigaciones posibles:
- Activar `$CACHE = true` en los modelos más consultados (ningún modelo lo tiene hoy).
- Envolver los KPI en `Cache::setWithNamespace()`.
- `safeQuantity()` ya evita que un fallo tumbe el dashboard entero, pero no mejora el tiempo.

### 11.2 Un listado va lento

```
1. ¿Cuántas filas devuelve getQuantityView() sin filtros?
2. ¿La vista SQL hace JOIN pesados?  SHOW CREATE VIEW vista_x\G
3. La búsqueda global genera un LIKE '%…%' sobre TODAS las columnas de fieldsShow,
   con OR y por cada palabra: no puede usar índices
4. Reducir fieldsShow o desactivar la búsqueda global
```

### 11.3 Cada página instancia controladores de más

Dos comportamientos con coste:
- `View::render_view()` **instancia un segundo controlador** solo para preguntarle su layout, lo que reejecuta `loadPermission()`, `loadSystemUser()`, `loadAccessControl()` y `getTabs()`.
- `PermisosModel::hasAccess()` **instancia un controlador por cada ítem de menú evaluado** — aunque el menú se calcula una sola vez, en el login.

---

## 12. Dónde mirar

| Fuente | Ruta / comando |
|---|---|
| Logs de la aplicación | `logs/` — o `Logger::tail(100)`, `Logger::getByLevel('ERROR', 50)` |
| Errores de PHP | `error_log` de Apache (`/var/log/apache2/error.log`) |
| Accesos HTTP | `access_log` de Apache |
| Auditoría de accesos | Tabla `log_acceso` |
| Auditoría de URL | Tabla `log_urls` |
| Auditoría de cambios (con diff) | Tabla `log_modules` — solo `colaboradores` y `usuarios` |
| Bitácora de dominio | Tabla `bitacora_auditoria` |
| Historial por módulo | `adelanto_historial`, `beneficios_solicitud_historial`, `servicio_ticket_historial`, `linea_etica_bitacora`, `reclutamiento_historial_etapas_postulacion`, `historial_plan_desarrollo` |
| SQL ejecutado | Consola del navegador, con `APP_DEBUG=true` |
| Estado de migraciones | `php bin/migrate status` |
| Validación estática | `php bin/migrate validate` · `php bin/seed --validate` |
| Cobertura de datos | `php bin/seed --verify-demo` |
| Pruebas | `composer test` |

---

## 13. Comandos de diagnóstico útiles

```bash
# Sintaxis de todos los ficheros PHP
find app core services database bin -name "*.php" -exec php -l {} \; | grep -v "No syntax errors"

# Clases *Model referenciadas que no existen
grep -rohP "(?<![A-Za-z0-9_])[A-Z][A-Za-z0-9]*Model(?=::|\s*\()" --include="*.php" app core services \
  | sort -u | while read m; do [ -f "app/models/$m.php" ] || echo "FALTA: $m"; done

# Acciones implementadas pero no declaradas en loadAccessControl
for f in app/controllers/*.php; do
  n=$(basename "$f" .php)
  for a in $(grep -oP 'function \K[a-zA-Z0-9_]+(?=Action\s*\()' "$f"); do
    grep -q "'$a'" "$f" || echo "$n :: $a  (NO declarada)"
  done
done

# Vistas SQL que existen en el esquema
mysql -u <user> -p <db> -e "SELECT table_name FROM information_schema.views WHERE table_schema='<db>';"

# Migraciones aplicadas vs ficheros
mysql -u <user> -p <db> -N -e "SELECT migration FROM migrations;" | sort > /tmp/aplicadas
ls database/migrations | sed 's/\.php$//' | sort > /tmp/ficheros
comm -23 /tmp/ficheros /tmp/aplicadas    # pendientes

# Permisos de un rol
mysql -u <user> -p <db> -e "SELECT ModuleName, Permission FROM permisos WHERE IdRol = 2;"

# Invalidar el classmap
rm -f storage/cache/classmap.php
```

---

## Documentos relacionados
- [13_CONFIGURATION.md](13_CONFIGURATION.md)
- [15_DEPLOYMENT.md](15_DEPLOYMENT.md)
- [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md)
