Saltar a contenido

Modelo de datos

Si sos nuevo en WorkDone Sanitarios, este es el mapa que tenés que tener a mano. Todo el sistema gira alrededor de una idea simple: el personal de limpieza acerca su teléfono (o una terminal) a una placa NFC pegada en un sanitario y eso registra un trabajo. El resto del modelo existe para darle contexto a ese tap: dónde está el sanitario, quién lo hizo, con qué placa, qué pasó adentro y si cuenta para el SLA.

Esta página describe el modelo de datos v2, que reemplaza al v1. La fuente de verdad son las tablas MSSQL del backend (Spring Boot / JHipster). El nombre de dominio va siempre en español (sanitario, trabajo, operario) — nunca se renombra a inglés. La app móvil Android mantiene un espejo local (Room) de un subconjunto de estas tablas para funcionar offline; las diferencias están en la sección Espejo móvil.

Convenciones globales

  • Las tablas de catálogo (Empresa, Sucursal, Sector, TipoSanitario, Sanitario, TagNfc, Operario, Dispositivo, EventoLimpieza, SlaLimpieza) tienen id BIGINT IDENTITY PK, updated_at DATETIME2 NOT NULL (deltas de sync) y deleted_at DATETIME2 NULL (soft delete). Las queries normales filtran el soft delete; el sync NO (propaga las bajas).
  • Llevan auditoría JHipster (AbstractAuditingEntity): created_by, created_date, last_modified_by, last_modified_date.
  • Las tablas transaccionales / de eventos (Trabajo, TrabajoEvento, TagNfcEvento, Alerta, Opinion) son inmutables y sin soft delete.
  • Todo el texto va en NVARCHAR. Para los timestamps del dominio, el del teléfono al momento del tap es la verdad (inicio_ts / fin_ts); el server guarda aparte server_received_ts.

Diagrama de entidades

El siguiente diagrama muestra las entidades principales y sus relaciones. La jerarquía física baja por la izquierda (Empresa → Sucursal → Sector → Sanitario) y la operación NFC cuelga del Sanitario.

erDiagram
    EMPRESA ||--o{ SUCURSAL : tiene
    SUCURSAL ||--o{ SECTOR : tiene
    SECTOR ||--o{ SANITARIO : contiene
    TIPO_SANITARIO ||--o{ SANITARIO : clasifica
    SANITARIO ||--o| SLA_LIMPIEZA : "define (1:1)"
    SANITARIO ||--o| TAG_NFC : "asignado (máx 1)"
    SANITARIO ||--o{ TRABAJO : "registra"
    SANITARIO ||--o{ OPINION : "recibe"
    TAG_NFC ||--o{ TAG_NFC_EVENTO : audita
    OPERARIO ||--o{ TRABAJO : crea
    OPERARIO ||--o{ TAG_NFC_EVENTO : ejecuta
    DISPOSITIVO ||--o{ TRABAJO : "registra desde"
    DISPOSITIVO ||--o{ OPINION : "captura (terminal)"
    DISPOSITIVO ||--o| SANITARIO : "terminal Smiley"
    TRABAJO ||--o{ TRABAJO_EVENTO : "reporta"
    EVENTO_LIMPIEZA ||--o{ TRABAJO_EVENTO : "instancia"
    TRABAJO ||--o{ ALERTA : "puede generar"
    MOTIVO_OPINION ||--o{ OPINION : clasifica

    EMPRESA {
        bigint id PK
        nvarchar codigo UK
        nvarchar nombre
        bit activo
    }
    SUCURSAL {
        bigint id PK
        bigint empresa_id FK
        nvarchar codigo UK
        nvarchar direccion
    }
    SECTOR {
        bigint id PK
        bigint sucursal_id FK
        nvarchar codigo
    }
    SANITARIO {
        bigint id PK
        nvarchar codigo UK
        bigint sector_id FK
        bigint tipo_sanitario_id FK
    }
    TAG_NFC {
        bigint id PK
        nvarchar uuid_tag UK
        nvarchar estado
        bigint sanitario_id FK
    }
    OPERARIO {
        bigint id PK
        nvarchar usuario UK
        nvarchar rol
    }
    DISPOSITIVO {
        bigint id PK
        nvarchar device_uuid UK
        nvarchar tipo
    }
    TRABAJO {
        bigint id PK
        nvarchar client_uuid UK
        nvarchar tipo
        nvarchar estado
        bigint operario_id FK
        bigint sanitario_id FK
    }
    TRABAJO_EVENTO {
        bigint id PK
        bigint trabajo_id FK
        bigint evento_limpieza_id FK
    }
    EVENTO_LIMPIEZA {
        bigint id PK
        nvarchar codigo UK
    }
    OPINION {
        bigint id PK
        nvarchar client_uuid UK
        bigint sanitario_id FK
        nvarchar valor
    }

El diagrama es un resumen

Muestra solo las claves y FKs más relevantes para entender las relaciones. Las tablas de cada entidad, más abajo, listan los campos completos que aparecen en las fuentes. Entidades de soporte (TagNfcEvento, Alerta, MotivoOpinion, ReglaTendencia, rutas, planos) se documentan en texto, no todas en el ER.

La jerarquía física

Toda la infraestructura cuelga de una cadena de cuatro niveles. No es multi-tenant: la Empresa es el cliente del servicio, no un aislamiento de datos.

Empresa (cliente del servicio, ej. AA2000 — NO multi-tenant)
  └── Sucursal (ej. AEP, EZE)
        └── Sector (ej. Terminal A, Patio de comidas)
              └── Sanitario (+ TipoSanitario)
  • Empresa — el cliente contratante (ej. AA2000). Tiene un codigo único global.
  • Sucursal — una sede del cliente (ej. AEP, EZE). Pertenece a una Empresa.
  • Sector — una zona dentro de la sucursal (ej. Terminal A, Patio de comidas). Su codigo es único dentro de la sucursal (UK compuesta sucursal_id, codigo).
  • Sanitario — el baño concreto donde va la placa NFC. Su codigo es único global (ej. "AEP-T-B12"). Se clasifica opcionalmente con un TipoSanitario.

El Sanitario es el centro de gravedad

La historia de limpiezas, las opiniones y el SLA viven en el Sanitario, no en la placa. Una placa se puede desasignar, dar de baja o reemplazar, y el sanitario conserva todo su histórico. Por eso, cuando reemplazás un tag, no se pierde nada.

Empresa

Columna Tipo Descripción
codigo NVARCHAR(20) UK Código único global de la empresa
nombre NVARCHAR(100) NN Nombre del cliente
activo BIT NN Si está operativa
visible_opiniones BIT NN default 0 Si el portal cliente muestra opiniones Smiley de esta empresa
logo_cliente_url / logo_cliente_hash Branding de la terminal Smiley por empresa
texto_pregunta / texto_gracias Textos de la terminal Smiley por empresa
pin_tecnico_hash NVARCHAR (BCrypt) Override del PIN técnico del menú oculto del kiosk; nunca en texto plano
umbral_publico_min INT NN default 90 Minutos máx. para mostrar "Limpiado hace X min" al público (QR)
mostrar_estado_publico BIT default 1 Kill-switch del estado público por empresa

Sucursal

Columna Tipo Descripción
empresa_id FK NN → Empresa Empresa a la que pertenece
codigo NVARCHAR(20) UK Código de la sucursal
nombre NVARCHAR(100) NN Nombre de la sede
direccion NVARCHAR(255) Dirección física
activo BIT NN Si está operativa

Sector

Columna Tipo Descripción
sucursal_id FK NN → Sucursal Sucursal contenedora · UK compuesta (sucursal_id, codigo)
codigo NVARCHAR(20) NN Código del sector (único dentro de la sucursal)
nombre NVARCHAR(100) NN Nombre del sector
activo BIT NN Si está operativo

TipoSanitario

Catálogo chico. Seed: DAMAS, CABALLEROS, DISCAPACITADOS, MIXTO.

Sanitario

Columna Tipo Descripción
codigo NVARCHAR(30) UK Código único global, ej. "AEP-T-B12"
nombre NVARCHAR(100) NN Nombre del sanitario
descripcion NVARCHAR(255) Descripción libre
sector_id FK NN → Sector Sector contenedor (reemplaza al ubicacion_id v1)
tipo_sanitario_id FK NULL → TipoSanitario Clasificación opcional
activo BIT NN Si está operativo
slug_publico VARCHAR(64) UK NN Identificador opaco para la URL pública del QR (/p/{slug}), generado con SecureRandom

Cambio v1 → v2

En v1 existía una tabla ubicacion. En v2 desaparece: se descompone en Empresa + Sucursal + Sector, y el Sanitario ahora apunta a sector_id.

La placa NFC y su máquina de estados

Cada placa física es un registro TagNfc identificado por su uuid_tag (normalizado: trim/upper/sin espacios en todo punto de entrada). El sistema usa whitelist estricta: un tag no registrado es inutilizable. Un tap con UUID desconocido genera un trabajo OFFLINE_PENDIENTE_RESOLUCION y una alerta TAG_DESCONOCIDO.

●──ALTA_STOCK(app móvil)──► EN_STOCK ──ASIGNACION──► ASIGNADO
                              ▲  │ ▲                    │
                              │  │ └───DESASIGNACION────┘
                       RE_ALTA│  └──BAJA──► BAJA
  • Alta de stock SOLO desde la app móvil (rol ADMIN — hay que leer el chip onsite).
  • Asignar / desasignar / reasignar / baja: app o BackOffice web. Re-alta: solo web.
  • Máximo 1 tag ASIGNADO por sanitario. Para reemplazar: desasignar → asignar.
  • BAJA solo desde EN_STOCK (desasignar primero).

TagNfc

Columna Tipo Descripción
uuid_tag NVARCHAR(64) UK UUID normalizado del chip; UNIQUE global
alias NVARCHAR(100) NN Descriptivo, ej. "tag #14 lote mayo"
estado NVARCHAR(20) NN EN_STOCK | ASIGNADO | BAJA
sanitario_id FK NULL → Sanitario Solo cuando ASIGNADO; al desasignar queda NULL (la historia vive en TagNfcEvento)
asignado_en / dado_baja_en DATETIME2 NULL Marcas temporales de transición
activo BIT NN Legado v1, derivado (= estado=='ASIGNADO'); se mantiene por compat de DTO

Constraints MSSQL: UNIQUE(uuid_tag) y UNIQUE(sanitario_id) WHERE estado='ASIGNADO' (índice filtrado que enforza "máx. 1 tag asignado por sanitario").

TagNfcEvento (auditoría dedicada, inmutable)

Registra cada transición de la máquina de estados.

Columna Descripción
tag_nfc_id FK NN · uuid_tag snapshot Tag afectado
accion ALTA_STOCK | ASIGNACION | DESASIGNACION | REASIGNACION¹ | BAJA | RE_ALTA
sanitario_anterior_id / sanitario_nuevo_id FK NULL — origen/destino de la asignación
operario_id FK NULL (null solo en backfill) — quién lo hizo
dispositivo_id FK NULL — si fue desde móvil
fecha · notas Cuándo y observaciones

Diferencia backend vs. mobile

¹ El backend define la acción REASIGNACION (1 evento con anterior + nuevo). El MODEL del mobile lista solo ALTA_STOCK | ASIGNACION | DESASIGNACION | BAJA | RE_ALTA (sin REASIGNACION). Tomá el backend como fuente de verdad.

Usuarios, dispositivos y roles

Operario

El Operario es la identidad del personal en la app móvil. Su rol decide qué tipo de trabajo crea.

Columna Tipo Descripción
usuario NVARCHAR(50) UK Login móvil
nombre / apellido NVARCHAR(100) NN Nombre del operario
rol NVARCHAR(30) NN ADMIN | SUPERVISOR | OPERADOR_LIMPIEZA (nuevo v2)
pin_hash / password_hash NVARCHAR(128) NN BCrypt cost 12 · @JsonIgnore · NUNCA viajan al móvil
telefono / email / ultimo_login Datos de contacto y último acceso
user_id FK NULL → jhi_user Link opcional; jhi_user sigue siendo SOLO BackOffice web
activo BIT NN Si está habilitado

Semántica de roles en la app:

  • OPERADOR_LIMPIEZA crea trabajos LIMPIEZA.
  • SUPERVISOR crea trabajos SUPERVISION.
  • ADMIN no crea trabajos por tap — su app es la de Gestión NFC.

Dispositivo

El Dispositivo es el equipo físico: un celular con la app del operario o una terminal Smiley (kiosk). Una parte de sus columnas son de telemetría y provisioning.

Columna Tipo Descripción
device_uuid UK Identificador del equipo
nombre / alias Nombre amigable
api_key_hash Credencial del equipo
tipo APP_OPERARIO | TERMINAL_SMILEY (default APP_OPERARIO)
sanitario_id FK NULL Solo terminales — sanitario al que está pegada la terminal
sonido_activo / linea_diagnostico Config de terminal Smiley
codigo_vinculacion / codigo_expira_en / codigo_usado_en Provisioning de terminal (codigo_vinculacion con @JsonIgnore)
numero_terminal INT UNIQUE Correlativo global asignado al alta
ultimo_sync / ultima_ip / ultimo_contacto_ts Telemetría de conexión
app_version / os_version / bateria_pct / red_tipo Telemetría opcional recibida en el sync
drift_seg INT NULL Último drift de reloj (seg) medido en el sync cuando el device manda device_now
lock_task_activo BIT NULL Confinamiento Lock Task del kiosk: true confinado, false no, null no reportado
activo BIT NN Si está habilitado

Constraint: UNIQUE(sanitario_id) WHERE tipo='TERMINAL_SMILEY' AND activo=1 — máx. 1 terminal activa por sanitario (solo prod; H2 no lo enforza).

RefreshToken acompaña al login: token_hash, operario_id, device_uuid, issued_at, expires_at, revoked_at, con rotation.

Trabajo: la entidad central

El Trabajo es el corazón del sistema: un registro por turno de limpieza o supervisión (mismo motor, distinto tipo). Se crea por tap NFC (el tap funciona como toggle: el primero abre INGRESO, el segundo cierra EGRESO).

Columna Tipo Descripción
client_uuid NVARCHAR(64) UK Idempotencia; generado en el device ANTES de cualquier I/O
tipo NVARCHAR(20) NN LIMPIEZA | SUPERVISION (lo define el rol del operario)
uuid_tag_leido NVARCHAR(64) NN Snapshot crudo del tap
operario_id FK NN Quién lo hizo
dispositivo_id FK NN Desde qué equipo
sanitario_id / tag_id FK NULL NULL si el tag es desconocido (pendiente de resolución)
inicio_ts NN Timestamp del teléfono al abrir (= la verdad)
fin_ts / duracion_seg NULL Cierre y duración (NULL mientras abierto)
estado INGRESO | EGRESO | EGRESO_CIERRE_AUTOMATICO
modo_registro ONLINE | OFFLINE_SYNC | OFFLINE_PENDIENTE_RESOLUCION | MANUAL²
ts_local_device / server_received_ts NN Reloj del device vs. reloj del server (drift > 5 min → warning)
warnings NVARCHAR(500) Acumulativo
duracion_anomala BIT NN default 0 true si el EGRESO tuvo duración < app.tap.duracion-minima-seg (piso de validez)
cuenta_para_sla BIT NN default 1 Si la fila cuenta para el SLA. NFC siempre true; el colgado cerrado por scheduler lo pone en false
motivo_contingencia_id / motivo_contingencia_codigo FK NULL + snapshot Solo en registro MANUAL (sin placa)

El registro MANUAL es parte de V21_ROBUSTEZ

² El valor MANUAL de modo_registro (registro sin placa) y los campos motivo_contingencia_* aparecen solo en el MODEL del backend (sección Robustez operativa). El catálogo motivo_contingencia siembra PLACA_ROTA, PLACA_AUSENTE, NO_LEE, OTRO. El registro manual cuenta como limpieza válida según app.contingencia.cuenta-como-valida (default true).

TrabajoEvento (puente con snapshot, inmutable)

Cuando se cierra un trabajo, el operario reporta uno o más eventos. La relación N:N entre Trabajo y EventoLimpieza se materializa acá, con snapshot del catálogo para preservar el histórico:

trabajo_id FK · evento_limpieza_id FK · codigo_snapshot · nombre_snapshot · observacion NULL · created

Eventos de limpieza

EventoLimpieza es un catálogo configurable (no un enum) de qué puede reportar el operario al cerrar.

Columna Descripción
codigo UK · nombre · orden · activo Identidad y orden del evento
roles_permitidos NVARCHAR(100) NN — CSV de roles que pueden usarlo, ej. "OPERADOR_LIMPIEZA,SUPERVISOR"³
es_exclusivo BIT — si se selecciona, debe ser el único (ej. SIN_NOVEDAD)
genera_alerta BIT — al cerrar el trabajo crea Alerta(EVENTO_REPORTADO)
invalida_limpieza BIT — la LIMPIEZA que lo incluye NO resetea el SLA
visible_cliente BIT NN default 0 — si el evento es visible en el portal del cliente

Seed:

codigo roles exclusivo alerta invalida
SIN_NOVEDAD ambos
ROTURAS ambos
REPONER_INSUMOS ambos
NO_SE_PUDO_LIMPIAR solo OPERADOR_LIMPIEZA
NO_ESTA_LIMPIO solo SUPERVISOR

Reglas: mínimo 1 evento en un EGRESO normal · EGRESO_CIERRE_AUTOMATICO va SIN eventos · la validación de rol y exclusividad es server-side (no se confía en la app).

Diferencia backend vs. mobile en roles_permitidos

³ El backend define roles_permitidos como CSV concreto (ADR-023) y agrega visible_cliente. El MODEL del mobile describe roles_permitidos de forma genérica ("qué roles lo ven/usan, representación elegida en implementación") y no menciona visible_cliente.

SLA y alertas

SlaLimpieza

Define la exigencia de limpieza por sanitario (relación 1:1, sanitario_id es UK).

sanitario_id FK UK · frecuencia_min · ventana_desde/hasta · duracion_minima_seg · activo

V21_ROBUSTEZ agrega dias_semana CHAR(7) NN default 'LMXJVSD' (patrón Lun..Dom): un día sin su letra queda en estado SIN_EXIGENCIA (sin alertas SLA). Default = todos los días (comportamiento v1 intacto).

Qué es una limpieza válida

Solo una limpieza válida resetea el reloj del SLA. La definición v2 (backend) es: Trabajo tipo=LIMPIEZA cerrado (EGRESO o EGRESO_CIERRE_AUTOMATICO), sin eventos con invalida_limpieza=true y con duracion_anomala=false. Las SUPERVISION nunca resetean. El cierre automático SÍ resetea (lleva warning).

El MODEL del mobile describe la misma definición sin la condición duracion_anomala=false (esa robustez del ANEXO DT vive solo del lado backend).

Alerta (inmutable)

Columna Descripción
sanitario_id FK NULL (v2: habilita TAG_DESCONOCIDO sin sanitario)
dispositivo_id FK NULL — alertas de equipo (ej. RELOJ_DESVIADO), no de sanitario
tipo SLA_VENCIDO | DURACION_ANOMALA | OPERARIO_INACTIVO_OFFLINE | TAG_DESCONOCIDO | EVENTO_REPORTADO | RELOJ_DESVIADO⁴ | DISPOSITIVO_SIN_REPORTAR
mensaje · generada_en · atendida_en · atendida_por_usuario Contenido y atención ('sistema' en auto-atención)

Tipos de alerta solo-backend

⁴ Los tipos RELOJ_DESVIADO (ANEXO DT), DISPOSITIVO_SIN_REPORTAR (terminal Smiley sin contacto > umbral, V21_SMILEY/G3) y los de robustez TAG_DEFECTUOSO / TENDENCIA_NEGATIVA aparecen solo en el MODEL del backend. El MODEL del mobile lista hasta EVENTO_REPORTADO.

Smiley y opiniones del público

Las terminales Smiley (kiosk) y los QR públicos capturan la opinión del usuario del baño. Esta capa vive solo en el MODEL del backend (V21_SMILEY / V22_QR_PUBLICO); el MODEL del mobile no la documenta.

Opinion (transaccional, inmutable)

Columna Tipo Descripción
client_uuid UK Idempotencia de la opinión
sanitario_id NN Sanitario opinado
dispositivo_id FK NULL Nullable: se setea para opiniones de terminal, queda NULL para QR público
origen NN default TERMINAL_SMILEY TERMINAL_SMILEY | QR_PUBLICO
valor POSITIVA | NEUTRA | NEGATIVA
motivo_id FK NULL + motivo_codigo_snapshot Motivo de la opinión
ts_local_device / server_received_ts Reloj del device / del server
descartada / motivo_descarte Colapso anti-abuso: las ráfagas se marcan, no se borran

Regla de oro de las opiniones QR (ADR-041)

Las opiniones con origen=QR_PUBLICO son de segunda clase: NUNCA alimentan el motor de tendencias ni las stats de confianza. El motor filtra TERMINAL_SMILEY.

Entidades de soporte del módulo:

  • MotivoOpinion — catálogo (codigo UK, nombre, orden, activo, soft delete, auditoría). Seed: SUCIO, SIN_INSUMOS, MAL_OLOR, ALGO_ROTO, OTRO.
  • ReglaTendencia — define cuándo una racha de negativas dispara TipoAlerta.TENDENCIA_NEGATIVA. sanitario_id FK NULL (null = regla global; la específica pisa la global), ventana_min, modo (CONTEO | PROPORCION), umbral_negativas, umbral_pct + min_opiniones. Seed: 1 regla global CONTEO 3/30.

Anti doble-tap y robustez (solo backend)

El ANEXO DT agrega la tabla tap_descartado (inmutable): un INGRESO que rebota a < rebote-ingreso-seg de un EGRESO del mismo operario+sanitario+device se registra acá con motivo REBOTE_POST_EGRESO y no crea Trabajo (el sync responde RECHAZADO_REBOTE, idempotente). Campos: client_uuid UK, operario_id/dispositivo_id FK NN, sanitario_id FK NULL, motivo NVARCHAR(40) NN, ts_local_device/server_received_ts.

Otras capas que viven solo del lado backend: calendario_excepcion (feriados por sucursal), motivo_contingencia (registro manual), alerta_notificacion (suscripciones a mail), portal del cliente (cliente_empresa_acceso, authority ROLE_CLIENTE), rutas (ruta, ruta_parada, ruta_asignacion, ruta_ejecucion, ruta_ejecucion_parada) y planos (plano, plano_pin). No se detallan acá; consultá el MODEL del backend.

Espejo móvil (Room)

La app Android no habla con MSSQL: mantiene un espejo local en Room poblado por sync, para funcionar offline-first. El espejo es un subconjunto del modelo backend.

Qué espeja y qué es solo-local

Espejos por sync: sucursal_local, sector_local, tipo_sanitario_local, sanitario_local (con sector_id), operario_local (con rol, sin hashes), tag_local (con estado/alias, sanitario_id nullable), evento_limpieza_local, trabajo_sincronizado (con tipo, eventos_csv), ruta_ejecucion_local + ruta_parada_local (snapshot de "mi ruta de hoy", reemplazable cada sync).

Solo-local (no viajan al server como tales): sync_state (watermarks), trabajo_pendiente (con tipo y manual/motivo_contingencia_codigo) + trabajo_pendiente_evento, nfc_evento_pendiente (cola de gestión NFC del admin).

Diferencias clave a tener en cuenta:

  • No hay ubicacion_local (igual que en backend: descompuesto en sucursal/sector).
  • El soft delete no se persiste local: se traduce a activo=false.
  • Las credenciales NUNCA bajan: operario_local no tiene pin_hash ni password_hash.
  • Las migraciones de Room están versionadas y nunca usan fallbackToDestructiveMigration — la cola offline (trabajo_pendiente) debe sobrevivir a cada upgrade de schema.

Schema Room: v5 (validado contra el código)

El MODEL del backend menciona, en su resumen del espejo, Room schema 2 sin ruta_ejecucion_local / ruta_parada_local — está desactualizado. La verdad está en el código: WorkDoneDatabase.kt:50 declara @Database(..., version = 5) con migraciones 1→2→3→4→5; las tablas de ruta se crearon en la migración 3→4. Usá v5 como referencia del espejo local.