# Revisión de las semillas asociadas a colaboradores

Estado: **21 de 23 semillas cerradas** y **3 de 4 bloqueantes**. Documento de
trabajo para resolver los hallazgos uno a uno hasta que el demo cuente una
historia coherente.

> **Pendiente de aplicar en `kuorum`.** Los arreglos de B-1 y B-2 están
> verificados en base limpia, pero la base de trabajo todavía tiene 12 tablas en
> MyISAM: hace falta un `php bin/migrate up` sobre `kuorum` para aplicar
> `2026_08_31_113000_convert_core_tables_to_innodb`.

Cada hallazgo tiene un identificador estable (`B-n` bloqueante, `H-nn` normal).
Marca la casilla cuando lo cierres y anota en la columna de estado quién y
cuándo. No borres los hallazgos cerrados: sirven para no reintroducirlos.

---

## Cómo se obtuvo esta revisión

Tres comprobaciones, todas reproducibles (ver [Anexo](#anexo-cómo-repetir-la-auditoría)):

1. **Contra el modelo**: los valores sembrados se compararon con los catálogos de
   `app/config/GeneralDataArray.php` y con las reglas de los controladores
   (`TicketsController`, `VacacionesColaboradorController`, `PotencialesController`…).
2. **Contra la base actual** (`kuorum`, 104 colaboradores): consultas de
   integridad y de aritmética sobre las filas ya sembradas.
3. **Contra una base limpia**: `migrate up` + `seed completo` en una base
   temporal, comparando el resultado con la base actual. Esta es la que destapó
   los bloqueantes.

> La base temporal se creó y se eliminó durante la auditoría. La base `kuorum`
> no se modificó: las comprobaciones que escriben corrieron dentro de
> transacciones revertidas.

---

## Semáforo por semilla

| Semilla | Estado | Hallazgos |
|---|---|---|
| `030_ColaboradoresSeeder` | ✅ cerrada | — |
| `130_ServicioColaboradorSeeder` | ✅ cerrada | — |
| `025_FestivosSeeder` | ✅ cerrada | — |
| `105_ColaboradoresDemoSeeder` | ✅ cerrada | — |
| `021_CargosSeeder` | ✅ cerrada | — |
| `220_CompaniaRealColombiaSeeder` | ✅ cerrada | — |
| `230_SolucionesAndinasSeeder` | ✅ cerrada | — |
| `031_ColaboradorEducacionSeeder` | ✅ cerrada | — |
| `032_ColaboradorExperienciaSeeder` | ✅ cerrada | — |
| `040_PoliticasVacacionesSeeder` | ✅ cerrada | — |
| `118_SaldosVacacionesSeeder` (nueva) | ✅ cerrada | — |
| `120_NominaSeeder` | ✅ cerrada | — |
| `125_AsistenciaSeeder` | ✅ cerrada | — |
| `140_CapacitacionSeeder` | ✅ cerrada | — |
| `145_CarreraTalentoSeeder` | ✅ cerrada | — |
| `150_MapaTalentoSeeder` | 🟢 | — |
| `165_BeneficiosSeeder` | 🟢 | — |
| `170_AdelantosNominaSeeder` | ✅ cerrada | — |
| `210_ModulesDemoSeeder` | ✅ cerrada | — |
| `110_Evaluacion360Seeder` | ✅ cerrada | — |
| `115_PlanesDesarrolloSeeder` | ✅ cerrada | — |
| `135_LineaEticaSeeder` | 🟢 | — |
| `160_ComunicacionInternaSeeder` | 🟢 | — |
| `155_MetasCategoriasSeeder` | 🟢 | — |
| `DemoReadinessVerifier` (no es semilla) | 🔴 | H-15 |

---

## Bloqueantes

Cuatro problemas que hay que resolver antes que los demás: mientras sigan ahí,
arreglar una semilla concreta no cambia lo que se ve en el demo.

### ☑ B-1 · `php bin/seed completo` falla en una base limpia

> **Cerrado** por Iván Velasco el 2026-08-31. Verificado en base limpia:
> `38 ejecutada(s), 0 omitida(s), 0 con error.`

**Síntoma.** Tres semillas terminan en error:

```
[error] CarreraTalentoSeeder       — Cannot add or update a child row ... fk_potencial_evaluaciones_periodo
[error] CompaniaRealColombiaSeeder — Cannot add or update a child row ... fk_potencial_evaluaciones_periodo
[error] SolucionesAndinasSeeder    — Cannot add or update a child row ... fk_potencial_evaluaciones_periodo
35 ejecutada(s), 0 omitida(s), 3 con error.
```

**Causa raíz.** No es un dato mal referenciado: es un choque de motores de
almacenamiento. `2026_02_21_000002_create_core_tables.php` crea `periodos` como
**MyISAM**, y `2026_05_13_000086` le añade a `potencial_evaluaciones` (InnoDB)
una clave foránea que apunta a esa tabla. InnoDB no puede validar una referencia
contra MyISAM, así que **todo** INSERT en `potencial_evaluaciones` falla con
errno 1452, con cualquier valor de `PeriodoId`. Comprobado: un `INSERT` manual
con `PeriodoId = 1` (que existe) falla igual.

En la base `kuorum` actual no ocurre porque ahí `periodos` es InnoDB — se
convirtió en algún momento fuera de las migraciones.

Tablas creadas como MyISAM en una instalación limpia:
`alertas`, `alertas_email`, `configuraciones`, `graficas`, `ips_autorizadas`,
`log_acceso`, `log_modules`, `log_urls`, `periodos`, `permisos`, `roles`,
`sedes`, `usuarios`.

**Daño colateral.** El error corta `145_CarreraTalentoSeeder` en su segundo paso,
así que nunca se ejecutan `seedSucesion()` ni `seedReconocimientos()`. Lo mismo
en `220` y `230`. Resultado en base limpia: **sucesión 0 filas, reconocimientos
0 filas, potencial 0 filas** y solo 37 de 86 planes de carrera.

**Arreglo aplicado.** Tres cambios:

1. `2026_08_31_113000_convert_core_tables_to_innodb` convierte a InnoDB las 13
   tablas del core creadas como MyISAM. Es idempotente: solo altera la tabla si
   su motor actual es MyISAM. El `down()` revierte únicamente las tablas a las
   que no apunta ninguna clave foránea, porque devolver `periodos` a MyISAM
   reintroduciría exactamente este fallo.
2. `2026_02_21_000001_initial_setup` y `2026_02_21_000002_create_core_tables`
   pasan a declarar `ENGINE=InnoDB`, para que las instalaciones nuevas no
   necesiten la conversión. Comprobado que ningún índice de esas tablas se
   acerca al límite de clave de InnoDB (el mayor es el UNIQUE de `permisos`,
   604 bytes de 3072) y que no hay índices FULLTEXT.
3. Al desaparecer el choque de motores quedó a la vista un dato realmente mal
   referenciado: `230_SolucionesAndinasSeeder` fijaba `PeriodoId => 10`, que era
   el Id de la fila que esa misma semilla crea en `potencial_periodos` —la tabla
   a la que la clave foránea apuntaba **antes** de la migración 000086—. Ahora
   resuelve el periodo con `Seeder::getPeriodoValido()`, helper que se subió a la
   clase base desde `220_CompaniaRealColombiaSeeder`, donde estaba duplicado.

**Resultado en base limpia**, contra los conteos de B-3:

| Tabla | Antes | Ahora | Base `kuorum` |
|---|---:|---:|---:|
| `potencial_evaluaciones` | 0 | 69 | 104 |
| `potencial_evaluacion_detalle` | 0 | 114 | 324 |
| `sucesion_planes` | 0 | 24 | 24 |
| `sucesion_candidatos` | 0 | 44 | 44 |
| `reconocimientos` | 0 | 30 | 40 |

Sucesión y reconocimientos vuelven a poblarse y sucesión llega a la paridad. Lo
que sigue por debajo de la base actual ya no es este bloqueante sino H-10, H-11 y
la cobertura de B-4. `php bin/migrate validate` da 0 errores y los 41 tests
siguen en verde.

Consulta para encontrar tablas MyISAM referenciadas por claves foráneas, por si
reaparecen:

```sql
SELECT k.TABLE_NAME, k.COLUMN_NAME, k.REFERENCED_TABLE_NAME
FROM information_schema.KEY_COLUMN_USAGE k
JOIN information_schema.TABLES t
  ON t.TABLE_SCHEMA = k.TABLE_SCHEMA AND t.TABLE_NAME = k.REFERENCED_TABLE_NAME
WHERE k.TABLE_SCHEMA = DATABASE()
  AND k.REFERENCED_TABLE_NAME IS NOT NULL
  AND t.ENGINE = 'MyISAM';
```

---

### ☑ B-2 · `php bin/migrate up` falla en una base limpia

> **Cerrado** por Iván Velasco el 2026-08-31. Verificado en base limpia:
> `migrate up` → `42 migration(s) executed`, sin pasos intermedios.

**Síntoma.**

```
[✗] Error in 2026_05_13_000086_link_potencial_evaluaciones_to_general_periodos:
    No existe ningun periodo en tabla periodos.
```

**Causa.** La migración 086 exige que `periodos` tenga al menos una fila, pero
quien llena `periodos` es `010_PeriodosSeeder`. Migraciones y semillas se
necesitan mutuamente.

**Secuencia que hacía falta antes** —cuatro pasos, con una semilla intercalada
entre dos pasadas de migración:

```
php bin/migrate up      # falla en 086, pero deja aplicadas las anteriores
php bin/seed base       # llena periodos
php bin/migrate up      # ahora sí completa
php bin/seed completo
```

**Secuencia actual** — la instalación limpia son dos comandos:

```
php bin/migrate up      # 42 migration(s) executed
php bin/seed completo   # 38 ejecutada(s), 0 omitida(s), 0 con error
```

**Arreglo aplicado.** Dos cambios:

1. `2026_05_13_000086` deja de abortar cuando `periodos` está vacía: crea un
   periodo mínimo con `crearPeriodoPorDefecto()` y sigue. El Id es el 1, que es
   la clave del upsert de `010_PeriodosSeeder`, de modo que la semilla **sustituye**
   ese marcador en lugar de añadir un segundo periodo. Comprobado en base limpia:
   tras `migrate up` la fila es `Periodo 2026 / creado por la migracion 000086`, y
   tras `seed completo` es `Periodo Demo 2026`, una sola fila.
2. `025_FestivosSeeder` deja de declarar `Id` fijos. Esto no se veía antes porque
   el orden roto separaba a los dos dueños del calendario; al completar `migrate up`
   de una vez, la migración `000094` —que inserta los 18 festivos por
   AUTO_INCREMENT— pasa a correr **antes** que la semilla, y el guard de
   `Seeder::upsert()` abortaba el perfil completo:

   ```
   [error] FestivosSeeder — `festivos` ya tiene la fila Fecha=2026-05-01 con Id 6,
                            pero esta semilla la declara con Id 2.
   ```

   El guard tenía razón: la clave de negocio de `festivos` es `Fecha` (hay un
   `UNIQUE(Fecha)`) y el Id es cosa de la tabla. Además la semilla declaraba solo
   3 de los 18 festivos, así que el calendario real lo ponía una migración: ahora
   la semilla es dueña del calendario completo, que es el que
   `SolicitudesVacacionesModel::calcularDiasSolicitados()` necesita para H-05.

Comprobado que la secuencia es idempotente: repetir `migrate up` + `seed completo`
sobre la misma base deja 18 festivos, 1 periodo y los mismos conteos.

---

### ☐ B-3 · La base actual no se puede reconstruir desde el repositorio

Comparación entre la base `kuorum` de hoy y una recién sembrada con
`migrate up` + `seed completo`:

| Tabla | Base actual | Resembrada | Diferencia |
|---|---:|---:|---|
| `potencial_evaluaciones` | 104 | 0 | **−104** |
| `carrera_plan_etapas` | 244 | 62 | −182 |
| `comunicacion_notificaciones` | 130 | 31 | −99 |
| `asistencia_registros` | 95 | 5 | −90 |
| `nomina_liquidacion_detalle` | 73 | 5 | −68 |
| `carrera_plan_colaborador` | 86 | 37 | −49 |
| `sucesion_candidatos` | 44 | 0 | −44 |
| `reconocimientos` | 40 | 0 | −40 |
| `sucesion_planes` | 24 | 0 | −24 |
| `saldos_vacaciones` | 20 | 0 | **−20** |
| `solicitudes_vacaciones` | 20 | 0 | **−20** |
| `nomina_liquidaciones` | 20 | 1 | −19 |
| `colaborador_experiencia` | 21 | 2 | −19 |
| `colaborador_educacion` | 20 | 2 | −18 |
| `planes_desarrollo` | 20 | 2 | −18 |
| `nomina_novedades` | 12 | 2 | −10 |
| `capacitacion_inscripciones` | 20 | 10 | −10 |
| `asistencia_ausencias` | 8 | 1 | −7 |
| `beneficios_asignaciones` | 17 | 10 | −7 |
| `servicio_ticket_comentarios` | 57 | 92 | +35 *(mejora de la semilla 130 ya corregida)* |

Parte de la diferencia es consecuencia de B-1. El resto es data que **ninguna
semilla produce**: `saldos_vacaciones` y `solicitudes_vacaciones` no aparecen en
ningún archivo de `database/seeders`, y de las 20 liquidaciones de nómina solo
la 9001 tiene semilla.

**Riesgo para el demo.** Si se resetea la base antes de la demostración, el
módulo de Vacaciones queda vacío y Nómina baja a una liquidación.

**Arreglo propuesto.** Decidir para cada tabla de la lista: o se escribe la
semilla que la reproduce, o se acepta explícitamente que el demo corre sobre una
base que no se resetea. Lo segundo es frágil; lo primero es el trabajo que
detallan H-04 a H-07.

---

### ☑ B-4 · La cobertura está partida en dos poblaciones que no se tocan

> **Cerrado** por Iván Velasco el 2026-08-31 con la **opción B**. Verificado
> en base limpia: la nómina base pasa de 0 a tener metas, evaluaciones 360,
> competencias y planes de desarrollo.

Ningún colaborador de la base puede mostrar la plataforma completa.

| Módulo | Nómina base (1–20) | Padrón demo (20001–20020) |
|---|---|---|
| Educación / Experiencia | ✅ | ❌ |
| Vacaciones | ✅ | ❌ |
| Nómina | ✅ | ❌ |
| Asistencia | ✅ | ❌ |
| Beneficios / Adelantos | ✅ (solo 1–10) | ❌ |
| Capacitación | ✅ | ❌ |
| **Metas** | ❌ | ✅ |
| **Evaluaciones 360** | ❌ | ✅ |
| **Competencias** | ❌ | ✅ |
| **Planes de desarrollo** | ❌ | ✅ |

Matriz por colaborador de la nómina base (`.` = sin datos):

```
Id   Colaborador          educ exp  vaca meta eval comp pdi  pot  nom  asis capa carr bene adel
1    JUAN PEREZ            1    1    1    .    .    .    .    1    1    4    1    1    3    1
2    MARIA GOMEZ           1    1    1    .    .    .    .    1    1    1    1    1    4    1
...
11   SERGIO MORA           1    1    1    .    .    .    .    1    1    5    1    2    .    .
20   MAURICIO CASTRO       1    1    1    .    .    .    .    1    1    5    1    1    .    .
```

`110_Evaluacion360Seeder` opera **solo** sobre el rango 20000–29999, y esa guarda
la protege `Evaluacion360SeederSafetyTest`, así que no basta con cambiarle el
rango. `115_PlanesDesarrolloSeeder` hereda la misma población.

**Decisión tomada: opción B.** El demo se hace con la **nómina base**
(`colab1`…), porque ya cubría 10 de 15 módulos y `030` quedó consistente; había
menos camino que llevando nómina, asistencia y vacaciones al padrón demo.
`130_ServicioColaboradorSeeder` ya había movido los tickets en esa dirección.

**Arreglo aplicado.** Cuatro cambios:

1. **La población deja de estar escrita en cada consulta.** `Seeder` gana la
   constante `POBLACION_DEMO` —la nómina base `1–20` y el padrón demo
   `20000–29999`— y el método `filtroPoblacionDemo($columna)` que la traduce a
   SQL. Los padrones de escenario (Soluciones Andinas 200–249 y Compañía Real
   300–313) quedan deliberadamente fuera: tienen su propia historia y sus
   propias semillas.
2. **`110_Evaluacion360Seeder`** sustituye sus **seis** copias literales de
   `Id >= 20000 AND Id < 30000` por ese filtro. Con eso metas, metas_consolidado,
   evaluaciones y competencias llegan a la nómina base.
3. **`115_PlanesDesarrolloSeeder` reescrita.** El documento daba por hecho que
   heredaba la población del padrón demo; en realidad sembraba **dos planes
   fijos** sobre los colaboradores 1, 2 y 3, y las 20 filas de la base actual
   habían entrado por fuera del pipeline. Ahora genera un plan por colaborador
   de la población, rotando cuatro perfiles (`Completado`, `Activo`, `Aprobado`,
   `EnAprobacion`) para que el módulo enseñe el ciclo entero, y respeta tres
   reglas que la aplicación da por ciertas:
   - `ProgresoPorcentaje` del plan **se deriva** de sus acciones con la fórmula
     de `CalculadorProgresoPlanService::recalcular()`, y los pesos suman 100. Es
     el mismo vicio que denuncia H-10, evitado desde el principio.
   - una acción `Completada` que exige evidencia **tiene** evidencia, como pide
     `puedeMarcarAccionCompletada()`;
   - `MotivoCreacion` no contradice a las columnas, y `IdEvaluacion`/`IdMeta`
     apuntan a la evaluación y la meta **del propio colaborador**, no a la
     primera fila de la tabla.
4. **La guarda de seguridad se reescribe, no se relaja.** `Evaluacion360SeederSafetyTest`
   comprobaba la cadena literal del rango. Ahora comprueba tres cosas más
   exigentes: que la semilla no fije ningún rango por su cuenta, que **toda**
   lectura de `colaboradores` pase por el filtro, y que `POBLACION_DEMO` siga
   excluyendo los padrones de escenario. Son 43 tests, en verde.

**Cobertura en base limpia después del cambio** (filas / personas distintas):

| Módulo | Nómina base 1–20 | Padrón demo |
|---|---|---|
| Metas | **60 / 20p** | 62 / 20p |
| Metas consolidado | **20 / 20p** | 20 / 20p |
| Evaluaciones 360 | **20 / 20p** | 20 / 20p |
| Competencias | **160 / 20p** | 160 / 20p |
| Planes de desarrollo | **20 / 20p** | 20 / 20p |

Antes las cinco filas eran `·` en la columna de la nómina base.

**Un detalle que apareció al unir las poblaciones.** Los dos padrones comparten
Id de área, y `110` elegía los pares por área: sin más, un colaborador de la
nómina base habría acabado evaluado por pares del padrón demo. Se añadió
`Seeder::padronDe()` y los pares se restringen al mismo padrón. Comprobado que
no queda ninguna evaluación con pares cruzados.

Cinco colaboradores se quedan sin pares y uno sin jefe, y es correcto: el 5 es el
CEO —sin jefe y único en su área— y los otros cuatro son los únicos de la suya.

**Lo que NO cierra B-4.** La nómina base sigue floja en los módulos que dependen
de otros hallazgos: educación y experiencia 2 de 20 (H-04), vacaciones en cero
(H-05), una sola liquidación (H-07) y cinco registros de asistencia (H-08). Esos
siguen abiertos.

---

## Hallazgos por semilla

### ☑ H-01 · `105` · El padrón demo repite los errores de catálogo que se corrigieron en `030`

> **Cerrado** por Iván Velasco el 2026-08-31.

| Campo | Valor sembrado | Filas | Debe ser |
|---|---|---:|---|
| `Modalidad` | `Hibrido` | 16 | `Teletrabajo suplementario` |
| `JornadaLaboral` | `Tiempo completo` | 20 | `8 HORAS` |

Un valor fuera de `GeneralDataArray` no queda seleccionado en el `<select>` del
formulario de colaborador, así que la primera edición desde la interfaz lo pierde.

**Arreglo aplicado.** Los mismos reemplazos que en `030`: `Modalidad` pasa a
`Teletrabajo suplementario` en las 16 filas y `JornadaLaboral` sale de las veinte
filas y se declara una sola vez en `comunes()`, como en `030`.

**Comprobado de paso que el catálogo correcto es el que se estaba usando.** El
`<select>` de `Modalidad` no se pinta con `GeneralDataArray::$modalidades` —que
está en mayúsculas y no contiene esos textos— sino con `$Modalidad`, un mapa
clave⇒valor con exactamente `Teletrabajo Movíl`, `Presencial`,
`Teletrabajo autónomo` y `Teletrabajo suplementario`. Los tres campos se guardan
distinto y conviene no confundirlos:

| Campo | Catálogo | El `<select>` guarda |
|---|---|---|
| `Modalidad` | `$Modalidad` | la clave (que aquí es el propio texto) |
| `ModalidadTrabajo` | `$modalidades_laborales` | el valor |
| `JornadaLaboral` | `$jornadas_laborales` | el valor |

---

### ☑ H-02 · `105`, `220`, `230` · `JornadaLaboral` fuera de catálogo en todos los escenarios

> **Cerrado** por Iván Velasco el 2026-08-31. Las 84 filas pasan a `8 HORAS`.

`'Tiempo completo'` en 20 + 14 + 50 = **84 colaboradores**. El catálogo
`GeneralDataArray::$jornadas_laborales` solo admite `'8 HORAS'` y `'Otro...'`.

**Decisión: reemplazo, no ampliar el catálogo.** `8 HORAS` es la única jornada
de tiempo completo que ofrece la aplicación, `030` ya había sentado ese
precedente y ampliar `GeneralDataArray` sería cambiar la aplicación para que
encaje con la semilla, no al revés. Si el negocio quiere «Tiempo completo» como
opción distinta, eso es un cambio del catálogo de la aplicación y merece su
propia decisión; la semilla no debe adelantarla.

**Arreglo aplicado.** 20 filas en `105` (vía `comunes()`), 14 en `220` y 1 punto
en `230` que cubre sus 50 colaboradores.

**Comprobado el resto del catálogo, no solo este campo.** Se validaron los ocho
campos de `colaboradores` que el formulario pinta como `<select>`, leyendo los
valores admitidos de `GeneralDataArray` en vez de copiarlos:

```
[ok] TipoDocumento     [ok] Genero        [ok] EstadoCivil   [ok] Nacionalidad
[ok] Departamento      [ok] Modalidad     [ok] ModalidadTrabajo   [ok] JornadaLaboral
```

Los seis que no denunciaba el documento ya estaban bien; `Modalidad` y
`JornadaLaboral` eran los únicos fuera de catálogo.

---

### ☑ H-03 · `105`, `220`, `230` · Ninguna escribe `LiderEquipo`

> **Cerrado** por Iván Velasco el 2026-08-31. Ningún jefe queda sin marca en
> ninguno de los cuatro padrones.

La columna decide si el portal muestra el módulo "Mi Equipo". Hoy la base la
tiene bien puesta, pero **ningún seeder la escribe salvo `030`**, así que se
pierde en cuanto se resiembre.

Jefes reales por rango: demo 4, Compañía Real 6, Soluciones Andinas 13.

**Arreglo aplicado.** `conLiderazgoDerivado()` sube de `030` a `Seeder`, y
`Seeder::conJefesDerivados()` lo aplica siempre: se deriva de `IdJefeInmediato`
por la misma razón que las seis columnas de jefe, para que no pueda contradecir a
la jerarquía. `030` y `105` lo heredan sin tocar nada más; `220` y `230` escriben
fila a fila, así que ahora arman la lista completa antes de escribirla y la pasan
por el helper.

| Padrón | Jefes sin marca antes | Ahora |
|---|---:|---:|
| Nómina base | 0 | 0 |
| Padrón demo | 4 | **0** |
| Compañía Real | 6 | **0** |
| Soluciones Andinas | 13 | **0** |

Los líderes de la nómina base siguen siendo los 8 que esperaba este documento:
5, 6, 7, 8, 9, 10, 11 y 13.

**Relacionado, también cerrado.** `cargos.LiderEquipo` estaba en 0 en los 44
cargos. La clasificación vive ahora en `Seeder::cargoLideraEquipo()`, que decide
por la primera palabra del nombre (`CEO`, `Gerente`, `Jefe`, `Director`, `Líder`,
`Coordinador`) y la usan las **dos** semillas que escriben cargos —`021` y
`230`—, para que no diverjan. Resultado: 22 cargos de liderazgo y 22 de
contribución individual.

Se hace en tiempo de siembra a propósito: el docblock de
`CargosModel::esLiderEquipo()` dice que la columna existe para «identificar
líderes sin depender de nombres hardcodeados», y así la aplicación sigue sin
mirar nombres en tiempo de ejecución.

> Nota: hoy ninguna pantalla llama a `CargosModel::esLiderEquipo()` —el menú y
> `MiEquipoController` usan `ColaboradoresModel::esLiderEquipo()`—, así que esto
> no cambia nada visible; deja el dato correcto para cuando se use.

---

### ☑ H-04 · `031` / `032` · Solo 2 de 104 colaboradores tienen hoja de vida

> **Cerrado** por Iván Velasco el 2026-08-31. De 2 y 2 filas a **28 de
> formación y 40 de experiencia**, cubriendo los 20 de la nómina base.

En base limpia: `colaborador_educacion` 2 filas, `colaborador_experiencia` 2.
La base actual tiene 20 y 21 porque se cargaron fuera del pipeline.

La ficha del colaborador queda vacía en las pestañas de formación y experiencia
para el 98% de la nómina.

**Arreglo aplicado.** Ambas cubren la nómina base, el padrón que eligió B-4.

- **`031`**: un pregrado para cada uno y, además, un posgrado para quien dirige
  equipo. El nivel no es aleatorio: se lee de `colaboradores.LiderEquipo`, que
  deriva de `IdJefeInmediato`, así que la hoja de vida no puede contradecir a la
  jerarquía.
- **`032`**: las fechas se calculan **hacia atrás** desde la entrada del
  colaborador a la compañía. Todo empleo anterior está cerrado y termina antes de
  `FechaInicio`, los empleos de una misma persona no se solapan, y ninguno
  empieza antes de que cumpliera 16 años (se recorta o se descarta lo que no
  quepa). Antes una de las dos filas declaraba `FechaRetiro` nula: un empleo
  anterior que nunca terminó, o sea la persona en dos sitios a la vez.

`colaborador_educacion` no tiene columnas de fecha, así que la regla de «ningún
estudio antes de los 15 años» no puede violarse ahí; la de experiencia sí aplica.

**Acotadas a la nómina base a propósito.** Son semillas `0xx` y corren antes que
`105_ColaboradoresDemoSeeder`: si mirasen la población entera, la primera pasada
vería 20 colaboradores y la segunda 40. Es la misma trampa que obligó a mover
`118` y se evita con `Seeder::PADRON_NOMINA_BASE`.

---

### ☑ H-05 · `040` · Nadie siembra saldos ni solicitudes de vacaciones

> **Cerrado** por Iván Velasco el 2026-08-31 con la semilla nueva
> `118_SaldosVacacionesSeeder`: 40 saldos y 58 solicitudes en base limpia,
> con las once invariantes del módulo comprobadas.

`040_PoliticasVacacionesSeeder` siembra la política (15 días/año, hábiles, con
aprobación) y nada más. `saldos_vacaciones` y `solicitudes_vacaciones` no
aparecen en ningún archivo de `database/seeders`.

Las 40 filas de la base actual llegaron por otra vía y además tienen errores:

- **11 solicitudes con `DiasSolicitados` que no son los días hábiles del rango.**
  Ejemplos: la 9005 declara 7 días para `2026-03-30..2026-04-05` (son 3 hábiles);
  la 9015 declara 7 para `2026-06-08..2026-06-14` (son 4).
- **4 saldos donde `DiasDisponibles + DiasTomados ≠ 15`**: colaboradores 2 (11),
  8 (10), 14 (9) y 20 (8).

**Aritmética que debe cumplirse** (según `VacacionesColaboradorController`):

```
DiasSolicitados  = días hábiles del rango, sin fines de semana ni festivos activos
DiasDisponibles + DiasTomados = DiasPorAnio de la política
DiasPendientes   = suma de las solicitudes en estado SOLICITADA
DiasTomados      = suma de las solicitudes en estado APROBADA
```

Ojo con la semántica: radicar una solicitud **solo suma a `DiasPendientes`**;
`DiasDisponibles` no baja hasta que el jefe aprueba. Con 12 disponibles y una
solicitud de 3: `Disponibles 12 / Pendientes 3`, y tras aprobar
`Disponibles 9 / Tomados 3 / Pendientes 0`.

**Arreglo aplicado.** Semilla nueva **`118_SaldosVacacionesSeeder`**. Nada de lo
que escribe es un número puesto a mano:

- `DiasSolicitados` sale de `diasHabiles()`, que replica paso por paso
  `SolicitudesVacacionesModel::calcularDiasSolicitados()` —descarta sábados,
  domingos y festivos activos—. Se replica en vez de invocarse porque el CLI de
  semillas no carga los modelos de la aplicación.
- El saldo se deriva de las solicitudes **que la propia semilla acaba de
  escribir**, no de la plantilla: `DiasTomados` suma las APROBADA,
  `DiasPendientes` suma las SOLICITADA y `DiasDisponibles` es
  `DiasPorAnio - DiasTomados`. La igualdad del anexo sale sola.
- `DiasPorAnio` y `CuentaDiasHabiles` se leen de la política activa.
- Seis situaciones rotadas para que el módulo enseñe los cuatro estados
  (SOLICITADA, APROBADA, RECHAZADA, CANCELADA), con las aprobadas en el pasado y
  las pendientes en el futuro respecto a `FECHA_REFERENCIA`.

Que el cálculo importa se ve en los datos: la solicitud 9028 va del 2026-03-30 al
2026-04-03 —lunes a viernes— pero cae encima de Jueves y Viernes Santo, así que
declara **3** días, no 5. Doce solicitudes se solapan con algún festivo.

**Por qué `118` y no `041`.** La primera versión iba en `041`, como proponía este
documento, y **no era idempotente**: al correr antes que
`105_ColaboradoresDemoSeeder`, la primera pasada solo veía la nómina base y la
segunda veía también el padrón demo, de modo que `seed completo` dos veces
seguidas dejaba 40 saldos donde la primera vez había 20. Medido. Una semilla no
puede depender de si otra posterior ya se ejecutó, así que se movió al grupo de
módulos, después de `105`. La política (`040`) se queda donde está: es un
catálogo y pertenece al perfil `base`.

**Comprobado en base limpia**, con un verificador escrito aparte de la semilla:

```
[ok] DiasSolicitados = dias habiles del rango (58 solicitudes)
[ok] DiasDisponibles + DiasTomados = DiasPorAnio
[ok] DiasTomados = suma de las APROBADA
[ok] DiasPendientes = suma de las SOLICITADA
[ok] DiasPendientes no supera a DiasDisponibles
[ok] Solicitudes que se cruzan entre si (estados que bloquean)
[ok] APROBADA con fecha de inicio en el futuro
[ok] SOLICITADA con fecha de inicio en el pasado
[ok] Solicitudes fuera del anio del saldo
[ok] Rastro incoherente con el estado
[ok] Saldos o solicitudes de colaborador inexistente
```

---

### ☑ H-06 · `120` · Se paga auxilio de transporte por encima del tope

> **Cerrado** por Iván Velasco el 2026-08-31. El auxilio se paga solo por
> debajo de `TOPE_INGRESO_AUX`, para toda la población y no solo en la
> liquidación de ejemplo.

La liquidación 9001 es de Juan Pérez (salario 3.500.000) e incluye
`AUX_TRANS` por 162.000. El propio seeder define
`TOPE_INGRESO_AUX = 2.847.000`, así que no le corresponde.

```php
// 120_NominaSeeder.php
array('Id' => 9004, 'Clave' => 'TOPE_INGRESO_AUX', 'Valor' => '2847000', ...)
// pero:
array('Id' => 9002, 'LiquidacionId' => 9001, 'ConceptoId' => 9002, 'Valor' => 162000, ...)
```

**Arreglo aplicado.** Ninguna de las dos opciones por separado: al liquidar
toda la población (H-07) la regla se aplica a cada colaborador, así que el tope
deja de ser un caso particular. Juan Pérez (3.500.000) ya no cobra el auxilio y
Carlos Rodríguez (2.800.000) sí, que es justo el colaborador que cita el ticket 3
de `130_ServicioColaboradorSeeder`: los dos módulos cuentan lo mismo.

La línea del detalle se escribe igualmente con valor 0 —como haría el calculador
de la aplicación, que emite una fila por concepto activo— y explica por qué:

```
AUX_TRANS | Base 162000.00 | Valor 0.00 | BASE=PARAM:AUX_TRANSPORTE; no aplica: salario por encima de TOPE_INGRESO_AUX
```

Solo 2 de los 40 colaboradores de la población quedan por debajo del tope. Es
correcto: el tope son dos salarios mínimos y los salarios sembrados son altos.

---

### ☑ H-07 · `120` · Una sola liquidación y un solo periodo de nómina

> **Cerrado** por Iván Velasco el 2026-08-31. De 1 liquidación y 5 líneas de
> detalle a **80 liquidaciones y 400 líneas** en dos periodos recientes.

En base limpia: 1 liquidación, 5 líneas de detalle, 2 novedades, 1 periodo
(febrero 2026). El módulo de nómina no tiene nada que mostrar.

Además el periodo sembrado es `2026-02-01..2026-02-28`, seis meses atrás.

**Arreglo aplicado.** `120_NominaSeeder` reescrita. Dos periodos recientes
respecto a `FECHA_REFERENCIA = 2026-08-31` —julio **cerrado**, para que haya
historia, y agosto **en revisión**, que es el que enseña el flujo— y ambos
liquidados para toda la población.

Ninguna cifra se escribe a mano: cada liquidación se calcula con las reglas de
`NominaCalculoModel::recalcularColaborador()`, leyendo el salario del colaborador
y los porcentajes de `nomina_parametros`. `DiasLiquidados` replica
`calcularDiasLiquidados()`. El Id de la liquidación no se fija: la clave de
negocio de `nomina_liquidaciones` es `(PeriodoId, ColaboradorId)`, así que lo
pone la tabla y se recoge de vuelta para colgar el detalle.

De paso, `Origen` de las novedades pasa de `DUMMY` a `MANUAL`, que es lo que
escribe `NominaController` y lo que se ve en la columna «Origen» de la pantalla
del periodo.

| | Antes | Ahora |
|---|---:|---:|
| `nomina_periodos` | 1 (feb, 6 meses atrás) | 2 (jul y ago) |
| `nomina_liquidaciones` | 1 | 80 |
| `nomina_liquidacion_detalle` | 5 | 400 |
| `nomina_novedades` | 2 | 28 |

**Comprobado en base limpia**, con un verificador escrito aparte de la semilla:

```
[ok] NetoPagar = TotalDevengado - TotalDeduccion
[ok] El detalle suma TotalDevengado de la cabecera
[ok] El detalle suma TotalDeduccion de la cabecera
[ok] SALUD = PORC_SALUD % del salario
[ok] PENSION = PORC_PENSION % del salario
[ok] AUX_TRANS pagado por encima del tope
[ok] AUX_TRANS NO pagado estando por debajo del tope
[ok] SALARIO_BASE distinto del salario del colaborador
[ok] BONO que no corresponde a una novedad del periodo
[ok] Liquidaciones sin las 5 lineas de detalle
[ok] Periodos anteriores a 2026-06 (el demo es de agosto)
[ok] Liquidaciones de colaborador inexistente
```

---

### ☑ H-08 · `125` · Registros de asistencia imposibles y solapados

> **Cerrado** por Iván Velasco el 2026-08-31. De 5 registros para 2 personas
> a **383 para 40**, en los últimos 10 días hábiles.

Sobre la base actual:

- **Registro 9103: jornada de −45 minutos** (la salida es anterior a la entrada
  una vez descontado el almuerzo).
- **2 días marcados como trabajados dentro de una ausencia aprobada.**
- 1 registro en fin de semana, 1 sin hora de salida con estado distinto de
  `ausente`/`permiso`.

En base limpia solo hay 5 registros y 1 ausencia, así que además aplica H-04/B-3.

**Dos de los cuatro síntomas no eran defectos.** Contrastados con el modelo:

- `Estado = 'inconsistente'` con la salida antes de la entrada es exactamente lo
  que escribe `AsistenciaRegistrosModel::marcarSalida()` en ese caso, y
  `calcularMinutosTrabajados()` devuelve 0, no un número negativo.
- Un registro sin salida en estado `pendiente` es lo que crea `marcarEntrada()`.

Lo que sí había que arreglar: el volumen, la antigüedad del periodo y los días
trabajados dentro de una ausencia aprobada.

**Arreglo aplicado.** `125` genera un calendario real de los últimos 10 días
hábiles hasta `FECHA_REFERENCIA`, descartando en este orden: fines de semana,
festivos activos, días cubiertos por una **ausencia aprobada** y días cubiertos
por una **solicitud de vacaciones aprobada** —esto último no lo pedía el
hallazgo, pero es la misma contradicción—. `getEstadoDiaVirtual()` ya resuelve
esos días sin necesidad de una fila, así que la semilla no la crea.

`HoraSalidaReal` sale de la entrada más los minutos programados del turno más el
almuerzo, de modo que `calcularMinutosExtra()` devuelva justo los minutos que la
semilla escribe en `asistencia_horas_extra`. Todos los colaboradores tienen turno
vigente durante el periodo, así que `calcularMinutosProgramados()` no devuelve
cero para ninguno.

> Detalle del modelo que conviene conocer: `calcularMinutosExtra()` compara los
> minutos trabajados —que sí descuentan el almuerzo— contra los minutos
> programados del turno, que **no** lo descuentan. La semilla sigue esa
> aritmética para que las cifras cuadren con lo que recalcularía la aplicación.

Resultado: 383 registros para 40 personas del 2026-08-18 al 2026-08-31, 16
ausencias y 22 tramos de horas extra. Catorce invariantes comprobadas, incluidas
las cuatro del enunciado.

---

### ☑ H-09 · `140` · Certificados emitidos sin aprobar el quiz

> **Cerrado** por Iván Velasco el 2026-08-31.

Inscripciones **3, 7 y 10** tienen `CodigoCertificado` con `AprobadoQuiz = 0`.

**Matiz importante:** solo el curso 1 tiene quiz (`PuntajeMinimo` 70). Las tres
inscripciones certificadas eran de los cursos 3, 2 y 5, que no tienen quiz, así
que `AprobadoQuiz = 0` era técnicamente correcto —pero el módulo no enseñaba en
ningún sitio el camino «curso con quiz, aprobado, certificado».

**Arreglo aplicado**, tres cambios y un tercer problema que el enunciado no
recogía:

1. `AprobadoQuiz` y `PuntajeQuiz` dejan de depender de un `cursoId === 1` escrito
   a mano y se derivan de **si el curso tiene quiz**, leyéndolo de
   `capacitacion_quiz`. El puntaje queda diez puntos por encima del mínimo del
   curso, nunca por debajo.
2. Una de las tres inscripciones completadas pasa al curso 1, para que exista el
   caso «quiz aprobado y certificado».
3. Los certificados se emiten solo a inscripciones completadas que **o** no
   tienen quiz **o** lo aprobaron.
4. **Lo que no estaba en el hallazgo:** los dos intentos de quiz (60 fallido y 80
   aprobado) colgaban de la inscripción 1, que estaba en estado `asignado` con
   progreso 0 — alguien había aprobado el quiz de un curso que no había empezado.
   Ahora cuelgan de una inscripción completada y con el quiz aprobado, y el
   puntaje del intento aprobado coincide con el que declara la inscripción.

**Y un problema de orden:** `seedQuiz()` corría **después** de
`seedInscripciones()`, así que en una base limpia la tabla de quiz estaba vacía
cuando se decidía `AprobadoQuiz` y ninguna inscripción lo aprobaba. Se movió
antes. Es la tercera vez en esta revisión que el orden de ejecución cambia
silenciosamente el resultado.

Once invariantes comprobadas, incluida la del enunciado sobre
`ProgresoPorcentaje` contra `capacitacion_leccion_progreso`.

---

### ☑ H-10 · `145` · `PorcentajeAvance` inventado

> **Cerrado** por Iván Velasco el 2026-08-31. El avance se deriva de las
> etapas en las tres semillas que escriben planes.
>
> **Corrección al enunciado original.** El título decía «86 planes con 0
> etapas completadas» y no era cierto: la consulta del anexo comparaba
> `pe.Estado = 'completada'` y el valor que escriben las semillas y espera
> el controlador es `'completado'`. Con el valor correcto la base de trabajo
> tiene **68 etapas completadas**. Lo que sí se confirma es el fondo del
> hallazgo: de los 86 planes, **84 declaraban un avance que no correspondía
> a sus etapas**; solo 2 cuadraban.

Los porcentajes son una rampa aritmética (25, 37, 49, 61, 73, 85, 27, 39…) sin
relación con las etapas. **Los 86 planes tienen 0 de sus etapas en `completada`**,
y aun así 14 de ellos declaran 100%.

```
plan 1  (colab 1)   avance 25%   0/4 etapas completadas
plan 110 (colab 16) avance 100%  0/3 etapas completadas
plan 124 (colab 310) avance 100% 0/3 etapas completadas
```

Es el mismo problema que tenían los minutos de SLA en `130`: un número decorativo
que la interfaz presenta como calculado.

**Arreglo aplicado.** `Seeder::avanceDeEtapas()` implementa la regla de
`PlanesCarreraController::actualizarAvanceEtapa()` —`completadas / total * 100`,
contando como completada la etapa cuyo `Estado` es exactamente `completado`— y
las tres semillas que escriben planes la usan. En las tres el orden se invierte:
**primero se deciden las etapas y después se escribe el plan** con el avance que
sale de ellas, de modo que la cifra no puede volver a divergir.

| Semilla | Antes | Ahora |
|---|---|---|
| `145` | rampa `25 + i*12` (25, 37, 49, 61, 73) con 1 de 4 etapas cerradas en todos | 1 a 3 etapas cerradas de 4, avance derivado |
| `220` | rampa `20 + idx*8` | 0 a 3 etapas cerradas de 3, avance derivado |
| `230` | 18 valores a mano, sin etapas (ver H-11) | etapas de la ruta, avance derivado |

Los porcentajes que quedan son exactamente los alcanzables con 3 o 4 etapas:

| Avance | Planes |
|---:|---:|
| 0,00 | 7 |
| 25,00 | 4 |
| 33,33 | 14 |
| 50,00 | 4 |
| 66,67 | 4 |
| 75,00 | 1 |
| 100,00 | 3 |

Antes había 14 planes al 100% sin ninguna etapa que lo respaldara; ahora los 3
que llegan al 100% tienen todas sus etapas cerradas y el plan queda en estado
`completado`.

---

### ☑ H-11 · `230` · Los planes de Soluciones Andinas no tienen etapas

> **Cerrado** por Iván Velasco el 2026-08-31. Los 18 planes tienen ahora sus
> etapas. (El hallazgo estaba anotado en `145` y en el enunciado se citaba
> también a `220`: los planes sin etapas eran solo los de `230`; `220` sí
> escribía las suyas.)

18 planes (Ids 50–67, de Soluciones Andinas) declaran avance entre 8% y 60% y
**no tienen ninguna fila en `carrera_plan_etapas`**. La vista de detalle del plan
sale vacía.

**Arreglo aplicado.** `230` genera las etapas de cada plan leyendo las de su
ruta con `etapasDeRuta()`, en vez de repetirlas en el archivo: `seedRutasCarrera()`
acaba de escribirlas y así un cambio en la ruta no deja los planes descuadrados.

El avance que traía la lista a mano no se tira: pasa a llamarse `AvanceObjetivo`,
**no se escribe en la tabla** y solo sirve para decidir cuántas etapas se dan por
cerradas (`round(objetivo/100 * total)`). El `PorcentajeAvance` real se recalcula
de esas etapas. Así se conserva la intención —quién va más adelantado— y la cifra
deja de ser inventada.

De 0 a **122 etapas de plan** en base limpia, 46 de ellas completadas.

---

### ☑ H-12 · `220` · Cinco colaboradores son su propio mentor

> **Cerrado** por Iván Velasco el 2026-08-31. Ningún plan tiene
> `MentorId = ColaboradorId`, y el arreglo aguanta cuando se corrija H-16.

Planes 114–118 (colaboradores 300–304) tienen `MentorId = ColaboradorId`.
`MentorId` es una clave foránea a `colaboradores` y
`CarreraPortalController.php:101` lo resuelve con `ColaboradoresModel::getById()`,
así que el portal muestra al colaborador como mentor de sí mismo.

```php
// 220_CompaniaRealColombiaSeeder.php:739
'MentorId' => $colaboradores[($idx % 5)],
```

**Arreglo aplicado.** `Seeder::mentorDe($idColaborador, $propuestos)`: devuelve
el primer mentor propuesto que no sea el propio colaborador y, si no hay ninguno
válido, su `IdJefeInmediato`. Nunca devuelve al colaborador.

El orden importa y por eso los tres sitios lo llaman distinto:

- `220` y `145` no tienen criterio propio y lo llaman **sin propuestos**, así que
  se quedan con el jefe inmediato, que es el mentor natural.
- `230` sí eligió a mano el mentor de cada uno de sus 18 planes, y lo pasa como
  propuesto para que se respete. Comprobado: el plan 50 sigue con el mentor 209 y
  el 51 con el 210, como declaraba la lista.

**Ojo con el orden de los arreglos.** Hoy el síntoma no se reproduce en base
limpia, pero solo por accidente: `220` toma sus colaboradores con
`getIds('colaboradores', 50)`, que devuelve la nómina base en vez de su propio
padrón (**H-16**). Al corregir H-16, `$colaboradores[($idx % 5)]` habría vuelto a
señalar a los cinco primeros de su propio padrón —que es justo lo que este
hallazgo describe—. Con `mentorDe()` sin propuestos eso ya no puede pasar, sea
cual sea la población.

---

### ☑ H-13 · `170` · Un adelanto desembolsado sin plan de cuotas

> **Cerrado** por Iván Velasco el 2026-08-31.

`ADN-2026-000005` está en estado `desembolsada` y no tiene filas en
`adelanto_cuotas`. El resto sí cuadra: las cuotas suman el monto aprobado y
ningún aprobado supera lo solicitado.

**Arreglo aplicado.** Las tres tablas que dependen del estado —plan de
descuento, cuotas y desembolso— eran listas fijas que cubrían las solicitudes 3,
6, 7, 9 y 10. Ahora se **derivan** del estado de cada solicitud:

- plan y cuotas para `aprobada`, `desembolsada`, `en_descuento` y `descontada`
  (el estado real es `descontada`, no `pagada`);
- desembolso para `desembolsada`, `en_descuento` y `descontada` — **las
  solicitudes 5 y 7 tampoco lo tenían**, pese a estar desembolsada y descontada;
- las cuotas reparten el monto aprobado en valores enteros cuya suma es
  exactamente el monto: la última absorbe el redondeo;
- el estado de cada cuota lo decide su propia fecha, y los meses se eligen para
  que concuerden con el estado de la solicitud (todas pasadas si está
  `descontada`, la primera pasada si está `en_descuento`, todas futuras si solo
  está aprobada o desembolsada).

**De paso, la reproducibilidad.** Las fechas salían de `date()` en tiempo de
ejecución (`-{$i} days`), así que sembrar dos días distintos daba datos
distintos, y un adelanto ya descontado quedaba *solicitado después* de haberse
descontado. Todo cuelga ahora de `FECHA_REFERENCIA`, y la solicitud es siempre
anterior a su primera cuota.

Trece invariantes comprobadas.

---

### ☑ H-14 · `210` · Dos metas sin periodo rompen la suma de pesos

> **Cerrado** por Iván Velasco el 2026-08-31 eliminando las dos metas.

Las metas 96001 y 96002 (colaboradores 20001 y 20002) tienen `IdPeriodo = NULL` y
`Peso = 50`. Son los únicos dos grupos colaborador/periodo cuyos pesos no suman
100; las 60 metas del periodo 1 sí lo hacen.

Una meta sin periodo no aparece en ninguna vista filtrada por periodo, pero sí
descuadra el consolidado.

**Ninguna de las dos opciones propuestas servía**, y comprobarlo fue el arreglo:

- **Asignarles el periodo 1** deja a los colaboradores 20001 y 20002 con **150**
  de peso, porque `110_Evaluacion360Seeder` ya les da tres metas que suman 100 en
  ese periodo. Medido en base limpia.
- **Subirles el peso a 100** arregla la aritmética pero los deja igual de
  invisibles, que es el otro problema que este mismo hallazgo describe.

**Arreglo aplicado: se eliminan.** Son duplicados estrictamente peores de lo que
ya existe: 20001 y 20002 tienen su ciclo completo de metas —con seguimientos y
consolidado— en el periodo 1. Estas dos no tenían seguimientos, no entraban en el
consolidado y no se veían en ninguna vista filtrada por periodo.

Con esto el verificador de cobertura de B-4 queda **entero en verde**: era el
único de sus once comprobaciones que seguía fallando.

> Las semillas no borran, así que en una base ya sembrada las dos filas siguen
> ahí hasta que se resiembre desde cero. Para limpiarlas sin resembrar:
> `DELETE FROM metas WHERE Id IN (96001, 96002);`

El rango de Id `96001-96002` se quitó también de la tabla de rangos de
`database/README.md`.

---

### ☐ H-17 · aplicación · `NominaCalculoModel` ignora `TOPE_INGRESO_AUX`

Destapado al cerrar H-06. **No es un problema de las semillas**, pero condiciona
lo que enseña el demo, así que queda anotado aquí.

`TOPE_INGRESO_AUX` no se lee en ningún punto de la aplicación: la única
aparición en todo el repositorio está en `120_NominaSeeder`. El concepto
`AUX_TRANS` se resuelve con `Formula = 'AUX_TRANSPORTE'` y
`BaseCalculo = 'PARAM:AUX_TRANSPORTE'`, de modo que
`NominaCalculoModel::calcularValorConcepto()` devuelve siempre los 162.000, sea
cual sea el salario.

Consecuencia práctica: la semilla escribe la cifra correcta, pero **pulsar
«recalcular» en la interfaz volvería a pagar el auxilio a los 38 colaboradores
que están por encima del tope**, y las cifras del demo cambiarían en pantalla.

El calculador no puede expresar la regla tal como está: `safeEvalFormula()` solo
admite `[0-9.+-*/() ]` después de sustituir las variables, así que no hay forma
de escribir una condición sobre el salario. `TopeMax` del concepto tampoco sirve:
acota el valor, no lo condiciona al ingreso.

**Arreglo.** Aplicar el tope en `NominaCalculoModel`, junto a la resolución del
concepto `AUX_TRANS`. Es trabajo de la aplicación, no de las semillas.

---

### ☑ H-16 · `220` · Siembra potencial y sucesión sobre la nómina base, no sobre su propio padrón

> **Cerrado** por Iván Velasco el 2026-08-31. Compañía Real pasa de 0 a **14
> evaluaciones de potencial y 14 planes de carrera** en su propio padrón.

Destapado al verificar B-1: con las semillas ya corriendo sin error, las 14
evaluaciones de potencial de `220_CompaniaRealColombiaSeeder` caen en
colaboradores de la nómina base y **ninguna en los 300–313 que la semilla crea**.

```php
// 220_CompaniaRealColombiaSeeder.php:26
$nuevosColaboradores = $this->getIds('colaboradores', 50);
// ...
// 220_CompaniaRealColombiaSeeder.php:765
$nuevosIds = array_slice($colaboradores, 5, 14);
```

`getIds()` devuelve los 50 Id **menores** de la tabla, que en el orden de
ejecución del perfil completo son los 20 de la nómina base más los 200–229 de
Soluciones Andinas. El padrón propio de la semilla (300–313) queda fuera del
`array_slice`. Reparto en base limpia:

| Padrón | Filas en `potencial_evaluaciones` |
|---|---:|
| Nómina base 1–20 | 19 |
| Soluciones Andinas 200–249 | 50 |
| **Compañía Real 300–313** | **0** |

Afecta igual a `seedSucesionParaNuevosColaboradores()`, que parte del mismo
arreglo. Es la misma clase de error que H-12: la semilla elige colaboradores por
posición en una lista en lugar de por su padrón.

**Arreglo aplicado.** `220` lee su propio padrón con `colaboradoresDelEscenario()`,
que consulta el rango de Id del escenario (`300-349`) en vez de tomar los 50 Id
menores de la tabla. Los tres `array_slice($colaboradores, 5, 14)` desaparecen:
la lista que reciben las tres sub-semillas **ya es** el padrón.

Se leen de la tabla y no de una lista repetida en el archivo, para que un alta o
una baja en `seedColaboradoresRealCompania()` no deje el reparto desincronizado.

| Padrón | Potencial antes | Ahora | Planes antes | Ahora |
|---|---:|---:|---:|---:|
| Nómina base | 19 | 10 | 19 | 5 |
| **Compañía Real** | **0** | **14** | **0** | **14** |
| Soluciones Andinas | 50 | 50 | 18 | 18 |

La nómina base baja a lo que le da `145`, que es lo que le corresponde.

---

### ☐ H-15 · `DemoReadinessVerifier` da luz verde sobre una base con todos estos huecos

`php bin/seed --verify-demo` responde **"Resumen: demo completo listo"** sobre la
base actual, con los 14 hallazgos anteriores presentes.

La razón es que solo comprueba que ciertas tablas **no estén vacías**, y para
Vacaciones la única tabla que mira es la de la política:

```php
// database/DemoReadinessVerifier.php
'Vacaciones' => array('politicas_vacaciones'),
'Carrera y talento' => array('carrera_rutas', 'potencial_criterios',
                             'sucesion_puestos_clave', 'reconocimiento_tipos'),
```

Es decir: da verde con `saldos_vacaciones` en 0 filas, con
`potencial_evaluaciones` en 0 y con `sucesion_candidatos` en 0, porque ninguna de
esas tablas está en la lista. Tampoco mira nunca **de quién** son las filas, que
es justo el problema de B-4.

**Riesgo.** Es la herramienta que el equipo usaría para dar por listo el demo, y
hoy no detecta ninguno de los bloqueantes.

**Arreglo.** Añadir a `requirements()` las tablas transaccionales que el demo
necesita (`saldos_vacaciones`, `solicitudes_vacaciones`, `potencial_evaluaciones`,
`sucesion_candidatos`, `carrera_plan_colaborador`, `colaborador_educacion`,
`colaborador_experiencia`, `planes_desarrollo`) y, sobre todo, una comprobación de
cobertura: que el colaborador vitrina elegido en B-4 tenga al menos una fila en
cada módulo.

---

## Ya cerrados

### ✅ `030_ColaboradoresSeeder`

- `Departamento` `BOGOTA D.C.` → `BOGOTÁ D.C.` (12 filas fuera de catálogo).
- `ModalidadTrabajo` `MANANA` → `MAÑANA`, `DIRECTIVO` → `FLEXIBLE`.
- `Modalidad` `Hibrido` → `Teletrabajo suplementario`, `Remoto` → `Teletrabajo autónomo`.
- `JornadaLaboral` → `8 HORAS`, movido a `comunes()`.
- Ciclo de jefes: Juan (1) y María (2) eran jefes el uno del otro.
- `IdCeco` de Laura (4) apuntaba a Talento Humano estando en Operaciones.
- `LiderEquipo` derivado de `IdJefeInmediato`: 8 líderes (5, 6, 7, 8, 9, 10, 11, 13).

### ✅ `105_ColaboradoresDemoSeeder`

- `Modalidad` `Hibrido` → `Teletrabajo suplementario` (16 filas).
- `JornadaLaboral` → `8 HORAS`, movido a `comunes()` como en `030`.
- `LiderEquipo` derivado: sus 4 jefes recuperan el módulo «Mi Equipo».

### ✅ `230_SolucionesAndinasSeeder`

- `JornadaLaboral` → `8 HORAS` en sus 50 colaboradores.
- `LiderEquipo` derivado para sus 13 jefes.
- `PeriodoId` fijo a 10 que no existía en `periodos` (ver B-1).
- Sus 18 planes de carrera no tenían etapas (ver H-11).
- Escribe `cargos.LiderEquipo` en los cargos que crea.

### ✅ `021_CargosSeeder`

- Los 44 cargos estaban con `LiderEquipo = 0`. Ahora la clasificación sale de
  `Seeder::cargoLideraEquipo()`, compartida con `230`: 22 de liderazgo y 22 de
  contribución individual.

### ✅ `145_CarreraTalentoSeeder`

- `PorcentajeAvance` era la rampa `25 + i*12`, sin relación con las etapas. Ahora
  se deriva con `Seeder::avanceDeEtapas()`, la regla del controlador.
- Los cinco planes tenían la misma etapa cerrada; ahora avanzan de forma
  distinta y el plan al 100% queda en estado `completado`.
- El mentor sale de `Seeder::mentorDe()` y nunca es el propio colaborador.

### ✅ `120_NominaSeeder`

- Pagaba auxilio de transporte por encima del tope que la propia semilla define.
  Ahora la regla se aplica a toda la población.
- Una sola liquidación en un periodo de seis meses atrás: ahora dos periodos
  recientes (julio cerrado, agosto en revisión) con 80 liquidaciones.
- Las cifras se calculan con las reglas de `NominaCalculoModel`, no a mano.
- `Origen` de las novedades pasa de `DUMMY` a `MANUAL`, que es lo que se ve en
  pantalla y lo que escribe la aplicación.

### ✅ `118_SaldosVacacionesSeeder` (nueva)

- Nadie sembraba `saldos_vacaciones` ni `solicitudes_vacaciones`: el módulo salía
  vacío en base limpia.
- `DiasSolicitados` se calcula con la regla real de días hábiles, festivos
  incluidos; el saldo se deriva de las solicitudes escritas.

### ✅ `110_Evaluacion360Seeder`

- Seis copias literales del rango `20000–30000` sustituidas por
  `Seeder::filtroPoblacionDemo()`, que ahora incluye la nómina base.
- Los pares se elegían solo por área, y los dos padrones comparten Id de área:
  se restringen al mismo padrón con `Seeder::padronDe()`.

### ✅ `115_PlanesDesarrolloSeeder`

- Sembraba dos planes fijos sobre los colaboradores 1, 2 y 3. Ahora genera un
  plan por colaborador de la población, con cuatro perfiles rotados.
- `ProgresoPorcentaje` se deriva de las acciones en vez de declararse.
- Toda acción `Completada` que exige evidencia la tiene, y ningún plan queda sin
  acciones.
- `IdEvaluacion` e `IdMeta` apuntan a las filas del propio colaborador, y
  `MotivoCreacion` concuerda con ellas.

### ✅ `025_FestivosSeeder`

- Declaraba `Id` fijos pese a que su clave es `Fecha`; chocaba con los Id que la
  migración `000094` asigna por AUTO_INCREMENT. Ahora no declara `Id`.
- Declaraba 3 de los 18 festivos: el calendario real lo ponía una migración.
  Ahora la semilla es dueña del calendario completo.

### ✅ `130_ServicioColaboradorSeeder`

- Datos personales inventados (cédulas, `jmartinez@empresa.com`, un jefe
  inexistente): ahora todo se lee de la fila real de `colaboradores`.
- `MinutosPrimeraRespuesta` era la constante 45; `MinutosResolucion` una fórmula
  (`diasAtras * 60 + 120`) sin relación con `FechaCierre`; `SlAEstado` no seguía
  la política. Todo se deriva ahora de la conversación.
- Calificación y comentario de satisfacción se indexaban con fórmulas distintas.
- Tickets `nuevo` con responsable asignado, ticket `asignado` con respuesta del
  agente, primer comentario distinto de la descripción.
- Fechas prometidas en el pasado: todo cuelga de `FECHA_REFERENCIA = 2026-08-31`.
- El ticket de vacaciones cita el saldo real y la aritmética de la política.

---

## Orden sugerido

1. ~~**B-1** y **B-2**~~ — cerrados. La instalación limpia ya es
   `migrate up` + `seed completo`, así que a partir de aquí cada arreglo se puede
   validar en base limpia.
2. ~~**B-4**~~ — cerrado con la opción B: el demo se hace con la nómina base,
   y ya tiene metas, evaluaciones, competencias y planes de desarrollo.
3. ~~**H-05**, **H-06**, **H-07**~~ — cerrados. Vacaciones y Nómina eran los
   módulos más vacíos y los que más cifras muestran en pantalla.
4. ~~**H-10**, **H-11**, **H-12**~~ — cerrados. Carrera y Talento ya no muestra
   porcentajes que no corresponden a nada.
5. ~~**H-01**, **H-02**, **H-03**~~ — cerrados. Catálogos y liderazgo quedan
   consistentes en los cuatro padrones.
6. ~~**H-04**, **H-08**, **H-09**, **H-13**, **H-14**, **H-16**~~ — cerrados.
7. **H-15** — endurecer el verificador para que estos huecos no vuelvan a pasar
   desapercibidos.
8. Volver a correr la auditoría y cerrar el documento.

---

## Anexo: cómo repetir la auditoría

**Cobertura e integridad sobre la base actual** — solo lectura:

```sql
-- huérfanos: filas que apuntan a un colaborador inexistente
SELECT COUNT(*) FROM <tabla> t
LEFT JOIN colaboradores c ON c.Id = t.<ColumnaColaborador>
WHERE t.<ColumnaColaborador> IS NOT NULL AND c.Id IS NULL;

-- valores fuera de catálogo
SELECT Modalidad, COUNT(*) FROM colaboradores GROUP BY Modalidad;
SELECT JornadaLaboral, COUNT(*) FROM colaboradores GROUP BY JornadaLaboral;

-- jefes que no verán "Mi Equipo"
SELECT c.Id FROM colaboradores c
WHERE c.LiderEquipo = 0
  AND EXISTS (SELECT 1 FROM colaboradores d WHERE d.IdJefeInmediato = c.Id);

-- vacaciones: el saldo no cuadra con la política
SELECT s.IdColaborador, s.DiasDisponibles, s.DiasTomados
FROM saldos_vacaciones s JOIN politicas_vacaciones p ON p.Id = s.IdPolitica
WHERE s.DiasDisponibles + s.DiasTomados <> p.DiasPorAnio;

-- carrera: avance declarado contra etapas completadas
SELECT pc.Id, pc.PorcentajeAvance,
       SUM(pe.Estado = 'completada') AS completadas, COUNT(pe.Id) AS total
FROM carrera_plan_colaborador pc
LEFT JOIN carrera_plan_etapas pe ON pe.PlanId = pc.Id
GROUP BY pc.Id, pc.PorcentajeAvance;
```

**Reproducibilidad** — en una base **temporal**, nunca sobre la de trabajo:

```bash
mysql -e "CREATE DATABASE kuorum_auditoria_tmp CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
# apuntar DB_NAME del .env a la base temporal
php bin/migrate up      # 42 migration(s) executed
php bin/seed completo   # 38 ejecutada(s), 0 omitida(s), 0 con error
# comparar conteos contra la base real, restaurar .env y borrar la temporal
mysql -e "DROP DATABASE kuorum_auditoria_tmp"
```

**Herramientas del propio proyecto**, que conviene dejar en verde:

```bash
php bin/seed --validate      # revisión estática, no toca la base
php bin/seed --verify-demo   # cobertura por módulo (hoy poco exigente: ver H-15)
php vendor/bin/phpunit tests # 41 tests, hoy en verde
```
