Saltar a contenido

Horarios

Dos tablas con la mayor densidad de restricciones del módulo. Es donde se resuelve la lógica temporal que después consumen las publicaciones.

La jerarquía es estricta: el horario de atención acota a la ventana de recogida. La programación semanal de cada plantilla y las excepciones de calendario que sustituyen puntualmente esa programación para una fecha concreta dependen además de plantilla_oferta, así que se documentan en Ofertas para no formar una dependencia circular entre los dos módulos.

Requerimientos cubiertos


comercio_horario

Horario de atención por día de la semana. Es la restricción maestra: ninguna ventana de recogida puede quedar fuera de él.

Columnas

Campo Tipo Nulo Descripción
comercio_id uuid no Llave foránea RESTRICT
dia_semana smallint no 0 = domingo … 6 = sábado
cerrado boolean no
hora_apertura time sí Nulo si cerrado
hora_cierre time sí Nulo si cerrado
franja_atencion int4range sí Generada virtual

Columna generada franja_atencion

franja_atencion int4range
GENERATED ALWAYS AS (
  CASE WHEN cerrado THEN NULL
  ELSE int4range(
    minutos_desde_medianoche(hora_apertura),
    minutos_desde_medianoche(hora_cierre)
  )
  END
)
VIRTUAL

El CASE es obligatorio: Postgres no trata un límite NULL de int4range(...) como rango vacío, lo trata como sin límite. Sin el CASE, un día cerrado (con hora_apertura/hora_cierre en NULL por diseño) quedaría representado como abierto las 24 horas, justo lo opuesto de lo que necesita cualquier validación que compare una ventana_recogida.franja contra este campo. Es VIRTUAL, no STORED: nada la indexa directamente, solo la lee trg_ventanarecogida_dentro_horario.

Llaves foráneas

Columna Referencia ON DELETE Notas
comercio_id comercio RESTRICT El horario acompaña al comercio siempre

Constraints

CONSTRAINT chk_comerciohorario_diasemana CHECK (dia_semana BETWEEN 0 AND 6)

CONSTRAINT chk_comerciohorario_horario_coherente
CHECK (
  (cerrado AND hora_apertura IS NULL AND hora_cierre IS NULL)
  OR
  (
    NOT cerrado AND
    hora_apertura IS NOT NULL AND
    hora_cierre IS NOT NULL AND
    hora_apertura < hora_cierre
  )
)

Una sola constraint cubre las dos direcciones a la vez: un día cerrado sin limpiar sus horas viejas, o un día abierto con alguna hora nula o desordenada, ninguno de los dos es representable. Implementa además la decisión de no permitir el cruce de medianoche: un local que cierra a las 00:30 configura dos filas, sábado hasta 23:59 y domingo desde 00:00.

Unicidad

Nombre Definición Propósito
unq_comerciohorario_comercio_dia (comercio_id, dia_semana) Un día de la semana no se repite

Esta unicidad impide que sobren filas; que no falten lo impone trg_comercio_horario_completo, un constraint trigger diferido sobre comercio descrito en Comercios. Las dos juntas son lo que hace cierto el "exactamente siete filas por comercio" de la cabecera.

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_comerciohorario_ventanas_contenidas UPDATE BEFORE ROW Reducir o cerrar el día está bloqueado si deja fuera una ventana activa RF-C-17

Es la dirección inversa de la restricción maestra, y la que RF-C-17 enuncia explícitamente: "reducir el horario está bloqueado si dejaría fuera alguna ventana activa, y el sistema nombra cuáles". El trigger recorre las ventana_recogida activas del mismo (comercio_id, dia_semana), junta en el mensaje de error las etiquetas de las que ya no caben en la franja nueva, y rechaza.

Sin este trigger, la restricción maestra solo se impone en el momento de crear la ventana, y basta con encoger el horario después para dejar publicaciones abriendo su ventana con el local cerrado.


ventana_recogida

Franja de retiro configurada por el comercio, por día de la semana. Hasta tres por día, sin solapamiento.

Columnas

Campo Tipo Nulo Descripción
comercio_id uuid no Llave foránea RESTRICT
dia_semana smallint no 0-6
etiqueta varchar(40) no cierre_almuerzo
cierre_noche
hora_inicio time no
hora_fin time no
franja int4range no Generada almacenada, minutos desde medianoche

franja es STORED, a diferencia de comercio_horario.franja_atencion: forma parte del índice GiST de la restricción de exclusión de abajo, y una columna generada solo puede indexarse cuando se materializa en la escritura.

Llaves foráneas

Columna Referencia ON DELETE Notas
comercio_id comercio RESTRICT

Constraints

CONSTRAINT chk_ventanarecogida_diasemana CHECK (dia_semana BETWEEN 0 AND 6)
CONSTRAINT chk_ventanarecogida_etiqueta CHECK (length(etiqueta) > 0)
CONSTRAINT chk_ventanarecogida_horas_orden CHECK (hora_inicio < hora_fin)

CONSTRAINT chk_ventanarecogida_duracion_absoluta
CHECK (
  (extract(epoch from (hora_fin - hora_inicio)) / 60) BETWEEN 15 AND 240
)

CONSTRAINT excl_ventanarecogida_solape
EXCLUDE USING gist (
  comercio_id WITH =,
  dia_semana  WITH =,
  franja      WITH &&
) WHERE (is_active)

chk_ventanarecogida_duracion_absoluta fija una cota fija de 15 a 240 minutos, independiente de parametro_sistema. El límite real y ajustable (ventana.duracion_min_minutos/ventana.duracion_max_minutos) lo impone trg_ventanarecogida_duracion, porque un CHECK no puede consultar otra tabla; pero esa cota absoluta sigue rechazando cualquier ventana fuera de 15-240 minutos incluso si parametro_sistema llegara a configurarse mal.

excl_ventanarecogida_solape es una de las invariantes críticas del sistema. Requiere btree_gist para combinar igualdad sobre uuid y smallint con solapamiento sobre int4range en la misma restricción, y es el único mecanismo correcto bajo concurrencia: dos altas simultáneas con horarios superpuestos no pueden pasar ambas, a diferencia de una comprobación previa en Python.

Unicidad

No declara ninguna además de la exclusión de arriba, que ya cumple ese rol para las filas activas.

Triggers

Nombre Evento Momento Nivel Regla Origen
trg_ventanarecogida_dentro_horario INSERT
UPDATE
BEFORE ROW La franja debe caber en comercio_horario.franja_atencion de su día RF-C-18
trg_ventanarecogida_duracion INSERT
UPDATE
BEFORE ROW Duración entre ventana.duracion_min_minutos y _max_minutos RF-C-18
trg_ventanarecogida_maximo_dia INSERT
UPDATE
BEFORE ROW Ventanas activas del día dentro de ventana.max_por_dia RF-C-20
trg_ventanarecogida_desactivar UPDATE OF is_active BEFORE ROW No se desactiva una ventana con publicaciones vivas que la referencian RF-C-20

trg_ventanarecogida_dentro_horario

Es la restricción maestra, la regla que la cabecera de esta página enuncia como la jerarquía estricta del módulo. El horario de atención y las ventanas de recogida son dos tablas independientes, así que sin este trigger nada impediría configurar una ventana de 22:00 a 23:00 un domingo que el local tiene marcado como cerrado.

-- BEFORE INSERT OR UPDATE, ROW

SELECT cerrado, franja_atencion
INTO   horario
FROM   comercio_horario
WHERE  comercio_id = NEW.comercio_id
  AND  dia_semana  = NEW.dia_semana
  AND  is_active;

IF NOT FOUND THEN
    RAISE check_violation
    USING COLUMN = 'dia_semana',
          MESSAGE = 'El comercio no tiene horario configurado para ese día.';
END IF;

IF horario.cerrado THEN
    RAISE check_violation
    USING COLUMN = 'dia_semana',
          MESSAGE = 'El comercio está cerrado ese día.';
END IF;

IF NOT (horario.franja_atencion @> NEW.franja) THEN
    RAISE check_violation
    USING COLUMN = 'hora_inicio',
          MESSAGE = 'La ventana queda fuera del horario de atención del día.';
END IF;

El operador @> entre dos int4range es lo que hace trivial la comprobación, y es la razón de que las dos tablas expresen sus horas como rangos de minutos en vez de como pares de time. El CASE de franja_atencion descrito arriba es lo que impide que un día cerrado se lea como abierto las 24 horas: sin él, @> devolvería verdadero siempre.

La consecuencia de que falte no es una ventana fea en un panel: es una publicación con ventana_inicio_at derivado de una ventana imposible, un usuario con una reserva cuya ventana de retiro cae con el local cerrado, y una expiración por no presentación que el comercio provocó sin saberlo.

trg_ventanarecogida_maximo_dia

excl_ventanarecogida_solape garantiza que las ventanas activas de un día no se pisen, pero no cuántas puede haber: catorce ventanas de quince minutos caben sin solaparse en una tarde. El tope de tres que fija RF-C-20 es configurable por RF-A-40, así que es regla parametrizada y no CHECK.

trg_ventanarecogida_desactivar

RF-C-20 dice que "una ventana desactivada libera su franja horaria pero conserva las publicaciones históricas que la referencian". Las históricas, no las vivas: desactivar la ventana de una publicación que todavía admite reservas dejaría a esa publicación apuntando a una franja que ya no existe y liberaría el hueco para una ventana nueva superpuesta. El trigger rechaza cuando queda alguna publicacion de esa ventana con cerrada_at IS NULL.