Saltar a contenido

Modelo de datos

El Sistema de Premios corre sobre SQL Server, versionado con Flyway (V1 a V12 al momento de escribir esto). Esta página documenta el esquema real — columnas, tipos SQL Server e invariantes — tal como quedó tras aplicar todas las migraciones. Para el porqué de cada decisión de diseño (por qué una sola campaña vigente, por qué el acta es inmutable, por qué Cliente es un solo tipo) anda a Decisiones de arquitectura; acá vas a encontrar la forma, no el motivo.

Fuente de la verdad

Todo lo que sigue está tomado directo de las migraciones en backend/src/main/resources/db/migration/ (V1 a V12). Si alguna vez este documento y el código no coinciden, gana el código — avisá para corregir la página.

Diagrama de entidades

Diagrama

El diagrama es un resumen

Muestra las columnas y relaciones más relevantes para entender el modelo. clave_firma, voucher_rechazado, consentimiento_privacidad, aceptacion_bases, evento_premio, contacto_ganador, voucher_prueba, reconocimiento_dispositivo y evento_dato_cliente se documentan en detalle más abajo, no todas en el ER.

Convenciones del esquema

  • Nombres de tabla y columna: snake_case, en español (mismo vocabulario que CONTEXT.md del proyecto).
  • PKs: BIGINT IDENTITY(1,1) en casi todas las tablas — la excepción es clave_firma, cuya PK es el propio key_id TINYINT (no hace falta un id sintético para una clave).
  • Timestamps: DATETIME2, casi siempre NOT NULL DEFAULT SYSUTCDATETIME() para las marcas de auditoría (creado_en, fecha_registro, fecha_hora).
  • Enums como VARCHAR + CHECK: el proyecto no usa tipos enumerados nativos de SQL Server; cada estado es un VARCHAR acotado por un CONSTRAINT ck_..._... CHECK (columna IN (...)). Se listan los valores válidos en cada tabla más abajo.
  • Bloqueo optimista (version): premio_unidad, asignacion_premio, resultado_sorteo y cliente tienen una columna version BIGINT NOT NULL DEFAULT 0 (agregada en V9 y V12). Hibernate la incrementa en cada UPDATE; si otra transacción ya avanzó la fila, la operación falla con OptimisticLockException, traducido a 409 por el manejador de errores del gestor. Motivo: son las cuatro entidades que dos operadores del gestor pueden llegar a mutar en simultáneo (entrega, pase a suplente, devolución al stock, edición de cliente).
  • Hash, nunca el dato sensible en claro: chance.hash_token, voucher_prueba.hash_token y reconocimiento_dispositivo.hash_reconocimiento persisten un CHAR(64) (SHA-256 hex) — nunca el token del QR ni el secreto de reconocimiento del dispositivo. Ver El token del QR para el detalle de cómo se calcula ese hash.

campania

La promoción completa. Solo puede existir una fila con estado = 'vigente' — invariante garantizada a nivel de base de datos, no solo en la capa de aplicación.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
nombre VARCHAR(200) NOT NULL Badge visible en la landing
descripcion VARCHAR(MAX) NOT NULL Texto de campaña
promo_id BIGINT NOT NULL Id de promoción del backoffice (M1) — política de reuso diferida (ADR-014)
estado VARCHAR(20) NOT NULL borrador | vigente | cerrada
vigencia_desde / vigencia_hasta DATETIME2 NOT NULL Ventana de vigencia por fecha de emisión del voucher
regla_chances INT NOT NULL DEFAULT 1 1 voucher = 1 chance
tope_chances_dni_dia INT NOT NULL DEFAULT 5 Tope diario por DNI
bases_url VARCHAR(500) NOT NULL Ruta pública del PDF de bases vigente
version_bases VARCHAR(50) NOT NULL Versión inmutable de las bases aceptadas
plazo_retiro_dias INT NOT NULL DEFAULT 10 Días desde el primer contacto para retirar el premio
key_id_vigente TINYINT NOT NULL FK a clave_firma.key_id

Invariante: única campaña vigente

CREATE UNIQUE INDEX ux_campania_vigente ON campania (estado) WHERE estado = 'vigente';
Es un índice único filtrado: no restringe cuántas filas borrador o cerrada puede haber, solo impide que dos campañas estén vigente al mismo tiempo. Un INSERT/UPDATE que viole esto falla en el flush inmediato (saveAndFlush), no en un commit posterior.

También hay un CHECK (vigencia_desde < vigencia_hasta) (ck_campania_vigencia, V9) como segunda línea de defensa por si algún camino de escritura se saltea la validación del DTO.

sorteo

Cada evento de sorteo dentro de una campaña: los semanales más el de cierre.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
campania_id BIGINT NOT NULL FK → campania
nombre VARCHAR(100) NOT NULL "Semana 1".."Semana 5", "Cierre"
tipo VARCHAR(20) NOT NULL semanal | cierre
fecha_hora_publicada DATETIME2 NOT NULL Referencia en bases — no dispara la ejecución
orden INT NOT NULL Orden dentro de la campaña
estado VARCHAR(20) NOT NULL pendiente | ejecutado
suplentes_a_sortear INT NOT NULL DEFAULT 10 Suplentes de esta acta

Invariante

La ejecución es siempre manual desde el gestor (ADR-009). ejecutado implica que existe un acta para ese sorteo (uq_acta_sorteo); nunca hay un sorteo ejecutado sin acta.

premio_tipo

Catálogo de premios por tipo y cantidad ("Bicicleta playera × 10").

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
campania_id BIGINT NOT NULL FK → campania
descripcion VARCHAR(200) NOT NULL Descripción comercial
valor_unitario DECIMAL(12,2) NOT NULL Valor de referencia
cantidad INT NOT NULL Cuántas unidades genera

premio_unidad

Cada unidad física individual generada a partir de un premio_tipo.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
premio_tipo_id BIGINT NOT NULL FK → premio_tipo
estado VARCHAR(20) NOT NULL DEFAULT 'Disponible' DisponibleAsignadoSorteadoOtorgado | NO_OTORGADO
version BIGINT NOT NULL DEFAULT 0 Bloqueo optimista (V9)

Invariante: stock_disponible no es un contador paralelo

stock_disponible siempre se calcula como COUNT(*) WHERE estado = 'Disponible' sobre esta tabla en el momento de la consulta — nunca hay una columna separada que lo cachee y pueda desincronizarse.

asignacion_premio

Vincula una premio_unidad a un sorteo, con su orden de prelación.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
sorteo_id BIGINT NOT NULL FK → sorteo
premio_unidad_id BIGINT NOT NULL UNIQUE FK → premio_unidad — una unidad, a lo sumo, una asignación viva
prelacion INT NOT NULL 1º, 2º… orden de asignación de premios dentro del sorteo
version BIGINT NOT NULL DEFAULT 0 Bloqueo optimista (V9)

Invariante: prelación única por sorteo

CREATE UNIQUE INDEX ux_asignacion_sorteo_prelacion ON asignacion_premio (sorteo_id, prelacion) (V2) — segunda línea de defensa a nivel DB; el servicio ya valida antes de insertar.

cliente

El padrón. Una persona identificada por DNI — un único tipo de entidad tanto para quien participa del sorteo como para altas post-campaña (D5 del dominio: sin un tipo lead aparte).

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
dni VARCHAR(20) NOT NULL UNIQUE Alta idempotente por DNI
nombre / apellido VARCHAR(150) NOT NULL
celular VARCHAR(10) NOT NULL Normalizado, solo dígitos
fecha_nacimiento DATE NULL CHECK (fecha_nacimiento IS NULL OR <= hoy) (V8)
sexo VARCHAR(1) NULL M | F | X
email VARCHAR(255) NULL
creado_en DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
origen VARCHAR(30) NOT NULL participacion | post_campania
version BIGINT NOT NULL DEFAULT 0 Bloqueo optimista (V12) — edición concurrente landing/gestor
datos_origen VARCHAR(10) NOT NULL DEFAULT 'manual' escaneo | manual (V12) — gobierna si la identidad es editable

datos_origen: por qué existe (V12 / ADR-028)

Si el cliente cargó sus datos escaneando el código de barras del DNI físico (datos_origen = 'escaneo'), la landing trata nombre/apellido/sexo/fecha de nacimiento como solo lectura — no se pueden "corregir" datos que vinieron de una fuente confiable sin pasar por soporte. Si vinieron de carga manual, sí son editables presentando el DNI. El celular y el email nunca cuentan como "cambio de identidad": siempre son editables.

chance

Una participación registrada: un voucher consumido + un DNI. Es la entidad de mayor volumen del sistema — tiene tres índices dedicados a los caminos calientes del motor de sorteo y el panel.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
campania_id BIGINT NOT NULL FK → campania
cliente_id BIGINT NOT NULL FK → cliente
hash_token CHAR(64) NOT NULL UNIQUE SHA-256 hex de la codificación canónica del token
fecha_emision DATETIME2 NOT NULL Del token (offsets 8/10) — la fecha que gobierna toda decisión temporal
nro_sucursal INT NOT NULL Del token (offset 5)
nro_pos TINYINT NOT NULL Del token (offset 7)
nro_ticket BIGINT NOT NULL Del token (offset 12)
key_id TINYINT NOT NULL Del token (offset 0)
fecha_registro DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() Cuándo se registró en el server (distinta de fecha_emision)

Invariante: consumo atómico, nunca el token en claro

uq_chance_hash_token es la única defensa contra duplicar una chance ante un doble envío simultáneo del mismo voucher: el INSERT que pierde la carrera falla por violación de UNIQUE, y ese camino resuelve a Caso B (ya usado), nunca a una segunda chance. hash_token es lo único que se persiste — el token en sí nunca toca la base de datos. Ver El token del QR.

Índices de camino caliente: (cliente_id, campania_id, fecha_emision) y (campania_id, fecha_emision) para la ventana de elegibles del motor de sorteo y el tope diario (V2); (campania_id, fecha_registro) para las señales antifraude del panel (V7).

voucher_rechazado

Registro interno de todo voucher rechazado — nunca se le muestra al cliente el motivo real (mensaje siempre genérico), pero queda acá para antifraude y soporte.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
hash_token CHAR(64) NULL Nulo si la firma era inválida (no hay payload confiable que hashear)
motivo VARCHAR(30) NOT NULL firma_invalida | ya_usado | fuera_vigencia | tope_diario | bot (V4)
fecha DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
ip VARCHAR(45) NOT NULL
nro_sucursal / nro_pos INT / TINYINT NULL Solo si el token era decodificable

El motivo bot (V4) es el honeypot: un campo invisible del formulario (contacto_web) que solo un bot llena — si llega no vacío, se registra acá y la respuesta es idéntica a la de Caso A.

acta

Snapshot inmutable y reproducible de la ejecución de un sorteo.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
sorteo_id BIGINT NOT NULL UNIQUE FK → sorteo — un sorteo, a lo sumo, un acta
fecha_ejecucion DATETIME2 NOT NULL
seed BIGINT NOT NULL Semilla del sorteo
algo_version VARCHAR(50) NOT NULL Versión del algoritmo de sorteo
filtros VARCHAR(MAX) NOT NULL Ventana + exclusiones, serializado
lista_chance_ids VARCHAR(MAX) NOT NULL Lista ordenada, snapshot completo de elegibles
hash_lista CHAR(64) NOT NULL Hash de la lista, para verificar integridad
cantidad_participantes INT NOT NULL

Invariante: inmutable y reproducible (D4)

lista_chance_ids + seed + algo_version → siempre el mismo resultado. No existe una operación de "re-ejecutar" un acta; una ejecución errónea se corrige por decisión humana documentada fuera del sistema (nueva asignación de premios + nuevo sorteo). Ver El motor de sorteo y el acta.

resultado_sorteo

Cada posición del acta: ganador o suplente, en orden.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
acta_id BIGINT NOT NULL FK → acta
orden INT NOT NULL 1..N+suplentes
tipo VARCHAR(20) NOT NULL ganador | suplente
chance_id / cliente_id BIGINT NOT NULL FK → chance / cliente
premio_unidad_id BIGINT NULL Mutable: cambia con pase a suplente / devolución al stock
premio_unidad_id_original BIGINT NULL Inmutable (V10): el premio efectivamente sorteado, nunca tocado por operaciones de entrega
estado_entrega VARCHAR(30) NOT NULL DEFAULT 'pend_contacto' pend_contacto | contactado | entregado | pasado_a_suplente | devuelto (V3)
dni_verificado_en_entrega VARCHAR(20) NULL DNI presentado al momento de entregar
fecha_entrega / sucursal_entrega / responsable_entrega Datos de la entrega física
version BIGINT NOT NULL DEFAULT 0 Bloqueo optimista (V9)

Por qué dos columnas de premio_unidad_id (V10 / ADR-025)

El acta tiene que seguir siendo reproducible (seed + lista + algo → mismos ganadores), pero premio_unidad_id muta con registrarEntrega / pasarASuplente / devolverAlStock. premio_unidad_id_original se fija una única vez al ejecutar el sorteo y nunca se vuelve a tocar — es lo que separa, en el export del acta, la sección "inmutable" (lo sorteado) de la sección "estado de entrega" (snapshot, lo entregado a la fecha del export).

contacto_ganador

Cada intento de contacto con un ganador o suplente — prueba frente al plazo de retiro.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
resultado_id BIGINT NOT NULL FK → resultado_sorteo
fecha DATETIME2 NOT NULL
canal VARCHAR(20) NOT NULL llamada | whatsapp | otro
resultado_contacto VARCHAR(200) NOT NULL Texto libre

clave_firma

Claves HMAC de firma del token, por key_id — permiten rotar sin cortar la emisión.

Columna Tipo SQL Server Notas
key_id TINYINT PK (no hay id sintético)
secreto_cifrado VARBINARY(512) NOT NULL Cifrado at-rest, nunca en claro
estado VARCHAR(20) NOT NULL activa | rotada | revocada
vigencia_desde DATETIME2 NOT NULL
vigencia_hasta DATETIME2 NULL

consentimiento_privacidad

Consentimiento permanente del cliente a comunicaciones — una fila por cliente.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
cliente_id BIGINT NOT NULL UNIQUE FK → cliente
fecha_hora DATETIME2 NOT NULL
acepta_comunicaciones BIT NOT NULL

aceptacion_bases

Aceptación de las bases y condiciones, por campaña y versión.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
cliente_id BIGINT NOT NULL FK → cliente
campania_id BIGINT NOT NULL FK → campania
version_bases VARCHAR(50) NOT NULL Las versiones son inmutables (ADR-007): nunca se pisa un PDF ya aceptado
fecha_hora DATETIME2 NOT NULL

Invariante

UNIQUE (cliente_id, campania_id, version_bases) — el botón de confirmar participación implica esta aceptación (UX de un toque, sin checkbox aparte).

evento_premio

Auditoría de todo cambio de estado de una premio_unidad.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
premio_unidad_id BIGINT NOT NULL FK → premio_unidad
evento VARCHAR(30) NOT NULL asignado | desasignado | sorteado | otorgado | devuelto_a_stock | reasignado_suplente | no_otorgado
motivo VARCHAR(300) NULL Obligatorio en la práctica para toda reasignación/devolución
usuario VARCHAR(150) NOT NULL Operador del gestor que hizo el cambio
fecha_hora DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()

voucher_prueba

Auditoría de cada voucher generado por el modo prueba del gestor (nunca habilitable en producción — el bean del controller ni se registra sin el flag sp.modo-prueba.enabled).

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
campania_id BIGINT NOT NULL FK → campania
key_id TINYINT NOT NULL
nro_sucursal / nro_pos / nro_ticket Datos generados del voucher de prueba
fecha_emision DATETIME2 NOT NULL
hash_token CHAR(64) NOT NULL UNIQUE Mismo criterio que chance: nunca se persiste el token en claro
usuario VARCHAR(150) NOT NULL Operador que lo generó
creado_en DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()

reconocimiento_dispositivo

Agregada en V11 (ADR-027). Permite que la landing "reconozca" un dispositivo ya usado para saltar el tipeo del DNI en visitas siguientes, sin persistir el DNI en el navegador.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
cliente_id BIGINT NOT NULL FK → clienteN filas por cliente, una por dispositivo
hash_reconocimiento CHAR(64) NOT NULL UNIQUE Hash del secreto de reconocimiento; nunca el secreto en claro
creado_en DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
ultimo_uso_en DATETIME2 NULL

Vida permanente, cruza campañas

A diferencia de chance (scoped a una campaña), el reconocimiento de dispositivo no expira ni se acota a una campaña — decisión explícita del ADR-027.

evento_dato_cliente

Agregada en V12 (ADR-028). Auditoría de todo cambio de dato de un cliente — nunca expuesta por la API pública, mismo criterio que voucher_rechazado.

Columna Tipo SQL Server Notas
id BIGINT IDENTITY(1,1) PK
cliente_id BIGINT NOT NULL FK → cliente
campo VARCHAR(30) NOT NULL Qué campo cambió
valor_anterior / valor_nuevo NVARCHAR(255) NULL
cambiado_en DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
hash_token / ip CHAR(64) NULL / VARCHAR(45) NULL Contexto del cambio, si vino de la landing

Índices de soporte de FK

SQL Server, a diferencia de MySQL/InnoDB, no crea automáticamente un índice para cada foreign key. La migración V6__indices_fk.sql agrega un índice no-clustered dedicado a cada una de las 10 FKs que no tenían ya un índice compuesto cuyas columnas líderes coincidieran — sin esto, los joins contra la tabla referenciada podían forzar un table scan completo de la tabla hija.

Ver también