Saltar a contenido

Comercios

Ficha del comercio, expediente de afiliación, historial de estados, cola de cambios sensibles, pausas e indicadores de cumplimiento.

La fila de comercio se crea al enviar el formulario público, en estado pendiente_revision. Eso hace que la unicidad de RUC y la detección de duplicados operen desde el primer instante.

Requerimientos cubiertos


comercio

Ficha del comercio y su configuración de publicación.

Columnas

Campo Tipo Nulo Predeterminado Descripción
nombre_comercial varchar(100) no
slug varchar(150) no Identificador legible para la ruta pública
ruc_cedula varchar(30) no
tipo_negocio_id uuid no
categoria_comida_id uuid no Categoría principal para filtros
estado_id uuid no Llave foránea a estado_comercio
descripcion text no '' Máx. 300 caracteres
direccion_texto varchar(255) no
referencias_llegada varchar(150) no ''
ubicacion geography(Point,4326) sí Nulo solo antes de completar el formulario
zona_id uuid sí
telefono_negocio varchar(20) no Interno
telefono_publico varchar(20) no '' Visible en la app, opcional
logo_id uuid sí Referencia a media, ON DELETE SET NULL
calificacion_promedio numeric(3,2) sí Denormalizada
calificacion_conteo integer no 0 Denormalizada
hora_generacion_publicacion time sí Alternativa mutuamente excluyente con la de abajo
horas_antes_generacion smallint sí Alternativa a la hora fija
reservable_desde_generacion boolean no false Abrir reservas al generar
horas_antes_reservable smallint no 8
afiliado_at timestamptz sí Instante de aprobación
ultima_publicacion_at timestamptz sí Denormalizada, alimenta el recordatorio diario

Llaves foráneas

Columna Referencia ON DELETE Notas
estado_id estado_comercio RESTRICT
tipo_negocio_id tipo_negocio RESTRICT
categoria_comida_id categoria_comida RESTRICT
zona_id zona RESTRICT
logo_id media SET NULL Retirar el logo no borra la ficha

Constraints

CONSTRAINT chk_comercio_descripcion_longitud CHECK (length(descripcion) <= 300)

CONSTRAINT chk_comercio_slug_format
CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$')

CONSTRAINT chk_comercio_regla_generacion
CHECK (num_nonnulls(hora_generacion_publicacion, horas_antes_generacion) = 1)

CONSTRAINT chk_comercio_horas_antes_reservable
CHECK (horas_antes_reservable BETWEEN 0 AND 48)

CONSTRAINT chk_comercio_calificacion_coherente
CHECK ((calificacion_promedio IS NULL) = (calificacion_conteo = 0))

Unicidad

Nombre Definición Propósito
unq_comercio_slug (slug) Ruta pública estable
unq_comercio_ruccedula (ruc_cedula) RF-C-05

Índices

Nombre Definición Propósito
gist_comercio_ubicacion GIST (ubicacion) Mapa y filtros por radio
gin_comercio_nombre GIN (upper(immutable_unaccent(nombre_comercial)) gin_trgm_ops) Búsqueda por texto en la app y en el buscador administrativo
idx_comercio_directorio (estado_id, categoria_comida_id, zona_id) Filtros combinados del directorio
idx_comercio_ranking (calificacion_promedio DESC NULLS LAST) Ranking de comercios

No hay un índice dedicado sobre ultima_publicacion_at todavía: un barrido diario sobre el volumen de comercios afiliados no lo necesita a esta escala.

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_comercio_zona INSERT
UPDATE OF ubicacion
BEFORE ROW Deriva zona_id de la zona cuyo polígono contiene la ubicación RF-A-47
trg_comercio_duplicado INSERT AFTER ROW Marca la solicitud como posible duplicado por nombre y proximidad RF-C-05
trg_comercio_horario_completo INSERT AFTER ROW, diferido El comercio debe tener exactamente siete filas de comercio_horario al cerrar la transacción RF-C-17

trg_comercio_zona es la contraparte de gist_zona_poligono, que la ficha de Catálogos ya describe como el índice que "sostiene la derivación automática de comercio.zona_id". El trigger busca la zona activa cuyo polígono cubre ubicacion y la escribe; si no hay ninguna, deja la columna nula, que es su valor de ausencia honesto. Corre también en el UPDATE de la ubicación, que es lo que RF-A-13 llama "rederiva la zona" al aprobar un cambio de pin.

trg_comercio_horario_completo es un constraint trigger diferido, no un trigger corriente: en el momento del INSERT del comercio todavía no existe ninguna fila de horario, así que la comprobación tiene que ocurrir al COMMIT. Es el único de todo el modelo que necesita ese mecanismo, y lo necesita porque RF-C-17 define el horario de atención como una propiedad total del comercio (siete días, ni uno menos), no como una colección opcional. unq_comerciohorario_comercio_dia impide que sobren; solo este impide que falten.

Notas de diseño

calificacion_promedio admite nulo y calificacion_conteo no. Un comercio sin valoraciones no tiene promedio cero, no tiene promedio: la diferencia importa en el ranking, donde NULLS LAST deja abajo a los comercios nuevos en lugar de hundirlos como si tuvieran la peor nota. Las dos columnas las mantiene trg_valoracion_calificacion, no la capa de aplicación: chk_comercio_calificacion_coherente es la red de seguridad de D-17, no el mecanismo.

La detección de duplicados por proximidad no puede expresarse como EXCLUDE: la distancia geodésica combinada con similitud de texto no es un operador de solapamiento indexable en ese sentido. trg_comercio_duplicado combina ST_DWithin sobre gist_comercio_ubicacion con similarity() sobre gin_comercio_nombre, los dos índices que la tabla ya tiene, y escribe posible_duplicado/duplicado_de_comercio_id en la solicitud. Al vivir en un trigger en vez de una constraint, no es una garantía absoluta bajo concurrencia extrema (dos altas simultáneas a 30 m podrían pasar ambas), pero el efecto de un falso negativo es una revisión manual de más, no una inconsistencia de datos.


solicitud_afiliacion

Expediente de revisión. Relación 1:1 con comercio. Existe como tabla separada porque su ciclo de vida termina (aprobada o rechazada) mientras el comercio sigue vivo, y porque contiene datos del solicitante que no forman parte de la ficha pública.

Columnas

Campo Tipo Nulo Descripción
comercio_id uuid no Llave foránea RESTRICT, única
estado_id uuid no Llave foránea a estado_solicitud_afiliacion
solicitante_id uuid no Llave foránea RESTRICT a usuario; quien envió el formulario
responsable_nombre varchar(120) no
responsable_cargo varchar(80) no ''
responsable_telefono varchar(20) no
responsable_email varchar no
telefono_verificado_at timestamptz sí RF-C-04
email_verificado_at timestamptz sí RF-C-04
fachada_id uuid no Obligatoria
licencia_id uuid sí Opcional, máx. 5 MB
horario_propuesto jsonb no Lista de 7 objetos, uno por día; ver el trigger de especificación abajo
fuera_de_cobertura boolean no Calculado contra zona_cobertura
posible_duplicado boolean no Calculado por proximidad
duplicado_de_comercio_id uuid sí Candidato detectado
informacion_requerida text no Máx. 500 caracteres
vence_at timestamptz no Creación más 15 días
resuelta_at timestamptz sí Marca de cierre del expediente
resuelta_by uuid sí Llave foránea RESTRICT a usuario
motivo_rechazo_id uuid sí Llave foránea RESTRICT a motivo
nota_resolucion text no ''
puede_reaplicar_desde timestamptz sí resuelta_at más 15 días

solicitante_id no viene de una versión anterior de docs/: quien llena el formulario debe estar autenticado, para poder crear la membresía inicial de usuario_comercio cuando se aprueba la solicitud.

Constraints

CONSTRAINT chk_solicitudafiliacion_informacion_longitud
CHECK (length(informacion_requerida) <= 500)

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

CONSTRAINT chk_solicitudafiliacion_duplicado_coherente
CHECK (duplicado_de_comercio_id IS NULL OR posible_duplicado)

CONSTRAINT chk_solicitudafiliacion_horario_propuesto_tipo
CHECK (jsonb_typeof(horario_propuesto) = 'array')

CONSTRAINT chk_solicitudafiliacion_vence
CHECK (vence_at > created_at)

CONSTRAINT chk_solicitudafiliacion_reaplicar
CHECK (
  puede_reaplicar_desde IS NULL
  OR (resuelta_at IS NOT NULL AND puede_reaplicar_desde > resuelta_at)
)

CONSTRAINT chk_solicitudafiliacion_duplicado_distinto
CHECK (duplicado_de_comercio_id IS NULL OR duplicado_de_comercio_id <> comercio_id)

La tercera parece obvia y no lo es: trg_comercio_duplicado busca candidatos por proximidad y similitud de nombre, y el comercio de la propia solicitud es el candidato perfecto para sí mismo. Sin la restricción, un error en la consulta del trigger produciría un expediente que se señala a sí mismo como duplicado, que es indistinguible de un duplicado real en la bandeja de RF-A-06.

Unicidad

Nombre Definición
unq_solicitudafiliacion_comercio (comercio_id)

Índices

Es el índice de apoyo del proceso que vence las solicitudes a los 15 días.

Nombre Definición Propósito
idx_solicitudafiliacion_vence (vence_at) WHERE resuelta_at IS NULL Vencimiento a los 15 días

No hay un índice dedicado para la bandeja ordenada por antigüedad todavía; con el volumen de un piloto, created_at (ya indexada por la columna estándar) alcanza.

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_solicitudafiliacion_horario_especificacion INSERT
UPDATE
BEFORE ROW Forma completa de horario_propuesto: 7 días, sin repetir, coherentes RF-C-03
trg_solicitudafiliacion_verificacion UPDATE BEFORE ROW No se resuelve como aprobada sin ambos canales verificados RF-C-04
trg_solicitudafiliacion_media INSERT BEFORE ROW fachada debe ser media.tipo = 'fachada' y licencia, 'licencia' RF-C-03
trg_solicitudafiliacion_reapertura UPDATE BEFORE ROW Limpiar resuelta_at exige haber pasado puede_reaplicar_desde RF-C-07

trg_solicitudafiliacion_horario_especificacion valida la forma completa de horario_propuesto: exige exactamente 7 elementos, cada uno con dia_semana numérico entre 0 y 6 sin repetir, cerrado booleano, y hora_apertura/hora_cierre como texto presentes si y solo si el día no está cerrado. Es el mismo problema que resuelven trg_parametrosistema_especificacion y trg_media_variantes_especificacion en Catálogos: un jsonb de forma libre necesita su propia barrera porque ningún CHECK declarativo puede validar la estructura completa de un arreglo.

trg_solicitudafiliacion_verificacion es la traducción de la palabra "obligatoria" de RF-C-04. Lee el estado destino y, si tiene apply-equivalente (es decir, si es el estado aprobado del catálogo), rechaza cuando telefono_verificado_at o email_verificado_at siguen nulos. Sin él, un expediente se puede aprobar con datos de contacto que nadie comprobó, y el correo con el enlace de creación de contraseña de RF-C-07 sale hacia una dirección no verificada.

trg_solicitudafiliacion_reapertura cubre el caso raro pero real de RF-C-07: reaplicar reabre el expediente anterior, porque la relación con comercio es 1:1 y unq_comercio_ruccedula impide crear otro comercio con el mismo RUC. Reabrir es limpiar resuelta_at, y eso solo puede pasar cuando ya se cumplieron los quince días que la propia fila anotó en puede_reaplicar_desde.

Notas de diseño

horario_propuesto se guarda como evidencia de lo que el solicitante declaró, y no se normaliza en comercio_horario hasta la aprobación: si un comercio alega que se le aprobó con otro horario, la solicitud es la que responde.


solicitud_cambio_comercio

Cambios sensibles que no se aplican de inmediato: el valor anterior sigue publicado hasta que administración apruebe.

Columnas

Campo Tipo Nulo Descripción
comercio_id uuid no Llave foránea RESTRICT
campo varchar(30) no nombre_comercial
categoria_comida
direccion_texto
ubicacion
valor_anterior text no Serializado; la ubicación como WKT
valor_propuesto text no
distancia_m numeric(8,2) sí Solo si campo = 'ubicacion'
estado_id uuid no Llave foránea a estado_solicitud_cambio
solicitada_by uuid no Llave foránea RESTRICT a usuario
resuelta_at timestamptz sí Marca de cierre
resuelta_by uuid sí Llave foránea RESTRICT a usuario
motivo_rechazo_id uuid sí Llave foránea RESTRICT a motivo

Constraints

CONSTRAINT chk_solicitudcambiocomercio_distancia_coherente
CHECK ((campo = 'ubicacion') = (distancia_m IS NOT NULL))

CONSTRAINT chk_solicitudcambiocomercio_distancia_umbral
CHECK (distancia_m IS NULL OR distancia_m > 100)

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

CONSTRAINT chk_solicitudcambiocomercio_valor_distinto
CHECK (valor_propuesto <> valor_anterior)

Unicidad

Nombre Definición Propósito
unq_solicitudcambiocomercio_pendiente (comercio_id, campo) WHERE resuelta_at IS NULL No puede haber dos propuestas simultáneas del mismo campo

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_solicitudcambiocomercio_snapshot INSERT BEFORE ROW Deriva valor_anterior del valor vigente de comercio.<campo> RF-A-13
trg_solicitudcambiocomercio_aplica UPDATE AFTER ROW Al resolverse con un estado apply, escribe valor_propuesto en comercio RF-A-13

trg_solicitudcambiocomercio_snapshot pertenece a la familia de derivación de snapshot: sobrescribe la columna en vez de comparar contra ella. RF-A-13 pide mostrar "el valor anterior y el propuesto lado a lado", y un valor anterior que el llamador declara no prueba nada sobre lo que la ficha pública decía cuando se propuso el cambio.

trg_solicitudcambiocomercio_aplica es la traducción directa de lo que estado_solicitud_cambio.apply significa: el catálogo declara qué estados aplican el cambio, y el trigger lo ejecuta en la misma transacción que marca la solicitud como resuelta. Escribe en comercio la columna que nombra campo, y para ubicacion deserializa el WKT del valor_propuesto, lo que dispara a su vez trg_comercio_zona y rederiva la zona sin ninguna coordinación adicional.

Notas de diseño

chk_solicitudcambiocomercio_distancia_umbral codifica que mover el pin menos de 100 m se aplica directo y por tanto no genera fila aquí: la capa de API resuelve ese atajo antes de escribir, y la constraint cierra la vía por la que un INSERT podría dejar una fila con un umbral que no corresponde. Es deliberado que el atajo no sea un trigger: aplicar el cambio directo o encolarlo son dos operaciones distintas, y elegir entre ellas es una decisión del llamador, no un invariante de la fila.


comercio_foto

Adjunto fotográfico del comercio.

Columnas

comercio_id con llave foránea CASCADE (una foto no significa nada fuera de su comercio y nadie más la referencia), media_id con RESTRICT, y orden (smallint, 0 por defecto): la secuencia que el comercio eligió para su galería, no el orden de inserción.

Unicidad

Nombre Definición Estado
unq_comerciofoto_comercio_media (comercio_id, media_id) Vigente

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_comerciofoto_maximo INSERT
UPDATE
BEFORE ROW Hasta cinco fotos activas por comercio, y media.tipo = 'foto_local' RF-C-15

Notas de diseño

La galería de RF-C-15 admite hasta cinco fotos por comercio. Ese tope no es una unicidad ni un CHECK: cuenta filas hermanas, así que es trigger. La ficha anterior documentaba una unq_comerciofoto_comercio que habría limitado a una foto por comercio; no existe en el código y no debe crearse, porque contradice el requerimiento.


pausa_comercio

Pausa temporal declarada por el propio comercio.

Columnas

Campo Tipo Nulo Descripción
comercio_id uuid no Llave foránea RESTRICT
tipo_duracion varchar(14) no hoy
hasta_fecha
indefinida
inicio_at timestamptz no
fin_at timestamptz sí Obligatorio si y solo si tipo_duracion = 'hasta_fecha'
motivo_id uuid sí Llave foránea RESTRICT a motivo
nota text no ''
reservas_pendientes_al_pausar smallint no Congelado al crear
creada_by uuid no Llave foránea RESTRICT a usuario
finalizada_at timestamptz sí Marca de cierre

Constraints

CONSTRAINT chk_pausacomercio_fin_coherente
CHECK ((tipo_duracion = 'indefinida') = (fin_at IS NULL))

CONSTRAINT chk_pausacomercio_fin_posterior
CHECK (fin_at IS NULL OR fin_at > inicio_at)

Unicidad

Nombre Definición Propósito
unq_pausacomercio_vigente (comercio_id) WHERE finalizada_at IS NULL Una sola pausa vigente

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_pausacomercio_reservas_vivas INSERT BEFORE ROW Deriva reservas_pendientes_al_pausar y rechaza la pausa si no es cero RF-C-22
trg_pausacomercio_fin INSERT BEFORE ROW tipo_duracion = 'hoy' resuelve fin_at al cierre del día operativo RF-C-22

trg_pausacomercio_reservas_vivas hace las dos cosas a la vez, y por eso es un solo trigger: cuenta las reservas del comercio con finalizada_at IS NULL, escribe el número en reservas_pendientes_al_pausar y rechaza si es mayor que cero. RF-C-22 dice que el sistema "muestra el contador de reservas vivas y exige resolverlas antes de permitir la pausa", así que el contador siempre queda en cero en las filas que llegan a existir; su valor está en que el número lo escribió la base y no el llamador, de modo que la columna no puede mentir sobre por qué se admitió la pausa.

chk_pausacomercio_fin_coherente solo exige fin_at IS NULL para tipo_duracion = 'indefinida'; tanto hasta_fecha como hoy exigen un fin_at resuelto. Para hasta_fecha lo trae el llamador; para hoy, trg_pausacomercio_fin lo deriva al cierre del día operativo en America/Managua, que es la duración "hoy" que pide RF-C-22.


comercio_indicador

Instantánea diaria de las tasas de cumplimiento por ventana móvil.

Columnas

Campo Tipo Nulo Descripción
comercio_id uuid no Llave foránea RESTRICT
ventana_dias smallint no 14 o 30
calculado_para date no Día de cierre de la ventana
reservas_base integer no Denominador de las tasas
tasa_entrega numeric(6,2) sí Nulo si reservas_base = 0, nunca cero
tasa_cancelacion numeric(6,2) sí
tasa_expiracion numeric(6,2) sí
incidencias_por_100 numeric(6,2) sí Sin cota superior de rango

Constraints

CONSTRAINT chk_comercioindicador_ventanadias CHECK (ventana_dias IN (14, 30))

CONSTRAINT chk_comercioindicador_base_coherente
CHECK ((reservas_base = 0) = (tasa_entrega IS NULL))

CONSTRAINT chk_comercioindicador_tasas_juntas
CHECK (
  num_nonnulls(tasa_entrega, tasa_cancelacion, tasa_expiracion, incidencias_por_100)
  IN (0, 4)
)

CONSTRAINT chk_comercioindicador_tasa_entrega_rango
CHECK (tasa_entrega IS NULL OR tasa_entrega BETWEEN 0 AND 100)

CONSTRAINT chk_comercioindicador_tasa_cancelacion_rango
CHECK (tasa_cancelacion IS NULL OR tasa_cancelacion BETWEEN 0 AND 100)

CONSTRAINT chk_comercioindicador_tasa_expiracion_rango
CHECK (tasa_expiracion IS NULL OR tasa_expiracion BETWEEN 0 AND 100)

CONSTRAINT chk_comercioindicador_reservas_base CHECK (reservas_base >= 0)

incidencias_por_100 queda fuera de chk_comercioindicador_tasas_rango: no está acotada a 100 como las otras tres, así que se agrupa con ellas solo en la constraint "juntas o ninguna".

Unicidad

Nombre Definición
unq_comercioindicador_comercio_ventana_fecha (comercio_id, ventana_dias, calculado_para)

Notas de diseño

Las tasas admiten nulo y el CHECK obliga a que las cuatro lo sean a la vez: un comercio sin reservas en la ventana no tiene tasa de entrega del 0 %, no tiene tasa. Escribir cero lo haría aparecer en la alerta de cumplimiento como el peor comercio de la plataforma por el mérito de no haber vendido nada.