Saltar a contenido

Reputación

Valoraciones, respuestas del comercio, incidencias y alertas operativas.

Dos decisiones estructurales gobiernan el módulo. La primera: una valoración pertenece a una reserva entregada, no a un comercio, y su llave foránea a reserva es única por construcción (OneToOneField). Eso hace imposible por construcción valorar sin haber comprado y valorar dos veces la misma compra, sin escribir ninguna comprobación. La segunda: alerta es la única referencia polimórfica aceptada del modelo, y esta página explica por qué.

Requerimientos cubiertos


valoracion

Calificación de una entrega. Relación 1:1 con reserva.

Columnas

Campo Tipo Nulo Descripción
reserva_id uuid no Llave foránea RESTRICT, única
comercio_id uuid no Denormalizado: el promedio se agrupa por comercio
perfil_id uuid no Denormalizado, a perfil_consumidor
puntuacion smallint no 1-5
comentario text no '', máx. 300 caracteres
estado_id uuid no Llave foránea a estado_moderacion
oculta_at timestamptz sí Marca de invisibilidad
motivo_moderacion_id uuid sí Llave foránea RESTRICT a motivo
moderada_by uuid sí Llave foránea RESTRICT a usuario
moderada_at timestamptz sí
plazo_hasta_at timestamptz no Snapshot del plazo de valoración
editada_at timestamptz sí

Llaves foráneas

Columna Referencia ON DELETE Notas
reserva_id reserva RESTRICT Relación 1:1
comercio_id comercio RESTRICT Copiado de la reserva y de solo lectura
perfil_id perfil_consumidor RESTRICT Copiado de la reserva y de solo lectura
estado_id estado_moderacion RESTRICT
motivo_moderacion_id motivo RESTRICT
moderada_by usuario RESTRICT

Constraints

CONSTRAINT chk_valoracion_puntuacion CHECK (puntuacion BETWEEN 1 AND 5)
CONSTRAINT chk_valoracion_comentario_longitud CHECK (length(comentario) <= 300)

CONSTRAINT chk_valoracion_moderacion_coherente
CHECK ((moderada_at IS NULL) = (moderada_by IS NULL))

CONSTRAINT chk_valoracion_oculta_con_motivo
CHECK (oculta_at IS NULL OR motivo_moderacion_id IS NOT NULL)

CONSTRAINT chk_valoracion_editada_plazo
CHECK (editada_at IS NULL OR editada_at <= plazo_hasta_at)

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_readonly_snapshot UPDATE OF reserva_id
UPDATE OF comercio_id
UPDATE OF perfil_id
BEFORE ROW Las tres columnas de origen son inmutables D-10
trg_valoracion_reserva_retirada INSERT BEFORE ROW La reserva debe estar retirada y el plazo, abierto RF-U-42
trg_valoracion_snapshot INSERT BEFORE ROW Deriva comercio_id, perfil_id y plazo_hasta_at de la reserva D-16
trg_valoracion_edicion UPDATE BEFORE ROW Una sola edición de puntuacion/comentario, dentro del plazo RF-U-42
trg_valoracion_calificacion INSERT
UPDATE
AFTER ROW Recalcula comercio.calificacion_promedio y calificacion_conteo D-17
trg_valoracion_alerta_baja INSERT AFTER ROW puntuacion <= 2 levanta una alerta de moderación RF-A-25

trg_valoracion_reserva_retirada es lo que hace cierta la frase con la que abre este módulo. El OneToOneField hacia reserva hace imposible valorar dos veces la misma compra, pero no hace imposible valorar una compra que nunca ocurrió: nada impide hoy una valoración sobre una reserva cancelada o expirada. El trigger exige retirada_at IS NOT NULL y now() <= plazo_hasta_at.

trg_valoracion_edicion implementa la "sola edición dentro del mismo plazo" de RF-U-42: si puntuacion o comentario cambian, exige que editada_at estuviera en nulo y lo fija. La moderación administrativa (RF-A-26) no cuenta como edición, porque toca otras columnas.

trg_valoracion_calificacion recalcula sobre las valoraciones visibles del comercio (oculta_at IS NULL), de modo que ocultar un comentario por moderación ajusta el promedio en la misma transacción. Cuando no queda ninguna, escribe NULL en el promedio y 0 en el conteo, que es el par que chk_comercio_calificacion_coherente admite.

Notas de diseño

plazo_hasta_at es un snapshot y no se calcula al vuelo: el plazo es configurable, y un usuario cuya reserva se entregó bajo un plazo de 72 h no debería perder la oportunidad porque el parámetro bajó a 48 mientras tanto. Quien lo congela es trg_valoracion_snapshot, leyendo valoracion.plazo_horas una sola vez, al crear la fila.


respuesta_valoracion

Respuesta pública del comercio. Relación 1:1 con valoracion.

Columnas

Campo Tipo Nulo Descripción
valoracion_id uuid no Llave foránea RESTRICT, única
texto text no 1-300 caracteres
estado_id uuid no Llave foránea a estado_moderacion
oculta_at timestamptz sí Marca de invisibilidad
autor_id uuid no Llave foránea RESTRICT a usuario_comercio
moderada_by uuid sí Llave foránea RESTRICT a usuario
moderada_at timestamptz sí
motivo_rechazo_id uuid sí Llave foránea RESTRICT a motivo

Nace en el estado "pendiente"-equivalente, no "publicada": asimetría de negocio deliberada frente a valoracion, que nace publicada. Toda respuesta de comercio pasa por moderación antes de ser visible; la valoración del cliente no.

Constraints

CONSTRAINT chk_respuestavaloracion_texto_longitud
CHECK (length(texto) BETWEEN 1 AND 300)

CONSTRAINT chk_respuestavaloracion_moderacion
CHECK ((moderada_at IS NULL) = (moderada_by IS NULL))

CONSTRAINT chk_respuestavaloracion_rechazo_con_motivo
CHECK (oculta_at IS NULL OR motivo_rechazo_id IS NOT NULL)

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_respuestavaloracion_autor INSERT BEFORE ROW El autor debe ser miembro activo del comercio valorado RF-C-50

La llave foránea a usuario_comercio garantiza que el autor es personal de algún comercio, no del comercio que la valoración juzga. Sin el trigger, el operador de un comercio podría responder públicamente en el hilo de otro.

Una sola respuesta por valoración, garantizada por el OneToOneField, es lo que impide que un intercambio público se convierta en un hilo de discusión que nadie va a moderar.


incidencia

Reporte de un problema con una entrega.

Columnas

Campo Tipo Nulo Descripción
folio varchar(20) no Único, visible para el usuario
reserva_id uuid sí Nulo si el origen no es una reserva concreta
comercio_id uuid no Denormalizado
reportada_by uuid sí Nulo si origen = 'sistema'
origen varchar(16) no usuario
comercio
administracion
sistema
categoria_motivo_id uuid no Llave foránea RESTRICT a motivo
descripcion text no 1-500 caracteres
estado_id uuid no Llave foránea a estado_incidencia
asignada_a uuid sí Llave foránea RESTRICT a usuario
prioridad smallint no 1 = alta, 3 = baja
resolucion text no '', obligatoria al cerrar
resuelta_by uuid sí Llave foránea RESTRICT a usuario
resuelta_at timestamptz sí Marca de cierre
ultimo_movimiento_at timestamptz no Base de la alerta de 48 h

Constraints

CONSTRAINT chk_incidencia_origen
CHECK (origen IN ('usuario', 'comercio', 'administracion', 'sistema'))

CONSTRAINT chk_incidencia_reportante
CHECK ((origen = 'sistema') = (reportada_by IS NULL))

CONSTRAINT chk_incidencia_descripcion_longitud
CHECK (length(descripcion) BETWEEN 1 AND 500)

CONSTRAINT chk_incidencia_prioridad CHECK (prioridad BETWEEN 1 AND 3)

CONSTRAINT chk_incidencia_resolucion_coherente
CHECK ((resuelta_at IS NULL) = (resuelta_by IS NULL))

CONSTRAINT chk_incidencia_resolucion_completa
CHECK (resuelta_at IS NULL OR length(resolucion) > 0)

Unicidad

Nombre Definición Propósito
unq_incidencia_folio (folio) El folio identifica el caso ante el usuario
unq_incidencia_reserva_reportante (reserva_id, reportada_by) WHERE reserva_id IS NOT NULL Un usuario no abre dos incidencias sobre una reserva

Índices

Nombre Definición Propósito
idx_incidencia_abierta_movimiento (ultimo_movimiento_at) WHERE resuelta_at IS NULL Barrido de evaluar_alertas_antiguedad

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_incidencia_snapshot INSERT BEFORE ROW Deriva comercio_id de la reserva cuando la hay D-10
trg_incidencia_reportante INSERT BEFORE ROW El reportante debe corresponder al origen: el usuario de la reserva, o personal activo del comercio RF-U-43
trg_incidencia_movimiento UPDATE BEFORE ROW Fija ultimo_movimiento_at = now() en todo cambio RF-A-32
trg_incidencia_folio INSERT BEFORE ROW Genera el folio si no viene dado RF-U-43
trg_incidencia_inocuidad INSERT
UPDATE
BEFORE ROW La categoría de contenido en mal estado fuerza prioridad = 1 RF-A-35

trg_incidencia_movimiento es el que sostiene la alerta de 48 h. RF-A-32 es explícito en que la antigüedad se calcula "sobre la fecha del último cambio y no sobre la de apertura", y una columna que el llamador puede escribir a mano no mide eso: basta con no actualizarla para que un caso abandonado nunca dispare la alerta. Es el mismo argumento por el que updated_at la mantiene la base y no el ORM.

trg_incidencia_reportante completa lo que chk_incidencia_reportante deja abierto. La constraint exige que haya reportante cuando el origen no es sistema; el trigger exige que sea el reportante correcto: para origen = 'usuario', la cuenta del perfil que hizo la reserva; para origen = 'comercio', un miembro activo del comercio señalado. Sin él, cualquier cuenta puede abrir una incidencia sobre la reserva de otra persona, y unq_incidencia_reserva_reportante no lo impide porque el par sigue siendo distinto.


incidencia_descargo

Descargo del comercio dentro de una incidencia.

  • Régimen: Mutable protegida, igual desviación que factor_impacto: la marca respondido_at es un UPDATE real, incompatible con append-only puro.
  • Volumen estimado: una a tres filas por incidencia
  • Origen:

Columnas

Campo Tipo Nulo Descripción
incidencia_id uuid no Llave foránea RESTRICT
autor_id uuid sí Llave foránea RESTRICT a usuario_comercio
texto text no '', máx. 1000 caracteres
solicitado_at timestamptz no Instante en que se pidió el descargo
vence_at timestamptz no Plazo indicado al comercio
respondido_at timestamptz sí Nulo mientras no haya respuesta

Llaves foráneas

Columna Referencia ON DELETE Notas
incidencia_id incidencia RESTRICT La incidencia no se borra; la cascada nunca correría

Constraints

CONSTRAINT chk_incidenciadescargo_texto_longitud CHECK (length(texto) <= 1000)
CONSTRAINT chk_incidenciadescargo_vigencia CHECK (vence_at > solicitado_at)

CONSTRAINT chk_incidenciadescargo_respuesta
CHECK (respondido_at IS NULL OR (length(texto) > 0 AND autor_id IS NOT NULL))

CONSTRAINT chk_incidenciadescargo_respuesta_plazo
CHECK (respondido_at IS NULL OR respondido_at <= vence_at)

RF-A-34 dice que "si el plazo vence sin respuesta, la incidencia continúa y esa ausencia queda registrada": la ausencia es el valor nulo, y una respuesta escrita después de vence_at la borraría sin dejar rastro de que llegó tarde.

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_readonly_snapshot UPDATE de todo salvo respondido_at BEFORE ROW Solo respondido_at es mutable D-07
trg_incidenciadescargo_abierta INSERT BEFORE ROW No se solicita descargo sobre una incidencia ya resuelta RF-A-34
trg_incidenciadescargo_respuesta UPDATE BEFORE ROW respondido_at se escribe una sola vez y no se deshace RF-A-34

trg_readonly_snapshot cubre incidencia_id, autor_id, texto, solicitado_at y vence_at.

trg_incidenciadescargo_respuesta es la mitad que trg_readonly_snapshot no puede dar: la columna tiene que ser mutable una vez, y solo una vez, y eso es una condición sobre el valor viejo (OLD.respondido_at IS NULL) que un ReadOnly no distingue.


alerta

Señales operativas que exigen revisión humana. Es la única tabla del modelo con referencia polimórfica.

Columnas

Campo Tipo Nulo Descripción
tipo varchar(16) no moderacion
cumplimiento
seguridad
codigo varchar(40) no Código del disparador, ver alerta_especificacion
severidad varchar(12) no informativa
advertencia
critica
entidad_tipo varchar(20) no Ver CHECK
entidad_id uuid no Sin llave foránea
comercio_id uuid sí Denormalizado cuando aplica, para filtrar
datos jsonb no Valores que dispararon la alerta
estado_id uuid no Llave foránea a estado_alerta
revisada_at timestamptz sí Marca de cierre
revisada_by uuid sí Llave foránea RESTRICT a usuario
nota text no ''

tipo/codigo son los nombres reales de lo que las notas de dominio llamaban "familia"/"tipo" en versiones anteriores; esta ficha usa los nombres finales.

Llaves foráneas

Columna Referencia ON DELETE Notas
estado_id estado_alerta RESTRICT
comercio_id comercio RESTRICT
revisada_by usuario RESTRICT
entidad_id n/a n/a Referencia polimórfica, sin llave declarada

Constraints

CONSTRAINT chk_alerta_tipo CHECK (tipo IN ('moderacion', 'cumplimiento', 'seguridad'))
CONSTRAINT chk_alerta_severidad CHECK (severidad IN ('informativa', 'advertencia', 'critica'))

CONSTRAINT chk_alerta_entidad_tipo
CHECK (entidad_tipo IN ('publicacion', 'comercio', 'usuario', 'reserva', 'parametro', 'exportacion'))

CONSTRAINT chk_alerta_revision_coherente
CHECK ((revisada_at IS NULL) = (revisada_by IS NULL))

CONSTRAINT chk_alerta_datos_tipo CHECK (jsonb_typeof(datos) = 'object')

CONSTRAINT chk_alerta_comercio_entidad
CHECK (entidad_tipo <> 'comercio' OR comercio_id = entidad_id)

Cuando la entidad señalada es un comercio, comercio_id y entidad_id nombran lo mismo, y dos columnas que nombran lo mismo sin restricción que las amarre acaban nombrando cosas distintas. Es el único caso del polimorfismo en que las dos columnas son comparables entre sí.

Unicidad

Nombre Definición Propósito
unq_alerta_abierta (codigo, entidad_tipo, entidad_id) WHERE revisada_at IS NULL No se acumulan alertas idénticas sin atender

Ese índice único es la diferencia entre un tablero útil y uno que nadie mira: sin él, el proceso diario de cumplimiento generaría una alerta nueva por comercio y por día mientras la tasa siguiera por encima del umbral.

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_alerta_especificacion INSERT
UPDATE
BEFORE ROW codigo registrado y activo, tipo/entidad_tipo coincidentes, datos con sus claves RF-A-25

Vigente. Busca alerta_especificacion por codigo; si no existe (o está inactiva), rechaza. Si tipo/entidad_tipo no coinciden con los de la especificación, rechaza. Por cada clave de claves_requeridas, exige que datos la traiga. Ver Catálogos para el diseño completo de la tabla de especificación.

Las alertas no las inserta nadie a mano: llegan desde trg_publicacion_alerta_moderacion y trg_valoracion_alerta_baja, o desde los procesos programados de cumplimiento y antigüedad. Este trigger es la barrera común a todos esos caminos.

¿Por qué se acepta el polimorfismo aquí?

Una referencia polimórfica sin llave foránea renuncia a la integridad referencial, y en este modelo eso solo se acepta aquí. Las alternativas son peores: seis columnas nulas con llave foránea cada una (cinco nulos por fila, y agregar un séptimo tipo de entidad exige alterar una tabla en crecimiento), o seis tablas de alerta (el tablero unificado requeriría una unión de seis).

El riesgo real, una alerta que apunta a una entidad inexistente, está acotado porque ninguna de las entidades referenciadas admite DELETE real: todas están protegidas por su régimen de auditoría.

Las tres familias

Cada tipo tiene sus disparadores concretos:

  • Descuento excesivo, precio bajo el mínimo, oferta específica sin foto, cantidad superior a 30, primera publicación de un comercio nuevo (desde trg_publicacion_alerta_moderacion).
  • Valoración baja (desde trg_valoracion_alerta_baja).
  • Incidencia sin movimiento por más de 48 h (desde evaluar_alertas_antiguedad, proceso programado: la espera no es un evento de escritura).
  • Tasas de comercio_indicador sobre umbral en las ventanas de 14 y 30 días (desde evaluar_alertas_cumplimiento, corrido justo después de calcular_indicadores_diarios).
  • Cambio de un parámetro marcado como crítico.

La siembra concreta de alerta_especificacion, con sus claves_requeridas por codigo, es un ejercicio de implementación separado de esta ficha: el modelo y el trigger no dependen de que esa lista esté cerrada.