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) ydeleted_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 aparteserver_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
codigoes único dentro de la sucursal (UK compuestasucursal_id, codigo). - Sanitario — el baño concreto donde va la placa NFC. Su
codigoes ú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_LIMPIEZAcrea trabajosLIMPIEZA.SUPERVISORcrea trabajosSUPERVISION.ADMINno 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).
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 (
codigoUK, 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_idFK 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_localno tienepin_hashnipassword_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.