Saltar a contenido

Estados

Ningún estado del sistema se almacena como cadena de texto. Cada entidad con ciclo de vida referencia por llave foránea una tabla de catálogo propia, que puede ampliarse con filas nuevas sin migrar el esquema.

Esta página define el patrón, el reparto de responsabilidades entre el catálogo y las marcas temporales de la entidad, y el catálogo completo de los catorce dominios de estado del modelo.

Requerimientos cubiertos


Taxonomía de enumeraciones

No toda enumeración cerrada es un estado. Convertirlas todas en tablas prometería una extensibilidad que no existe, y dejarlas todas como texto perdería la que sí existe. El modelo distingue tres clases y aplica un mecanismo distinto a cada una.

Clases de enumeración y sus mecanismos

  • Posición de una entidad en su ciclo de vida.
  • Cambia con el tiempo para una misma fila y gobierna qué operaciones se admiten.
  • Agregar un valor nuevo es una decisión operativa, no una migración.
  • Mecanismo: tabla estado_<entidad> y llave foránea.
  • Distingue variantes de una fila que tienen columnas obligatorias distintas.
  • No cambia durante la vida de la fila.
  • Agregar un valor obliga a agregar columnas que solo aplican a ese valor.
  • Mecanismo: varchar y CHECK con la lista de valores.
  • Ejemplos: zona_cobertura.tipo, excepcion_calendario.tipo, plantilla_oferta.tipo, reserva.origen_plataforma.
  • Valor de negocio que administración da de alta, edita y desactiva sin que el comportamiento del sistema cambie.
  • Mecanismo: tabla propia con activo.
  • Ejemplos: motivo, categoria_comida, zona, descritos en Catálogos.

La prueba para separar estado de discriminador: si agregar un valor nuevo obliga a agregar columnas que solo aplican a ese valor, es un discriminador. Modelarlo como tabla no lo haría extensible, solo escondería que no lo es.


Reparto de responsabilidades

El catálogo de estado y la entidad gobernada cubren necesidades distintas y no compiten.

Responsabilidad Dónde vive Por qué
Etiqueta y descripción para la interfaz estado_<entidad> Editable sin migrar el esquema
Semántica que consulta el motor estado_<entidad> Agregar un estado es declarar sus atributos, no ramificar
Transiciones legales transicion_<entidad> D-09
Subconjunto vivo para índices parciales Marcas temporales de la fila D-08
Coherencia entre ambos trg_<tabla>_estado_coherente Un CHECK no puede consultar otra tabla

Las cuatro reglas que impone la última fila (estado activo, estado terminal, transición declarada y coherencia con las marcas) son una familia completa de triggers, definida en Triggers. Aquí se describe el contrato que esa familia lee; allá, cómo lo impone.

La entidad gobernada lleva marcas temporales que registran sus transiciones (confirmada_at, cancelada_at, resuelta_at, revisada_at) y que existen de todos modos por obligación de auditoría. Esas marcas son las que sostienen los índices parciales, porque el predicado de un índice solo admite expresiones inmutables sobre columnas de la propia fila.

CREATE UNIQUE INDEX unq_reserva_usuario_comercio_activa
ON reserva (perfil_id, comercio_id)
WHERE finalizada_at IS NULL;

estado_<entidad>

Forma base que comparten los catorce catálogos de estado del modelo, heredada de la clase base compartida por todo el modelo, no declarada fila por fila en cada módulo.

Columnas

Además de las columnas estándar (id, created_at, updated_at) y de is_active (régimen mutable rastreada):

Campo Tipo Nulo Predeterminado Descripción
codigo varchar(50) no Identificador estable citado por triggers
etiqueta varchar(50) no Nombre visible en la interfaz
descripcion text no '' Qué significa el estado para el operador que lo lee
initial boolean no false El motor asigna este estado al crear la entidad
terminal boolean no false No admite transiciones salientes

A estos campos comunes cada dominio agrega sus atributos semánticos: los booleanos que el motor consulta en lugar de comparar contra codigo. Se componen a partir de seis mixins compartidos (allow_edit, allow_login, allow_reserva, public, require_admin, require_motivo) más, en algunos dominios, columnas propias sin equivalente en otro catálogo. El contrato completo, dominio por dominio, está más abajo.

Los prefijos no son decorativos y fijan la dirección en que se lee el atributo. Confundirlas invierte la mitad del catálogo, así que se declaran una sola vez aquí:

Prefijo Se lee Ejemplo
allow_* Estando en este estado, la acción se admite allow_reserva en activo: una cuenta activa puede reservar
public Estando en este estado, la fila es visible public en agotada: se ve, marcada sin stock
require_* Para llegar a este estado hace falta require_motivo en cancelada: cancelar exige elegir motivo

require_admin es, por tanto, "alcanzar este estado exige que actúe un administrador", y por eso es falso en todo estado initial: a un estado inicial no llega nadie, se nace en él.

La excepción son los dos require_* que describen una espera y no una condición de entrada: estado_moderacion.require_revision y estado_incidente.require_comercio marcan que una fila en ese estado sigue pendiente de alguien. Se leen como allow_*, no como require_admin, y por eso require_revision puede ser cierto en un estado initial.

Llaves foráneas

La forma base no declara ninguna. Un catálogo de estado es hoja del grafo: nadie lo referencia desde dentro del módulo y él no referencia a nadie.

Constraints

CONSTRAINT chk_<tabla>_codigo_format
CHECK (codigo ~ '^[a-z][a-z0-9_]*$')

CONSTRAINT chk_<tabla>_initial_isactive
CHECK (NOT initial OR is_active)

CONSTRAINT chk_<tabla>_initial_terminal
CHECK (NOT (initial AND terminal))

Unicidad

Nombre Definición Propósito
unq_<tabla>_codigo (codigo) El código es la referencia estable del trigger
unq_<tabla>_initial (true) WHERE initial Como máximo un estado inicial por dominio

Triggers

Los tres primeros son compartidos por toda tabla del modelo (ver Columnas estándar) y no llevan el nombre de la tabla en el nombre del trigger; el cuarto es específico de estado_<entidad>.

Nombre Evento Momento Nivel Regla Origen
trg_protect_truncate TRUNCATE BEFORE STATEMENT Bloquea el truncado Convención
trg_readonly_primarykey UPDATE OF id BEFORE ROW Rechaza cualquier cambio de id Convención
trg_touch_updatedat UPDATE BEFORE ROW Actualiza updated_at Convención
trg_softdelete_isactive DELETE BEFORE ROW Reescribe el borrado en is_active = false, la fila nunca desaparece D-13
trg_readonly_codigo UPDATE OF codigo BEFORE ROW Rechaza cualquier cambio de codigo D-12

Triggers de la entidad gobernada

No viven en el catálogo sino en cada tabla que lo referencia, y son los que convierten los atributos semánticos de arriba en garantías. Los cuatro se instancian igual en las catorce tablas con estado_id; el detalle de la plantilla está en Triggers. Ninguno está implementado todavía.

Nombre Evento Momento Nivel Regla Origen
trg_<tabla>_estado_activo INSERT
UPDATE OF estado_id
BEFORE ROW El estado asignado debe estar is_active RF-A-63
trg_<tabla>_estado_terminal UPDATE OF estado_id BEFORE ROW Una fila cuyo estado actual es terminal no cambia de estado RF-U-35
trg_<tabla>_transicion_valida UPDATE OF estado_id BEFORE ROW El par (from, to) existe activo en transicion_<entidad> y trae lo que exige RF-A-63
trg_<tabla>_estado_coherente INSERT
UPDATE
BEFORE ROW Los atributos semánticos del estado concuerdan con las marcas de la fila D-08

trg_<tabla>_transicion_valida solo se instala en las tres tablas con catálogo de transiciones. En las once restantes, trg_<tabla>_estado_coherente carga con toda la regla: no hay par que validar, pero sí un contrato que cumplir entre el estado y las marcas.

Índices

Nombre Definición Propósito
idx_<tabla>_codigo (codigo) WHERE is_active Resolución del catálogo asignable

Los dos índices únicos cubren el resto de accesos. Con menos de diez filas por tabla, cualquier índice adicional sería más caro de mantener que el recorrido secuencial que evita.

Régimen de auditoría

Mutable rastreada

codigo es de solo lectura: cambiarlo rompería todo el trigger que lo menciona y toda referencia en esta documentación. Las etiquetas, descripciones y atributos semánticos sí son editables. Un DELETE no borra la fila: la reescribe en is_active = false, porque un estado referenciado por una sola fila histórica es parte del historial. Cada UPDATE queda además en la tabla de eventos gemela, por el mismo régimen que cualquier catálogo administrable.


transicion_<entidad>

Tres entidades llevan su máquina de estados como datos, en una tabla transicion_<entidad> que enumera los pares admitidos: comercio, reserva y publicacion.

Columnas

Además de las columnas estándar y de is_active:

Campo Tipo Nulo Predeterminado Descripción
from_estado_id uuid no Llave foránea al estado de origen
to_estado_id uuid no Llave foránea al estado de destino
actor varchar(20) no admin
comercio
sistema
user
require_motivo boolean no false Exige motivo_id en la fila que transiciona
require_descripcion boolean no false Exige texto libre además del motivo

Llaves foráneas

Columna Referencia ON DELETE Notas
from_estado_id estado_<entidad> RESTRICT Un estado referenciado es parte del historial
to_estado_id estado_<entidad> RESTRICT

Constraints

CONSTRAINT chk_<tabla>_noreflex
CHECK (from_estado_id <> to_estado_id)

CONSTRAINT chk_<tabla>_actor
CHECK (actor IN ('admin', 'comercio', 'sistema', 'user'))

CONSTRAINT chk_<tabla>_descripcion_motivo_mutex
CHECK (NOT require_descripcion OR require_motivo)

Unicidad

No declara ninguna más allá de la llave primaria. Dos filas con el mismo par (from_estado_id, to_estado_id) y distinto actor son legítimas: el mismo tránsito puede estar permitido para un actor y no para otro, con exigencias de motivo distintas.

Cómo se consume

La validación del par la impone trg_<tabla>_transicion_valida, un trigger BEFORE UPDATE OF estado_id en la entidad gobernada. Antes de escribir, consulta transicion_<entidad> por (from_estado_id, to_estado_id, actor) y rechaza si no existe una fila activa, o si la transición encontrada exige motivo o descripción y la fila no los trae.

El actor de la petición llega por SET LOCAL hz.actor, que la capa de API fija al abrir la transacción. Cuando nadie lo fija (una migración de datos, una corrección desde psql) el trigger sigue exigiendo que el par exista, pero no que exista para un actor concreto: la integridad del ciclo de vida no depende de que el llamador se identifique, y la autorización por rol no es asunto de la base. El contrato completo está en Triggers.

¿Por qué solo tres entidades?

reserva, publicacion y comercio tienen máquinas de estados con ramas, actores distintos y consecuencias irreversibles: una transición ilegal de reserva produce entregas duplicadas o stock fantasma. El resto de los dominios tiene ciclos lineales sin ramificación, donde el orden está implícito y una tabla de transiciones sería mecanismo sin regla que imponer. Esos once ciclos no quedan sin validar: trg_<tabla>_estado_terminal impide revivir una fila cerrada y trg_<tabla>_estado_coherente exige que las marcas temporales respalden el estado declarado, que es toda la regla que un ciclo lineal necesita.


Catálogo de los 14 dominios

Cada bloque lista sus filas semilla, sus atributos semánticos y la marca temporal de la entidad gobernada que identifica el subconjunto vivo para los índices parciales. Salvo donde se indica lo contrario, el código exacto de cada fila, su etiqueta y su descripción quedan a criterio de quien siembra el catálogo: lo único que las notas de dominio fijan es qué fila es initial y cuáles son terminal.

Las tablas de esta sección son el contenido real de hz_estados/seeders.py, y los bloques citados como Marca en <tabla> son la especificación que lee trg_<tabla>_estado_coherente: cada uno declara qué marca temporal tiene que respaldar qué atributo semántico. Ahí donde el bloque dice "equivale a", el trigger impone una equivalencia estricta en las dos direcciones.

estado_usuario

codigo allow_login allow_reserva require_canalverificado initial terminal
pendiente_verificacion sí no no sí no
activo sí sí sí no no
suspendido sí no sí no no
anonimizado no no no no sí

allow_login distingue solo a anonimizado de los otros tres: los tres admiten sesión, y es allow_reserva el que separa activo de pendiente_verificacion/suspendido. Es la corrección que RF-U-02 pide ("una cuenta sin ningún canal verificado puede iniciar sesión y explorar, pero no puede reservar") y que RF-A-20 pide del otro lado ("el usuario conserva el acceso a la aplicación y ve el motivo y la fecha de reactivación al intentar reservar").

No hay un estado de "eliminación pendiente" en este catálogo. La solicitud de eliminación de RF-U-12 y RF-A-62 no está modelada todavía: hoy la ejecución solo se refleja como el paso a anonimizado, y el expediente intermedio que esos requerimientos describen queda pendiente.

estado_comercio

codigo public require_motivo allow_login allow_reserva allow_publicar initial terminal
pendiente_revision no no no no no sí no
afiliado sí no sí sí sí no no
suspendido no sí sí no no no no
cancelado no sí no no no no sí

Cuatro estados, no siete: la revisión de la solicitud de afiliación (en_revision, aprobada, rechazada) vive en estado_solicitud_afiliacion, y una pausa temporal declarada por el comercio vive en pausa_comercio; este catálogo solo gobierna la cuenta del comercio en sí.

estado_solicitud_afiliacion

codigo allow_edit require_admin require_motivo initial terminal
en_revision sí no no sí no
aprobada no sí no no sí
rechazada no sí sí no sí

allow_edit en en_revision es lo que habilita la edición del formulario que pide RF-A-10 mientras el expediente sigue abierto, y se apaga en los dos estados terminales.

Marca en solicitud_afiliacion

  • resuelta_at IS NULL delimita el expediente abierto

estado_solicitud_cambio

codigo require_admin require_motivo apply initial terminal
pendiente no no no sí no
aprobada sí no sí no sí
rechazada sí sí no no sí

apply es el flag que el trigger consulta para decidir si escribe el valor_propuesto en comercio al resolver: es la traducción directa de "aprobar aplica el cambio, rechazar no".

Marca en solicitud_cambio_comercio

  • resuelta_at IS NULL

estado_publicacion

codigo public require_motivo allow_reserva mutate_stock initial terminal
programada no no no no sí no
publicada sí no sí sí no no
agotada sí no no sí no no
cerrada no no no no no sí
cancelada no sí no no no sí
despublicada no sí no no no sí

mutate_stock marca en qué estados trg_reserva_ajustar_stock puede mover cantidad_reservada; está apagado en programada porque ese estado no admite reservas y por tanto no hay stock que mover, y deja de aplicar en cuanto la publicación cierra por cualquier vía, para que un ajuste tardío no reviva una fila ya cerrada. agotada sigue siendo public porque el cliente debe verla como "sin stock", no como inexistente.

Marcas en publicacion

  • publicada_at IS NOT NULL AND cerrada_at IS NULL delimita la publicación viva

estado_reserva

codigo public require_motivo allow_validacion allow_cancelacion consume_stock finalizada incumplida initial terminal
alta no no no no no no no sí no
retenida no no no sí sí no no no no
activa sí no sí sí sí no no no no
retirada sí no no no sí sí no no sí
cancelada_usuario sí sí no no no sí no no sí
cancelada_comercio sí sí no no no sí no no sí
cancelada_administracion sí sí no no no sí no no sí
expirada sí no no no sí sí sí no sí

alta es un estado de tránsito casi instantáneo: existe para que la fila nazca antes de contar el stock, y por eso es el único estado no terminal con consume_stock apagado; el trigger la mueve a retenida en la misma transacción, y es ese paso el que reserva la unidad. consume_stock es el atributo del que depende la aritmética de inventario: verdadero en expirada y en retirada porque la unidad se consumió o se perdió y en ningún caso volvió a estar disponible, y falso en las tres cancelaciones, que sí devuelven inventario. Quien lo lee es trg_reserva_ajustar_stock, que compara el atributo del estado viejo contra el del nuevo y escribe el delta en publicacion.cantidad_reservada. finalizada coincide con terminal en todas las filas de este dominio; se expone como atributo propio para que una consulta pueda filtrar por él sin depender de la columna base.

Marcas en reserva

  • finalizada_at IS NULL (columna generada, COALESCE de retirada_at/cancelada_at/expirada_at) equivale a que el estado actual no tenga finalizada
  • retencion_expira_at sostiene el barrido de retenida vencidas
  • validacion_hasta_at sostiene el barrido de activa vencidas

resultado_validacion

codigo exito
exito sí
inexistente no
ya_utilizado no
otro_comercio no
antes_de_ventana no
fuera_de_tolerancia no
reserva_cancelada no

No es un estado de ciclo de vida sino el desenlace inmutable de un intento de canje en mostrador. Comparte la forma base porque cada caso necesita un mensaje propio para el operador, y ese mensaje es contenido editable, no un valor fijo en código.

Las siete filas están activas simultáneamente, no hay transición entre ellas. initial se fija en exito únicamente porque la columna lo exige por esquema, no porque tenga significado de ciclo de vida.

estado_invitacion

codigo canjeable initial terminal
pendiente sí sí no
aceptada no no sí
revocada no no sí
expirada no no sí

Marca en invitacion

  • consumida_at IS NULL equivale a canjeable

estado_moderacion

codigo public require_motivo require_revision initial terminal
pendiente no no sí sí no
publicada sí no no no no
oculta no sí no no sí

Es el único dominio compartido por dos entidades con arranques distintos, y el initial del catálogo solo sirve a una de ellas: una respuesta_valoracion de comercio nace pendiente porque la moderación previa es obligatoria, mientras que una valoracion de usuario nace publicada y se modera después. El trigger de valoracion fija su estado inicial explícitamente en vez de apoyarse en initial, y unq_<tabla>_initial no se interpone porque la marca sigue estando en una sola fila.

Marca en ambas tablas

  • oculta_at IS NULL equivale a public

estado_incidente

codigo require_admin require_comercio require_resolucion initial terminal
abierta no no no sí no
en_progreso sí no no no no
resuelta sí no sí no sí
cerrada sí no sí no sí

require_comercio está apagado en las cuatro filas: el descargo del comercio que pide RF-A-34 es opcional y sus rondas viven en incidencia_descargo, no en un estado propio. Queda declarado para cuando el flujo exija una espera formal al comercio antes de resolver.

Marca en incidencia

  • resuelta_at IS NULL delimita la incidencia abierta; combinada con ultimo_movimiento_at sostiene la alerta de 48 h sin movimiento

estado_alerta

codigo require_admin initial terminal
abierta no sí no
revisada sí no sí

Marca en alerta

  • revisada_at IS NULL sostiene el índice único que impide acumular alertas idénticas sin atender

estado_notificacion

codigo happy_path pendiente fallo initial terminal
pendiente no sí no sí no
enviada no sí no no no
entregada sí no no no sí
fallida no no sí no sí
descartada no no no no sí

pendiente es el atributo que delimita la cola de envío, y sigue encendido en enviada: el mensaje salió pero todavía espera acuse del proveedor, así que sigue siendo trabajo abierto para el monitor de salud de RF-A-61. happy_path se reserva para el desenlace bueno de verdad, que es entregada.

descartada no marca fallo: RF-A-61 dice que "un mensaje no enviado por preferencia del usuario no cuenta como fallo del proveedor", que es exactamente lo que ese estado registra. Si marcara fallo, el conteo de fallos de 24 h se inflaría con supresiones legítimas.

Marcas en notificacion

  • enviada_at IS NULL AND descartada_at IS NULL es la cola de envío (idx_notificacion_cola)
  • fallida_at IS NOT NULL sostiene el conteo de fallos de 24 h

estado_campana

codigo require_admin initial terminal
borrador no sí no
confirmada sí no no
enviando sí no no
finalizada sí no sí
cancelada sí no sí

Confirmar una campaña siempre exige un segundo administrador distinto de quien la creó, ver Notificaciones.

No hay un estado previsualizada separado: previsualizar no muta el estado de la campaña, solo fija destinatarios_estimados: es un prerrequisito de datos para confirmar_campana, no un tránsito.

estado_plantilla_mensaje

codigo allow_edit vigente initial terminal
borrador sí no sí no
vigente no sí no no
retirada no no no sí

Marcas en plantilla_mensaje

  • vigente_desde IS NOT NULL AND vigente_hasta IS NULL equivale a vigente y sostiene la unicidad "una sola plantilla vigente por código y canal" (impuesta en trigger, no en constraint, ver Catálogos)