# Estado del Dashboard Talento Humano — pendientes y bitácora

> Copia versionada de la memoria de trabajo del proyecto (`~/.claude/.../memory/PENDIENTES.md`).
> Tareas pendientes y registro de cambios, ordenado de más reciente a más antiguo.
> Última sincronización: 2026-09-16.

---


## 📌 Sesión 2026-09-15/16 — FC Zona G (FCZN) cargado completo + columna Salario + fix Payroll

### FC Zona G — alta de la propiedad y carga histórica completa ✅
- **Alta del hotel (2026-09-15):** NIT `999999001` (placeholder — no es un NIT DIAN real, confirmar si NominaApp llega a activarlo algún día), referencia interna "LIV65". Sin API de nómina/P&G — todo histórico se carga manual. Dado de alta en `empresas` (nit, notas) y `hotel_unificacion` (`nombre_personalizado='FC Zona G'`, sin `hotel_name_api`) vía `Unificacion_Hoteles.xlsx` + `/proxy/admin/sync-hoteles`. Sacado de las 5 listas `EXCL`/`EXCL_H` en `servidor.py` (antes excluido junto a ELMA/FNS).
- **Empleados — 224 filas, 51 personas, ene-ago 2026.** Fuente: 8 Excel mensuales de Zeus (`Base de Datos - Maestro de Contrato Zeus/Zona G Liv`), en **2 formatos distintos** (limpio ene-abr; reporte nativo Zeus may-ago con desfase de 1 columna en Sueldo, detectado y corregido). Fecha de nacimiento/sexo cruzados desde `Listado Empleados x Razon Social/FCZN.xlsx` (100% match, 51/51). Verificado en navegador: filtro "FC Zona G" agosto → 29 empleados, tabla completa OK.
- **Ingresos y Retiros — 43 movimientos** (35 de los 8 archivos de `Base de Datos - Liquidación de Contrato/ZONA G` + 8 encontrados/corregidos cruzando contra el archivo de nómina). Reconciliación mes a mes no cuadra 100% exacto (ej. mayo con diferencia real) — se determinó que el snapshot mensual de empleados no representa estrictamente "día 1" (Zeus parece capturar "activo en algún punto del mes"), es una ambigüedad de la fuente, no un hueco de información. Decisión: cargar datos reales confirmados, no forzar más ajustes sin evidencia.
  - **Ene-abr tiene un hueco real:** la fuente ("Liquidación de Contrato") solo trackea salidas, no entradas — 7 ingresos de marzo/abril no tenían registro en IR hasta que se cruzaron contra el archivo de nómina (que sí trae Fecha Ingreso/Retiro confirmadas).
- **Nómina detallada — 234 filas empleado-mes** (52 personas × ene-ago) desde `Base de Datos - Payroll Zeus-Buk/.../Nomina FC ZONA G 2026.xlsx` (20 hojas, una por mes, **con distinto layout de columnas entre meses** — mapeo por nombre de columna, no índice fijo). Mapeo verificado matemáticamente: `Seguridad Social` (agregado) = Salud+Pensión+Riesgo+Caja Compensación P&G; `Total Provisiones` = Cesantías+Prima+Vacaciones+Intereses P&G. Se insertaron los 4 tipos de fila (`respuesta`/`resumen`/`concepto`/`empleado`) igual que las demás propiedades — 503 filas totales. **Verificado en navegador:** panel "Nómina histórica" de la hoja de vida funciona perfecto (cuadra exacto: $2.060.033 devengado − $171.464 deducido = $1.888.569 neto).
- **Fix de código — Payroll no mostraba FCZN ("sin NIT configurado"):** `/proxy/nomina/hoteles-nit` exigía `hotel_name_api IS NOT NULL`, un requisito de cuando Payroll solo podía usar la API en vivo — campo que el frontend **no usa en ningún lado** (grep confirmado). Se quitó del WHERE. Verificado: `/proxy/nomina/reporte` para FCZN agosto ya responde desde caché (gasto total personal $121.428.016). Commits: dev `9076e98` → main `3f6d9d7`, ambos servidores reiniciados.
- **Hallazgo pasivo (no bug):** al cargar Payroll con todos los hoteles, el sistema de "conceptos sin clasificar" (ya existente) detectó correctamente los códigos nuevos de FCZN (`SS_SS_PENSION`, `PROV_CES`, `DV81`, etc.) — funcionando como debe. Si se quiere que se agrupen igual que las demás propiedades: `/regenerar-conceptos` → revisar Excel → `/sync-conceptos`.
- Todo cargado directo a la BD compartida — no requiere nada en el Mac mini salvo el próximo `git pull` para llevar el código (`servidor.py`, exclusiones + fix Payroll).

### Nueva columna "Salario" en la tabla del tab Empleados (2026-09-15) — ⚠️ SOLO EN DEV (8081), sin mergear a main
- Columna añadida a la tabla desktop/tablet (ordenable, `fmtCOP()`) y a las tarjetas móviles (≤640px). Anchos de columna reajustados en los 2 breakpoints con `table-layout:fixed` + agregada a los 2 bloques `width:auto`. Verificado en navegador: desktop, ordenamiento asc/desc, tablet (~1000px), móvil (390px) — sin errores de consola.
- Commit dev `716d932`, pusheado. **El usuario pidió explícitamente NO pasarlo a main** ("solo en el worktree dev hasta que pidas mergear") — sigue así, pendiente de que el usuario confirme el merge.

---

## 📌 Sesión 2026-09-14 — DORA recuperado y recargado, queda solo PSOL

## Renombrado "Casa del Agua" → "Palacio del Agua" (2026-09-14) ✅
- **A pedido del usuario.** El hotel `CDAG` (`hotel_name_api = Hotel_PalacioDeAgua`) mostraba `nombre_personalizado = "Casa del Agua"` en el dashboard — el nombre de la API ya decía "PalacioDeAgua", el catálogo tenía el nombre viejo/equivocado.
- **Fix (fuente → BD, flujo normal del proyecto):**
  1. `Unificacion_Hoteles.xlsx` — celda H7 (fila CDAG, columna "✏️ NOMBRE PERSONALIZADO") cambiada a `Palacio del Agua`.
  2. Sincronizado a `hotel_unificacion` vía `POST /proxy/admin/sync-hoteles` (con `ADMIN_API_KEY`) en **ambos** servidores (8080 y 8081) → `{"ok": true, "actualizadas": 28}` en los dos.
- **Verificado:** `hotel_unificacion` y el endpoint `/proxy/historico/hoteles-map` ya devuelven `CDAG → "Palacio del Agua"`. No requiere reiniciar servidor — el frontend recarga el mapa al abrir/refrescar la página.
- Sin cambios de código, solo dato (Excel fuente + tabla de unificación). Se propaga solo a cualquier otra instancia que sincronice desde el mismo Excel (ya está en el repo).

- **DORA (900385136): ✅ RECUPERADO Y RECARGADO.** Aloft ya cerró agosto — probado en vivo: jul=75 y ago=75 empleados OK (sep sigue "no hay periodos cerrados", esperado). Recargado con `python3 cargar_historico_nomina_pyg.py --fuente nomina --desde 2026-08 --hasta 2026-08` → `OK nomina 2026-08 nit=900385136 filas=122` (75 empleado + resumen + conceptos). Resumen real en BD: gasto_total_personal $320.271.034 (consistente con meses previos). De paso, los otros 16 NITs también se recargaron con datos frescos en la misma corrida (algunos con más filas que la carga del 8-sep, ej. GHS 166→180, SERV 244→291 — probablemente ajustes tardíos del proveedor).
- **PSOL (800157788): sin cambios, sigue igual.** Probado jul/ago/sep 2026 y también `empleados-activos-detalle`: mismo `Cannot open database "NominaEmp" ... login failed (4060)` en los tres meses. Último en BD: **2026-06** (desde julio no carga nada). Sigue siendo 100% responsabilidad del proveedor — sin solución posible desde acá.
- **Estado agosto 2026 nómina: 17/18 NITs con detalle de empleados.** Solo falta PSOL. P&G no revisado en esta pasada (última vez 21/21).
- Recarga hecha solo en la BD compartida (Mac local) — no requiere nada en el Mac mini.

---

## 📌 Sesión 2026-09-08 — nóminas faltantes revisadas: CCAR recargado, quedan PSOL + DORA

- **El apagón general del proveedor (8 NITs caídos el 4-sep) se aclaró.** Prueba en vivo de los 18 NITs para agosto 2026: **16 OK**, solo 2 fallan.
- **CCAR (901936953): ✅ RECUPERADO Y RECARGADO.** La API ya devuelve 86 empleados (antes: `num_empleados=0` / `Login timeout`). Corrida `python3 cargar_historico_nomina_pyg.py --fuente nomina --desde 2026-08 --hasta 2026-08` desde la Mac local → `OK nomina 2026-08 nit=901936953 filas=112` (86 empleado + resumen + conceptos). Resumen real en BD: gasto_total_personal $315.171.461 (julio fue $306M — consistente). `cargado_en` 2026-09-08 09:23.
- **DORA (900385136): sigue faltando, pero volvió a "normal".** Error hoy: `No hay periodos cerrados para 2026-08` (ya NO `Login timeout` como el 4-sep). Es el caso benigno — Aloft aún no cierra su nómina de agosto. Se resuelve solo cuando cierren; recargar con el mismo comando. Último en BD: **2026-07**.
- **PSOL (800157788): sin cambios.** `Cannot open database "NominaEmp" ... login failed (4060)`. Base del proveedor caída desde julio. Último en BD: **2026-06**. Falta julio Y agosto. Solo se arregla si el proveedor levanta ese login.
- **Estado agosto 2026 nómina: 16/18** (antes 15/18 efectivos — CCAR era un cascarón). P&G sigue 21/21. Faltan PSOL + DORA.
- Recarga hecha solo en la BD (compartida) — no requiere nada en el Mac mini.

---

## 📌 Sesión 2026-09-04 — PSOL/DORA revisados de nuevo: el backend del proveedor empeoró

- **PSOL (800157788): sin cambios.** Mismo error de siempre, confirmado con 3 reintentos: `Cannot open database "NominaEmp" requested by the login. The login failed. (4060)`.
- **DORA (900385136) y CCAR (901936953): el error CAMBIÓ y es más grave.** Ya no es "no hay periodos cerrados" (normal/auto-resuelve) — ahora es `Login timeout expired (0)`, el mismo tipo de falla de infraestructura que PSOL.
- **Hallazgo clave: no es solo DORA/CCAR.** Al probar los 18 NITs en vivo, **7 fallan con el mismo `Login timeout expired`**: `900024971, 900361237, 900385136 (DORA), 901475697, 901630694, 901724291, 901936953 (CCAR)`. Confirmado **consistente en 2 pasadas separadas** (mismo set los dos intentos, no rota al azar) → no es ruido de red de este lado, es un problema real y sostenido del backend de NominaApp/Zeus hoy. Un NIT adicional (`890404970`) pasó de OK a timeout de lectura en la 2ª pasada — posible sobrecarga puntual encima del problema de fondo.
- **Implicación:** ya no se puede asumir "DORA/CCAR se resuelven solos el 5-7 sep" — si el backend del proveedor sigue así, el reintento del Mac mini también fallará para esos NITs. Hay que re-probar antes/durante el reintento del día 5.
- **Acción:** esto amerita reportarse al proveedor junto con PSOL — 8 de 18 NITs caídos con errores de login/timeout SQL Server el mismo día es un problema de infraestructura, no de nóminas sin cerrar.
- No se tocó código ni BD — solo se probó la API en vivo (`/nomina/reporte` por NIT).

---

## 📌 Sesión 2026-09-01 — resumen de lo cerrado hoy

### ✅ Cerrado
1. **Cierres mensuales consolidados en el Mac mini.** Se descubrió que corrían en paralelo en el Mac mini Y en la Mac local (el `launchctl unload` del 18-ago no era permanente — los `.plist` seguían en `~/Library/LaunchAgents/`). Decisión del usuario: **solo el Mac mini**. En la Mac local: `launchctl bootout` de los 6 agentes (`com.bia.cierre-mes-dia1/5/7`, `com.bia.nomina-pyg-dia1/5/7`) + `.plist` movidos a `~/Library/LaunchAgents/disabled-cierres-migrados-macmini/` (con README). Siguen activos en la local: `dashboard-talento` (8080), `dashboard-talento-dev` (8081), `backup-postgres`. → detalle en *"Cierres migrados a Mac mini dedicado"*.
2. **Cierre de agosto 2026 certificado** (corrió el 1-sep). El **Mac mini lo hizo a las 05:00** (inserción fresca): `historico_empleados` 1003 filas / 19 razones, `historico_ir` 105 movimientos, nómina 16/18 NITs, P&G 21/21. Faranda_Bogota (NIT 900345450) entró **sin workaround manual**. Verificado en el Mac mini: 6 agentes cargados, repo al día (`42af4b1`), venv `~/rrhh-report/.venv/bin/python` con psycopg2, logs en `~/rrhh-report/logs/cloud/`, `sleep 0`.
3. **PSOL ≠ DORA — distinción hecha y desplegada.** Probado en vivo contra la API leyendo el cuerpo del 400:
   - **PSOL (800157788):** `Cannot open database "NominaEmp" ... login failed (4060)` — base del proveedor caída, falla en TODOS los períodos (jun/jul/ago). Último en BD: **2026-06**. → hay que reportarlo.
   - **DORA/Aloft (900385136):** `No hay periodos cerrados para 2026-08` — nómina de agosto sin cerrar aún, jun/jul OK. Último en BD: **2026-07**. → se resuelve solo.
   - **Fix:** `post_json()` en `cargar_historico_nomina_pyg.py` ahora expone el `detail` del `HTTPError`. dev `8664d2e` → main `ff51b0e`; CHANGELOG **v1.25.1** (`bb277bd` → `42af4b1`). Mac mini actualizado (`git pull`) y verificado.

### ⏳ Seguimiento pendiente
- **5–7 sep:** verificar que `nomina-pyg-dia5`/`dia7` del Mac mini corran (hoy `runs=0`, aún no les toca) y que recuperen **DORA agosto** (900385136) cuando Aloft cierre.
- **PSOL:** reportar al proveedor / admin de NominaApp el error de login SQL Server a la base `NominaEmp` (`4060`) para el NIT 800157788. Sin eso, julio y agosto de PSOL no se pueden cargar.
- **Migración Mac mini:** confirmar que sus 6 agentes de cierre disparan con sesión de usuario activa (los `gui/` la necesitan) y que nadie vuelve a activar los locales.

---

## 📌 Sesión 2026-09-03 — cuadre agosto + fallback IR Faranda_Bogota

### ✅ Cerrado
1. **Cuadre del cierre de agosto** (ver Revisión 3 en la sección de nómina): `historico_empleados` cuadra internamente (996+58−51=1003). Descuadres = datos que el proveedor no entregó. **Hallazgo nuevo:** CCAR/FC Cartagena (NIT 901936953) también sin nómina de agosto — la API devolvió `200 OK` con `num_empleados=0`, se registró como `OK filas=7` (tercer hueco además de PSOL y DORA).
2. **Fallback IR por snapshot para Belvedere (FEB) + FC Bogotá (CLUB).**
   - **Causa raíz confirmada en vivo:** `/rrhh/ingresos-retiros` devuelve 16 buckets y **ninguno es `Faranda_Bogota`** (NIT 900345450 = CLUB + FEB). Probado con los 6 `tipo_nomina_id`. CLUB/FEB tuvieron IR normal hasta **2026-05**; desde jun-2026, cero. El usuario mostró el reporte de **Zeus** (fuente SQL Server) con los 12 movimientos de agosto → **el dato existe en Zeus, el hueco es de NominaApp** (que sí expone ese NIT en `empleados-activos-detalle` y `nomina/reporte`). Perdidos jun–ago: **32 movimientos** (18 I + 14 R).
   - **Fix:** `cierre_mes.py` → `completar_ir_desde_snapshot()` + `IR_FALLBACK_SNAPSHOT_RAZONES = {"CLUB","FEB"}`. Para razones sin ninguna fila IR del período, reconstruye I/R comparando `historico_empleados` mes vs mes−1. Filas con `origen='snapshot'` (columna nueva en `historico_ir`). Guardia: si la API vuelve a traer la razón, los datos reales mandan. Se llama tras `insertar_ir` en cada cierre.
   - **`backfill_ir_snapshot.py`** (nuevo): misma lógica para meses ya cerrados. **Ejecutado contra la BD para jun–ago 2026: 32 filas** (coinciden exacto con Zeus, incl. Baeza Puentes Sebastián en Belvedere). Verificado: CLUB y FEB cuadran (Inicio+I−R=Fin) en jun/jul/ago. Razones de control (GHS, DORA, CCAR, SERV) sin cambio.
   - `servidor.py` NO tocado — `/proxy/historico/ir` e `ir-mensual` leen `historico_ir` en vivo, así que ya muestran las filas sintéticas sin reiniciar nada.
   - Commits: dev `ba8e57b`+`5b78cec` → main `a495cf0`+`ae666d7`. CHANGELOG **v1.25.2**.
   - **Limitación:** un traslado CLUB↔FEB (mismo grupo) se ve como retiro+ingreso, no como transferencia. Y no detecta a quien entró y salió dentro del mismo mes (no aparece en ningún snapshot).

### FC Cartagena + Servihoteles agosto 2026 — descuadre de 1, resuelto con filas manuales (2026-09-03)
- **Síntoma:** FC Cartagena agosto: 81 + 8 − 4 = 85 ≠ Activos Fin 86 (**+1**). Servihoteles agosto: 227 + 24 − 17 = 234 ≠ Activos Fin 233 (**−1**). Espejo exacto — la misma persona.
- **Causa:** **CANTILLO URUETA MANUEL DE JESUS** (cédula `1143363867`). Empleado desde 2024-01-19, sin cambio de contrato. Julio: `SERV` / centro `130402-HCARIBE FINANCIERA`. Agosto: `SERV` / centro `1201-FARANDA COLLECTION CARTAGENA`. Es un **traslado de centro de costo dentro de Servihoteles**, no alta ni baja → no hay movimiento en Zeus/NominaApp. `activos-count` lo reclasifica server-side (`SERV_HOTEL_PATTERNS`): sale del conteo de Servihoteles y entra al de FC Cartagena. Ninguna de las dos vistas de IR lo refleja. CCAR pura sí cuadra (78 + 8 − 4 = 82).
- **Fix (decisión del usuario: "solo haz que cuadre, agrégalo como ingreso", sin fallback ni lógica nueva):** dos filas a mano en `historico_ir`, ambas `origen='ajuste'`, `codigo_empleado='1143363867'`, `periodo='2026-08'`:
  - `id=7047` — `razon_social='CCAR'`, `tipo_movimiento='I'`, `ingreso='2026-08-01'` → FC Cartagena cuadra: 81 + 9 − 4 = 86.
  - `id=7048` — `razon_social='SERV'`, `tipo_movimiento='R'`, `termino='2026-08-31'` → Servihoteles cuadra: 227 + 24 − 18 = 233.
  - (`tipo_contrato` en NULL en ambas — el snapshot de ese registro SERV traía la cédula en ese campo.)
- **⚠️ No sobreviven un re-cierre de agosto:** `cierre_mes.py` hace `DELETE FROM historico_ir WHERE periodo='2026-08'` y recarga del API. Si se re-cierra agosto, volver a insertar ambas (buscar `origen='ajuste'` periodo 2026-08). Agosto ya está cerrado → improbable.
- Solo dato en BD, sin cambio de código. No aplica a dev/main/Mac mini.

### ⏳ Seguimiento
- **`git pull` en el Mac mini** (`~/rrhh-report`) para llevar `cierre_mes.py` + `backfill_ir_snapshot.py` + CHANGELOG. El backfill de jun–ago YA está en la BD (compartida), no hay que re-correrlo; el cambio en `cierre_mes.py` hace que los cierres futuros lo apliquen solos.
- **CCAR agosto (nómina): ✅ RESUELTO 2026-09-08** — la API se recuperó, recargado a mano (86 empleados, gasto $315M). Ver *"Sesión 2026-09-08"* arriba.
- **Reportar a NominaApp:** el NIT 900345450 ("Club House Bogota S.A.S.", BUs Belvedere + Club House) falta en `/rrhh/ingresos-retiros` desde jun-2026, aunque está en `empleados-activos-detalle`, `nomina/reporte` y Zeus. Adjuntar el reporte de Zeus.
- **Mejora futura:** que `cargar_historico_nomina_pyg.py` loguee `WARN … num_empleados=0` en vez de `OK` cuando la respuesta viene vacía (caso CCAR).

---

## Documentadas las 3 formas de calcular antigüedad (2026-08-26) ✅
- **Último punto pendiente de la auditoría general.** Se agregaron comentarios cruzados en el código (sin cambiar ningún comportamiento) explicando que "Antigüedad" tiene 3 definiciones de negocio distintas, no intercambiables:
  1. **Tabla/panel** — `calcMeses()`+`fechaIngresoReal()` (`dashboard-empleados.html`, comentario maestro justo antes de `calcMeses()`) — usa `INGRESO_REAL_MAP` (`servidor.py` endpoint `ingreso-real-map`, gaps-and-islands): solo suma dentro de la MISMA `razon_social`, corta con huecos >62 días.
  2. **Gráfica "Distribución por Antigüedad"** — endpoint `/proxy/historico/antiguedad` (`servidor.py:1594`) — `MIN(fecha) GROUP BY codigo_empleado` sin partición por empresa ni por huecos: un empleado que salió y volvió años después puede salir con antigüedad "continua" desde su primer ingreso histórico. Documentado como definición distinta, no bug sin arreglar.
  3. **Panel "Historial por empresa"** — `empDetailLoadHistory()` (`dashboard-empleados.html:10651`) — suma la duración de cada bloque cargo+empresa, SÍ incluye cambios de razón social (a diferencia de #1). Campo "Antigüedad total en el grupo".
- Los 3 comentarios se referencian entre sí para que una futura sesión no "corrija" una pensando que es un bug, sin saber que rompe la consistencia con las otras 2.
- **Verificado:** sintaxis de `servidor.py` OK, bloque `<script>` de `dashboard-empleados.html` parsea OK, servidor dev (8081) reiniciado y respondiendo.
- Sincronizado en **main** (`0e17449`), **worktree** (`625f014`) y **Mac mini** (`git pull` confirmado por el usuario 2026-08-26, sin ver el output exacto). `backup_postgres.py` solo en main (vive en la raíz del repo principal, no es código del dashboard). Ambos servidores del dashboard reiniciados (8080 y 8081) — el Mac mini no sirve el dashboard, solo mantiene el repo alineado.

## Fallos silenciosos en nómina/P&G en vivo — ahora quedan en el log (2026-08-26) ✅
- **Hallazgo de la auditoría:** 4 sitios en `servidor.py` con `except Exception: pass` alrededor de llamadas a la API en vivo de nómina/P&G por NIT — si fallaba un hotel puntual (timeout, 429, NIT malo), esos números quedaban en 0/incompletos sin ningún rastro en ningún log.
- **Fix (solo agrega logging, no cambia el comportamiento — sigue saltando ese hotel y continuando, que es lo correcto):**
  - `servidor.py:2130` — nómina en vivo por NIT (`_stats`, KPIs Payroll) → `print(f"  ⚠️  nómina en vivo falló para NIT {nit}...")`
  - `servidor.py:2295` y `2310` — P&G en vivo por `(company_id, bu)`, incluye el fallback a bu="local" → mismo patrón de warning
  - `servidor.py:2579` — KPI de ocupación agregado (`kpis-agregado`) por hotel → mismo patrón, usa `h.get('label')`
- Van a `print()` normal (mismo patrón que el resto del archivo, sin módulo `logging`), capturados en `logs/servidor_dev.log`/`servidor.log` vía el LaunchAgent existente — no requiere infraestructura nueva.
- **Verificado:** `ast.parse()` OK, servidor dev (8081) reiniciado y respondiendo 200 en `/proxy/historico/salarios`.
- Sincronizado en **main** (`0e17449`) y **worktree** (`625f014`). Ambos servidores reiniciados (8080 y 8081).

## Backup automático de PostgreSQL implementado (2026-08-26) ✅
- **Hallazgo de la auditoría:** no existía ningún backup de la base `Base de Datos Historicos Talento Humano` (172.19.0.160) — única copia de `historico_empleados`, `historico_ir`, `historico_nomina_pyg`, etc.
- **Decisiones del usuario:** diario, retención 30 días, solo local en esta Mac por ahora (sin copia al Mac mini todavía — se puede agregar después).
- **Implementado:** `backup_postgres.py` (nuevo, raíz del repo) — `pg_dump -Fc` (formato comprimido) vía `db_config()` de `app_config.py`, guarda en `backups/postgres/historico_talento_humano_YYYYMMDD_HHMMSS.dump` (carpeta gitignored), rota automáticamente lo que pase de 30 días.
- **LaunchAgent `com.bia.backup-postgres`** (`~/Library/LaunchAgents/`) — corre 4:00 AM diario, mismo patrón que `com.bia.cierre-mes-dia1`. `PATH` incluye `/opt/homebrew/bin` porque `pg_dump` vive ahí (Homebrew Apple Silicon), no en el PATH restringido que usan los LaunchAgents existentes.
- **Probado manualmente end-to-end:** corrida real completa, dump de 24.7 MB (base de 118 MB, comprime bien), verificado con `pg_restore --list` (65 tablas, TOC íntegro). LaunchAgent cargado (`launchctl load -w`, `launchctl list` confirma registrado).
- **No cubierto todavía (a propósito, decisión del usuario):** sin copia offsite/segunda máquina — si esta Mac pierde el disco, se pierden los backups también. Se puede agregar copia al Mac mini más adelante si se pide.
- Cambio de infraestructura, no de código del dashboard — no aplica a worktree/main.

## Feature nueva: Nómina histórica en el panel "hoja de vida" (2026-08-27) ✅
- **Mejora opcional #10 ("Nóminas históricas detalladas"), pero mucho más simple de lo que decía la nota original** — al investigarlo a fondo (ver hallazgo anterior) resultó que `historico_nomina_pyg` (fuente='nomina', registro_tipo='empleado') YA tiene el desglose completo por empleado en la columna JSON `raw_data` (conceptos, deducciones, provisiones, seguridad social) — 32.622 filas, ene-2024 → jul-2026. No hizo falta cargar ningún Excel ni crear tablas nuevas, solo exponerlo.
- **Backend (`servidor.py`):** nuevo endpoint `GET /proxy/historico/nomina-empleado?identificacion=X` — trae TODOS los períodos de una vez (como `trayectoria`), extrayendo del JSON `conceptos`, `provisiones`, `seguridad_social`, totales. Filtra por `raw_data->>'identificacion'` (la columna `empleado_codigo` está vacía en toda la tabla, no sirve).
- **Frontend (`dashboard-empleados.html`):** nuevo panel flotante `#emp-pay-panel` (mismo patrón visual que "Evolución salarial" — tarjeta sobre el área difuminada, izquierda del panel de detalle), botón "📋 Ver nómina histórica" junto al de evolución salarial en la hoja de vida. Selector de período (dropdown) + desglose Devengos/Deducciones/Provisiones/Aportes patronales/Neto pagado. `_empDetailIdent` nuevo (cédula del empleado abierto, antes solo se guardaba nombre+razón social).
- **Mutuamente excluyente con "Evolución salarial"** (abrir uno cierra el otro, para no superponerse) — mismo criterio en el botón ✕ de cada panel y en el backdrop.
- **Verificado en navegador:** 30 períodos reales para un empleado antiguo (ene-2024→jun-2026), cambio de período vía dropdown funciona, botón real (no solo llamada directa a la función) funciona para un empleado actual (17 períodos), estado vacío correcto para alguien sin datos, exclusión mutua entre paneles confirmada, sin errores de consola.
- **Limitación conocida (documentada en el código):** el mes en curso (agosto 2026) no está — esta tabla se llena en el cierre mensual. Seguiría dependiendo de la API en vivo, como se hizo hoy para el Excel de gerentes.
- Sincronizado en **main** (`f1602f5`) y **worktree** (`ac54ce3`). Ambos servidores reiniciados (8080 y 8081).

## Bug corregido: filtro de Operación no funcionaba en Ingresos y Retiros del mes en vivo (2026-08-27) ✅
- **Reportado por el usuario** al preguntar por el pendiente #7 (que resultó ya no aplicar) — "¿por qué en el tab de Ingresos y Retiros este filtro no sirve?".
- **Causa raíz:** el filtro de Operación (En Operación/Socio) funciona bien en meses históricos (`historico_ir.operacion`, filtrado server-side), pero en el **mes en vivo** (API externa `/proxy/rrhh/ingresos-retiros`) las filas NO traen ningún campo de operación — ni se envía como filtro en `irBuildBody()`, ni se cruzaba después en `irFilterRows()` (que sí filtraba por hotel/cargo/empleado/sexo, pero nunca por operación). El selector cambiaba de valor sin afectar los datos.
- **Complicación:** cruzar por `codigo_empleado` contra `allEmps` (como se hace para sexo) no sirve para los que ya se retiraron ese mes — no aparecen en la lista de activos. Solución: usar la tabla `socios` de PostgreSQL (ya existente, la usa `cierre_mes.py` para clasificar en el cierre) en vez de `allEmps` — es una simple membresía de cédula, no depende de que la persona siga activa.
- **Fix:**
  - **`servidor.py`:** nuevo endpoint `GET /proxy/historico/socios` — devuelve `{codigos: [...]}` desde `SELECT codigo_empleado FROM socios WHERE activo=true`.
  - **`dashboard-empleados.html`:** nuevo `SOCIOS_SET` (Set global) + `loadSociosMap()`, cargado en el init junto a `loadHotelMap()`/`loadCargoMap()`/`loadIngresoRealMap()`. `irFilterRows()` ahora filtra por operación: usa `r.operacion` si existe (histórico, ya viene del servidor) o `SOCIOS_SET.has(r.codigo_empleado)` si no (mes en vivo).
  - **`_estSerieLabels`** no se tocó — nada que ver, cambio no relacionado.
- **Verificado en navegador:** con los 78 movimientos reales de agosto 2026 (0 socios este mes), el filtro ahora sí distingue (Socio=0, En Operación=78, suma=total — antes ambos daban 78 sin distinguir). Inyectando la cédula de un socio real de `SOCIOS_SET` en una fila de prueba, el filtro lo clasifica correctamente como Socio. Sin regresión en histórico: junio 2026 (82 filas, vía PostgreSQL) sigue funcionando igual que antes.
- Sincronizado en **main** (`f1602f5`) y **worktree** (`ac54ce3`). Ambos servidores reiniciados (8080 y 8081).

## Limpieza segura: código muerto eliminado (2026-08-27) ✅
- **Mejora opcional #6 ("Refactor limpio del HTML"), alcance acordado con el usuario: solo limpieza segura** (sin reorganizar ni modularizar) — borrar funciones sin ningún call site, sin tocar lógica viva.
- **9 funciones eliminadas de `dashboard-empleados.html`**, todas verificadas con 0 referencias en todo el archivo (ni siquiera dentro de `onclick=""` u otras strings) antes de borrar:
  - Las 8 que ya había detectado la auditoría del 26-ago: `ausHasDetailFilters`, `estSerieInit`, `estSerieRenderAll` (superada por `estSerieRenderAgregado`, que sí se usa), `fmtMoney`, `prApplyCecoCargoFilter` (superada por `_prApplyCecoCargoMulti`), `prVarPct`, `syncIR`, `toSentence`.
  - 1 encontrada en un barrido nuevo (conteo de referencias de las 222 funciones top-level del archivo): `refreshHotelDependentTabs` — un wrapper de una línea con comentario "mantener por compatibilidad" que ya no tenía ningún llamador real.
  - **Nota:** `_estSerieLabels` (variable leída por una rama muerta dentro de `estSerieRender`, ver línea ~10285) se dejó intacta a propósito — es una rama inalcanzable dentro de una función que SÍ está viva, tocarla ya sería "reescribir lógica", fuera del alcance acordado.
  - **Resultado:** 10.866 → 10.738 líneas (-128).
- **Verificado:** sintaxis OK (`ast`/`new Function`), y en navegador — recorrida las 7 pestañas (Empleados, Salarios CECO, IR, Ausencias, Payroll, Estadísticas, Análisis) con consola de Chrome monitoreada, 0 errores.
- Sincronizado en **main** (`f1602f5`) y **worktree** (`ac54ce3`).

## Detección pasiva de conceptos nuevos en Payroll (2026-08-27) ✅
- **Mejora opcional #9 de la lista de pendientes, implementada a pedido del usuario.** Antes, un concepto de nómina que la API devuelve y que no existe en `nomina_conceptos` se descartaba SILENCIOSAMENTE (`prConceptoIncluir()` retorna `false` si no hay match) — no aparecía en ningún lado, ni siquiera en "(Sin grupo)". Solo se detectaba corriendo el batch completo `/regenerar-conceptos` (3-5 min, 522 llamadas a la API).
- **Fix (`dashboard-empleados.html`):** nueva función `prCheckNuevosConceptos()`, llamada al final de `prLoad()` — compara los `(codigo, nombre)` crudos de `_prRawCur.conceptos/.provisiones/.ss_patronal` del período/filtro actual contra `_prLookup()` (índice ya cargado desde `nomina_conceptos`). Si encuentra alguno sin NINGÚN mapeo (ni siquiera `incluir=false`), muestra un banner amarillo (`#pr-nuevos-banner`, debajo del banner contextual del tab Payroll) con código, nombre, valor total y empleados afectados — y apunta al flujo ya existente (`/regenerar-conceptos` → revisar Excel → `/sync-conceptos`) en vez de reinventar una clasificación en línea.
- Banner descartable (✕) por sesión — recuerda vía `sessionStorage` la "firma" (códigos ordenados) de lo ya visto para ese hotel+período, así no vuelve a aparecer hasta que cambie algo real.
- **Verificado en navegador:** con los datos reales de hoy no hay conceptos nuevos (el batch ya los cubre todos) — se probó inyectando un concepto falso (`ZZTEST99`) vía JS para confirmar detección, render del banner, y que el dismiss persiste correctamente: no vuelve a mostrarse para la misma combinación tras cerrarlo.
- Sincronizado en **main** (`f1602f5`) y **worktree** (`ac54ce3`).

## 🟡 Pendiente de decisión — servidor sin autenticación + abierto a toda la red (2026-08-26)
- **Confirmado en código:** `do_GET()` (`servidor.py:154-169`) nunca llama `_require_admin_auth()` — solo `do_POST()` (línea 171) lo hace. Todas las rutas de lectura (`/proxy/historico/*`, `/proxy/stats/*`, `/proxy/*`) exponen nombre/cédula/sueldo/fecha nacimiento de ~1.000 empleados sin pedir ninguna clave. El mecanismo de auth ya existe (`_require_admin_auth()`, HMAC contra `ADMIN_API_KEY` del `.env`) pero solo protege `/proxy/admin/*` y `cecos-sync`.
- **`ThreadingHTTPServer(("", PORT), ...)`** (línea 2679) escucha en todas las interfaces, no solo localhost.
- **Confirmado con el usuario:** el acceso por IP de red desde otros equipos de oficina **es uso legítimo actual** — no se puede simplemente atar a `127.0.0.1`.
- **Mi recomendación (pendiente de aprobación):** agregar HTTP Basic Auth a nivel de servidor (un usuario/contraseña compartido en `.env`, protege todas las rutas de una vez, sin tocar el frontend). Motivo: aunque el acceso por red es legítimo, hoy cualquier dispositivo que se conecte a esa red (wifi de visitas, equipo comprometido) puede raspar los datos sin ninguna barrera — dato sensible bajo Ley de Habeas Data (cédula + salario).
- **Estado:** el usuario pidió dejarlo pendiente mientras lo revisa — no implementar hasta que confirme.

## Auditoría general del proyecto + fix codigo_empleado='NaN' (2026-08-26) ✅
- **Auditoría general** (seguridad, integridad de datos, calidad de código) hecha con 3 forks en paralelo, entregada como Artifact. Hallazgo crítico: 298 empleados en `historico_empleados` compartían `codigo_empleado='NaN'` (string literal, no NULL) entre ene-2023 y sep-2024 — contaminaba antigüedad, `ingreso-real-map` (filtro de género) y el cruce nuevo en `historico_ir`.
- **Causa raíz investigada y confirmada:** para 4 entidades (`DORA`, `YASS`, `MAN`, `FNS`) la columna de cédula del Excel/sistema fuente venía vacía durante un rango de meses — interruptor todo-o-nada por mes (100% NaN antes de una fecha de corte, 0% después), no filas dispersas. DORA/YASS/MAN se corrigieron solas en la fuente entre jun-jul 2024; FNS nunca se corrigió y dejó de reportarse tras sep-2024.
- **Fix aplicado directo en PostgreSQL (172.19.0.160, DB "Base de Datos Historicos Talento Humano"), con confirmación del usuario:** backfill por `(razon_social, nombre_completo normalizado)` cruzando contra cualquier fila de esa misma persona con código válido en otro mes. **1.271 filas corregidas** (298→689... quedan 688 sin poder recuperar, ver abajo). Verificado antes/después con SQL directo, corrido dentro de transacción.
- **Hallazgo relacionado encontrado y corregido en el mismo pase:** 1.024 filas en toda la tabla (no solo las NaN) tenían `codigo_empleado` con sufijo `".0"` (ej. `"79749330.0"`) — artefacto de conversión a float en algún punto del pipeline de carga. Mismo tipo de riesgo (rompe joins/agrupaciones por string exacto). Normalizado con `regexp_replace(...,'\.0$','')`, confirmado 0 filas restantes con el sufijo. `historico_ir` se revisó y NO tenía ninguno de los dos problemas.
- **688 filas siguen en `'NaN'` — irrecuperables desde esta tabla:** personas que salieron de DORA/YASS/MAN antes de que la fuente se corrigiera, o los 24 registros de FNS (nunca tuvo código válido, entidad discontinuada tras sep-2024). No hay ningún otro dato en `historico_empleados` de donde reconstruirlos — solo se arreglaría con una fuente externa (nómina original de esas empresas).
- Cambio de datos únicamente (no de código) — no aplica a worktree/main ni requiere reinicio de servidor. No sincronizado a ningún otro lugar porque es la misma base remota que usan ambos.

## Bug corregido: filas duplicadas del mismo mes rompían "Historial por empresa" (2026-08-25) ✅
- **Reportado con un caso real:** Ruiz Sarkar Alexy Enrique (codigo_empleado 73169623) mostraba un historial con fechas invertidas ("31 oct 2022 — 31 jul 2011") y bloques duplicados/intercalados entre "Conserje en Hotel Caribe" y "Gerente Junior en Servihoteles".
- **Causa:** `historico_empleados` tiene, para este empleado y solo 2 meses (2023-01 y 2023-02), **dos filas con el mismo codigo_empleado y fecha** pero cargo/razon_social distintos — dato inconsistente en la fuente. El cargo real "Conserje/Hotel Caribe" es continuo desde 2011 (15 años); "Gerente Junior/Servihoteles" solo existe esos 2 meses (probable error de captura). El endpoint `/proxy/historico/trayectoria` no deduplicaba por fecha, así que ambas filas llegaban al frontend y el JS las procesaba como transiciones reales.
- **Fix (`servidor.py`):** `SELECT DISTINCT ON (fecha) ... ORDER BY fecha, id` — se queda con la fila de `id` más bajo por mes. Verificado que en los casos duplicados detectados el id más bajo siempre corresponde al cargo real/dominante (el que persiste en el tiempo).
- **Verificado en navegador:** el historial de Ruiz Sarkar pasó a un solo bloque limpio: Conserje / Hotel Caribe / 31 jul 2011 — Actual · 15a 1m.
- **Nota:** no se investigó si hay otros empleados con el mismo patrón de fila duplicada — el fix es general (aplica a cualquier caso futuro/existente con esta inconsistencia), no específico a este empleado.
- Sincronizado en **worktree** (`7850a30`) — no en main. Servidor dev (8081) reiniciado.

## Filtro por género (Masculino/Femenino) — click en tarjetas del header (2026-08-25) ✅
- **A pedido del usuario**, iterado en varios pasos dentro de la misma sesión: (1) tooltip nominal+% en las tarjetas de género, (2) click en las tarjetas filtra todo el dashboard, (3) las tarjetas KPI (Activos Inicio/Fin Mes) no reflejaban el filtro, (4) Ingresos/Retiros tampoco — se investigó y se implementó la solución completa ("Opción A": agregar `codigo_empleado` a `historico_ir`).
- **Filtro global `_filtroSexo`** (`dashboard-empleados.html`): toggle al hacer click en `.g-block` (tarjetas Masculino/Femenino del header), aplicado en `applyFilters()` igual que los demás filtros — afecta tabla, gráficas y todos los tabs. Tarjeta activa resaltada visualmente. `clearFilters()` también lo resetea.
- **KPIs "Activos Inicio/Fin Mes":** se calculan con llamadas aparte al servidor (`/proxy/historico/activos-count`), no desde `filteredEmps` — se agregó parámetro `sexo` (`UPPER(sexo)=%s`) al endpoint y se pasa desde `loadHeaderKPIs()`.
- **KPIs "Ingresos/Retiros" — requería trabajo de datos, no solo UI:** `historico_ir` no tenía ninguna columna para cruzar con el sexo del empleado. Investigado y confirmado que la API de IR **sí** devuelve la cédula en el campo `"contrato"` (a veces con sufijo `-N` de renovación, ej. `"1137222929-1"`) — simplemente no se estaba capturando.
  - **Migración:** `ALTER TABLE historico_ir ADD COLUMN codigo_empleado VARCHAR` (ejecutado directo en PostgreSQL).
  - **`cierre_mes.py`:** `fetch_ir()`/`insertar_ir()` ahora extraen y guardan `codigo_empleado` — **de aquí en adelante los cierres nuevos ya lo capturan automáticamente**. ⚠️ **Este cambio solo está en el worktree/rama dev — el Mac mini que corre el cierre real en producción sigue en `main`, sin este fix, hasta que se mergee.** Mientras tanto los cierres mensuales seguirán sin poblar `codigo_empleado` para meses nuevos.
  - **Backfill histórico (una sola vez, script en `/private/tmp/.../scratchpad/backfill_ir_codigo_empleado.py`, no versionado en el repo):** re-consultó la API mes a mes (48 periodos) y cruzó contra las filas existentes por `(nombre_normalizado, tipo_movimiento, fecha_ingreso)` — usa la misma lógica de `norm_name()` de `servidor.py` (ordena palabras alfabéticamente) porque periodos viejos vienen de un Excel con "Nombre Apellido" y la API en vivo devuelve "APELLIDO NOMBRE". **La fecha de "término"/vencimiento se descartó del cruce** — es la fecha de vencimiento del contrato ACTUAL, cambia si se renueva, así que compararla contra el valor guardado hace tiempo fallaba sistemáticamente (causa raíz del primer intento con ~0% de match en 2023).
  - **Cobertura final:** 80.4% general (4.214/5.240 filas), **88.4% desde 2024-01** (el rango más relevante). El resto son movimientos muy antiguos (pre-2023, incluidos 5 registros sueltos de 2005/2016/2017) o casos sin coincidencia exacta de nombre. No bloquea el filtro: esas filas simplemente no cuentan para ningún género cuando el filtro está activo (no se duplican ni se ocultan mal para el género equivocado).
  - **`servidor.py`:** endpoint `/proxy/historico/ir` acepta `sexo`, hace `LEFT JOIN` contra una subconsulta que trae el sexo más reciente conocido por `codigo_empleado` desde `historico_empleados`.
  - **Frontend (mes en vivo, sin tocar el backend):** la API de IR en vivo también trae `"contrato"` por fila — `irFlattenRows()` lo extrae como `codigo_empleado` y `irFilterRows()` lo cruza contra `allEmps` (que ya tiene `identificacion`+`sexo` cargados) para filtrar el mes actual sin esperar al backfill.
  - **Bug de caché encontrado y corregido durante las pruebas:** `fetchIRData()` cacheaba por `(período, hotel, centro, operación, cargo, empleado)` sin incluir `_filtroSexo` — togglear género sin cambiar otro filtro no disparaba un nuevo fetch, dejando datos sin filtrar. Se agregó `sexoKey` a la clave de caché.
- **Verificado con curl y en navegador:** MASCULINO(34)+FEMENINO(46)=80 vs 91 sin filtro (jul-2026) — la diferencia son los casos sin coincidencia, consistente con la cobertura del 88%.
- Sincronizado en **worktree** (`ca4a08f`, `ff35a63`) — **NO en main** (ver [[feedback_workflow_8081]], cambios solo en 8081 hasta que el usuario pida pasar a producción). Cambios en `servidor.py` → servidor dev (8081) reiniciado.

## Bug corregido: nombres/cargos con ñ o tildes mostraban una mayúscula extra (2026-08-25) ✅
- **Reportado por el usuario:** "en los nombres de los empleados veo que la ñ sale diferente" — ej. "MUÑOZ" se mostraba como "MuñOz" (mayúscula extra justo después de la ñ).
- **Causa:** `toTitle()` usaba `/\b\w/g` para detectar el inicio de cada palabra y capitalizarla. En JavaScript, `\w` solo reconoce `[A-Za-z0-9_]` — no reconoce `ñ` ni vocales con tilde como letra. Esto hacía que el motor de regex tratara la ñ/tilde como si fuera un separador de palabra (como un espacio), así que la letra INMEDIATAMENTE DESPUÉS de la ñ/tilde también quedaba marcada como "inicio de palabra" y se capitalizaba. Afectaba cualquier nombre/cargo/centro con ñ o tilde en posición no-inicial (Muñoz→MuñOz, Nuñez→NuñEz, Peña→PeñA).
- **Fix:** reemplazado por `/(^|[\s'-])(\p{L})/gu` (letra Unicode con flag `u`), que capitaliza solo tras inicio de cadena, espacio, apóstrofo o guion — maneja correctamente ñ, tildes, apellidos con guion (Pérez-Gómez) y apóstrofo (O'Brien).
- **Alcance:** `toTitle()` es compartida por nombres de empleados (tabla, tarjetas mobile, filtro, panel de detalle, IR, drill-down CECO) y por `cleanCargo()`/`cleanCentro()` (cargos y centros de costo) — un solo fix corrige todos los lugares a la vez.
- **Verificado en navegador con datos reales:** Avendaño Ortiz Miguel, Barrera Muñoz, Cañizares Arenas, Hernandez Carreño — todos correctos.
- Sincronizado en **main** (`6b3ebbe`) y **worktree** (`e846a34`). Solo HTML/JS, no requiere reinicio de servidor.

## 🔴 Pendiente — cargar histórico faltante hasta 2018 (huecos previos a 2022)

### "Historial por empresa" incompleto para empleados con antigüedad anterior a 2022 (2026-08-24)
- **Detectado al revisar a Torres Torralvo Andrea Carolina** (ingreso real 2018-10-16, confirmado por `fecha_ingreso` — ver entrada de "Antigüedad" abajo). `historico_empleados` **solo tiene snapshots desde enero 2022** (`MIN(fecha)=2022-01-01`, verificado por SQL directo — 42.954 filas, hasta 2026-07). Para cualquier empleado con antigüedad real anterior a 2022, el panel "Historial por empresa" (línea de tiempo de cargos/empresas en la hoja de vida) **no puede reconstruir ningún cambio de cargo o de empresa ocurrido antes de enero 2022** — simplemente no hay snapshots de esos años. El historial visible arranca con el cargo que la persona tenía en enero 2022, no con su cargo/empresa real de ingreso ni con eventuales cambios intermedios 2018-2021.
- **El campo "Antigüedad" (número) NO se ve afectado por este hueco** — viene directo del campo `fecha_ingreso` de la fuente (dato literal, correcto aunque sea de 2018), no se reconstruye sumando snapshots. Solo afecta la RECONSTRUCCIÓN VISUAL del historial de cargos/empresas para el período 2018-2021.
- **⚠️ Importante — no es solo "correr una carga":** el archivo Excel que ya se usó para poblar `historico_empleados` (`Relacion de Empleados Historicos.xlsx`, ver [[project_historicos]]) **también arranca en enero 2022** (51 meses, ene-2022 → mar-2026) — no cubre 2018-2021. Habría que primero **ubicar una fuente adicional/distinta** (backups, Excels más antiguos del área de Talento Humano) antes de poder cargar nada; no se sabe todavía si existe.
- **Acción pendiente:** preguntar/buscar con el usuario si existe algún archivo de nómina/empleados de 2018-2021. Si existe → diseñar carga incremental a `historico_empleados` con la misma estructura (fecha, razon_social, codigo_empleado, cargo, etc.). Si no existe → este hueco queda como limitación permanente y ya documentada del panel (no es un bug, es cobertura de datos).

## 🟡 Pendiente de certificar

### Fix mayor: Antigüedad ya NO se reinicia por cambios de cargo dentro de la MISMA empresa — aplicado a la tabla principal (2026-08-24) ✅
- **Continuación directa del hallazgo anterior** (ver entrada de abajo): el usuario pidió explícitamente "si esa antigüedad es en la misma empresa debe sumar, cámbialo también en la tabla" — no bastaba con mostrarlo solo en el historial del panel, debía corregirse el campo Antigüedad real de la tabla de Empleados y del resumen del panel.
- **Backend — nuevo endpoint `/proxy/historico/ingreso-real-map`** (`servidor.py`): calcula, por `codigo_empleado`, la fecha real de inicio del bloque continuo más reciente de la misma `razon_social`, usando window functions tipo "gaps and islands" (`LAG`+`SUM` acumulado) sobre TODO `historico_empleados`. Un bloque nuevo se abre si cambia `razon_social` **o** si hay un hueco de más de 62 días sin registro para ese empleado.
- **⚠️ Bug encontrado y corregido durante la implementación (antes de sincronizar):** la primera versión sin el control de huecos fusionaba incorrectamente períodos de empleo NO continuos que compartían razón social — ej. Cardona Llerena Sandra Iveth trabajó en CCAN de mar-ago/2023, no aparece en el histórico entre sep/2023 y jun/2026 (hueco real de ~3 años, reingresó jul/2026), pero el algoritmo original la fusionaba como si fueran 40 meses continuos. Se agregó el filtro de huecos >62 días, verificado que corrige este caso (y 2 más detectados con el mismo patrón) sin afectar los casos legítimos de contrato renovado sin salida real (ej. Muñoz Rosero Anne Ferneira, CCAN desde 2018, contrato renovado en 2023 sin hueco en los registros — su antigüedad de ~8 años se mantiene correcta).
- **Frontend** (`dashboard-empleados.html`): `loadIngresoRealMap()` (cargado junto a `loadCargoMap()`/`loadHotelMap()` al iniciar) + `fechaIngresoReal(identificacion, razonActual, centroActual, fechaIngresoRaw)`. Solo aplica el ingreso real si la empresa actual coincide con la del mapa — por `razon_social` exacta **o** por `centro_costo` normalizado (`normalizeCecoKey`), porque el bucket `hotel_name` que devuelve la API en vivo usa nombres distintos a los códigos `razon_social` del histórico (ej. API="Hotel_Canabal" vs histórico="CCUC"/"CCAN" — confirmado que un mismo NIT-bucket de la API puede contener varios códigos `razon_social`, igual que el caso ya conocido GHS+TCLUB). Aplicado en los 3 lugares donde se calcula `_meses` (modo API en vivo `loadData()`, modo histórico `loadHistoricoEmps()`, y refresco de período `onPeriodChange()`).
- **El campo "Fecha de ingreso" NO se tocó** — sigue mostrando el dato crudo (inicio del contrato/cargo vigente), solo "Antigüedad" usa el valor corregido. Esto puede verse inconsistente a simple vista (F. Ingreso reciente + Antigüedad de años) pero es intencional: F. Ingreso es un campo literal de la fuente, Antigüedad es el cálculo de negocio.
- **Verificado en navegador:** Martinez Gonzalez Ricardo Andres (GHS) — tabla principal y campo resumen del panel pasan de "3a 6m" a "**4 años**", coincidiendo con el total ya mostrado en "Historial por empresa". Auditado programáticamente contra los 997 empleados activos de agosto 2026 buscando saltos sospechosos (>36 meses de diferencia entre antigüedad cruda y corregida) — solo quedó 1 caso tras el fix de huecos, y se confirmó legítimo (Muñoz Rosero, sin hueco real en los datos).
- **Alcance NO cubierto (decisión de negocio ya tomada, no tocado):** cambios de razón social (empresa distinta) siguen sin sumarse en la tabla principal — eso sigue siendo exclusivo del "Historial por empresa" del panel de detalle, por la decisión original del usuario.
- Sincronizado en **main** (`adff2d7`) y **worktree** (`20d80a5`). Cambio en `servidor.py` → **ambos servidores reiniciados** (8080 y 8081).

### Hallazgo ampliado + fix: fecha_ingreso se reinicia con CUALQUIER cambio de cargo/contrato, no solo de razón social (2026-08-24) ✅
- **Reportado por el usuario sobre su propio registro** (Martinez Gonzalez Ricardo Andres, GHS): el campo "Antigüedad" mostraba 3a 6m, pero el historial de abajo mostraba 3 bloques continuos desde ago-2022 (Aprendiz Sena → Analista Junior Revenue → Analista BI) que suman 4 años reales.
- **Corrige/amplía el hallazgo previo documentado más abajo** ("Antigüedad se reinicia al cambiar de razón social"): confirmado con datos reales vía `/proxy/historico/trayectoria` que `fecha_ingreso` en `historico_empleados` se reinicia con **cualquier cambio de cargo o tipo de contrato**, incluso dentro de la MISMA empresa/razón social (este caso: Aprendiz→Analista Junior, ambos en GHS, `fecha_ingreso` saltó de 2022-08-03 a 2023-02-03). El comentario que había en el código (`"cambio de cargo... fecha_ingreso no se actualiza en el origen"`) era una suposición incorrecta — por eso el timeline ya usaba `s.fecha` (no `s.fecha_ingreso`) para bloques de cambio de cargo, así que el historial visual siempre fue correcto; el problema era solo el campo resumen "Antigüedad" arriba.
- **Fix:** en `empDetailLoadHistory()`, se suma la duración de todos los bloques del historial y se muestra una línea nueva al final: "Antigüedad total en el grupo" (solo si hay más de 1 bloque). El campo "Antigüedad" de "Información laboral" arriba se dejó igual (consistente con la tabla principal, decisión previa de la sesión) — el total real ahora vive junto al historial que lo sustenta. CSS: `.emp-hist-total`.
- **Verificado en navegador:** para Ricardo Martinez, campo superior sigue en "3a 6m" (desde el último cambio de cargo), nueva línea de total muestra correctamente "4 años" (desde el ingreso real en ago-2022, sin huecos entre bloques).
- Sincronizado en **main** (`d9c5eb3`) y **worktree** (`d6779bf`). Solo HTML/JS, no requiere reinicio de servidor.

### Bug corregido: cargo duplicado en "Historial por empresa" por mismatch de mayúsculas/minúsculas (2026-08-24) ✅
- **Reportado por el usuario con captura:** en el panel de hoja de vida de Torres Torralvo Andrea Carolina (GHS), el cargo "Analista Business Intelligence" aparecía **3 veces** como nodos distintos en la línea de tiempo (uno incluso con typo "Inteligence"), en vez de un solo bloque continuo.
- **Causa raíz (confirmada con SQL directo contra `172.19.0.160`):** `historico_empleados.cargo` guarda el mismo cargo real con distinta capitalización entre meses (`"Analista Business Inteligence"` mixto vs. `"ANALISTA BUSINESS INTELIGENCE"` todo mayúsculas). Ya existía el mapeo correcto en `cargo_unificacion` (`ANALISTA BUSINESS INTELIGENCE → ANALISTA BUSINESS INTELLIGENCE`), pero `CARGO_MAP` en el frontend se poblaba con la clave tal cual venía (case-sensitive) y `cleanCargo()` hacía lookup exacto `CARGO_MAP[c]` — fallaba para cualquier variante que no coincidiera carácter por carácter. El backend (`servidor.py`, `_where_cargo`) ya comparaba con `UPPER(...)`, solo el frontend tenía el bug.
- **Fix:** normalizar a mayúsculas tanto la clave al poblar `CARGO_MAP` (dentro de `loadCargoMap()`) como el lookup en `cleanCargo()` — `dashboard-empleados.html` ~línea 4214-4228.
- **Verificado en navegador (Chrome MCP):** `cleanCargo('Analista Business Inteligence')`, `cleanCargo('ANALISTA BUSINESS INTELIGENCE')` y `cleanCargo('Analista Business Intelligence')` ahora devuelven el mismo valor normalizado. El historial de Torres Torralvo pasó de 4 nodos a **2 nodos correctos**: "Asistente De Presidencia" (2018-2023) → "Analista Business Intelligence" (2023-actual, continuo).
- Sincronizado en **main** (`285e0a0`) y **worktree** (`329a011`). Solo HTML/JS, no requiere reinicio de servidor.

### Feature nueva: panel "hoja de vida" al clic en empleado (2026-08-24) ✅
- Nuevo panel lateral (desktop + móvil), reemplaza el clic directo a trayectoria salarial en tab Empleados. Muestra todos los campos disponibles del empleado (identificación, sexo, edad, fecha nacimiento, hotel, razón social, centro de costo, cargo, operación, tipo contrato, fecha ingreso, vencimiento contrato, antigüedad, sueldo base) + botón para abrir la trayectoria salarial existente (`empTlLoad`, sin duplicar esa lógica).
- Funciones: `empDetailOpen(nombre, razonSocial)`, `empDetailClose()`, `empDetailShowTrajectory()`. HTML: `#emp-detail-backdrop`, `#emp-detail-panel`. Patrón visual: mismo que `ceco-sheet` pero deslizante desde la derecha (`translateX`) en vez de abajo.
- Probado en navegador: abre correctamente desde fila de tabla y desde tarjeta móvil.
- Sincronizado en **main** (`535b936`) y **worktree** (`83d2de4`).

### ⚠️ Hallazgo de negocio: Antigüedad se reinicia al cambiar de razón social (2026-08-24) — resuelto con historial en el panel ✅
- **Detectado a pedido del usuario al revisar la fórmula de edad.** `fecha_ingreso` en `historico_empleados` NO es una fecha de ingreso original persistente — se reinicia cada vez que el empleado cambia de razón social dentro del grupo Faranda (confirmado con `git`/SQL: mismo `codigo_empleado`, múltiples `fecha_ingreso` distintas, una por cada razón social). **361 de 996 empleados activos en julio 2026 (36%) están afectados.**
- Ejemplo real: empleado ingresó en sept-2023 (Bantú), pasó a Casa Don Luis en feb-2025, a FC Cartagena en nov-2025 — Antigüedad mostrada hoy: ~9 meses. Antigüedad real con el grupo: ~2 años 11 meses.
- Relevante legalmente: en Colombia, sustitución patronal dentro de un mismo grupo económico normalmente **conserva** la antigüedad para prestaciones — el sistema fuente no la conserva.
- **Decisión del usuario:** mantener la columna "Antigüedad" de la tabla principal de Empleados tal cual (sin cambios) — pero en el panel de detalle ("hoja de vida"), agregar el historial completo por empresa con fechas de ingreso/salida de cada una. Implementado como nueva sección "Historial por empresa" en `empDetailOpen`/`empDetailLoadHistory()`, reutilizando `/proxy/historico/trayectoria` (agrupa por `nombre_personalizado`, no por `razon_social` cruda, para evitar bloques duplicados por el caso CCAR/SPIA que comparten NIT — ver hallazgos previos de esta sesión).
- **Actualización 2026-08-24:** se agregó también `cargo` (último registrado en cada empresa) y `antigüedad` por bloque (helper `_monthsBetween`) — commits `28d9308` (main) / `7681f39` (worktree).
- **Rediseño 2026-08-24 (a pedido del usuario, con referencia visual):** las "tarjetas" se reemplazaron por una **línea de tiempo vertical con nodos conectados** (círculos + línea, cronológico de más antiguo arriba a actual abajo, nodo actual resaltado en verde). Además, los bloques ahora se separan por **cargo Y empresa** (no solo empresa) — una persona puede tener varios cargos dentro de la misma nómina (ascensos), antes se colapsaban mostrando solo el último. Clases CSS: `.emp-hist-timeline`, `.emp-hist-node`, `.emp-hist-dot`, `.is-actual`.
- Sincronizado en **main** (`6ef6294`) y **worktree** (`607f364`).

### ⚠️ Bug importante corregido: Edad y Antigüedad congeladas desde abril 2026 (2026-08-24) ✅
- **Encontrado al revisar la fórmula de edad a pedido del usuario.** `const HOY = new Date('2026-04-23')` estaba hardcodeada — un solo commit de creación, nunca actualizada (confirmado con `git log -p`). `calcAge()` y `calcMeses()` (Edad y Antigüedad en toda la tabla/tarjetas de Empleados, y en el nuevo panel de hoja de vida) calculaban todo como si siempre fuera el 23 de abril de 2026, **4 meses atrasado** respecto a la fecha real (24-ago-2026 al momento de la corrección).
- La fórmula en sí (resta de años + ajuste si el cumpleaños/aniversario aún no pasó ese año) es correcta — el problema era solo la fecha base congelada.
- **Fix:** cambiado a `new Date()`. Verificado en navegador que `HOY` ahora coincide con la fecha/hora real del sistema.
- **Alcance de la revisión:** no se rediseñó para ser "period-aware" (edad/antigüedad como de la fecha del período histórico seleccionado) — se dejó como "edad/antigüedad a la fecha real de hoy", igual que el resto del dashboard usa `new Date()` en otros lugares (ej. `_now` en `loadHeaderKPIs()`). Si en el futuro se pide edad/antigüedad "a fecha del período visto" en vez de "a hoy", seria un cambio de diseño distinto, no cubierto aquí.
- Sincronizado en **main** (`00ffe75`) y **worktree** (`372ed8c`).

### Lag al cambiar de filtro/hotel — precarga redundante de Salarios/IR (2026-08-21) ✅
- **Diagnóstico:** `_scheduleAllTabsUpdate()` (debounce 400ms, dispara con CUALQUIER cambio de filtro) precargaba Salarios CECO e Ingresos/Retiros **aunque no fueran el tab activo** — a diferencia de Payroll/Estadísticas, que correctamente solo recargan si son el tab activo. Medido con Chrome DevTools vía MCP: en el mes en vivo (agosto 2026), un solo toggle de filtro de hotel disparaba **34 peticiones de red**, incluyendo **8 llamadas** a la API externa de nómina (`/proxy/rrhh/ingresos-retiros`, ~500ms c/u medido directamente) — el origen real del lag reportado.
- **Fix:** se removieron las 2 líneas de precarga incondicional en `_scheduleAllTabsUpdate()` (`dashboard-empleados.html`). La recarga real de Salarios/IR ya la cubre: el debounce de 350ms en `loadHeaderKPIs()` cuando esas pestañas SÍ son la activa, y el handler de clic en tabs (ya recargaba datos frescos al entrar, línea 5276-5277 — la precarga era pura redundancia).
- **Verificado antes/después con el mismo toggle:** 34→**12** peticiones, 8→**4** llamadas a la API externa (las 4 restantes son necesarias, para las tarjetas KPI Ingresos/Retiros del header, visibles en todas las pestañas). En meses históricos (Postgres, sin API externa) el costo síncrono por filtro ya era bajo (~80ms), sin "long tasks" detectados.
- **Efecto secundario esperado (aceptado):** al entrar por primera vez a Salarios CECO o Ingresos/Retiros después de cambiar un filtro en otra pestaña, se verá un breve loading en vez de datos ya precargados — trade-off correcto dado el costo real medido.
- Sincronizado en **main** (`8ddd3cd`), **worktree** (`1765200`) y **Mac mini** (pull confirmado 2026-08-21). Solo HTML/JS, no requiere reinicio de servidor.

### ⚠️ Corrección: GHS NO incluye "The Club" de Servihoteles (2026-08-21) ✅
- **Error propio detectado por el usuario:** al reutilizar `PR_HOTEL_SERV_PATTERNS`, asumí sin verificar que el patrón `'The Club': ['THE CLUB CTG']` correspondía al hotel GHS (por el comentario `NIT_UNICO['900657316']='GHS' # GHS + The Club`). Estaba mal — **"THE CLUB CTG" es una operación de venta de multipropiedad/timeshare en Cartagena** (cargos: Director The Club, Gerente de Mercadeo, Representante de Ventas Closer/Liner, Promotor de Mercadeo), sin ninguna relación con el hotel GHS.
- **Impacto del error:** GHS llegó a mostrarse como 156 (122 reales + 34 de esta operación de Cartagena) en tabla Empleados, gráficos y tarjetas KPI.
- **Corregido:** removido `'GHS': ['THE CLUB CTG']` de `SERV_HOTEL_PATTERNS` (`servidor.py`) y la traducción `servHotel==='The Club'?'GHS':servHotel` en `loadHistoricoEmps()` (`dashboard-empleados.html`). Esas 34 personas vuelven al genérico "Servihoteles" — no tienen hotel propio asignable en este sistema (no confundir con el "GHS+TCLUB" real, que sigue unido por diseño vía `NIT_UNICO`, eso sí es correcto).
- **Verificado:** GHS vuelve a 122 (antes 156), CCAR+SPIA se mantiene en 81 (no afectado por este revert).
- **Auditoría de los patrones restantes (2026-08-21):** Hotel Caribe (MYS) — cargos verificados = mesero/camarera/cocina/aseador/steward, parecían personal operativo real de hotel — **pero el usuario indicó explícitamente que tampoco se debe fusionar** (decisión de negocio, no error técnico como GHS; ver entrada siguiente). La Mantilla (MAN) y Casa Don Luis (CDL) — 0 empleados coinciden actualmente con esos patrones en julio 2026, sin impacto. FC Cartagena (CCAR) ya se había verificado y confirmado con cargos reales (mesero, aprendiz, arquitecta de obra) — este SÍ se mantiene fusionado.

### ⚠️ Corrección adicional: MYS (Hotel Caribe) tampoco debe fusionar Servihoteles (2026-08-21) ✅
- Aunque los cargos de los 188 empleados Servihoteles con centro_costo HCARIBE/H CARIBE parecían legítimamente operativos de hotel, el usuario pidió explícitamente revertir esta fusión también, mismo tratamiento que GHS/The Club.
- Removido `'MYS': ['HCARIBE','H CARIBE']` de `SERV_HOTEL_PATTERNS` (`servidor.py`) y agregado `'Hotel Caribe'` a la exclusión en `loadHistoricoEmps()` (junto a `'The Club'`) en `dashboard-empleados.html`.
- **`SERV_HOTEL_PATTERNS` activo ahora solo tiene 2 entradas:** `MAN: ['MANTILLA']` (0 empleados actualmente) y `CCAR: ['FARANDA COLLECTION CARTAGENA']` (el único que realmente suma gente hoy: +3, de 78→81). `CDON: ['CDL']` también activo, 0 empleados actualmente.
- Verificado: MYS vuelve a 114 (antes 302, era 114+188). GHS=122 y CCAR+SPIA=81 no afectados.
- Sincronizado en **main** (`6e34a69`) y **worktree** (`3bf4481`), ambos servidores reiniciados.

### ⚠️ Corrección adicional: doble conteo Servihoteles ↔ hoteles reclasificados (2026-08-21) ✅
- **Detectado por el usuario:** los 3 empleados de FC Cartagena contaban DOS veces — 230 en Servihoteles Y 78→81 en CCAR (`SERV_HOTEL_PATTERNS` solo sumaba al destino, nunca restaba del origen). El frontend (`loadHistoricoEmps`) ya reasignaba bien (reemplaza `hotelKey`, no suma) — el duplicado era solo en el backend (`activos-count`).
- **Fix:** al consultar `razon_social=SERV` (sola o combinada), ahora se excluyen empleados cuyo `centro_costo` coincide con CUALQUIER patrón activo de `SERV_HOTEL_PATTERNS` (ya "pertenecen" a otro hotel). Al consultar el hotel destino, se siguen incluyendo vía el OR existente — es una reclasificación real (mover), no una suma.
- **Verificado:** SERV 230→227 (-3). CCAR+SPIA=81, GHS=122, MYS=114 sin cambio. Total general sin filtro sigue en 996. Frontend confirmado consistente: 227+81=308.
- Sincronizado en **main** (`53938af`) y **worktree** (`87aaaf7`), ambos servidores reiniciados.
- Sincronizado en **main** (`2743baf`) y **worktree** (`6048fe9`), ambos servidores reiniciados.

### servidor.py — tarjetas KPI Análisis no incluían empleados Servihoteles reclasificados (2026-08-21) ✅
- **Seguimiento del fix anterior (reclasificación Servihoteles):** la tabla de Empleados y el gráfico de dona (leen de `allEmps` en el navegador) ya mostraban FC Cartagena=81 correctamente, pero las tarjetas KPI "Activos Inicio/Fin Mes" arriba en Análisis seguían en 78 — usan un endpoint de servidor aparte (`/proxy/historico/activos-count`) que filtra por `razon_social` literal (`CCAR,SPIA`), sin saber nada de los 3 empleados guardados como `SERV`.
- **Fix (esta vez sí en `servidor.py`, con aprobación explícita del usuario):** se agregó `SERV_HOTEL_PATTERNS` (espejo de `PR_HOTEL_SERV_PATTERNS` del frontend) y se extendió el WHERE de `activos-count`: si la razón solicitada tiene un patrón Servihoteles asociado, incluye también empleados `SERV` cuyo `centro_costo` coincide. Cambio **scoped solo a `activos-count`** — no se tocó `razon_where()` compartida (usada por otros 10 endpoints) para minimizar riesgo.
- **Verificado con curl y en navegador:** `fecha=2026-07-01&razon_social=CCAR,SPIA` → 81 (antes 78); con `operacion=SOCIO` → 0 (correcto); junio (nunca tuvo el bug) sigue en 83.
- Sincronizado en **main** (`9212ff7`) y **worktree** (`0e35987`) — **ambos servidores reiniciados** (8080 y 8081, ya que es cambio de Python, no HTML). Falta sincronizar/reiniciar en el Mac mini si se retoma (aunque ahí no sirve el dashboard, solo mantiene el repo).

### dashboard-empleados.html — KPI "Activos Fin/Inicio Mes" ignoraba operación=0 (2026-08-21) ✅
- **Síntoma reportado por el usuario:** "el filtro de Operación está como invertido" — al filtrar un hotel sin socios (ej. FC Cartagena) por Operación=Socio, la tarjeta "Activos Fin Mes" seguía mostrando el conteo total (78) en vez de 0.
- **Causa real:** en `loadHeaderKPIs()`, `activosFin`/`activosFinLY` se calculaban con `actFinRes?.activos || null`. Como `0` es falsy en JS, un resultado legítimo de 0 se convertía en `null`. `animCountKpi()` tiene `if (target == null) return;` — ignora silenciosamente valores null, dejando el valor ANTERIOR en pantalla. Parecía que el filtro no aplicaba o estaba invertido, pero el backend (`/proxy/historico/activos-count`) siempre devolvió el valor correcto (confirmado con `curl` directo: 0 para FC Cartagena+Socio).
- **Fix:** cambiado `|| null` por `?? null` (nullish coalescing) en los 3 sitios afectados (`activosFin`, `activosFinLY`, branch de mes actual con `filteredEmps.length`).
- **Probado en navegador (Chrome MCP + network requests):** FC Cartagena + Operación=Socio + jul-2026 → las 5 tarjetas KPI muestran 0 correctamente.
- Sincronizado en **main** (`1452e80`), **worktree** (`be685e5`) y **Mac mini** (pull confirmado 2026-08-21 — no funcionalmente necesario ahí, el Mac mini no sirve el dashboard, solo mantiene el repo alineado).

### dashboard-empleados.html — reclasificación Servihoteles por centro_costo en tab Empleados/Análisis (2026-08-21) ✅
- **Contexto:** `historico_empleados.razon_social` representa el NIT empleador legal (SERV genérico para todo Servihoteles), no dónde trabaja físicamente el empleado. Se descubrió que 3 personas de FC Cartagena (Blanco Gomez Carlos Andres-Mesero, Burgos Diaz Melany-Aprendiz, Salgado Alvarez Damaris Johana-Arquitecta Residente/Obra) están contratadas bajo el NIT de Servihoteles (901418135) aunque su `centro_costo` dice literalmente "FARANDA COLLECTION CARTAGENA" — mismas 3 personas que en junio 2026 SÍ estaban correctamente bajo CCAR (cuando el split aún existía).
- **Investigado y descartado:** no hay precedente de código para este split en `historico_empleados` — el split de junio (CCAR=3, SPIA=80 separados) no pudo venir del `cierre_mes.py` actual (bu_codigo ya era "Local" uniforme desde entonces también, verificado en `raw_data` guardado); probablemente fue una corrección manual puntual, no un mecanismo repetible.
- **Fix aplicado (alcance acordado: Empleados + Análisis, frontend only, sin tocar servidor.py ni la base):** en `loadHistoricoEmps()`, cuando `razon_social==='SERV'`, se reutiliza `PR_HOTEL_SERV_PATTERNS`/`prClassifyServ()` (ya existente para Payroll) para reclasificar por `centro_costo` — sin modificar el dato guardado. Traduce `'The Club'→'GHS'` para consistencia con el label ya usado en histórico (GHS/TCLUB comparten razón social ahí).
- **NO cubre:** Salarios CECO ni Estadísticas Hoteles (usan endpoints agregados server-side en `servidor.py` sin `centro_costo` disponible en el navegador — requeriría cambios SQL, no se hizo). Tampoco IR (`historico_ir` no tiene columna `centro_costo`, imposible aplicar ahí).
- **Probado en navegador (Chrome vía MCP):** tab Empleados, período Jul 2026, filtro razón social=FC Cartagena → **81 empleados** (antes 78), incluye a Blanco Gomez Carlos Andres correctamente agrupado.
- Sincronizado en **main** (`3951696`) y **worktree** (`e36c58b`). No aplica al Mac mini (no sirve el dashboard).

### cierre_mes.py — fixes aplicados y sincronizados (2026-08-19) ✅
- **Fix 1 — validación de hoteles faltantes:** `validar_hoteles_recibidos()` compara los NITs devueltos por `GET /rrhh/empleados-activos-detalle` contra los NITs activos esperados (tabla `empresas`) y loguea WARNING explícito por cada NIT faltante (antes esos hoteles quedaban en cero sin ninguna señal en el log). Se mantiene.
- **Fix 2 — clasificación por centro_costo — REVERTIDO.** Se había agregado `CENTRO_COSTO_OUTSOURCING`/`clasificar_outsourcing()` para separar Servihoteles (SERVMYS/SERVTCLUB/etc.) y CCAR/SPIA en `historico_empleados`. El usuario aclaró que esa separación **es exclusiva del endpoint/vista de nómina** (Payroll, vía `PR_HOTEL_SERV_PATTERNS` en el frontend) y **no aplica a `historico_empleados`** — se removió por completo el código (commit `bce7a79`/`e0d9fa8`).
- **Fix 3 — NIT_UNICO extendido (el que sí queda):** al revertir el Fix 2 se descubrió que la resolución de razón social para NITs compartidos sin `clasificar_outsourcing` era **no determinística** — sin filas propias en `hotel_unificacion` para SERVMYS/SERVTCLUB/SPIA, el "ganador" del NIT dependía del orden (no garantizado) de la consulta SQL en `cargar_nit_razon_map()` (una corrida dio `SERV`, la siguiente `SERVTCLUB`, mismos datos). Se extendió `NIT_UNICO` (mismo patrón que ya forzaba GHS) para fijar: `'901418135': 'SERV'`, `'901936953': 'CCAR'` — determinístico en ambos métodos de cierre.
- Sincronizado en **main** (`bce7a79`), **worktree** (`e0d9fa8`) y **Mac mini** (pull confirmado 2026-08-19, compila OK). Las tres copias alineadas.

### Julio 2026 — reconstrucción completa (2026-08-19) ✅
- **Belvedere/Club House recuperados:** el endpoint masivo `empleados-activos-detalle` (usado por el LaunchAgent día 1) no devuelve el NIT 900345450 (Faranda_Bogota = CLUB + FEB) en absoluto — confirmado que la propia API lo omite (su `grand_activos` no lo incluye). Pero el endpoint por-NIT `/nomina/reporte` SÍ tiene los datos completos (46 empleados: 26 Club House + 20 Belvedere). Se hizo una recarga manual puntual de julio con `python3 cierre_mes.py --ano=2026 --mes=7` (sin `--empleados-activos-detalle`, usa el método clásico por-NIT/`cierre_empleados()`), que sí trae este hotel.
- **Resultado final julio (con NIT_UNICO determinístico):** 996 empleados. CLUB=24, FEB=20, SERV=230, CCAR=78 (clasificación genérica correcta, sin split de submarcas).
- **No se cambió el LaunchAgent día 1** (sigue usando `--empleados-activos-detalle` por ser más rápido) — esto fue solo una recarga manual de julio. Si el mismo patrón (hotel completo ausente del endpoint masivo) se repite en meses futuros, el fix es correr `cierre_mes.py --ano=YYYY --mes=MM` sin el flag para ese mes específico.
- **Único hueco real que queda en julio: PSOL (NIT 800157788)** — sigue sin responder (antes error de login SQL Server, ahora timeout) incluso vía `/nomina/reporte`. Es un problema del proveedor NominaApp para este NIT específico, no resoluble desde acá. Ver más abajo.

### Endpoint `GET /rrhh/ingresos-retiros` (IR) — Faranda_Bogota: RESUELTO con fallback por snapshot (2026-09-03)
- Auditado 2026-08-19 / 21 y **reconfirmado en vivo 2026-09-03**: la respuesta omite **Faranda_Bogota** (CLUB/FC Bogotá + FEB/Belvedere, NIT 900345450) — 16 buckets, ninguno es ese NIT, con los 6 `tipo_nomina_id`. CLUB/FEB traían IR normal hasta 2026-05; desde jun-2026, cero. El dato SÍ existe en Zeus (reporte que mostró el usuario) y en `empleados-activos-detalle`/`nomina/reporte`.
- **✅ Corregido con fallback por snapshot** — ver *"Sesión 2026-09-03"* arriba. `completar_ir_desde_snapshot()` en `cierre_mes.py` + `backfill_ir_snapshot.py`. Backfill jun–ago ya en BD (32 filas, `origen='snapshot'`). CLUB y FEB ya cuadran. Falta solo **reportar a NominaApp** para que arreglen su API (entonces los datos reales vuelven a mandar por la guardia).
- **Hotel_PS (PSOL, NIT 800157788):** sigue ausente de IR — pero PSOL tampoco está en el snapshot de empleados (no viene en `empleados-activos-detalle`), así que el fallback por snapshot NO aplica. Queda sin IR hasta que el proveedor levante la base `NominaEmp`.
- **Bug latente sin corregir:** `insertar_ir()` hace `hotel_bu_map.get((hotel_api, bu_low))` **sin fallback ni warning** — si algún hotel/bu no mapea, inserta `razon_social=NULL` silenciosamente. No se ha materializado pero es un riesgo pendiente.
- No se tocó nada aquí — solo quedó documentado. La separación Servihoteles/CCAR-SPIA NO aplica a IR tampoco (mismo criterio que empleados).

### 🟢 Novedad 2026-08-21 — el proveedor arregló Faranda_Bogota en `empleados-activos-detalle`
- Reverificado en vivo: **`Faranda_Bogota` ya aparece de nuevo** en `GET /rrhh/empleados-activos-detalle` (17 hoteles ahora, antes 16) — 43 empleados activos hoy (21 Belvedere/BE + 22 Club House/Local). El endpoint `/nomina/reporte` para este NIT sigue funcionando igual que antes.
- **Sigue faltando solo en `ingresos-retiros`** (reconfirmado el mismo día, sin cambio ahí).
- **PSOL sin cambios** — mismo error de login SQL Server en `/nomina/reporte`, sigue ausente en `empleados-activos-detalle` e `ingresos-retiros`. Solo P&G funciona para PSOL.
- **Implicación para el cierre de septiembre (1-sep-2026, vía Mac mini, LaunchAgent día 1 con `--empleados-activos-detalle`):** si el proveedor mantiene a Faranda_Bogota disponible, el cierre automático debería traerlo correctamente esta vez, sin necesitar el workaround manual (`cierre_mes.py --ano=YYYY --mes=MM` sin el flag) que se usó para julio. Solo PSOL seguiría fallando. **Verificar esto al certificar el cierre de septiembre.**
- **✅ CERTIFICADO 2026-09-01 (verificado contra BD):** el cierre de agosto 2026 corrió bien.
  - `historico_empleados` 2026-08-01: **1003 empleados**, 19 razones sociales.
  - **Faranda_Bogota entró automáticamente, SIN workaround manual** — `CLUB=25`, `FEB=21`, `COLH`(Club House)`=34`. El proveedor mantuvo el NIT 900345450 en `empleados-activos-detalle`, como se anticipaba. Sub-punto cerrado.
  - `historico_ir` 2026-08: **105 movimientos**, OK.
  - **Único faltante: PSOL** (NIT 800157788) — el log lo marca explícito (`⚠️ NIT 800157788 ... quedará SIN empleados en este cierre`). Es el corte del proveedor del punto de nómina julio/agosto, no un fallo del cierre.

### Cierres migrados a Mac mini dedicado (launchd) — instalado 2026-08-18, ✅ certificado 2026-09-01
- **Completado:** el Mac mini `macserverfos001s-Mac-mini` (usuario `macserverfos001`, ya usado para otras cargas: `com.faranda.mlx-glmocr`, `com.faranda.mlx-qwen35-9b`, sin reposo — `sleep=0` por powerd/coreaudiod/screensharingd) ahora corre los 6 LaunchAgents de cierre (`com.bia.cierre-mes-dia1/5/7`, `com.bia.nomina-pyg-dia1/5/7`).
- Repo en `~/rrhh-report`. **venv en `~/rrhh-report/.venv/`** — los LaunchAgents invocan `/Users/macserverfos001/rrhh-report/.venv/bin/python` (tiene psycopg2 2.9.12). ⚠️ `/opt/homebrew/bin/python3.12` **NO** tiene psycopg2 — no probar con ese, no lo usa nadie. `.env` con las mismas credenciales de `172.19.0.160`.
- **Rutas de logs en el Mac mini (distintas a la Mac local):** stdout de launchd en `~/rrhh-report/logs/cloud/` (`cierre_mes_dia1.log`, `nomina_pyg.log`, + `*_err.log`); además `cierre_mes.py` escribe su propio log con timestamp en `~/rrhh-report/logs/cierres/cierre_YYYY_MM_*.log`.
- **Plists del Mac mini:** `cierre-mes-dia1` → Day 1 / Hour 5:00, `--empleados-activos-detalle`. `nomina-pyg-dia1` → Day 1 / Hour 5:30, `--mes-anterior --fuente ambas`. (dia5/dia7 análogos para los días 5 y 7).
- Se creó `deploy/mac-mini/` (README, `INSTALACION_MAC_MINI.md`, `install-macmini-launchd.sh`, plantillas `.plist`) y se mergeó a `main` (commit `ee8e495`).
- Se desactivaron (`launchctl unload`, no borrados) los 6 LaunchAgents equivalentes en la Mac de trabajo — solo quedan activos `com.bia.dashboard-talento` y `com.bia.dashboard-talento-dev` (dashboard, no cierres).
- Administrador del Mac mini / servidor de la API de nómina: sin identificar por nombre — es el mismo Mac mini que ya corre otras cargas de Faranda.

#### ⚠️→✅ 2026-09-01 — los cierres estaban corriendo en AMBAS máquinas; corregido
- **Hallazgo al certificar el cierre de septiembre:** el `launchctl unload` del 18-ago **no era permanente** — los 6 `.plist` seguían en `~/Library/LaunchAgents/` (primer nivel), así que se recargaron solos en algún login/reinicio. Confirmado el 2026-09-01: esta Mac corrió el cierre de agosto (`com.bia.cierre-mes-dia1`, log `logs/cierres/launchagent_dia1.log` @ 05:15, `runs=1`, exit 0) **además** del Mac mini — evidencia de doble ejecución: el log local dice `⚠️ Ya existen 1003 registros para Agosto 2026` (otra máquina los cargó antes de las 05:15), y `historico_nomina_pyg` agosto se cargó en 2 oleadas (05:32 = Mac mini programado ~05:30; 07:32–07:56 = esta Mac, dormida a las 05:30, corrió tarde). BD quedó correcta igual porque el cierre es idempotente (DELETE+INSERT por período), pero era trabajo duplicado + riesgo de escrituras concurrentes.
- **Decisión del usuario 2026-09-01:** los cierres los hace **SOLO el Mac mini**.
- **Aplicado en esta Mac (2026-09-01):** `launchctl bootout gui/$(id -u)/<label>` de los 6 (`com.bia.cierre-mes-dia1/5/7`, `com.bia.nomina-pyg-dia1/5/7`) + los `.plist` movidos a `~/Library/LaunchAgents/disabled-cierres-migrados-macmini/` (con `README.txt` con instrucciones de reactivación). launchd solo escanea el primer nivel, así que ya no se recargan. **Verificado:** `launchctl list | grep bia` ahora solo muestra `com.bia.dashboard-talento`, `com.bia.dashboard-talento-dev`, `com.bia.backup-postgres`.
- **NO tocados en esta Mac (siguen activos a propósito):** `com.bia.dashboard-talento` + `-dev` (sirven el dashboard 8080/8081), `com.bia.backup-postgres` (backup diario de PostgreSQL, decisión previa: solo local en esta Mac).
- **✅ CERTIFICADO en el Mac mini 2026-09-01** (el usuario corrió los comandos de verificación allá y pegó la salida):
  - **6 LaunchAgents cargados**, todos `last exit code = 0`.
  - **Repo al día:** HEAD `2f04168` = `origin/main` (ya no `ee8e495` — alguien hizo pull). Incluye el fix de `codigo_empleado` en `historico_ir` (`3a6455d`).
  - **Cierre empleados+IR (`cierre-mes-dia1`):** corrió **05:00:05 del 1-sep**, `[EMPLEADOS] Sin datos previos — inserción fresca` → **1003 empleados** + **105 IR** → `✅ CIERRE COMPLETADO — Agosto 2026`. `cierre_mes_dia1_err.log` = 0 bytes. El "Sin datos previos" confirma que **el Mac mini cargó primero** (05:00); la Mac local a las 05:15 solo reescribió encima.
  - **Nómina+P&G (`nomina-pyg-dia1`):** `runs = 1`, `last exit = 0`, `nomina_pyg_err.log` = 0 bytes. Log `logs/cloud/nomina_pyg.log` (05:34): **16/18 NITs OK**, P&G **21/21 OK** (incluye `company=8` = PSOL, cuyo P&G sí funciona). Únicos `ERROR` (HTTP 400): `800157788` (PSOL) y `900385136` (DORA) — corte del proveedor, ver sección de abajo; DORA suele resolverse en el reintento día 5/7.
  - **`sleep 0` / `disksleep 0`** confirmado.
  - `nomina-pyg-dia5/dia7` y `cierre-mes-dia5/dia7`: `runs = 0` (aún no llega su fecha — disparan el 5 y 7-sep). **Verificar el 5-7 sep** que corran y que recuperen DORA (900385136).
- **Conclusión:** los cierres quedan **SOLO en el Mac mini** y está certificado funcionando. Doble ejecución eliminada. Único gap real = PSOL (proveedor).

### Cierre nómina Julio + Agosto 2026 incompletos en `historico_nomina_pyg` — requiere acción del proveedor

#### Revisión 2026-09-01 (verificado contra BD + `logs/cierres/nomina_pyg_dia1.log` del 2026-09-01 07:56)
- **Estado actual `historico_nomina_pyg` fuente='nomina':**
  - Junio 2026: 18/18 NITs ✅
  - Julio 2026: **17/18** — falta `nit=800157788` (PSOL / Puerta del Sol, pyg_company_id=8)
  - Agosto 2026: **16/18** NITs con datos, pero en realidad **3 huecos** (ver Revisión 3): PSOL (800157788), DORA (900385136) **y CCAR (901936953)** — este último cargó un cascarón vacío, no dio error.

#### Revisión 3 — 2026-09-03, cuadre del cierre de agosto + hallazgo CCAR
- **Cuadre pedido por el usuario.** `historico_empleados` agosto **CUADRA internamente**: jul 996 + entraron 58 − salieron 51 = ago **1003**, sin cédulas duplicadas. Crecimiento neto real +7.
- **`historico_empleados` vs `historico_ir` agosto:** de 58 que entraron al snapshot, 51 tienen `I`/`IR` en IR → **7 sin registro** (6 CLUB + 1 FEB). De 51 que salieron, 46 tienen `R`/`IR` → **5 sin registro** (5 CLUB). Los **12 son de CLUB/FEB** (NIT 900345450 Faranda_Bogota) = limitación ya conocida: el API `/rrhh/ingresos-retiros` omite ese NIT. El snapshot SÍ los tiene (vienen de `empleados-activos-detalle`). IR explica +5 de headcount, snapshot muestra +7 → los +2 son el neto de ese hueco. **No es error del cierre.**
- **`historico_empleados` vs nómina agosto:** snapshot 1003 vs nómina `registro_tipo='empleado'` 872. Descompuesto por NIT:
  - **DORA (900385136):** 71 en snapshot, 0 en nómina — HTTP 400 "no hay periodos cerrados".
  - **CCAR / Hotel_FC_Cartagena (901936953):** 82 en snapshot, **0 en nómina**. ⚠️ **NUEVO — no estaba en la lista.** La API respondió **HTTP 200 OK** pero con `num_empleados=0`, `empleados:[]` y `resumen` todo en `0.0`. El log lo mostró engañosamente como `OK nomina 2026-08 nit=901936953 filas=7` (1 respuesta + 6 resumen en ceros). `periodos_ids:[1651,1652]` existen pero sin cerrar. Todos los meses previos (oct-2025→jul-2026) trajeron 76-99 empleados; julio: 83, gasto 306M. Se comporta como DORA (nómina del mes sin cerrar) pero **pasa como "OK" porque devuelve 200 en vez de 400** — el fix de la Revisión 2 (leer el body del 4xx) no lo cubre.
  - **PSOL (800157788):** 0 en snapshot (tampoco viene en `empleados-activos-detalle`), 0 en nómina — base caída.
  - **Resto (15 NITs):** snapshot 850 vs nómina 872 → **+22** = gente pagada en agosto pero retirada antes del 31 (el snapshot de fin de mes no las incluye). **Normal.**
- **Veredicto:** el cierre de agosto está bien hecho; `historico_empleados` (1003) e `historico_ir` (105) son consistentes entre sí. Todos los descuadres son datos que el proveedor no entregó: CLUB/FEB fuera del feed IR, y PSOL + DORA + **CCAR** sin nómina de agosto.
- **Acción CCAR:** debería auto-resolverse en el reintento `nomina-pyg-dia5`/`dia7` del Mac mini (5-7 sep) SI FC Cartagena cierra su nómina. Verificar junto con DORA. Si para el día 7 sigue en `num_empleados=0`, es un hueco real a reportar (igual que DORA/PSOL). Mejora futura: que `cargar_historico_nomina_pyg.py` loguee `WARN ... num_empleados=0` en vez de `OK` cuando la respuesta viene vacía.

#### Revisión 2 — 2026-09-01, PSOL ≠ DORA (probado EN VIVO contra la API, leyendo el cuerpo del 400)
- El `HTTP 400 Bad Request` del log **escondía** el motivo real (`str(HTTPError)` no incluye el body). Leyendo `e.read()`, son **dos casos totalmente distintos**:
  - **PSOL (800157788) — ❌ infra del proveedor.** `{"detail":"Error de conexion: (...) Cannot open database \"NominaEmp\" requested by the login. The login failed. (4060) (SQLDriverConnect)"}`. Es **el MISMO error SQL Server de siempre** (2026-08-19), solo que ahora envuelto en 400. Probados jun/jul/ago → los **tres** fallan igual: la base `NominaEmp` está caída, no se puede consultar ningún período. Último período de PSOL en BD = **2026-06**. **Acción:** reportar al proveedor con ese texto exacto (login ODBC a `NominaEmp`). No se arregla esperando.
  - **DORA / Aloft (900385136) — ✅ NO es error, solo falta cerrar.** `{"detail":"No hay periodos cerrados para 2026-08"}`. Jun y jul dan OK (77 y 75 empleados). Solo agosto falla, con el mensaje **normal** de "nómina del mes aún sin cerrar". Último período de DORA en BD = **2026-07** (completo). **Se resuelve solo** cuando Aloft cierre agosto → lo recoge el reintento del Mac mini día 5/7 (5-7 sep), como pasó con julio.
- **Fix aplicado:** `post_json()` en `cargar_historico_nomina_pyg.py` ahora lee el cuerpo del `HTTPError` y expone `detail`. Verificado en vivo: el log sale `HTTP 400 Bad Request — Cannot open database "NominaEmp"...` (PSOL) vs `HTTP 400 Bad Request — No hay periodos cerrados para 2026-08` (DORA). Sin cambio de comportamiento (sigue saltando el NIT y continuando).
  - dev `claude/angry-diffie-524755` commit `8664d2e` (pusheado).
  - **Mergeado a `main` 2026-09-01, commit `ff51b0e` (pusheado)** — merge quirúrgico: solo ese archivo (`dashboard-empleados.html` NO se trajo, tiene WIP sin commitear en el worktree).
  - ✅ **Mac mini actualizado 2026-09-01** (`git pull`, fast-forward `2f04168..42af4b1`). Verificado allá con `.venv/bin/python`: el log ya distingue PSOL (`Cannot open database "NominaEmp"... 4060`) de DORA (`No hay periodos cerrados para 2026-08`).

#### Revisión 1 — 2026-09-01 (log crudo, antes de leer el body)
- Antes (2026-08-19) el error se veía como `Cannot open database "NominaEmp"... login failed (4060)` (SQL Server ODBC crudo). En el log del cierre solo salía:
  - `ERROR nomina 2026-08 nit=800157788: HTTP Error 400: Bad Request`
  - `ERROR nomina 2026-08 nit=900385136: HTTP Error 400: Bad Request`
- **PSOL está caído en todos los endpoints del proveedor menos P&G:**
  - `/nomina/reporte` → HTTP 400
  - `/rrhh/empleados-activos-detalle` → no lo devuelve. El cierre de empleados del 2026-09-01 lo confirma: `⚠️ NIT 800157788 (PSOL) — quedará SIN empleados en este cierre` (agosto 2026 = 1003 empleados, sin PSOL).
  - P&G sí funciona: `OK pyg 2026-08 company=8`. P&G de julio y agosto está completo (21 filas c/u).
- **Otros `ERROR` del log son timeouts transitorios** (890404970, 901054410, 901156377), no pérdida de datos — esos NITs sí están en BD de corridas previas; los recupera el reintento día 5/7.
- **Observación:** el log del 2026-09-01 es de **esta Mac** (`/Users/ricardomartinez/...`), no del Mac mini — los LaunchAgents de cierre locales parecen estar activos otra vez, aunque la nota de migración (2026-08-18) decía que se desactivaron. Confirmar cuál máquina es la oficial para no pisar cierres.
- **Acción SOLO para PSOL:** reportar a quien administra NominaApp que el NIT 800157788 devuelve `Cannot open database "NominaEmp" ... login failed (4060)` en `/nomina/reporte` y no aparece en `/rrhh/empleados-activos-detalle` desde julio 2026. No se resuelve esperando. (DORA no hay que reportarla — se cierra sola.)
- **Reintentar tras el fix del proveedor (idempotente):**
  - PSOL: `python3 cargar_historico_nomina_pyg.py --fuente nomina --desde 2026-07 --hasta 2026-08` + `python3 cierre_mes.py --ano=2026 --mes=8` (empleados). Correr en el Mac mini (`~/rrhh-report`, con `.venv/bin/python`).
  - DORA: no hace falta acción manual — el `nomina-pyg-dia5`/`dia7` del Mac mini lo recoge cuando Aloft cierre agosto.

#### Contexto original (2026-08-19)
- PSOL cargó sin problema los 12 meses anteriores consecutivos (ago 2025 → jun 2026, 69-77 filas c/u) — julio 2026 es el primer mes que falla.

## 🟢 Mejoras opcionales

### 9. Detección automática de conceptos nuevos en Payroll
- **Opción preferida:** detección pasiva en `prLoad()` + botón "🔍 Buscar nuevos"
- Detección pasiva: comparar `(codigo, nombre)` API vs `_prConceptos`; si hay nuevos → banner amarillo con mini-modal S/N
- Botón ligero: endpoint `/check-nuevos-conceptos` → 18 NITs × mes actual (~5 seg)

### 5. Tab Salarios CECO desktop — ✅ ya resuelto, entrada desactualizada (verificado 2026-08-27)
- Ambos puntos que decía este pendiente YA están en el código actual: `.ceco-table-wrap` tiene `max-height:480px; overflow-y:auto` (línea ~855) y la leyenda de `ch-sal` ya usa `type:'scroll'` + `grid.top:46` (línea ~6183-6184).
- Verificado en navegador: con los 1.432 nodos del árbol expandidos, el contenedor cae exacto en 480px con scroll interno, sin desbordar la tarjeta. Se corrigió en alguna sesión anterior sin que se actualizara esta nota — queda cerrada.

### 6. Refactor limpio del HTML
- El dashboard acumula parches de múltiples sesiones
- Refactor limpio del código JS para mejorar mantenibilidad

### 7. Opciones de Operación para datos históricos — ✅ ya no aplica, entrada desactualizada (verificado 2026-08-27)
- Consultado directo contra `historico_empleados.operacion` (42.954 filas) e `historico_ir.operacion`: hoy solo existen 2 valores reales (case-insensitive) — "En Operación" y "Socio". Sin nulos, sin vacíos, sin ningún "RETIROS" ni otro valor suelto. Ambos ya están cubiertos por las opciones actuales del filtro (`f-operacion`: Todos / En Operación / Socio).
- Probablemente el "RETIROS" que describía esta nota vino de una carga puntual con datos sucios (Excel viejo) y se corrigió en una recarga posterior sin actualizar el pendiente. Queda cerrado.

### 8. Tabla `unidades_negocio` — ❌ descartado, no se va a hacer (verificado y decidido 2026-08-27)
- **Investigado a fondo:** la tabla no existe en la base (ni en `public` ni en ningún otro schema) — no es que esté vacía, nunca se creó. Las únicas 3 menciones de "unidades_negocio" en todo el código (`servidor.py`, `dashboard-empleados.html`, `cierre_mes.py`) son la clave `unidades_negocio` dentro del JSON de la API externa de IR (`hoteles[] → unidades_negocio[] → rows[]`), sin ninguna relación con una tabla SQL. Nada en el código actual espera que exista.
- **Decisión del usuario:** cerrar este pendiente, no se va a construir. En su lugar, agregar `ciudad`/`país` directamente a `hotel_unificacion` (ver siguiente entrada).

### 9. `ciudad`/`país` en `hotel_unificacion` — plan propuesto, no implementado todavía (2026-08-27)
- **Reemplaza al pendiente de `unidades_negocio` de arriba.** El lugar correcto es `hotel_unificacion` (una fila por propiedad física, clave `razon_social`) y NO `empresas` (clave `codigo` = entidad legal/NIT, que puede agrupar más de una propiedad bajo el mismo NIT — ej. `CLUB`="FC Bogota" y `FEB`="Belvedere" comparten NIT 900345450 pero son propiedades distintas).
- **Verificado con las 29 propiedades actuales:** todas parecen ser de Colombia (Bogotá, Cartagena, Cúcuta, Barranquilla, Medellín, Cali, etc. según el nombre) — no encontré ninguna evidencia de propiedades fuera de Colombia. `país` hoy sería una constante para las 29 filas.
- **`ciudad` sí varía y es útil**, pero **no tengo una fuente confiable para poblarla** — se puede inferir de algunos nombres (ej. "FC Cartagena"→Cartagena) pero para otros no es obvio (GHS, Casa Canabal, Casa Bianca, Bantú, Hotel Bolivar, Hotel Caribe, La Mantilla, Puerta del sol, Factoria) y no se debe adivinar.
- **Plan propuesto (pendiente de que el usuario confirme y provea las ciudades reales):**
  1. `ALTER TABLE hotel_unificacion ADD COLUMN ciudad TEXT, ADD COLUMN pais TEXT DEFAULT 'Colombia'`
  2. Backfill manual de `ciudad` por propiedad (28 valores reales, el usuario los tiene) — o vía una fila nueva en `Unificacion_Hoteles.xlsx` + extender `_sync_hoteles()` en `servidor.py` para leerla, si se prefiere mantenerlo editable desde Excel como el resto de esa tabla.
  3. Exponer los campos nuevos en el endpoint `/proxy/historico/hoteles-map` (ya trae toda la tabla, solo habría que agregar las 2 columnas al SELECT).
- **No implementado todavía** — falta que el usuario confirme el enfoque (Excel vs. carga directa) y provea las ciudades reales.

### 10. Nóminas históricas detalladas → PostgreSQL (feature nueva, no bloquea nada actual)
- **No conecta con nada del dashboard hoy** — el dashboard ya funciona 100% completo (hasta Mayo 2026) usando `historico_nomina_pyg`, que trae el total por empleado vía API en vivo
- **Qué agregaría:** desglose concepto-por-concepto (sueldo base, auxilios, horas extra, deducciones, etc.) por empleado y mes, en vez de solo el total — habilitaría features futuras tipo "ver recibo de nómina histórico completo" o "tendencia de un concepto específico en el tiempo"
- **Archivo fuente:** `OneDrive/.../Base de Datos Historicos/Nominas de Empleados Historicos.xlsx` — 27.796 filas × 416 columnas, cubre Ene 2024–Mar 2026 (verificado 2026-06-30)
- **Modelo propuesto:** EAV — `nomina_header` + `nomina_lineas` + `conceptos` (tablas aún no creadas)
- **Gap:** aunque se cargue el Excel, faltarían Abril/Mayo/Junio 2026 (no están en el archivo fuente)
- **Próximo paso si se retoma:** resolver mapeo Área, validar Cargo, agrupar conceptos, luego script de carga

## ✅ Completados esta sesión (2026-07-21)

- ✅ **IR API dos llamadas en vivo** — `tipo_nomina_id` es REQUERIDO por la API. Se reemplazó la llamada única (sin el campo → 422 silencioso → 0 filas) por `irFetchLive(fi, ff)`: dos llamadas en paralelo con `tipo_nomina_id:4` (Quincenal) y `tipo_nomina_id:5` (Mensual). Resultados concatenados. Julio 2026: 28 ingresos + 16 retiros visibles. Mismo fix aplicado en `cierre_mes.py` para `fetch_ir()` y `_build_vence_map()`.
- ✅ **Merge worktree → main completado** (sesión 2026-07-14)

## ✅ Completados esta sesión (2026-07-14)

- ✅ **Cierre junio 2026 completo** — `historico_nomina_pyg` cargado con `cargar_historico_nomina_pyg.py --fuente ambas --desde 2026-06 --hasta 2026-06`. 18 NITs nómina + 20 entradas P&G. Junio 2026 visible 100% desde PostgreSQL en todos los tabs del dashboard.
- ✅ **historico_empleados junio 2026 recargado** — 1,024 filas (23 empresas) desde Excel OneDrive actualizado por el usuario. Incluye DORA, SPIA, CCAR, SERVMYS que antes faltaban.

## ✅ Completados esta sesión (2026-07-10)

- ✅ **`tipo_nomina_id` eliminado del dashboard** — `irBuildBody()` ya no envía `tipo_nomina_id:4`. La API une todos los tipos de nómina automáticamente (Quincenal + Mensual). Aloft y Spiaggia ahora aparecen en tab Ingresos y Retiros para períodos futuros.
- ✅ **`tipo_nomina_id` eliminado de `cierre_mes.py`** — Ambas llamadas IR del cierre ya no filtran por tipo. A partir de julio 2026 los cierres incluirán DORA y CCAR automáticamente.
- ✅ **LaunchAgent día 7 creado** — `com.bia.cierre-mes-dia7.plist` a las 5:00 AM, para reintentar hoteles con nómina tarde (ej. Aloft).
- ✅ **historico_ir junio 2026 recargado** — 60 → 82 rows. Agregados DORA (Aloft, 10 movs) y CCAR (Spiaggia, 12 movs). Excel OneDrive actualizado: 5,127 → 5,149 filas.

## ✅ Completados esta sesión (2026-06-30)

- ✅ **`_is_periodo_historico` — ahora dinámico** — ya no hardcodeado a `(2026, 5)`. Lee `MAX(periodo)` de `historico_nomina_pyg` con caché de 5 min. Se actualiza solo tras cada cierre mensual sin tocar código.
- ✅ **`IR_HIST_MAX` — ahora dinámico** — ya no hardcodeado en el HTML. Al arrancar la página, `loadData()` llama `/proxy/historico/max-periodo` y actualiza el valor automáticamente.
- ✅ **Nuevo endpoint `/proxy/historico/max-periodo`** — devuelve `{empleados, ir, nomina_pyg}` con los últimos períodos cargados en cada tabla.
- ✅ **Comparación de trayectoria empleado** — buscador "vs" en la card de trayectoria, segunda serie naranja, eje de tiempo unificado, tooltip con diff de salario.
- ✅ **Debug trayectoria junio 2026 resuelto** — `norm_name()` ahora ordena palabras alfabéticamente para comparar "APELLIDO NOMBRE" (API) vs "Nombre Apellido" (histórico).

## ✅ Completados sesión anterior (2026-06-09 — cierre_mes nueva lógica + Mayo 2026)

- ✅ **`cierre_mes.py` — nueva lógica `insertar_empleados()`**
  - Filtro retirados: excluye empleados con `fecha_retiro <= último día del mes` (50 excluidos en mayo)
  - `operacion`: `'SOCIO'` si cedula en tabla `socios`, `'EN OPERACION'` si no
  - `fecha_vencimiento_contrato`: cascada 3 fuentes (empleados-activos-detalle → IR → nomina fijo futuro → NULL indefinidos)
  - `cierre_empleados()` carga `socios_set` + `vence_map` en paralelo antes del fetch de nóminas
- ✅ **Tabla `socios` creada en PostgreSQL** — 20 socios históricos populados (GHS, CCAN, CDON, CCUC)
- ✅ **Cierre Mayo 2026 ejecutado con nueva lógica** — 1.016 empleados + 76 movimientos IR
  - 50 empleados retirados en mayo correctamente excluidos
  - Mapa vencimientos: 1.076 registros desde ambas APIs
- ✅ **`_is_periodo_historico` worktree actualizado a Mayo 2026** — `(2026, 5)` en dev
- ✅ **Tarjetas KPI Estadísticas Hoteles** — nueva paleta 5 azules (blue-1→blue-5), gradientes, bandas
  - Card 3 icono 🛎️ (antes ✨)
  - `Días Laborados Mes` = (directos + outsourcing) × días_mes
  - `Días Promedio Trab` = SUM(dias nómina) / total_emps (entero)
  - `serv_by_label` cambió a `{label: {'emps': int, 'dias': float}}`

## ✅ Completados sesión anterior (2026-06-05 — cierre_mes + data fixes)

- ✅ **cierre_mes.py** — script de cierre mensual automático para empleados e IR
  - Día 1 y día 5 de cada mes a las 5:00 AM vía LaunchAgent
  - Detecta mes anterior automáticamente
  - `NominaNoDisponible`: distingue "nómina no cerrada" (WARNING) de errores reales (ERROR)
  - `nit_bu_map`: asigna razon_social correcta por (NIT, bu_codigo) — soluciona NITs compartidos
  - `NIT_UNICO = {'900657316': 'GHS'}`: GHS + The Club quedan juntos bajo GHS (consistencia histórica)
  - Retry: 1 intento, espera 1 hora, reintenta, si falla → log y exit(1)
  - Sin notificación por correo (eliminada por decisión del usuario)
  - Logs en `logs/cierres/cierre_YYYY_MM_YYYYMMDD_HHMMSS.log`
- ✅ **LaunchAgents registrados**: `com.bia.cierre-mes-dia1` + `com.bia.cierre-mes-dia5`
- ✅ **Cierre Mayo 2026 ejecutado**: 896 empleados + 76 movimientos IR insertados
  - DORA (NIT 900385136 — Aloft Bogotá) sin nómina cerrada: WARNING esperado
- ✅ **historico_empleados consistencia GHS**: todos los empleados de GHS+TCLUB quedan bajo razon_social='GHS' para mantener coherencia con datos históricos del Excel

## ✅ Completados sesión anterior (v1.16.0 — 2026-06-04)

- ✅ **ThreadingHTTPServer** — servidor multi-hilo; peticiones en paralelo (antes single-threaded → bloqueaba Promise.all)
- ✅ **Connection pool PostgreSQL** — `ThreadedConnectionPool(min=2, max=10)`; elimina ~150ms overhead por request
- ✅ **try/finally en _historico() y _stats()** — conexión siempre devuelta al pool, incluso en errores
- ✅ **loadHeaderKPIs() 2 bloques** — activos inicio inmediato (PostgreSQL), ingresos/retiros async (puede usar API)
- ✅ **animCountKpi()** — animación de conteo en los 4 KPI cards del header (k-inicio, k-ingresos, k-retiros, k-fin)
- ✅ **Removido "…" loading indicator** — cards mantienen valor anterior hasta que llega el nuevo
- ✅ **renderAll() con debounce** — tabla/KPIs inmediato · gráficas 180ms · HTTP 350ms
- ✅ **_scheduleAllTabsUpdate()** — actualiza TODOS los tabs desde CUALQUIER filtro (hotel, centro, cargo, empleado, operacion, búsqueda móvil, limpiar filtros) con debounce 400ms
- ✅ **Filtros tab Estadísticas** — condición invertida corregida; ahora filtra correctamente al seleccionar razón social
- ✅ **Filtro período visible en todos los tabs** — `hdr-sal-controls` siempre display:''
- ✅ **Donut Payroll resize** — `chart.resize()` al cambiar al tab payroll
- ✅ **Pills variación 2 decimales** — `toFixed(2)` en prMiniSpark
- ✅ **Outsourcing card** — número grande + hotel name inline, eliminado número duplicado pequeño

## ✅ Completados sesión anterior (v1.15.0 — 2026-05-26)

- ✅ **NIT CCUC corregido** — `empresas SET nit='900361237'` (era 890930668). CCUC comparte NIT con Casa Canabal
- ✅ **P&G por company_id** — endpoint proxy acepta `company_id` directamente (no solo `nit`). `pygCompanyMap` en JS agrupa hoteles por `pyg_company_id` → una llamada P&G por empresa
- ✅ **P&G por BU key correcto** — `servidor.py` iteraba hardcoded `"local"` → corregido a iterar `bu_codigos` en `raw["pnl"]`. Belvedere (be), FC Cucuta (cu), The Club (tc) funcionan
- ✅ **bu_codigos normalizados a lowercase** — `servidor.py` normaliza antes de llamar API P&G (`b.lower()`)
- ✅ **`prApplyBUFilter` case-insensitive** — comparación `.toLowerCase()` en ambos lados (DB='cu', API='CU')
- ✅ **Conceptos LY-only antes de reconciliación** — sección 2 (poolMap LY-only) se procesa ANTES de sección 1d para evitar doble conteo en ajuste contable
- ✅ **Ajuste Contable P&G lyDiff** — `ly = lyDiff` (P&G fuente de verdad para LY); elimina inconsistencias en columna LY
- ✅ **The Club en Outsourcing** — `PR_HOTEL_SERV_PATTERNS['The Club']` incluye 'THE CLUB CTG' (era 'GHS')
- ✅ **pyg_company_id CCUC=4** — `empresas UPDATE pyg_company_id=4 WHERE codigo='CCUC'` (era 20 → Hotel Bolívar Cúcuta)
- ✅ **pyg_company_id GHS=21** — `empresas UPDATE pyg_company_id=21 WHERE codigo='GHS'`
- ✅ **TCLUB en empresas** — INSERT `(codigo='TCLUB', nit='900657316', pyg_company_id=21)`
- ✅ **bu_codigo lowercase en hotel_unificacion** — CCUC='cu', FEB='be', COLH='fb', TCLUB='tc' (todos lowercase)
- ✅ **hoteles-nit devuelve pyg_company_id** — query JOIN actualizado para incluir esta columna

## ✅ Completados sesión anterior (v1.14.0 — 2026-05-25)

- ✅ **SS patronal integrado** — `_prBuildSsPatronal()`, `ss_vista:"ambos"`, prefijo `SS_`, 7 conceptos en BD
- ✅ **API P&G integrada** — endpoint `/proxy/pyg/reporte`, `_prPygData`, merge paralelo con nómina
- ✅ **Reconciliación P&G vs Nómina** — fila "⚠ Ajuste contable P&G" en subgrupos nómina-backed cuando diff > $1, oculta con filtro CECO/cargo
- ✅ **`nomina_backed` flag** — en `_pyg_reporte()` CONCEPTOS list y respuesta JSON
- ✅ **Loading animation** — shimmer skeleton overlay con KPIs + tabla + donut + bouncing dots
- ✅ **KPI cards reducidos a 3** — Empleados, Días Trabajados, Gasto Total Personal
- ✅ **Layout 55%/45%** — tabla más angosta, donut más espacioso
- ✅ **201 conceptos activos** en `nomina_conceptos`
