Saltar a contenido

Reglas operativas (parámetros y SLA)

Estos son los números y reglas de negocio que gobiernan el comportamiento del sistema; cambiarlos cambia la operación. Todo lo que sigue está verificado contra el código del backend (no contra documentos previos): cada fila apunta al archivo y la línea que la define. Si mañana alguien edita un default, este documento queda desactualizado salvo que se actualice junto al código.

Cómo leer esta página

Los parámetros son configurables vía application.yml (prefijos app.tap, app.smiley, app.monitor, app.scheduler). Los valores de la tabla son los defaults de seguridad que aplica el constructor de cada record cuando el valor inyectado es <= 0 (o vacío). Es decir: aunque no configures nada, el sistema arranca con estos números.


1. Parámetros configurables de negocio

1.1. Algoritmo del tap — app.tap

Definidos en TapProperties.java (@ConfigurationProperties(prefix = "app.tap")). El constructor compacto del record fija el default si el valor es <= 0.

Parámetro Default Qué controla
cierreAutomaticoMaxMinutes 30 min Si el operario olvida marcar salida, la duración del trabajo cerrado automáticamente se trunca a este tope (ADR-003).
clockDriftToleranceMinutes 5 min Tolerancia entre ts_local_device y server_received_ts. Si se excede, agrega un warning pero NO rechaza el trabajo.
duracionMinimaSeg 30 seg Piso de validez de una limpieza (ANEXO DT, DT-D2). Un EGRESO con duración menor cierra el trabajo con duracion_anomala=1: no cuenta como limpieza válida y genera Alerta DURACION_ANOMALA. Es un detector de tap accidental, no una vara de calidad fina.
reboteIngresoSeg 8 seg Ventana anti-rebote server-side (DT-D3 / DT-B3). Un INGRESO a menos de este margen de un EGRESO del mismo operario+sanitario+device se descarta como doble-tap accidental.
driftMaxSeg 300 seg (5 min) Umbral de drift de reloj del device (DT-D5). Si \|device_now − server_now\| lo excede, se estampa drift_seg y se genera Alerta RELOJ_DESVIADO.
silencioClienteSeg 8 seg Config remota que viaja al cliente en el sync (DT-D1): tras procesar un tap, la app ignora lecturas del mismo tag por estos segundos.
confirmarEgresoMin 2 min Config remota que viaja al cliente (DT-D4): si un EGRESO resuelve con duración menor a estos minutos, la app pide confirmación al operario.

Dos parámetros distintos para el reloj desviado

clockDriftToleranceMinutes (5 min) y driftMaxSeg (300 seg) coinciden en valor pero son cosas distintas: el primero decide el warning clock_drift_warning en el trabajo; el segundo decide la Alerta RELOJ_DESVIADO. No los unifiques pensando que es redundancia.

1.2. Anti-abuso del Smiley — app.smiley

Definidos en SmileyProperties.java (prefix = "app.smiley").

Parámetro Default Qué controla
debounceSeg 3 seg Opiniones del mismo device separadas por menos de este valor se descartan (se conserva la primera de cada ráfaga temporal).
rafagaMax 5 opiniones Máximo de opiniones del mismo valor permitidas dentro de la ventana de ráfaga. Si hay más, se conserva 1 y el resto se marca descartada=true, motivoDescarte="rafaga".
rafagaVentanaSeg 30 seg Ventana de tiempo para el colapso de ráfaga.
pinTecnicoDefault 2468 PIN técnico GLOBAL del menú oculto de la terminal (S13/B3a). Es un secreto de deploy: el backend lo hashea con BCrypt al arrancar y solo entrega el hash en /sync. El override por empresa (empresa.pin_tecnico_hash) lo pisa si existe.

Colapso de ráfaga, en una frase

Hasta 5 opiniones del mismo valor en 30 segundos se cuentan como una sola. Es la regla que evita que alguien martille el botón "feliz" 50 veces.

1.3. Estado de conexión de dispositivos — app.monitor

Definidos en MonitorDispositivoProperties.java (prefix = "app.monitor"). Derivan el estado de conexión a partir de ultimoContactoTs.

Parámetro Default Qué controla
operarioEnLineaMin 30 min Minutos desde el último contacto por debajo de los cuales un APP_OPERARIO se considera EN_LINEA.
operarioConRetrasoMin 120 min Umbral superior: entre 30 y 120 min un APP_OPERARIO está CON_RETRASO; por encima, offline.
terminalEnLineaMin 15 min Minutos por debajo de los cuales una TERMINAL_SMILEY se considera EN_LINEA.
terminalSinReportarMin 30 min Minutos a partir de los cuales una TERMINAL_SMILEY se considera SIN_REPORTAR (genera alerta G3).
monitorCheckFixedRateMin 5 min Intervalo del scheduler G3 que detecta tablets caídas y auto-atiende las que reconectaron.

Terminal vs app operario tienen umbrales distintos

Una terminal se considera caída a los 30 min (umbral único SIN_REPORTAR). Una app de operario tiene dos escalones: EN_LINEA (< 30 min), CON_RETRASO (30–120 min) y offline (> 120 min). No es el mismo número porque la terminal reporta de forma continua y el operario no.


2. "Limpieza válida" y cuentaParaSla — la regla más importante

Una limpieza resetea el SLA del sanitario (es decir, vuelve a poner el reloj de "última limpieza" en cero) solo si cumple TODO lo siguiente. Si falla cualquiera de los criterios, no cuenta y el sanitario sigue acumulando tiempo sin limpiar.

Evidencia: TrabajoRepository.findUltimaLimpiezaValida (TrabajoRepository.java:95-108) y su gemela existsLimpiezaValidaById (líneas 115-127), que aplican los mismos criterios.

# Criterio Campo / condición
1 Es una limpieza (no una supervisión) tipo = LIMPIEZA
2 Está cerrada finTs IS NOT NULL
3 No es de duración anómala (egreso corto, < duracionMinimaSeg) duracionAnomala = false
4 Cuenta para el SLA cuentaParaSla = true
5 Ningún evento la invalida no existe TrabajoEvento con eventoLimpieza.invalidaLimpieza = true

Los criterios son AND, no OR

Basta con que uno falle para que la limpieza no resetee el SLA. Una limpieza puede estar perfectamente cerrada y registrada (finTs != null, sin eventos) y aun así no contar si cuentaParaSla = false. Ese es exactamente el caso del trabajo colgado que se ve abajo.

2.1. El default y quién lo cambia

Trabajo.cuentaParaSla arranca en Boolean.TRUE por construcción de la entidad (Trabajo.java:78-79). Casi nadie lo baja a false. Solo dos caminos lo tocan:

Camino Efecto sobre cuentaParaSla Evidencia
Registro MANUAL (placa rota + motivo de contingencia) true si NFC; si es MANUAL depende de contingenciaProps.cuentaComoValida() (RB-D3, default true) TrabajoTapService.java:523
Cierre por scheduler de trabajo colgado false — forzado SlaSchedulerService.java:150

2.2. Los dos cierres automáticos que se comportan distinto

Esta es la causa de la confusión clásica: "hay dos cierres automáticos y se comportan distinto". Y es cierto. Ambos dejan el trabajo en estado EGRESO_CIERRE_AUTOMATICO y truncan la duración a cierreAutomaticoMaxMinutes (30 min), pero solo uno toca cuentaParaSla.

Cierre automático por tap Cierre por scheduler (colgado)
Qué lo dispara El operario abre en sanitario A y tapea B sin cerrar A El trabajo quedó abierto más de app.scheduler.trabajos-colgados-max-hours (horas) y nadie lo cerró
Quién lo ejecuta TrabajoTapService (en el sync) SlaSchedulerService.cerrarTrabajosColgados() (job @Scheduled, cada 1 hora)
Estado resultante EGRESO_CIERRE_AUTOMATICO EGRESO_CIERRE_AUTOMATICO
Duración truncada a 30 min (o al tap de B si es antes) truncada a 30 min (o a now si es antes)
cuentaParaSla NO se toca → queda true se fuerza a false
¿Resetea el SLA? (es una limpieza real que el operario olvidó cerrar al instante) NO (un colgado de horas no es una limpieza real medida)
Warning cierre_automatico scheduler_cierre
Evidencia TrabajoTapService.java:334-340 SlaSchedulerService.java:141-155

La distinción es deliberada (ADR-024)

El cierre por tap cuenta porque el operario efectivamente limpió A y se fue a B: la limpieza ocurrió, solo faltó el egreso explícito. El cierre por scheduler no cuenta porque un trabajo abierto durante horas no representa una limpieza medible — pudo haber sido un tap accidental al inicio del turno que quedó huérfano. El comentario en SlaSchedulerService.java:147-150 lo dice textual: "el cierre automático por siguiente ingreso (path del tap, ADR-024) sí cuenta: aquel no toca cuentaParaSla".

flowchart TD
    A[Trabajo abierto sin egreso] --> B{¿Quién lo cierra?}
    B -->|Operario tapea otro sanitario| C[Cierre por tap]
    B -->|Scheduler tras N horas| D[Cierre por scheduler]
    C --> E[estado = EGRESO_CIERRE_AUTOMATICO<br/>cuentaParaSla NO se toca = true]
    D --> F[estado = EGRESO_CIERRE_AUTOMATICO<br/>cuentaParaSla = false]
    E --> G[Resetea el SLA del sanitario]
    F --> H[NO resetea el SLA]

3. Cierre automático y truncado de duración

Cuando el operario abre en el sanitario A y tapea B sin cerrar A, el sistema cierra A automáticamente. El fin_ts no es simplemente "ahora": se calcula como el mínimo entre el tope de 30 min y el momento del ingreso a B.

fin_ts(A) = min( inicio_ts(A) + cierreAutomaticoMaxMinutes , ts_ingreso(B) )

Evidencia: calcularCierreAutomatico (TrabajoTapService.java:672-675), invocado desde el flujo de cierre+ingreso (TrabajoTapService.java:334-356):

private Instant calcularCierreAutomatico(Instant inicioAbierto, Instant tapActual) {
    Instant maxFin = inicioAbierto.plus(Duration.ofMinutes(props.cierreAutomaticoMaxMinutes()));
    return tapActual.isBefore(maxFin) ? tapActual : maxFin;
}

Por qué el mínimo y no el tap directo

Si el operario abrió A a las 09:00 y recién tapea B a las 11:30, sería absurdo registrar 2h30 de limpieza en A. El tope de 30 min corta esa inflación. Pero si tapeó B a las 09:10, el cierre usa las 09:10 reales (10 min), no los 30 de tope. El tope es un techo, no un valor fijo.


4. Reglas de aceptación / rechazo del tap

El orden de validación importa: el rol se chequea antes de resolver el tag, y los operarios inactivos no se rechazan.

4.1. Operario con rol ADMIN → ROL_NO_PERMITIDO (no persiste nada)

Un operario con rol = ADMIN que tapea devuelve RolNoPermitido y no persiste absolutamente nada: ni el trabajo, ni un TAG_DESCONOCIDO, ni su alerta. El cliente ADMIN purga el tap sin reintentar.

Evidencia: TrabajoTapService.java:200-203.

RolOperario rol = operario.getRol() != null ? operario.getRol() : RolOperario.OPERADOR_LIMPIEZA;
if (rol == RolOperario.ADMIN) {
    return new RolNoPermitido(rol);
}

Regla de seguridad de auditoría (2026-06-11)

El chequeo de rol va antes de resolver el tag justamente para que un ADMIN no deje rastro alguno. Si lo movieras después de la resolución del tag, un ADMIN tapeando una placa desconocida generaría un TAG_DESCONOCIDO persistido y su alerta — exactamente lo que esta regla evita. No reordenes este chequeo.

4.2. Operario inactivo → se ACEPTA (con warning + alerta)

Contraintuitivamente, un operario inactivo que tapea no se rechaza: el trabajo se crea igual, se le agrega el warning operario_inactivo_offline y se genera la Alerta OPERARIO_INACTIVO_OFFLINE.

Evidencia: el flag se calcula sin cortar el flujo en TrabajoTapService.java:190, se agrega el warning en la línea 194, y la alerta se emite tras el cierre/ingreso (líneas 257-261, 328, 355).

boolean operarioInactivo = !Boolean.TRUE.equals(operario.getActivo());
// ...
if (operarioInactivo) warnings.add(WARN_OPERARIO_INACTIVO);

Por qué no se rechaza

El operario ya está físicamente en el sanitario y ya limpió — rechazar el dato perdería trabajo real hecho en campo. El sistema prefiere registrar y alertar antes que descartar. La inactividad es un problema administrativo (alguien lo dio de baja en el sistema pero sigue trabajando), no una razón para tirar evidencia.

4.3. Resumen de resoluciones del tap

Situación Resultado (TapResultado) ¿Persiste trabajo?
Mismo client_uuid ya procesado Duplicado No (devuelve el existente)
Rol ADMIN RolNoPermitido No (nada)
Operario inactivo sigue el flujo normal (Ingreso/Egreso/...) + warning + alerta
Tag no ASIGNADO (stock/baja/inexistente) TagDesconocido Sí (con tag nulo) + alerta
Sanitario inactivo o borrado SanitarioInactivo No
Rebote post-egreso (< 8 seg) RechazadoRebote No (registra tap_descartado)
MANUAL sin sanitario/motivo válido ManualInvalido No

5. Acumulación de warnings

El trabajo guarda sus warnings en una sola columna warnings NVARCHAR(500), concatenados con |, y truncados a 500 caracteres. No es una tabla de eventos: es un string plano.

Constantes definidas en TrabajoTapService.java:78-86; concatenación y truncado en appendWarnings (TrabajoTapService.java:680-694):

private static void appendWarnings(Trabajo t, List<String> warnings) {
    if (warnings == null || warnings.isEmpty()) return;
    String joined = String.join("|", warnings);
    if (t.getWarnings() == null || t.getWarnings().isBlank()) {
        t.setWarnings(joined);
    } else {
        t.setWarnings(t.getWarnings() + "|" + joined);
    }
    // Mantener dentro del límite de 500 chars
    if (t.getWarnings().length() > 500) t.setWarnings(t.getWarnings().substring(0, 500));
}

Warnings posibles:

Constante String almacenado Cuándo se agrega
WARN_OPERARIO_INACTIVO operario_inactivo_offline Operario dado de baja que tapea
WARN_CLOCK_DRIFT clock_drift_warning Drift entre device y server > 5 min
WARN_CIERRE_AUTOMATICO cierre_automatico Cierre por tap en otro sanitario
WARN_TAG_DESCONOCIDO tag_desconocido Tag no ASIGNADO
WARN_SIN_EVENTOS sin_eventos EGRESO sin eventos (B2)
WARN_EVENTOS_EXCLUSIVO_COMBINADO eventos_exclusivo_combinado Evento exclusivo combinado con otros
WARN_EVENTO_RECHAZADO_PREFIX evento_rechazado:<ref> Evento inexistente/inactivo/borrado
WARN_EVENTO_NO_PERMITIDO_PREFIX evento_no_permitido:<codigo> Evento no permitido para el rol
scheduler_cierre (WARN_SCHEDULER_CIERRE) scheduler_cierre Cierre por scheduler de colgado (SlaSchedulerService)

El truncado a 500 puede perder warnings

Si un trabajo acumula muchos warnings (varios evento_rechazado: con refs largas, por ejemplo), el string se corta a 500 caracteres y los últimos warnings se pierden silenciosamente. No confíes en la columna warnings como auditoría completa cuando hay alta cardinalidad de eventos — para eso están las alertas y la tabla de eventos.


Ver también

  • Sincronización — el protocolo de sync donde se procesan los taps y se aplican estas reglas.
  • Jobs y alertas — los schedulers (SlaSchedulerService, monitor G3) que cierran colgados y derivan estados de conexión.
  • Modelo de datos — los campos cuenta_para_sla, duracion_anomala, warnings y la tabla de eventos.