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¶

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 esclave_firma, cuya PK es el propiokey_id TINYINT(no hace falta un id sintético para una clave). - Timestamps:
DATETIME2, casi siempreNOT 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 unVARCHARacotado por unCONSTRAINT ck_..._... CHECK (columna IN (...)). Se listan los valores válidos en cada tabla más abajo. - Bloqueo optimista (
version):premio_unidad,asignacion_premio,resultado_sorteoyclientetienen una columnaversion BIGINT NOT NULL DEFAULT 0(agregada en V9 y V12). Hibernate la incrementa en cadaUPDATE; si otra transacción ya avanzó la fila, la operación falla conOptimisticLockException, 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_tokenyreconocimiento_dispositivo.hash_reconocimientopersisten unCHAR(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
Es un índice único filtrado: no restringe cuántas filasborrador 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' |
Disponible → Asignado → Sorteado → Otorgado | 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 → cliente — N 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¶
- El token del QR (Anexo A) — cómo se calcula
chance.hash_token. - API HTTP — superficie que lee y escribe sobre este esquema.
- Casos de participación (A–E) — el flujo de negocio
que crea filas en
chance,clienteyvoucher_rechazado. - El motor de sorteo y el acta — cómo se llenan
actayresultado_sorteo.