# 11 — Workflows

> Procesos de extremo a extremo tal como están implementados. Cada diagrama nombra el controlador, la acción y el modelo que ejecuta cada paso.

---

## Índice

1. [Autenticación en el backoffice](#1-autenticación-en-el-backoffice)
2. [Autenticación en el portal del colaborador](#2-autenticación-en-el-portal-del-colaborador)
3. [CRUD genérico](#3-crud-genérico)
4. [Alta y contratación de un colaborador](#4-alta-y-contratación-de-un-colaborador-reclutamiento-completo)
5. [Ciclo de metas](#5-ciclo-de-metas)
6. [Evaluación 360](#6-evaluación-360)
7. [Cálculo de talento y 9-Box](#7-cálculo-de-talento-y-9-box)
8. [Solicitud y aprobación de vacaciones](#8-solicitud-y-aprobación-de-vacaciones)
9. [Marcación de asistencia](#9-marcación-de-asistencia)
10. [Liquidación de nómina](#10-liquidación-de-nómina)
11. [Solicitud de adelanto de nómina](#11-solicitud-de-adelanto-de-nómina)
12. [Solicitud de beneficio](#12-solicitud-de-beneficio)
13. [Ciclo de vida de un ticket](#13-ciclo-de-vida-de-un-ticket)
14. [Denuncia ética anónima](#14-denuncia-ética-anónima)
15. [Curso de capacitación](#15-curso-de-capacitación)
16. [Consulta al Asistente IA](#16-consulta-al-asistente-ia)
17. [Envío programado de correo](#17-envío-programado-de-correo)
18. [Despliegue de un esquema desde cero](#18-despliegue-de-un-esquema-desde-cero)

---

## 1. Autenticación en el backoffice

```mermaid
sequenceDiagram
    autonumber
    actor U as Usuario
    participant LC as LoginController
    participant RL as RateLimiter
    participant UM as UsuariosModel
    participant PM as PermisosModel
    participant MN as Menu
    participant S as $_SESSION[APP_ID]

    U->>LC: GET ?c=login&a=admin
    LC->>LC: validateSession() → false
    LC-->>U: login/admin.php (layout metronic_empty) con _csrf_token
    U->>LC: POST ?c=login&a=validate
    LC->>LC: método POST + validateCsrfToken()
    LC->>LC: user y pass no vacíos
    LC->>RL: check('login_admin:'+sha256(user), 10, 900)
    LC->>UM: validateUser(user, pass)
    UM->>UM: SELECT * FROM usuarios WHERE Usuario=…
    UM->>UM: PassHelper::verify() → md5(pass) == Contrasena
    UM->>S: User (sin Contrasena) + UserType (desde roles)
    UM->>UM: Controller::filtersSesionValuesDefault()
    UM->>PM: loadPermissions() → SELECT * FROM permisos WHERE IdRol=…
    PM->>S: Permissions[ModuleName] = json_decode(Permission)
    UM->>MN: loadMenu(Menu::$principal)
    MN->>MN: validarMenuItems() descarta ítems cuya URL sea '#'
    MN->>S: Menu (árbol filtrado)
    UM->>UM: LogAccionesModel::saveAccess('Inicio Sesión')
    LC->>RL: reset(clave)
    LC->>LC: regenerateSessionOnAuthentication()
    LC->>S: unset ColaboradorPublic; MenuOpen=true; ShowPopup=true
    LC-->>U: 302 ?c=colaboradores&a=list
```

**Puntos de fallo y su mensaje:**

| Situación | Mensaje |
|---|---|
| No es POST o CSRF inválido | *"Solicitud de inicio de sesión inválida. Intenta nuevamente."* |
| `user` o `pass` vacíos | *"Usuario y contraseña son obligatorios."* |
| Rate limit superado | *"Demasiados intentos. Intenta nuevamente en unos minutos."* |
| Usuario inexistente, inactivo o contraseña incorrecta | *"No fue posible iniciar sesión con esas credenciales."* (el mismo para los tres, deliberadamente) |

---

## 2. Autenticación en el portal del colaborador

```mermaid
sequenceDiagram
    autonumber
    actor C as Colaborador
    participant PC as PublicController
    participant CM as ColaboradoresModel
    participant S as $_SESSION[APP_ID]

    C->>PC: GET ?c=public&a=index
    PC-->>C: public/login.php con selector de periodo y _csrf_token
    C->>PC: POST ?c=public&a=validate {user, pass, periodo, _csrf_token}
    PC->>PC: POST + CSRF + campos obligatorios (periodo > 0)
    PC->>PC: RateLimiter 'login_colaborador:'+sha256(user) (10/900)
    PC->>CM: validateUser(user, pass, periodo)
    CM->>CM: SELECT * FROM colaboradores WHERE Usuario=…
    CM->>CM: Estado == 0 → user_inactive
    CM->>CM: PassHelper::verify() → md5
    CM->>S: User = fila de colaboradores (sin Contrasena), Type = 5
    CM->>S: UserType = RolesModel::getById(5)
    CM->>S: loadPermissions() (rol 5)
    CM->>S: sobrescribe permisos por código para ~12 módulos
    PC->>PC: regenerateSessionOnAuthentication()
    PC->>S: ColaboradorPublic = true; MenuOpen = true
    PC-->>C: 302 ?c=PerfilColaborador&a=panel
```

En cada petición posterior, `Controller::validateAccess()` comprueba **además** que la clase y la acción estén en `Controller::$PUBLIC_COLLABORATOR_ROUTES`.

---

## 3. CRUD genérico

Patrón que siguen la mayoría de los módulos de catálogo.

```mermaid
flowchart TD
    A["GET ?c=X&a=list"] --> A1["Menu::setActive()"]
    A1 --> A2["Controller::listAction()\n→ getListAjaxObject() → ListaAjax::getHtml()"]
    A2 --> A3["View::render_view('x/list')\n→ tabla vacía + script DataTables"]
    A3 --> B["POST ?c=X&a=dataListAjax (asíncrono)"]
    B --> B1["ListaAjax::generateDataListAjax()\n→ loadCriteria() → getQuantityView ×2 → getAllView()"]
    B1 --> B2["JSON {draw, recordsTotal, recordsFiltered, data[]}"]

    A3 --> C["GET ?c=X&a=create"]
    C --> C1["Model::model()\ngenerateCsrfToken()"]
    C1 --> C2["View::render_view('x/create')\n→ load_view('x/_form')"]
    C2 --> D["POST ?c=X&a=create"]
    D --> D1{"isset(\$_POST[get_class(\$model)])"}
    D1 --> D2["Model::save()"]
    D2 --> D3{"validateCsrfToken()"}
    D3 -- no --> DE["Flash 'Token de seguridad inválido' → false"]
    D3 -- sí --> D4["Id == null → saveOnCreate()"]
    D4 --> D5["beforeCreate() → receiveData('Create')\npor cada Atributo con CanEditOnCreate"]
    D5 --> D6{"¿todos válidos?"}
    D6 -- no --> DF["Flash 'Verifique sus datos' → false"]
    D6 -- sí --> D7["MysqlPDO::insert() → INSERT"]
    D7 --> D8["Id = lastInsertId(); log('Creación') si \$LOG"]
    D8 --> D9["Flash Success + redirect a list"]

    A3 --> E["GET ?c=X&a=edit&Id=N"]
    E --> E1["loadById(N) hidrata los Atributos"]
    E1 --> E2["View::render_view('x/edit')"]
    E2 --> F["POST ?c=X&a=edit"]
    F --> F1["loadById(\$_POST[Modelo]['Id'])  ← imprescindible"]
    F1 --> F2["save() → Id != null → saveOnUpdate() → UPDATE"]
    F2 --> F3["Flash Success + redirect a list"]

    A3 --> G["POST ?c=X&a=remove&Id=N\n(formulario generado por ListaAjax)"]
    G --> G1{"Controller::process():\nPOST + validateCsrfTokenNoRotate"}
    G1 -- no --> GE["Flash 'Solicitud de eliminación inválida' → redirect"]
    G1 -- sí --> G2["deleteById(N) → DELETE … LIMIT 1"]
    G2 --> G3["Flash Success + redirect a list"]
```

---

## 4. Alta y contratación de un colaborador (reclutamiento completo)

```mermaid
flowchart TD
    subgraph "Configuración previa"
      P0["Pipeline global\n?c=Reclutamiento&a=pipeline\n→ reclutamiento_etapas_pipeline"]
      P1["Motivos de rechazo\n→ reclutamiento_motivos_rechazo"]
    end

    subgraph "Vacante"
      V1["?c=Reclutamiento&a=vacanteCreate\n→ reclutamiento_vacantes"]
      V1 --> V2["EtapasEmbudoVacanteModel::sincronizarConPipelineGlobal()\n→ etapas_embudo_vacante"]
      V2 --> V3["Reglas de filtro (opcional)\n→ reglas_filtro_vacante"]
      V3 --> V4["?a=vacanteChangeEstado → 'publicada'"]
    end

    subgraph "Candidato"
      C1["Interno: ?a=candidatoCreate"]
      C2["Externo: ?c=public&a=vacantesPublicas\n→ ?a=postularVacante"]
      C1 --> C3["reclutamiento_candidatos"]
      C2 --> C3
    end

    subgraph "Postulación"
      C3 --> A1["?a=applicationCreate\n→ RecruitmentApplicationsModel::crearPostulacion()"]
      A1 --> A2{"candidatoYaEstaContratado?"}
      A2 -- sí --> AX["Rechazo"]
      A2 -- no --> A3{"¿ya existe postulación\na esta vacante?"}
      A3 -- sí --> AX
      A3 -- no --> A4["reclutamiento_postulaciones\nEstadoProceso = 'nueva'"]
      A4 --> A5["ScoringCandidatosService\n→ PuntajeAutomatico"]
    end

    subgraph "Embudo (kanban)"
      A5 --> K1["?a=kanban  /  ?a=moveStage\n→ moverEtapa()"]
      K1 --> K2["Validaciones R1–R4"]
      K2 --> K3["reclutamiento_historial_etapas_postulacion"]
      K3 --> K4["Entrevistas: ?a=interviewCreate\n→ reclutamiento_entrevistas\n?a=interviewReprogramar\n→ historial_entrevista_postulacion"]
    end

    subgraph "Oferta"
      K4 --> O1["?a=offerCreate → reclutamiento_ofertas"]
      O1 --> O2["?a=offerChangeEstado → ACEPTADA"]
      O2 --> O3["moverEtapa a Codigo='oferta'\nEstadoProceso = 'oferta'"]
    end

    subgraph "Contratación"
      O3 --> T1["moverEtapa a Codigo='contratado'"]
      T1 --> T2{"validarContratacion():\n¿hay oferta ACEPTADA?"}
      T2 -- no --> TX["Error, no se mueve"]
      T2 -- sí --> T3["EstadoProceso = 'contratada'"]
      T3 --> T4["ContratacionDesdeOfertaService\n→ colaboradores + procesos_seleccion_colaborador"]
      T4 --> T5["?a=contractCreate → reclutamiento_contratos"]
      T5 --> T6["?a=contractChangeEstado → firmado\n(configuraciones.ReclutamientoContratoExigeFirma)"]
      T6 --> T7["Colaborador: EstadoActual='Activo', Estado=1"]
    end
```

**Rechazo** en cualquier punto: `moverEtapa` a la etapa con `Codigo='rechazado'` **exigiendo** `IdMotivoRechazo` → `EstadoProceso = 'rechazada'` + `ComentarioRechazo`.

---

## 5. Ciclo de metas

```mermaid
flowchart TD
    subgraph "Configuración (Administrador)"
      S1["?c=periodos&a=create\nInicioMetas · LimiteMetas · FinalMetas\nMinimoMetas · MaximoMetas"]
      S2["?c=MetasCategorias&a=create\nNombre · Maximo (peso) · NumeroMetas"]
    end

    subgraph "Planeación — ventana 'planeacion'"
      S1 --> M1["Colaborador: ?c=MetasColaborador&a=list\nAdmin: ?c=Metas&a=create"]
      M1 --> M2["validateMetaPayload()"]
      M2 --> M3{"Categoría y objetivo no vacíos"}
      M3 --> M4{"suma de pesos <= 100"}
      M4 --> M5{"cantidad < MaximoMetas"}
      M5 --> M6{"validateCategoryRules():\ntope de metas y peso por categoría"}
      M6 -- falla --> MX["array de errores → flash"]
      M6 -- ok --> M7["metas: EstadoMeta='borrador'"]
    end

    subgraph "Envío a revisión"
      M7 --> R1["validateMetaSubmission()"]
      R1 --> R2{"¿hay metas editables\n(borrador o rechazada)?"}
      R2 -- no --> RX["Error"]
      R2 -- sí --> R3{"suma == 100 EXACTO"}
      R3 -- no --> RX
      R3 --> R4{"cantidad >= MinimoMetas\ny <= MaximoMetas"}
      R4 -- no --> RX
      R4 -- sí --> R5["EstadoMeta='en_revision'\nFechaEnvioRevision = ahora"]
    end

    subgraph "Aprobación (jefe)"
      R5 --> A1["?c=Metas&a=aprobarMeta"]
      A1 --> A2{"shouldRequireExactHundred → suma == 100"}
      A2 -- no --> AX["Error"]
      A2 -- sí --> A3["EstadoMeta='aprobada'\nAprobadaPorJefe=1 · FechaAprobacion\nAceptadaJefe / AceptadaFuncional"]
      R5 --> B1["?a=rechazarMeta\n→ EstadoMeta='rechazada' + ComentarioRevision"]
      B1 --> M7
    end

    subgraph "Seguimiento — ventana 'seguimiento'"
      A3 --> G1["?c=MetasSeguimientos&a=create\n→ metas_seguimientos\nPorcentajeReportado · Evidencia"]
      G1 --> G2["EstadoValidacion · ObservacionValidacion"]
      G2 --> G3["metas.Cumplido · AvanceCalculado"]
      G3 --> G4["recalculateConsolidadoPreliminar()\n→ metas_consolidado.Preliminar"]
    end

    subgraph "Cierre"
      G4 --> C1["?c=Metas&a=consolidado"]
      C1 --> C2["?a=consolidarFinal\n→ metas_consolidado.Consolidado=1, Total"]
      C2 --> C3["EstadoMeta='cerrada' · FechaCierre"]
      C3 --> C4["Alimenta TalentScoreService::calcularPuntajeMetas()"]
    end

    subgraph "Casos especiales"
      X1["?a=heredar → heredarMetasPeriodo()\ncopia metas entre colaboradores"]
      X2["?a=cambioCargo → recalcula tras cambio de cargo"]
      X3["?a=sincronizacionJefes → sincroniza jefes en las metas"]
    end
```

---

## 6. Evaluación 360

```mermaid
flowchart TD
    subgraph "Configuración del periodo"
      P1["?c=periodos&a=edit\nflags AutoEvaluacion, EvaluacionJefe…\npesos PesoAutoEvaluacion, PesoJefe…\nCantidadMaximaParesEvaluar\nInicioCompetencias / FinalCompetencias"]
    end

    subgraph "Catálogos"
      Q1["?c=Competencias&a=create → competencias"]
      Q2["?c=Escalas&a=create → escalas (Opcion0..5)"]
      Q3["?c=Preguntas&a=create\n→ preguntas(IdCompetencia, IdEscala)"]
      Q4["?c=CompetenciasColaboradores&a=create\no cargueMasivo\n→ competencias_colaboradores"]
    end

    subgraph "Generación"
      P1 --> G1["?c=Evaluaciones&a=generar"]
      Q4 --> G1
      G1 --> G2["evaluaciones: una fila por (periodo, colaborador)\nflags activos + listas de evaluadores en *Valores"]
      G2 --> G3["?a=solucionarRepetidos si hubo duplicados"]
    end

    subgraph "Recolección"
      G2 --> R1["Colaborador: ?c=EvaluacionesColaborador&a=list"]
      R1 --> R2["?a=encuesta"]
      R2 --> R3["respuestas(IdPeriodo, IdColaborador,\nIdPregunta, Tipo, IdCalificador, Calificacion)"]
      R3 --> R4["UNIQUE uk_respuesta_unica impide duplicados"]
      R4 --> R5["evaluaciones.*Realizada se actualiza"]
    end

    subgraph "Cálculo"
      R5 --> C1["?c=Evaluaciones&a=calcular"]
      C1 --> C2["Totales por fuente:\nTotalAutoEvaluacion, TotalEvaluacionJefe,\nTotalEvaluacionColaboradores, TotalEvaluacionPares,\nTotalEvaluacionCliente"]
      C2 --> C3["Total = suma ponderada por los pesos del periodo"]
      C3 --> C4["?a=consolidar → consolidado_evaluacion_360"]
      C4 --> C5["?a=recalcular / ?a=imprimir"]
      C3 --> C6["Alimenta TalentScoreService::calcularPuntaje360()"]
    end
```

---

## 7. Cálculo de talento y 9-Box

```mermaid
flowchart TD
    A["?c=Potenciales&a=periodos\n→ potencial_periodos"] --> B["?a=criterios\n→ potencial_criterios (Nombre, Peso)"]
    B --> C["?a=evaluar\n→ potencial_evaluaciones + potencial_evaluacion_detalle"]
    C --> D["?a=recalcular → TalentScoreService::actualizarEvaluacion()"]

    D --> E1["calcularPuntajeMetas(colaborador, periodo)\n← metas_consolidado.Preliminar\no calcularPuntajeMetasDirecto()"]
    D --> E2["calcularPuntaje360(colaborador, periodo)\n← evaluaciones"]
    D --> E3["calcularPotencialCriterios(detalles)\n← potencial_evaluacion_detalle ponderado por Peso"]

    E2 --> F["calcularPotencialConsolidado()\n= 0.60 × criterios + 0.40 × 360"]
    E3 --> F
    E1 --> G["calcularTotal()\n= pesoDesempeno × desempeño + pesoPotencial × potencial"]
    F --> G

    G --> H["categorizarTalento(total)\n← talento_categorias (fallback CATEGORIAS_DEFAULT)"]
    H --> I["Persiste en potencial_evaluaciones:\nPuntajeDesempeno · PuntajePotencial · PuntajeTotal\nPuntajeMetasAuto · Puntaje360Auto\nCategoriaMapaTalento · EsAltoPotencial · FuenteDesempeno"]

    I --> J1["?a=mapaTalentos → matriz 9-Box\n← talento_celdas_9box"]
    I --> J2["?a=listado → Altos Potenciales\n(EsAltoPotencial según getUmbralHipo())"]
    I --> J3["?a=validarCategoria\n→ puedeCategorizar() impide categoría incoherente"]
    I --> J4["requierePlanDesarrollo(total)\n→ dispara Planes de Desarrollo"]
    I --> J5["?a=cerrarPeriodo / ?a=exportar"]
    I --> J6["MiEquipoController\n← obtenerResumenEquipo()"]
    I --> J7["CarreraPortalController::miEstadoTalento"]
```

---

## 8. Solicitud y aprobación de vacaciones

```mermaid
sequenceDiagram
    autonumber
    actor C as Colaborador
    participant VC as VacacionesColaboradorController
    participant SV as SolicitudesVacacionesModel
    participant F as FestivosModel
    actor A as Aprobador
    participant VA as VacacionesController
    participant SA as SaldosVacacionesModel

    C->>VC: POST ?c=VacacionesColaborador&a=list (nueva solicitud)
    VC->>SV: existeCruce(colaborador, inicio, fin, estadosBloqueo)
    Note over SV: estadosBloqueo = [APROBADA] + [SOLICITADA] si<br/>VACACIONES_BLOQUEAR_SOLICITADAS
    alt hay cruce
        SV-->>C: Error de cruce de fechas
    end
    VC->>SV: calcularDiasSolicitados(inicio, fin, politica.CuentaDiasHabiles)
    SV->>F: obtenerFestivosActivosEnRango()
    Note over SV: hábiles → format('N') < 6 y no festivo<br/>calendario → diff->days + 1
    VC->>SV: crear solicitud Estado = SOLICITADA
    SV-->>C: "Solicitud registrada y pendiente de aprobación."

    A->>VA: POST ?c=Vacaciones&a=aprobar {IdSolicitud, _csrf_token}
    VA->>VA: POST + validateCsrfToken()
    VA->>VA: aprobarSolicitudInterna()
    VA->>VA: ¿Estado == SOLICITADA?
    VA->>VA: getPoliticaById(IdPolitica)
    VA->>SA: getOrCreateSaldo(colaborador, politica, año de FechaInicio)
    Note over SA: si no existe, lo crea con<br/>DiasDisponibles = politica.DiasPorAnio
    VA->>VA: ¿DiasDisponibles >= DiasSolicitados<br/>o PermiteSaldoNegativo?
    VA->>SV: editFromParameters(Estado=APROBADA, IdAprobador, FechaAprobacion)
    Note over VA,SA: ⚠️ Sin transacción: si lo siguiente falla,<br/>la solicitud queda aprobada sin descontar
    VA->>SA: actualizarSaldo(disponibles−n, tomados+n, max(0, pendientes−n))
    VA-->>A: "Solicitud aprobada correctamente."
```

Rutas alternativas: `?a=rechazar` (con motivo) y `?a=cancelar` (con motivo y `FechaCancelacion`).

Efecto secundario: una solicitud `APROBADA` bloquea la marcación de asistencia en ese rango (`AsistenciaRegistrosModel::tieneVacacionesAprobadas()`).

---

## 9. Marcación de asistencia

```mermaid
flowchart TD
    A["POST ?c=Asistencia&a=registrarEntrada"] --> B["marcarEntrada(colaborador, fechaHora)"]
    B --> C{"validarBloqueoMarcacion()"}
    C -- "vacaciones aprobadas" --> X1["'El día está en vacaciones aprobadas.'"]
    C -- "ausencia aprobada" --> X2["'El día tiene una ausencia aprobada.'"]
    C -- ok --> D["getRegistroDia(colaborador, fecha)"]
    D --> E{"¿ya existe el registro?"}
    E -- sí --> F{"¿HoraEntradaReal ya está?"}
    F -- sí --> X3["'La entrada del día ya está registrada.'"]
    F -- no --> G["UPDATE HoraEntradaReal, Estado='pendiente'"]
    E -- no --> H["INSERT asistencia_registros\nEstado='pendiente'"]

    G --> I["POST ?a=registrarSalida"]
    H --> I
    I --> J["marcarSalida(colaborador, fechaHora, minutosAlmuerzo)"]
    J --> K["getRegistroDia() o getRegistroPendienteSalida()\n(turnos que cruzan medianoche)"]
    K --> L{"¿hay registro con entrada?"}
    L -- no --> X4["'No se permite salida sin entrada.'"]
    L -- sí --> M{"¿HoraSalidaReal ya está?"}
    M -- sí --> X5["'La salida del día ya está registrada.'"]
    M -- no --> N{"¿salida < entrada?"}
    N -- sí --> O1["Estado = 'inconsistente'"]
    N -- no --> O2["Estado = 'completo'"]
    O1 --> P["UPDATE HoraSalidaReal, MinutosAlmuerzo, Estado"]
    O2 --> P
    P --> Q["calcularMinutosExtra(registro)"]
    Q --> Q1["trabajados = (salida−entrada)/60 − almuerzo"]
    Q --> Q2["programados = turno asignado esa fecha"]
    Q1 --> R["extra = max(0, trabajados − programados)"]
    Q2 --> R
    R --> S["AsistenciaHorasExtraModel::clasificarTipo(fecha, hora)"]
    S --> T["reemplazarPorRegistro(registroId, tipo, minutos)\n→ asistencia_horas_extra (Aprobada = 0)"]
```

---

## 10. Liquidación de nómina

```mermaid
flowchart TD
    subgraph "Preparación"
      A1["?c=Nomina&a=conceptos\n→ nomina_conceptos\nCodigo · Tipo · FormulaTipo · Formula\nBaseCalculo · TopeMin · TopeMax"]
      A2["nomina_parametros\nAUX_TRANSPORTE · PORC_SALUD\nPORC_PENSION · HORAS_EXTRA_FACTOR"]
      A3["?a=crearPeriodo\n→ nomina_periodos(PeriodoInicio, PeriodoFin, TipoPeriodicidad)"]
      A4["?a=guardarNovedad o plantillaCsvNovedades\n→ nomina_novedades(ConceptoId, Cantidad, Valor)"]
    end

    A3 --> B["?a=recalcular\n→ NominaCalculoModel::recalcularPeriodo()"]
    B --> C{"¿existe el periodo\ny puedeEditar()?"}
    C -- no --> X["'El periodo está cerrado.'"]
    C -- sí --> D["Estado → 'en_calculo'"]
    D --> E["getColaboradoresActivos()\n(colaboradores.Estado = 1)"]
    E --> F["Por cada colaborador:\nrecalcularColaborador()"]

    subgraph "recalcularColaborador"
      F --> F1["diasLiquidados:\nQUINCENAL → 15\nsi no → diff días + 1\ninválido → 30"]
      F1 --> F2["contexto = SALARIO, DIAS_LIQUIDADOS,\nAUX_TRANSPORTE, PORC_SALUD,\nPORC_PENSION, HORAS_EXTRA_FACTOR"]
      F2 --> F3["Por cada concepto activo:"]
      F3 --> F4["resolverBase(): SALARIO | SALARIO_DIARIO |\nTOTAL_DEVENGADO | NETO | PARAM:CLAVE"]
      F4 --> F5["calcularValorConcepto():\nFIJO | PORCENTAJE | FORMULA (eval)"]
      F5 --> F6["valorFinal = calculado + novedad.Valor"]
      F6 --> F7["Aplicar TopeMin / TopeMax"]
      F7 --> F8["Acumular en totalDevengado\no totalDeduccion según Tipo"]
      F8 --> F3
      F8 --> F9["neto = devengado − deduccion"]
      F9 --> F10["hashCalculo = md5(json(entradas))"]
      F10 --> F11["nomina_liquidaciones\n(UNIQUE periodo+colaborador)"]
      F11 --> F12["limpiarPorLiquidacion()\n+ INSERT nomina_liquidacion_detalle"]
    end

    F12 --> G["Estado → 'en_revision'"]
    G --> H["BitacoraAuditoriaModel::registrar('RECALCULO')"]
    H --> I["?a=detalleLiquidacion\n?a=comprobante (layout impresiones)\n?a=exportCsv"]
    I --> J["?a=cerrarPeriodo → puedeEditar() = false"]
```

> ⚠️ El resultado **depende del orden** en que `listarActivos()` devuelve los conceptos, porque `TOTAL_DEVENGADO` usa el acumulado parcial.

---

## 11. Solicitud de adelanto de nómina

```mermaid
sequenceDiagram
    autonumber
    actor C as Colaborador
    participant PP as AdelantosNominaPortalController
    participant AS as AdelantoSolicitudesModel
    participant AP as AdelantoPoliticasModel
    actor G as Gestor
    participant AC as AdelantosNominaController
    participant AH as AdelantoHistorialModel

    C->>PP: GET ?c=AdelantosNominaPortal&a=crear
    PP-->>C: formulario
    C->>PP: POST ?a=guardar {monto, motivo, soporte}
    PP->>AS: validarSolicitud(colaborador, monto, fecha, tieneSoporte, motivo)
    AS->>AP: obtenerPoliticaActiva()  (Activo = 1)
    AS->>AS: AD2 motivo · AD3 soporte · AD4 DiaCorteMes
    AS->>AS: AD5 MontoMinimo · AD6 MontoMaximo
    AS->>AS: AD7 % del salario · AD8 antigüedad en meses
    AS->>AS: AD9 máx. por mes · AD10 días entre solicitudes
    AS->>AS: AD11 máx. activas simultáneas
    alt errores
        AS-->>C: array de errores → flashes
    end
    AS->>AS: generarCodigo()
    AS-->>C: adelanto_solicitudes EstadoSolicitud = 'creada'
    AS->>AH: historial('CREACION')

    G->>AC: GET ?c=AdelantosNomina&a=index / ?a=ver
    G->>AC: POST ?a=aprobar {MontoAprobado}
    AC->>AS: EstadoSolicitud = 'aprobada', AprobadoPor, FechaAprobacion
    AC->>AH: historial('APROBACION')
    G->>AC: POST ?a=registrarDesembolso
    AC->>AC: adelanto_desembolsos (Metodo, CuentaDestino, Comprobante)
    AC->>AS: EstadoSolicitud = 'desembolsada'
    AC->>AC: adelanto_plan_descuento (TipoDescuento, NumeroCuotas)
    AC->>AC: adelanto_cuotas (N filas, Estado='pendiente')
    AC->>AS: EstadoSolicitud = 'en_descuento'
    loop por cada cuota
        G->>AC: POST ?a=marcarCuotaDescontada
        AC->>AC: cuota.Estado = descontada, FechaDescontada
    end
    AC->>AS: EstadoSolicitud = 'descontada'
```

Rutas alternativas: `?a=rechazar` (con `MotivoRechazo`) y, desde el portal, `?a=cancelar`.

---

## 12. Solicitud de beneficio

```mermaid
flowchart TD
    subgraph "Catálogo (Administrador)"
      A1["?c=Beneficios&a=crear\nTipoBeneficio · Frecuencia · MontoFijo/MontoMaximo\nCupoTotal · PresupuestoTotal · AprobacionRequerida"]
      A1 --> A2["Reglas de elegibilidad → beneficios_reglas\nReglaTipo: incluir | excluir\nCriterio: area|cargo|sede|contrato|antiguedad_min_meses"]
      A2 --> A3["?a=activar → Estado='activo'"]
    end

    subgraph "Portal del colaborador"
      A3 --> B1["?c=BeneficiosPortal&a=disponibles"]
      B1 --> B2["obtenerBeneficiosElegibles(colaborador)"]
      B2 --> B3["estaVigente() por fechas"]
      B3 --> B4["esElegibleParaColaborador():\n1) exclusiones tienen prioridad absoluta\n2) si hay inclusiones, basta con cumplir una\n3) sin reglas → elegible"]
      B4 --> B5["?a=crearSolicitud → ?a=guardarSolicitud"]
      B5 --> B6["puedeSolicitarPorFrecuencia()\nventana: una_vez|mensual|trimestral|anual|sin_limite"]
      B6 -- no --> BX["Error de frecuencia"]
      B6 -- sí --> B7["beneficios_solicitudes\nEstadoSolicitud='creada' + generarCodigo()"]
      B7 --> B8["beneficios_solicitud_historial"]
    end

    subgraph "Gestión"
      B7 --> C1["?c=BeneficiosSolicitudes&a=index / ?a=ver"]
      C1 --> C2["?a=aprobar {MontoAprobado}"]
      C2 --> C3["tieneDisponibilidad(beneficio, monto)"]
      C3 -- no --> C4["marcarSinCupo()\nObservacionRevision='sin_cupo'"]
      C3 -- sí --> C5["aprobarSolicitud()\nEstadoSolicitud='aprobada'"]
      C5 --> C6["aplicarConsumoAprobacion():\nrelee el beneficio, revalida,\nCupoUsado += 1, PresupuestoUsado += monto"]
      C6 --> C7["?a=entregar\nEstadoSolicitud='entregada'\nEntregadoPor · FechaEntrega · EvidenciaEntrega"]
      C1 --> C8["?a=rechazar → 'rechazada' + MotivoRechazo"]
    end

    subgraph "Asignación directa (sin solicitud)"
      A3 --> D1["?c=BeneficiosAsignaciones&a=asignar\no ?a=asignarMasivo"]
      D1 --> D2["beneficios_asignaciones"]
      D2 --> D3["?a=entregar / ?a=cancelar"]
    end
```

---

## 13. Ciclo de vida de un ticket

```mermaid
stateDiagram-v2
    [*] --> nuevo : crear + guardar
    nuevo --> asignado : asignar
    asignado --> en_progreso : cambiarEstado
    en_progreso --> en_espera : cambiarEstado
    en_espera --> en_progreso : cambiarEstado
    en_progreso --> resuelto : cambiarEstado
    resuelto --> cerrado : cerrar
    cerrado --> reabierto : cambiarEstado
    reabierto --> en_progreso
    cerrado --> [*]
```

```mermaid
flowchart TD
    A["Colaborador: ?c=Tickets&a=crear → ?a=guardar"] --> B["servicio_tickets\nCodigo · CategoriaId · SubcategoriaId\nPrioridad · Estado='nuevo'"]
    B --> C["servicio_ticket_historial ('CREACION')"]
    C --> D["Gestor: ?a=asignar → AsignadoA, Estado='asignado'"]
    D --> E["?a=responder → servicio_ticket_comentarios"]
    E --> F["Primera respuesta → FechaPrimeraRespuesta"]
    F --> G["MinutosPrimeraRespuesta = minutos(CreatedAt → FechaPrimeraRespuesta)"]
    G --> H["actualizarSlaEstado(ticketId)"]
    H --> I["getByPrioridad(Prioridad) → servicio_sla_politicas"]
    I --> J{"MinutosPrimeraRespuesta > política\nO MinutosResolucion > política"}
    J -- sí --> K1["SlAEstado = 'vencido'"]
    J -- no --> K2["SlAEstado = 'en_tiempo'"]
    K1 --> L["?a=cambiarEstado → en_progreso / en_espera / resuelto"]
    K2 --> L
    L --> M["?a=cerrar → Estado='cerrado', FechaCierre"]
    M --> N["MinutosResolucion = minutos(CreatedAt → FechaCierre)\n→ actualizarSlaEstado() de nuevo"]
    N --> O["Colaborador: ?a=calificar\n→ Calificacion + ComentarioSatisfaccion"]
```

> Si no hay política de SLA para esa prioridad, `recalcularSla()` devuelve `null` y **el estado SLA no se actualiza**.

---

## 14. Denuncia ética anónima

```mermaid
sequenceDiagram
    autonumber
    actor D as Denunciante (sin sesión)
    participant LP as LineaEticaPublicaController
    participant LM as LineaEticaCasosModel
    actor G as Gestor (con sesión)
    participant LC as LineaEticaController

    D->>LP: GET ?c=LineaEticaPublica&a=crear
    LP-->>D: formulario (TipoId, CategoriaId, descripción, contacto opcional)
    D->>LP: POST ?a=guardar
    LP->>LM: generarCodigoUnico() → 'ETH-2026-483920' (30 reintentos)
    LP->>LM: generarPinTemporal() → random_int(100000, 99999999)
    LP->>LM: crearPinHash(pin) → password_hash(PASSWORD_DEFAULT)
    LP->>LM: linea_etica_casos Estado='recibido'
    LP->>LM: linea_etica_bitacora ('CREACION')
    LP-->>D: confirmacion.php — MUESTRA EL CÓDIGO Y EL PIN UNA SOLA VEZ

    Note over D: El PIN no es recuperable

    G->>LC: GET ?c=LineaEtica&a=index / ?a=ver
    G->>LC: POST ?a=asignar → AsignadoA
    G->>LC: POST ?a=cambiarEstado
    LC->>LM: puedeTransicionarEstado(actual, nuevo, permitirReabrir)
    Note over LM: recibido → en_revision | archivado<br/>en_revision → en_investigacion | en_espera | archivado<br/>en_investigacion → en_espera | cerrado | archivado<br/>en_espera → en_investigacion | cerrado | archivado<br/>cerrado → en_investigacion SOLO si permitirReabrir<br/>archivado → (terminal)
    G->>LC: POST ?a=responder → linea_etica_mensajes (VisibleParaDenunciante = 1)
    G->>LC: POST ?a=agregarNota → linea_etica_notas_internas (NUNCA visible)
    LC->>LM: actualizarUltimaActividad() + FechaPrimeraRespuesta

    D->>LP: GET ?a=seguimiento {Codigo, PIN}
    LP->>LM: getByCodigo(codigo)
    LP->>LM: verificarPin(hash, pin) → password_verify()
    LP-->>D: ver_caso.php — solo mensajes con VisibleParaDenunciante = 1
    D->>LP: POST ?a=agregarMensaje → linea_etica_mensajes (AutorTipo = denunciante)

    G->>LC: POST ?a=cerrar → Estado='cerrado', FechaCierre
    G->>LC: POST ?a=archivar → Estado='archivado' (terminal)
```

---

## 15. Curso de capacitación

```mermaid
flowchart TD
    subgraph "Autoría (Administrador)"
      A1["?c=Capacitacion&a=crear → capacitacion_cursos"]
      A1 --> A2["?c=CapacitacionLecciones&a=crear\n→ capacitacion_lecciones (Orden)"]
      A2 --> A3["?a=reordenar"]
      A1 --> A4["capacitacion_quiz (PuntajeMinimo)\n+ capacitacion_quiz_preguntas (A/B/C/D, RespuestaCorrecta)"]
      A3 --> A5["?c=Capacitacion&a=publicar"]
      A4 --> A5
    end

    subgraph "Inscripción"
      A5 --> B1["?c=CapacitacionInscripciones&a=asignar\no ?a=asignarMasivo"]
      B1 --> B2["getByCursoColaborador() evita duplicados"]
      B2 --> B3["capacitacion_inscripciones\nEstado='asignado' · FechaVencimiento"]
    end

    subgraph "Aprendizaje (portal)"
      B3 --> C1["?c=CapacitacionPortal&a=misCursos"]
      C1 --> C2["?a=verCurso → ?a=iniciar\nEstado='en_progreso' · FechaInicio"]
      C2 --> C3["?a=completarLeccion\n→ capacitacion_leccion_progreso"]
      C3 --> C4["ProgresoPorcentaje se recalcula"]
      C4 --> C5{"¿todas las lecciones completadas?"}
      C5 -- no --> C3
      C5 -- sí --> D1{"¿el curso tiene quiz?"}
    end

    subgraph "Evaluación"
      D1 -- no --> E1["Estado='completado' · FechaCompletado"]
      D1 -- sí --> D2["?a=presentarQuiz → ?a=enviarQuiz"]
      D2 --> D3["capacitacion_quiz_intentos\nIntentoNumero · Puntaje · RespuestasJson"]
      D3 --> D4{"Puntaje >= PuntajeMinimo"}
      D4 -- no --> D2
      D4 -- sí --> D5["AprobadoQuiz=1 · PuntajeQuiz"]
      D5 --> E1
    end

    subgraph "Certificación"
      E1 --> F1["?a=descargarCertificado"]
      F1 --> F2["capacitacion_certificados\nCodigo · FechaEmision · Plantilla"]
      F2 --> F3["mPDF sobre\napp/views/capacitacion_certificados/plantilla.php"]
      F3 --> F4["?c=CapacitacionCertificados&a=validar\nverificación pública por Codigo"]
      E1 --> F5["?a=misCertificados"]
    end
```

---

## 16. Consulta al Asistente IA

```mermaid
sequenceDiagram
    autonumber
    actor U as Usuario
    participant AC as AsistenteIAController
    participant AM as AsistenteIAModel
    participant AS as AsistenteIAService
    participant DC as DatabaseContext
    participant SG as SQLGenerator
    participant OL as OllamaProvider
    participant CH as Cache
    participant DB as MySQL

    U->>AC: POST ?c=AsistenteIA&a=ask {question, _csrf_token}
    AC->>AC: POST + validateCsrfTokenNoRotate
    AC->>AC: question no vacía, truncada a 1200 caracteres
    AC->>AM: ask(question, userId)
    AM->>AS: processQuestion(question, history)
    AS->>DC: getSchemaContext()
    Note over DC: limitado por IA_MAX_SCHEMA_TABLES (50),<br/>IA_MAX_SCHEMA_COLUMNS_PER_TABLE (12),<br/>IA_MAX_SCHEMA_CHARS (5000)
    AS->>AS: history = últimos IA_MAX_HISTORY_MESSAGES (10)
    AS->>SG: generateSelectSql(question, schema, history)
    SG->>OL: POST al endpoint de Ollama (modelo IA_MODEL)
    OL-->>SG: SQL propuesto
    SG-->>AS: {ok, sql, reason}
    alt ok = false
        AS-->>U: "No pude construir una consulta segura para esa pregunta."
    end
    AS->>SG: isReadOnlySelect(sql)
    alt no es SELECT seguro
        AS->>AS: Logger::warning('AsistenteIA bloqueo SQL inseguro')
        AS-->>U: "La consulta fue bloqueada por politicas de seguridad (solo lectura)."
    end
    AS->>CH: get(['asistente_ia','sql', sha1(sql)])
    alt cache miss
        AS->>DB: queryAllSql(sql)
        alt error SQL
            AS->>SG: buildMainIndicatorsFallbackSql(schema)
            AS->>DB: reintento con el SQL de respaldo
        end
        AS->>CH: setWithNamespace(TTL = IA_QUERY_CACHE_TTL)
    end
    AS->>OL: ResponseFormatter → respuesta en prosa
    AS-->>AC: {ok, answer, sql, rows, elapsed_ms, cached}
    AC-->>U: JSON {ok, answer, sql, elapsed_ms, cached}
```

---

## 17. Envío programado de correo

```mermaid
flowchart TD
    A["Planificador externo (cron)\ncurl -H 'X-Cron-Token: …'\n'?c=AlertasEmail&a=sendCron'"] --> B["guardCronAccess()"]
    B --> C{"¿hay sesión activa?"}
    C -- sí --> C1{"¿Permissions['AlertasEmail']['send']\no usuario == getUserAccess()?"}
    C1 -- no --> E403a["403 'No tienes permiso…'"]
    C1 -- sí --> F
    C -- no --> D{"¿CRON_TOKEN configurado?"}
    D -- no --> E503["503 'CRON_TOKEN no configurado.'"]
    D -- sí --> D1["token = X-Cron-Token o ?token="]
    D1 --> D2{"hash_equals()"}
    D2 -- no --> E403b["403 'Token invalido.'"]
    D2 -- sí --> F["sendCronAction()"]

    F --> G{"configuraciones['DetenerEnvioNotificaciones']\nno vacío?"}
    G -- sí --> H["No hace nada"]
    G -- no --> I["SELECT de alertas_email\nEstadoEnvio = 1 AND Destinatarios != ''"]
    I --> J["LIMIT = configuraciones['EnvioMaximoNotificaciones']\nsi no es numérico o <= 0 → 2"]
    J --> K["Por cada alerta:\nsend(alerta.Id) vía PHPMailer\n+ sleep(3)"]
    K --> L["Actualiza EstadoEnvio"]
```

---

## 18. Despliegue de un esquema desde cero

```mermaid
flowchart TD
    A["git clone"] --> B["composer install"]
    B --> C["cp .env.example .env\ny rellenar DB_*, MAIL_*, APP_*"]
    C --> D["Crear la base de datos\nCREATE DATABASE kuorum CHARACTER SET utf8mb4"]
    D --> E["php bin/migrate status"]
    E --> F["php bin/migrate up\n→ 41 migraciones · tabla migrations"]
    F --> G["php bin/migrate validate\n(revisión estática)"]
    G --> H{"¿qué perfil de datos?"}
    H -- "Producción" --> I1["php bin/seed base\n(0xx: núcleo, catálogos, nómina base)"]
    H -- "Preproducción" --> I2["php bin/seed modulos\n(0xx + 1xx)"]
    H -- "Demostración" --> I3["php bin/seed completo\n(0xx + 1xx + 2xx)"]
    I1 --> J["php bin/seed --verify-demo\n(cobertura por módulo)"]
    I2 --> J
    I3 --> J
    J --> K["chmod de escritura:\nfiles/  storage/cache/  logs/"]
    K --> L["Apache: DocumentRoot → public/"]
    L --> M["Cron: sendCron con X-Cron-Token"]
    M --> N["Verificar login del backoffice\ny del portal"]
```

Ver [15_DEPLOYMENT.md](15_DEPLOYMENT.md) para el detalle operativo.

---

## Documentos relacionados
- [04_MODULES.md](04_MODULES.md)
- [10_BUSINESS_LOGIC.md](10_BUSINESS_LOGIC.md)
- [15_DEPLOYMENT.md](15_DEPLOYMENT.md)
