Reservas¶
El núcleo del sistema. reserva es la entidad crítica: concentra la
concurrencia, la inmutabilidad exigida, el snapshot obligatorio y todas
las reglas temporales del motor.
Requerimientos cubiertos¶
RF-U-28RF-U-29RF-U-30RF-U-31RF-U-32RF-U-33RF-U-34RF-U-36RF-U-37RF-C-36RF-C-38RF-C-41RF-C-42RF-C-43RF-C-44RF-C-45RF-C-47RF-C-48RF-A-28RF-A-30
reserva¶
- Régimen: Mutable protegida.
DELETEbloqueado sin excepción - Volumen estimado: 300-1.500 filas diarias
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Predeterminado | Descripción |
|---|---|---|---|---|
codigo |
varchar(6) |
no | Único global | |
perfil_id |
uuid |
no | Llave foránea a perfil_consumidor, no a la cuenta directamente |
|
publicacion_id |
uuid |
no | Llave foránea | |
comercio_id |
uuid |
no | Denormalizado desde la publicación | |
ventana_id |
uuid |
no | Denormalizado | |
fecha_operativa |
date |
no | Denormalizada | |
cantidad |
smallint |
no | ||
precio_unitario |
numeric(10,2) |
no | Snapshot | |
valor_referencia_unitario |
numeric(10,2) |
no | Snapshot | |
monto_total |
numeric(12,2) |
no | generada | Virtual |
nombre_oferta |
varchar(60) |
no | Snapshot para el comprobante | |
estado_id |
uuid |
no | Llave foránea a estado_reserva |
|
retencion_expira_at |
timestamptz |
sí | Instante de alta más la retención configurada | |
confirmada_at |
timestamptz |
sí | ||
ventana_inicio_at |
timestamptz |
no | Snapshot | |
ventana_fin_at |
timestamptz |
no | Snapshot | |
validacion_hasta_at |
timestamptz |
no | Snapshot, incluye la tolerancia congelada | |
cancelable_hasta_at |
timestamptz |
no | Snapshot | |
retirada_at |
timestamptz |
sí | Entrega registrada | |
cancelada_at |
timestamptz |
sí | ||
expirada_at |
timestamptz |
sí | ||
finalizada_at |
timestamptz |
sí | generada | Almacenada, marca de cierre |
cancelada_by |
varchar(20) |
no | '' |
usuariocomercioadministracion |
cancelada_por_usuario_id |
uuid |
sí | Nulo si canceló un proceso | |
motivo_id |
uuid |
sí | Llave foránea a motivo |
|
motivo_nota |
text |
no | '' |
|
no_show_confirmado_at |
timestamptz |
sí | ||
no_show_confirmado_by_id |
uuid |
sí | Llave foránea a usuario |
|
origen_plataforma |
varchar(10) |
no | iosandroidweb |
Columnas generadas¶
monto_total numeric(12,2)
GENERATED ALWAYS AS (cantidad * precio_unitario) VIRTUAL
finalizada_at timestamptz
GENERATED ALWAYS AS (COALESCE(retirada_at, cancelada_at, expirada_at)) STORED
finalizada_at sostiene los dos índices parciales de abajo. Al derivarse
de las tres marcas de desenlace por COALESCE no puede desincronizarse de
ellas ni requiere trigger que la mantenga.
Llaves foráneas¶
| Columna | Referencia | ON DELETE |
Notas |
|---|---|---|---|
estado_id |
estado_reserva |
RESTRICT |
|
perfil_id |
perfil_consumidor |
RESTRICT |
La cuenta se anonimiza, la reserva sobrevive |
publicacion_id |
publicacion |
RESTRICT |
|
comercio_id |
comercio |
RESTRICT |
Denormalizado, escrito una sola vez |
ventana_id |
ventana_recogida |
RESTRICT |
Denormalizado |
motivo_id |
motivo |
RESTRICT |
|
cancelada_por_usuario_id |
usuario |
RESTRICT |
|
no_show_confirmado_by_id |
usuario |
RESTRICT |
Constraints¶
CONSTRAINT chk_reserva_codigo_formato CHECK (codigo ~ '^[0-9A-HJ-NP-TV-Z]{6}$')
CONSTRAINT chk_reserva_cantidad CHECK (cantidad >= 1)
CONSTRAINT chk_reserva_precio_unitario CHECK (precio_unitario >= 0)
CONSTRAINT chk_reserva_valor_referencia_unitario
CHECK (valor_referencia_unitario >= precio_unitario)
CONSTRAINT chk_reserva_retencion_posterior
CHECK (retencion_expira_at IS NULL OR retencion_expira_at > created_at)
CONSTRAINT chk_reserva_ventana_orden CHECK (ventana_inicio_at < ventana_fin_at)
CONSTRAINT chk_reserva_validacion_posterior
CHECK (validacion_hasta_at >= ventana_fin_at)
CONSTRAINT chk_reserva_confirmacion_previa
CHECK (
(retirada_at IS NULL AND expirada_at IS NULL)
OR confirmada_at IS NOT NULL
)
CONSTRAINT chk_reserva_desenlace_unico
CHECK (num_nonnulls(retirada_at, cancelada_at, expirada_at) <= 1)
CONSTRAINT chk_reserva_cancelacion_completa
CHECK (
cancelada_at IS NULL
OR (length(cancelada_by) > 0 AND motivo_id IS NOT NULL)
)
CONSTRAINT chk_reserva_cancelada_by
CHECK (cancelada_by IN ('', 'usuario', 'comercio', 'administracion'))
CONSTRAINT chk_reserva_noshow_coherente
CHECK ((no_show_confirmado_at IS NULL) = (no_show_confirmado_by_id IS NULL))
CONSTRAINT chk_reserva_noshow_expirada
CHECK (no_show_confirmado_at IS NULL OR expirada_at IS NOT NULL)
CONSTRAINT chk_reserva_origen_plataforma
CHECK (origen_plataforma IN ('ios', 'android', 'web'))
CONSTRAINT chk_reserva_cancelable_hasta
CHECK (cancelable_hasta_at <= ventana_fin_at)
CONSTRAINT chk_reserva_cancelacion_plazo
CHECK (
cancelada_at IS NULL
OR cancelada_by <> 'usuario'
OR cancelada_at <= cancelable_hasta_at
)
CONSTRAINT chk_reserva_orden_marcas
CHECK (
confirmada_at IS NULL
OR (
(retirada_at IS NULL OR retirada_at >= confirmada_at)
AND (cancelada_at IS NULL OR cancelada_at >= confirmada_at)
AND (expirada_at IS NULL OR expirada_at >= confirmada_at)
)
)
chk_reserva_desenlace_unico impide que una reserva figure a la vez como
entregada y como cancelada, que sería la forma más silenciosa de falsear
el ingreso del comercio.
chk_reserva_cancelacion_plazo es la única de las tres últimas que traduce un
requerimiento nuevo y no una coherencia interna: RF-U-34
permite cancelar "hasta el plazo configurado antes del cierre de la ventana,
mostrado desde el momento de reservar", y ese plazo ya está congelado en
cancelable_hasta_at. El límite solo aplica al usuario: el comercio
(RF-C-40) y administración (RF-A-29) pueden
cancelar después, y de hecho es su caso de uso principal.
Unicidad¶
| Nombre | Definición | Propósito |
|---|---|---|
unq_reserva_codigo |
(codigo) |
Invariante crítica: el código es único global |
unq_reserva_usuario_comercio_activa |
(perfil_id, comercio_id) WHERE finalizada_at IS NULL |
Una reserva viva por comercio |
El segundo índice es la única forma correcta de imponer ese límite bajo
concurrencia: dos peticiones simultáneas del mismo perfil al mismo
comercio pasarían cualquier comprobación previa en Python, y con el
índice la segunda falla en el INSERT.
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_readonly_snapshot |
UPDATE de los snapshot |
BEFORE |
ROW |
Los campos congelados son inmutables tras la creación | RF-C-41 |
trg_reserva_reservable |
INSERT |
BEFORE |
ROW |
La publicación admite reserva, está dentro de su ventana de compra y tiene disponibilidad | RF-U-31 |
trg_reserva_snapshot |
INSERT |
BEFORE |
ROW |
Deriva las diez columnas congeladas desde la publicación | D-15 |
trg_reserva_codigo |
INSERT |
BEFORE |
ROW |
Genera el código Crockford Base32 si no viene dado | RF-U-32 |
trg_reserva_perfil_apto |
INSERT |
BEFORE |
ROW |
El perfil puede reservar y no tiene sanción vigente | RF-U-37 |
trg_reserva_limite_activas |
INSERT |
BEFORE |
ROW |
Reservas vivas y unidades dentro de reserva.max_activas y reserva.max_unidades |
RF-U-33 |
trg_reserva_ajustar_stock |
INSERTUPDATE |
AFTER |
ROW |
Mueve publicacion.cantidad_reservada según consume_stock |
D-17 |
trg_reserva_sancion_automatica |
UPDATE |
AFTER |
ROW |
Al expirar, cuenta expiradas del periodo y aplica la sanción si corresponde | RF-U-37 |
trg_readonly_snapshot cubre codigo, perfil_id, publicacion_id,
comercio_id, ventana_id, fecha_operativa, cantidad,
precio_unitario, valor_referencia_unitario y nombre_oferta. Por sí
solo eso hace cierta la mitad de lo que D-10 afirma sobre las
columnas denormalizadas ("no pueden divergir porque no pueden escribirse
desde fuera"): verdadero del UPDATE, pero no del INSERT, donde un valor
inventado quedaría inmutable igual de bien. trg_reserva_snapshot es lo que
cierra esa puerta, sobrescribiendo lo que el llamador haya mandado con lo
que la publicación realmente dice.
trg_reserva_snapshot¶
Pertenece a la familia de derivación y sobrescribe las diez columnas con los valores de la publicación, ignorando lo que haya mandado el llamador:
Se deriva de publicacion |
Se calcula |
|---|---|
comercio_id, ventana_id, fecha_operativa |
retencion_expira_at = now() + reserva.retencion_min |
precio_unitario ← precio |
cancelable_hasta_at = ventana_fin_at − reserva.plazo_cancelacion_min |
valor_referencia_unitario ← valor_referencia |
|
nombre_oferta ← nombre |
|
ventana_inicio_at, ventana_fin_at, validacion_hasta_at |
Los tres instantes de ventana se copian de la publicación en vez de
recalcularse: la publicación ya los resolvió al crearse y son el contrato
congelado con quien reserva. Los dos que se calculan leen parametro_sistema
una sola vez y quedan congelados por D-16, que es lo que permite
que bajar el plazo de cancelación mañana no le quite el derecho a quien
reservó hoy.
trg_reserva_reservable¶
La comprobación que hoy no existe en ninguna capa. Tres condiciones, en este orden:
- El estado de la publicación tiene
allow_reserva. now()cae entrereservable_desde_atyreservas_cierran_at.cantidad_disponible >= NEW.cantidad.
La tercera es redundante con chk_publicacion_stock_coherente, que igual
rechazaría el UPDATE del contador, y se incluye de todos modos porque el
error tiene que nombrar la reserva y su cantidad, no una restricción de otra
tabla. La sobreventa se sigue resolviendo en la restricción, que es el único
mecanismo correcto bajo concurrencia; esta comprobación solo hace que el
noventa y nueve por ciento de los casos den un mensaje útil.
trg_reserva_perfil_apto¶
Dos condiciones que RF-U-37 y RF-A-20 describen
como la misma cosa vista desde dos lados: el estado del perfil tiene
allow_reserva, y no existe una sancion_usuario vigente de tipo
bloqueo_reservas ni suspension_cuenta. La segunda mitad no se puede
derivar de la primera porque una sanción de bloqueo no cambia el estado
de la cuenta: el usuario sigue activo y sigue entrando a la aplicación, solo
que sin poder reservar.
trg_reserva_sancion_automatica¶
RF-U-37 es la única regla del modelo que crea una fila en otra
tabla como consecuencia de un conteo histórico. Al pasar una reserva a un
estado con incumplida, el trigger cuenta las reservas del mismo perfil
expiradas dentro de sancion.periodo_conteo_dias y, si alcanzan
sancion.expiradas_para_bloqueo, inserta la sanción con
fin_at = now() + sancion.duracion_bloqueo_dias.
Es trigger y no proceso programado porque tiene un evento de escritura claro
que lo dispara, y porque el usuario tiene que ver el bloqueo en el mismo
instante en que el sistema decide aplicarlo. Que los tres parámetros queden
congelados en la fila de la sanción es lo que exige RF-A-39;
ver Perfiles para las columnas que faltan.
Índices¶
| Nombre | Definición | Propósito |
|---|---|---|
idx_reserva_expiracion |
(estado_id, validacion_hasta_at) WHERE finalizada_at IS NULL |
Barrido de expirar_reserva |
idx_reserva_retencion |
(estado_id, retencion_expira_at) WHERE finalizada_at IS NULL |
Barrido de expirar_retenciones_vencidas |
El código de retiro¶
Lo genera trg_reserva_codigo dentro de la base, con reintento sobre
colisión de unq_reserva_codigo, y no la capa de aplicación: la unicidad
global es una invariante crítica y generar el candidato fuera
de la transacción que lo inserta obligaría a un bucle de reintentos en cada
cliente que cree reservas.
Seis caracteres del alfabeto Crockford Base32, que excluye I, L,
O y U. Las tres primeras se excluyen porque se confunden con 1 y 0
al dictarse por teléfono o al leerse de una pantalla con poca luz; la U
porque su omisión reduce la probabilidad de generar palabras ofensivas por
accidente. La unicidad es global y no por comercio: distinguir
código inexistente de código de otro comercio solo es computable si un
código se resuelve contra el universo completo antes de comprobar a quién
pertenece.
Aritmética del stock¶
publicacion.cantidad_reservada sube y baja exclusivamente a través de
trg_reserva_ajustar_stock, en la misma sentencia que escribe la reserva,
nunca de forma diferida.
El trigger no decide nada por sí mismo: compara consume_stock del estado
viejo contra el del nuevo y aplica el delta.
consume_stock viejo |
consume_stock nuevo |
Efecto |
|---|---|---|
n/a (INSERT) |
cierto | + NEW.cantidad |
n/a (INSERT) |
falso | Sin efecto |
| falso | cierto | + NEW.cantidad |
| cierto | falso | − NEW.cantidad |
| igual | igual | Sin efecto |
La expiración no libera stock
Una unidad que nadie recogió dentro de la ventana no vuelve a estar disponible, porque la ventana ya cerró y no hay a quién vendérsela. Las cancelaciones sí liberan, porque ocurren con tiempo por delante.
Eso no está escrito en ningún trigger: está en el catálogo de estados,
como consume_stock = true en expirada y false en las tres
cancelaciones. Cambiar la política es un UPDATE sobre
estado_reserva, no una migración.
Máquina de estados¶
---
config:
fontFamily: monospace
layout: elk
---
flowchart LR
N["alta"] -->|"el usuario pulsa reservar"| R["retenida"]
R -->|"confirma antes de 2 min"| A["activa"]
R -->|"vence la retencion"| CU["cancelada_usuario"]
A -->|"validada en mostrador"| RE["retirada"]
A -->|"cancela el usuario a tiempo"| CU
A -->|"cancela el comercio"| CC["cancelada_comercio"]
A -->|"cancela administracion"| CA["cancelada_administracion"]
A -->|"pasa validacion_hasta_at"| EX["expirada"]
Los pares de este diagrama son las filas de transicion_reserva, con su
actor y su exigencia de motivo. El diagrama documenta; la tabla y
trg_reserva_transicion_valida imponen, junto con
trg_reserva_estado_terminal, que impide revivir cualquiera de los cinco
desenlaces, y trg_reserva_estado_coherente, que exige que las marcas
temporales respalden el estado: finalizada del estado equivale a
finalizada_at IS NOT NULL, incumplida exige expirada_at, y el código
cancelada_usuario exige cancelada_by = 'usuario'. El contrato completo
está en Estados.
validacion_reserva¶
La entrega. Una reserva se valida a lo sumo una vez: en el código
actual esa garantía la da el OneToOneField hacia reserva, sin una
UniqueConstraint nombrada aparte. Es el mecanismo que cumple la
idempotencia de RF-C-44 sin escribir ninguna comprobación.
- Régimen: Mutable protegida, no
append-onlypuro: el modo contingencia necesita fijarsincronizada_at/conflicto_at/conflicto_detalledespués delINSERT, y ese régimen no admite ningúnUPDATE. - Volumen estimado: 200-1.000 filas diarias
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Predeterminado | Descripción |
|---|---|---|---|---|
reserva_id |
uuid |
no | Llave foránea RESTRICT, única |
|
validada_at |
timestamptz |
no | now() |
Hora del servidor, autoritativa |
registrada_at |
timestamptz |
sí | Hora declarada por el dispositivo | |
sincronizada_at |
timestamptz |
sí | Instante en que llegó al servidor | |
validada_by_id |
uuid |
no | Llave foránea RESTRICT a usuario_comercio |
|
metodo |
varchar(20) |
no | qrcodigo_manual |
|
origen |
varchar(14) |
no | en_lineacontingencia |
|
cantidad_esperada |
smallint |
no | Snapshot de reserva.cantidad |
|
cantidad_entregada |
smallint |
no | Puede ser menor que la esperada | |
entrega_parcial |
boolean |
no | Generada virtual | |
motivo_parcial_id |
uuid |
sí | Llave foránea RESTRICT a motivo |
|
monto_esperado |
numeric(12,2) |
no | ||
monto_cobrado |
numeric(12,2) |
no | Lo que el operador declara haber cobrado | |
nota |
text |
no | '' |
Obligatoria si los montos difieren |
autorizada_by_id |
uuid |
sí | No nula si la validación fue excepcional | |
conflicto_at |
timestamptz |
sí | La reserva ya no era validable al sincronizar | |
conflicto_detalle |
text |
no | '' |
Columna generada entrega_parcial¶
entrega_parcial boolean
GENERATED ALWAYS AS (cantidad_entregada < cantidad_esperada) VIRTUAL
Llaves foráneas¶
| Columna | Referencia | ON DELETE |
Notas |
|---|---|---|---|
reserva_id |
reserva |
RESTRICT |
Relación 1:1 |
validada_by_id |
usuario_comercio |
RESTRICT |
Debe seguir nombrándolo |
motivo_parcial_id |
motivo |
RESTRICT |
|
autorizada_by_id |
usuario |
RESTRICT |
Constraints¶
CONSTRAINT chk_validacionreserva_metodo CHECK (metodo IN ('qr', 'codigo_manual'))
CONSTRAINT chk_validacionreserva_origen CHECK (origen IN ('en_linea', 'contingencia'))
CONSTRAINT chk_validacionreserva_contingencia
CHECK (
(origen = 'contingencia' AND registrada_at IS NOT NULL AND sincronizada_at IS NOT NULL)
OR (origen <> 'contingencia' AND registrada_at IS NULL AND sincronizada_at IS NULL)
)
CONSTRAINT chk_validacionreserva_cantidad_esperada CHECK (cantidad_esperada >= 1)
CONSTRAINT chk_validacionreserva_cantidad_entregada
CHECK (cantidad_entregada BETWEEN 0 AND cantidad_esperada)
CONSTRAINT chk_validacionreserva_motivo_parcial
CHECK (cantidad_entregada = cantidad_esperada OR motivo_parcial_id IS NOT NULL)
CONSTRAINT chk_validacionreserva_monto_esperado CHECK (monto_esperado >= 0)
CONSTRAINT chk_validacionreserva_monto_cobrado CHECK (monto_cobrado >= 0)
CONSTRAINT chk_validacionreserva_diferencia_justificada
CHECK (monto_cobrado = monto_esperado OR length(nota) > 0)
CONSTRAINT chk_validacionreserva_conflicto
CHECK (
(conflicto_at IS NULL AND conflicto_detalle = '')
OR (conflicto_at IS NOT NULL AND length(conflicto_detalle) > 0)
)
CONSTRAINT chk_validacionreserva_contingencia_orden
CHECK (sincronizada_at IS NULL OR sincronizada_at >= registrada_at)
La diferencia entre ambos "mide cuánto duró la caída", como dice la nota de diseño de abajo; con el orden invertido mide un número negativo.
Triggers¶
| Nombre | Evento | Momento | Nivel | Regla | Origen |
|---|---|---|---|---|---|
trg_readonly_snapshot |
UPDATE de casi todo |
BEFORE |
ROW |
Solo sincronizada_at, conflicto_at y conflicto_detalle son mutables |
RF-C-44 |
trg_validacionreserva_validable |
INSERT |
BEFORE |
ROW |
La reserva admite validación, está dentro de la ventana con tolerancia, y el operador es del comercio | RF-C-43 |
trg_validacionreserva_snapshot |
INSERT |
BEFORE |
ROW |
Deriva cantidad_esperada y monto_esperado de la reserva |
D-10 |
trg_validacionreserva_entrega |
INSERT |
AFTER |
ROW |
Cierra la reserva como retirada y suma a publicacion.cantidad_entregada |
RF-U-39 |
trg_validacionreserva_validable¶
RF-C-43 enumera seis rechazos con mensaje diferenciado, y los
seis corresponden a una condición que este trigger comprueba. Cinco de ellas
tienen ya su fila en resultado_validacion, que es el
catálogo de mensajes que el operador ve.
| Condición | Se comprueba contra | resultado_validacion |
|---|---|---|
| El código existe | unq_reserva_codigo |
inexistente |
| La reserva es del comercio | reserva.comercio_id = validada_by.comercio_id |
otro_comercio |
| La ventana ya abrió | now() >= reserva.ventana_inicio_at |
antes_de_ventana |
| La tolerancia no venció | now() <= reserva.validacion_hasta_at |
fuera_de_tolerancia |
| La reserva es validable | estado_reserva.allow_validacion |
reserva_cancelada |
| No se validó ya | unq_validacionreserva_reserva, por construcción |
ya_utilizado |
La tolerancia no se recalcula: validacion_hasta_at es un snapshot que
ya la incorpora, congelado al crear la publicación. Es lo que hace que una
entrega de ayer se siga explicando con la tolerancia de ayer aunque el
parámetro haya cambiado, por D-16.
autorizada_by es la única salida: cuando viene con valor, la validación es
la excepcional de RF-A-30 y el trigger salta las dos
comprobaciones temporales. Es, en palabras del requerimiento, "el único
camino para validar fuera de la ventana con tolerancia".
trg_validacionreserva_entrega¶
Validar es entregar. El trigger escribe reserva.retirada_at, mueve el
estado de la reserva a retirada (par declarado en transicion_reserva con
actor comercio) y suma cantidad_entregada al contador de la publicación,
todo en la misma transacción, que es lo que RF-C-45 exige al
decir que el cobro "forma parte de la misma transacción que la validación, no
de un paso posterior".
No corre cuando conflicto_at viene con valor: RF-C-48 dice que
una validación en conflicto "no cierra la reserva y entra en una cola de
resolución".
Notas de diseño¶
La cola local que se sincroniza al recuperar conexión se modela con tres
instantes distintos, y ninguno sustituye a otro: validada_at (hora del
servidor, autoritativa), registrada_at (hora declarada por el
dispositivo, nunca fuente de verdad) y sincronizada_at (cuándo llegó al
servidor; su diferencia con registrada_at mide cuánto duró la caída).
favorito¶
Comercios marcados por el usuario.
- Régimen: Mutable rastreada
- Volumen estimado: 2.000-10.000 filas
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
perfil_id |
uuid |
no | Llave foránea RESTRICT a perfil_consumidor |
comercio_id |
uuid |
no | Llave foránea RESTRICT |
Unicidad¶
| Nombre | Definición | Propósito |
|---|---|---|
unq_favorito_perfil_comercio |
(perfil_id, comercio_id) |
Un comercio se marca una sola vez |
Notas de diseño¶
Es la única tabla del módulo con DELETE real: quitar un favorito es
exactamente eso, y conservar la fila con una marca de baja no aporta
nada. En la práctica el DELETE se sigue reescribiendo como
is_active = false (régimen heredado de toda tabla mutable rastreada),
y la capa de lectura filtra siempre por is_active = true, logrando el
mismo efecto observable que un borrado real. La preferencia de que
nada se borre aplica a hechos transaccionales, no a preferencias
reversibles del usuario.