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.
- Régimen: Mutable rastreada
- Volumen estimado: exactamente siete filas por comercio
- Origen:
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.
- Régimen: Mutable rastreada
- Volumen estimado: 7-21 filas por comercio
- Origen:
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_almuerzocierre_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 |
INSERTUPDATE |
BEFORE |
ROW |
La franja debe caber en comercio_horario.franja_atencion de su día |
RF-C-18 |
trg_ventanarecogida_duracion |
INSERTUPDATE |
BEFORE |
ROW |
Duración entre ventana.duracion_min_minutos y _max_minutos |
RF-C-18 |
trg_ventanarecogida_maximo_dia |
INSERTUPDATE |
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.