Ciclo de vida de los tags NFC¶
Si recién entrás al proyecto, esta es la página que explica cómo nace, se asigna, se reasigna y se da de baja una placa NFC en WorkDone Sanitarios, y qué transiciones de estado son válidas (y cuáles no). Una placa (tag_nfc) es el chip físico que el personal acerca con el teléfono para registrar un trabajo; antes de poder usarse, tiene que recorrer un ciclo de vida controlado.
La fuente de verdad de este documento es el backend (Spring Boot / JHipster), concretamente TagNfcLifecycleService: el servicio que concentra TODAS las transiciones de estado del tag. Ningún otro componente cambia el estado de un tag por su cuenta.
Punto de partida
Cada operación recibe un uuid crudo (leído del chip o recibido por API), un contexto de auditoría (AccionNfcContexto: operario, dispositivo, notas — todos nullable) y devuelve el TagNfc resultante. Las transiciones inválidas lanzan TransicionInvalidaException; los conflictos de asignación, ConflictoAsignacionException. Cada transición exitosa persiste un registro inmutable en tag_nfc_evento.
Para el contexto del modelo de tablas, ver Modelo de datos. Para cómo estas operaciones viajan offline desde la app, ver Sincronización.
Máquina de estados¶
Un tag tiene tres estados (EstadoTag): EN_STOCK, ASIGNADO y BAJA. El alta lo crea directamente en EN_STOCK. Las transiciones entre estados están todas mediadas por una acción concreta del TagNfcLifecycleService.
stateDiagram-v2
[*] --> EN_STOCK: ALTA_STOCK
EN_STOCK --> ASIGNADO: ASIGNACION
ASIGNADO --> EN_STOCK: DESASIGNACION
ASIGNADO --> ASIGNADO: REASIGNACION (otro sanitario)
EN_STOCK --> BAJA: BAJA
BAJA --> EN_STOCK: RE_ALTA (solo web)
note right of ASIGNADO
REASIGNACION es un solo
paso atomico: ASIGNADO -> ASIGNADO'
(decision 2026-06-10)
end note
Lectura del diagrama
ALTA_STOCKes idempotente: si el tag ya existe, devuelve el existente sin generar evento.REASIGNACIONes un bucle sobreASIGNADO: el tag no pasa porEN_STOCK; cambia de sanitario en una sola operación atómica.BAJAsale solo desdeEN_STOCK. Un tagASIGNADOdebe desasignarse antes de darse de baja.RE_ALTA(BAJA → EN_STOCK) está disponible solo desde web (BackOffice), nunca por/sync.
Acciones¶
Cada acción valida el estado de origen esperado, aplica el cambio y persiste un TagNfcEvento con la AccionTagNfc correspondiente. La siguiente tabla resume qué hace cada una.
| Acción | De → A | Quién puede ejecutarla | Evento que genera |
|---|---|---|---|
ALTA_STOCK |
[*] → EN_STOCK |
Web (BackOffice) y mobile vía /sync |
ALTA_STOCK — salvo que el tag ya exista (idempotente, no genera evento) |
ASIGNACION |
EN_STOCK → ASIGNADO |
Web y mobile vía /sync |
ASIGNACION (con sanitarioNuevo) |
DESASIGNACION |
ASIGNADO → EN_STOCK |
Web y mobile vía /sync |
DESASIGNACION (con sanitarioAnterior) |
REASIGNACION |
ASIGNADO → ASIGNADO' |
Web y mobile vía /sync |
REASIGNACION (un solo evento con sanitarioAnterior + sanitarioNuevo) |
BAJA |
EN_STOCK → BAJA |
Web y mobile vía /sync |
BAJA |
RE_ALTA |
BAJA → EN_STOCK |
Solo web (BackOffice) | RE_ALTA |
Mobile no envía todas las acciones
El procesador de la cola NFC del sync (SyncService.procesarUnNfcEvento) sólo despacha ALTA_STOCK, ASIGNACION, DESASIGNACION y REASIGNACION. RE_ALTA y BAJA no están en ese switch: RE_ALTA es exclusiva de web por diseño. Cualquier otra acción recibida por sync cae en el default y se marca como ERROR.
ALTA_STOCK — alta idempotente¶
Crea el tag en EN_STOCK con activo = false. Es idempotente por uuid: si el uuid normalizado ya existe en tag_nfc, devuelve el tag existente sin crear evento ni modificar nada. Esto es clave para la idempotencia best-effort del sync (ver más abajo).
ASIGNACION — vincular a un sanitario¶
Requiere que el tag esté EN_STOCK y que el sanitario destino no tenga ya un tag ASIGNADO. Pasa el tag a ASIGNADO, le setea el sanitario, asignadoEn y activo = true.
Conflicto: el primero en sincronizar gana
Si el tag no está EN_STOCK, o si el sanitario ya tiene un tag ASIGNADO, se lanza ConflictoAsignacionException. En el sync esto se traduce a CONFLICTO_ASIGNACION. Cuando dos dispositivos asignan offline al mismo sanitario, el primero en el orden de sincronización gana y el segundo recibe el conflicto. El orden lo define ts_local_device ASC.
DESASIGNACION — devolver a stock¶
Sólo válida desde ASIGNADO. Limpia el sanitario, asignadoEn, pone activo = false y devuelve el tag a EN_STOCK. Guarda el sanitarioAnterior en el evento.
REASIGNACION — mover entre sanitarios (atómica)¶
Mueve un tag de un sanitario a otro en un solo paso atómico, sin pasar por EN_STOCK. El tag permanece ASIGNADO; sólo cambian sanitario y asignadoEn.
Decisión 2026-06-10: un solo evento
REASIGNACION no es "desasignar + asignar". Genera un único TagNfcEvento con sanitarioAnterior y sanitarioNuevo poblados. Esto preserva la trazabilidad del movimiento como una sola operación en la auditoría.
Caso borde: si el sanitario nuevo es el mismo que el anterior, no se valida el conflicto (no tendría sentido chequear que el sanitario "ya tiene un tag" cuando ese tag es justamente el que estamos moviendo). En cualquier otro caso, el destino no puede tener un tag ASIGNADO, o se lanza ConflictoAsignacionException.
BAJA — desactivar definitivamente¶
Sólo válida desde EN_STOCK. Pone el tag en BAJA, setea dadoBajaEn y activo = false. Un tag ASIGNADO no puede darse de baja directamente: hay que desasignarlo primero.
RE_ALTA — reactivar desde baja (solo web)¶
Devuelve un tag desde BAJA a EN_STOCK (limpia dadoBajaEn, activo = false). Disponible únicamente desde web (BackOffice); no se procesa por /sync.
Transiciones inválidas¶
Cada método valida el estado de origen esperado antes de aplicar el cambio. Si el tag está en un estado distinto del esperado, se lanza TransicionInvalidaException, que en el sync se reporta como TRANSICION_INVALIDA. Además, si el uuid no existe en tag_nfc, resolverTag lanza la misma excepción (tag no registrado).
Estas son las combinaciones que rechazan la operación:
| Acción intentada | Estado del tag | Resultado |
|---|---|---|
ASIGNACION |
ASIGNADO o BAJA |
ConflictoAsignacionException (debe estar EN_STOCK) |
DESASIGNACION |
EN_STOCK o BAJA |
TransicionInvalidaException (solo desde ASIGNADO) |
REASIGNACION |
EN_STOCK o BAJA |
TransicionInvalidaException (solo desde ASIGNADO) |
BAJA |
ASIGNADO |
TransicionInvalidaException (desasignar primero) |
BAJA |
BAJA |
TransicionInvalidaException (ya está en BAJA) |
RE_ALTA |
EN_STOCK o ASIGNADO |
TransicionInvalidaException (solo desde BAJA) |
| Cualquiera | uuid inexistente | TransicionInvalidaException (tag no registrado) |
Conflicto vs. transición inválida
ASIGNACION sobre un tag que no está EN_STOCK lanza ConflictoAsignacionException (no TransicionInvalidaException), porque el caso típico es una carrera de asignación, no un estado imposible. La distinción importa: en el sync producen estados distintos (CONFLICTO_ASIGNACION vs TRANSICION_INVALIDA).
Normalización de UUID¶
Todo punto de entrada de uuid pasa por UuidTagNormalizer.normalize() antes de cualquier búsqueda o persistencia. La forma canónica es: null-safe, sin espacios (internos y de borde) y en mayúsculas (raw.replaceAll("\\s+", "").toUpperCase()).
Cada método del TagNfcLifecycleService normaliza el uuid en su primera línea. TrabajoTapService también normaliza para resolver el tag al procesar un tap (el uuid_tag_leido del Trabajo se persiste crudo, como snapshot del tap).
El backend es defensivo a propósito
Aunque la app móvil ya normalice el uuid antes de enviarlo, el backend vuelve a normalizar en cada entrada. No se confía en que el cliente haya hecho el trabajo: dos lecturas del mismo chip con distinto formato (con/sin guiones, mayúsculas/minúsculas) deben resolver al mismo registro tag_nfc. Esto sostiene la whitelist por uuid normalizado del ADR de sync.
Auditoría¶
Cada transición exitosa persiste un TagNfcEvento (tabla inmutable tag_nfc_evento). El evento captura: el tag, el uuidTag (ya normalizado), la AccionTagNfc, sanitarioAnterior, sanitarioNuevo, operario, dispositivo, fecha y notas.
Operario y dispositivo son nullable
Los campos operario y dispositivo del contexto de auditoría son nullable. AccionNfcContexto.vacio() los deja en null para operaciones web o de sistema que no tienen un actor humano / dispositivo asociado (por ejemplo, backfill o tareas programáticas). No asumas que todo evento NFC tiene operario.
| Campo | Poblado en |
|---|---|
sanitarioAnterior |
DESASIGNACION, REASIGNACION |
sanitarioNuevo |
ASIGNACION, REASIGNACION |
operario / dispositivo |
Operaciones con actor humano (nullable en web/sistema) |
Estados de procesamiento NFC en el sync¶
Cuando los eventos NFC viajan en la cola del sync, cada ítem se procesa de forma aislada (un ítem que falla no aborta el sync completo) y se devuelve con un EstadoNfcEventoProcesado. La autorización es estricta: solo el operario con rol ADMIN puede enviar ítems NFC; si no lo es y la cola no está vacía, todos los ítems se rechazan con ROL_INSUFICIENTE sin abortar.
| Estado | Significado |
|---|---|
OK |
Transición ejecutada con éxito. |
CONFLICTO_ASIGNACION |
El sanitario ya tiene un tag ASIGNADO, o el tag no está EN_STOCK (ConflictoAsignacionException). |
DUPLICADO_IGNORADO |
client_uuid ya visto (best-effort, según el estado del tag). |
TRANSICION_INVALIDA |
La máquina de estados rechaza la operación (TransicionInvalidaException). |
ROL_INSUFICIENTE |
El operario del sync no es ADMIN — ítem ignorado sin abortar. |
ERROR |
Excepción inesperada o acción desconocida — no aborta el sync completo. |
Más sobre el protocolo de sincronización, orden de procesamiento y casos offline en Sincronización.
Limitación real: idempotencia best-effort (riesgo a evaluar)
La idempotencia de los eventos NFC en el sync es best-effort, no estricta. No existe una tabla nfc_evento_procesado que persista los client_uuid ya vistos. La idempotencia depende enteramente del comportamiento idempotente de cada acción — principalmente que ALTA_STOCK devuelve el tag existente sin tocar nada.
Consecuencia: el sistema no puede distinguir un reintento genuino de una operación nueva con el mismo uuid. Para idempotencia estricta haría falta persistir el client_uuid en una tabla auxiliar. El código lo marca explícitamente:
Tratá esto como un riesgo a evaluar antes de cualquier escenario con reintentos agresivos o redelivery de la cola NFC.