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.
- Régimen: Mutable protegida
- Volumen estimado: 100-500 filas diarias
- Origen:
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_idUPDATE OF comercio_idUPDATE 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 |
INSERTUPDATE |
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.
- Régimen: Mutable protegida
- Volumen estimado: decenas de filas diarias
- Origen:
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.
- Régimen: Mutable protegida
- Volumen estimado: decenas de filas diarias
- Origen:
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 | usuariocomercioadministracionsistema |
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 |
INSERTUPDATE |
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 marcarespondido_ates unUPDATEreal, incompatible conappend-onlypuro. - 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.
- Régimen: Mutable protegida
- Volumen estimado: decenas de filas diarias
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
tipo |
varchar(16) |
no | moderacioncumplimientoseguridad |
codigo |
varchar(40) |
no | Código del disparador, ver alerta_especificacion |
severidad |
varchar(12) |
no | informativaadvertenciacritica |
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 |
INSERTUPDATE |
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_indicadorsobre umbral en las ventanas de 14 y 30 días (desdeevaluar_alertas_cumplimiento, corrido justo después decalcular_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.