Saltar a contenido

Sincronización offline-first

El protocolo de sincronización es el corazón de WorkDone Sanitarios. Los dispositivos —la app móvil de los operarios y el kiosk Smiley de opinión del público— operan en lugares donde la red no es confiable (sótanos de aeropuertos, sanitarios de shoppings). El sistema está diseñado para que nada de eso importe: el dispositivo trabaja 100% offline, acumula eventos localmente y los sube cuando puede.

Esta página explica por qué offline-first, el ciclo de sync paso a paso, las garantías (idempotencia, deduplicación, resolución de conflictos) y las diferencias entre el sync del mobile y el del kiosk Smiley.

Contratos de referencia

El detalle de request/response está en el API. Las entidades involucradas, en el modelo de datos. El vocabulario, en el glosario.

Por qué offline-first

La realidad física manda: un operario tapea su tag NFC en un sanitario donde no hay señal. Si el sistema dependiera de la red en ese instante, perdería el dato. En cambio:

  • El dispositivo genera el client_uuid al momento de la acción (el tap), ANTES de cualquier I/O. Esa es la identidad del evento y la única garantía de idempotencia: PK local (Room) + UK server.
  • El timestamp del teléfono es la verdad temporal (ts_local_deviceinicio_ts/fin_ts). El server registra cuándo lo recibió, pero no recalcula cuándo ocurrió.
  • La cola local se drena cuando hay red, en orden cronológico.

Lo que esto garantiza:

  • Nada se pierde: cada acción queda persistida localmente antes de cualquier intento de red.
  • Nada se duplica: el client_uuid deduplica tanto en el cliente (OnConflictStrategy.IGNORE en Room) como en el server (UK).
  • El server siempre re-valida: roles, exclusividad de eventos, máquina de estados del tag. Nunca se confía en la validación del cliente.

Lo que el protocolo NO hace (a propósito)

No hace merge de conflictos en catálogos (en catálogos el server siempre gana), no usa websockets ni push (es pull-based), y no implementa compresión propia (la maneja HTTP).

El ciclo de sync paso a paso

El sync es un único request transaccional que combina pull (bajar deltas de catálogos) y push (subir la cola local). Dos reglas de orden son intocables:

  1. Push en orden cronológico (ts_local_device ASC) — ambas colas (trabajos y NFC). El server confía en ese orden.
  2. Los deltas se aplican ANTES que los resultados de push en el cliente.
sequenceDiagram
    autonumber
    participant D as Dispositivo (Room)
    participant B as Backend
    participant DB as MSSQL

    Note over D: El operario tapea el tag offline.<br/>Se genera client_uuid + ts_local_device<br/>ANTES de cualquier I/O. Se persiste local.

    Note over D: Hay red (apertura app, post-tap,<br/>WorkManager 15 min, recuperar red, login...)

    D->>B: POST /api/v1/app/sync<br/>{last_sync, trabajos_pendientes,<br/>nfc_eventos_pendientes, device_now}

    Note over B,DB: PULL — calcula deltas por updated_at<br/>de los 7 catálogos (incluye soft-deleted)
    B->>DB: SELECT por watermark
    DB-->>B: filas actualizadas + ids borrados

    Note over B,DB: PUSH — procesa cada ítem en orden,<br/>re-valida todo, dedup por client_uuid
    B->>DB: INSERT trabajos / TRABAJO_EVENTO<br/>+ transiciones NFC + alertas
    DB-->>B: resultados por ítem

    Note over B: Mide drift si vino device_now<br/>(|device_now − server_now|)

    B-->>D: {server_ts, deltas,<br/>trabajos_procesados[], nfc_eventos_procesados[],<br/>config}

    Note over D: 1. Aplica deltas (catálogos)<br/>2. Procesa resultados de push<br/>3. Purga de la cola lo confirmado

Disparadores del sync (mobile)

Abrir la app (last_sync > 5 min) · tras cada tap/acción NFC · WorkManager cada 15 min · al recuperar red · al loguearse · pull-to-refresh · "Sincronizar ahora".

Procesamiento server por trabajo (el orden importa)

El server procesa cada trabajo de la cola en este orden:

  1. Dedup: si el client_uuid ya está en tap_descartadoRECHAZADO_REBOTE (idempotente, se consulta antes que trabajo). Si ya está en trabajoDUPLICADO_IGNORADO (devuelve el existente, no duplica eventos ni alertas).
  2. Resolver tag (uuid normalizado): si no existe / EN_STOCK / BAJA → trabajo con sanitario_id=NULL, modo=OFFLINE_PENDIENTE_RESOLUCION + alerta TAG_DESCONOCIDO → estado TAG_DESCONOCIDO.
  3. Sanitario inactivo/eliminadoSANITARIO_INACTIVO, no se persiste (el cliente purga sin reintentar).
  4. Operario inactivo → se acepta + warning + alerta OPERARIO_INACTIVO_OFFLINE.
  5. Toggle según rol (LIMPIEZA si OPERADOR, SUPERVISION si SUPERVISOR; ADMIN → ROL_NO_PERMITIDO, no persiste):
    • sin trabajo abierto → INGRESO.
    • abierto en el mismo sanitario → EGRESO (+ procesar eventos).
    • abierto en otro sanitario → cerrar el anterior con EGRESO_CIERRE_AUTOMATICO (duración truncada, sin eventos, warning) + INGRESO nuevo.
  6. Eventos del EGRESO: validar existencia/activo, roles_permitidos, exclusividad (es_exclusivo ⇒ único), mínimo 1; materializar TRABAJO_EVENTO con snapshots; cada genera_alerta produce una alerta EVENTO_REPORTADO.
  7. Drift > 5 min → warning.
  8. Race de client_uuid en el INSERT → devolver el existente.

Limpieza válida y SLA

Solo una limpieza válida resetea el SLA: tipo LIMPIEZA cerrada, sin eventos que invaliden la limpieza y sin duracion_anomala. SUPERVISION nunca resetea. El cierre automático sí resetea, con warning.

Idempotencia, deduplicación y conflictos

Idempotencia y deduplicación

La idempotencia se ancla por completo en el client_uuid:

  • Generado en el dispositivo antes de cualquier I/O, es PK local y UK server.
  • Un reintento del mismo client_uuid devuelve siempre el mismo resultado: si ya está procesado, DUPLICADO_IGNORADO con el trabajo existente; si fue un rebote, RECHAZADO_REBOTE desde tap_descartado.
  • Esto hace que reintentar la cola sea seguro: la red puede cortar a mitad de un sync sin generar duplicados.

Resolución de conflictos NFC

Para la cola de eventos NFC (nfc_eventos_pendientes), el server procesa cada uno:

  1. client_uuid duplicado → DUPLICADO_IGNORADO.
  2. Normalizar el uuid_tag y aplicar la acción vía TagNfcLifecycleService:
    • ALTA_STOCK: idempotente por uuid (existente → OK con el actual).
    • ASIGNACION: el tag debe estar EN_STOCK y el sanitario sin tag ASIGNADO; si no → CONFLICTO_ASIGNACION (el primero en sincronizar gana; el delta de tag_nfc trae la verdad).
    • DESASIGNACION / REASIGNACION: validación de máquina de estados → TRANSICION_INVALIDA si no aplica.
    • Rol del operario insuficiente para la gestión NFC → ROL_INSUFICIENTE.
    • Fallo inesperado al procesar el evento → ERROR.
  3. Toda transición OK persiste su TAG_NFC_EVENTO.

Reacción del cliente ante la respuesta: OK/DUPLICADO → purgar de la cola. CONFLICTO/TRANSICION_INVALIDA → purgar, revertir el optimismo local con el delta y notificar al usuario. Error transitorio → backoff (mismo esquema de 8 intentos que los trabajos).

Casos especiales destacados

  • Tag reasignado entre tap y sync: el trabajo se asocia al sanitario actual del tag (la realidad física manda) + warning.
  • Olvido de salida: cierre automático truncado (por el server o por el scheduler de trabajos colgados).
  • Conflicto de asignación NFC offline: dos devices asignan tags al mismo sanitario; el primero en sincronizar gana, el segundo recibe CONFLICTO_ASIGNACION y revierte.
  • Egreso corto accidental (doble-tap al salir): el server cierra el trabajo con duracion_anomala=1 (no resetea SLA) + alerta DURACION_ANOMALA; el registro queda auditable.
  • Rebote de ingreso: un segundo tap a menos de rebote-ingreso-seg del EGRESO (mismo operario+sanitario+device) se interpreta como doble-tap, no como INGRESO nuevo → RECHAZADO_REBOTE, se registra en tap_descartado, no crea trabajo fantasma.
  • Registro MANUAL (placa rota): el ítem trae manual=true + sanitario_id + motivo_contingencia_codigo sin tag; el server resuelve por id, exige motivo válido (si falta → MANUAL_INVALIDO), crea el trabajo con modo_registro=MANUAL + alerta TAG_DEFECTUOSO.

Mobile vs. kiosk Smiley

El kiosk Smiley tiene su propio endpoint de sync (POST /api/v1/smiley/sync), separado del de operarios. Comparte las reglas de oro (idempotencia por client_uuid, ts_local_device como verdad temporal, push en orden cronológico, deltas antes que push, el server re-valida todo), pero es mucho más simple.

Aspecto Mobile (/api/v1/app/sync) Kiosk Smiley (/api/v1/smiley/sync)
Autenticación JWT del operario (ROLE_OPERARIO) api_key del device, headers X-Device-Uuid + X-Api-Key (ROLE_TERMINAL)
Qué sube (push) trabajos_pendientes + nfc_eventos_pendientes opiniones_pendientes
Qué baja (pull) 7 catálogos (deltas) solo el catálogo motivo_opinion
Identidad del sanitario va en el request (vía tag/sanitario) la determina el server por el vínculo del device; la terminal no puede opinar por otro sanitario
Estados de push 8 estados (OK, TAG_DESCONOCIDO, RECHAZADO_REBOTE, …) solo OK y DUPLICADO_IGNORADO
Anti-abuso rebote/drift/duración anómala debounce (3 s) + colapso de ráfaga (5 / 30 s)

El sync de opiniones

La terminal envía opiniones con valorPOSITIVA | NEUTRA | NEGATIVA (enum ValorOpinion), motivo_codigo? opcional, más telemetría opcional (app_version, os_version, bateria_pct, red_tipo, lock_task_activo). El server responde con opiniones_procesadas (estado OK o DUPLICADO_IGNORADO, los únicos dos que emite), el delta de motivo_opinion, y un bloque config (último servicio, textos, logo, pin_tecnico_hash).

Anti-abuso silencioso en el kiosk

Los descartes por debounce o ráfaga también devuelven OK a propósito, y se persisten con descartada=true (auditables, no se borran). El cliente drena de la cola toda opinión cuyo estado sea OK o DUPLICADO_IGNORADO; si una opinión enviada no vuelve en opiniones_procesadas, queda en cola para reintentar.

Disparadores del sync Smiley: tras cada opinión (inmediato) · WorkManager cada 15 min · al recuperar red. El idle ("último servicio") se refresca aparte con GET /api/v1/smiley/estado cada 5 min mientras la terminal está visible.

Detalles internos del sync

Hasta acá vimos el sync desde el contrato (qué entra, qué sale, qué garantías da). Esta sección baja un nivel: cómo lo implementa el server por dentro. Es material para debuggear —entender por qué una query es cara, por qué un trabajo abortó el batch pero un evento NFC no, o por qué rotar el device no resetea el throttling—. La fuente es el código de SyncService, TrabajoTapService, LoginThrottlingService y AppAuthService. Si algo de acá contradice al contrato del endpoint, gana el código.

Cálculo de deltas: no hay un watermark único

El pull no usa un único last_sync. El cliente manda un last_sync con un since por cada catálogo y el server dispara 7 queries separadas, una por catálogo (SyncService.calcularDeltas, SyncService.java:490-544):

sanitarioRepository.findByUpdatedAtGreaterThan(sinceSanitario)
tagNfcRepository.findByUpdatedAtGreaterThan(sinceTagNfc)
operarioRepository.findByUpdatedAtGreaterThan(sinceOperario)
// ...sucursal, sector, tipo_sanitario, evento_limpieza

Cada query trae todas las filas con updated_at > since, incluyendo las soft-deleted (las que tienen deleted_at != null). El server no filtra los borrados en SQL: trae todo y particiona en memoria. El helper partition (SyncService.java:656-672) recorre las filas y las reparte:

Condición de la fila Va a
deleted_at == null updated[] (DTO completo)
deleted_at != null deleted_ids[] (solo el id)

Por eso un catálogo borrado viaja como id en deleted_ids y el cliente lo elimina de su Room local. Si last_sync es null para un catálogo, el server usa Instant.EPOCH como since (SyncService.java:491) → trae todo el catálogo.

El riesgo a evaluar: queries sin LIMIT

Riesgo real: pull sin paginación → posible OOM

Las 7 queries de deltas no tienen LIMIT ni paginación. Con un last_sync nulo (primer sync de un device) o muy viejo, findByUpdatedAtGreaterThan(EPOCH) materializa el catálogo entero en memoria del server: primero la List<Entity> de Hibernate, después la List<DTO> que arma partition. Con catálogos chicos no se nota; con catálogos grandes (miles de filas, p. ej. tag_nfc o sanitario en un despliegue grande) hay riesgo de OOM o pausa de GC que degrada todo el nodo. Para el piloto Corpal los volúmenes son chicos y no muerde, pero antes de escalar a catálogos grandes hay que paginar o acotar el delta (o cachear/streamear). Marcado como riesgo a evaluar, no como bug confirmado.

Asimetría transaccional: trabajos vs. eventos NFC

El método sync() está anotado @Transactional a nivel de clase (SyncService.java:90), así que todo el batch corre en UNA transacción. La consecuencia es una asimetría deliberada en el manejo de errores:

flowchart TD
    A[sync @Transactional] --> B[procesarTrabajos]
    A --> C[procesarNfcEventos]
    B --> D{¿Un trabajo<br/>tira excepción<br/>no capturada?}
    D -->|Sí| E[ROLLBACK de TODO<br/>el sync]
    D -->|No| F[Commit normal]
    C --> G[try/catch POR ÍTEM]
    G --> H[Conflicto/transición inválida<br/>= estado en el response]
    H --> I[Los demás eventos<br/>siguen procesándose]
  • Trabajos (procesarTrabajos, SyncService.java:249-261): no hay try/catch por ítem. Si tapService.procesar(...) revienta con una excepción no capturada, propaga y hace rollback de todo el sync (trabajos previos, eventos NFC, drift, ultimo_sync).
  • Eventos NFC (procesarUnNfcEvento, SyncService.java:451-486): cada ítem va envuelto en try/catch. Un ConflictoAsignacionException o TransicionInvalidaException se traduce a un estado en el response (CONFLICTO_ASIGNACION, TRANSICION_INVALIDA) y no aborta los demás ítems ni la transacción.

Para el detalle de la máquina de estados NFC que dispara esas excepciones, ver la resolución de conflictos NFC más arriba en esta misma página.

Orden defensivo: el server no confía en el cliente

El protocolo exige que el cliente mande la cola en orden cronológico (ts_local_device ASC), pero el server no se confía: re-ordena la cola en memoria antes de procesar (SyncService.java:252-253):

List<TrabajoPendiente> ordenados = pendientes.stream()
    .sorted(Comparator.comparing(TrabajoPendiente::tsLocalDevice)).toList();

La cola NFC hace lo mismo (SyncService.java:431-435, con Instant.EPOCH como fallback si falta el timestamp). Esto importa para los casos donde el orden decide el resultado: el primer INGRESO/EGRESO en el tiempo gana, y en NFC el primero que asigna un sanitario gana (los demás reciben CONFLICTO_ASIGNACION).

Idempotencia de trabajos en 3 pasos (el orden importa)

TrabajoTapService.procesar (TrabajoTapService.java:174-184) chequea idempotencia en tres consultas, en este orden exacto —y el orden no es casual—:

flowchart TD
    A[client_uuid] --> B{¿Está en<br/>tap_descartado?}
    B -->|Sí| R1[RECHAZADO_REBOTE]
    B -->|No| C{¿Está en<br/>trabajo?}
    C -->|Sí| R2[DUPLICADO_IGNORADO<br/>devuelve el existente]
    C -->|No| D[Procesar como nuevo]
  1. tap_descartado primero (findByClientUuidRECHAZADO_REBOTE): un reintento de un INGRESO ya rechazado por rebote debe responder RECHAZADO_REBOTE igual que la primera vez. Si chequeáramos trabajo primero, no lo encontraríamos y procesaríamos un INGRESO fantasma.
  2. trabajo después (findByClientUuidDUPLICADO_IGNORADO): devuelve el trabajo existente, no duplica eventos ni alertas.
  3. Recién si no existe en ninguna, procesa como nuevo.

Throttling de login con dos buckets

LoginThrottlingService (LoginThrottlingService.java:42-72) mantiene dos buckets en una cache Caffeine in-memory:

Bucket Clave Para qué
Por usuario usuario Sobrevive a la rotación del device
Por par (usuario, device_uuid) Aísla el throttling de un device específico

El device_uuid lo manda el cliente sin autenticar (header X-Device-UUID). Si el contador fuera solo por par, un atacante rotaría el header en cada intento y resetearía el contador para bruteforcear el usuario. El bucket por usuario sobrevive esa rotación (ADR-039). Ambos buckets se incrementan en cada fallo (recordFailure) y se invalidan en un login exitoso (recordSuccess). El límite es 5 intentos → bloqueo de 10 min, y la entrada expira ~1h sin nuevos fallos (expireAfterWrite(ATTEMPT_WINDOW_HOURS=1), LoginThrottlingService.java:34).

Rotación de refresh tokens

En cada login (AppAuthService.doLogin, AppAuthService.java:145-151) el server revoca los refresh tokens anteriores del mismo par antes de emitir uno nuevo:

refreshTokenRepository.revokeAllByOperarioAndDevice(operario, deviceUuid, now);

Tres decisiones de diseño:

  • Ligado al device_uuid: el refresh trae el device_uuid y al refrescar se valida que coincida (AppAuthService.java:118-126). Usarlo en otro device es ilegítimo → InvalidCredentialsException. En la práctica: cambiar de device = re-login.
  • Rotación en cada refresh: el refresh() revoca el token usado (setRevokedAt) y emite uno nuevo (AppAuthService.java:128-131). Un token de refresh es de un solo uso.
  • Hash SHA-256, no BCrypt (sha256Hex, AppAuthService.java:205-214): el token se guarda hasheado para poder buscarlo por igualdad (findByTokenHash). SHA-256 es determinístico y rápido; BCrypt (con salt por fila) obligaría a un scan + verify y no permite lookup directo. El token plano tiene suficiente entropía (UUID.randomUUID()), así que no necesita el work-factor de BCrypt.

Revocación de dispositivo inmediata

Cada /sync valida dispositivo.activo por request, sin cache (SyncService.java:158-164). Si un admin deshabilita un device (activo=false), el próximo sync de ese device se rechaza con 401 DEVICE_REVOCADO —no hay ventana de cache que demore la revocación—. El cliente borra credenciales y se bloquea; su cola offline no se acepta hasta re-habilitarlo. Ver la nota sobre revocación en el modelo de datos.

Eventos de limpieza: CSV, array y exclusividad todo-o-nada

Dos detalles de los eventos de limpieza que viajan en el delta y se validan en el EGRESO:

  • roles_permitidos: CSV en la base, array en el wire. La entidad guarda los roles como un CSV ("OPERADOR,SUPERVISOR"), pero la app espera roles_permitidos como array JSON. splitRolesPermitidos (SyncService.java:644-650) parte el CSV antes de serializar; emitir el CSV crudo rompería la deserialización Moshi del cliente. Null/blank → array vacío.
  • Exclusividad todo-o-nada. Al validar los eventos de un EGRESO (TrabajoTapService.java:413-419), si algún evento es es_exclusivo y vino acompañado de otros, se rechaza la combinación entera y no se persiste ningún trabajo_evento (solo queda un warning). No es "ignoro el exclusivo y proceso el resto" ni al revés: o el exclusivo viene solo, o no entra nada.