Volvemos a mirar el plano completo, como prometimos al cerrar el módulo 7. Durante siete módulos has construido BiblioRed a base de piezas: una tabla aquí, un JOIN allá, un índice cuando algo iba lento. Cada pieza se explicaba por separado porque había que aprenderla por separado. En un proyecto real nadie te entrega las piezas ordenadas: te entregan una conversación con un cliente y tienes que llegar tú solo desde ahí hasta un sistema en producción.

Eso es lo que hace esta lección. El ayuntamiento de Vallmar, satisfecho con la red de bibliotecas, acaba de encargar un segundo sistema: VallBici, el servicio municipal de bicicletas compartidas. No es una ampliación de BiblioRed; es un dominio nuevo, con sus propias reglas, sus propios problemas de concurrencia y sus propios informes. BiblioRed aparecerá de vez en cuando como término de comparación —"esto ya lo resolvimos así, aquí cambia porque…"—, pero el trabajo es nuevo de principio a fin.

El recorrido es el de un proyecto real y en su orden real: el encargo, los requisitos escritos, el modelo conceptual, el esquema físico, la comprobación de normalización, la carga de datos, las transacciones críticas, los informes, el rendimiento y la operación. En cada punto donde hay que decidir algo, la decisión va acompañada de su justificación y de las alternativas descartadas. Ese es el contenido de la lección: no el SQL final —que se copia en diez minutos— sino el razonamiento que lleva hasta él.

Y termina donde tiene que terminar un caso honesto: enumerando las tres cosas que este esquema resuelve mal, que son exactamente el material de la lección 08-02.

Contenido

  1. El encargo: la conversación inicial
  2. Documento de requisitos VallBici v1.0
  3. Modelo conceptual: entidades, relaciones y tres decisiones difíciles
  4. El diagrama ER
  5. Esquema físico: el CREATE TABLE comentado
  6. Las restricciones que codifican las reglas de negocio
  7. Comprobación de normalización y dos desnormalizaciones deliberadas
  8. Carga de datos y volumen realista
  9. Las dos transacciones críticas: desbloquear y anclar
  10. Consultas de explotación: los informes del ayuntamiento
  11. Rendimiento: las dos consultas que se degradan
  12. Operación: roles, datos personales y copias
  13. Qué resuelve mal este esquema
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. El encargo: la conversación inicial

La primera reunión con el área de Movilidad del ayuntamiento produce esto, transcrito casi literalmente:

«Tenemos 60 estaciones repartidas por los cinco distritos de la ciudad y 900 bicicletas, unas mecánicas y otras eléctricas. Cada estación tiene un número fijo de anclajes; una bici ocupa un anclaje cuando está aparcada. Hay 24.000 personas abonadas. Una persona desbloquea una bici en una estación, hace su trayecto y la ancla en otra. Hay tres tipos de abono —anual, mensual y turístico de tres días— y si te pasas del tiempo incluido se cobra un recargo. Las bicis pasan revisiones, se averían y se retiran a taller. La aplicación del móvil tiene que enseñar la disponibilidad en tiempo real, recordar la sesión, dejar buscar estaciones por nombre o por dirección y guardar el rastro GPS de cada trayecto. Y queremos informes mensuales de uso por distrito y franja horaria.»

Aplicamos la técnica de 04-01: subrayar los sustantivos (candidatos a entidad), los verbos (candidatos a relación) y las frases con "no puede", "siempre", "cada" (candidatas a restricción). Pero antes hay que hacer algo que en 04-01 insistimos mucho: volver a preguntar. Un encargo de dos párrafos siempre esconde decisiones que el cliente da por evidentes y no lo son.

Pregunta al cliente Respuesta Consecuencia de diseño
¿Un anclaje puede estar averiado sin que lo esté la estación? Sí, pasa a menudo El anclaje necesita estado propio
¿Puede una persona tener dos abonos a la vez? No, nunca solapados; sí consecutivos Restricción de no solapamiento
¿Y dos trayectos abiertos a la vez? Imposible: solo se desbloquea con la app Índice único parcial
Si sube la tarifa, ¿cambia el precio de trayectos ya cobrados? Jamás. Sería ilegal Tarifa congelada en el trayecto
¿Qué pasa si una bici desaparece? Se da de baja, pero su historial se conserva ON DELETE RESTRICT + baja lógica
¿La eléctrica y la mecánica se cobran igual? La eléctrica lleva un recargo fijo por desbloqueo La jerarquía afecta al precio
¿Cada cuánto queréis los informes? Mensual, pero el panel de operaciones es diario Dos perfiles de consulta distintos

Esas siete respuestas valen más que las siguientes cien líneas de SQL. La cuarta, en particular, es la que evita el error caro: si la tarifa no se congela, el primer cambio de precios reescribe la facturación de dos años.

  1. Documento de requisitos VallBici v1.0

Requisitos funcionales

Id Requisito
RF1 Registrar personas abonadas con sus datos de contacto y su distrito de residencia
RF2 Vender abonos anuales, mensuales y turísticos de 3 días, con su periodo de vigencia
RF3 Mantener el inventario de estaciones, anclajes y bicicletas, con su estado
RF4 Registrar el desbloqueo de una bicicleta y su anclaje posterior, con instantes exactos
RF5 Calcular el importe de cada trayecto según la tarifa vigente en el momento del desbloqueo
RF6 Registrar revisiones y averías, y retirar bicicletas a taller
RF7 Publicar la disponibilidad de cada estación
RF8 Producir los informes de explotación del apartado siguiente

Reglas de negocio

Id Regla Dónde se codifica
RN1 Un anclaje aloja como máximo una bicicleta PRIMARY KEY de anclajes
RN2 Una bicicleta está como máximo en un anclaje UNIQUE (bicicleta_id)
RN3 Una persona no puede tener dos abonos con vigencias solapadas EXCLUDE USING gist
RN4 Una bicicleta no puede tener dos trayectos abiertos Índice único parcial
RN5 Una persona no puede tener dos trayectos abiertos Índice único parcial
RN6 El instante de anclaje es posterior al de desbloqueo CHECK
RN7 Un trayecto cerrado tiene estación destino, anclaje destino, instante de fin e importe; uno abierto no tiene ninguno de los cuatro CHECK conjunto
RN8 El número de bicis en una estación nunca supera su número de anclajes CHECK
RN9 La tarifa aplicada a un trayecto no cambia aunque cambien las tarifas Copia de tarifa en trayectos
RN10 Un trayecto solo puede iniciarse con un abono en vigor Lógica de la transacción

Consultas que el ayuntamiento quiere poder responder

Esta lista es parte del requisito, no un extra. Es lo que en 04-01 llamábamos el criterio de éxito del esquema: un modelo que no puede responderlas es un modelo fallido, por elegante que sea.

  • C1 — Trayectos por distrito y franja horaria, mes a mes.
  • C2 — Los diez pares de estaciones origen→destino más frecuentes.
  • C3 — Estaciones que se vacían o se llenan sistemáticamente, por franja (el problema del reequilibrado: es el que cuesta dinero de verdad, porque obliga a mover bicis en furgoneta).
  • C4 — Ingresos por tipo de abono, separando cuotas de recargos.
  • C5 — Bicicletas con más averías por hora de uso (no en términos absolutos: una bici muy usada se avería más y eso no la hace mala).

  1. Modelo conceptual: entidades, relaciones y tres decisiones difíciles

Del texto salen sin discusión: distrito, estación, anclaje, bicicleta, modelo de bicicleta, persona abonada, tipo de abono, tarifa, abono, trayecto, orden de taller y cobro. Lo que sí tiene discusión son tres decisiones, y son las que separan un modelo que aguanta de uno que no.

Decisión 1 — ¿El anclaje es una entidad o es un número dentro de la estación?

La tentación: guardar en estaciones una columna num_anclajes y otra bicis_disponibles, y no modelar el anclaje. Es más simple y aparentemente suficiente: para pintar la app basta con saber cuántas bicis hay.

Por qué se descarta. Tres motivos, en orden de peso:

  1. El cliente dijo que un anclaje puede averiarse solo. Un atributo no tiene estado; una entidad sí. Sin anclajes, una estación con 20 anclajes y 3 rotos sigue "teniendo capacidad 20" y el sistema promete plazas que no existen.
  2. La app tiene que decir a la persona en qué anclaje está la bici que ha reservado y en cuál debe dejarla. Ese dato no existe si el anclaje no existe.
  3. RN1 y RN2 —"un anclaje, una bici; una bici, un anclaje"— son restricciones de integridad, y en el módulo 4 fijamos el principio: una restricción que el esquema puede imponer no se delega en el código. Con anclajes como tabla son una clave primaria y un UNIQUE; sin ella, son código de aplicación y confianza.

Cómo se modela: el anclaje es una entidad débil de la estación (regla 8 de 04-03), con clave primaria compuesta (estacion_id, numero). El número 7 solo significa algo dentro de una estación concreta, exactamente igual que el número de recibo de una multa dependía de la multa en BiblioRed.

Decisión 2 — ¿El trayecto es una relación o una entidad?

Un trayecto conecta una persona (vía su abono), una bicicleta y dos estaciones. En términos de 04-02 sería una relación de grado 4, y las relaciones de grado alto son casi siempre un síntoma de que falta una entidad.

Se modela como entidad, y por cuatro razones:

  1. Tiene atributos propios y abundantes: dos instantes, duración, importe, tarifa congelada.
  2. Tiene identidad: la misma persona puede hacer el mismo trayecto entre las mismas dos estaciones con la misma bici dos veces el mismo día, y son dos hechos distintos. Una tabla de unión con clave compuesta los confundiría.
  3. Nace incompleto. Cuando se desbloquea la bici solo se conoce la mitad del trayecto. Una relación que existe a medias es una entidad con columnas nulas, no una relación.
  4. Otras cosas lo referencian: el cobro del recargo apunta al trayecto.

Las dos estaciones son dos relaciones 1:N distintas hacia estaciones (regla 5 de 04-03): estacion_origen y estacion_destino. No es un caso raro: es el mismo patrón que "vuelo con aeropuerto de salida y de llegada", y la única precaución es no olvidar que las dos claves ajenas apuntan a la misma tabla, lo que obliga a poner alias en todas las consultas que las usen.

Detalle fino: la clave ajena no apunta a estaciones sino a anclajes(estacion_id, numero), porque nos interesa saber de qué anclaje concreto salió y en cuál entró. La estación queda determinada por el anclaje.

Decisión 3 — La jerarquía mecánica / eléctrica

Toda bicicleta es mecánica o eléctrica (jerarquía total y disjunta). Las eléctricas tienen tres atributos que las mecánicas no tienen sentido: capacidad de batería, autonomía y número de serie de la batería. En 04-03, regla 10, vimos las tres estrategias. Repasemos la decisión con los criterios de allí:

Criterio Tabla única Tabla por subclase Tabla por clase concreta
Atributos específicos 3 columnas nulas en 900 filas Sin nulos Sin nulos
¿trayectos puede referenciar la superclase? No — necesitaría dos FK
"Todas las bicis de la estación 12" Trivial Trivial UNION de dos ramas
n_serie_bateria obligatorio y único solo en eléctricas Imposible con NOT NULL Directo Directo
Coste de consulta típica Ninguno Un LEFT JOIN ocasional UNION siempre

Elegimos tabla por subclase (estrategia 2), igual que hicimos con los materiales de BiblioRed y por el mismo motivo dominante: hay tablas que referencian la superclase. trayectos, anclajes y ordenes_taller apuntan a "una bicicleta", sin importar de qué tipo, y la estrategia 3 haría eso imposible sin duplicar todas las claves ajenas.

La estrategia 1 (tabla única) era defendible —solo son tres columnas— y con 900 filas el desperdicio es irrelevante. Se descarta por el cuarto criterio: n_serie_bateria tiene que ser obligatorio y único en las eléctricas, y en una tabla única solo podría ser opcional. Es exactamente el argumento que en BiblioRed nos hizo separar materiales_libro por el isbn.

Y como en 04-03, la estrategia 2 arrastra su agujero conocido: nada impide por sí solo que una bici con tipo = 'mecanica' tenga fila en bicicletas_electricas. Se tapa con el truco de la clave ajena discriminada, que verás en el esquema.

  1. El diagrama ER

erDiagram
    DISTRITO      ||--o{ ESTACION           : agrupa
    DISTRITO      ||--o{ PERSONA_ABONADA    : "reside en"
    ESTACION      ||--|{ ANCLAJE            : contiene
    ANCLAJE       |o--o| BICICLETA          : aloja
    MODELO_BICI   ||--o{ BICICLETA          : "es del modelo"
    BICICLETA     ||--o| BICICLETA_ELECTRICA : "especializa a"
    BICICLETA     ||--o{ TRAYECTO           : "se usa en"
    BICICLETA     ||--o{ ORDEN_TALLER       : "pasa por"
    PERSONA_ABONADA ||--o{ ABONO            : contrata
    TIPO_ABONO    ||--o{ ABONO              : clasifica
    TIPO_ABONO    ||--o{ TARIFA             : "se tarifica con"
    TARIFA        ||--o{ ABONO              : "fija precio de"
    TARIFA        ||--o{ TRAYECTO           : "congelada en"
    ABONO         ||--o{ TRAYECTO           : autoriza
    ANCLAJE       ||--o{ TRAYECTO           : "es origen de"
    ANCLAJE       ||--o{ TRAYECTO           : "es destino de"
    PERSONA_ABONADA ||--o{ COBRO            : paga
    ABONO         ||--o{ COBRO              : "genera cuota"
    TRAYECTO      ||--o{ COBRO              : "genera recargo"

Léelo con la notación de 04-02: || es participación total y cardinalidad 1, o{ es cardinalidad N con participación parcial, |{ es N con participación total. Que ESTACION ||--|{ ANCLAJE sea total en los dos lados dice algo cierto y no trivial: una estación sin ningún anclaje no es una estación, y un anclaje sin estación no existe. Que ANCLAJE |o--o| BICICLETA sea parcial en ambos lados dice lo contrario: hay anclajes vacíos y hay bicis fuera de todo anclaje (circulando o en taller).

  1. Esquema físico: el CREATE TABLE comentado

Dos extensiones antes de empezar. btree_gist permite mezclar en un EXCLUDE columnas de igualdad (un entero) con columnas de solapamiento (un rango), que es justo lo que piden RN3 y las tarifas.

CREATE EXTENSION IF NOT EXISTS btree_gist;

Inventario

CREATE TABLE distritos (
    distrito_id  SMALLINT     PRIMARY KEY,           -- 5 filas: clave natural, estable
    nombre       VARCHAR(40)  NOT NULL UNIQUE
);

CREATE TABLE estaciones (
    estacion_id       INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    codigo            CHAR(6)      NOT NULL UNIQUE,  -- 'VB-012', el que va rotulado
    nombre            VARCHAR(80)  NOT NULL,
    direccion         VARCHAR(120) NOT NULL,
    distrito_id       SMALLINT     NOT NULL REFERENCES distritos ON DELETE RESTRICT,
    latitud           NUMERIC(9,6) NOT NULL CHECK (latitud  BETWEEN  -90 AND  90),
    longitud          NUMERIC(9,6) NOT NULL CHECK (longitud BETWEEN -180 AND 180),
    num_anclajes      SMALLINT     NOT NULL CHECK (num_anclajes BETWEEN 8 AND 40),
    estado            VARCHAR(14)  NOT NULL DEFAULT 'activa'
                      CHECK (estado IN ('activa','mantenimiento','retirada')),
    fecha_alta        DATE         NOT NULL DEFAULT CURRENT_DATE,
    -- Desnormalización deliberada nº 1 (se justifica en el apartado 7)
    bicis_disponibles SMALLINT     NOT NULL DEFAULT 0 CHECK (bicis_disponibles >= 0),
    CONSTRAINT ck_cabe_en_la_estacion CHECK (bicis_disponibles <= num_anclajes)  -- RN8
);

Por qué estos tipos, uno a uno:

Columna Tipo elegido Alternativa descartada y por qué
distrito_id SMALLINT natural IDENTITY: cinco distritos que no cambian nunca no necesitan clave subrogada
estacion_id INTEGER IDENTITY SERIAL: obsoleto desde PostgreSQL 10; IDENTITY es estándar SQL y no deja secuencias huérfanas
codigo CHAR(6) Sería la clave natural, pero un rótulo se repinta: se queda como UNIQUE, no como PK
latitud/longitud NUMERIC(9,6) FLOAT: precisión decimal exacta y ≈11 cm de resolución bastan. Cuando haga falta geometría de verdad, PostGIS (lo discutimos en 08-02)
num_anclajes SMALLINT INTEGER: ninguna estación tendrá 33.000 anclajes
estado VARCHAR + CHECK ENUM: añadir un valor a un ENUM requiere ALTER TYPE; un CHECK se cambia con ALTER TABLE y se lee en el \d
CREATE TABLE modelos_bici (
    modelo_id   SMALLINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    fabricante  VARCHAR(40) NOT NULL,
    nombre      VARCHAR(40) NOT NULL,
    tipo        VARCHAR(10) NOT NULL CHECK (tipo IN ('mecanica','electrica')),
    peso_kg     NUMERIC(4,1) NOT NULL CHECK (peso_kg > 0),
    UNIQUE (fabricante, nombre)
);

CREATE TABLE bicicletas (
    bicicleta_id  INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    matricula     CHAR(7)     NOT NULL UNIQUE,       -- 'VB-0417'
    modelo_id     SMALLINT    NOT NULL REFERENCES modelos_bici ON DELETE RESTRICT,
    tipo          VARCHAR(10) NOT NULL CHECK (tipo IN ('mecanica','electrica')),
    fecha_alta    DATE        NOT NULL DEFAULT CURRENT_DATE,
    estado        VARCHAR(10) NOT NULL DEFAULT 'anclada'
                  CHECK (estado IN ('anclada','en_uso','taller','baja')),
    km_acumulados NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (km_acumulados >= 0),
    -- Redundante como clave, pero necesaria: es el ancla del discriminante
    CONSTRAINT uq_bici_tipo UNIQUE (bicicleta_id, tipo)
);

CREATE TABLE bicicletas_electricas (
    bicicleta_id    INTEGER     PRIMARY KEY,
    tipo            VARCHAR(10) NOT NULL DEFAULT 'electrica' CHECK (tipo = 'electrica'),
    capacidad_wh    SMALLINT    NOT NULL CHECK (capacidad_wh > 0),
    autonomia_km    SMALLINT    NOT NULL CHECK (autonomia_km BETWEEN 10 AND 200),
    n_serie_bateria VARCHAR(24) NOT NULL UNIQUE,
    FOREIGN KEY (bicicleta_id, tipo)
        REFERENCES bicicletas (bicicleta_id, tipo) ON DELETE CASCADE
);

El truco del discriminante, explicado. La clave ajena compuesta (bicicleta_id, tipo) obliga a que la fila referenciada de bicicletas tenga tipo = 'electrica', porque el CHECK de esta tabla fija tipo a ese valor. Resultado: es imposible dar de alta una batería para una bici mecánica. El agujero que 04-03 dejaba abierto en la estrategia 2 queda tapado en la mitad que importa. La otra mitad —que una eléctrica no tenga fila aquí— sigue sin poder imponerse declarativamente y se controla en el proceso de alta.

CREATE TABLE anclajes (
    estacion_id  INTEGER     NOT NULL REFERENCES estaciones ON DELETE CASCADE,
    numero       SMALLINT    NOT NULL CHECK (numero > 0),
    estado       VARCHAR(10) NOT NULL DEFAULT 'operativo'
                 CHECK (estado IN ('operativo','averiado','bloqueado')),
    bicicleta_id INTEGER     REFERENCES bicicletas ON DELETE SET NULL,
    PRIMARY KEY (estacion_id, numero),                 -- RN1: entidad débil
    CONSTRAINT uq_bici_en_un_anclaje UNIQUE (bicicleta_id)   -- RN2
);

Las dos reglas más importantes del sistema son dos líneas de esquema. La clave primaria compuesta impide que el anclaje 7 de la estación 12 aloje dos bicis. El UNIQUE (bicicleta_id) impide que la bici 417 esté simultáneamente en dos anclajes; funciona porque en PostgreSQL UNIQUE admite tantos NULL como quiera, así que todos los anclajes vacíos conviven sin conflicto.

ON DELETE CASCADE de anclajes hacia estaciones está justificado: un anclaje no tiene vida propia fuera de su estación (es la acción que 02-06 reservaba precisamente para entidades débiles). ON DELETE SET NULL hacia bicicletas es lo correcto para lo contrario: si una bici desapareciera del sistema, el anclaje debe quedar libre, no desaparecer.

Personas, abonos y tarifas

CREATE TABLE personas_abonadas (
    persona_id     INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    documento_hash CHAR(64)     NOT NULL UNIQUE,      -- SHA-256 con sal; ver apartado 12
    nombre         VARCHAR(60)  NOT NULL,
    apellidos      VARCHAR(80)  NOT NULL,
    email          VARCHAR(120) NOT NULL UNIQUE,
    telefono       VARCHAR(20),
    fecha_nac      DATE         NOT NULL,
    distrito_id    SMALLINT     REFERENCES distritos ON DELETE SET NULL,
    fecha_registro TIMESTAMPTZ  NOT NULL DEFAULT now(),
    fecha_baja     DATE,
    CONSTRAINT ck_edad_minima CHECK (fecha_nac <= CURRENT_DATE - INTERVAL '14 years'),
    CONSTRAINT ck_baja_posterior CHECK (fecha_baja IS NULL
                                    OR fecha_baja >= fecha_registro::date)
);

CREATE TABLE tipos_abono (
    tipo_abono  VARCHAR(12) PRIMARY KEY
                CHECK (tipo_abono IN ('anual','mensual','turistico')),
    descripcion VARCHAR(60) NOT NULL,
    duracion    INTERVAL    NOT NULL                  -- '1 year', '1 mon', '3 days'
);

CREATE TABLE tarifas (
    tarifa_id         INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tipo_abono        VARCHAR(12)  NOT NULL REFERENCES tipos_abono ON DELETE RESTRICT,
    vigencia          DATERANGE    NOT NULL,
    cuota             NUMERIC(6,2) NOT NULL CHECK (cuota >= 0),
    minutos_incluidos SMALLINT     NOT NULL CHECK (minutos_incluidos >= 0),
    minutos_fraccion  SMALLINT     NOT NULL CHECK (minutos_fraccion > 0),
    precio_fraccion   NUMERIC(5,2) NOT NULL CHECK (precio_fraccion >= 0),
    recargo_electrica NUMERIC(5,2) NOT NULL DEFAULT 0 CHECK (recargo_electrica >= 0),
    -- Dos tarifas del mismo tipo de abono no pueden estar vigentes a la vez
    EXCLUDE USING gist (tipo_abono WITH =, vigencia WITH &&)
);

CREATE TABLE abonos (
    abono_id   INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    persona_id INTEGER      NOT NULL REFERENCES personas_abonadas ON DELETE RESTRICT,
    tipo_abono VARCHAR(12)  NOT NULL REFERENCES tipos_abono ON DELETE RESTRICT,
    tarifa_id  INTEGER      NOT NULL REFERENCES tarifas ON DELETE RESTRICT,
    vigencia   DATERANGE    NOT NULL,
    importe    NUMERIC(6,2) NOT NULL CHECK (importe >= 0),
    estado     VARCHAR(10)  NOT NULL DEFAULT 'activo'
               CHECK (estado IN ('activo','suspendido','anulado')),
    CONSTRAINT ck_vigencia_acotada CHECK (NOT lower_inf(vigencia) AND NOT upper_inf(vigencia)),
    -- RN3: nada de abonos solapados de la misma persona
    EXCLUDE USING gist (persona_id WITH =, vigencia WITH &&) WHERE (estado <> 'anulado')
);

Sobre NUMERIC para el dinero no hay debate y conviene repetirlo porque es el error más caro que se comete con los tipos: FLOAT no representa exactamente 0,10, y un sistema que suma 1,6 millones de importes con FLOAT produce un descuadre contable que nadie sabrá explicar. NUMERIC(6,2) da hasta 9.999,99 €, sobrado para una cuota anual.

DATERANGE en lugar de dos columnas fecha_inicio/fecha_fin es lo que hace posible el EXCLUDE. Con dos columnas sueltas, "estos dos abonos se solapan" es una consulta con cuatro comparaciones que hay que escribir bien cada vez; con un rango es el operador && y lo impone el motor.

Trayectos, taller y cobros

CREATE TABLE trayectos (
    trayecto_id       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    abono_id          INTEGER     NOT NULL REFERENCES abonos ON DELETE RESTRICT,
    bicicleta_id      INTEGER     NOT NULL REFERENCES bicicletas ON DELETE RESTRICT,
    estacion_origen   INTEGER     NOT NULL,
    anclaje_origen    SMALLINT    NOT NULL,
    ts_inicio         TIMESTAMPTZ NOT NULL DEFAULT now(),
    estacion_destino  INTEGER,
    anclaje_destino   SMALLINT,
    ts_fin            TIMESTAMPTZ,
    duracion          INTERVAL GENERATED ALWAYS AS (ts_fin - ts_inicio) STORED,
    -- Desnormalización deliberada nº 2: la tarifa congelada (RN9)
    tarifa_id         INTEGER      NOT NULL REFERENCES tarifas ON DELETE RESTRICT,
    minutos_incluidos SMALLINT     NOT NULL,
    minutos_fraccion  SMALLINT     NOT NULL,
    precio_fraccion   NUMERIC(5,2) NOT NULL,
    recargo_electrica NUMERIC(5,2) NOT NULL DEFAULT 0,
    importe           NUMERIC(6,2) CHECK (importe >= 0),
    FOREIGN KEY (estacion_origen,  anclaje_origen)
        REFERENCES anclajes (estacion_id, numero) ON DELETE RESTRICT,
    FOREIGN KEY (estacion_destino, anclaje_destino)
        REFERENCES anclajes (estacion_id, numero) ON DELETE RESTRICT,
    CONSTRAINT ck_orden_temporal CHECK (ts_fin IS NULL OR ts_fin > ts_inicio),   -- RN6
    CONSTRAINT ck_cierre_completo CHECK (                                        -- RN7
         (ts_fin IS NULL     AND estacion_destino IS NULL
                             AND anclaje_destino  IS NULL AND importe IS NULL)
      OR (ts_fin IS NOT NULL AND estacion_destino IS NOT NULL
                             AND anclaje_destino  IS NOT NULL AND importe IS NOT NULL))
);

-- RN4 y RN5: ni la bici ni la persona pueden tener dos trayectos abiertos
CREATE UNIQUE INDEX uq_trayecto_abierto_bici  ON trayectos (bicicleta_id) WHERE ts_fin IS NULL;
CREATE UNIQUE INDEX uq_trayecto_abierto_abono ON trayectos (abono_id)     WHERE ts_fin IS NULL;

Tres tipos que merecen un comentario:

  • TIMESTAMPTZ, no TIMESTAMP. Vallmar cambia de hora dos veces al año. El domingo de octubre en que los relojes retrasan, un TIMESTAMP sin zona convierte "02:30" en un instante ambiguo, y los trayectos de esa madrugada pueden salir con duración negativa. TIMESTAMPTZ guarda un instante absoluto y la ambigüedad desaparece. El precio es acordarse de convertir a hora local cuando se agrupa por franja horaria, cosa que haremos explícitamente en C1.
  • INTERVAL generada. duracion no se almacena a mano: es una columna generada (04-04). Cumple la regla 4 de 04-03 —los derivados no se almacenan— sin renunciar a poder indexarla, porque STORED sí ocupa disco pero se calcula sola y nunca puede discrepar de sus fuentes.
  • BIGINT en trayecto_id. Con 1,6 millones de trayectos al año, INTEGER (2.147 millones) tardaría más de mil años en agotarse. Aun así se usa BIGINT: cambiar el tipo de una clave primaria en producción es una de las migraciones más dolorosas que existen, y el coste hoy son cuatro bytes por fila.
CREATE TABLE ordenes_taller (
    orden_id     BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    bicicleta_id INTEGER     NOT NULL REFERENCES bicicletas ON DELETE RESTRICT,
    tipo         VARCHAR(10) NOT NULL CHECK (tipo IN ('revision','averia')),
    motivo       VARCHAR(60) NOT NULL,
    ts_apertura  TIMESTAMPTZ NOT NULL DEFAULT now(),
    ts_cierre    TIMESTAMPTZ,
    coste        NUMERIC(7,2) CHECK (coste >= 0),
    CONSTRAINT ck_cierre_taller CHECK (ts_cierre IS NULL OR ts_cierre >= ts_apertura)
);
CREATE UNIQUE INDEX uq_orden_abierta ON ordenes_taller (bicicleta_id) WHERE ts_cierre IS NULL;

CREATE TABLE cobros (
    cobro_id    BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    persona_id  INTEGER      NOT NULL REFERENCES personas_abonadas ON DELETE RESTRICT,
    concepto    VARCHAR(10)  NOT NULL CHECK (concepto IN ('abono','recargo')),
    abono_id    INTEGER      REFERENCES abonos    ON DELETE RESTRICT,
    trayecto_id BIGINT       REFERENCES trayectos ON DELETE RESTRICT,
    importe     NUMERIC(6,2) NOT NULL CHECK (importe > 0),
    ts_cobro    TIMESTAMPTZ  NOT NULL DEFAULT now(),
    estado      VARCHAR(10)  NOT NULL DEFAULT 'pendiente'
                CHECK (estado IN ('pendiente','cobrado','fallido','devuelto')),
    CONSTRAINT ck_origen_del_cobro CHECK (
         (concepto = 'abono'   AND abono_id    IS NOT NULL AND trayecto_id IS NULL)
      OR (concepto = 'recargo' AND trayecto_id IS NOT NULL AND abono_id    IS NULL))
);

ck_origen_del_cobro es un ejemplo del patrón que 04-04 llamaba restricción de coherencia entre columnas: no basta con que cada columna sea válida por separado; la combinación tiene que tener sentido. Un cobro de concepto abono con trayecto_id relleno es un dato incoherente, y el esquema lo rechaza.

  1. Las restricciones que codifican las reglas de negocio

Vale la pena ver las tres más interesantes fallando, porque una restricción que nunca has visto saltar no sabes si funciona.

-- RN2: intentar poner la bici 417, que ya está en el anclaje 3 de la estación 12,
--      también en el anclaje 5 de la estación 12
UPDATE anclajes SET bicicleta_id = 417 WHERE estacion_id = 12 AND numero = 5;
ERROR:  llave duplicada viola restricción de unicidad «uq_bici_en_un_anclaje»
DETALLE:  Ya existe la llave (bicicleta_id)=(417).
-- RN3: la persona 8801 ya tiene un abono anual del 2026-01-01 al 2027-01-01
INSERT INTO abonos (persona_id, tipo_abono, tarifa_id, vigencia, importe)
VALUES (8801, 'mensual', 7, daterange('2026-06-01','2026-07-01'), 12.00);
ERROR:  el valor llave (persona_id, vigencia)=(8801, [2026-06-01,2026-07-01))
        entra en conflicto con el valor llave existente
        (persona_id, vigencia)=(8801, [2026-01-01,2027-01-01))
DETALLE:  conflicto con la restricción de exclusión «abonos_persona_id_vigencia_excl».
-- RN4: la bici 417 ya tiene un trayecto abierto
INSERT INTO trayectos (abono_id, bicicleta_id, estacion_origen, anclaje_origen,
                       tarifa_id, minutos_incluidos, minutos_fraccion, precio_fraccion)
VALUES (10233, 417, 12, 3, 7, 30, 15, 0.60);
ERROR:  llave duplicada viola restricción de unicidad «uq_trayecto_abierto_bici»
DETALLE:  Ya existe la llave (bicicleta_id)=(417).

Ese último es el más valioso de los tres. La regla "una bici no puede estar en dos trayectos abiertos" parece código de aplicación puro, y en el 90 % de los sistemas lo es —con el resultado de que, bajo carga, dos peticiones simultáneas la violan alegremente—. Un índice único parcial la convierte en una garantía del motor que ninguna condición de carrera puede burlar.

  1. Comprobación de normalización y dos desnormalizaciones deliberadas

Repasamos el esquema con el método de 05-03. Para cada tabla: identificar la clave, listar las dependencias funcionales y comprobar que todo atributo no primo depende de la clave completa y de nada más.

Tabla Clave Dependencias problemáticas Veredicto
distritos distrito_id Ninguna FNBC
estaciones estacion_id bicis_disponibles es derivable 3FN rota a propósito (ver abajo)
anclajes (estacion_id, numero) Ninguna: estado y bicicleta_id dependen del par completo FNBC
bicicletas bicicleta_id tipo también está en modelos_bici Ver nota
abonos abono_id tipo_abono es deducible vía tarifa_id Ver nota
trayectos trayecto_id Las cuatro columnas de tarifa dependen de tarifa_id 2FN/3FN rota a propósito
cobros cobro_id Ninguna FNBC

La nota sobre bicicletas.tipo. Existe la dependencia modelo_id → tipo, y modelo_id no es clave: es una dependencia transitiva y por tanto una violación de 3FN de manual. Se conserva por una razón concreta y verificable: es la columna que hace funcionar el discriminante de la jerarquía, y una clave ajena no puede apuntar a un valor que hay que ir a buscar a otra tabla. Y no genera anomalías, porque un modelo no cambia de tipo jamás: una bici mecánica no se convierte en eléctrica. Es el caso que 05-02 describía como "dependencia transitiva sobre un atributo inmutable", donde el riesgo de anomalía de actualización es cero. Aun así, se blinda con un disparador de verificación en el alta.

La nota sobre abonos.tipo_abono. Mismo razonamiento y misma conclusión: tarifa_id → tipo_abono. Se conserva porque las consultas de explotación agrupan por tipo de abono constantemente y evitar un JOIN en el 80 % de los informes lo justifica. Se blinda con una clave ajena compuesta:

ALTER TABLE tarifas ADD CONSTRAINT uq_tarifa_tipo UNIQUE (tarifa_id, tipo_abono);
ALTER TABLE abonos  ADD CONSTRAINT fk_abono_tarifa_coherente
      FOREIGN KEY (tarifa_id, tipo_abono) REFERENCES tarifas (tarifa_id, tipo_abono);

Ahora la redundancia es imposible de contradecir, que es la única forma aceptable de convivir con una redundancia. Es exactamente el mismo patrón del discriminante de las bicis.

Desnormalización 1 — estaciones.bicis_disponibles

Qué se rompe: el dato es derivable con SELECT COUNT(*) FROM anclajes WHERE estacion_id = ? AND bicicleta_id IS NOT NULL.

Por qué se acepta: la app móvil pide la disponibilidad de las 60 estaciones cada vez que alguien abre el mapa, y con 24.000 personas abonadas eso son decenas de miles de peticiones al día que degeneran en un recuento sobre anclajes cada una. Es el caso de libro de 05-04: lectura masiva, escritura poco frecuente, dato pequeño.

Cómo se mantiene: con un disparador sobre anclajes, no a mano desde la aplicación. Que lo mantenga la aplicación es lo que garantiza que algún día discrepe.

CREATE OR REPLACE FUNCTION trg_recalcular_disponibles() RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP IN ('UPDATE','DELETE') AND OLD.bicicleta_id IS NOT NULL THEN
        UPDATE estaciones SET bicis_disponibles = bicis_disponibles - 1
         WHERE estacion_id = OLD.estacion_id;
    END IF;
    IF TG_OP IN ('UPDATE','INSERT') AND NEW.bicicleta_id IS NOT NULL THEN
        UPDATE estaciones SET bicis_disponibles = bicis_disponibles + 1
         WHERE estacion_id = NEW.estacion_id;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tr_anclajes_disponibles
AFTER INSERT OR UPDATE OF bicicleta_id OR DELETE ON anclajes
FOR EACH ROW EXECUTE FUNCTION trg_recalcular_disponibles();

Y, como manda 05-04, una consulta de auditoría que se ejecuta cada noche y avisa si el contador ha derivado:

SELECT e.codigo, e.bicis_disponibles AS contador,
       COUNT(a.bicicleta_id)          AS real
  FROM estaciones e
  JOIN anclajes  a USING (estacion_id)
 GROUP BY e.estacion_id, e.codigo, e.bicis_disponibles
HAVING e.bicis_disponibles <> COUNT(a.bicicleta_id);
 codigo | contador | real
--------+----------+------
(0 filas)

Desnormalización 2 — la tarifa congelada en trayectos

Qué se rompe: minutos_incluidos, minutos_fraccion, precio_fraccion y recargo_electrica dependen de tarifa_id, no de trayecto_id.

Por qué se acepta: no es rendimiento, es corrección. Es la respuesta a la cuarta pregunta de la reunión inicial. Si el trayecto solo guardara tarifa_id, cualquier recálculo posterior aplicaría la tarifa actual; el día que el ayuntamiento suba el precio de la fracción, todo el histórico cambiaría de importe. Los datos históricos deben ser reproducibles: es un requisito contable y a veces legal.

Cómo se mantiene: copiando la tarifa en el momento del desbloqueo, dentro de la transacción, y no volviéndola a tocar nunca. Un disparador BEFORE UPDATE que rechace cambios en esas cuatro columnas es una protección barata y sensata.

Fíjate en la diferencia entre las dos: la primera se desnormaliza por velocidad y hay que vigilarla; la segunda se desnormaliza por semántica y vigilarla sería un error, porque su valor debe diferir del actual.

  1. Carga de datos y volumen realista

INSERT INTO distritos VALUES
 (1,'Puerto'), (2,'Ensanche'), (3,'Vallmar Alta'), (4,'Ribera'), (5,'Industrial');

INSERT INTO tipos_abono VALUES
 ('anual',    'Abono anual con 30 min incluidos por trayecto', INTERVAL '1 year'),
 ('mensual',  'Abono mensual con 30 min incluidos',            INTERVAL '1 mon'),
 ('turistico','Abono de 3 días con 15 min incluidos',          INTERVAL '3 days');

INSERT INTO tarifas (tipo_abono, vigencia, cuota, minutos_incluidos,
                     minutos_fraccion, precio_fraccion, recargo_electrica) VALUES
 ('anual',     daterange('2026-01-01','2027-01-01'), 45.00, 30, 15, 0.60, 0.35),
 ('mensual',   daterange('2026-01-01','2027-01-01'),  9.50, 30, 15, 0.60, 0.35),
 ('turistico', daterange('2026-01-01','2027-01-01'), 15.00, 15, 15, 1.10, 0.50);

-- 60 estaciones repartidas por los cinco distritos
INSERT INTO estaciones (codigo, nombre, direccion, distrito_id, latitud, longitud, num_anclajes)
SELECT 'VB-' || lpad(n::text, 3, '0'),
       'Estación ' || n,
       'Calle Ficticia ' || n,
       1 + (n % 5),
       40.100000 + (n % 12) * 0.004,
       -3.200000 + (n % 9) * 0.005,
       12 + (n % 5) * 4
  FROM generate_series(1, 60) AS n;

-- Los anclajes de cada estación (entidad débil: se generan a partir de num_anclajes)
INSERT INTO anclajes (estacion_id, numero)
SELECT e.estacion_id, g
  FROM estaciones e, LATERAL generate_series(1, e.num_anclajes) AS g;
INSERT 0 1512

Ese 1.512 es el primer dato de dimensionamiento útil: 1.512 anclajes para 900 bicicletas, un 68 % de ocupación media. Es una holgura sana; por debajo del 80 % el reequilibrado se vuelve una pesadilla operativa.

Tabla Filas iniciales Crecimiento anual Notas
distritos 5 0 Fija
estaciones 60 +5 Crecimiento por plan municipal
anclajes 1.512 +120 Derivada de las estaciones
modelos_bici 6 +1
bicicletas 900 +90 / −60 Altas y bajas
personas_abonadas 24.000 +4.000
abonos 26.500 +30.000 Los turísticos rotan mucho
trayectos 0 +1.600.000 ≈4.400/día; es la tabla del sistema
ordenes_taller 0 +7.000
cobros 0 +180.000 Cuotas + recargos

Toda decisión de rendimiento del apartado 11 se refiere a esos 1,6 millones anuales de trayectos. Las demás tablas son irrelevantes a efectos de plan de ejecución, y confundir eso es la forma más habitual de perder una tarde indexando lo que no toca.

  1. Las dos transacciones críticas: desbloquear y anclar

Aquí es donde el módulo 6 deja de ser teoría. El escenario que hay que resolver es concreto: son las 08:12, la estación del Puerto tiene una sola bici libre y dos personas pulsan "desbloquear" con 40 milisegundos de diferencia.

sequenceDiagram
    participant A as App persona A
    participant B as App persona B
    participant PG as PostgreSQL
    A->>PG: BEGIN · SELECT anclaje con bici FOR UPDATE SKIP LOCKED
    PG-->>A: anclaje 3 (bici 417) — fila bloqueada
    B->>PG: BEGIN · SELECT anclaje con bici FOR UPDATE SKIP LOCKED
    PG-->>B: 0 filas (salta la 3, no hay más)
    A->>PG: UPDATE anclajes · INSERT trayectos · COMMIT
    B->>PG: ROLLBACK — "no quedan bicicletas"

Desbloqueo

BEGIN;

-- 1. Verificar que el abono está en vigor (RN10). Sin bloqueo: leerlo basta.
SELECT abono_id, tarifa_id
  FROM abonos
 WHERE persona_id = 8801 AND estado = 'activo'
   AND vigencia @> CURRENT_DATE;

-- 2. Tomar UNA bici anclada de la estación 12, bloqueando solo esa fila.
--    ORDER BY km_acumulados reparte el desgaste de la flota.
SELECT a.estacion_id, a.numero, a.bicicleta_id, b.tipo
  FROM anclajes   a
  JOIN bicicletas b ON b.bicicleta_id = a.bicicleta_id
 WHERE a.estacion_id = 12
   AND a.estado      = 'operativo'
   AND b.estado      = 'anclada'
 ORDER BY b.km_acumulados
 FOR UPDATE OF a SKIP LOCKED
 LIMIT 1;

-- 3. Liberar el anclaje y marcar la bici en uso
UPDATE anclajes   SET bicicleta_id = NULL WHERE estacion_id = 12 AND numero = 3;
UPDATE bicicletas SET estado = 'en_uso'   WHERE bicicleta_id = 417;

-- 4. Abrir el trayecto congelando la tarifa vigente
INSERT INTO trayectos (abono_id, bicicleta_id, estacion_origen, anclaje_origen,
                       tarifa_id, minutos_incluidos, minutos_fraccion,
                       precio_fraccion, recargo_electrica)
SELECT 10233, 417, 12, 3,
       t.tarifa_id, t.minutos_incluidos, t.minutos_fraccion,
       t.precio_fraccion,
       CASE WHEN b.tipo = 'electrica' THEN t.recargo_electrica ELSE 0 END
  FROM tarifas t
  JOIN bicicletas b ON b.bicicleta_id = 417
 WHERE t.tipo_abono = 'anual' AND t.vigencia @> CURRENT_DATE
RETURNING trayecto_id;

COMMIT;
 trayecto_id
-------------
      884213
COMMIT

Por qué SKIP LOCKED y no FOR UPDATE a secas. Con FOR UPDATE, la sesión B se queda esperando a que A confirme, y luego reevalúa: como el anclaje ya no tiene bici, obtiene 0 filas. El resultado final es correcto, pero B ha esperado sin necesidad. Con SKIP LOCKED, B ignora la fila bloqueada y sigue buscando otra bici en la misma estación; solo si de verdad no queda ninguna devuelve 0 filas. En una estación con 8 bicis y 8 personas desbloqueando a la vez, la diferencia es que las 8 lo consiguen en paralelo en lugar de hacer cola. Es exactamente el patrón de cola que vimos en 06-02, aplicado a un inventario.

Por qué FOR UPDATE OF a. Sin el OF a, PostgreSQL bloquearía también la fila de bicicletas, y no hace falta: la fila que decide quién gana es la del anclaje. Bloquear de más multiplica los interbloqueos.

El nivel de aislamiento es READ COMMITTED, el de por defecto. No hace falta subir a REPEATABLE READ: no hay ninguna lectura que deba repetirse de forma estable, y el bloqueo explícito ya resuelve la carrera. Subir el aislamiento "por si acaso" solo añade errores de serialización que habría que reintentar.

Anclaje

BEGIN;

-- 1. Reservar un anclaje libre y operativo en la estación de destino
SELECT estacion_id, numero
  FROM anclajes
 WHERE estacion_id = 34 AND estado = 'operativo' AND bicicleta_id IS NULL
 ORDER BY numero
 FOR UPDATE SKIP LOCKED
 LIMIT 1;

-- 2. Cerrar el trayecto calculando el importe con la tarifa CONGELADA
UPDATE trayectos t
   SET ts_fin           = now(),
       estacion_destino = 34,
       anclaje_destino  = 7,
       importe = t.recargo_electrica
               + t.precio_fraccion
               * GREATEST(0, ceil(
                     (EXTRACT(EPOCH FROM (now() - t.ts_inicio)) / 60
                      - t.minutos_incluidos) / t.minutos_fraccion))
 WHERE t.trayecto_id = 884213
   AND t.ts_fin IS NULL                 -- idempotencia: un segundo anclaje no hace nada
RETURNING importe;

-- 3. Ocupar el anclaje y devolver la bici a estado 'anclada'
UPDATE anclajes   SET bicicleta_id = 417 WHERE estacion_id = 34 AND numero = 7;
UPDATE bicicletas SET estado = 'anclada',
                      km_acumulados = km_acumulados + 3.40
 WHERE bicicleta_id = 417;

-- 4. Si hay recargo, generar el cobro
INSERT INTO cobros (persona_id, concepto, trayecto_id, importe)
SELECT a.persona_id, 'recargo', t.trayecto_id, t.importe
  FROM trayectos t JOIN abonos a USING (abono_id)
 WHERE t.trayecto_id = 884213 AND t.importe > 0;

COMMIT;
 importe
---------
    1.55
UPDATE 1
COMMIT

Un trayecto de 68 minutos con abono anual: 68 − 30 incluidos = 38 minutos excedidos, ceil(38/15) = 3 fracciones × 0,60 € = 1,80 €… más el recargo de eléctrica 0,35 €. El resultado de arriba, 1,55 €, corresponde a 2 fracciones (1,20 €) más 0,35 €, es decir a un trayecto de 55 minutos. Comprueba tú el cálculo con los dos casos: verificar a mano el primer importe que produce un sistema de cobro es una costumbre que ahorra disgustos.

Los tres detalles que hacen esta transacción robusta:

  1. AND t.ts_fin IS NULL en el WHERE del UPDATE la hace idempotente. Si la app reintenta el anclaje porque perdió la respuesta por red, el segundo intento afecta a 0 filas y no vuelve a cobrar.
  2. El RETURNING permite a la aplicación comprobar cuántas filas cambió. Cero filas no es un éxito silencioso: es un error que hay que tratar.
  3. El contador bicis_disponibles no se toca aquí: lo actualiza el disparador de anclajes. Si se tocara también a mano, el contador subiría de dos en dos, y ese es el fallo de desnormalización más común que existe.

  1. Consultas de explotación: los informes del ayuntamiento

C1 — Trayectos por distrito y franja horaria.

SELECT d.nombre AS distrito,
       COUNT(*) FILTER (WHERE h BETWEEN  7 AND  9) AS punta_manana,
       COUNT(*) FILTER (WHERE h BETWEEN 10 AND 16) AS valle,
       COUNT(*) FILTER (WHERE h BETWEEN 17 AND 20) AS punta_tarde,
       COUNT(*) FILTER (WHERE h > 20 OR h < 7)     AS nocturno,
       COUNT(*)                                    AS total
  FROM (SELECT t.estacion_origen,
               EXTRACT(HOUR FROM t.ts_inicio AT TIME ZONE 'Europe/Madrid')::int AS h
          FROM trayectos t
         WHERE t.ts_inicio >= DATE '2026-06-01'
           AND t.ts_inicio <  DATE '2026-07-01') x
  JOIN estaciones e ON e.estacion_id = x.estacion_origen
  JOIN distritos  d USING (distrito_id)
 GROUP BY d.nombre
 ORDER BY total DESC;
   distrito   | punta_manana | valle | punta_tarde | nocturno | total
--------------+--------------+-------+-------------+----------+--------
 Ensanche     |        14820 | 11340 |       16905 |     2115 |  45180
 Puerto       |         9640 | 14210 |       12880 |     3410 |  40140
 Ribera       |         7115 |  6320 |        8090 |     1145 |  22670
 Vallmar Alta |         5980 |  4110 |        6240 |      705 |  17035
 Industrial   |         4210 |  1890 |        4560 |      380 |  11040

Ahí se lee un patrón real: el Ensanche y el Industrial son claramente pendulares (dos puntas, poco valle) mientras que el Puerto tiene su máximo en valle — es turismo, no desplazamiento al trabajo. El AT TIME ZONE 'Europe/Madrid' no es decorativo: sin él, en verano las franjas saldrían desplazadas dos horas.

C2 — Los pares origen→destino más frecuentes, con su ranking dentro del distrito.

SELECT * FROM (
  SELECT eo.nombre AS origen, ed.nombre AS destino, dd.nombre AS distrito_destino,
         COUNT(*) AS viajes,
         ROUND(AVG(EXTRACT(EPOCH FROM t.duracion) / 60)::numeric, 1) AS min_medio,
         RANK() OVER (PARTITION BY dd.distrito_id ORDER BY COUNT(*) DESC) AS puesto
    FROM trayectos  t
    JOIN estaciones eo ON eo.estacion_id = t.estacion_origen
    JOIN estaciones ed ON ed.estacion_id = t.estacion_destino
    JOIN distritos  dd ON dd.distrito_id = ed.distrito_id
   WHERE t.ts_fin IS NOT NULL
     AND t.ts_inicio >= DATE '2026-06-01' AND t.ts_inicio < DATE '2026-07-01'
   GROUP BY eo.estacion_id, eo.nombre, ed.estacion_id, ed.nombre,
            dd.distrito_id, dd.nombre
) r
WHERE puesto <= 2
ORDER BY distrito_destino, puesto;
       origen        |      destino       | distrito_destino | viajes | min_medio | puesto
---------------------+--------------------+------------------+--------+-----------+--------
 Estación 7          | Estación 22        | Ensanche         |   1284 |      14.2 |      1
 Estación 41         | Estación 22        | Ensanche         |    967 |      18.6 |      2
 Estación 3          | Estación 18        | Industrial       |    712 |      21.4 |      1
 ...

El RANK() OVER (PARTITION BY ...) sobre un COUNT(*) agregado es el patrón "top N por grupo" que practicaste en 07-04. Nótese que la función de ventana se evalúa después del GROUP BY, por eso puede ordenar por COUNT(*).

C3 — El problema del reequilibrado. Esta es la consulta que ahorra dinero de verdad, y la que mejor demuestra por qué modelamos el trayecto como entidad con dos extremos.

WITH movimientos AS (
    SELECT estacion_origen AS estacion_id, ts_inicio AS ts, -1 AS delta
      FROM trayectos
     WHERE ts_inicio >= DATE '2026-06-01' AND ts_inicio < DATE '2026-07-01'
    UNION ALL
    SELECT estacion_destino, ts_fin, +1
      FROM trayectos
     WHERE ts_fin IS NOT NULL
       AND ts_fin >= DATE '2026-06-01' AND ts_fin < DATE '2026-07-01'
),
por_franja AS (
    SELECT estacion_id,
           EXTRACT(HOUR FROM ts AT TIME ZONE 'Europe/Madrid')::int / 4 AS bloque,
           SUM(delta) AS neto
      FROM movimientos
     GROUP BY 1, 2
)
SELECT e.codigo, e.nombre, e.num_anclajes,
       SUM(neto) FILTER (WHERE bloque = 1) AS "04-08",
       SUM(neto) FILTER (WHERE bloque = 2) AS "08-12",
       SUM(neto) FILTER (WHERE bloque = 4) AS "16-20",
       SUM(neto)                            AS neto_mes,
       CASE WHEN SUM(neto) < -300 THEN 'se vacía · reponer'
            WHEN SUM(neto) >  300 THEN 'se llena · retirar'
            ELSE 'equilibrada' END          AS diagnostico
  FROM por_franja JOIN estaciones e USING (estacion_id)
 GROUP BY e.estacion_id, e.codigo, e.nombre, e.num_anclajes
 ORDER BY ABS(SUM(neto)) DESC
 LIMIT 5;
 codigo |    nombre    | num_anclajes | 04-08 | 08-12 | 16-20 | neto_mes |    diagnostico
--------+--------------+--------------+-------+-------+-------+----------+--------------------
 VB-022 | Estación 22  |           24 |   -18 |  +892 |  -774 |     +611 | se llena · retirar
 VB-007 | Estación 7   |           16 |   +31 |  -845 |  +698 |     -498 | se vacía · reponer
 VB-041 | Estación 41  |           20 |   +12 |  -602 |  +515 |     -402 | se vacía · reponer
 VB-018 | Estación 18  |           28 |    -8 |  +498 |  -401 |     +377 | se llena · retirar
 VB-003 | Estación 3   |           12 |   +22 |  -344 |  +266 |     -281 | equilibrada

Los signos cuentan la historia entera: la 7 y la 41 son residenciales (se vacían por la mañana, se rellenan por la tarde) y la 22 es un destino de trabajo. La columna neto_mes dice cuántas bicis hay que mover en furgoneta cada mes, y el 08-12 frente al 16-20 dice a qué hora hay que moverlas. La técnica —convertir dos columnas de una fila en dos filas con signo mediante UNION ALL— es la forma canónica de tratar cualquier entidad con dos extremos.

C4 — Ingresos por tipo de abono.

SELECT ta.tipo_abono,
       COUNT(*) FILTER (WHERE c.concepto = 'abono')                 AS n_cuotas,
       SUM(c.importe) FILTER (WHERE c.concepto = 'abono')           AS eur_cuotas,
       SUM(c.importe) FILTER (WHERE c.concepto = 'recargo')         AS eur_recargos,
       SUM(c.importe)                                               AS eur_total,
       ROUND(100.0 * SUM(c.importe) FILTER (WHERE c.concepto = 'recargo')
                   / NULLIF(SUM(c.importe), 0), 1)                  AS pct_recargo
  FROM cobros c
  JOIN personas_abonadas p USING (persona_id)
  JOIN abonos  ab ON ab.persona_id = p.persona_id
                 AND ab.vigencia @> c.ts_cobro::date
  JOIN tipos_abono ta ON ta.tipo_abono = ab.tipo_abono
 WHERE c.estado = 'cobrado'
   AND c.ts_cobro >= DATE '2026-01-01'
 GROUP BY ta.tipo_abono
 ORDER BY eur_total DESC;
 tipo_abono | n_cuotas | eur_cuotas | eur_recargos | eur_total | pct_recargo
------------+----------+------------+--------------+-----------+-------------
 anual      |     9840 |  442800.00 |     38215.40 | 481015.40 |         7.9
 turistico  |     8120 |  121800.00 |     71430.80 | 193230.80 |        36.9
 mensual    |     6410 |   60895.00 |     19204.20 |  80099.20 |        24.0

El pct_recargo del abono turístico (36,9 %) es el tipo de hallazgo que justifica el informe: las personas visitantes se pasan del tiempo incluido casi la mitad de las veces. O bien 15 minutos son pocos, o bien la app no avisa. Es una conversación de negocio que solo existe porque la consulta la hizo posible.

C5 — Averías por hora de uso.

WITH uso AS (
    SELECT bicicleta_id,
           SUM(EXTRACT(EPOCH FROM duracion)) / 3600 AS horas
      FROM trayectos
     WHERE ts_fin IS NOT NULL AND ts_inicio >= DATE '2026-01-01'
     GROUP BY bicicleta_id
),
averias AS (
    SELECT bicicleta_id, COUNT(*) AS n
      FROM ordenes_taller
     WHERE tipo = 'averia' AND ts_apertura >= DATE '2026-01-01'
     GROUP BY bicicleta_id
)
SELECT b.matricula, m.fabricante, m.nombre AS modelo, b.tipo,
       ROUND(u.horas::numeric, 1)                        AS horas_uso,
       COALESCE(a.n, 0)                                  AS averias,
       ROUND((COALESCE(a.n,0) * 100 / u.horas)::numeric, 2) AS averias_por_100h
  FROM uso u
  JOIN bicicletas   b USING (bicicleta_id)
  JOIN modelos_bici m USING (modelo_id)
  LEFT JOIN averias a USING (bicicleta_id)
 WHERE u.horas >= 50                    -- descarta bicis con muestra insuficiente
 ORDER BY averias_por_100h DESC
 LIMIT 5;
 matricula | fabricante | modelo  |   tipo    | horas_uso | averias | averias_por_100h
-----------+------------+---------+-----------+-----------+---------+------------------
 VB-0417   | Norvent    | Urbana2 | mecanica  |     214.6 |       9 |             4.19
 VB-0388   | Norvent    | Urbana2 | mecanica  |     188.2 |       7 |             3.72
 VB-0512   | Ciclmar    | E-Vall  | electrica |     341.9 |      11 |             3.22
 VB-0041   | Norvent    | Urbana2 | mecanica  |     255.0 |       8 |             3.05
 VB-0733   | Ciclmar    | E-Vall  | electrica |     298.4 |       9 |             3.02

El WHERE u.horas >= 50 es la parte importante y la que casi nadie pone: sin él, una bici con 2 horas de uso y una avería encabeza la lista con 50 averías/100 h y el informe carece de valor. Toda tasa necesita un umbral mínimo de denominador.

  1. Rendimiento: las dos consultas que se degradan

Con la tabla vacía todo va rápido. El día que trayectos tiene 1,6 millones de filas, dos cosas se rompen. Seguimos el orden de intervención de 06-03: medir primero, entender el plan, y solo entonces tocar.

Degradación 1 — el informe mensual C1

EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*) FROM trayectos
 WHERE ts_inicio >= DATE '2026-06-01' AND ts_inicio < DATE '2026-07-01';
 Finalize Aggregate  (cost=98421.30..98421.31 rows=1) (actual time=2184.402..2189.117 rows=1)
   ->  Gather  (...)
         ->  Partial Aggregate  (...)
               ->  Parallel Seq Scan on trayectos  (actual time=0.041..2050.883 rows=45180 loops=3)
                     Filter: ((ts_inicio >= '2026-06-01') AND (ts_inicio < '2026-07-01'))
                     Rows Removed by Filter: 488153
                     Buffers: shared read=31204
 Planning Time: 0.198 ms
 Execution Time: 2189.204 ms

Diagnóstico: Rows Removed by Filter: 488153 por cada uno de los 3 procesos. Se leen 1,6 millones de filas para quedarse con 45.180. Es el síntoma clásico de 06-03: un filtro muy selectivo (2,8 %) resuelto con recorrido secuencial.

Intervención. Aquí hay una elección real entre dos índices:

Índice Tamaño Coste de escritura Cuándo gana
B-tree (ts_inicio) ≈34 MB Alto: 4.400 inserciones/día lo actualizan Rangos pequeños, y también sirve para "el último trayecto de X"
BRIN (ts_inicio) ≈48 KB Casi nulo Rangos grandes sobre datos correlacionados con el orden físico

trayectos se inserta siempre en orden cronológico y nunca se reordena: la correlación física de ts_inicio es prácticamente 1. Es el caso exacto para el que existe BRIN.

CREATE INDEX idx_trayectos_ts_brin ON trayectos USING brin (ts_inicio)
    WITH (pages_per_range = 64);
ANALYZE trayectos;
 Aggregate  (actual time=118.940..118.941 rows=1)
   ->  Bitmap Heap Scan on trayectos  (actual time=3.117..114.226 rows=45180 loops=1)
         Recheck Cond: ((ts_inicio >= '2026-06-01') AND (ts_inicio < '2026-07-01'))
         Rows Removed by Index Recheck: 2841
         Heap Blocks: lossy=1408
         Buffers: shared hit=1412 read=3
         ->  Bitmap Index Scan on idx_trayectos_ts_brin  (actual time=0.402..0.402 rows=14080 loops=1)
 Planning Time: 0.211 ms
 Execution Time: 118.987 ms

De 2.189 ms a 119 ms, con un índice de 48 KB. Rows Removed by Index Recheck: 2841 es normal en BRIN: el índice trabaja por bloques, así que devuelve algún bloque de más y el motor descarta las filas sobrantes. A cambio ocupa mil veces menos que el B-tree y su mantenimiento en las inserciones es despreciable.

Degradación 2 — "¿qué trayecto tengo abierto?"

Es la consulta más frecuente del sistema: la ejecuta la app cada vez que alguien la abre con una bici en curso.

EXPLAIN ANALYZE
SELECT trayecto_id, ts_inicio, estacion_origen
  FROM trayectos WHERE abono_id = 10233 AND ts_fin IS NULL;
 Seq Scan on trayectos  (actual time=1893.221..1893.223 rows=1 loops=1)
   Filter: ((ts_fin IS NULL) AND (abono_id = 10233))
   Rows Removed by Filter: 1599999
 Execution Time: 1893.244 ms

Y aquí está el detalle bonito: el índice que lo arregla ya existe. Es uq_trayecto_abierto_abono, el índice único parcial que creamos en el apartado 5 para imponer RN5. Un índice parcial sobre WHERE ts_fin IS NULL cubre, con 1,6 millones de filas en la tabla, solo las 900 como máximo que pueden estar abiertas simultáneamente. Lo único que faltaba era un ANALYZE:

 Index Scan using uq_trayecto_abierto_abono on trayectos  (actual time=0.031..0.033 rows=1 loops=1)
   Index Cond: (abono_id = 10233)
   Buffers: shared hit=3
 Execution Time: 0.049 ms

De 1.893 ms a 0,049 ms. La lección es la que 06-03 repetía: una restricción bien elegida es también un índice, y muchas veces el índice que necesitas ya lo has creado sin darte cuenta.

Los tres índices restantes que sí hay que crear a mano:

CREATE INDEX idx_trayectos_origen_ts  ON trayectos (estacion_origen, ts_inicio);
CREATE INDEX idx_trayectos_destino_ts ON trayectos (estacion_destino, ts_fin)
    WHERE ts_fin IS NOT NULL;
CREATE INDEX idx_ordenes_bici_tipo    ON ordenes_taller (bicicleta_id, tipo, ts_apertura);

El orden de las columnas en los compuestos sigue la regla de 06-03 —igualdad primero, rango después— y responde a C2 y C3. El segundo es parcial porque los trayectos abiertos no tienen destino y no aportan nada al índice.

  1. Operación: roles, datos personales y copias

Roles y permisos mínimos

CREATE ROLE vallbici_app     LOGIN PASSWORD '...';   -- la API que atiende la app móvil
CREATE ROLE vallbici_taller  LOGIN PASSWORD '...';   -- el equipo de mantenimiento
CREATE ROLE vallbici_analista LOGIN PASSWORD '...';  -- informes del ayuntamiento

GRANT SELECT, INSERT, UPDATE ON anclajes, trayectos, bicicletas TO vallbici_app;
GRANT SELECT                 ON estaciones, tarifas, abonos     TO vallbici_app;
GRANT INSERT                 ON cobros                          TO vallbici_app;
-- La API NO puede borrar nada, en ninguna tabla. Ni siquiera sus propias filas.

GRANT SELECT, INSERT, UPDATE ON ordenes_taller TO vallbici_taller;
GRANT SELECT, UPDATE (estado) ON bicicletas    TO vallbici_taller;

GRANT SELECT ON ALL TABLES IN SCHEMA public TO vallbici_analista;
REVOKE SELECT ON personas_abonadas, cobros   FROM vallbici_analista;
GRANT  SELECT ON v_trayectos_anonimos        TO vallbici_analista;

El GRANT SELECT, UPDATE (estado) ON bicicletas es la aplicación literal del principio de mínimo privilegio de 06-04: el taller cambia el estado de una bici, y solo el estado. No puede tocar km_acumulados ni matricula.

Datos personales

Los informes del ayuntamiento no necesitan saber quién hizo cada trayecto. La vista que consume el perfil analista rompe el vínculo:

CREATE VIEW v_trayectos_anonimos AS
SELECT t.trayecto_id, t.ts_inicio, t.ts_fin, t.duracion, t.importe,
       t.estacion_origen, t.estacion_destino,
       ab.tipo_abono,
       p.distrito_id AS distrito_residencia,
       date_part('year', age(p.fecha_nac))::int / 10 * 10 AS decada_edad
  FROM trayectos t
  JOIN abonos ab USING (abono_id)
  JOIN personas_abonadas p USING (persona_id);

Dos observaciones honestas sobre esto:

  1. La vista no anonimiza de verdad. Con distrito de residencia, década de edad y patrón de trayectos, una persona con recorrido singular puede ser reidentificable. Reducir el riesgo requiere agregación mínima (no publicar celdas con menos de N personas) y eso es una decisión de política, no de SQL.
  2. El cumplimiento normativo no lo decide quien diseña la base de datos. Base legal del tratamiento, plazos de conservación de la telemetría, evaluación de impacto, derecho de supresión: todo eso lo revisa un profesional de protección de datos o compliance. Lo que sí es responsabilidad técnica es que el esquema permita cumplirlo: por eso el documento se guarda como documento_hash y no en claro, por eso ON DELETE RESTRICT obliga a un procedimiento explícito de supresión en lugar de borrar en cascada el histórico contable, y por eso existe la vista.

Copias

Elemento Estrategia Frecuencia Objetivo
Copia base física pg_basebackup Diaria, 02:00 Restauración completa
WAL archivado archive_command a almacenamiento externo Continuo PITR: restaurar a cualquier instante
Volcado lógico pg_dump -Fc Semanal Recuperar una tabla suelta sin restaurar todo
Prueba de restauración Restaurar en máquina aparte y ejecutar la auditoría del contador Mensual Que la copia sirva de verdad

La última fila es la que más se salta y la única que garantiza algo. En palabras de 06-04: una copia que nunca se ha restaurado no es una copia, es una esperanza. Con PITR configurado, el escenario "a las 11:40 alguien ejecutó un UPDATE sin WHERE sobre tarifas" se resuelve restaurando a las 11:39.

  1. Qué resuelve mal este esquema

El sistema funciona, cumple los ocho requisitos funcionales y responde a las cinco consultas. Y aun así hay tres cosas del encargo inicial que este esquema resuelve mal. Vale la pena ser preciso, porque son exactamente el material de la lección siguiente.

1. La telemetría GPS. El encargo pedía guardar el rastro de cada trayecto: posiciones cada 5 segundos, batería de las eléctricas, incidencias. Un trayecto medio de 14 minutos son ~170 puntos. Con 1,6 millones de trayectos anuales, eso es una tabla posiciones de 272 millones de filas al año, cada una con trayecto_id, ts, lat, lon y poco más. Se puede hacer —PostgreSQL aguanta— pero es un mal encaje: son datos que se escriben masivamente, se leen siempre enteros y por trayecto, no se actualizan nunca, no necesitan integridad referencial estricta y caducan a los pocos meses. Estás pagando el precio del modelo relacional (índice por fila, WAL por fila, visibilidad por fila) para un dato que no usa ninguna de sus ventajas.

2. La ficha enriquecida de la estación. Nuestra tabla estaciones tiene 11 columnas planas. La ficha que quiere la app tiene fotos, horarios de acceso que varían por estación, accesibilidad, si está bajo cubierta, si tiene bomba de aire, notas de la empresa mantenedora, etiquetas de puntos de interés cercanos… y cada estación tiene un subconjunto distinto de esos atributos. Modelarlo en el relacional lleva a una de tres salidas, todas malas: 40 columnas nulas, una tabla clave-valor genérica (el anti-patrón EAV que 04-01 marcó en rojo) o una tabla nueva por cada atributo que se le ocurra a alguien.

3. Las incidencias con estructura variable. ordenes_taller tiene un motivo VARCHAR(60). Pero una incidencia de "frenos" necesita registrar qué freno y qué medida; una de "batería" necesita ciclos de carga y voltaje; una de "vandalismo" necesita fotos y número de atestado. Son estructuras distintas por tipo, y añadir un tipo nuevo no debería requerir un ALTER TABLE en producción.

Las tres tienen algo en común: estructura variable o volumen alto sin necesidad de transacciones. Y las tres tienen algo más en común, más importante todavía: ninguna de ellas es el núcleo transaccional. Nadie cobra dinero a partir de un punto GPS. Eso es lo que permite sacarlas de PostgreSQL sin poner en riesgo lo que importa, y es justo lo que hace la lección 08-02.

Errores Comunes y Consejos

Error 1: empezar por el CREATE TABLE. Es el error del que se derivan casi todos los demás. Las siete preguntas del apartado 1 costaron veinte minutos de reunión y determinaron la mitad del esquema. Si no las hubiéramos hecho, habríamos descubierto la necesidad de la tarifa congelada el día de la primera subida de precios, con dos años de facturación ya emitida.

Error 2: no modelar el anclaje "porque con un contador basta". Funciona hasta el primer anclaje averiado. La regla general: si el cliente dice que algo tiene estado propio, es una entidad, aunque parezca un número.

Error 3: FLOAT para el dinero. Sigue ocurriendo. NUMERIC para todo lo que se cobra, se factura o se suma.

Error 4: TIMESTAMP sin zona en un sistema con cambio horario. El domingo de octubre producirá trayectos de duración negativa y la restauración de esa noche será un rompecabezas.

Error 5: mantener a mano un contador desnormalizado. Si bicis_disponibles se actualiza desde la aplicación y desde el disparador, se duplica el incremento. Uno de los dos, y mejor el disparador.

Error 6: subir el nivel de aislamiento en vez de bloquear la fila correcta. SERIALIZABLE no es una solución mágica: convierte una carrera en un error de serialización que la aplicación debe reintentar. Un FOR UPDATE SKIP LOCKED sobre la fila que decide es más barato y más predecible.

Consejo 1: escribe la lista de consultas antes que el esquema. Es el mejor detector de entidades que falta. C3 fue la que confirmó que el trayecto necesitaba sus dos extremos como claves ajenas independientes.

Consejo 2: cada restricción que escribas, pruébala fallando. Una restricción que nunca has visto rechazar un INSERT no sabes si está bien escrita.

Consejo 3: mira si el índice ya existe antes de crearlo. La degradación 2 se resolvió sola. Los índices únicos, parciales o no, son índices completos.

Consejo 4: documenta las desnormalizaciones en el propio esquema. El comentario -- Desnormalización deliberada nº 1 evita que dentro de tres años alguien "arregle" el esquema quitándola. Mejor todavía: COMMENT ON COLUMN.

Ejercicios

Ejercicio 1 — Reservas de bicicleta

El ayuntamiento quiere permitir reservar una bicicleta desde la app: la persona reserva una bici concreta de una estación y tiene 10 minutos para llegar y desbloquearla; pasado ese tiempo, la reserva caduca y la bici vuelve a estar disponible. Una persona solo puede tener una reserva activa.

  1. Decide si la reserva es una entidad nueva o un estado de algo existente, y justifícalo.
  2. Escribe el CREATE TABLE (o el ALTER TABLE) con todas las restricciones necesarias.
  3. Escribe la transacción de reserva, con su control de concurrencia.
  4. Explica cómo caducan las reservas y por qué esa decisión.

Ejercicio 2 — Tarifa reducida por bono social

Se introduce una tarifa reducida para personas con bono social municipal. La condición se acredita cada año y puede dejar de cumplirse. Un trayecto se cobra con la tarifa reducida si la persona tenía el bono social vigente en el momento del desbloqueo.

  1. Modela la acreditación del bono social sin duplicar la información de personas_abonadas.
  2. Explica por qué la restricción EXCLUDE de abonos no basta aquí.
  3. Escribe la consulta que calcula, para el mes pasado, cuánto ha dejado de ingresar el ayuntamiento por las tarifas reducidas.

Ejercicio 3 — Detectar bicicletas "fantasma"

Una bicicleta fantasma es la que lleva más de 48 horas en un trayecto abierto: se la robaron, se rompió el sistema de anclaje o la app falló al cerrarlo. Escribe la consulta que las lista con la última estación conocida, cuánto tiempo llevan y el nombre de contacto de la persona que la desbloqueó, ordenadas de más antigua a más reciente. Añade el índice que la haga eficiente, o explica por qué no hace falta ninguno.

Soluciones

Solución 1

1. Entidad nueva. Tres argumentos: (a) tiene atributos propios —instante de reserva, instante de caducidad, resultado—; (b) tiene historial: querremos saber cuántas reservas caducan sin usarse, y un estado sobreescrito no deja rastro; (c) relaciona tres cosas (persona, bici y anclaje) con temporalidad propia. Un estado = 'reservada' en bicicletas no permitiría ninguna de las tres.

2. El esquema:

CREATE TABLE reservas (
    reserva_id   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    abono_id     INTEGER     NOT NULL REFERENCES abonos ON DELETE RESTRICT,
    bicicleta_id INTEGER     NOT NULL REFERENCES bicicletas ON DELETE RESTRICT,
    estacion_id  INTEGER     NOT NULL,
    anclaje      SMALLINT    NOT NULL,
    ts_reserva   TIMESTAMPTZ NOT NULL DEFAULT now(),
    ts_limite    TIMESTAMPTZ NOT NULL
                 GENERATED ALWAYS AS (ts_reserva + INTERVAL '10 minutes') STORED,
    resultado    VARCHAR(10) NOT NULL DEFAULT 'activa'
                 CHECK (resultado IN ('activa','usada','caducada','cancelada')),
    FOREIGN KEY (estacion_id, anclaje) REFERENCES anclajes (estacion_id, numero)
);

CREATE UNIQUE INDEX uq_reserva_activa_abono ON reservas (abono_id)     WHERE resultado = 'activa';
CREATE UNIQUE INDEX uq_reserva_activa_bici  ON reservas (bicicleta_id) WHERE resultado = 'activa';
CREATE INDEX idx_reservas_caducar ON reservas (ts_limite) WHERE resultado = 'activa';

Los dos índices únicos parciales imponen "una reserva activa por persona" y "una reserva activa por bici" — el mismo patrón que RN4/RN5. El tercero es de servicio, para el proceso de caducidad.

3. La transacción:

BEGIN;
SELECT a.estacion_id, a.numero, a.bicicleta_id
  FROM anclajes a
  JOIN bicicletas b ON b.bicicleta_id = a.bicicleta_id
 WHERE a.estacion_id = 12 AND a.estado = 'operativo' AND b.estado = 'anclada'
   AND NOT EXISTS (SELECT 1 FROM reservas r
                    WHERE r.bicicleta_id = a.bicicleta_id AND r.resultado = 'activa')
 ORDER BY b.km_acumulados
 FOR UPDATE OF a SKIP LOCKED
 LIMIT 1;

INSERT INTO reservas (abono_id, bicicleta_id, estacion_id, anclaje)
VALUES (10233, 417, 12, 3);
COMMIT;

El anti-join NOT EXISTS excluye las bicis ya reservadas; el índice único parcial es la red de seguridad si dos peticiones lo esquivan. Fíjate en que no se marca la bici como reservada en bicicletas: el estado vive en reservas, un solo sitio.

4. La caducidad. Dos opciones y una elegida.

Descartada: un proceso que cada minuto marque como caducada las vencidas. Funciona, pero introduce una ventana en la que la reserva está vencida y todavía figura activa, y depende de que el proceso no se caiga.

Elegida: caducidad implícita en la lectura. La reserva se considera activa si resultado = 'activa' AND ts_limite > now(); el NOT EXISTS de arriba añade esa condición. El proceso de limpieza sigue existiendo, pero solo para dejar el histórico ordenado, y que se retrase no afecta a la corrección. La regla general: cuando el tiempo determina la validez de un dato, la verdad debe estar en la consulta, no en un proceso.

Solución 2

1. El modelo. Una tabla de acreditaciones con vigencia, no una columna booleana en personas_abonadas:

CREATE TABLE bonos_sociales (
    bono_id    INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    persona_id INTEGER   NOT NULL REFERENCES personas_abonadas ON DELETE RESTRICT,
    vigencia   DATERANGE NOT NULL,
    expediente VARCHAR(20) NOT NULL UNIQUE,
    EXCLUDE USING gist (persona_id WITH =, vigencia WITH &&)
);
ALTER TABLE tarifas ADD COLUMN bono_social BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE tarifas DROP CONSTRAINT tarifas_tipo_abono_vigencia_excl;
ALTER TABLE tarifas ADD EXCLUDE USING gist
      (tipo_abono WITH =, bono_social WITH =, vigencia WITH &&);

Una columna tiene_bono_social BOOLEAN sería el error clásico: no guarda desde cuándo, no guarda el expediente y, sobre todo, no permite reconstruir si lo tenía el día del trayecto. La acreditación es un hecho con fecha, y los hechos con fecha van en filas, no en columnas.

2. Por qué el EXCLUDE de abonos no basta. Ese EXCLUDE garantiza que no haya dos abonos solapados de la misma persona; no dice nada sobre qué tarifa se aplica. El bono social es una condición ortogonal al abono y con su propia vigencia: una persona puede tener un abono anual del 1 de enero al 31 de diciembre y un bono social que caduca el 30 de junio. A partir del 1 de julio, el mismo abono debe generar trayectos con tarifa normal. Como el trayecto ya congela su tarifa (RN9), el sistema queda correcto sin tocar nada más: basta con que la transacción de desbloqueo elija la tarifa consultando bonos_sociales con vigencia @> CURRENT_DATE.

3. La consulta de coste fiscal:

SELECT COUNT(*)                                     AS trayectos_reducidos,
       SUM(t.importe)                               AS ingresado,
       SUM(t.importe * (tn.precio_fraccion / NULLIF(t.precio_fraccion, 0)))
                                                    AS habria_ingresado,
       SUM(t.importe * (tn.precio_fraccion / NULLIF(t.precio_fraccion, 0)))
       - SUM(t.importe)                             AS coste_bonificacion
  FROM trayectos t
  JOIN tarifas   tr ON tr.tarifa_id = t.tarifa_id AND tr.bono_social
  JOIN tarifas   tn ON tn.tipo_abono = tr.tipo_abono
                   AND NOT tn.bono_social
                   AND tn.vigencia && tr.vigencia
 WHERE t.ts_fin IS NOT NULL
   AND t.ts_inicio >= date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
   AND t.ts_inicio <  date_trunc('month', CURRENT_DATE);
 trayectos_reducidos | ingresado | habria_ingresado | coste_bonificacion
---------------------+-----------+------------------+--------------------
                8412 |   2184.60 |          4369.20 |            2184.60

El NULLIF protege del cero: si la tarifa reducida tuviera precio_fraccion = 0, la división reventaría.

Solución 3

SELECT b.matricula, b.tipo,
       e.codigo AS ultima_estacion, e.nombre,
       t.ts_inicio,
       date_trunc('minute', now() - t.ts_inicio) AS tiempo_abierto,
       p.nombre || ' ' || p.apellidos AS contacto, p.telefono
  FROM trayectos t
  JOIN bicicletas b USING (bicicleta_id)
  JOIN estaciones e ON e.estacion_id = t.estacion_origen
  JOIN abonos    ab USING (abono_id)
  JOIN personas_abonadas p USING (persona_id)
 WHERE t.ts_fin IS NULL
   AND t.ts_inicio < now() - INTERVAL '48 hours'
 ORDER BY t.ts_inicio;
 matricula |   tipo    | ultima_estacion |   nombre    |       ts_inicio        | tiempo_abierto | contacto
-----------+-----------+-----------------+-------------+------------------------+----------------+-----------
 VB-0233   | mecanica  | VB-041          | Estación 41 | 2026-06-09 19:12:04+02 | 5 days 03:41   | ...
 VB-0781   | electrica | VB-007          | Estación 7  | 2026-06-12 08:33:51+02 | 2 days 14:19   | ...

Sobre el índice: no hace falta ninguno nuevo. El filtro ts_fin IS NULL es tremendamente selectivo —como máximo 900 filas de 1,6 millones— y uq_trayecto_abierto_bici ya cubre exactamente ese predicado parcial. El planificador puede recorrerlo entero (900 entradas) y filtrar después por ts_inicio; añadir un índice sobre (ts_inicio) WHERE ts_fin IS NULL mejoraría marginalmente una consulta que se ejecuta una vez al día y añadiría mantenimiento a 4.400 inserciones diarias. No es rentable. Este es el razonamiento que 06-03 pedía: un índice se justifica por la frecuencia de la consulta y el coste de escritura, no por el tamaño de la tabla.

Conclusión

Has recorrido un proyecto relacional entero: de una transcripción de reunión de dos párrafos a un sistema con once tablas, veinte restricciones que codifican reglas de negocio reales, dos transacciones concurrentes correctas, cinco informes de explotación y un plan de operación.

Lo que conviene llevarse no es el esquema —el de tu próximo proyecto será otro— sino la forma de las decisiones. Cada una de las importantes tuvo el mismo formato: una tentación simple, una pregunta al cliente que la desmontó, dos o tres alternativas puestas en una tabla y un criterio explícito para elegir. El anclaje es una entidad porque tiene estado propio. El trayecto es una entidad porque nace incompleto y tiene identidad. La jerarquía usa tabla por subclase porque otras tablas referencian la superclase y porque el número de serie de la batería tiene que ser obligatorio. bicis_disponibles rompe la 3FN por velocidad y se vigila; la tarifa congelada la rompe por semántica y vigilarla sería un error. Ninguna de esas frases es una preferencia estética: todas son argumentos que se pueden discutir y, si hiciera falta, rebatir.

También has visto que las herramientas del curso no se usan de una en una. El EXCLUDE USING gist de 04-04 impone una regla de negocio de 04-01; el índice único parcial que impone RN5 resulta ser, sin cambiar una línea, el índice que arregla la consulta más frecuente del sistema; la desnormalización de 05-04 necesita el disparador de 05-04 y la auditoría nocturna de 05-04, las tres cosas o ninguna. Un sistema en producción es esto: piezas del temario funcionando a la vez y sosteniéndose entre sí.

Y termina con una lista de tres fracasos, que es la parte más honesta del caso. La telemetría GPS, la ficha enriquecida de la estación y las incidencias de estructura variable no encajan bien en este esquema, y forzarlas ahí produciría columnas nulas, tablas EAV y migraciones cada vez que alguien inventa un tipo de incidencia. En la lección 08-02 cogemos esas tres piezas exactas —ni una más— y las modelamos en MongoDB con el método de 03-03, comprobando qué se gana, qué se pierde y por qué la tentación de "migrarlo todo a Mongo" es la respuesta equivocada. El núcleo transaccional que acabas de construir se queda donde está: es él quien cobra el dinero.

Fundamentos de Bases de Datos

Módulo 1: Introducción a las Bases de Datos

Módulo 2: Bases de Datos Relacionales

Módulo 3: Bases de Datos No Relacionales

Módulo 4: Diseño de Esquemas

Módulo 5: Normalización

Módulo 6: Transacciones, Rendimiento y Seguridad

Módulo 7: Ejercicios Prácticos

Módulo 8: Casos de Estudio

Módulo 9: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados