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¶
RF-C-03RF-C-05RF-C-06RF-C-07RF-C-15RF-C-16RF-C-22RF-C-51RF-A-06RF-A-07RF-A-08RF-A-09RF-A-10RF-A-11RF-A-12RF-A-13RF-A-14RF-A-15RF-A-16RF-A-31RF-A-44
comercio¶
Ficha del comercio y su configuración de publicación.
- Régimen: Mutable rastreada
- Volumen estimado: 30-80 filas
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 |
INSERTUPDATE 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.
- Régimen: Mutable protegida
- Origen:
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 |
INSERTUPDATE |
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.
- Régimen: Mutable protegida
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
comercio_id |
uuid |
no | Llave foránea RESTRICT |
campo |
varchar(30) |
no | nombre_comercialcategoria_comidadireccion_textoubicacion |
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.
- Régimen: Mutable rastreada
- Origen:
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 |
INSERTUPDATE |
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.
- Régimen: Mutable protegida
- Origen:
Columnas¶
| Campo | Tipo | Nulo | Descripción |
|---|---|---|---|
comercio_id |
uuid |
no | Llave foránea RESTRICT |
tipo_duracion |
varchar(14) |
no | hoyhasta_fechaindefinida |
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.
- Régimen:
append-only - Origen:
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.