# Plan: Módulo RRHH — Integración Reloj Biométrico Hikvision DS-K1T343MFWX

**Fecha**: 2026-05-23
**Fuente funcional**: `Integracion_Biometrico_DS-K1T343MFWX.pdf` (versión 1.0 — Mayo 2026)
**Documento base**: [`plan-rrhh-presentismo.md`](./plan-rrhh-presentismo.md) — Presentismo M16–M22 ya implementado y validado
**Estándares**: [`PROJECT_STANDARDS.md`](./PROJECT_STANDARDS.md) y [`backend/CLAUDE.md`](../CLAUDE.md)
**Estado**: Propuesta — pendiente de implementación

---

## Contexto de partida

El PDF describe un servicio Node.js **standalone** (`digest-fetch`, `node-cron`, `pg`, tablas `biometric_attendance_raw` / `hr_daily_attendance`). **No se va a construir ese servicio**: duplicaría infraestructura que ya existe y está validada.

El módulo Presentismo (M16–M22) ya implementa el pipeline completo de relojes marcadores:

- `rrhh_relojes_marcadores` con `tipo_conexion ∈ { API, PLANILLA }` y credenciales cifradas AES-256-CBC ([`relojes-marcadores.service.ts:44`](../src/rrhh/services/relojes-marcadores.service.ts#L44)).
- Cliente HTTP genérico de relojes ([`reloj-api-client.service.ts`](../src/rrhh/services/reloj-api-client.service.ts)) con auth `NONE | BASIC | BEARER | API_KEY` y parseo JSON/XML.
- Ingesta y deduplicación: `rrhh_marcaciones_raw` → `MarcacionesDedupService` → `rrhh_marcaciones_limpias` (min entrada / max salida por `empleado_id + fecha`).
- Motor de novedades `presentismo-engine.service.ts` → `rrhh_asistencia_novedades` → descuentos en liquidación.
- Polling automático con BullMQ (`rrhh-presentismo-queue`): `PresentismoSyncScheduler` registra un job repeatable por reloj API activo según `intervalo_polling_min`; `PresentismoSyncProcessor` ejecuta `relojesService.sincronizar()` ([`presentismo-sync.processor.ts:32`](../src/rrhh/services/presentismo-sync.processor.ts#L32)).
- Identificación de funcionarios por `cedula_identidad` filtrado por `empresa_id` (decisión 5 del plan base).
- Frontend: 7 sub-tabs de Presentismo, incluyendo `RelojesPresentismoTab.jsx` con alta/edición y prueba de conexión.

**El Hikvision DS-K1T343MFWX se integra como un reloj API más.** Sólo restan tres huecos: (1) Digest Auth no soportado, (2) el endpoint `AcsEvent` es POST con paginación (el cliente genérico sólo hace GET), (3) mapeo de `employeeNoString`/`attendanceStatus` a los campos existentes.

### Decisiones del usuario (2026-05-23)

| Tema | Decisión | Implicancia |
|---|---|---|
| Modo de captura | **Polling AcsEvent** (no alertStream) | Reusa el scheduler BullMQ y `intervalo_polling_min`. Cero infraestructura nueva de conexión persistente. |
| Identificación | **`employeeNoString = cédula`** | Empleados registrados en el reloj con su CI. Reusa el resolver existente. Sin cambios de schema en `rrhh_empleados`. |
| Tipos de marcación | **Solo entrada/salida** | `check in → ENTRADA`, `check out → SALIDA`. Reusa el dedup (min/max). Sin tocar `rrhh_marcacion_tipo` ni el motor. |

---

## Decisiones de diseño

| # | Decisión | Resolución |
|---|---|---|
| 1 | Reusar infraestructura | El Hikvision se modela como `rrhh_relojes_marcadores` con `tipo_conexion = API`. No se crean tablas ni servicios paralelos al PDF. |
| 2 | Discriminador de protocolo | Nueva columna `protocolo_api rrhh_reloj_protocolo_api ∈ { GENERICO, HIKVISION_ISAPI }` (default `GENERICO`). Mantener `tipo_conexion = API` evita tocar los ~10 gates `=== API` existentes; sólo el routing de sincronización ramifica por `protocolo_api`. |
| 3 | Digest Auth | Ampliar enum `rrhh_reloj_tipo_auth` con `DIGEST`. Implementar el handshake RFC 2617 **nativamente con `crypto` (MD5)** sobre `fetch` + `AbortController` (consistente con el cliente genérico actual). No se agrega la dependencia `digest-fetch` para no mezclar con `node-fetch` legacy. |
| 4 | Cliente dedicado | Nuevo `HikvisionIsapiClientService` que hace **POST** a `/ISAPI/AccessControl/AcsEvent?format=json` con body `AcsEventCond`, **paginando** por `searchResultPosition` hasta `totalMatches`. Devuelve el mismo tipo `RelojApiResult` que el cliente genérico ⇒ aguas abajo (raw → dedup → novedades) **no cambia**. |
| 5 | Mapeo de campos | El cliente Hikvision extrae `documento_raw = employeeNoString`, `timestamp_marcacion = time`. Por consistencia, `campo_documento = "employeeNoString"` y `campo_timestamp = "time"` se persisten en el reloj. El resolver matchea `documento_raw` contra `cedula_identidad`. |
| 6 | Solo ENTRADA/SALIDA | El cliente filtra `eventAttribute = attendance`; mapea `attendanceStatus`: `check in → ENTRADA`, `check out → SALIDA`. `break*`/`overtime*` se descartan en v1 (el dedup deriva entrada/salida por min/max de todos modos). Sin cambios en `rrhh_marcacion_tipo`. |
| 7 | Ventana de sincronización | El polling automático llama `sincronizar(..., {})` sin rango. Para Hikvision, si no hay `desde/hasta`, el cliente consulta `[now − lookback, now]` con `lookback = max(intervalo_polling_min × 2, 15) min`. El solape es seguro: el dedup es idempotente (decisión 6 del plan base). |
| 8 | Zona horaria | `AcsEventCond.startTime/endTime` se envían en ISO-8601 con offset. `timestamp_marcacion` se persiste como instante (UTC); la derivación de `fecha` a `America/Asuncion` la hace el dedup existente (sin cambios). |
| 9 | Seguridad | `api_key = "usuario:password"` cifrada con el helper AES-256-CBC existente (igual que BASIC). Nunca se loguea en claro. Timeout de Hikvision a 30 s (PDF) vs. 15 s genérico. |
| 10 | Frontend | Extender `RelojesPresentismoTab.jsx`: opción `DIGEST` en auth, selector `Protocolo` (Genérico / Hikvision ISAPI) cuando `tipo_conexion = API`, y botón "Preconfigurar Hikvision" que autocompleta los campos por defecto. Selectores con `Autocomplete` y diálogos con `ConfirmDialog` (memorias `feedback_selectores_buscables`, `feedback_no_alert_browser`). |
| 11 | Reconciliación diaria (opcional) | Cron diario 00:05 `America/Asuncion` que re-consulta el día anterior vía AcsEvent como seguro ante caídas largas del polling. Marcado opcional — el polling con ventana solapada ya cubre cortes breves. |

---

## Alcance funcional

| Ítem | Descripción | Prioridad |
|---|---|---|
| H1 | Soporte Digest Auth (RFC 2617) en el stack de relojes | Crítica |
| H2 | Cliente AcsEvent (POST + paginación) con mapeo a `RelojApiResult` | Crítica |
| H3 | Discriminador `protocolo_api` + routing de sincronización | Alta |
| H4 | Frontend: protocolo/auth/preconfiguración Hikvision en el alta de reloj | Alta |
| H5 | Reconciliación diaria de respaldo (opcional) | Baja |

No requiere cambios: turnos, permisos, tolerancias, motor de novedades, liquidación, deduplicación, ni el schema de `rrhh_marcaciones_*`.

---

## Modelo de datos propuesto

### Cambios de enums Prisma (`prisma/schema.prisma`)

```prisma
enum rrhh_reloj_tipo_auth { NONE BASIC BEARER API_KEY DIGEST }   // + DIGEST

enum rrhh_reloj_protocolo_api { GENERICO HIKVISION_ISAPI }       // nuevo
```

### Columna nueva en `rrhh_relojes_marcadores`

```prisma
model rrhh_relojes_marcadores {
  // ...campos existentes...
  protocolo_api rrhh_reloj_protocolo_api @default(GENERICO)
}
```

### Migración (`prisma/migrations/20260523_rrhh_presentismo_hikvision/migration.sql`)

Seguir el flujo obligatorio de [`backend/CLAUDE.md`](../CLAUDE.md) (directorio + `db execute` + `migrate resolve --applied` + `generate`). `ALTER TYPE ... ADD VALUE` debe ir **fuera de transacción**, en su propia migración o como sentencia aislada:

```sql
-- 1) Nuevo valor de enum (sentencia aislada — no dentro de BEGIN/COMMIT)
ALTER TYPE "rrhh_reloj_tipo_auth" ADD VALUE IF NOT EXISTS 'DIGEST';

-- 2) Enum nuevo (idempotente)
DO $$ BEGIN
  CREATE TYPE "rrhh_reloj_protocolo_api" AS ENUM ('GENERICO', 'HIKVISION_ISAPI');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

-- 3) Columna nueva
ALTER TABLE "rrhh_relojes_marcadores"
  ADD COLUMN IF NOT EXISTS "protocolo_api" "rrhh_reloj_protocolo_api" NOT NULL DEFAULT 'GENERICO';
```

---

## Reglas de negocio obligatorias (backend)

1. `protocolo_api = HIKVISION_ISAPI` sólo es válido con `tipo_conexion = API`; en alta/edición rechazar combinación inválida (`HTTP 400`).
2. `tipo_auth = DIGEST` requiere `api_key` con formato `usuario:password` (validar presencia de `:`); de lo contrario `HTTP 400`.
3. El cliente Hikvision debe **paginar** hasta `position >= totalMatches`; cortar ante `responseStatusStrg = "NO MATCH"` o página vacía. Tope de seguridad de páginas para evitar loops (p. ej. 1000 × `maxResults`).
4. La ventana por defecto del polling Hikvision es `[now − max(intervalo×2, 15)min, now]`. El solape no genera duplicados (dedup idempotente).
5. `documento_raw` que no matchee `cedula_identidad` de la empresa ⇒ raw en `estado_importacion = ERROR` con `error_descripcion`; no bloquea el lote (regla 13 del plan base, ya implementada en `persistirRaw`).
6. Eventos con `attendanceStatus` distinto de `check in`/`check out` (break/overtime) se ignoran en v1.
7. `api_key` nunca se devuelve en claro ni se loguea (enmascarado por `maskApiKey`, ya implementado).
8. `intervalo_polling_min ≥ 5` (validación DTO existente) — evita saturar el dispositivo.

---

## Componentes a crear / modificar

### Nuevo: `src/rrhh/services/hikvision-isapi-client.service.ts`

Cliente dedicado, mismo contrato de salida que el genérico (`RelojApiResult`):

```typescript
@Injectable()
export class HikvisionIsapiClientService {
  // consultarAcsEvent(config, { desde?, hasta? }): Promise<RelojApiResult>
  //  1. Ventana: startTime/endTime ISO-8601 (default: now-lookback .. now).
  //  2. Loop de paginación: POST {url_base}/ISAPI/AccessControl/AcsEvent?format=json
  //     body = { AcsEventCond: { searchID, searchResultPosition, maxResults:100,
  //              major:0, minor:0, startTime, endTime, eventAttribute:'attendance' } }
  //  3. Digest handshake (helper privado): 1er POST sin Authorization → 401 +
  //     WWW-Authenticate (realm/nonce/qop) → MD5(HA1:nonce:nc:cnonce:qop:HA2) → reintento.
  //  4. Mapear AcsEvent.InfoList[]: documento_raw = employeeNoString, timestamp = new Date(time);
  //     filtrar attendanceStatus ∈ {check in, check out}.
  //  5. Timeout 30s con AbortController; errores de red/HTTP → { ok:false, detalle }.
}
```

- Digest nativo con `crypto.createHash('md5')`; sin dependencias nuevas.
- Reusar el tipo `RelojApiResult` exportado por `reloj-api-client.service.ts`.

### Modificar: `src/rrhh/services/marcaciones.service.ts` (`sincronizarReloj`, ~L229)

Ramificar por protocolo antes de construir el config:

```typescript
const result = reloj.protocolo_api === 'HIKVISION_ISAPI'
  ? await this.hikvisionClient.consultarAcsEvent({ url_base, tipo_auth, api_key,
      campo_documento: reloj.campo_documento, campo_timestamp: reloj.campo_timestamp },
      { desde: opts.desde, hasta: opts.hasta })
  : await this.relojClient.consultar({ /* ... config genérico actual ... */ },
      { desde: opts.desde, hasta: opts.hasta });
```

El resto (`persistirRaw` → `dedup.deduplicarBatch`) queda **idéntico**.

### Modificar: `src/rrhh/services/relojes-marcadores.service.ts`

- `create`/`update`: persistir `protocolo_api` dentro del gate `tipo_conexion === API` (defaults a `GENERICO`).
- Validar reglas 1–2 (protocolo vs. conexión; formato de `api_key` para DIGEST).

### Modificar: `src/rrhh/dto/relojes.dto.ts`

- `RelojTipoAuth`: agregar `DIGEST = 'DIGEST'`.
- Nuevo `enum RelojProtocoloApi { GENERICO, HIKVISION_ISAPI }` + campo opcional `protocolo_api?` en `CreateRelojDto` (y en `QueryRelojesDto` si se quiere filtrar).

### Modificar: `src/rrhh/rrhh.module.ts`

- Registrar `HikvisionIsapiClientService` en `providers` (y `exports` si aplica), e inyectarlo en `MarcacionesService`.

### Frontend: `frontend/src/components/rrhh/.../RelojesPresentismoTab.jsx` + `src/api/rrhh-presentismo.service.js`

- Agregar `DIGEST` a las opciones de auth.
- Selector **Protocolo** (`Autocomplete`: Genérico / Hikvision ISAPI) visible cuando `tipo_conexion = API`.
- Botón **"Preconfigurar Hikvision"** que autocompleta: `tipo_auth = DIGEST`, `formato_respuesta = JSON`, `campo_documento = employeeNoString`, `campo_timestamp = time`, hint de `url_base = http://<IP>:80` y `api_key = usuario:password`.
- `FieldHint`/placeholders con ejemplos; confirmaciones con `ConfirmDialog`/`useConfirmDialog`.

---

## Plan de implementación

### Fase 1 — Backend: Digest + AcsEvent (núcleo)

- Migración `20260523_rrhh_presentismo_hikvision/` (enum `DIGEST`, enum `rrhh_reloj_protocolo_api`, columna `protocolo_api`); actualizar `schema.prisma`; `prisma generate`.
- DTO: `DIGEST` + `RelojProtocoloApi` + `protocolo_api`.
- `HikvisionIsapiClientService` (Digest nativo + AcsEvent + paginación + mapeo).
- Routing en `marcaciones.service.ts` + persistencia de `protocolo_api` en `relojes-marcadores.service.ts` + validaciones (reglas 1–2).
- Registrar provider en `rrhh.module.ts`.
- Tests unitarios: cómputo Digest (vector RFC 2617), paginación AcsEvent (mock multi-página), mapeo `attendanceStatus`/`employeeNoString`, ventana por defecto.

### Fase 2 — Frontend + sincronización end-to-end

- `RelojesPresentismoTab.jsx`: protocolo, auth DIGEST, preconfiguración Hikvision, hints.
- Verificar que el alta de reloj Hikvision queda activa y el `PresentismoSyncScheduler` registra su job repeatable.
- "Probar conexión"/sincronización manual (`POST /relojes/:id/sincronizar`) contra dispositivo o mock.

### Fase 3 — Reconciliación diaria ✅

- ✅ `PresentismoReconciliacionCron` ([`presentismo-reconciliacion.cron.ts`](../src/rrhh/presentismo-reconciliacion.cron.ts)): `@Cron('5 0 * * *', { timeZone: 'America/Asuncion' })` que re-sincroniza el día anterior completo (`[00:00:00, 23:59:59]` hora local, convertido a instantes UTC) por cada reloj Hikvision activo (`tipo_conexion = API`, `protocolo_api = HIKVISION_ISAPI`).
- ✅ Falla por reloj aislada (no detiene a los demás); idempotente vía dedup.
- ✅ Provider registrado en `rrhh.module.ts`; descubierto por el `ScheduleModule.forRoot()` global.
- ✅ Tests: `presentismo-reconciliacion.cron.spec.ts` (iteración por reloj, ventana del día anterior en Asunción, tolerancia a fallos).

---

## Plan de pruebas — modo dev

> `pnpm start:dev` (backend) + `pnpm dev` (frontend) sobre empresa Demo RRHH. Si no hay dispositivo físico, levantar un **mock ISAPI** en `tools/mock-hikvision` que emita el reto Digest 401 y responda AcsEvent paginado.

#### PRUEBA H.1 — Alta de reloj Hikvision

**Dónde**: RRHH › Presentismo › Relojes › Nuevo
**Pasos**: `tipo_conexion = API`, click "Preconfigurar Hikvision", `url_base = http://<IP>:80`, `api_key = admin:****`, intervalo 5.
**Resultado esperado**: Reloj creado con `protocolo_api = HIKVISION_ISAPI`, `tipo_auth = DIGEST`; `api_key` cifrada (verificar en BD que no está en claro).
**Negativo**: Si `GET /relojes` devuelve `api_key` en claro ⇒ defecto crítico de seguridad.

#### PRUEBA H.2 — Digest handshake

**Pasos**: Sincronizar manualmente. Inspeccionar tráfico: 1er POST sin credenciales → 401, 2do POST con `Authorization: Digest ...`.
**Resultado esperado**: 200 OK y marcaciones recibidas.
**Negativo**: Si envía Basic o falla con 401 persistente ⇒ defecto del handshake.

#### PRUEBA H.3 — Paginación AcsEvent

**Pasos**: Mock con 250 eventos (`maxResults = 100`).
**Resultado esperado**: 3 páginas consumidas; 250 marcaciones en `rrhh_marcaciones_raw`.
**Negativo**: Si trae sólo 100 ⇒ paginación rota.

#### PRUEBA H.4 — Mapeo y matching por CI

**Pasos**: Eventos con `employeeNoString` = CI de empleados Demo + uno inexistente.
**Resultado esperado**: matcheados `PROCESADO`; el inexistente en `ERROR` con `error_descripcion`. Limpias = 1 por empleado-día (min entrada / max salida).
**Negativo**: Si el inexistente bloquea el lote ⇒ defecto.

#### PRUEBA H.5 — Idempotencia / ventana solapada

**Pasos**: Sincronizar dos veces con ventanas solapadas.
**Resultado esperado**: Sin filas extra en `rrhh_marcaciones_limpias` (dedup idempotente).

#### PRUEBA H.6 — Polling automático

**Pasos**: Dejar el reloj activo con intervalo 5; esperar el ciclo.
**Resultado esperado**: `PresentismoSyncProcessor` ejecuta; `ultima_sincronizacion` se actualiza.

#### PRUEBA H.7 — End-to-end a novedades

**Pasos**: Con turno asignado, marcar tarde en el reloj/mock y procesar período.
**Resultado esperado**: `presentismo-engine` genera `TARDANZA` a partir de la marcación Hikvision, igual que un reloj genérico.

#### PRUEBA H.8 — Caída del dispositivo

**Pasos**: Apagar el mock; sincronizar.
**Resultado esperado**: Error funcional ("Falló la consulta al reloj: ..."), sin stack trace; BullMQ reintenta sin bloquear operaciones manuales.

---

## Riesgos y mitigaciones

- **Digest mal implementado**: cubrir con vector de prueba RFC 2617 y test de integración contra mock que valide `nc`/`cnonce`/`qop`.
- **Volumen alto en backfill inicial**: la primera sincronización puede traer miles de eventos. Limitar la ventana inicial (p. ej. 7 días) y dejar que el polling incremente; tope de páginas (regla 3).
- **Zona horaria**: validar que `fecha` derivada quede en `America/Asuncion`; añadir caso de prueba con marcación cercana a medianoche.
- **Formato AcsEvent por firmware**: el PDF asume firmware V2.x; ante variaciones, registrar `detalle` del parse y exponer "Probar conexión" para diagnóstico.
- **Reloj sin polling (manual)**: si `intervalo_polling_min = NULL`, depende de sincronización manual o de la reconciliación diaria (Fase 3).

---

## Entregables mínimos

- Migración idempotente aplicada (enum `DIGEST`, enum `rrhh_reloj_protocolo_api`, columna `protocolo_api`).
- `HikvisionIsapiClientService` con Digest nativo, AcsEvent paginado y mapeo a `RelojApiResult`, con tests unitarios.
- Routing por `protocolo_api` en `marcaciones.service.ts` + validaciones en `relojes-marcadores.service.ts`.
- Frontend de alta de reloj con protocolo/auth DIGEST y preconfiguración Hikvision (selectores `Autocomplete`, diálogos no nativos).
- Plan de pruebas H.1–H.8 ejecutado en modo dev con evidencias.

---

## Anexo — Checklist de cumplimiento PROJECT_STANDARDS

- [ ] **Enums en código y BD**: `DIGEST` y `rrhh_reloj_protocolo_api` declarados como enum Prisma + TS; sin `VARCHAR + comentario`.
- [ ] **Migraciones**: directorio (no `.sql` suelto), idempotente, `ALTER TYPE ADD VALUE` aislado.
- [ ] **Selectores buscables**: protocolo/auth con `Autocomplete` (memoria `feedback_selectores_buscables`).
- [ ] **Diálogos**: confirmaciones con `ConfirmDialog`/`useConfirmDialog` (memoria `feedback_no_alert_browser`).
- [ ] **Seguridad**: `api_key` cifrada y enmascarada; sin logueo en claro; timeout y reintentos.
- [ ] **Feedback**: errores funcionales (no stack trace) en "Probar conexión" y sincronización.
- [ ] **Reuso**: ingesta/dedup/motor/liquidación existentes sin cambios; sólo se añade el cliente Hikvision.
