# 10 — Reglas de Negocio

> Este es el documento más importante para modificar el sistema sin romperlo.
> Cada regla indica **dónde vive en el código**, para que pueda verificarse y modificarse en el sitio correcto.
> Solo se recogen reglas **implementadas**, no intenciones ni comentarios.

---

## 0. Índice

| Dominio | Sección |
|---|---|
| Transversales | [§1](#1-reglas-transversales) |
| Colaborador | [§2](#2-colaborador) |
| Periodos y ventanas de tiempo | [§3](#3-periodos-y-ventanas-de-tiempo) |
| Metas | [§4](#4-metas) |
| Evaluación 360 | [§5](#5-evaluación-360) |
| Talento y 9-Box | [§6](#6-talento-y-9-box) |
| Planes de desarrollo | [§7](#7-planes-de-desarrollo) |
| Reclutamiento | [§8](#8-reclutamiento) |
| Vacaciones | [§9](#9-vacaciones) |
| Asistencia | [§10](#10-asistencia) |
| Nómina | [§11](#11-nómina) |
| Adelantos de nómina | [§12](#12-adelantos-de-nómina) |
| Beneficios | [§13](#13-beneficios) |
| Capacitación | [§14](#14-capacitación) |
| Tickets y SLA | [§15](#15-tickets-y-sla) |
| Línea de ética | [§16](#16-línea-de-ética) |
| Reconocimientos | [§17](#17-reconocimientos) |
| Carrera y sucesión | [§18](#18-carrera-y-sucesión) |
| Comunicación interna | [§19](#19-comunicación-interna) |

---

## 1. Reglas transversales

| # | Regla | Implementación |
|---|---|---|
| T1 | `Estado = 0` es **borrado lógico**. Un registro con `Estado = 0` sigue en la base pero desaparece de los listados | Filtro de sesión `Estado` en `Model::addFiltersSesion()` |
| T2 | El **periodo activo de sesión** se inyecta como `FIND_IN_SET('<periodo>', <columna>)` en toda consulta de un modelo que tenga columna `Periodo` o `IdPeriodo` | `Model::addFiltersSesion()` + `Config::$FILTERS_SESION` |
| T3 | Pasar `array()` como tercer argumento (`$filtersGeneral`) **desactiva** los filtros de sesión de esa consulta | `Model::getAll/getByCriteria/getQuantity/editFromParameters` |
| T4 | El usuario con `usuarios.Id = 1` tiene **acceso total incondicional**, aunque no tenga filas en `permisos` | `Config::getUserAccess()` + `PermisosModel::getPermissions()` |
| T5 | Todo flujo con estados registra el cambio en una tabla `*_historial` o `*_bitacora` con `Accion`, `ValorAnterior`, `ValorNuevo`, `UsuarioId`, `CreatedAt` | 7 módulos: adelantos, beneficios, tickets, línea de ética, reclutamiento, entrevistas, planes de desarrollo |
| T6 | Toda eliminación (`action = 'remove'`) exige **POST + token CSRF** | `Controller::process()` |
| T7 | El menú y los permisos se **congelan en la sesión durante el login**. Cambiarlos en BD no afecta a sesiones abiertas | `UsuariosModel::loadMenu()` / `loadPermissions()` |
| T8 | Los códigos legibles de negocio se generan con reintento por colisión | `generarCodigo()` / `generarCodigoUnico()` en adelantos, beneficios, tickets, línea de ética |
| T9 | La zona horaria de referencia es **America/Bogota** y la localización **es_CO** | `core/AutoLoad.php` |

---

## 2. Colaborador

| # | Regla | Implementación |
|---|---|---|
| C1 | El **documento** (`NoDocumento`), el **correo corporativo** y el **usuario** son únicos | Índices `UNIQUE` en BD + `ColaboradoresModel::normalizaCamposUnicos()` |
| C2 | Un colaborador **debe ser mayor de edad** en su creación y en su edición | `ColaboradoresModel::validaMayorDeEdad()`, invocada desde `beforeCreate()` y `beforeUpdate()` |
| C3 | `Estado` (0/1) es el borrado lógico; **`EstadoActual` (texto) es el estado laboral.** No son intercambiables | Esquema + uso en `HomeController` |
| C4 | La jerarquía organizacional se construye con `IdJefeInmediato`; existe además `IdJefeFuncional` para matriz funcional | `ColaboradoresController::organigrama` / `buildOrganigramaTree()` |
| C5 | `LiderEquipo = 1` habilita "Mi Equipo" y "Planes del Equipo" en el portal | `ColaboradoresModel::esLiderEquipo()` ← `Menu::evaluarCondicionItem('colaborador_lider_equipo')` |
| C6 | `esLiderEquipo()` consulta la **tabla base**, no `vista_colaboradores`, porque la vista **no** expone `LiderEquipo` | `ColaboradoresModel::esLiderEquipo()` |
| C7 | Todas las lecturas de `ColaboradoresModel` van a `vista_colaboradores` (aporta `NombreCompleto`) | `getById()`, `getAll()`, `getByCriteria()` **sobreescritos** |
| C8 | Eliminar un colaborador **borra en cascada, en transacción**, sus registros de ~15 tablas dependientes | `ColaboradoresModel::deleteCascade()` — **la única transacción del proyecto** |
| C9 | Los cambios sobre `colaboradores` se auditan en `log_modules` con diff old/new | `$LOG = true` + `Model::log()` |
| C10 | Al contratar a un candidato con `IdColaborador`, el colaborador pasa a `EstadoActual='Activo'`, `Estado=1` | `RecruitmentApplicationsModel::moverEtapa()` |

---

## 3. Periodos y ventanas de tiempo

El **periodo** (`periodos`) es el eje temporal de metas y evaluación.

### Ventana de metas (`PeriodosModel::getMetasWindowState($periodo, $fecha)`)

```
hoy < InicioMetas                        → 'pendiente_apertura'
InicioMetas <= hoy <= LimiteMetas        → 'planeacion'
LimiteMetas < hoy <= FinalMetas          → 'seguimiento'
hoy > FinalMetas                         → 'cerrado'
falta cualquiera de las tres fechas      → 'no_definido'
```

| # | Regla | Implementación |
|---|---|---|
| P1 | Se puede **crear y editar** metas solo en estado `planeacion` o `no_definido` | `MetasModel::canEditByWindow()` |
| P2 | Se puede **registrar seguimiento** solo en `seguimiento`, `cerrado` o `no_definido` | `MetasModel::canTrackByWindow()` |
| P3 | `no_definido` (fechas sin configurar) **permite todo**: es un modo permisivo por defecto | Ambos métodos |
| P4 | El periodo activo vive en `$_SESSION[APP_ID]['filtersSesion']['Periodo']` | `PeriodosModel::getSesionId()` |
| P5 | El periodo del portal se elige **en el formulario de login** | `PublicController::validateAction()` (campo `periodo` obligatorio) |
| P6 | `periodos.Mostrar` controla qué periodos aparecen en los selectores | `PeriodosModel::getAllFiltersSesion()`, `getActivos()` |

---

## 4. Metas

### Estados (`metas.EstadoMeta`)

`borrador` · `en_revision` · `aprobada` · `rechazada` · `cerrada`

```mermaid
stateDiagram-v2
    [*] --> borrador
    borrador --> en_revision : envío a revisión
    rechazada --> en_revision : reenvío
    en_revision --> aprobada : aprobarMeta
    en_revision --> rechazada : rechazarMeta
    aprobada --> cerrada : cierre del periodo
    cerrada --> [*]
```

`sanitizeEstadoMeta()` normaliza cualquier valor desconocido a `borrador`.

### Reglas

| # | Regla | Implementación |
|---|---|---|
| M1 | Solo se editan metas en `borrador` o `rechazada` | `MetasModel::isMetaEditable()` |
| M2 | La suma de pesos de un colaborador en un periodo **no puede superar 100 %** (`MAX_TOTAL_PESO`, tolerancia `0.0001`) | `MetasModel::validateMetaPayload()` |
| M3 | Para **aprobar**, la suma debe ser **exactamente 100 %** | `validateMetaPayload($requireExactHundred = true)` |
| M4 | La exigencia de 100 % exacto se activa si `Aceptada`, `AceptadaJefe` o `AceptadaFuncional` son verdaderos, **o** si `EstadoMeta ∈ {aprobada, cerrada}` | `MetasModel::shouldRequireExactHundred()` |
| M5 | Para **enviar a revisión**, la suma de las metas editables debe ser exactamente 100 % | `MetasModel::validateMetaSubmission()` |
| M6 | No se puede enviar a revisión si no hay metas en `borrador` o `rechazada` | `validateMetaSubmission()` |
| M7 | Máximo de metas por colaborador y periodo: `periodos.MaximoMetas`; si es 0, `configuraciones['metas.max_por_colaborador']`; por defecto **8** | `validateMetaPayload()` |
| M8 | Mínimo de metas para enviar a revisión: `periodos.MinimoMetas` (si > 0) | `validateMetaSubmission()` |
| M9 | Cada categoría tiene tope de número de metas (`metas_categorias.NumeroMetas`) y de peso acumulado (`metas_categorias.Maximo`) | `MetasModel::validateCategoryRules()` |
| M10 | La categoría se compara **por nombre, ignorando mayúsculas y espacios** | `MetasModel::findCategoriaByNombre()` |
| M11 | Si la categoría de la meta no existe en el catálogo, **no se aplica ninguna restricción de categoría** | `validateCategoryRules()` devuelve `ok` si no encuentra la categoría |
| M12 | El peso se satura al rango `[0, 100]` y se redondea a 2 decimales | `MetasModel::sanitizePeso()` |
| M13 | `Objetivo` y `CategoriaMeta` son obligatorios | `validateMetaPayload()` |
| M14 | Los valores considerados "verdaderos" para las banderas de aceptación son `1, true, on, si, sí, aprobada, cerrada` | `MetasModel::isTruthy()` |
| M15 | La frecuencia del periodo (Anual/Semestral) se resuelve así: `configuraciones['metas.frecuencia_periodo']` → frecuencia del periodo → `.env METAS_FRECUENCIA_PERIODO_DEFAULT` → `Anual` | `Config::getFrecuenciaMetasPeriodoActiva()` |
| M16 | Las frecuencias disponibles salen de `configuraciones['metas.frecuencias_disponibles']`; solo se aceptan `Anual` y `Semestral` | `Config::getFrecuenciasMetasDisponibles()` |
| M17 | Al **heredar** metas (cambio de cargo o de titular) se copian del colaborador origen al destino dentro del mismo periodo | `MetasModel::heredarMetasPeriodo()` |
| M18 | El consolidado preliminar se recalcula desde las metas del colaborador en el periodo | `MetasModel::recalculateConsolidadoPreliminar()` / `MetasConsolidadoModel::consolidadoPreliminar()` |
| M19 | El consolidado final marca `metas_consolidado.Consolidado = 1` y fija `Total` | `MetasController::consolidarFinalAction()` |
| M20 | El rango de calificación se deriva del porcentaje cumplido | `MetasModel::rangoCalificacion($porcentaje)` |

---

## 5. Evaluación 360

| # | Regla | Implementación |
|---|---|---|
| E1 | Hay **cinco fuentes** de evaluación: autoevaluación, jefe, colaboradores a cargo, pares y cliente | Columnas de `evaluaciones` y `respuestas.Tipo` |
| E2 | Cada fuente se activa o desactiva **por periodo** | `periodos.AutoEvaluacion`, `EvaluacionColaboradorJefe`, `EvaluacionJefeColaborador`, `EvaluacionPares`, `EvaluacionCliente`, `EvaluacionReferencia` |
| E3 | Cada fuente tiene un **peso configurable por periodo** | `periodos.PesoAutoEvaluacion`, `PesoJefe`, `PesoColaboradores`, `PesoPares`, `PesoEvaluacionCliente` |
| E4 | El número de pares que un colaborador puede evaluar está limitado | `periodos.CantidadMaximaParesEvaluar` |
| E5 | Un calificador solo puede responder **una vez** cada pregunta de un colaborador en un periodo y para un tipo de fuente | Índice `UNIQUE respuestas.uk_respuesta_unica (IdPeriodo, IdColaborador, IdPregunta, Tipo, IdCalificador)` |
| E6 | Los evaluadores asignados se guardan como listas en `evaluaciones.*Valores`; quién ya respondió, en `evaluaciones.*Realizada` | Esquema |
| E7 | Cada pregunta pertenece a una competencia y usa una escala de `Opcion0` a `Opcion5` | `preguntas.IdCompetencia`, `preguntas.IdEscala`, tabla `escalas` |
| E8 | Las competencias que se evalúan a cada colaborador se declaran por periodo | `competencias_colaboradores` |
| E9 | La ventana de evaluación de competencias la fija el periodo | `periodos.InicioCompetencias`, `FinalCompetencias` |
| E10 | Existe una acción de saneamiento de evaluaciones duplicadas | `EvaluacionesController::solucionarRepetidos` |

---

## 6. Talento y 9-Box

**Servicio central: `TalentScoreService` (`app/models/TalentScoreService.php`, 746 líneas).**

### Fórmulas

```
Desempeño   = puntaje de metas
              (metas_consolidado.Preliminar, o cálculo directo con calcularPuntajeMetasDirecto())

Potencial   = 0.60 × puntaje de criterios HiPo  +  0.40 × puntaje 360

Consolidado = getPesoDesempeno() × Desempeño   +  getPesoPotencial() × Potencial
              (por defecto 0.50 y 0.50)
```

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 dos primeros son sobreescribibles desde la tabla `talento_config` (`getPesoDesempeno()`, `getPesoPotencial()`).

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

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

| # | Regla | Implementación |
|---|---|---|
| TL1 | Las categorías se leen de `talento_categorias`; si la tabla no existe o está vacía, se usa `$CATEGORIAS_DEFAULT` | `TalentScoreService::listarCategorias()` |
| TL2 | La configuración se lee de `talento_config` con *fallback* a las constantes | `getConfig($clave, $default)` |
| TL3 | El umbral de Alto Potencial es configurable | `getUmbralHipo()` → `potencial_evaluaciones.EsAltoPotencial` |
| TL4 | No se puede asignar manualmente una categoría incompatible con el puntaje | `puedeCategorizar($puntaje, $categoriaObjetivo)` ← `PotencialesController::validarCategoria` |
| TL5 | Un puntaje por debajo del umbral **exige plan de desarrollo** | `requierePlanDesarrollo($puntaje)` |
| TL6 | Las 9 celdas del mapa son configurables, con celdas de asignación exclusivamente manual | `talento_celdas_9box` (`CeldaKey`, `FilaY`, `ColX`, `SoloManual`) |
| TL7 | El recálculo persiste `PuntajeDesempeno`, `PuntajePotencial`, `PuntajeTotal`, `EsAltoPotencial`, `PuntajeMetasAuto`, `Puntaje360Auto`, `CategoriaMapaTalento`, `FuenteDesempeno` | `actualizarEvaluacion()` |
| TL8 | El resumen de talento de un equipo se calcula sobre una lista de colaboradores | `obtenerResumenEquipo()` ← usado por `MiEquipoController` |
| TL9 | Las categorías y la configuración se cachean **en propiedades estáticas por petición** | `$_categoriasCache`, `$_configCache` |

---

## 7. Planes de desarrollo

| # | Regla | Implementación |
|---|---|---|
| PD1 | Un plan puede originarse en una **meta**, una **evaluación** o un **resultado 9-Box** | `planes_desarrollo.IdMeta`, `IdEvaluacion`, `ResultadoNineBoxId`, `OrigenBrecha`, `MotivoCreacion` |
| PD2 | El progreso del plan se deriva de las acciones, ponderadas por `PesoPorcentaje` | `CalculadorProgresoPlanService` → `planes_desarrollo.ProgresoPorcentaje` |
| PD3 | Los pesos de las acciones se pueden renormalizar | `PlanesDesarrolloController::recalcularPesos` |
| PD4 | Una acción con `RequiereEvidencia = 1` exige adjuntar evidencia para cerrarse | `acciones_desarrollo.RequiereEvidencia` + `evidencias_accion_desarrollo` |
| PD5 | Se pueden generar acciones sugeridas a partir de brechas de competencias | `GeneradorPlanesDesarrolloService` ← `PlanesDesarrolloController::generarSugerido` |
| PD6 | Todo evento del plan y de sus acciones se registra | `historial_plan_desarrollo` |
| PD7 | Cada acción se asocia a una competencia | `acciones_desarrollo.IdCompetencia → competencias.Id` |
| PD8 | Un líder ve los planes de su equipo | `PlanesDesarrolloEquipoController`, condicionado a `LiderEquipo = 1` |

---

## 8. Reclutamiento

### Estados de la postulación (`reclutamiento_postulaciones.EstadoProceso`)

`nueva` · `en_proceso` · `oferta` · `rechazada` · `retirada` · `contratada`

### Reglas de `RecruitmentApplicationsModel::moverEtapa()`

| # | Regla |
|---|---|
| R1 | Una postulación en estado `contratada` **no admite más movimientos de etapa** |
| R2 | Si la vacante bloquea el kanban (`RecruitmentVacanciesModel::bloqueaMovimientosKanban()`), no se puede mover |
| R3 | Si la vacante está pausada y no lo permite (`puedeMoverConPausada()` ← `PermiteContinuarConPausada`), no se puede mover |
| R4 | La etapa destino **debe pertenecer al embudo de esa vacante** (`etapas_embudo_vacante`), no basta con existir en el pipeline global |
| R5 | Mover a la etapa con `Codigo = 'rechazado'` **exige** `IdMotivoRechazo`; el estado pasa a `rechazada` |
| R6 | Mover a `Codigo = 'contratado'` **exige** una oferta en estado `ACEPTADA` para esa postulación (`validarContratacion()`); el estado pasa a `contratada` |
| R7 | Mover a `Codigo = 'oferta'` pone el estado en `oferta` |
| R8 | Cualquier otro movimiento pone el estado en `en_proceso` |
| R9 | Todo movimiento escribe una fila en `reclutamiento_historial_etapas_postulacion` con `FromStageId`, `ToStageId`, comentario y usuario |
| R10 | Al contratar, si la postulación tiene `IdColaborador`, ese colaborador pasa a `EstadoActual='Activo'`, `Estado=1` |
| R11 | Se actualizan `FechaUltimoMovimiento` y `UltimaActividad` en cada movimiento |

### Otras reglas del módulo

| # | Regla | Implementación |
|---|---|---|
| R12 | Un candidato ya contratado no puede volver a postularse | `RecruitmentApplicationsModel::candidatoYaEstaContratado()` |
| R13 | No puede haber dos postulaciones del mismo candidato a la misma vacante | `getByVacanteCandidato()` ← `crearPostulacion()` |
| R14 | Al crear, duplicar o editar una vacante se sincroniza su embudo con el pipeline global | `EtapasEmbudoVacanteModel::sincronizarConPipelineGlobal()` |
| R15 | Si al mover una etapa la vacante no tiene embudo, se sincroniza de forma defensiva | `ReclutamientoController::moveStage` |
| R16 | El scoring automático del candidato se calcula con las reglas de la vacante | `ScoringCandidatosService` + `reglas_filtro_vacante` (`TipoRegla`, `Parametro`, `ValorEsperado`, `Peso`) → `reclutamiento_postulaciones.PuntajeAutomatico` |
| R17 | La conversión de oferta aceptada en colaborador es un proceso propio | `ContratacionDesdeOfertaService` → `procesos_seleccion_colaborador` |
| R18 | El contrato puede exigir firma | `configuraciones['ReclutamientoContratoExigeFirma']` (⚠️ duplicada como `RecruitmentContratoExigeFirma`) |
| R19 | Una vacante tiene cupos y cupos cubiertos | `reclutamiento_vacantes.Cupos`, `CuposCubiertos` |

---

## 9. Vacaciones

### Estados (`solicitudes_vacaciones.Estado`, en MAYÚSCULAS)

`SOLICITADA` → `APROBADA` | `RECHAZADA` | `CANCELADA`

### Reglas

| # | Regla | Implementación |
|---|---|---|
| V1 | Solo se aprueban solicitudes en estado `SOLICITADA` | `VacacionesController::aprobarSolicitudInterna()` |
| V2 | **No puede haber cruce de fechas** con otra solicitud del mismo colaborador. La condición de cruce es `FechaInicio <= fechaFinNueva AND FechaFin >= fechaInicioNueva` | `SolicitudesVacacionesModel::existeCruce()` |
| V3 | Qué estados bloquean el cruce depende de la configuración: siempre `APROBADA`, y además `SOLICITADA` si `VACACIONES_BLOQUEAR_SOLICITADAS = true` (valor por defecto) | `getEstadosBloqueoCruce()` ← `Config::$VACACIONES_BLOQUEAR_SOLICITADAS` |
| V4 | Si `politicas_vacaciones.CuentaDiasHabiles = 0`, los días son **calendario**: `diff->days + 1` | `calcularDiasSolicitados()` |
| V5 | Si `CuentaDiasHabiles = 1`, se cuentan solo **lunes a viernes** (`format('N') < 6`) **excluyendo los festivos activos** del rango | `calcularDiasSolicitados()` + `obtenerFestivosActivosEnRango()` sobre la tabla `festivos` |
| V6 | Si `FechaFin < FechaInicio`, los días solicitados son **0** | `calcularDiasSolicitados()` |
| V7 | El saldo se crea al vuelo por la terna (colaborador, política, año) con `DiasDisponibles = politicas_vacaciones.DiasPorAnio` | `SaldosVacacionesModel::getOrCreateSaldo()` |
| V8 | El año del saldo se toma de la **fecha de inicio** de la solicitud, no de la fecha actual | `aprobarSolicitudInterna()`: `date('Y', strtotime($solicitud['FechaInicio']))` |
| V9 | **Sin saldo suficiente no se aprueba**, salvo que la política tenga `PermiteSaldoNegativo = 1` | `aprobarSolicitudInterna()` |
| V10 | Al aprobar: `DiasDisponibles −= n`, `DiasTomados += n`, `DiasPendientes = max(0, DiasPendientes − n)` | `SaldosVacacionesModel::actualizarSaldo()` |
| V11 | Una solicitud aprobada **bloquea la marcación de asistencia** en esas fechas | `AsistenciaRegistrosModel::tieneVacacionesAprobadas()` |

> 🔴 **V-RIESGO.** La aprobación **no es transaccional**: primero se cambia el estado de la solicitud y después se actualiza el saldo. Si lo segundo falla, la solicitud queda aprobada **sin descontar días**. El código lo reconoce con el mensaje *"La solicitud cambió de estado, pero hubo un problema actualizando el saldo."*

---

## 10. Asistencia

### Estados del registro diario (`asistencia_registros.Estado`)

`pendiente` · `completo` · `inconsistente` · `ausente` · `vacaciones` · `incapacidad` · `permiso`

### Reglas

| # | Regla | Implementación |
|---|---|---|
| A1 | **No se puede marcar** si hay vacaciones aprobadas ese día → estado `vacaciones` | `validarBloqueoMarcacion()` + `tieneVacacionesAprobadas()` |
| A2 | **No se puede marcar** si hay una ausencia aprobada ese día → el estado se deriva del tipo de ausencia | `validarBloqueoMarcacion()` + `getAusenciaAprobada()` + `AsistenciaAusenciasModel::mapEstadoDiaDesdeTipo()` |
| A3 | La entrada crea (o completa) el registro del día en estado `pendiente` | `marcarEntrada()` |
| A4 | **No se puede marcar entrada dos veces** el mismo día | `marcarEntrada()`: *"La entrada del día ya está registrada."* |
| A5 | **No se permite salida sin entrada** | `marcarSalida()` |
| A6 | **No se puede marcar salida dos veces** | `marcarSalida()` |
| A7 | Si la hora de salida es **anterior** a la de entrada, el registro queda en estado `inconsistente` (no se rechaza) | `marcarSalida()` |
| A8 | `MinutosTrabajados = (salida − entrada)/60 − MinutosAlmuerzo`, nunca negativo | `calcularMinutosTrabajados()` |
| A9 | Los minutos programados salen del **turno asignado a ese colaborador en esa fecha** | `calcularMinutosProgramados()` → `AsistenciaColaboradorTurnosModel::getTurnoPorFecha()` → `AsistenciaTurnosModel::getHorasProgramadasMinutos()` |
| A10 | `MinutosExtra = max(0, trabajados − programados)` | `calcularMinutosExtra()` |
| A11 | Al registrar la salida se **recalculan y reemplazan** las horas extra del registro, clasificadas por tipo | `AsistenciaHorasExtraModel::clasificarTipo($fecha, $fechaHora)` + `reemplazarPorRegistro()` |
| A12 | Las horas extra requieren aprobación | `asistencia_horas_extra.Aprobada` |
| A13 | Un día sin registro obtiene un estado derivado (no persistido) | `getEstadoDiaVirtual()` |
| A14 | El turno define tolerancias de entrada y salida, y si cruza medianoche | `asistencia_turnos.ToleranciaEntradaMinutos`, `ToleranciaSalidaMinutos`, `CruzaMedianoche` |
| A15 | Una salida sin registro del día busca un registro pendiente de días anteriores (turnos nocturnos) | `getRegistroPendienteSalida()` |

---

## 11. Nómina

### Estados del periodo (`nomina_periodos.Estado`)

Incluye `en_calculo` y `en_revision` (`NominaPeriodosModel::ESTADO_*`); `puedeEditar($periodo)` bloquea los periodos cerrados.

### Motor de liquidación (`NominaCalculoModel`)

| # | Regla |
|---|---|
| N1 | No se recalcula un periodo inexistente ni uno cerrado (`puedeEditar()`) |
| N2 | El recálculo pone el periodo en `en_calculo`, procesa **todos los colaboradores con `Estado = 1`** y lo deja en `en_revision` |
| N3 | Cada recálculo registra una entrada `RECALCULO` en `bitacora_auditoria` con el número de colaboradores procesados |
| N4 | `DiasLiquidados` = **15** si `TipoPeriodicidad = 'QUINCENAL'`; si no, días entre `PeriodoInicio` y `PeriodoFin` **+ 1**; **30** si el rango es inválido |
| N5 | Variables del contexto de cálculo: `SALARIO`, `DIAS_LIQUIDADOS`, y desde `nomina_parametros`: `AUX_TRANSPORTE`, `PORC_SALUD`, `PORC_PENSION`, `HORAS_EXTRA_FACTOR` (por defecto 1.25) |
| N6 | **Base del concepto** según `BaseCalculo`: `SALARIO` (por defecto) · `SALARIO_DIARIO` (= salario/30) · `TOTAL_DEVENGADO` (acumulado **parcial**) · `NETO` (⚠️ **se trata igual que `TOTAL_DEVENGADO`**) · `PARAM:CLAVE` (valor de `nomina_parametros`) |
| N7 | **Valor del concepto** según `FormulaTipo`: `FIJO` (la fórmula es el número) · `PORCENTAJE` (`base × formula/100`) · `FORMULA` (expresión evaluada) |
| N8 | Al valor calculado se le **suma** la novedad del concepto (`nomina_novedades.Valor`) |
| N9 | Después se aplican `TopeMin` y `TopeMax` del concepto |
| N10 | El valor se acumula en `TotalDevengado` o `TotalDeduccion` según `nomina_conceptos.Tipo` |
| N11 | `NetoPagar = TotalDevengado − TotalDeduccion` |
| N12 | Se guarda un `HashCalculo` = `md5(json(periodo, colaborador+salario, novedades, conceptos, dias))` — permite detectar si algo cambió |
| N13 | El detalle (`nomina_liquidacion_detalle`) se **borra y regenera** en cada recálculo |
| N14 | Hay **una sola liquidación** por (periodo, colaborador): índice `UNIQUE uq_nomina_liquidaciones_periodo_colaborador` |

> ⚠️ **N-ORDEN.** Como `TOTAL_DEVENGADO` usa el acumulado **parcial**, el **orden en que `NominaConceptosModel::listarActivos()` devuelve los conceptos determina el resultado**. La tabla `nomina_conceptos` no tiene columna de orden explícita.
>
> 🔴 **N-EVAL.** `safeEvalFormula()` sustituye las variables y, tras validar la expresión contra `/^[0-9\.\+\-\*\/\(\)\s]+$/`, ejecuta **`eval()`**. La lista blanca es estricta, pero un `eval()` alimentado desde una columna de base de datos es una superficie de riesgo. Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md).

---

## 12. Adelantos de nómina

### Estados (`adelanto_solicitudes.EstadoSolicitud`)

`creada` → `en_revision` → `aprobada` → `desembolsada` → `en_descuento` → `descontada`
Ramas: `rechazada`, `cancelada`.

`estadosActivos()` define cuáles cuentan como "activas" para el límite de simultaneidad.

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

Todas se comprueban contra la **política activa** (`adelanto_politicas` con `Activo = 1`). Devuelve `['ok'=>bool, 'errores'=>[], 'politica'=>…, 'fechaSolicitud'=>…]`.

| # | Regla | Columna de la política |
|---|---|---|
| AD1 | Sin colaborador válido, se aborta de inmediato | — |
| AD2 | Motivo obligatorio | `RequiereMotivo` (por defecto 1) |
| AD3 | Soporte adjunto obligatorio | `RequiereSoporte` (por defecto 0) |
| AD4 | No se admiten solicitudes después del día N del mes | `DiaCorteMes` (si > 0) |
| AD5 | Monto ≥ mínimo | `MontoMinimo` |
| AD6 | Monto ≤ máximo absoluto | `MontoMaximo` (si no es null ni vacío) |
| AD7 | Monto ≤ `Salario × MontoMaximoPorcentajeSalario / 100` | `MontoMaximoPorcentajeSalario` (solo si el colaborador tiene salario) |
| AD8 | Antigüedad en meses desde `colaboradores.FechaInicio` ≥ mínimo | `AntiguedadMinMeses` |
| AD9 | Número de solicitudes **en el mes natural** < máximo | `MaxSolicitudesPorMes` (por defecto 1) |
| AD10 | Días transcurridos desde la última solicitud ≥ mínimo | `DiasMinEntreSolicitudes` |
| AD11 | Número de solicitudes en estado activo < máximo | `MaxSolicitudesActivas` (por defecto 1) |

### Reglas de gestión

| # | Regla |
|---|---|
| AD12 | Cada cambio se registra en `adelanto_historial` con `Accion`, `ValorAnterior`, `ValorNuevo`, `UsuarioId` |
| AD13 | El plan de descuento define `TipoDescuento` y `NumeroCuotas` (`adelanto_plan_descuento`) |
| AD14 | Cada cuota (`adelanto_cuotas`) tiene `NumeroCuota`, `ValorCuota`, `Periodo`, `FechaEstimada` y estado `pendiente`/descontada |
| AD15 | El desembolso registra método, cuenta destino y comprobante (`adelanto_desembolsos`) |

---

## 13. Beneficios

### Enumeraciones (`BeneficiosModel`)

- **Tipo:** `subsidio`, `auxilio`, `bono`, `programa`
- **Estado del beneficio:** `borrador` → `activo` ↔ `inactivo` → `archivado`
- **Frecuencia:** `una_vez`, `mensual`, `trimestral`, `anual`, `sin_limite`
- **Estado de solicitud:** `creada` → `en_revision` → `aprobada` → `entregada`; ramas `rechazada`, `cancelada`

### Elegibilidad (`BeneficiosModel::esElegibleParaColaborador()`)

Algoritmo exacto:

```mermaid
flowchart TD
    A["esElegibleParaColaborador(beneficioId, colaboradorId)"] --> B{"¿existe el beneficio\ny el colaborador?"}
    B -- no --> N["false"]
    B -- sí --> C["obtenerReglas(beneficioId)"]
    C --> D{"¿lista de reglas vacía?"}
    D -- sí --> S["true — elegible por defecto"]
    D -- no --> E["separar reglas en 'incluir' y 'excluir'"]
    E --> F["antiguedadMeses = meses desde FechaInicio"]
    F --> G{"¿alguna regla 'excluir'\nse cumple?"}
    G -- sí --> N2["false — la exclusión tiene prioridad absoluta"]
    G -- no --> H{"¿hay reglas 'incluir'?"}
    H -- no --> S2["true"]
    H -- sí --> I{"¿alguna 'incluir'\nse cumple?"}
    I -- sí --> S3["true"]
    I -- no --> N3["false"]
```

**Criterios soportados** (`beneficios_reglas.Criterio`), cualquier otro valor devuelve `false`:

| Criterio | Comparación |
|---|---|
| `area` | `colaboradores.IdArea == ReferenciaId` |
| `cargo` | `colaboradores.IdCargo == ReferenciaId` |
| `sede` | `colaboradores.IdSede == ReferenciaId` |
| `contrato` | `colaboradores.IdTipoContrato == ReferenciaId` |
| `antiguedad_min_meses` | `antiguedadMeses >= Valor` |

### Otras reglas

| # | Regla | Implementación |
|---|---|---|
| B1 | Un beneficio solo se ofrece si está **vigente** por fechas | `estaVigente()` — `FechaInicio` / `FechaFin` |
| B2 | El catálogo del portal solo muestra los beneficios elegibles | `obtenerBeneficiosElegibles($colaboradorId)` |
| B3 | Hay cupo disponible si `CupoUsado < CupoTotal` (cuando `CupoTotal` no es null) | `tieneDisponibilidad()` |
| B4 | Hay presupuesto disponible si `PresupuestoUsado + monto <= PresupuestoTotal` (cuando `PresupuestoTotal` no es null) | `tieneDisponibilidad()` |
| B5 | Si `montoAprobar` es null, se usa `MontoFijo` del beneficio para la comprobación de presupuesto | `tieneDisponibilidad()` |
| B6 | Al aprobar: `CupoUsado += 1` (si hay `CupoTotal`) y `PresupuestoUsado += montoAprobado` (si hay `PresupuestoTotal`) | `aplicarConsumoAprobacion()` |
| B7 | `aplicarConsumoAprobacion()` **relee el beneficio de la BD** y **vuelve a comprobar disponibilidad** antes de consumir | `aplicarConsumoAprobacion()` |
| B8 | El límite por frecuencia mira las solicitudes en estados `creada`, `en_revision`, `aprobada`, `entregada` dentro de la ventana correspondiente: `una_vez` = desde 2000-01-01, `mensual` = −1 mes, `trimestral` = −3 meses, `anual` = −1 año, `sin_limite` = sin restricción | `BeneficiosSolicitudesModel::puedeSolicitarPorFrecuencia()` |
| B9 | Una solicitud sin cupo se marca con `ObservacionRevision = 'sin_cupo'` | `marcarSinCupo()` |
| B10 | `beneficios.AprobacionRequerida = 0` permite entrega directa sin flujo de aprobación | Columna |
| B11 | Cada cambio se registra en `beneficios_solicitud_historial` | Patrón T5 |

> 🔴 **B-RIESGO.** `aplicarConsumoAprobacion()` hace read-check-write **sin transacción ni bloqueo**. Dos aprobaciones concurrentes del último cupo pueden pasar ambas.

---

## 14. Capacitación

### Estados de inscripción (`capacitacion_inscripciones.Estado`)

`asignado` → `en_progreso` → `completado`; más `vencido` y `cancelado`.

| # | Regla | Implementación |
|---|---|---|
| CP1 | No se duplica la inscripción de un colaborador a un curso | `getByCursoColaborador()` ← `asignar()` |
| CP2 | La inscripción puede tener fecha de vencimiento | `asignar($cursoId, $colaboradorId, $fechaVencimiento)` |
| CP3 | El progreso se deriva del avance por lección | `capacitacion_leccion_progreso` → `capacitacion_inscripciones.ProgresoPorcentaje` |
| CP4 | Las lecciones tienen orden explícito y son reordenables | `capacitacion_lecciones.Orden` ← `CapacitacionLeccionesController::reordenar` |
| CP5 | El quiz define el puntaje mínimo de aprobación | `capacitacion_quiz.PuntajeMinimo` → `capacitacion_inscripciones.AprobadoQuiz`, `PuntajeQuiz` |
| CP6 | Cada intento se guarda con sus respuestas en JSON | `capacitacion_quiz_intentos.RespuestasJson`, `IntentoNumero` |
| CP7 | El certificado se emite solo tras completar el curso y, si hay quiz, aprobarlo | `CapacitacionPortalController::descargarCertificado` |
| CP8 | El certificado tiene un código validable públicamente | `capacitacion_certificados.Codigo` ← `CapacitacionCertificadosController::validar` |
| CP9 | El PDF se genera con **mPDF** desde `app/views/capacitacion_certificados/plantilla.php` | `CapacitacionPortalController` |
| CP10 | Un curso pasa por `borrador` → `publicar` → `archivar` | `CapacitacionController` |

---

## 15. Tickets y SLA

### Estados (`servicio_tickets.Estado`)

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

### Reglas

| # | Regla | Implementación |
|---|---|---|
| TK1 | La política de SLA se resuelve **por prioridad** del ticket | `ServicioSlaPoliticasModel::getByPrioridad()` |
| TK2 | `MinutosPrimeraRespuesta` = minutos entre `CreatedAt` y `FechaPrimeraRespuesta` | `calcularMinutos()` |
| TK3 | `MinutosResolucion` = minutos entre `CreatedAt` y `FechaCierre` | `calcularMinutos()` |
| TK4 | `SlAEstado = 'vencido'` si **cualquiera** de los dos supera su umbral; `'en_tiempo'` en caso contrario | `recalcularSla()` |
| TK5 | Si no hay política para esa prioridad, `recalcularSla()` devuelve `null` y **no se actualiza** el estado SLA | `actualizarSlaEstado()` |
| TK6 | El cálculo devuelve `null` si falta alguna fecha o si el fin es anterior al inicio | `calcularMinutos()` |
| TK7 | El portal solo muestra los tickets del colaborador de la sesión | `listarConFiltros($filtros, $soloColaboradorId)` |
| TK8 | La calificación de satisfacción se captura después del cierre | `TicketsController::calificar` → `Calificacion`, `ComentarioSatisfaccion` |
| TK9 | Cada cambio se registra en `servicio_ticket_historial` | Patrón T5 |
| TK10 | Los comentarios distinguen autor colaborador de autor gestor | `servicio_ticket_comentarios.AutorTipo` |

> ⚠️ La columna se llama **`SlAEstado`** (con A mayúscula intercalada). No es un error tipográfico de esta documentación: así está en el esquema y en el código.

---

## 16. Línea de ética

### Anonimato

| # | Regla | Implementación |
|---|---|---|
| LE1 | El caso se identifica con un código público `ETH-{año}-{6 dígitos}`, con hasta **30 reintentos** ante colisión | `generarCodigoUnico()` |
| LE2 | El acceso al seguimiento requiere **código + PIN** | `getByCodigo()` + `verificarPin()` |
| LE3 | El PIN es un entero aleatorio entre 100000 y 99999999 | `generarPinTemporal()` — `random_int()` |
| LE4 | **Solo se almacena el hash del PIN**, con `password_hash(PASSWORD_DEFAULT)` | `crearPinHash()` |
| LE5 | La verificación usa `password_verify()` | `verificarPin()` |
| LE6 | **El PIN no es recuperable.** Se muestra una sola vez al radicar | Diseño |
| LE7 | Todo el flujo público (`crear`, `guardar`, `seguimiento`, `verCaso`, `agregarMensaje`) es accesible **sin sesión** | `LineaEticaPublicaController::loadAccessControl()` — todas `'*'` |

### Máquina de estados (`puedeTransicionarEstado($actual, $nuevo, $permitirReabrir)`)

| Desde | Puede ir a |
|---|---|
| `recibido` | `en_revision`, `archivado` |
| `en_revision` | `en_investigacion`, `en_espera`, `archivado` |
| `en_investigacion` | `en_espera`, `cerrado`, `archivado` |
| `en_espera` | `en_investigacion`, `cerrado`, `archivado` |
| `cerrado` | `en_investigacion` **solo si `$permitirReabrir = true`** |
| `archivado` | **ninguno — terminal absoluto** |

Una transición al mismo estado siempre se permite.

### Confidencialidad

| # | Regla | Implementación |
|---|---|---|
| LE8 | Los mensajes con `VisibleParaDenunciante = 0` **no** se muestran en el seguimiento público | `linea_etica_mensajes.VisibleParaDenunciante` |
| LE9 | Las **notas internas** son una tabla aparte y **nunca** son visibles para el denunciante | `linea_etica_notas_internas` |
| LE10 | `AutorTipo` distingue al denunciante del gestor | `linea_etica_mensajes.AutorTipo` |
| LE11 | El contacto es opcional | `DeseaSerContactado`, `CorreoContacto`, `TelefonoContacto` |
| LE12 | Se registran `FechaPrimeraRespuesta`, `FechaUltimaActividad` y `FechaCierre` para los KPIs | `actualizarUltimaActividad()`, `getKpis()` |
| LE13 | Cada acción sobre el caso se registra en `linea_etica_bitacora` | Patrón T5 |

---

## 17. Reconocimientos

| # | Regla | Implementación |
|---|---|---|
| RC1 | Un emisor tiene un **límite mensual** de reconocimientos | `reconocimiento_reglas.LimiteMensualPorEmisor` + `ReconocimientosModel::countByEmisorRango()` |
| RC2 | Un tipo con `RequiereAprobacion = 1` debe aprobarse antes de publicarse | `reconocimiento_tipos.RequiereAprobacion` |
| RC3 | Solo los tipos con `Publicable = 1` llegan al muro | `reconocimiento_tipos.Publicable` |
| RC4 | El muro solo muestra reconocimientos publicados | `listarPublicadosConNombres()` |
| RC5 | Emisor y receptor son ambos FK a `colaboradores` | `reconocimientos.EmisorId`, `ReceptorId` |

---

## 18. Carrera y sucesión

### Planes de carrera

| # | Regla | Implementación |
|---|---|---|
| CR1 | Una ruta parte de un **cargo origen** y encadena etapas ordenadas, cada una con cargo destino | `carrera_rutas.CargoOrigenId`, `carrera_ruta_etapas.Orden`, `CargoDestinoId` |
| CR2 | Cada etapa declara requisitos y experiencia mínima en meses | `carrera_ruta_etapas.Requisitos`, `ExperienciaMinMeses` |
| CR3 | Al asignar un plan se materializan las etapas de la ruta | `carrera_plan_etapas` |
| CR4 | Un plan puede tener **mentor** (otro colaborador) y cargo meta | `carrera_plan_colaborador.MentorId`, `CargoMetaId` |
| CR5 | El avance se recalcula sobre las etapas completadas | `PlanesCarreraController::actualizarAvance` → `PorcentajeAvance` |
| CR6 | Las rutas se versionan | `carrera_rutas.Version` |

### Sucesión

| # | Regla | Implementación |
|---|---|---|
| SU1 | Un puesto clave se declara sobre un **cargo**, con motivo de criticidad y riesgo de vacancia | `sucesion_puestos_clave` |
| SU2 | Cada puesto clave tiene un plan de sucesión con responsable | `sucesion_planes.ResponsableId` |
| SU3 | Cada candidato tiene `Readiness`, `PuntajeAjuste`, fortalezas, brechas y acciones de desarrollo | `sucesion_candidatos` |
| SU4 | Un plan tiene un candidato marcado como principal | `sucesion_candidatos.Principal` |
| SU5 | El reporte de riesgo cruza `RiesgoVacancia` con la disponibilidad de candidatos listos | `SucesionController::reporteRiesgo` |

---

## 19. Comunicación interna

| # | Regla | Implementación |
|---|---|---|
| CI1 | Una noticia se segmenta por audiencias (área, sede, cargo…) | `comunicacion_noticias_audiencias(TipoAudiencia, ReferenciaId)` |
| CI2 | Ciclo de la noticia: borrador → `publicarNoticia` → `archivarNoticia` | `ComunicacionInternaController` |
| CI3 | `FechaExpiracion` y `Destacado` gobiernan la visibilidad en el portal | `comunicacion_noticias` |
| CI4 | Los murales se ordenan explícitamente y admiten enlace destino | `comunicacion_murales.Orden`, `LinkTipo`, `LinkDestino` |
| CI5 | La mensajería interna es **estrictamente 1 a 1**: la conversación tiene exactamente dos participantes. **No hay grupos** | `comunicacion_conversaciones.ParticipanteAId`, `ParticipanteBId` |
| CI6 | Cada usuario puede archivar o eliminar un mensaje **sin afectar** a la copia del otro | `comunicacion_mensajes_estado(MensajeId, UsuarioId, Archivado, Eliminado)` |
| CI7 | Las notificaciones se marcan como leídas individualmente o en bloque | `NotificacionesController::marcarLeido` / `marcarTodasLeidas` |

---

## 20. Correo saliente

| # | Regla | Implementación |
|---|---|---|
| ML1 | El envío programado **no se ejecuta** si `configuraciones['DetenerEnvioNotificaciones']` no está vacío | `AlertasEmailController::sendCronAction()` |
| ML2 | El tope de correos por ejecución es `configuraciones['EnvioMaximoNotificaciones']`; si no es numérico o es ≤ 0, se usa **2** | `sendCronAction()` (corregido por el hallazgo HR-026) |
| ML3 | Se envía uno a uno con `sleep(3)` entre correos | `sendCronAction()` |
| ML4 | Solo se envían alertas con `EstadoEnvio = 1` y `Destinatarios` no vacío | `sendCronAction()` |
| ML5 | La invocación anónima exige `CRON_TOKEN`; sin él configurado se responde **503** | `guardCronAccess()` (hallazgo HR-025) |
| ML6 | Una sesión activa solo puede disparar el cron si tiene el permiso `AlertasEmail.send` o es el usuario 1 | `guardCronAccess()` |

> `Config::$correos_diarios` (`MAIL_DIARIOS_MAX`) existe en la configuración pero **no se consulta en ninguna parte del código**.

---

## 21. Reglas de configuración que afectan al comportamiento

| Clave | Efecto real |
|---|---|
| `VACACIONES_BLOQUEAR_SOLICITADAS` | Si es `true`, las solicitudes en `SOLICITADA` también bloquean el cruce de fechas |
| `METAS_FRECUENCIA_PERIODO_DEFAULT` | Frecuencia de metas cuando no hay valor en `configuraciones` ni en el periodo |
| `configuraciones['metas.max_por_colaborador']` | Máximo de metas si el periodo no lo define |
| `configuraciones['metas.frecuencias_disponibles']` | Lista de frecuencias ofrecidas |
| `configuraciones['metas.frecuencia_periodo']` | Frecuencia forzada |
| `configuraciones['DetenerEnvioNotificaciones']` | Interruptor de emergencia del correo |
| `configuraciones['EnvioMaximoNotificaciones']` | Tope de correos por ejecución de cron |
| `configuraciones['ReclutamientoContratoExigeFirma']` | Exigir firma en el contrato |
| `talento_config` | Pesos y umbral del cálculo de talento |
| `DEMO_MODE_ENABLED` | Habilita el modo demo (siembra datos y suplanta identidad) |
| `LOG_ACTIONS` / `LOG_MODULES` / `LOG_ACCESS` | Activan cada subsistema de auditoría |

---

## 22. Riesgos de consistencia identificados

| # | Riesgo | Dónde |
|---|---|---|
| 1 | Aprobación de vacaciones **no transaccional** | `VacacionesController::aprobarSolicitudInterna()` |
| 2 | Consumo de cupo/presupuesto de beneficios **sin bloqueo** (read-check-write) | `BeneficiosModel::aplicarConsumoAprobacion()` |
| 3 | Recálculo de nómina **sin transacción**: liquidación + borrado + regeneración de detalle | `NominaCalculoModel::recalcularColaborador()` |
| 4 | El resultado de nómina **depende del orden** de los conceptos | `NominaCalculoModel` con `BaseCalculo = TOTAL_DEVENGADO` |
| 5 | `BaseCalculo = 'NETO'` se comporta como `TOTAL_DEVENGADO` | `NominaCalculoModel::resolverBase()` |
| 6 | La comprobación de propiedad del registro (IDOR) **no está centralizada** | Todos los controladores del portal |
| 7 | La mayoría de los módulos **no auditan** en `log_modules` (solo `ColaboradoresModel` y `UsuariosModel` tienen `$LOG = true`) | `core/Model.php` |
| 8 | Los permisos del portal se conceden **por código**, no por base de datos | `ColaboradoresModel::validateUser()` |

Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md) para la clasificación por severidad.

---

## Documentos relacionados
- [04_MODULES.md](04_MODULES.md)
- [05_DATABASE.md](05_DATABASE.md)
- [11_WORKFLOWS.md](11_WORKFLOWS.md)
- [20_GLOSSARY.md](20_GLOSSARY.md)
