Saltar a contenido

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


reserva

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 '' usuario
comercio
administracion
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 ios
android
web

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 INSERT
UPDATE
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:

  1. El estado de la publicación tiene allow_reserva.
  2. now() cae entre reservable_desde_at y reservas_cierran_at.
  3. 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-only puro: el modo contingencia necesita fijar sincronizada_at/conflicto_at/ conflicto_detalle después del INSERT, y ese régimen no admite ningún UPDATE.
  • 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 qr
codigo_manual
origen varchar(14) no en_linea
contingencia
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.

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.