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:
varcharyCHECKcon 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 enCatá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.
- Régimen: Mutable rastreada
- Volumen estimado: 3-8 filas por dominio
- Origen:
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 |
INSERTUPDATE 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 |
INSERTUPDATE |
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¶
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.
- Régimen: Mutable rastreada
- Volumen estimado: 5-15 filas por dominio
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 | admincomerciosistemauser |
|
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 NULLdelimita 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 NULLdelimita 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,COALESCEderetirada_at/cancelada_at/expirada_at) equivale a que el estado actual no tengafinalizadaretencion_expira_atsostiene el barrido deretenidavencidasvalidacion_hasta_atsostiene el barrido deactivavencidas
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 NULLequivale acanjeable
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 NULLequivale apublic
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 NULLdelimita la incidencia abierta; combinada conultimo_movimiento_atsostiene 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 NULLsostiene 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 NULLes la cola de envío (idx_notificacion_cola)fallida_at IS NOT NULLsostiene 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 NULLequivale avigentey sostiene la unicidad "una sola plantilla vigente por código y canal" (impuesta en trigger, no en constraint, verCatálogos)