# 04 — Módulos Funcionales

> **24 módulos funcionales** + 1 grupo de controladores transversales.
> Cada módulo se identifica por su `$MODULE_NAME`, que es también la clave de la tabla `permisos`.

## Índice de módulos

| # | Módulo | Controlador(es) principal(es) | `$MODULE_NAME` |
|---|---|---|---|
| 1 | [Inicio / Dashboard](#1-inicio--dashboard) | `HomeController` | `home` |
| 2 | [Colaboradores](#2-colaboradores) | `ColaboradoresController`, `PerfilColaboradorController` | `Colaboradores` |
| 3 | [Reclutamiento y Selección](#3-reclutamiento-y-selección) | `ReclutamientoController` | `Reclutamiento` |
| 4 | [Metas](#4-metas) | `MetasController`, `MetasColaboradorController`, `MetasSeguimientosController`, `MetasCategoriasController` | `Metas`, `MetasColaborador`, … |
| 5 | [Evaluación 360](#5-evaluación-360) | `EvaluacionesController`, `EvaluacionesColaboradorController` | `Evaluaciones` |
| 6 | [Competencias, Preguntas y Escalas](#6-competencias-preguntas-y-escalas) | `CompetenciasController`, `PreguntasController`, `EscalasController`, `CompetenciasColaboradoresController` | `Competencias`, `Preguntas`, `Escalas` |
| 7 | [Planes de Desarrollo](#7-planes-de-desarrollo) | `PlanesDesarrolloController`, `PortalPlanesDesarrolloController`, `PlanesDesarrolloEquipoController` | `PlanesDesarrollo`, … |
| 8 | [Capacitación (LMS)](#8-capacitación-lms) | `CapacitacionController`, `CapacitacionLeccionesController`, `CapacitacionInscripcionesController`, `CapacitacionPortalController`, `CapacitacionCertificadosController` | `Capacitacion`, … |
| 9 | [Mapa de Talento / Altos Potenciales](#9-mapa-de-talento--altos-potenciales) | `PotencialesController` | `Potenciales` |
| 10 | [Planes de Carrera](#10-planes-de-carrera) | `PlanesCarreraController`, `CarreraPortalController` | `PlanesCarrera`, `CarreraPortal` |
| 11 | [Sucesión](#11-sucesión) | `SucesionController` | `Sucesion` |
| 12 | [Reconocimientos](#12-reconocimientos) | `ReconocimientosController` | `Reconocimientos` |
| 13 | [Vacaciones](#13-vacaciones) | `VacacionesController`, `VacacionesColaboradorController` | `Vacaciones`, `VacacionesColaborador` |
| 14 | [Nómina](#14-nómina) | `NominaController` | `Nomina` |
| 15 | [Adelantos de Nómina](#15-adelantos-de-nómina) | `AdelantosNominaController`, `AdelantoPoliticasController`, `AdelantosNominaPortalController` | `AdelantosNomina`, … |
| 16 | [Control de Asistencia](#16-control-de-asistencia) | `AsistenciaController`, `TurnosController`, `AusenciasController` | `Asistencia`, `Turnos`, `Ausencias` |
| 17 | [Beneficios](#17-beneficios) | `BeneficiosController`, `BeneficiosSolicitudesController`, `BeneficiosAsignacionesController`, `BeneficiosPortalController` | `Beneficios`, … |
| 18 | [Servicio al Colaborador (Tickets)](#18-servicio-al-colaborador-tickets) | `TicketsController`, `CentroAyudaController`, `ServicioConfiguracionController` | `Tickets`, `CentroAyuda`, `ServicioConfiguracion` |
| 19 | [Línea de Ética](#19-línea-de-ética) | `LineaEticaController`, `LineaEticaPublicaController` | `LineaEtica`, `LineaEticaPublica` |
| 20 | [Comunicación Interna](#20-comunicación-interna) | `ComunicacionInternaController`, `NotificacionesController`, `MensajesInternosController` | `ComunicacionInterna`, … |
| 21 | [Mi Equipo](#21-mi-equipo) | `MiEquipoController` | `MiEquipo` |
| 22 | [Portal del Colaborador](#22-portal-del-colaborador) | `PublicController`, `DesempenoController`, `TiempoBeneficiosController` | `public`, `Desempeno`, `TiempoBeneficios` |
| 23 | [Configuración y Administración](#23-configuración-y-administración) | 12 controladores | `Configuraciones`, `Roles`, … |
| 24 | [Asistente IA](#24-asistente-ia) | `AsistenteIAController` | `AsistenteIA` |
| — | [Transversales](#25-controladores-transversales) | `AjaxController`, `ApiController`, `GeneralController`, `ElFinderController`, `LoginController`, `DemoController`, `TestController`, `SincronizacionController` | — |

---

## 1. Inicio / Dashboard

**Propósito.** Panel de indicadores de RR. HH. para el backoffice.

**Usuarios.** Administrador, Analista.

**Controlador.** `HomeController` (1 213 líneas) · `$MODULE_NAME='home'` · `$ViewFolder='home'` · layout `metronic`.

**Acciones.** `index` (dashboard), `default`, `modalSede` (modal de selección de periodo vía AJAX), `changeFiltersSesion` (heredada).

**Cómo funciona.** `indexAction()` invoca 14 métodos privados de agregación y pasa cada resultado como una clave de `$parameters` a `home/admin.php`:

| Clave de `$parameters` | Método | Qué calcula |
|---|---|---|
| `dashboardKpis` | `getAdminDashboardKpis()` | Cabecera de indicadores |
| `dashboardVacacionesPendientes` | `getVacacionesPendientesRecientes()` | Solicitudes en estado `SOLICITADA` |
| `dashboardTicketsVencidos` | `getTicketsVencidosRecientes()` | Tickets con `SlAEstado='vencido'` |
| `dashboardAccesos` | `getAdminDashboardAccesos()` | Últimos accesos (`log_acceso`) |
| `dashboardQualityKpis` | `getQualityIndicators($idPeriodo)` | Calidad del periodo |
| `dashboardAbsenceKpis` | `getAbsenceKpis()` | Ausentismo |
| `dashboardDistribucion` | `getOrganizationalDistribution()` | Distribución por área/cargo |
| `dashboardFestivos` | `getProximosFestivos()` | Tabla `festivos` |
| `dashboardCumpleanios` | `getProximosCumpleanios()` | `colaboradores.FechaNacimiento` |
| `dashboardVariacionColaboradores` | `getVariacionColaboradoresPeriodo()` | Altas vs. bajas |
| `dashboardUbicacion` | `getDistribucionUbicacion()` | Por sede/ciudad |
| `dashboardOperativoAdmin` | `getSplitOperativoAdministrativo()` | Split operativo/administrativo |
| `dashboardAdvancedKpis` | `getAdvancedTalentKpis($idPeriodo)` | KPIs avanzados de talento |

Otros métodos privados disponibles (no todos cableados a `indexAction`): `getHeadcountHistorico`, `getEmployeesByContractType`, `getHiringVsTerminations`, `getHeadcountByGenero`, `getMonthlyWomenHires`, `getRecruitmentLeadTimeSummary`, `getWorkplaceDistribution`, `getAgeRanges`, `getHeadcountByPersonalType`, `getRotationMetrics`, `getNationalityDistribution`, `getPerformanceKpis`, `getAbsenceDaysSummary`, `getTerminationClassification`.

**Tablas leídas.** `colaboradores`, `solicitudes_vacaciones`, `servicio_tickets`, `log_acceso`, `festivos`, `areas`, `cargos`, `sedes`, `metas_consolidado`, `evaluaciones`, `asistencia_ausencias`, `reclutamiento_*`.

**Puntos críticos.**
- `safeQuantity()` envuelve las agregaciones para que un error de una consulta no tumbe el dashboard entero.
- El dashboard depende del **periodo activo de sesión** (`PeriodosModel::getSesionId()`). Si no hay periodo en sesión, varios KPIs salen en cero.
- Es el controlador más pesado por petición: decenas de consultas agregadas sin caché.

---

## 2. Colaboradores

**Propósito.** Ficha maestra del empleado. Es la **entidad central** del sistema: 20+ tablas referencian `colaboradores.Id`.

**Usuarios.** Administrador y Analista (CRUD completo); Colaborador (solo su propia ficha, vía `PerfilColaboradorController`).

### 2.1 Backoffice — `ColaboradoresController` (1 398 líneas)

`$MODULE_NAME='Colaboradores'` · `$ViewFolder='colaboradores'` · layout `metronic`.

**Acciones CRUD:** `create`, `edit`, `remove`, `view`/`ver`, `list` (heredada), `dataListAjax`, `organigrama`, `planesDesarrollo`.

**Acciones de pestañas (15).** Cada una devuelve el contenido de una pestaña de la ficha del colaborador:
`tabContratoDocumentos`, `tabAsistencia`, `tabVacaciones`, `tabNomina`, `tabTickets`, `tabCapacitacion`, `tabBeneficios`, `tabAdelantos`, `tabCarreraTalento`, `tabEvaluacion`, `tabMetas`, `tabPlanesDesarrollo`, `tabReclutamiento`, `tabTrayectoria`, `tabAuditoria`.

> Estas pestañas son el punto donde el módulo Colaboradores **agrega datos de casi todos los demás módulos**. Al añadir un módulo nuevo con datos por colaborador, el sitio natural para exponerlo es una pestaña nueva aquí.

**Nota de auditoría (HR-008):** tres acciones estaban declaradas como `'*'` (públicas) y se cambiaron a `'@'`.

### 2.2 Portal — `PerfilColaboradorController` (624 líneas)

`$MODULE_NAME='Colaboradores'` (**comparte módulo de permisos con el backoffice**) · `$ViewFolder='perfil_colaborador'` · layout `metronicPublic`.

**Acciones:** `panel`, `asistencia`, `edit`, `planesDesarrollo`.

> ⚠️ Porque comparte `$MODULE_NAME`, `Controller::validateAccess()` **no puede** distinguirlo del CRUD administrativo por permisos. Por eso existe la lista blanca `Controller::$PUBLIC_COLLABORATOR_ROUTES`, que valida **por nombre de clase** y no por módulo. Ver `09_AUTHENTICATION_AUTHORIZATION.md`.

### 2.3 Modelo — `ColaboradoresModel` (676 líneas)

| Aspecto | Detalle |
|---|---|
| Tabla | `colaboradores` |
| Vista | `vista_colaboradores` (añade `NombreCompleto = TRIM(CONCAT(Nombres,' ',Apellidos))`) |
| `$LOG` | **`true`** — cada create/update/delete se registra en `log_modules` |
| Lectura | `getById()`, `getAll()` y `getByCriteria()` están **sobreescritos para leer siempre de la vista** |
| Hooks | `beforeCreate()`/`beforeUpdate()` → `normalizaCamposUnicos()` + `validaMayorDeEdad()` |

**Métodos destacados:**
- `esLiderEquipo(int $id): bool` — lee `LiderEquipo` **de la tabla base**, porque la vista no expone esa columna.
- `deleteCascade($id)` — borrado en cascada **en transacción** sobre ~15 tablas dependientes.
- `validateUser($user, $pass, $periodo)` — autenticación del portal; ver §22 y `09_…`.
- `getByIdFromView`, `getByNombreCompleto`, `resolveIdByNombreCompleto`, `getAllSinFiltros`, `getColaboradorId`.

**Tablas relacionadas.** Directas: `cargos`, `areas`, `tipos_contrato`, `ceco`, `sedes`, `colaborador_educacion`, `colaborador_experiencia`. Referencias entrantes: ver `05_DATABASE.md` § claves foráneas (20+ tablas apuntan a `colaboradores.Id`).

**Reglas de negocio.**
- `NoDocumento`, `CorreoCorporativo` y `Usuario` son **únicos** (índices UNI en BD).
- Un colaborador debe ser **mayor de edad** en la creación y edición.
- `IdJefeInmediato` e `IdJefeFuncional` son autorreferencias que construyen el organigrama.
- `LiderEquipo = 1` habilita "Mi Equipo" y "Planes del Equipo" en el portal.
- `EstadoActual` (texto) es distinto de `Estado` (0/1). `Estado` es el borrado lógico; `EstadoActual` es el estado laboral ("Activo", …).

**Puntos críticos.**
- 🔴 `vista_colaboradores` **expone la columna `Contrasena`**. Como `getAll(array('*'))` lee de la vista, el hash de contraseña puede acabar en memoria y en respuestas que serialicen la fila completa.
- 🔴 El docblock de `getAll()` afirma que la vista incluye alias `Nombre`, `Apellido`, `NoIdentificacion`, `Cargo`, `Dependencia`. **La vista real solo añade `NombreCompleto`.** Cualquier criterio que filtre por esos alias fallará.

---

## 3. Reclutamiento y Selección

**Propósito.** Embudo completo desde la vacante hasta el contrato firmado y la conversión en colaborador.

**Usuarios.** Administrador, Analista. Los candidatos externos acceden sin sesión a `?c=public&a=vacantesPublicas` y `postularVacante`.

**Controlador.** `ReclutamientoController` (1 172 líneas) · `$MODULE_NAME='Reclutamiento'` · `$ViewFolder='reclutamiento'`.

**Acciones (27):**

| Grupo | Acciones |
|---|---|
| Pipeline global | `pipeline`, `stageCreate`, `stageReorder`, `stageToggle`, `reasonCreate` |
| Vacantes | `vacantes`, `vacanteView`, `vacanteCreate`, `vacanteEdit`, `vacanteDuplicar`, `vacanteChangeEstado` |
| Candidatos | `candidatos`, `candidatoView`, `candidatoCreate`, `candidatoEdit` |
| Postulaciones | `applications`, `applicationCreate`, `applicationView`, `kanban`, `moveStage` |
| Entrevistas | `interviewCreate`, `interviewReprogramar` |
| Ofertas | `offerCreate`, `offerChangeEstado` |
| Contratos | `contractCreate`, `contractChangeEstado` |

**Modelos.** Dos nomenclaturas coexistentes (ver `18_KNOWN_ISSUES.md`):

| Modelo canónico (inglés) | Alias (español) | Tabla |
|---|---|---|
| `RecruitmentVacanciesModel` | `ReclutamientoVacantesModel` | `reclutamiento_vacantes` |
| `RecruitmentCandidatesModel` | `ReclutamientoCandidatosModel` | `reclutamiento_candidatos` |
| `RecruitmentApplicationsModel` | `ReclutamientoPostulacionesModel` | `reclutamiento_postulaciones` |
| `RecruitmentPipelineStagesModel` | `ReclutamientoEtapasPipelineModel` | `reclutamiento_etapas_pipeline` |
| `RecruitmentApplicationStageHistoryModel` | `ReclutamientoHistorialEtapasPostulacionModel` | `reclutamiento_historial_etapas_postulacion` |
| `RecruitmentInterviewsModel` | `ReclutamientoEntrevistasModel` | `reclutamiento_entrevistas` |
| `RecruitmentOffersModel` | `ReclutamientoOfertasModel` | `reclutamiento_ofertas` |
| `RecruitmentContractsModel` | `ReclutamientoContratosModel` | `reclutamiento_contratos` |
| `RecruitmentRejectionReasonsModel` | `ReclutamientoMotivosRechazoModel` | `reclutamiento_motivos_rechazo` |

Los alias son clases de 5 líneas: `class ReclutamientoXModel extends RecruitmentXModel {}`.

**Modelos y servicios adicionales:** `EtapasEmbudoVacanteModel` (embudo por vacante), `ReglasFiltroVacanteModel` (reglas de scoring), `ScoringCandidatosService`, `ContratacionDesdeOfertaService`, `ProcesosSeleccionColaboradorModel`, `HistorialEntrevistaPostulacionModel`.

**Flujo funcional.**

```mermaid
stateDiagram-v2
    [*] --> nueva : crearPostulacion()
    nueva --> en_proceso : moverEtapa a etapa intermedia
    en_proceso --> en_proceso : moverEtapa
    en_proceso --> oferta : etapa Codigo='oferta'
    oferta --> contratada : etapa Codigo='contratado'\n(requiere oferta ACEPTADA)
    en_proceso --> rechazada : etapa Codigo='rechazado'\n(requiere IdMotivoRechazo)
    oferta --> rechazada
    nueva --> retirada
    contratada --> [*]
    rechazada --> [*]
```

**Reglas de negocio** (`RecruitmentApplicationsModel::moverEtapa()`):
1. Una postulación en estado `contratada` **no admite más movimientos**.
2. Si la vacante está cerrada (`bloqueaMovimientosKanban()`) o pausada sin permiso (`puedeMoverConPausada()`), se bloquea el movimiento.
3. La etapa destino **debe pertenecer al embudo de esa vacante** (`etapas_embudo_vacante`), no solo al pipeline global.
4. Mover a la etapa con `Codigo='rechazado'` **exige** `IdMotivoRechazo`.
5. Mover a `Codigo='contratado'` **exige** una oferta en estado `ACEPTADA` (`validarContratacion()`).
6. Todo movimiento escribe una fila en `reclutamiento_historial_etapas_postulacion`.
7. Al contratar, si la postulación tiene `IdColaborador`, se actualiza el colaborador a `EstadoActual='Activo'`, `Estado=1`.
8. `candidatoYaEstaContratado($idCandidato)` impide postular a alguien ya contratado.
9. `EtapasEmbudoVacanteModel::sincronizarConPipelineGlobal()` se invoca al crear/duplicar/editar una vacante y también de forma defensiva en `moveStage` si la vacante no tiene etapas.

**Configuración relacionada:** clave `ReclutamientoContratoExigeFirma` (y su duplicado `RecruitmentContratoExigeFirma`) en la tabla `configuraciones`.

**Puntos críticos.**
- La columna `CompanyId` aparece en `reclutamiento_vacantes`, `reclutamiento_candidatos`, `reclutamiento_postulaciones`, `etapas_embudo_vacante`, `reglas_filtro_vacante` y varias tablas de planes de desarrollo. **No hay multi-tenancy activo**: el valor se toma de `$_SESSION[…]['User']['CompanyId'] ?? 1`, columna que **no existe en la tabla `usuarios`** → siempre resulta `1`.
- La postulación pública (`PublicController::postularVacante`) es una acción `'*'`, accesible sin sesión.

---

## 4. Metas

**Propósito.** Gestión de objetivos individuales por periodo, con pesos, seguimientos, aprobación por jefe y consolidación de la calificación de desempeño.

**Usuarios.** Administrador/Analista (`MetasController`), Colaborador y jefe (`MetasColaboradorController`).

### Controladores

| Controlador | Módulo | Acciones |
|---|---|---|
| `MetasController` (717 l.) | `Metas` | `list`, `create`, `edit`, `view`, `remove`, `dataListAjax`, `aprobarMeta`, `rechazarMeta`, `consolidado`, `noConsolidado`, `consolidarFinal`, `heredar`, `cambioCargo`, `sincronizacionJefes` |
| `MetasColaboradorController` (544 l.) | `MetasColaborador` | `list` (portal, incluye alta/edición/envío a revisión desde la misma vista) |
| `MetasSeguimientosController` (100 l.) | `MetasSeguimientos` | `create`, `edit`, `remove` |
| `MetasCategoriasController` (210 l.) | `MetasCategorias` | CRUD estándar |

### Modelos

`MetasModel` (759 l.), `MetasSeguimientosModel`, `MetasCategoriasModel`, `MetasConsolidadoModel` (175 l.).

### Tablas

`metas`, `metas_seguimientos`, `metas_categorias`, `metas_consolidado`, `metas_ciclos` (definida en migración, **sin modelo ni uso**). Vistas: `vista_metas`, `vista_metas_seguimientos`, `vista_metas_consolidado`.

### Estados de una meta (`metas.EstadoMeta`)

```mermaid
stateDiagram-v2
    [*] --> borrador : create
    borrador --> en_revision : envío a revisión\n(suma de pesos == 100 exacto)
    en_revision --> aprobada : aprobarMeta
    en_revision --> rechazada : rechazarMeta
    rechazada --> en_revision : reenvío tras corregir
    aprobada --> cerrada : cierre de periodo
    cerrada --> [*]
```

Constantes: `ESTADO_BORRADOR`, `ESTADO_EN_REVISION`, `ESTADO_APROBADA`, `ESTADO_RECHAZADA`, `ESTADO_CERRADA`.

### Reglas de negocio (todas en `MetasModel`)

| Regla | Método | Detalle |
|---|---|---|
| Solo se editan metas en `borrador` o `rechazada` | `isMetaEditable()` | |
| La suma de pesos de un colaborador en un periodo **no puede superar 100 %** | `validateMetaPayload()` | `MAX_TOTAL_PESO = 100.0`, `TOLERANCE = 0.0001` |
| Para **aprobar**, la suma debe ser **exactamente 100 %** | `shouldRequireExactHundred()` + `validateMetaPayload($requireExactHundred=true)` | Se activa si `Aceptada`, `AceptadaJefe` o `AceptadaFuncional` son verdaderos, o si `EstadoMeta ∈ {aprobada, cerrada}` |
| Para **enviar a revisión**, la suma de las metas editables debe ser exactamente 100 % | `validateMetaSubmission()` | |
| Número máximo de metas por colaborador y periodo | `validateMetaPayload()` | `periodos.MaximoMetas`; si es 0, `configuraciones['metas.max_por_colaborador']`; por defecto **8** |
| Número mínimo para enviar a revisión | `validateMetaSubmission()` | `periodos.MinimoMetas` |
| Cada categoría tiene tope de metas y de peso | `validateCategoryRules()` | `metas_categorias.NumeroMetas` y `metas_categorias.Maximo` |
| La edición solo se permite en la ventana de planeación | `canEditByWindow()` | Estados `planeacion` o `no_definido` |
| El seguimiento solo se permite en ventana de seguimiento/cierre | `canTrackByWindow()` | Estados `seguimiento`, `cerrado`, `no_definido` |
| La ventana la calcula el periodo | `PeriodosModel::getMetasWindowState($periodo, $fecha)` | A partir de `InicioMetas`, `LimiteMetas`, `FinalMetas` |
| La frecuencia (Anual/Semestral) es configurable | `Config::getFrecuenciasMetasDisponibles()` / `getFrecuenciaMetasPeriodoActiva()` | Claves `metas.frecuencias_disponibles` y `metas.frecuencia_periodo` en `configuraciones`; por defecto `METAS_FRECUENCIA_PERIODO_DEFAULT` del `.env` |

**Herencia de metas.** `heredarMetasPeriodo($colaboradorAnterior, $colaboradorNuevo, $periodo, $ids)` copia metas de un colaborador a otro dentro del mismo periodo — se usa en `heredar` y `cambioCargo`.

**Consolidación.** `recalculateConsolidadoPreliminar($idColaborador, $idPeriodo)` recalcula `metas_consolidado.Preliminar`; `consolidarFinal` marca `Consolidado=1` y fija `Total`. `MetasConsolidadoModel::consolidadoPreliminar($idColaborador)` es la variante por colaborador.

**Punto crítico.** El campo `metas.Peso` tuvo un problema de duplicación corregido por la migración `2026_05_14_000087_fix_duplicate_metas_peso.php`.

---

## 5. Evaluación 360

**Propósito.** Evaluación multi-fuente (autoevaluación, jefe, colaboradores a cargo, pares, cliente) con pesos configurables por periodo.

**Usuarios.** Administrador/Analista configuran y consolidan; cualquier colaborador responde su encuesta.

### Controladores

| Controlador | Acciones |
|---|---|
| `EvaluacionesController` (680 l.) | `list`, `create`, `edit`, `view`, `remove`, `generar`, `calcular`, `consolidar`, `recalcular`, `imprimir`, `cargueMasivo`, `solucionarRepetidos`, `quitar` |
| `EvaluacionesColaboradorController` (290 l.) | `list`, `encuesta` (portal) |

### Tablas

| Tabla | Papel |
|---|---|
| `evaluaciones` | Una fila por colaborador y periodo. Guarda los flags de qué evaluaciones aplican, los evaluadores asignados (`*Valores`, listas), quién ya respondió (`*Realizada`), y los totales por fuente + `Total` |
| `respuestas` | Una fila por (periodo, colaborador evaluado, pregunta, tipo de fuente, calificador). **1 552 filas en la BD actual** — es la tabla más voluminosa |
| `preguntas` | Pregunta ligada a una competencia y a una escala |
| `escalas` | `Opcion0`..`Opcion5` — etiquetas de la escala de calificación |
| `competencias` | Catálogo de competencias |
| `competencias_colaboradores` | Qué competencias se evalúan a cada colaborador en cada periodo |
| `consolidado_evaluacion_360` | Consolidado por grupo referente |
| Vistas | `vista_evaluaciones`, `vista_respuestas`, `vista_respuestas_colaborador`, `vista_respuestas_grupo`, `vista_preguntas` |

### Pesos por periodo (`periodos`)

`PesoAutoEvaluacion`, `PesoJefe`, `PesoColaboradores`, `PesoPares`, `PesoEvaluacionCliente` (decimal 5,2). Flags de activación: `AutoEvaluacion`, `EvaluacionColaboradorJefe`, `EvaluacionJefeColaborador`, `EvaluacionPares`, `EvaluacionCliente`, `EvaluacionReferencia`. Límite: `CantidadMaximaParesEvaluar`. Ventana: `InicioCompetencias`, `FinalCompetencias`.

### Métodos de conteo de `EvaluacionesModel`

`getEvaluaciones()`, `getNoAutoEvaluacion()`, `getNoJefe()`, `getNoColaboradores()`, `getNoPares()`, `getNoClientes()`, `getEvaluacionesTerminadas()`, `getAutoEvaluacionesTerminadas()`, `getEvaluacionesJefeTerminadas()`, `getEvaluacionesParesTerminadas()`, `getEvaluacionesColaboradoresTerminadas()`, `getEvaluacionesClienteTerminadas()`.

**Nota de auditoría (HR-010):** una acción de este controlador estaba declarada `'*'` y se corrigió a `'@'`.

**Punto crítico.** La acción `solucionarRepetidos` existe porque el proceso de generación podía crear evaluaciones duplicadas. `Evaluacion360SeederSafetyTest` protege que la semilla no destruya datos del periodo.

---

## 6. Competencias, Preguntas y Escalas

Tres CRUD de catálogo que alimentan la Evaluación 360 y los Planes de Desarrollo.

| Controlador | Modelo | Tabla | Acciones |
|---|---|---|---|
| `CompetenciasController` (130 l.) | `CompetenciasModel` | `competencias` | `list`, `create`, `edit`, `view`, `remove`, `dataListAjax` |
| `PreguntasController` (130 l.) | `PreguntasModel` | `preguntas` (vista `vista_preguntas`) | idem |
| `EscalasController` (129 l.) | `EscalasModel` | `escalas` | idem |
| `CompetenciasColaboradoresController` (95 l.) | `CompetenciasColaboradoresModel` | `competencias_colaboradores` | `create`, `edit`, `remove`, `cargueMasivo` |

> `CompetenciasController` es el **ejemplo canónico de CRUD** de este proyecto. Úsalo como plantilla (ver `14_DEVELOPMENT_GUIDELINES.md`).

Una competencia tiene `Nombre`, `Categoria`, `Global`, `Nivel`, `Estado`. Una pregunta apunta a `IdCompetencia` e `IdEscala`.

---

## 7. Planes de Desarrollo

**Propósito.** Planes de mejora individuales, derivados de brechas de competencias, evaluaciones o resultados del 9-Box.

| Controlador | Módulo | Layout | Acciones |
|---|---|---|---|
| `PlanesDesarrolloController` (926 l.) | `PlanesDesarrollo` | `metronic` | `list`, `create`, `edit`, `view`, `generarSugerido`, `recalcularPesos`, `dataListAjax` |
| `PortalPlanesDesarrolloController` (230 l.) | `PortalPlanesDesarrollo` | `metronicPublic` | `list`, `view` |
| `PlanesDesarrolloEquipoController` (316 l.) | `PlanesDesarrolloEquipo` | `metronicPublic` | `list`, `view`, `equipoTalento` |

**Modelos y servicios.** `PlanesDesarrolloModel`, `AccionesDesarrolloModel`, `EvidenciasAccionDesarrolloModel`, `FeedbackPlanDesarrolloModel`, `HistorialPlanDesarrolloModel`, `GeneradorPlanesDesarrolloService` (240 l.), `CalculadorProgresoPlanService` (155 l.), `PlanesDesarrolloSchemaHelper`.

**Tablas.** `planes_desarrollo` → `acciones_desarrollo` → `evidencias_accion_desarrollo`; más `feedback_plan_desarrollo` e `historial_plan_desarrollo`.

**Claves foráneas reales:**
```
planes_desarrollo.IdColaborador  → colaboradores.Id
planes_desarrollo.IdMeta         → metas.Id
planes_desarrollo.IdEvaluacion   → evaluaciones.Id
acciones_desarrollo.IdPlanDesarrollo → planes_desarrollo.Id
acciones_desarrollo.IdCompetencia    → competencias.Id
evidencias_accion_desarrollo.IdAccionDesarrollo → acciones_desarrollo.Id
```

**Reglas.**
- Cada acción tiene `PesoPorcentaje` y `ProgresoPorcentaje`; `recalcularPesos` los normaliza.
- `CalculadorProgresoPlanService` deriva `planes_desarrollo.ProgresoPorcentaje` a partir de las acciones.
- `generarSugerido` usa `GeneradorPlanesDesarrolloService` para proponer acciones a partir de brechas de competencias / resultado 9-Box (`ResultadoNineBoxId`) / meta asociada (`OrigenBrecha`, `MotivoCreacion`).
- `acciones_desarrollo.RequiereEvidencia` obliga a adjuntar evidencia para cerrar la acción.
- `PlanesDesarrolloEquipoController` solo es útil para colaboradores con `LiderEquipo=1`.

**Puntos críticos.** Existen migraciones específicas para corregir la codificación de texto de este módulo (`..._095_fix_text_encoding_planes_desarrollo_colaboradores.php`, `..._096_..._explicit_map.php`), señal de que hubo un problema de charset. `PlanesDesarrolloSchemaHelper` sugiere que el esquema varía entre instalaciones.

---

## 8. Capacitación (LMS)

**Propósito.** Cursos internos con lecciones, cuestionario, progreso y certificado en PDF.

| Controlador | Módulo | Acciones |
|---|---|---|
| `CapacitacionController` (362 l.) | `Capacitacion` | `index`, `crear`, `guardar`, `ver`, `publicar`, `archivar`, `eliminar` |
| `CapacitacionLeccionesController` (190 l.) | `CapacitacionLecciones` | `index`, `crear`, `guardar`, `eliminar`, `reordenar` |
| `CapacitacionInscripcionesController` (340 l.) | `CapacitacionInscripciones` | `index`, `asignar`, `asignarMasivo`, `marcarCompletado`, `cancelar`, `exportar` |
| `CapacitacionPortalController` (542 l.) | `CapacitacionPortal` | `misCursos`, `verCurso`, `iniciar`, `completarLeccion`, `verProgreso`, `presentarQuiz`, `enviarQuiz`, `descargarCertificado`, `misCertificados` |
| `CapacitacionCertificadosController` (118 l.) | `CapacitacionCertificados` | `ver`, `generar`, `validar` |

**Tablas.** `capacitacion_cursos` → `capacitacion_lecciones` / `capacitacion_quiz` → `capacitacion_quiz_preguntas`; `capacitacion_inscripciones` → `capacitacion_leccion_progreso`, `capacitacion_quiz_intentos`, `capacitacion_certificados`.

**Estados de inscripción** (`CapacitacionInscripcionesModel`): `asignado` → `en_progreso` → `completado`; más `vencido` y `cancelado`.

**Reglas.**
- Una inscripción se crea con `asignar($cursoId, $colaboradorId, $fechaVencimiento)`; `getByCursoColaborador()` evita duplicados.
- `ProgresoPorcentaje` se deriva de `capacitacion_leccion_progreso`.
- `capacitacion_quiz.PuntajeMinimo` define el aprobado; el intento se guarda en `capacitacion_quiz_intentos` con `RespuestasJson`.
- El certificado se emite solo tras completar y (si hay quiz) aprobar; `capacitacion_certificados.Codigo` permite validación pública mediante `CapacitacionCertificados::validar`.
- El PDF se genera con **mPDF** en `CapacitacionPortalController` usando la plantilla `app/views/capacitacion_certificados/plantilla.php`.

---

## 9. Mapa de Talento / Altos Potenciales

**Propósito.** Consolidar desempeño y potencial en un puntaje único y ubicar a cada colaborador en una matriz 9-Box.

**Controlador.** `PotencialesController` (1 109 líneas) · `$MODULE_NAME='Potenciales'` · `$ViewFolder='carrera_talento'`.

**Acciones.** `periodos`, `criterios`, `evaluar`, `recalcular`, `validarCategoria`, `listado`, `cerrarPeriodo`, `exportar`, `mapaTalentos`.

**Servicio central.** `TalentScoreService` (746 líneas) — **la pieza de lógica de negocio más importante del sistema**.

### Fórmula de cálculo

```
Desempeño   = puntaje de metas (metas_consolidado.Preliminar, o cálculo directo)
Potencial   = 0.60 × criterios HiPo + 0.40 × puntaje 360
Consolidado = 0.50 × Desempeño     + 0.50 × Potencial
```

Constantes: `PESO_DESEMPENO_EN_TOTAL=0.5`, `PESO_POTENCIAL_EN_TOTAL=0.5`, `PESO_CRITERIOS_EN_POTENCIAL=0.6`, `PESO_360_EN_POTENCIAL=0.4`.
Los pesos de desempeño/potencial son sobreescribibles vía `talento_config` (`getPesoDesempeno()`, `getPesoPotencial()`, `getUmbralHipo()`).

### Categorías por puntaje consolidado (0–100)

| Rango | Categoría | Color |
|---|---|---|
| ≥ 70 | Talento Top | verde |
| 60–69 | Alto Potencial | azul |
| 50–59 | Talento Clave | turquesa |
| 40–49 | En Desarrollo | naranja |
| < 40 | Riesgo / Bajo Desempeño | rojo |

Son valores **por defecto**; la tabla `talento_categorias` (`Label`, `PuntajeMin`, `PuntajeMax`, `Color`, `ColorFondo`, `Icono`, `Orden`, `Activo`) los sobreescribe. `talento_celdas_9box` configura las 9 celdas de la matriz (`CeldaKey`, `FilaY`, `ColX`, `SoloManual`, …).

### Métodos públicos de `TalentScoreService`

`listarCategorias()`, `getUmbralHipo()`, `getPesoDesempeno()`, `getPesoPotencial()`, `calcularPuntajeMetas()`, `calcularPuntaje360()`, `calcularPotencialCriterios()`, `calcularPotencialConsolidado()`, `calcularTotal()`, `categorizarTalento()`, `puedeCategorizar()`, `requierePlanDesarrollo()`, `actualizarEvaluacion()`, `obtenerResumenEquipo()`.

**Tablas.** `potencial_periodos`, `potencial_criterios`, `potencial_evaluaciones`, `potencial_evaluacion_detalle`, `talento_categorias`, `talento_celdas_9box`, `talento_config`.

Campos calculados en `potencial_evaluaciones`: `PuntajeDesempeno`, `PuntajePotencial`, `PuntajeTotal`, `EsAltoPotencial`, `PuntajeMetasAuto`, `Puntaje360Auto`, `CategoriaMapaTalento`, `FuenteDesempeno`.

**Reglas.**
- `requierePlanDesarrollo($puntaje)` marca a quién hay que generarle un plan de desarrollo.
- `puedeCategorizar($puntaje, $categoriaObjetivo)` impide asignar manualmente una categoría incompatible con el puntaje (lo usa `validarCategoria`).
- El servicio cachea categorías y config en propiedades estáticas **por petición**.
- Tiene *fallback* completo: si `talento_categorias` no existe o está vacía, usa `$CATEGORIAS_DEFAULT`.

**Punto crítico.** `2026_05_13_000086_link_potencial_evaluaciones_to_general_periodos.php` cambió el vínculo de `potencial_periodos` a `periodos`. Verificar cuál está en uso antes de tocar `PeriodoId`.

---

## 10. Planes de Carrera

**Propósito.** Rutas de carrera por cargo, con etapas y planes individuales con mentor.

| Controlador | Acciones |
|---|---|
| `PlanesCarreraController` (988 l.) | `index`, `crearRuta`, `guardarRuta`, `verRuta`, `editarRuta`, `desactivarRuta`, `gestionarEtapas`, `asignarPlan`, `verPlanColaborador`, `actualizarAvance`, `reporte` |
| `CarreraPortalController` (355 l.) | `miPlanCarrera`, `miEstadoTalento` (portal) |

**Tablas y relaciones:**
```
carrera_rutas.CargoOrigenId          → cargos.Id
carrera_ruta_etapas.RutaId           → carrera_rutas.Id
carrera_ruta_etapas.CargoDestinoId   → cargos.Id
carrera_plan_colaborador.ColaboradorId → colaboradores.Id
carrera_plan_colaborador.RutaId      → carrera_rutas.Id
carrera_plan_colaborador.MentorId    → colaboradores.Id
carrera_plan_colaborador.CargoMetaId → cargos.Id
carrera_plan_etapas.PlanId           → carrera_plan_colaborador.Id
carrera_plan_etapas.EtapaId          → carrera_ruta_etapas.Id
```

**Reglas.** Una ruta parte de un cargo origen y encadena etapas ordenadas, cada una con `CargoDestinoId`, `Requisitos` y `ExperienciaMinMeses`. Al asignar un plan se materializan las etapas en `carrera_plan_etapas`; `PorcentajeAvance` se recalcula con `actualizarAvance`. `carrera_rutas.Version` permite versionar rutas.

`PlanesCarreraController` es uno de los dos únicos sitios que usan **PHPMailer** directamente.

---

## 11. Sucesión

**Propósito.** Identificar puestos críticos y preparar sucesores.

**Controlador.** `SucesionController` (577 l.) · `$ViewFolder='carrera_talento'` · Acciones: `puestosClave`, `planes`, `candidatos`, `cambiarEstadoPlan`, `reporteRiesgo`.

**Tablas:**
```
sucesion_puestos_clave.CargoId  → cargos.Id     (+ MotivoCriticidad, RiesgoVacancia)
sucesion_planes.PuestoClaveId   → sucesion_puestos_clave.Id
sucesion_candidatos.PlanId      → sucesion_planes.Id
sucesion_candidatos.ColaboradorId → colaboradores.Id
```

Campos de negocio de `sucesion_candidatos`: `Readiness`, `PuntajeAjuste`, `Fortalezas`, `Brechas`, `AccionesDesarrollo`, `Principal`.

`reporteRiesgo` cruza `RiesgoVacancia` con la disponibilidad de candidatos listos.

---

## 12. Reconocimientos

**Propósito.** Muro de reconocimientos entre colaboradores, con aprobación y publicación.

**Controlador.** `ReconocimientosController` (354 l.) · `$ViewFolder='carrera_talento'` · Acciones: `tipos`, `enviar`, `aprobar`, `rechazar`, `publicar`, `muro`, `misReconocimientos`, `reglas`.

**Tablas:** `reconocimientos` (`TipoId`, `EmisorId`, `ReceptorId`, `Mensaje`, `Estado`, `AprobadoPor`, `Publicado`, `Evidencia`), `reconocimiento_tipos` (`RequiereAprobacion`, `Publicable`), `reconocimiento_reglas` (`LimiteMensualPorEmisor`).

**Regla de negocio.** `ReconocimientosModel::countByEmisorRango($emisorId, $desde, $hasta)` aplica el límite mensual por emisor definido en `reconocimiento_reglas.LimiteMensualPorEmisor`. Un tipo con `RequiereAprobacion=1` pasa por `aprobar`/`rechazar` antes de poder `publicar`; solo los tipos con `Publicable=1` llegan al muro.

---

## 13. Vacaciones

**Propósito.** Solicitud, aprobación y control de saldos de vacaciones, con festivos colombianos.

| Controlador | Módulo | Layout | Acciones |
|---|---|---|---|
| `VacacionesController` (444 l.) | `Vacaciones` | `metronic` | `list`, `detalle`, `aprobar`, `rechazar`, `cancelar`, `politicas` |
| `VacacionesColaboradorController` (276 l.) | `VacacionesColaborador` | `metronicPublic` | `list` (incluye alta de solicitud) |

**Modelos.** `SolicitudesVacacionesModel`, `SaldosVacacionesModel`, `PoliticasVacacionesModel`, `FestivosModel`.

**Estados** (`solicitudes_vacaciones.Estado`, texto en MAYÚSCULAS): `SOLICITADA` → `APROBADA` | `RECHAZADA` | `CANCELADA`.

### Reglas de negocio

| Regla | Dónde |
|---|---|
| Solo se aprueban solicitudes en estado `SOLICITADA` | `VacacionesController::aprobarSolicitudInterna()` |
| No puede haber **cruce de fechas** con otra solicitud del mismo colaborador | `SolicitudesVacacionesModel::existeCruce()` |
| Qué estados bloquean el cruce depende de `VACACIONES_BLOQUEAR_SOLICITADAS` (`.env`) | `getEstadosBloqueoCruce()` |
| Los días se cuentan hábiles o calendario según `politicas_vacaciones.CuentaDiasHabiles` | `calcularDiasSolicitados()` |
| Los festivos activos del rango se descuentan | `obtenerFestivosActivosEnRango()` sobre la tabla `festivos` |
| Sin saldo suficiente no se aprueba, salvo `politicas_vacaciones.PermiteSaldoNegativo = 1` | `aprobarSolicitudInterna()` |
| El saldo se crea al vuelo por (colaborador, política, año) | `SaldosVacacionesModel::getOrCreateSaldo()` |
| Al aprobar: `DiasDisponibles -= n`, `DiasTomados += n`, `DiasPendientes = max(0, DiasPendientes - n)` | `SaldosVacacionesModel::actualizarSaldo()` |

**Punto crítico.** La aprobación **no es transaccional**: si falla la actualización del saldo después de cambiar el estado, la solicitud queda aprobada con el saldo sin descontar. El código lo reconoce con el mensaje *"La solicitud cambió de estado, pero hubo un problema actualizando el saldo"*.

---

## 14. Nómina

**Propósito.** Liquidación de nómina parametrizable por conceptos y fórmulas.

**Controlador.** `NominaController` (452 l.) · layout `impresion` · Acciones: `list`, `crearPeriodo`, `periodo`, `guardarNovedad`, `recalcular`, `cerrarPeriodo`, `conceptos`, `eliminarConcepto`, `detalleLiquidacion`, `comprobante`, `exportCsv`, `plantillaCsvNovedades`.

**Modelos.** `NominaPeriodosModel`, `NominaConceptosModel`, `NominaNovedadesModel`, `NominaLiquidacionesModel`, `NominaLiquidacionDetalleModel`, `NominaParametrosModel`, y el servicio **`NominaCalculoModel`** (254 l., no extiende `Model`).

**Estados de periodo** (`NominaPeriodosModel`): incluye `ESTADO_EN_CALCULO` y `ESTADO_EN_REVISION`; `puedeEditar($periodo)` bloquea los cerrados.

### Motor de cálculo (`NominaCalculoModel`)

```mermaid
flowchart TD
    A["recalcularPeriodo(periodoId, usuarioId)"] --> B{"¿periodo existe\ny puedeEditar?"}
    B -- no --> X["return ok=false"]
    B -- sí --> C["cambiarEstado → EN_CALCULO"]
    C --> D["getColaboradoresActivos()\n(colaboradores.Estado = 1)"]
    D --> E["por cada colaborador:\nrecalcularColaborador()"]
    E --> F["cambiarEstado → EN_REVISION"]
    F --> G["BitacoraAuditoriaModel::registrar('RECALCULO')"]
```

`recalcularColaborador()`:
1. `diasLiquidados` = 15 si `TipoPeriodicidad='QUINCENAL'`, si no días entre `PeriodoInicio` y `PeriodoFin` (+1); 30 por defecto si el rango es inválido.
2. Contexto de variables: `SALARIO`, `DIAS_LIQUIDADOS`, `AUX_TRANSPORTE`, `PORC_SALUD`, `PORC_PENSION`, `HORAS_EXTRA_FACTOR` — los cuatro últimos desde `nomina_parametros`.
3. Para cada concepto activo (`nomina_conceptos`):
   - **Base** según `BaseCalculo`: `SALARIO` (por defecto), `SALARIO_DIARIO` (= salario/30), `TOTAL_DEVENGADO`, `NETO` (⚠️ trata `NETO` igual que `TOTAL_DEVENGADO`), o `PARAM:CLAVE`.
   - **Valor** según `FormulaTipo`: `FIJO` (la fórmula es el número), `PORCENTAJE` (`base × formula/100`), `FORMULA` (evaluación de expresión).
   - Se suma la novedad del concepto (`nomina_novedades.Valor`).
   - Se aplican `TopeMin` / `TopeMax`.
   - Se acumula en `totalDevengado` o `totalDeduccion` según `Tipo`.
4. `Neto = TotalDevengado − TotalDeduccion`.
5. Se guarda `nomina_liquidaciones` con un `HashCalculo` (`md5` del JSON de entradas) y se regenera `nomina_liquidacion_detalle`.

**🔴 Punto crítico de seguridad.** `safeEvalFormula()` sustituye las variables y, tras validar con `preg_match('/^[0-9\.\+\-\*\/\(\)\s]+$/')`, ejecuta **`eval()`**. La lista blanca de caracteres es estricta, pero el uso de `eval()` sobre datos de la tabla `nomina_conceptos` es una superficie de riesgo si alguien con acceso a esa tabla puede escribir en `Formula`. Ver `18_KNOWN_ISSUES.md`.

**Otros puntos.** El orden de los conceptos importa: `TOTAL_DEVENGADO` usa el acumulado **parcial** hasta ese momento. `nomina_conceptos` no tiene columna de orden explícita en el listado de columnas, así que el orden depende de `listarActivos()`.

---

## 15. Adelantos de Nómina

**Propósito.** Solicitudes de adelanto de salario con política de elegibilidad, desembolso y descuento por cuotas.

| Controlador | Módulo | Layout | Acciones |
|---|---|---|---|
| `AdelantosNominaController` (601 l.) | `AdelantosNomina` | `metronic` | `index`, `ver`, `cambiarEstado`, `aprobar`, `rechazar`, `registrarDesembolso`, `marcarCuotaDescontada`, `exportar` |
| `AdelantoPoliticasController` (109 l.) | `AdelantoPoliticas` | `metronic` | `ver`, `editar`, `guardar` |
| `AdelantosNominaPortalController` (410 l.) | `AdelantosNominaPortal` | `metronicPublic` | `misRecibos`, `certificadoLaboral`, `misSolicitudes`, `crear`, `guardar`, `verSolicitud`, `cancelar` |

**Tablas.** `adelanto_politicas`, `adelanto_solicitudes` → `adelanto_plan_descuento`, `adelanto_cuotas`, `adelanto_desembolsos`, `adelanto_historial`.

**Estados** (`AdelantoSolicitudesModel`): `creada` → `en_revision` → `aprobada` → `desembolsada` → `en_descuento` → `descontada`; ramas `rechazada` y `cancelada`. `estadosActivos()` define cuáles cuentan para el límite de solicitudes simultáneas.

### Reglas de elegibilidad (`AdelantoSolicitudesModel::validarSolicitud()`)

Todas las comprueba contra la **política activa** (`adelanto_politicas` con `Activo=1`):

| Regla | Columna de la política |
|---|---|
| Motivo obligatorio | `RequiereMotivo` |
| Soporte obligatorio | `RequiereSoporte` |
| No se aceptan solicitudes después del día N del mes | `DiaCorteMes` |
| Monto mínimo | `MontoMinimo` |
| Monto máximo absoluto | `MontoMaximo` |
| Monto máximo como % del salario del colaborador | `MontoMaximoPorcentajeSalario` |
| Antigüedad mínima en meses desde `colaboradores.FechaInicio` | `AntiguedadMinMeses` |
| Máximo de solicitudes en el mes natural | `MaxSolicitudesPorMes` |
| Días mínimos entre solicitudes consecutivas | `DiasMinEntreSolicitudes` |
| Máximo de solicitudes activas simultáneas | `MaxSolicitudesActivas` |

Devuelve `['ok'=>bool, 'errores'=>[], 'politica'=>…, 'fechaSolicitud'=>…]`.

**Otros elementos.** `generarCodigo()` produce el código legible de la solicitud. `adelanto_historial` registra cada cambio (`Accion`, `ValorAnterior`, `ValorNuevo`, `UsuarioId`). `adelanto_plan_descuento` define `TipoDescuento` y `NumeroCuotas`; `adelanto_cuotas` materializa cada cuota con `Estado` (`pendiente`/descontada) y `Periodo`.

El portal también emite el **certificado laboral** (`certificadoLaboral`) y muestra los recibos de nómina del colaborador.

---

## 16. Control de Asistencia

**Propósito.** Registro de entradas/salidas, turnos, horas extra y ausencias.

| Controlador | Módulo | Layout | Acciones |
|---|---|---|---|
| `AsistenciaController` (332 l.) | `Asistencia` | `metronic` | `index`, `ver`, `registrarEntrada`, `registrarSalida`, `calendario`, `reporteMensual` |
| `TurnosController` (166 l.) | `Turnos` | `metronic` | `index`, `crear`, `guardar`, `eliminar`, `asignar` |
| `AusenciasController` (219 l.) | `Ausencias` | `metronicPublic` | `index`, `crear`, `guardar`, `aprobar`, `ver` |

**Modelos.** `AsistenciaRegistrosModel` (296 l.), `AsistenciaTurnosModel`, `AsistenciaColaboradorTurnosModel`, `AsistenciaHorasExtraModel`, `AsistenciaAusenciasModel`.

**Estados de un registro diario** (`AsistenciaRegistrosModel`): `pendiente`, `completo`, `inconsistente`, `ausente`, `vacaciones`, `incapacidad`, `permiso`.

### Reglas

| Regla | Método |
|---|---|
| No se puede marcar si hay vacaciones aprobadas ese día | `tieneVacacionesAprobadas()` + `validarBloqueoMarcacion()` |
| No se puede marcar si hay una ausencia aprobada ese día | `getAusenciaAprobada()` + `validarBloqueoMarcacion()` |
| Una entrada crea el registro del día en estado `pendiente` | `marcarEntrada()` |
| La salida cierra el registro y descuenta `MinutosAlmuerzo` | `marcarSalida()` |
| Minutos trabajados = salida − entrada − almuerzo | `calcularMinutosTrabajados()` |
| Minutos programados salen del turno asignado ese día | `calcularMinutosProgramados()` |
| Las horas extra son el excedente sobre lo programado | `calcularMinutosExtra()` → `asistencia_horas_extra` (`Tipo`, `Minutos`, `Aprobada`) |
| Un día sin registro se muestra con estado derivado | `getEstadoDiaVirtual()` |
| El turno define tolerancias | `asistencia_turnos.ToleranciaEntradaMinutos` / `ToleranciaSalidaMinutos` / `CruzaMedianoche` |

`asistencia_colaborador_turnos` asigna un turno a un colaborador en un rango de fechas.

---

## 17. Beneficios

**Propósito.** Catálogo de beneficios con reglas de elegibilidad, cupos, presupuesto, solicitudes y asignaciones.

| Controlador | Módulo | Layout | Acciones |
|---|---|---|---|
| `BeneficiosController` (339 l.) | `Beneficios` | `metronic` | `index`, `crear`, `guardar`, `ver`, `activar`, `inactivar`, `archivar` |
| `BeneficiosSolicitudesController` (398 l.) | `BeneficiosSolicitudes` | `metronic` | `index`, `ver`, `cambiarEstado`, `aprobar`, `rechazar`, `entregar`, `exportar` |
| `BeneficiosAsignacionesController` (304 l.) | `BeneficiosAsignaciones` | `metronic` | `index`, `asignar`, `asignarMasivo`, `entregar`, `cancelar` |
| `BeneficiosPortalController` (383 l.) | `BeneficiosPortal` | `metronicPublic` | `disponibles`, `verBeneficio`, `misAsignaciones`, `misSolicitudes`, `crearSolicitud`, `guardarSolicitud`, `verSolicitud`, `cancelarSolicitud` |

**Tablas.** `beneficios` → `beneficios_reglas`, `beneficios_catalogo_programas`; `beneficios_solicitudes` → `beneficios_solicitud_historial`; `beneficios_asignaciones`.

**Enumeraciones (`BeneficiosModel`):**
- Tipo: `subsidio`, `auxilio`, `bono`, `programa`
- Estado del beneficio: `borrador` → `activo` ↔ `inactivo` → `archivado`
- Frecuencia: `una_vez`, `mensual`, `trimestral`, `anual`, `sin_limite`

**Estados de solicitud (`BeneficiosSolicitudesModel`):** `creada` → `en_revision` → `aprobada` → `entregada`; ramas `rechazada` y `cancelada`.

### Reglas

| Regla | Método |
|---|---|
| Vigencia por fechas | `BeneficiosModel::estaVigente($beneficio)` — `FechaInicio` / `FechaFin` |
| Elegibilidad por reglas | `esElegibleParaColaborador($beneficioId, $colaboradorId)` sobre `beneficios_reglas` (`ReglaTipo`, `Criterio`, `ReferenciaId`, `Valor`) |
| Catálogo filtrado por elegibilidad | `obtenerBeneficiosElegibles($colaboradorId)` — es lo que ve el portal |
| Disponibilidad de cupo y presupuesto | `tieneDisponibilidad($beneficio, $montoAprobar)` — `CupoTotal` vs `CupoUsado`, `PresupuestoTotal` vs `PresupuestoUsado` |
| Al aprobar se consume cupo y presupuesto | `aplicarConsumoAprobacion($beneficio, $montoAprobado)` |
| Límite por frecuencia | `BeneficiosSolicitudesModel::puedeSolicitarPorFrecuencia()` |
| Sin cupo, la solicitud se marca | `marcarSinCupo($solicitudId)` |
| Aprobación centralizada | `aprobarSolicitud($solicitudId, $montoAprobado, $aprobadoPor)` |
| `AprobacionRequerida=0` permite entrega directa | columna de `beneficios` |

Cada cambio se registra en `beneficios_solicitud_historial`.

---

## 18. Servicio al Colaborador (Tickets)

**Propósito.** Mesa de ayuda interna con SLA y base de conocimiento.

| Controlador | Módulo | Layout | Acciones |
|---|---|---|---|
| `TicketsController` (821 l.) | `Tickets` | `metronicPublic` | `index`, `crear`, `guardar`, `ver`, `asignar`, `cambiarEstado`, `cambiarPrioridad`, `responder`, `cerrar`, `calificar`, `exportar` |
| `CentroAyudaController` (263 l.) | `CentroAyuda` | `metronicPublic` | `index`, `ver`, `adminIndex`, `crear`, `guardar`, `editar`, `eliminar` |
| `ServicioConfiguracionController` (306 l.) | `ServicioConfiguracion` | `metronic` | `categorias`, `guardarCategoria`, `eliminarCategoria`, `activarCategoria`, `subcategorias`, `guardarSubcategoria`, `eliminarSubcategoria`, `sla`, `guardarSla` |

**Tablas.** `servicio_tickets` → `servicio_ticket_comentarios`, `servicio_ticket_historial`; catálogos `servicio_categorias` → `servicio_subcategorias`, `servicio_articulos`, `servicio_sla_politicas`.

**Estados (`ServicioTicketsModel`):** `nuevo` → `asignado` → `en_progreso` ↔ `en_espera` → `resuelto` → `cerrado`; `reabierto`.
**Prioridades:** `baja`, `media`, `alta`, `critica`.
**Estados SLA:** `en_tiempo`, `vencido`.

### Reglas de SLA

`servicio_sla_politicas` define por prioridad `MinutosPrimeraRespuesta` y `MinutosResolucion`.

```
MinutosPrimeraRespuesta(ticket) = minutos(CreatedAt → FechaPrimeraRespuesta)
MinutosResolucion(ticket)       = minutos(CreatedAt → FechaCierre)

SlAEstado = 'vencido'  si  MinutosPrimeraRespuesta > política.MinutosPrimeraRespuesta
                        o  MinutosResolucion       > política.MinutosResolucion
            'en_tiempo' en otro caso
```

Implementado en `calcularMinutos()`, `recalcularSla()` y `actualizarSlaEstado($ticketId)`.

**Otras reglas.** `generarCodigo()` produce el consecutivo visible. `listarConFiltros($filtros, $soloColaboradorId)` — el segundo argumento **restringe el listado al colaborador**, y es el mecanismo por el que el portal solo muestra los tickets propios. `getResumenColaborador()` alimenta el panel del colaborador. `Calificacion` y `ComentarioSatisfaccion` se capturan en `calificar`, después de `cerrar`.

**Nota.** `TicketsController` usa layout `metronicPublic` **también para el backoffice**; es el mismo controlador para ambos públicos, diferenciado por permisos y por el filtro `soloColaboradorId`.

---

## 19. Línea de Ética

**Propósito.** Canal de denuncias anónimo con seguimiento por código y PIN.

| Controlador | Módulo | Acceso | Acciones |
|---|---|---|---|
| `LineaEticaPublicaController` (372 l.) | `LineaEticaPublica` | **Público (`'*'`)** | `crear`, `guardar`, `seguimiento`, `verCaso`, `agregarMensaje` |
| `LineaEticaController` (618 l.) | `LineaEtica` | `'@'` | `index`, `ver`, `asignar`, `cambiarEstado`, `cambiarPrioridad`, `responder`, `agregarNota`, `archivar`, `cerrar`, `configuracion`, `guardarTipo`, `guardarCategoria`, `desactivarTipo`, `desactivarCategoria` |

**Tablas.** `linea_etica_casos` → `linea_etica_mensajes`, `linea_etica_notas_internas`, `linea_etica_bitacora`; catálogos `linea_etica_tipos`, `linea_etica_categorias`.

### Mecanismo de anonimato

```
Radicación → generarCodigoUnico()  → 'ETH-{año}-{6 dígitos}'  (hasta 30 reintentos por colisión)
           → generarPinTemporal()  → random_int(100000, 99999999)
           → crearPinHash($pin)    → password_hash(PASSWORD_DEFAULT)
Seguimiento → getByCodigo($codigo) + verificarPin($hash, $pin) → password_verify()
```

El PIN **se muestra una sola vez** al denunciante y solo se almacena su hash. No hay recuperación.

### Máquina de estados (`puedeTransicionarEstado()`)

```mermaid
stateDiagram-v2
    [*] --> recibido
    recibido --> en_revision
    recibido --> archivado
    en_revision --> en_investigacion
    en_revision --> en_espera
    en_revision --> archivado
    en_investigacion --> en_espera
    en_investigacion --> cerrado
    en_investigacion --> archivado
    en_espera --> en_investigacion
    en_espera --> cerrado
    en_espera --> archivado
    cerrado --> en_investigacion : solo si permitirReabrir
    archivado --> [*]
```

`archivado` es **terminal absoluto**: no admite ninguna transición de salida.

**Otras reglas.** Prioridades: `baja`, `media`, `alta`, `critica`. `linea_etica_mensajes.VisibleParaDenunciante` separa la comunicación con el denunciante de la interna; `linea_etica_notas_internas` **nunca** es visible para él. `AutorTipo` distingue denunciante de gestor. Se registran `FechaPrimeraRespuesta`, `FechaUltimaActividad` y `FechaCierre`; `getKpis($casos)` los explota.

---

## 20. Comunicación Interna

| Controlador | Módulo | Acciones |
|---|---|---|
| `ComunicacionInternaController` (516 l.) | `ComunicacionInterna` | `index`, `noticias`, `crearNoticia`, `guardarNoticia`, `verNoticia`, `publicarNoticia`, `archivarNoticia`, `murales`, `crearMural`, `guardarMural`, `publicarMural`, `archivarMural`, `ordenarMurales`, **`portalNoticias` (`'*'`, público)** |
| `NotificacionesController` (419 l.) | `notificaciones` | `index`, `crear`, `enviarMasivo`, `marcarLeido`, `marcarTodasLeidas`, `list`, `estudiantes`, `docentes`, `directores`, `configurar` |
| `MensajesInternosController` (353 l.) | `MensajesInternos` | `bandeja`, `nuevaConversacion`, `verConversacion`, `enviar`, `marcarLeido`, `archivar` |

**Tablas.** `comunicacion_noticias` + `comunicacion_noticias_audiencias`; `comunicacion_murales`; `comunicacion_notificaciones`; `comunicacion_conversaciones` → `comunicacion_mensajes` → `comunicacion_mensajes_estado`.

**Reglas.**
- Una noticia se dirige a audiencias mediante `comunicacion_noticias_audiencias(TipoAudiencia, ReferenciaId)` — permite segmentar por área, sede, cargo, etc.
- Ciclo de la noticia: borrador → `publicarNoticia` → `archivarNoticia`; `FechaExpiracion` y `Destacado` controlan la visibilidad en el portal.
- Los murales se ordenan con `ordenarMurales` (campo `Orden`) y admiten `LinkTipo`/`LinkDestino`.
- La mensajería es **1 a 1**: `comunicacion_conversaciones` tiene exactamente `ParticipanteAId` y `ParticipanteBId`, ambos FK a `colaboradores`. No hay grupos.
- `comunicacion_mensajes_estado` permite archivar/eliminar un mensaje **por usuario** sin borrarlo para el otro.

**⚠️ Deuda visible.** `NotificacionesController` conserva las acciones `estudiantes`, `docentes` y `directores`, vocabulario de un producto académico anterior. No pertenecen al dominio de RR. HH.

---

## 21. Mi Equipo

**Propósito.** Vista de líder sobre su equipo directo.

**Controlador.** `MiEquipoController` (369 l.) · `$MODULE_NAME='MiEquipo'` · layout `metronicPublic` · Acciones: `index`, `colaborador`.

**Regla de visibilidad.** El ítem de menú solo aparece si `Menu::evaluarCondicionItem()` resuelve la condición `colaborador_lider_equipo`, que llama a `ColaboradoresModel::esLiderEquipo($idSesion)` (columna `colaboradores.LiderEquipo`). **Es el único mecanismo de condición de menú del sistema.**

El equipo se determina por `colaboradores.IdJefeInmediato` (y/o `IdJefeFuncional`). Agrega datos de metas, evaluaciones, planes de desarrollo y talento del equipo (`TalentScoreService::obtenerResumenEquipo()`).

---

## 22. Portal del Colaborador

**Propósito.** Autoservicio del empleado. No es un módulo con tabla propia sino un **contexto de ejecución**: layout `metronic_public`, menú `Menu::$public`, sesión marcada con `ColaboradorPublic`.

| Controlador | Módulo | Acciones | Acceso |
|---|---|---|---|
| `PublicController` (862 l.) | `public` | `index` (login), `validate`, `validated` (OAuth Microsoft), `home`, `gestiones`, `manual`, `vacantesPublicas`, `postularVacante` | `index`, `validate`, `validated`, `home`, `manual`, `vacantesPublicas`, `postularVacante` son `'*'`; `gestiones` es `'@'` |
| `DesempenoController` (152 l.) | `Desempeno` | `home` | `'@'` |
| `TiempoBeneficiosController` (118 l.) | `TiempoBeneficios` | `home` | `'@'` |

**Modelo.** `PublicModel` (260 l.).

**Controladores que forman el portal** (todos con layout `metronicPublic`): `PerfilColaboradorController`, `MetasColaboradorController`, `EvaluacionesColaboradorController`, `PortalPlanesDesarrolloController`, `PlanesDesarrolloEquipoController`, `CarreraPortalController`, `MiEquipoController`, `VacacionesColaboradorController`, `BeneficiosPortalController`, `AdelantosNominaPortalController`, `CapacitacionPortalController`, `TicketsController`, `CentroAyudaController`, `AusenciasController`, `DesempenoController`, `TiempoBeneficiosController`.

**Login.** `PublicController::validateAction()` requiere `user`, `pass` **y `periodo`** (el periodo se selecciona en el formulario de login). Ver `09_AUTHENTICATION_AUTHORIZATION.md`.

**⚠️ `manual` es contenido legado ajeno al dominio.** `app/views/public/manual.php` muestra *"Bienvenido al sistema de gestión docente"* y enlaza PDFs de `files/ayuda/` (`Estudiantes.pdf`, `Docentes.pdf`, `Directores.pdf`), con una ramificación por `Controller::getAppId() === "klee_compensar_escuela"` codificada a fuego. No tiene relación con Kuorum ni con los manuales de `docs/guias_modulos/`. Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md).

---

## 23. Configuración y Administración

| Controlador | Módulo | Tabla | Acciones |
|---|---|---|---|
| `ConfiguracionesController` (120 l.) | `configuraciones` | `configuraciones` | `view`, `editGeneral`, `removeConfig` |
| `RolesController` (155 l.) | `roles` | `roles` | `create`, `edit`, `remove`, `view` |
| `UsuariosController` (325 l.) | `usuarios` | `usuarios` | `create`, `edit`, `editPass`, `editPhoto`, `remove`, `view` |
| `PermisosController` (181 l.) | `permisos` | `permisos` | `create`, `edit`, `remove`, `view` |
| `PeriodosController` (136 l.) | `periodos` | `periodos` | `create`, `edit`, `remove`, `view` |
| `SedesController` (135 l.) | `sedes` | `sedes` | `create`, `edit`, `remove`, `view` |
| `TiposContratoController` (103 l.) | `TiposContrato` | `tipos_contrato` | `create`, `edit`, `remove`, `view` |
| `CargoAdministrativoController` (127 l.) | `CargoAdministrativo` | `cargos` | `create`, `edit`, `remove`, `view` |
| `DependenciasController` (135 l.) | `Dependencias` | `areas` | `create`, `edit`, `remove`, `view` |
| `MetasCategoriasController` (210 l.) | `MetasCategorias` | `metas_categorias` | CRUD |
| `EscalasController` (129 l.) | `Escalas` | `escalas` | CRUD |
| `AlertasController` (151 l.) | `alertas` | `alertas` | `view`, `generate`, `toggleEstado` |
| `AlertasEmailController` (363 l.) | `AlertasEmail` | `alertas_email` | `create`, `edit`, `remove`, `list`, `view`, `send`, **`sendCron` y `sendCronSolicitudAdmin` (`'*'` + `CRON_TOKEN`)** |
| `IpsAutorizadasController` (108 l.) | `ipsAutorizadas` | `ips_autorizadas` | `create`, `edit`, `remove`, `view` |
| `TiposContratoAdministrativoController` (7 l.) | `TiposContratoAdministrativo` | — | **Ninguna acción. Clase vacía** |

**Notas.**
- El nombre visible "Dependencias" corresponde a la tabla `areas`. Y "Cargos Administrativos" a la tabla `cargos`.
- `configuraciones` tiene PK textual (`Id` = la clave, p. ej. `metas.max_por_colaborador`), `Grupo` y `Valor`. Acceso: `ConfiguracionesModel::getValor($clave, $default)`.
- `periodos` es la tabla de configuración **más importante**: gobierna metas, evaluación 360 y filtros de sesión.
- `TiposContratoAdministrativoController` es una clase vacía de 7 líneas que sí tiene entrada en `permisos`.

---

## 24. Asistente IA

**Propósito.** Consultas en lenguaje natural sobre la base de datos, traducidas a SQL de solo lectura por un LLM local.

**Controlador.** `AsistenteIAController` (94 l.) · Acciones: `index`, `ask` (JSON), `clearHistory` (JSON). Todas `'@'`.

**Arquitectura del servicio:**

```mermaid
flowchart LR
    A["AsistenteIAController::ask\n(POST + CSRF sin rotación)"] --> B["AsistenteIAModel::ask()"]
    B --> C["AsistenteIAService::processQuestion()"]
    C --> D["DatabaseContext\nesquema resumido de la BD"]
    C --> E["SQLGenerator (1456 l.)\ngenerateSelectSql()"]
    E --> F["OllamaProvider\nHTTP → modelo local"]
    E --> G{"isReadOnlySelect(sql)?"}
    G -- no --> H["Bloqueo + Logger::warning"]
    G -- sí --> I["Cache::get(sha1(sql))"]
    I -- miss --> J["MysqlPDO::queryAllSql()"]
    J --> K["Cache::setWithNamespace(TTL=IA_QUERY_CACHE_TTL)"]
    I -- hit --> L
    K --> L["ResponseFormatter\n+ OllamaProvider → respuesta en prosa"]
```

**Salvaguardas.**
- `SQLGenerator::isReadOnlySelect()` bloquea todo lo que no sea un `SELECT` seguro (cubierto por `tests/SQLGeneratorTest.php`).
- Límites de contexto: `IA_MAX_SCHEMA_TABLES` (50), `IA_MAX_SCHEMA_COLUMNS_PER_TABLE` (12), `IA_MAX_SCHEMA_CHARS` (5 000), `IA_MAX_HISTORY_MESSAGES` (10).
- La pregunta se trunca a 1 200 caracteres.
- Caché de resultados por hash de SQL, TTL `IA_QUERY_CACHE_TTL` (180 s).
- `buildMainIndicatorsFallbackSql()` como plan B si el SQL generado falla.

**⚠️ Riesgo documentado en el propio `.env.example`:** por HTTP viajan en claro el esquema de la base y resultados con datos personales y salariales. Usar `https://` en producción.

El widget flotante se inyecta en **todas** las páginas del backoffice desde `app/layouts/metronic.php` → `_asistente_ia_widget.php`.

---

## 25. Controladores transversales

| Controlador | Propósito | Notas |
|---|---|---|
| `AjaxController` (831 l.) | 11 endpoints AJAX genéricos: `getAll`, `update`, `setAlertaVista`, `getResumen`, `getEstadosPais`, `getCiudadesEstado`, `getDocente`, `getDocentes`, `getProductosIntelectuales`, `subirImagenBase64`, `textoAImagenBase64` | Solo 6 están en `loadAccessControl()`; el resto **queda denegado**. `getDocente*`/`getProductosIntelectuales` son legado académico. Las operaciones de firma exigen POST + CSRF + rol `Administrador` |
| `ApiController` (47 l.) | `main` (`'*'`, devuelve `["API KLEE"]`), `quickSearch` (`'@'`, JSON) | Único endpoint JSON pensado como API |
| `GeneralController` (16 l.) | `copy`, `dropFiles` | Permisos generales para `Config::$NO_COPY` y `$DROP_FILES` |
| `ElFinderController` (228 l.) | `conector` (`'@'`) | Monta elFinder de `vendor/studio-42/elfinder` sobre `files/`. `error_reporting(0)` |
| `LoginController` (80 l.) | `admin`, `validate`, `logout` | Login del backoffice |
| `DemoController` (588 l.) | `index`, `salir`, `isSensitive`, `shouldBlock` | Modo demostración |
| `TestController` (262 l.) | `index`, `test`, `modelTest`, `log` | Herramienta de pruebas; su acción `'*'` fue corregida en la auditoría |
| `SincronizacionController` (1 255 l.) | 17 acciones de importación académica | **Legado ajeno al dominio.** Ver `18_KNOWN_ISSUES.md` |
| `CarreraTalentoController` (23 l.) | `index` | Contenedor casi vacío |

---

## Documentos relacionados
- [05_DATABASE.md](05_DATABASE.md)
- [10_BUSINESS_LOGIC.md](10_BUSINESS_LOGIC.md)
- [11_WORKFLOWS.md](11_WORKFLOWS.md)
- [19_DEPENDENCY_MAP.md](19_DEPENDENCY_MAP.md)
- [21_TRACEABILITY.md](21_TRACEABILITY.md)
