Tornem a mirar el plànol complet, tal com vam prometre en tancar el mòdul 7. Durant set mòduls has construït BiblioRed a base de peces: una taula aquí, un JOIN allà, un índex quan alguna cosa anava lenta. Cada peça s'explicava per separat perquè calia aprendre-la per separat. En un projecte real ningú no et lliura les peces ordenades: et lliuren una conversa amb un client i has d'arribar tu sol des d'allà fins a un sistema en producció.

Això és el que fa aquesta lliçó. L'ajuntament de Vallmar, satisfet amb la xarxa de biblioteques, acaba d'encarregar un segon sistema: VallBici, el servei municipal de bicicletes compartides. No és una ampliació de BiblioRed; és un domini nou, amb les seves pròpies regles, els seus propis problemes de concurrència i els seus propis informes. BiblioRed apareixerà de tant en tant com a terme de comparació —«això ja ho vam resoldre així, aquí canvia perquè…»—, però la feina és nova de cap a peus.

El recorregut és el d'un projecte real i en el seu ordre real: l'encàrrec, els requisits escrits, el model conceptual, l'esquema físic, la comprovació de normalització, la càrrega de dades, les transaccions crítiques, els informes, el rendiment i l'operació. A cada punt on cal decidir alguna cosa, la decisió va acompanyada de la seva justificació i de les alternatives descartades. Aquest és el contingut de la lliçó: no el SQL final —que es copia en deu minuts— sinó el raonament que hi porta.

I acaba on ha d'acabar un cas honest: enumerant les tres coses que aquest esquema resol malament, que són exactament el material de la lliçó 08-02.

Contingut

  1. L'encàrrec: la conversa inicial
  2. Document de requisits VallBici v1.0
  3. Model conceptual: entitats, relacions i tres decisions difícils
  4. El diagrama ER
  5. Esquema físic: el CREATE TABLE comentat
  6. Les restriccions que codifiquen les regles de negoci
  7. Comprovació de normalització i dues desnormalitzacions deliberades
  8. Càrrega de dades i volum realista
  9. Les dues transaccions crítiques: desbloquejar i ancorar
  10. Consultes d'explotació: els informes de l'ajuntament
  11. Rendiment: les dues consultes que es degraden
  12. Operació: rols, dades personals i còpies
  13. Què resol malament aquest esquema
  14. Errors Habituals i Consells
  15. Exercicis
  16. Conclusió

  1. L'encàrrec: la conversa inicial

La primera reunió amb l'àrea de Mobilitat de l'ajuntament produeix això, transcrit gairebé literalment:

«Tenim 60 estacions repartides pels cinc districtes de la ciutat i 900 bicicletes, unes de mecàniques i unes altres d'elèctriques. Cada estació té un nombre fix d'ancoratges; una bici ocupa un ancoratge quan està aparcada. Hi ha 24.000 persones abonades. Una persona desbloqueja una bici en una estació, fa el seu trajecte i l'ancora en una altra. Hi ha tres tipus d'abonament —anual, mensual i turístic de tres dies— i si et passes del temps inclòs es cobra un recàrrec. Les bicis passen revisions, s'avarien i es retiren al taller. L'aplicació del mòbil ha de mostrar la disponibilitat en temps real, recordar la sessió, deixar buscar estacions per nom o per adreça i guardar el rastre GPS de cada trajecte. I volem informes mensuals d'ús per districte i franja horària.»

Apliquem la tècnica de 04-01: subratllar els substantius (candidats a entitat), els verbs (candidats a relació) i les frases amb "no pot", "sempre", "cada" (candidates a restricció). Però abans cal fer una cosa que a 04-01 vam insistir molt: tornar a preguntar. Un encàrrec de dos paràgrafs sempre amaga decisions que el client dona per evidents i no ho són.

Pregunta al client Resposta Conseqüència de disseny
Un ancoratge pot estar avariat sense que ho estigui l'estació? Sí, passa sovint L'ancoratge necessita estat propi
Pot una persona tenir dos abonaments alhora? No, mai solapats; sí consecutius Restricció de no solapament
I dos trajectes oberts alhora? Impossible: només es desbloqueja amb l'app Índex únic parcial
Si puja la tarifa, canvia el preu de trajectes ja cobrats? Mai. Seria il·legal Tarifa congelada al trajecte
Què passa si una bici desapareix? Es dona de baixa, però el seu historial es conserva ON DELETE RESTRICT + baixa lògica
L'elèctrica i la mecànica es cobren igual? L'elèctrica porta un recàrrec fix per desbloqueig La jerarquia afecta el preu
Cada quant voleu els informes? Mensual, però el tauler d'operacions és diari Dos perfils de consulta diferents

Aquestes set respostes valen més que les cent línies de SQL següents. La quarta, en particular, és la que evita l'error car: si la tarifa no es congela, el primer canvi de preus reescriu la facturació de dos anys.

  1. Document de requisits VallBici v1.0

Requisits funcionals

Id Requisit
RF1 Registrar persones abonades amb les seves dades de contacte i el seu districte de residència
RF2 Vendre abonaments anuals, mensuals i turístics de 3 dies, amb el seu període de vigència
RF3 Mantenir l'inventari d'estacions, ancoratges i bicicletes, amb el seu estat
RF4 Registrar el desbloqueig d'una bicicleta i el seu ancoratge posterior, amb instants exactes
RF5 Calcular l'import de cada trajecte segons la tarifa vigent en el moment del desbloqueig
RF6 Registrar revisions i avaries, i retirar bicicletes al taller
RF7 Publicar la disponibilitat de cada estació
RF8 Produir els informes d'explotació de l'apartat següent

Regles de negoci

Id Regla On es codifica
RN1 Un ancoratge allotja com a màxim una bicicleta PRIMARY KEY d'ancoratges
RN2 Una bicicleta és com a màxim en un ancoratge UNIQUE (bicicleta_id)
RN3 Una persona no pot tenir dos abonaments amb vigències solapades EXCLUDE USING gist
RN4 Una bicicleta no pot tenir dos trajectes oberts Índex únic parcial
RN5 Una persona no pot tenir dos trajectes oberts Índex únic parcial
RN6 L'instant d'ancoratge és posterior al de desbloqueig CHECK
RN7 Un trajecte tancat té estació destí, ancoratge destí, instant de fi i import; un d'obert no en té cap dels quatre CHECK conjunt
RN8 El nombre de bicis en una estació mai no supera el seu nombre d'ancoratges CHECK
RN9 La tarifa aplicada a un trajecte no canvia encara que canviïn les tarifes Còpia de tarifa a trajectes
RN10 Un trajecte només es pot iniciar amb un abonament en vigor Lògica de la transacció

Consultes que l'ajuntament vol poder respondre

Aquesta llista és part del requisit, no un extra. És el que a 04-01 anomenàvem el criteri d'èxit de l'esquema: un model que no les pot respondre és un model fallit, per elegant que sigui.

  • C1 — Trajectes per districte i franja horària, mes a mes.
  • C2 — Els deu parells d'estacions origen→destí més freqüents.
  • C3 — Estacions que es buiden o s'omplen sistemàticament, per franja (el problema del reequilibratge: és el que costa diners de debò, perquè obliga a moure bicis en furgoneta).
  • C4 — Ingressos per tipus d'abonament, separant quotes de recàrrecs.
  • C5 — Bicicletes amb més avaries per hora d'ús (no en termes absoluts: una bici molt usada s'avaria més i això no la fa dolenta).

  1. Model conceptual: entitats, relacions i tres decisions difícils

Del text surten sense discussió: districte, estació, ancoratge, bicicleta, model de bicicleta, persona abonada, tipus d'abonament, tarifa, abonament, trajecte, ordre de taller i cobrament. El que sí que té discussió són tres decisions, i són les que separen un model que aguanta d'un que no.

Decisió 1 — L'ancoratge és una entitat o és un número dins de l'estació?

La temptació: guardar a estacions una columna num_ancoratges i una altra bicis_disponibles, i no modelar l'ancoratge. És més simple i aparentment suficient: per pintar l'app n'hi ha prou de saber quantes bicis hi ha.

Per què es descarta. Tres motius, en ordre de pes:

  1. El client va dir que un ancoratge es pot avariar tot sol. Un atribut no té estat; una entitat sí. Sense ancoratges, una estació amb 20 ancoratges i 3 de trencats continua "tenint capacitat 20" i el sistema promet places que no existeixen.
  2. L'app ha de dir a la persona en quin ancoratge és la bici que ha reservat i en quin l'ha de deixar. Aquesta dada no existeix si l'ancoratge no existeix.
  3. RN1 i RN2 —"un ancoratge, una bici; una bici, un ancoratge"— són restriccions d'integritat, i al mòdul 4 vam fixar el principi: una restricció que l'esquema pot imposar no es delega al codi. Amb ancoratges com a taula són una clau primària i un UNIQUE; sense ella, són codi d'aplicació i confiança.

Com es modela: l'ancoratge és una entitat feble de l'estació (regla 8 de 04-03), amb clau primària composta (estacio_id, numero). El número 7 només significa alguna cosa dins d'una estació concreta, exactament igual que el número de rebut d'una multa depenia de la multa a BiblioRed.

Decisió 2 — El trajecte és una relació o una entitat?

Un trajecte connecta una persona (via el seu abonament), una bicicleta i dues estacions. En termes de 04-02 seria una relació de grau 4, i les relacions de grau alt són gairebé sempre un símptoma que falta una entitat.

Es modela com a entitat, i per quatre raons:

  1. atributs propis i abundants: dos instants, durada, import, tarifa congelada.
  2. identitat: la mateixa persona pot fer el mateix trajecte entre les mateixes dues estacions amb la mateixa bici dues vegades el mateix dia, i són dos fets diferents. Una taula d'unió amb clau composta els confondria.
  3. Neix incomplet. Quan es desbloqueja la bici només es coneix la meitat del trajecte. Una relació que existeix a mitges és una entitat amb columnes nul·les, no una relació.
  4. Altres coses el referencien: el cobrament del recàrrec apunta al trajecte.

Les dues estacions són dues relacions 1:N diferents cap a estacions (regla 5 de 04-03): estacio_origen i estacio_desti. No és un cas rar: és el mateix patró que "vol amb aeroport de sortida i d'arribada", i l'única precaució és no oblidar que les dues claus foranes apunten a la mateixa taula, cosa que obliga a posar àlies a totes les consultes que les facin servir.

Detall fi: la clau forana no apunta a estacions sinó a ancoratges(estacio_id, numero), perquè ens interessa saber de quin ancoratge concret va sortir i en quin va entrar. L'estació queda determinada per l'ancoratge.

Decisió 3 — La jerarquia mecànica / elèctrica

Tota bicicleta és mecànica o elèctrica (jerarquia total i disjunta). Les elèctriques tenen tres atributs que a les mecàniques no tenen sentit: capacitat de bateria, autonomia i número de sèrie de la bateria. A 04-03, regla 10, vam veure les tres estratègies. Repassem la decisió amb els criteris d'allà:

Criteri Taula única Taula per subclasse Taula per classe concreta
Atributs específics 3 columnes nul·les en 900 files Sense nuls Sense nuls
trajectes pot referenciar la superclasse? No — necessitaria dues FK
"Totes les bicis de l'estació 12" Trivial Trivial UNION de dues branques
n_serie_bateria obligatori i únic només en elèctriques Impossible amb NOT NULL Directe Directe
Cost de consulta típica Cap Un LEFT JOIN ocasional UNION sempre

Triem taula per subclasse (estratègia 2), igual que vam fer amb els materials de BiblioRed i pel mateix motiu dominant: hi ha taules que referencien la superclasse. trajectes, ancoratges i ordres_taller apunten a "una bicicleta", sense importar de quin tipus, i l'estratègia 3 faria això impossible sense duplicar totes les claus foranes.

L'estratègia 1 (taula única) era defensable —només són tres columnes— i amb 900 files el malbaratament és irrellevant. Es descarta pel quart criteri: n_serie_bateria ha de ser obligatori i únic en les elèctriques, i en una taula única només podria ser opcional. És exactament l'argument que a BiblioRed ens va fer separar materials_llibre per l'isbn.

I com a 04-03, l'estratègia 2 arrossega el seu forat conegut: res no impedeix per si sol que una bici amb tipus = 'mecanica' tingui fila a bicicletes_electriques. Es tapa amb el truc de la clau forana discriminada, que veuràs a l'esquema.

  1. El diagrama ER

erDiagram
    DISTRICTE     ||--o{ ESTACIO            : agrupa
    DISTRICTE     ||--o{ PERSONA_ABONADA    : "resideix a"
    ESTACIO       ||--|{ ANCORATGE          : conte
    ANCORATGE     |o--o| BICICLETA          : allotja
    MODEL_BICI    ||--o{ BICICLETA          : "es del model"
    BICICLETA     ||--o| BICICLETA_ELECTRICA : "especialitza a"
    BICICLETA     ||--o{ TRAJECTE           : "es fa servir a"
    BICICLETA     ||--o{ ORDRE_TALLER       : "passa per"
    PERSONA_ABONADA ||--o{ ABONAMENT        : contracta
    TIPUS_ABONAMENT ||--o{ ABONAMENT        : classifica
    TIPUS_ABONAMENT ||--o{ TARIFA           : "es tarifa amb"
    TARIFA        ||--o{ ABONAMENT          : "fixa preu de"
    TARIFA        ||--o{ TRAJECTE           : "congelada a"
    ABONAMENT     ||--o{ TRAJECTE           : autoritza
    ANCORATGE     ||--o{ TRAJECTE           : "es origen de"
    ANCORATGE     ||--o{ TRAJECTE           : "es desti de"
    PERSONA_ABONADA ||--o{ COBRAMENT        : paga
    ABONAMENT     ||--o{ COBRAMENT          : "genera quota"
    TRAJECTE      ||--o{ COBRAMENT          : "genera recarrec"

Llegeix-lo amb la notació de 04-02: || és participació total i cardinalitat 1, o{ és cardinalitat N amb participació parcial, |{ és N amb participació total. Que ESTACIO ||--|{ ANCORATGE sigui total als dos costats diu una cosa certa i no trivial: una estació sense cap ancoratge no és una estació, i un ancoratge sense estació no existeix. Que ANCORATGE |o--o| BICICLETA sigui parcial en tots dos costats diu el contrari: hi ha ancoratges buits i hi ha bicis fora de tot ancoratge (circulant o al taller).

  1. Esquema físic: el CREATE TABLE comentat

Dues extensions abans de començar. btree_gist permet barrejar en un EXCLUDE columnes d'igualtat (un enter) amb columnes de solapament (un rang), que és just el que demanen RN3 i les tarifes.

CREATE EXTENSION IF NOT EXISTS btree_gist;

Inventari

CREATE TABLE districtes (
    districte_id SMALLINT     PRIMARY KEY,           -- 5 files: clau natural, estable
    nom          VARCHAR(40)  NOT NULL UNIQUE
);

CREATE TABLE estacions (
    estacio_id        INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    codi              CHAR(6)      NOT NULL UNIQUE,  -- 'VB-012', el que va retolat
    nom               VARCHAR(80)  NOT NULL,
    adreca            VARCHAR(120) NOT NULL,
    districte_id      SMALLINT     NOT NULL REFERENCES districtes 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_ancoratges    SMALLINT     NOT NULL CHECK (num_ancoratges BETWEEN 8 AND 40),
    estat             VARCHAR(14)  NOT NULL DEFAULT 'activa'
                      CHECK (estat IN ('activa','manteniment','retirada')),
    data_alta         DATE         NOT NULL DEFAULT CURRENT_DATE,
    -- Desnormalització deliberada núm. 1 (es justifica a l'apartat 7)
    bicis_disponibles SMALLINT     NOT NULL DEFAULT 0 CHECK (bicis_disponibles >= 0),
    CONSTRAINT ck_hi_cap_a_estacio CHECK (bicis_disponibles <= num_ancoratges)  -- RN8
);

Per què aquests tipus, un a un:

Columna Tipus triat Alternativa descartada i per què
districte_id SMALLINT natural IDENTITY: cinc districtes que no canvien mai no necessiten clau subrogada
estacio_id INTEGER IDENTITY SERIAL: obsolet des de PostgreSQL 10; IDENTITY és estàndard SQL i no deixa seqüències òrfenes
codi CHAR(6) Seria la clau natural, però un rètol es repinta: es queda com a UNIQUE, no com a PK
latitud/longitud NUMERIC(9,6) FLOAT: precisió decimal exacta i ≈11 cm de resolució n'hi ha prou. Quan calgui geometria de debò, PostGIS (ho discutim a 08-02)
num_ancoratges SMALLINT INTEGER: cap estació no tindrà 33.000 ancoratges
estat VARCHAR + CHECK ENUM: afegir un valor a un ENUM requereix ALTER TYPE; un CHECK es canvia amb ALTER TABLE i es llegeix al \d
CREATE TABLE models_bici (
    model_id    SMALLINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    fabricant   VARCHAR(40) NOT NULL,
    nom         VARCHAR(40) NOT NULL,
    tipus       VARCHAR(10) NOT NULL CHECK (tipus IN ('mecanica','electrica')),
    pes_kg      NUMERIC(4,1) NOT NULL CHECK (pes_kg > 0),
    UNIQUE (fabricant, nom)
);

CREATE TABLE bicicletes (
    bicicleta_id  INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    matricula     CHAR(7)     NOT NULL UNIQUE,       -- 'VB-0417'
    model_id      SMALLINT    NOT NULL REFERENCES models_bici ON DELETE RESTRICT,
    tipus         VARCHAR(10) NOT NULL CHECK (tipus IN ('mecanica','electrica')),
    data_alta     DATE        NOT NULL DEFAULT CURRENT_DATE,
    estat         VARCHAR(10) NOT NULL DEFAULT 'ancorada'
                  CHECK (estat IN ('ancorada','en_us','taller','baixa')),
    km_acumulats  NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (km_acumulats >= 0),
    -- Redundant com a clau, però necessària: és l'àncora del discriminant
    CONSTRAINT uq_bici_tipus UNIQUE (bicicleta_id, tipus)
);

CREATE TABLE bicicletes_electriques (
    bicicleta_id    INTEGER     PRIMARY KEY,
    tipus           VARCHAR(10) NOT NULL DEFAULT 'electrica' CHECK (tipus = 'electrica'),
    capacitat_wh    SMALLINT    NOT NULL CHECK (capacitat_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, tipus)
        REFERENCES bicicletes (bicicleta_id, tipus) ON DELETE CASCADE
);

El truc del discriminant, explicat. La clau forana composta (bicicleta_id, tipus) obliga que la fila referenciada de bicicletes tingui tipus = 'electrica', perquè el CHECK d'aquesta taula fixa tipus a aquell valor. Resultat: és impossible donar d'alta una bateria per a una bici mecànica. El forat que 04-03 deixava obert a l'estratègia 2 queda tapat en la meitat que importa. L'altra meitat —que una elèctrica no tingui fila aquí— continua sense poder-se imposar declarativament i es controla en el procés d'alta.

CREATE TABLE ancoratges (
    estacio_id   INTEGER     NOT NULL REFERENCES estacions ON DELETE CASCADE,
    numero       SMALLINT    NOT NULL CHECK (numero > 0),
    estat        VARCHAR(10) NOT NULL DEFAULT 'operatiu'
                 CHECK (estat IN ('operatiu','avariat','bloquejat')),
    bicicleta_id INTEGER     REFERENCES bicicletes ON DELETE SET NULL,
    PRIMARY KEY (estacio_id, numero),                  -- RN1: entitat feble
    CONSTRAINT uq_bici_en_un_ancoratge UNIQUE (bicicleta_id)  -- RN2
);

Les dues regles més importants del sistema són dues línies d'esquema. La clau primària composta impedeix que l'ancoratge 7 de l'estació 12 allotgi dues bicis. L'UNIQUE (bicicleta_id) impedeix que la bici 417 sigui simultàniament en dos ancoratges; funciona perquè a PostgreSQL UNIQUE admet tants NULL com vulgui, així que tots els ancoratges buits conviuen sense conflicte.

ON DELETE CASCADE d'ancoratges cap a estacions està justificat: un ancoratge no té vida pròpia fora de la seva estació (és l'acció que 02-06 reservava precisament per a entitats febles). ON DELETE SET NULL cap a bicicletes és el correcte per al contrari: si una bici desaparegués del sistema, l'ancoratge ha de quedar lliure, no desaparèixer.

Persones, abonaments i tarifes

CREATE TABLE persones_abonades (
    persona_id     INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    document_hash  CHAR(64)     NOT NULL UNIQUE,      -- SHA-256 amb sal; veure apartat 12
    nom            VARCHAR(60)  NOT NULL,
    cognoms        VARCHAR(80)  NOT NULL,
    email          VARCHAR(120) NOT NULL UNIQUE,
    telefon        VARCHAR(20),
    data_naix      DATE         NOT NULL,
    districte_id   SMALLINT     REFERENCES districtes ON DELETE SET NULL,
    data_registre  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    data_baixa     DATE,
    CONSTRAINT ck_edat_minima CHECK (data_naix <= CURRENT_DATE - INTERVAL '14 years'),
    CONSTRAINT ck_baixa_posterior CHECK (data_baixa IS NULL
                                    OR data_baixa >= data_registre::date)
);

CREATE TABLE tipus_abonament (
    tipus_abonament VARCHAR(12) PRIMARY KEY
                    CHECK (tipus_abonament IN ('anual','mensual','turistic')),
    descripcio      VARCHAR(60) NOT NULL,
    durada          INTERVAL    NOT NULL              -- '1 year', '1 mon', '3 days'
);

CREATE TABLE tarifes (
    tarifa_id         INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tipus_abonament   VARCHAR(12)  NOT NULL REFERENCES tipus_abonament ON DELETE RESTRICT,
    vigencia          DATERANGE    NOT NULL,
    quota             NUMERIC(6,2) NOT NULL CHECK (quota >= 0),
    minuts_inclosos   SMALLINT     NOT NULL CHECK (minuts_inclosos >= 0),
    minuts_fraccio    SMALLINT     NOT NULL CHECK (minuts_fraccio > 0),
    preu_fraccio      NUMERIC(5,2) NOT NULL CHECK (preu_fraccio >= 0),
    recarrec_electrica NUMERIC(5,2) NOT NULL DEFAULT 0 CHECK (recarrec_electrica >= 0),
    -- Dues tarifes del mateix tipus d'abonament no poden estar vigents alhora
    EXCLUDE USING gist (tipus_abonament WITH =, vigencia WITH &&)
);

CREATE TABLE abonaments (
    abonament_id    INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    persona_id      INTEGER      NOT NULL REFERENCES persones_abonades ON DELETE RESTRICT,
    tipus_abonament VARCHAR(12)  NOT NULL REFERENCES tipus_abonament ON DELETE RESTRICT,
    tarifa_id       INTEGER      NOT NULL REFERENCES tarifes ON DELETE RESTRICT,
    vigencia        DATERANGE    NOT NULL,
    import          NUMERIC(6,2) NOT NULL CHECK (import >= 0),
    estat           VARCHAR(10)  NOT NULL DEFAULT 'actiu'
                    CHECK (estat IN ('actiu','suspes','anullat')),
    CONSTRAINT ck_vigencia_acotada CHECK (NOT lower_inf(vigencia) AND NOT upper_inf(vigencia)),
    -- RN3: res d'abonaments solapats de la mateixa persona
    EXCLUDE USING gist (persona_id WITH =, vigencia WITH &&) WHERE (estat <> 'anullat')
);

Sobre NUMERIC per als diners no hi ha debat i convé repetir-ho perquè és l'error més car que es comet amb els tipus: FLOAT no representa exactament 0,10, i un sistema que suma 1,6 milions d'imports amb FLOAT produeix un descompte comptable que ningú no sabrà explicar. NUMERIC(6,2) dona fins a 9.999,99 €, de sobres per a una quota anual.

DATERANGE en lloc de dues columnes data_inici/data_fi és el que fa possible l'EXCLUDE. Amb dues columnes soltes, "aquests dos abonaments se solapen" és una consulta amb quatre comparacions que cal escriure bé cada vegada; amb un rang és l'operador && i ho imposa el motor.

Trajectes, taller i cobraments

CREATE TABLE trajectes (
    trajecte_id       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    abonament_id      INTEGER     NOT NULL REFERENCES abonaments ON DELETE RESTRICT,
    bicicleta_id      INTEGER     NOT NULL REFERENCES bicicletes ON DELETE RESTRICT,
    estacio_origen    INTEGER     NOT NULL,
    ancoratge_origen  SMALLINT    NOT NULL,
    ts_inici          TIMESTAMPTZ NOT NULL DEFAULT now(),
    estacio_desti     INTEGER,
    ancoratge_desti   SMALLINT,
    ts_fi             TIMESTAMPTZ,
    durada            INTERVAL GENERATED ALWAYS AS (ts_fi - ts_inici) STORED,
    -- Desnormalització deliberada núm. 2: la tarifa congelada (RN9)
    tarifa_id         INTEGER      NOT NULL REFERENCES tarifes ON DELETE RESTRICT,
    minuts_inclosos   SMALLINT     NOT NULL,
    minuts_fraccio    SMALLINT     NOT NULL,
    preu_fraccio      NUMERIC(5,2) NOT NULL,
    recarrec_electrica NUMERIC(5,2) NOT NULL DEFAULT 0,
    import            NUMERIC(6,2) CHECK (import >= 0),
    FOREIGN KEY (estacio_origen, ancoratge_origen)
        REFERENCES ancoratges (estacio_id, numero) ON DELETE RESTRICT,
    FOREIGN KEY (estacio_desti,  ancoratge_desti)
        REFERENCES ancoratges (estacio_id, numero) ON DELETE RESTRICT,
    CONSTRAINT ck_ordre_temporal CHECK (ts_fi IS NULL OR ts_fi > ts_inici),      -- RN6
    CONSTRAINT ck_tancament_complet CHECK (                                      -- RN7
         (ts_fi IS NULL     AND estacio_desti IS NULL
                            AND ancoratge_desti IS NULL AND import IS NULL)
      OR (ts_fi IS NOT NULL AND estacio_desti IS NOT NULL
                            AND ancoratge_desti IS NOT NULL AND import IS NOT NULL))
);

-- RN4 i RN5: ni la bici ni la persona poden tenir dos trajectes oberts
CREATE UNIQUE INDEX uq_trajecte_obert_bici ON trajectes (bicicleta_id) WHERE ts_fi IS NULL;
CREATE UNIQUE INDEX uq_trajecte_obert_abon ON trajectes (abonament_id) WHERE ts_fi IS NULL;

Tres tipus que mereixen un comentari:

  • TIMESTAMPTZ, no TIMESTAMP. Vallmar canvia d'hora dues vegades l'any. El diumenge d'octubre en què els rellotges endarrereixen, un TIMESTAMP sense zona converteix "02:30" en un instant ambigu, i els trajectes d'aquella matinada poden sortir amb durada negativa. TIMESTAMPTZ guarda un instant absolut i l'ambigüitat desapareix. El preu és recordar-se de convertir a hora local quan s'agrupa per franja horària, cosa que farem explícitament a C1.
  • INTERVAL generada. durada no s'emmagatzema a mà: és una columna generada (04-04). Compleix la regla 4 de 04-03 —els derivats no s'emmagatzemen— sense renunciar a poder indexar-la, perquè STORED sí que ocupa disc però es calcula sola i mai no pot discrepar de les seves fonts.
  • BIGINT a trajecte_id. Amb 1,6 milions de trajectes l'any, INTEGER (2.147 milions) trigaria més de mil anys a esgotar-se. Tot i així es fa servir BIGINT: canviar el tipus d'una clau primària en producció és una de les migracions més doloroses que hi ha, i el cost avui són quatre bytes per fila.
CREATE TABLE ordres_taller (
    ordre_id     BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    bicicleta_id INTEGER     NOT NULL REFERENCES bicicletes ON DELETE RESTRICT,
    tipus        VARCHAR(10) NOT NULL CHECK (tipus IN ('revisio','avaria')),
    motiu        VARCHAR(60) NOT NULL,
    ts_obertura  TIMESTAMPTZ NOT NULL DEFAULT now(),
    ts_tancament TIMESTAMPTZ,
    cost         NUMERIC(7,2) CHECK (cost >= 0),
    CONSTRAINT ck_tancament_taller CHECK (ts_tancament IS NULL OR ts_tancament >= ts_obertura)
);
CREATE UNIQUE INDEX uq_ordre_oberta ON ordres_taller (bicicleta_id) WHERE ts_tancament IS NULL;

CREATE TABLE cobraments (
    cobrament_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    persona_id   INTEGER      NOT NULL REFERENCES persones_abonades ON DELETE RESTRICT,
    concepte     VARCHAR(10)  NOT NULL CHECK (concepte IN ('abonament','recarrec')),
    abonament_id INTEGER      REFERENCES abonaments ON DELETE RESTRICT,
    trajecte_id  BIGINT       REFERENCES trajectes  ON DELETE RESTRICT,
    import       NUMERIC(6,2) NOT NULL CHECK (import > 0),
    ts_cobrament TIMESTAMPTZ  NOT NULL DEFAULT now(),
    estat        VARCHAR(10)  NOT NULL DEFAULT 'pendent'
                 CHECK (estat IN ('pendent','cobrat','fallit','retornat')),
    CONSTRAINT ck_origen_del_cobrament CHECK (
         (concepte = 'abonament' AND abonament_id IS NOT NULL AND trajecte_id  IS NULL)
      OR (concepte = 'recarrec'  AND trajecte_id  IS NOT NULL AND abonament_id IS NULL))
);

ck_origen_del_cobrament és un exemple del patró que 04-04 anomenava restricció de coherència entre columnes: no n'hi ha prou que cada columna sigui vàlida per separat; la combinació ha de tenir sentit. Un cobrament de concepte abonament amb trajecte_id emplenat és una dada incoherent, i l'esquema la rebutja.

  1. Les restriccions que codifiquen les regles de negoci

Val la pena veure les tres més interessants fallant, perquè d'una restricció que mai no has vist saltar no saps si funciona.

-- RN2: intentar posar la bici 417, que ja és a l'ancoratge 3 de l'estació 12,
--      també a l'ancoratge 5 de l'estació 12
UPDATE ancoratges SET bicicleta_id = 417 WHERE estacio_id = 12 AND numero = 5;
ERROR:  duplicate key value violates unique constraint "uq_bici_en_un_ancoratge"
DETAIL:  Key (bicicleta_id)=(417) already exists.
-- RN3: la persona 8801 ja té un abonament anual del 2026-01-01 al 2027-01-01
INSERT INTO abonaments (persona_id, tipus_abonament, tarifa_id, vigencia, import)
VALUES (8801, 'mensual', 7, daterange('2026-06-01','2026-07-01'), 12.00);
ERROR:  conflicting key value violates exclusion constraint
        "abonaments_persona_id_vigencia_excl"
DETAIL:  Key (persona_id, vigencia)=(8801, [2026-06-01,2026-07-01)) conflicts
         with existing key (persona_id, vigencia)=(8801, [2026-01-01,2027-01-01)).
-- RN4: la bici 417 ja té un trajecte obert
INSERT INTO trajectes (abonament_id, bicicleta_id, estacio_origen, ancoratge_origen,
                       tarifa_id, minuts_inclosos, minuts_fraccio, preu_fraccio)
VALUES (10233, 417, 12, 3, 7, 30, 15, 0.60);
ERROR:  duplicate key value violates unique constraint "uq_trajecte_obert_bici"
DETAIL:  Key (bicicleta_id)=(417) already exists.

Aquest últim és el més valuós dels tres. La regla "una bici no pot estar en dos trajectes oberts" sembla codi d'aplicació pur, i en el 90 % dels sistemes ho és —amb el resultat que, sota càrrega, dues peticions simultànies la violen alegrement—. Un índex únic parcial la converteix en una garantia del motor que cap condició de cursa no pot burlar.

  1. Comprovació de normalització i dues desnormalitzacions deliberades

Repassem l'esquema amb el mètode de 05-03. Per a cada taula: identificar la clau, llistar les dependències funcionals i comprovar que tot atribut no primer depèn de la clau completa i de res més.

Taula Clau Dependències problemàtiques Veredicte
districtes districte_id Cap FNBC
estacions estacio_id bicis_disponibles és derivable 3FN trencada a propòsit (veure a sota)
ancoratges (estacio_id, numero) Cap: estat i bicicleta_id depenen del parell complet FNBC
bicicletes bicicleta_id tipus també és a models_bici Veure nota
abonaments abonament_id tipus_abonament és deduïble via tarifa_id Veure nota
trajectes trajecte_id Les quatre columnes de tarifa depenen de tarifa_id 2FN/3FN trencada a propòsit
cobraments cobrament_id Cap FNBC

La nota sobre bicicletes.tipus. Existeix la dependència model_id → tipus, i model_id no és clau: és una dependència transitiva i per tant una violació de 3FN de manual. Es conserva per una raó concreta i verificable: és la columna que fa funcionar el discriminant de la jerarquia, i una clau forana no pot apuntar a un valor que s'ha d'anar a buscar a una altra taula. I no genera anomalies, perquè un model no canvia de tipus mai: una bici mecànica no es converteix en elèctrica. És el cas que 05-02 descrivia com a "dependència transitiva sobre un atribut immutable", on el risc d'anomalia d'actualització és zero. Tot i així, es blinda amb un activador de verificació en l'alta.

La nota sobre abonaments.tipus_abonament. Mateix raonament i mateixa conclusió: tarifa_id → tipus_abonament. Es conserva perquè les consultes d'explotació agrupen per tipus d'abonament constantment i evitar un JOIN en el 80 % dels informes ho justifica. Es blinda amb una clau forana composta:

ALTER TABLE tarifes    ADD CONSTRAINT uq_tarifa_tipus UNIQUE (tarifa_id, tipus_abonament);
ALTER TABLE abonaments ADD CONSTRAINT fk_abonament_tarifa_coherent
      FOREIGN KEY (tarifa_id, tipus_abonament) REFERENCES tarifes (tarifa_id, tipus_abonament);

Ara la redundància és impossible de contradir, que és l'única forma acceptable de conviure amb una redundància. És exactament el mateix patró del discriminant de les bicis.

Desnormalització 1 — estacions.bicis_disponibles

Què es trenca: la dada és derivable amb SELECT COUNT(*) FROM ancoratges WHERE estacio_id = ? AND bicicleta_id IS NOT NULL.

Per què s'accepta: l'app mòbil demana la disponibilitat de les 60 estacions cada vegada que algú obre el mapa, i amb 24.000 persones abonades això són desenes de milers de peticions al dia que degeneren en un recompte sobre ancoratges cadascuna. És el cas de llibre de 05-04: lectura massiva, escriptura poc freqüent, dada petita.

Com es manté: amb un activador sobre ancoratges, no a mà des de l'aplicació. Que ho mantingui l'aplicació és el que garanteix que algun dia discrepi.

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 estacions SET bicis_disponibles = bicis_disponibles - 1
         WHERE estacio_id = OLD.estacio_id;
    END IF;
    IF TG_OP IN ('UPDATE','INSERT') AND NEW.bicicleta_id IS NOT NULL THEN
        UPDATE estacions SET bicis_disponibles = bicis_disponibles + 1
         WHERE estacio_id = NEW.estacio_id;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

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

I, com mana 05-04, una consulta d'auditoria que s'executa cada nit i avisa si el comptador ha derivat:

SELECT e.codi, e.bicis_disponibles AS comptador,
       COUNT(a.bicicleta_id)       AS real_
  FROM estacions  e
  JOIN ancoratges a USING (estacio_id)
 GROUP BY e.estacio_id, e.codi, e.bicis_disponibles
HAVING e.bicis_disponibles <> COUNT(a.bicicleta_id);
  codi  | comptador | real_
--------+-----------+-------
(0 rows)

Desnormalització 2 — la tarifa congelada a trajectes

Què es trenca: minuts_inclosos, minuts_fraccio, preu_fraccio i recarrec_electrica depenen de tarifa_id, no de trajecte_id.

Per què s'accepta: no és rendiment, és correcció. És la resposta a la quarta pregunta de la reunió inicial. Si el trajecte només guardés tarifa_id, qualsevol recàlcul posterior aplicaria la tarifa actual; el dia que l'ajuntament apugi el preu de la fracció, tot l'històric canviaria d'import. Les dades històriques han de ser reproduïbles: és un requisit comptable i de vegades legal.

Com es manté: copiant la tarifa en el moment del desbloqueig, dins de la transacció, i no tornant-la a tocar mai. Un activador BEFORE UPDATE que rebutgi canvis en aquelles quatre columnes és una protecció barata i assenyada.

Fixa't en la diferència entre les dues: la primera es desnormalitza per velocitat i cal vigilar-la; la segona es desnormalitza per semàntica i vigilar-la seria un error, perquè el seu valor ha de diferir de l'actual.

  1. Càrrega de dades i volum realista

INSERT INTO districtes VALUES
 (1,'Port'), (2,'Eixample'), (3,'Vallmar Alta'), (4,'Ribera'), (5,'Industrial');

INSERT INTO tipus_abonament VALUES
 ('anual',   'Abonament anual amb 30 min inclosos per trajecte', INTERVAL '1 year'),
 ('mensual', 'Abonament mensual amb 30 min inclosos',            INTERVAL '1 mon'),
 ('turistic','Abonament de 3 dies amb 15 min inclosos',          INTERVAL '3 days');

INSERT INTO tarifes (tipus_abonament, vigencia, quota, minuts_inclosos,
                     minuts_fraccio, preu_fraccio, recarrec_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),
 ('turistic', daterange('2026-01-01','2027-01-01'), 15.00, 15, 15, 1.10, 0.50);

-- 60 estacions repartides pels cinc districtes
INSERT INTO estacions (codi, nom, adreca, districte_id, latitud, longitud, num_ancoratges)
SELECT 'VB-' || lpad(n::text, 3, '0'),
       'Estació ' || n,
       'Carrer Fictici ' || 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;

-- Els ancoratges de cada estació (entitat feble: es generen a partir de num_ancoratges)
INSERT INTO ancoratges (estacio_id, numero)
SELECT e.estacio_id, g
  FROM estacions e, LATERAL generate_series(1, e.num_ancoratges) AS g;
INSERT 0 1512

Aquest 1.512 és la primera dada de dimensionament útil: 1.512 ancoratges per a 900 bicicletes, un 68 % d'ocupació mitjana. És una folgança sana; per sota del 80 % el reequilibratge es torna un malson operatiu.

Taula Files inicials Creixement anual Notes
districtes 5 0 Fixa
estacions 60 +5 Creixement per pla municipal
ancoratges 1.512 +120 Derivada de les estacions
models_bici 6 +1
bicicletes 900 +90 / −60 Altes i baixes
persones_abonades 24.000 +4.000
abonaments 26.500 +30.000 Els turístics roten molt
trajectes 0 +1.600.000 ≈4.400/dia; és la taula del sistema
ordres_taller 0 +7.000
cobraments 0 +180.000 Quotes + recàrrecs

Tota decisió de rendiment de l'apartat 11 es refereix a aquests 1,6 milions anuals de trajectes. Les altres taules són irrellevants a efectes de pla d'execució, i confondre això és la manera més habitual de perdre una tarda indexant el que no toca.

  1. Les dues transaccions crítiques: desbloquejar i ancorar

Aquí és on el mòdul 6 deixa de ser teoria. L'escenari que cal resoldre és concret: són les 08:12, l'estació del Port té una sola bici lliure i dues persones premen "desbloquejar" amb 40 mil·lisegons de diferència.

sequenceDiagram
    participant A as App persona A
    participant B as App persona B
    participant PG as PostgreSQL
    A->>PG: BEGIN · SELECT ancoratge amb bici FOR UPDATE SKIP LOCKED
    PG-->>A: ancoratge 3 (bici 417) - fila bloquejada
    B->>PG: BEGIN · SELECT ancoratge amb bici FOR UPDATE SKIP LOCKED
    PG-->>B: 0 files (salta la 3, no queda cap mes)
    A->>PG: UPDATE ancoratges · INSERT trajectes · COMMIT
    B->>PG: ROLLBACK - "no queden bicicletes"

Desbloqueig

BEGIN;

-- 1. Verificar que l'abonament està en vigor (RN10). Sense bloqueig: llegir-lo n'hi ha prou.
SELECT abonament_id, tarifa_id
  FROM abonaments
 WHERE persona_id = 8801 AND estat = 'actiu'
   AND vigencia @> CURRENT_DATE;

-- 2. Prendre UNA bici ancorada de l'estació 12, bloquejant només aquella fila.
--    ORDER BY km_acumulats reparteix el desgast de la flota.
SELECT a.estacio_id, a.numero, a.bicicleta_id, b.tipus
  FROM ancoratges  a
  JOIN bicicletes  b ON b.bicicleta_id = a.bicicleta_id
 WHERE a.estacio_id = 12
   AND a.estat      = 'operatiu'
   AND b.estat      = 'ancorada'
 ORDER BY b.km_acumulats
 FOR UPDATE OF a SKIP LOCKED
 LIMIT 1;

-- 3. Alliberar l'ancoratge i marcar la bici en ús
UPDATE ancoratges SET bicicleta_id = NULL WHERE estacio_id = 12 AND numero = 3;
UPDATE bicicletes SET estat = 'en_us'     WHERE bicicleta_id = 417;

-- 4. Obrir el trajecte congelant la tarifa vigent
INSERT INTO trajectes (abonament_id, bicicleta_id, estacio_origen, ancoratge_origen,
                       tarifa_id, minuts_inclosos, minuts_fraccio,
                       preu_fraccio, recarrec_electrica)
SELECT 10233, 417, 12, 3,
       t.tarifa_id, t.minuts_inclosos, t.minuts_fraccio,
       t.preu_fraccio,
       CASE WHEN b.tipus = 'electrica' THEN t.recarrec_electrica ELSE 0 END
  FROM tarifes t
  JOIN bicicletes b ON b.bicicleta_id = 417
 WHERE t.tipus_abonament = 'anual' AND t.vigencia @> CURRENT_DATE
RETURNING trajecte_id;

COMMIT;
 trajecte_id
-------------
      884213
COMMIT

Per què SKIP LOCKED i no FOR UPDATE a seques. Amb FOR UPDATE, la sessió B es queda esperant que A confirmi, i després reavalua: com que l'ancoratge ja no té bici, obté 0 files. El resultat final és correcte, però B ha esperat sense necessitat. Amb SKIP LOCKED, B ignora la fila bloquejada i continua buscant una altra bici a la mateixa estació; només si de debò no en queda cap retorna 0 files. En una estació amb 8 bicis i 8 persones desbloquejant alhora, la diferència és que les 8 ho aconsegueixen en paral·lel en comptes de fer cua. És exactament el patró de cua que vam veure a 06-02, aplicat a un inventari.

Per què FOR UPDATE OF a. Sense l'OF a, PostgreSQL bloquejaria també la fila de bicicletes, i no cal: la fila que decideix qui guanya és la de l'ancoratge. Bloquejar de més multiplica els interbloqueigs.

El nivell d'aïllament és READ COMMITTED, el predeterminat. No cal pujar a REPEATABLE READ: no hi ha cap lectura que s'hagi de repetir de manera estable, i el bloqueig explícit ja resol la cursa. Pujar l'aïllament "per si de cas" només afegeix errors de serialització que caldria reintentar.

Ancoratge

BEGIN;

-- 1. Reservar un ancoratge lliure i operatiu a l'estació de destí
SELECT estacio_id, numero
  FROM ancoratges
 WHERE estacio_id = 34 AND estat = 'operatiu' AND bicicleta_id IS NULL
 ORDER BY numero
 FOR UPDATE SKIP LOCKED
 LIMIT 1;

-- 2. Tancar el trajecte calculant l'import amb la tarifa CONGELADA
UPDATE trajectes t
   SET ts_fi         = now(),
       estacio_desti = 34,
       ancoratge_desti = 7,
       import = t.recarrec_electrica
              + t.preu_fraccio
              * GREATEST(0, ceil(
                    (EXTRACT(EPOCH FROM (now() - t.ts_inici)) / 60
                     - t.minuts_inclosos) / t.minuts_fraccio))
 WHERE t.trajecte_id = 884213
   AND t.ts_fi IS NULL                  -- idempotència: un segon ancoratge no fa res
RETURNING import;

-- 3. Ocupar l'ancoratge i tornar la bici a estat 'ancorada'
UPDATE ancoratges SET bicicleta_id = 417 WHERE estacio_id = 34 AND numero = 7;
UPDATE bicicletes SET estat = 'ancorada',
                      km_acumulats = km_acumulats + 3.40
 WHERE bicicleta_id = 417;

-- 4. Si hi ha recàrrec, generar el cobrament
INSERT INTO cobraments (persona_id, concepte, trajecte_id, import)
SELECT a.persona_id, 'recarrec', t.trajecte_id, t.import
  FROM trajectes t JOIN abonaments a USING (abonament_id)
 WHERE t.trajecte_id = 884213 AND t.import > 0;

COMMIT;
 import
--------
   1.55
UPDATE 1
COMMIT

Un trajecte de 68 minuts amb abonament anual: 68 − 30 inclosos = 38 minuts excedits, ceil(38/15) = 3 fraccions × 0,60 € = 1,80 €… més el recàrrec d'elèctrica 0,35 €. El resultat de dalt, 1,55 €, correspon a 2 fraccions (1,20 €) més 0,35 €, és a dir a un trajecte de 55 minuts. Comprova tu el càlcul amb els dos casos: verificar a mà el primer import que produeix un sistema de cobrament és un costum que estalvia disgustos.

Els tres detalls que fan robusta aquesta transacció:

  1. AND t.ts_fi IS NULL al WHERE de l'UPDATE la fa idempotent. Si l'app reintenta l'ancoratge perquè va perdre la resposta per xarxa, el segon intent afecta 0 files i no torna a cobrar.
  2. El RETURNING permet a l'aplicació comprovar quantes files va canviar. Zero files no és un èxit silenciós: és un error que cal tractar.
  3. El comptador bicis_disponibles no es toca aquí: l'actualitza l'activador d'ancoratges. Si es toqués també a mà, el comptador pujaria de dos en dos, i aquesta és la fallada de desnormalització més comuna que hi ha.

  1. Consultes d'explotació: els informes de l'ajuntament

C1 — Trajectes per districte i franja horària.

SELECT d.nom AS districte,
       COUNT(*) FILTER (WHERE h BETWEEN  7 AND  9) AS punta_mati,
       COUNT(*) FILTER (WHERE h BETWEEN 10 AND 16) AS vall,
       COUNT(*) FILTER (WHERE h BETWEEN 17 AND 20) AS punta_tarda,
       COUNT(*) FILTER (WHERE h > 20 OR h < 7)     AS nocturn,
       COUNT(*)                                    AS total
  FROM (SELECT t.estacio_origen,
               EXTRACT(HOUR FROM t.ts_inici AT TIME ZONE 'Europe/Madrid')::int AS h
          FROM trajectes t
         WHERE t.ts_inici >= DATE '2026-06-01'
           AND t.ts_inici <  DATE '2026-07-01') x
  JOIN estacions  e ON e.estacio_id = x.estacio_origen
  JOIN districtes d USING (districte_id)
 GROUP BY d.nom
 ORDER BY total DESC;
  districte   | punta_mati | vall  | punta_tarda | nocturn | total
--------------+------------+-------+-------------+---------+--------
 Eixample     |      14820 | 11340 |       16905 |    2115 |  45180
 Port         |       9640 | 14210 |       12880 |    3410 |  40140
 Ribera       |       7115 |  6320 |        8090 |    1145 |  22670
 Vallmar Alta |       5980 |  4110 |        6240 |     705 |  17035
 Industrial   |       4210 |  1890 |        4560 |     380 |  11040

Allà s'hi llegeix un patró real: l'Eixample i l'Industrial són clarament pendulars (dues puntes, poca vall) mentre que el Port té el seu màxim a la vall — és turisme, no desplaçament a la feina. L'AT TIME ZONE 'Europe/Madrid' no és decoratiu: sense ell, a l'estiu les franges sortirien desplaçades dues hores.

C2 — Els parells origen→destí més freqüents, amb el seu rànquing dins del districte.

SELECT * FROM (
  SELECT eo.nom AS origen, ed.nom AS desti, dd.nom AS districte_desti,
         COUNT(*) AS viatges,
         ROUND(AVG(EXTRACT(EPOCH FROM t.durada) / 60)::numeric, 1) AS min_mitja,
         RANK() OVER (PARTITION BY dd.districte_id ORDER BY COUNT(*) DESC) AS posicio
    FROM trajectes  t
    JOIN estacions  eo ON eo.estacio_id = t.estacio_origen
    JOIN estacions  ed ON ed.estacio_id = t.estacio_desti
    JOIN districtes dd ON dd.districte_id = ed.districte_id
   WHERE t.ts_fi IS NOT NULL
     AND t.ts_inici >= DATE '2026-06-01' AND t.ts_inici < DATE '2026-07-01'
   GROUP BY eo.estacio_id, eo.nom, ed.estacio_id, ed.nom,
            dd.districte_id, dd.nom
) r
WHERE posicio <= 2
ORDER BY districte_desti, posicio;
       origen        |       desti        | districte_desti | viatges | min_mitja | posicio
---------------------+--------------------+-----------------+---------+-----------+---------
 Estació 7           | Estació 22         | Eixample        |    1284 |      14.2 |       1
 Estació 41          | Estació 22         | Eixample        |     967 |      18.6 |       2
 Estació 3           | Estació 18         | Industrial      |     712 |      21.4 |       1
 ...

El RANK() OVER (PARTITION BY ...) sobre un COUNT(*) agregat és el patró "top N per grup" que vas practicar a 07-04. Nota que la funció de finestra s'avalua després del GROUP BY, per això pot ordenar per COUNT(*).

C3 — El problema del reequilibratge. Aquesta és la consulta que estalvia diners de debò, i la que millor demostra per què vam modelar el trajecte com a entitat amb dos extrems.

WITH moviments AS (
    SELECT estacio_origen AS estacio_id, ts_inici AS ts, -1 AS delta
      FROM trajectes
     WHERE ts_inici >= DATE '2026-06-01' AND ts_inici < DATE '2026-07-01'
    UNION ALL
    SELECT estacio_desti, ts_fi, +1
      FROM trajectes
     WHERE ts_fi IS NOT NULL
       AND ts_fi >= DATE '2026-06-01' AND ts_fi < DATE '2026-07-01'
),
per_franja AS (
    SELECT estacio_id,
           EXTRACT(HOUR FROM ts AT TIME ZONE 'Europe/Madrid')::int / 4 AS bloc,
           SUM(delta) AS net
      FROM moviments
     GROUP BY 1, 2
)
SELECT e.codi, e.nom, e.num_ancoratges,
       SUM(net) FILTER (WHERE bloc = 1) AS "04-08",
       SUM(net) FILTER (WHERE bloc = 2) AS "08-12",
       SUM(net) FILTER (WHERE bloc = 4) AS "16-20",
       SUM(net)                          AS net_mes,
       CASE WHEN SUM(net) < -300 THEN 'es buida · reposar'
            WHEN SUM(net) >  300 THEN 'es plena · retirar'
            ELSE 'equilibrada' END       AS diagnostic
  FROM per_franja JOIN estacions e USING (estacio_id)
 GROUP BY e.estacio_id, e.codi, e.nom, e.num_ancoratges
 ORDER BY ABS(SUM(net)) DESC
 LIMIT 5;
  codi  |     nom     | num_ancoratges | 04-08 | 08-12 | 16-20 | net_mes |     diagnostic
--------+-------------+----------------+-------+-------+-------+---------+--------------------
 VB-022 | Estació 22  |             24 |   -18 |  +892 |  -774 |    +611 | es plena · retirar
 VB-007 | Estació 7   |             16 |   +31 |  -845 |  +698 |    -498 | es buida · reposar
 VB-041 | Estació 41  |             20 |   +12 |  -602 |  +515 |    -402 | es buida · reposar
 VB-018 | Estació 18  |             28 |    -8 |  +498 |  -401 |    +377 | es plena · retirar
 VB-003 | Estació 3   |             12 |   +22 |  -344 |  +266 |    -281 | equilibrada

Els signes expliquen la història sencera: la 7 i la 41 són residencials (es buiden al matí, es reomplen a la tarda) i la 22 és un destí de feina. La columna net_mes diu quantes bicis cal moure en furgoneta cada mes, i el 08-12 davant del 16-20 diu a quina hora s'han de moure. La tècnica —convertir dues columnes d'una fila en dues files amb signe mitjançant UNION ALL— és la forma canònica de tractar qualsevol entitat amb dos extrems.

C4 — Ingressos per tipus d'abonament.

SELECT ta.tipus_abonament,
       COUNT(*) FILTER (WHERE c.concepte = 'abonament')            AS n_quotes,
       SUM(c.import) FILTER (WHERE c.concepte = 'abonament')       AS eur_quotes,
       SUM(c.import) FILTER (WHERE c.concepte = 'recarrec')        AS eur_recarrecs,
       SUM(c.import)                                               AS eur_total,
       ROUND(100.0 * SUM(c.import) FILTER (WHERE c.concepte = 'recarrec')
                   / NULLIF(SUM(c.import), 0), 1)                  AS pct_recarrec
  FROM cobraments c
  JOIN persones_abonades p USING (persona_id)
  JOIN abonaments  ab ON ab.persona_id = p.persona_id
                     AND ab.vigencia @> c.ts_cobrament::date
  JOIN tipus_abonament ta ON ta.tipus_abonament = ab.tipus_abonament
 WHERE c.estat = 'cobrat'
   AND c.ts_cobrament >= DATE '2026-01-01'
 GROUP BY ta.tipus_abonament
 ORDER BY eur_total DESC;
 tipus_abonament | n_quotes | eur_quotes | eur_recarrecs | eur_total | pct_recarrec
-----------------+----------+------------+---------------+-----------+--------------
 anual           |     9840 |  442800.00 |      38215.40 | 481015.40 |          7.9
 turistic        |     8120 |  121800.00 |      71430.80 | 193230.80 |         36.9
 mensual         |     6410 |   60895.00 |      19204.20 |  80099.20 |         24.0

El pct_recarrec de l'abonament turístic (36,9 %) és la mena de troballa que justifica l'informe: les persones visitants es passen del temps inclòs gairebé la meitat de les vegades. O bé 15 minuts són pocs, o bé l'app no avisa. És una conversa de negoci que només existeix perquè la consulta la va fer possible.

C5 — Avaries per hora d'ús.

WITH us AS (
    SELECT bicicleta_id,
           SUM(EXTRACT(EPOCH FROM durada)) / 3600 AS hores
      FROM trajectes
     WHERE ts_fi IS NOT NULL AND ts_inici >= DATE '2026-01-01'
     GROUP BY bicicleta_id
),
avaries AS (
    SELECT bicicleta_id, COUNT(*) AS n
      FROM ordres_taller
     WHERE tipus = 'avaria' AND ts_obertura >= DATE '2026-01-01'
     GROUP BY bicicleta_id
)
SELECT b.matricula, m.fabricant, m.nom AS model, b.tipus,
       ROUND(u.hores::numeric, 1)                        AS hores_us,
       COALESCE(a.n, 0)                                  AS avaries,
       ROUND((COALESCE(a.n,0) * 100 / u.hores)::numeric, 2) AS avaries_per_100h
  FROM us u
  JOIN bicicletes  b USING (bicicleta_id)
  JOIN models_bici m USING (model_id)
  LEFT JOIN avaries a USING (bicicleta_id)
 WHERE u.hores >= 50                    -- descarta bicis amb mostra insuficient
 ORDER BY avaries_per_100h DESC
 LIMIT 5;
 matricula | fabricant |  model  |   tipus   | hores_us | avaries | avaries_per_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.hores >= 50 és la part important i la que gairebé ningú no posa: sense ell, una bici amb 2 hores d'ús i una avaria encapçala la llista amb 50 avaries/100 h i l'informe no té cap valor. Tota taxa necessita un llindar mínim de denominador.

  1. Rendiment: les dues consultes que es degraden

Amb la taula buida tot va ràpid. El dia que trajectes té 1,6 milions de files, dues coses es trenquen. Seguim l'ordre d'intervenció de 06-03: mesurar primer, entendre el pla, i només llavors tocar.

Degradació 1 — l'informe mensual C1

EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*) FROM trajectes
 WHERE ts_inici >= DATE '2026-06-01' AND ts_inici < 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 trajectes  (actual time=0.041..2050.883 rows=45180 loops=3)
                     Filter: ((ts_inici >= '2026-06-01') AND (ts_inici < '2026-07-01'))
                     Rows Removed by Filter: 488153
                     Buffers: shared read=31204
 Planning Time: 0.198 ms
 Execution Time: 2189.204 ms

Diagnòstic: Rows Removed by Filter: 488153 per a cadascun dels 3 processos. Es llegeixen 1,6 milions de files per quedar-se amb 45.180. És el símptoma clàssic de 06-03: un filtre molt selectiu (2,8 %) resolt amb recorregut seqüencial.

Intervenció. Aquí hi ha una elecció real entre dos índexs:

Índex Mida Cost d'escriptura Quan guanya
B-tree (ts_inici) ≈34 MB Alt: 4.400 insercions/dia l'actualitzen Rangs petits, i també serveix per a "l'últim trajecte de X"
BRIN (ts_inici) ≈48 KB Gairebé nul Rangs grans sobre dades correlacionades amb l'ordre físic

trajectes s'insereix sempre en ordre cronològic i mai no es reordena: la correlació física de ts_inici és pràcticament 1. És el cas exacte per al qual existeix BRIN.

CREATE INDEX idx_trajectes_ts_brin ON trajectes USING brin (ts_inici)
    WITH (pages_per_range = 64);
ANALYZE trajectes;
 Aggregate  (actual time=118.940..118.941 rows=1)
   ->  Bitmap Heap Scan on trajectes  (actual time=3.117..114.226 rows=45180 loops=1)
         Recheck Cond: ((ts_inici >= '2026-06-01') AND (ts_inici < '2026-07-01'))
         Rows Removed by Index Recheck: 2841
         Heap Blocks: lossy=1408
         Buffers: shared hit=1412 read=3
         ->  Bitmap Index Scan on idx_trajectes_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, amb un índex de 48 KB. Rows Removed by Index Recheck: 2841 és normal en BRIN: l'índex treballa per blocs, així que retorna algun bloc de més i el motor descarta les files sobrants. A canvi ocupa mil vegades menys que el B-tree i el seu manteniment en les insercions és menyspreable.

Degradació 2 — "quin trajecte tinc obert?"

És la consulta més freqüent del sistema: l'executa l'app cada vegada que algú l'obre amb una bici en curs.

EXPLAIN ANALYZE
SELECT trajecte_id, ts_inici, estacio_origen
  FROM trajectes WHERE abonament_id = 10233 AND ts_fi IS NULL;
 Seq Scan on trajectes  (actual time=1893.221..1893.223 rows=1 loops=1)
   Filter: ((ts_fi IS NULL) AND (abonament_id = 10233))
   Rows Removed by Filter: 1599999
 Execution Time: 1893.244 ms

I aquí hi ha el detall bonic: l'índex que ho arregla ja existeix. És uq_trajecte_obert_abon, l'índex únic parcial que vam crear a l'apartat 5 per imposar RN5. Un índex parcial sobre WHERE ts_fi IS NULL cobreix, amb 1,6 milions de files a la taula, només les 900 com a màxim que poden estar obertes simultàniament. L'única cosa que faltava era un ANALYZE:

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

De 1.893 ms a 0,049 ms. La lliçó és la que 06-03 repetia: una restricció ben triada és també un índex, i moltes vegades l'índex que necessites ja l'has creat sense adonar-te'n.

Els tres índexs restants que sí que cal crear a mà:

CREATE INDEX idx_trajectes_origen_ts ON trajectes (estacio_origen, ts_inici);
CREATE INDEX idx_trajectes_desti_ts  ON trajectes (estacio_desti, ts_fi)
    WHERE ts_fi IS NOT NULL;
CREATE INDEX idx_ordres_bici_tipus   ON ordres_taller (bicicleta_id, tipus, ts_obertura);

L'ordre de les columnes en els compostos segueix la regla de 06-03 —igualtat primer, rang després— i respon a C2 i C3. El segon és parcial perquè els trajectes oberts no tenen destí i no aporten res a l'índex.

  1. Operació: rols, dades personals i còpies

Rols i permisos mínims

CREATE ROLE vallbici_app      LOGIN PASSWORD '...';  -- l'API que atén l'app mòbil
CREATE ROLE vallbici_taller   LOGIN PASSWORD '...';  -- l'equip de manteniment
CREATE ROLE vallbici_analista LOGIN PASSWORD '...';  -- informes de l'ajuntament

GRANT SELECT, INSERT, UPDATE ON ancoratges, trajectes, bicicletes TO vallbici_app;
GRANT SELECT                 ON estacions, tarifes, abonaments    TO vallbici_app;
GRANT INSERT                 ON cobraments                        TO vallbici_app;
-- L'API NO pot esborrar res, en cap taula. Ni tan sols les seves pròpies files.

GRANT SELECT, INSERT, UPDATE ON ordres_taller TO vallbici_taller;
GRANT SELECT, UPDATE (estat) ON bicicletes    TO vallbici_taller;

GRANT SELECT ON ALL TABLES IN SCHEMA public TO vallbici_analista;
REVOKE SELECT ON persones_abonades, cobraments FROM vallbici_analista;
GRANT  SELECT ON v_trajectes_anonims           TO vallbici_analista;

El GRANT SELECT, UPDATE (estat) ON bicicletes és l'aplicació literal del principi de mínim privilegi de 06-04: el taller canvia l'estat d'una bici, i només l'estat. No pot tocar km_acumulats ni matricula.

Dades personals

Els informes de l'ajuntament no necessiten saber qui va fer cada trajecte. La vista que consumeix el perfil analista trenca el vincle:

CREATE VIEW v_trajectes_anonims AS
SELECT t.trajecte_id, t.ts_inici, t.ts_fi, t.durada, t.import,
       t.estacio_origen, t.estacio_desti,
       ab.tipus_abonament,
       p.districte_id AS districte_residencia,
       date_part('year', age(p.data_naix))::int / 10 * 10 AS decada_edat
  FROM trajectes t
  JOIN abonaments ab USING (abonament_id)
  JOIN persones_abonades p USING (persona_id);

Dues observacions honestes sobre això:

  1. La vista no anonimitza de debò. Amb districte de residència, dècada d'edat i patró de trajectes, una persona amb recorregut singular pot ser reidentificable. Reduir el risc requereix agregació mínima (no publicar cel·les amb menys de N persones) i això és una decisió de política, no de SQL.
  2. El compliment normatiu no el decideix qui dissenya la base de dades. Base legal del tractament, terminis de conservació de la telemetria, avaluació d'impacte, dret de supressió: tot això ho revisa un professional de protecció de dades o compliance. El que sí que és responsabilitat tècnica és que l'esquema permeti complir-lo: per això el document es guarda com a document_hash i no en clar, per això ON DELETE RESTRICT obliga a un procediment explícit de supressió en lloc d'esborrar en cascada l'històric comptable, i per això existeix la vista.

Còpies

Element Estratègia Freqüència Objectiu
Còpia base física pg_basebackup Diària, 02:00 Restauració completa
WAL arxivat archive_command a emmagatzematge extern Continu PITR: restaurar a qualsevol instant
Bolcat lògic pg_dump -Fc Setmanal Recuperar una taula solta sense restaurar-ho tot
Prova de restauració Restaurar en màquina a part i executar l'auditoria del comptador Mensual Que la còpia serveixi de debò

L'última fila és la que més se salta i l'única que garanteix alguna cosa. En paraules de 06-04: una còpia que no s'ha restaurat mai no és una còpia, és una esperança. Amb PITR configurat, l'escenari "a les 11:40 algú va executar un UPDATE sense WHERE sobre tarifes" es resol restaurant a les 11:39.

  1. Què resol malament aquest esquema

El sistema funciona, compleix els vuit requisits funcionals i respon a les cinc consultes. I tot i així hi ha tres coses de l'encàrrec inicial que aquest esquema resol malament. Val la pena ser precisos, perquè són exactament el material de la lliçó següent.

1. La telemetria GPS. L'encàrrec demanava guardar el rastre de cada trajecte: posicions cada 5 segons, bateria de les elèctriques, incidències. Un trajecte mitjà de 14 minuts són ~170 punts. Amb 1,6 milions de trajectes anuals, això és una taula posicions de 272 milions de files l'any, cadascuna amb trajecte_id, ts, lat, lon i poca cosa més. Es pot fer —PostgreSQL ho aguanta— però és un mal encaix: són dades que s'escriuen massivament, es llegeixen sempre senceres i per trajecte, no s'actualitzen mai, no necessiten integritat referencial estricta i caduquen al cap de pocs mesos. Estàs pagant el preu del model relacional (índex per fila, WAL per fila, visibilitat per fila) per a una dada que no fa servir cap dels seus avantatges.

2. La fitxa enriquida de l'estació. La nostra taula estacions té 11 columnes planes. La fitxa que vol l'app té fotos, horaris d'accés que varien per estació, accessibilitat, si està sota coberta, si té bomba d'aire, notes de l'empresa mantenidora, etiquetes de punts d'interès propers… i cada estació té un subconjunt diferent d'aquests atributs. Modelar-ho en el relacional porta a una de tres sortides, totes dolentes: 40 columnes nul·les, una taula clau-valor genèrica (l'antipatró EAV que 04-01 va marcar en vermell) o una taula nova per cada atribut que se li acudeixi a algú.

3. Les incidències amb estructura variable. ordres_taller té un motiu VARCHAR(60). Però una incidència de "frens" necessita registrar quin fre i quina mesura; una de "bateria" necessita cicles de càrrega i voltatge; una de "vandalisme" necessita fotos i número d'atestat. Són estructures diferents per tipus, i afegir un tipus nou no hauria de requerir un ALTER TABLE en producció.

Les tres tenen una cosa en comú: estructura variable o volum alt sense necessitat de transaccions. I les tres tenen una altra cosa en comú, encara més important: cap d'elles no és el nucli transaccional. Ningú no cobra diners a partir d'un punt GPS. Això és el que permet treure-les de PostgreSQL sense posar en risc el que importa, i és just el que fa la lliçó 08-02.

Errors Habituals i Consells

Error 1: començar pel CREATE TABLE. És l'error del qual es deriven gairebé tots els altres. Les set preguntes de l'apartat 1 van costar vint minuts de reunió i van determinar la meitat de l'esquema. Si no les haguéssim fet, hauríem descobert la necessitat de la tarifa congelada el dia de la primera pujada de preus, amb dos anys de facturació ja emesa.

Error 2: no modelar l'ancoratge "perquè amb un comptador n'hi ha prou". Funciona fins al primer ancoratge avariat. La regla general: si el client diu que alguna cosa té estat propi, és una entitat, encara que sembli un número.

Error 3: FLOAT per als diners. Continua passant. NUMERIC per a tot el que es cobra, es factura o se suma.

Error 4: TIMESTAMP sense zona en un sistema amb canvi horari. El diumenge d'octubre produirà trajectes de durada negativa i la restauració d'aquella nit serà un trencaclosques.

Error 5: mantenir a mà un comptador desnormalitzat. Si bicis_disponibles s'actualitza des de l'aplicació i des de l'activador, es duplica l'increment. Un dels dos, i millor l'activador.

Error 6: pujar el nivell d'aïllament en comptes de bloquejar la fila correcta. SERIALIZABLE no és una solució màgica: converteix una cursa en un error de serialització que l'aplicació ha de reintentar. Un FOR UPDATE SKIP LOCKED sobre la fila que decideix és més barat i més predictible.

Consell 1: escriu la llista de consultes abans que l'esquema. És el millor detector d'entitats que falten. C3 va ser la que va confirmar que el trajecte necessitava els seus dos extrems com a claus foranes independents.

Consell 2: cada restricció que escriguis, prova-la fallant. D'una restricció que mai no has vist rebutjar un INSERT no saps si està ben escrita.

Consell 3: mira si l'índex ja existeix abans de crear-lo. La degradació 2 es va resoldre sola. Els índexs únics, parcials o no, són índexs complets.

Consell 4: documenta les desnormalitzacions en el mateix esquema. El comentari -- Desnormalització deliberada núm. 1 evita que d'aquí a tres anys algú "arregli" l'esquema traient-la. Millor encara: COMMENT ON COLUMN.

Exercicis

Exercici 1 — Reserves de bicicleta

L'ajuntament vol permetre reservar una bicicleta des de l'app: la persona reserva una bici concreta d'una estació i té 10 minuts per arribar-hi i desbloquejar-la; passat aquest temps, la reserva caduca i la bici torna a estar disponible. Una persona només pot tenir una reserva activa.

  1. Decideix si la reserva és una entitat nova o un estat d'alguna cosa existent, i justifica-ho.
  2. Escriu el CREATE TABLE (o l'ALTER TABLE) amb totes les restriccions necessàries.
  3. Escriu la transacció de reserva, amb el seu control de concurrència.
  4. Explica com caduquen les reserves i per què aquesta decisió.

Exercici 2 — Tarifa reduïda per bo social

S'introdueix una tarifa reduïda per a persones amb bo social municipal. La condició s'acredita cada any i pot deixar de complir-se. Un trajecte es cobra amb la tarifa reduïda si la persona tenia el bo social vigent en el moment del desbloqueig.

  1. Modela l'acreditació del bo social sense duplicar la informació de persones_abonades.
  2. Explica per què la restricció EXCLUDE d'abonaments no n'hi ha prou aquí.
  3. Escriu la consulta que calcula, per al mes passat, quant ha deixat d'ingressar l'ajuntament per les tarifes reduïdes.

Exercici 3 — Detectar bicicletes "fantasma"

Una bicicleta fantasma és la que porta més de 48 hores en un trajecte obert: l'han robat, s'ha trencat el sistema d'ancoratge o l'app ha fallat en tancar-lo. Escriu la consulta que les llista amb l'última estació coneguda, quant temps porten i el nom de contacte de la persona que la va desbloquejar, ordenades de més antiga a més recent. Afegeix l'índex que la faci eficient, o explica per què no en cal cap.

Solucions

Solució 1

1. Entitat nova. Tres arguments: (a) té atributs propis —instant de reserva, instant de caducitat, resultat—; (b) té historial: voldrem saber quantes reserves caduquen sense fer-se servir, i un estat sobreescrit no deixa rastre; (c) relaciona tres coses (persona, bici i ancoratge) amb temporalitat pròpia. Un estat = 'reservada' a bicicletes no permetria cap de les tres.

2. L'esquema:

CREATE TABLE reserves (
    reserva_id   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    abonament_id INTEGER     NOT NULL REFERENCES abonaments ON DELETE RESTRICT,
    bicicleta_id INTEGER     NOT NULL REFERENCES bicicletes ON DELETE RESTRICT,
    estacio_id   INTEGER     NOT NULL,
    ancoratge    SMALLINT    NOT NULL,
    ts_reserva   TIMESTAMPTZ NOT NULL DEFAULT now(),
    ts_limit     TIMESTAMPTZ NOT NULL
                 GENERATED ALWAYS AS (ts_reserva + INTERVAL '10 minutes') STORED,
    resultat     VARCHAR(10) NOT NULL DEFAULT 'activa'
                 CHECK (resultat IN ('activa','usada','caducada','cancellada')),
    FOREIGN KEY (estacio_id, ancoratge) REFERENCES ancoratges (estacio_id, numero)
);

CREATE UNIQUE INDEX uq_reserva_activa_abon ON reserves (abonament_id) WHERE resultat = 'activa';
CREATE UNIQUE INDEX uq_reserva_activa_bici ON reserves (bicicleta_id) WHERE resultat = 'activa';
CREATE INDEX idx_reserves_caducar ON reserves (ts_limit) WHERE resultat = 'activa';

Els dos índexs únics parcials imposen "una reserva activa per persona" i "una reserva activa per bici" — el mateix patró que RN4/RN5. El tercer és de servei, per al procés de caducitat.

3. La transacció:

BEGIN;
SELECT a.estacio_id, a.numero, a.bicicleta_id
  FROM ancoratges a
  JOIN bicicletes b ON b.bicicleta_id = a.bicicleta_id
 WHERE a.estacio_id = 12 AND a.estat = 'operatiu' AND b.estat = 'ancorada'
   AND NOT EXISTS (SELECT 1 FROM reserves r
                    WHERE r.bicicleta_id = a.bicicleta_id AND r.resultat = 'activa')
 ORDER BY b.km_acumulats
 FOR UPDATE OF a SKIP LOCKED
 LIMIT 1;

INSERT INTO reserves (abonament_id, bicicleta_id, estacio_id, ancoratge)
VALUES (10233, 417, 12, 3);
COMMIT;

L'anti-join NOT EXISTS exclou les bicis ja reservades; l'índex únic parcial és la xarxa de seguretat si dues peticions l'esquiven. Fixa't que no es marca la bici com a reservada a bicicletes: l'estat viu a reserves, en un sol lloc.

4. La caducitat. Dues opcions i una de triada.

Descartada: un procés que cada minut marqui com a caducada les vençudes. Funciona, però introdueix una finestra en què la reserva està vençuda i encara figura activa, i depèn que el procés no caigui.

Triada: caducitat implícita en la lectura. La reserva es considera activa si resultat = 'activa' AND ts_limit > now(); el NOT EXISTS de dalt hi afegeix aquesta condició. El procés de neteja continua existint, però només per deixar l'històric ordenat, i que es retardi no afecta la correcció. La regla general: quan el temps determina la validesa d'una dada, la veritat ha d'estar a la consulta, no en un procés.

Solució 2

1. El model. Una taula d'acreditacions amb vigència, no una columna booleana a persones_abonades:

CREATE TABLE bons_socials (
    bo_id      INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    persona_id INTEGER   NOT NULL REFERENCES persones_abonades ON DELETE RESTRICT,
    vigencia   DATERANGE NOT NULL,
    expedient  VARCHAR(20) NOT NULL UNIQUE,
    EXCLUDE USING gist (persona_id WITH =, vigencia WITH &&)
);
ALTER TABLE tarifes ADD COLUMN bo_social BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE tarifes DROP CONSTRAINT tarifes_tipus_abonament_vigencia_excl;
ALTER TABLE tarifes ADD EXCLUDE USING gist
      (tipus_abonament WITH =, bo_social WITH =, vigencia WITH &&);

Una columna te_bo_social BOOLEAN seria l'error clàssic: no guarda des de quan, no guarda l'expedient i, sobretot, no permet reconstruir si en tenia el dia del trajecte. L'acreditació és un fet amb data, i els fets amb data van en files, no en columnes.

2. Per què l'EXCLUDE d'abonaments no n'hi ha prou. Aquell EXCLUDE garanteix que no hi hagi dos abonaments solapats de la mateixa persona; no diu res sobre quina tarifa s'aplica. El bo social és una condició ortogonal a l'abonament i amb la seva pròpia vigència: una persona pot tenir un abonament anual de l'1 de gener al 31 de desembre i un bo social que caduca el 30 de juny. A partir de l'1 de juliol, el mateix abonament ha de generar trajectes amb tarifa normal. Com que el trajecte ja congela la seva tarifa (RN9), el sistema queda correcte sense tocar res més: n'hi ha prou que la transacció de desbloqueig triï la tarifa consultant bons_socials amb vigencia @> CURRENT_DATE.

3. La consulta de cost fiscal:

SELECT COUNT(*)                                     AS trajectes_reduits,
       SUM(t.import)                                AS ingressat,
       SUM(t.import * (tn.preu_fraccio / NULLIF(t.preu_fraccio, 0)))
                                                    AS hauria_ingressat,
       SUM(t.import * (tn.preu_fraccio / NULLIF(t.preu_fraccio, 0)))
       - SUM(t.import)                              AS cost_bonificacio
  FROM trajectes t
  JOIN tarifes   tr ON tr.tarifa_id = t.tarifa_id AND tr.bo_social
  JOIN tarifes   tn ON tn.tipus_abonament = tr.tipus_abonament
                   AND NOT tn.bo_social
                   AND tn.vigencia && tr.vigencia
 WHERE t.ts_fi IS NOT NULL
   AND t.ts_inici >= date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
   AND t.ts_inici <  date_trunc('month', CURRENT_DATE);
 trajectes_reduits | ingressat | hauria_ingressat | cost_bonificacio
-------------------+-----------+------------------+------------------
              8412 |   2184.60 |          4369.20 |          2184.60

El NULLIF protegeix del zero: si la tarifa reduïda tingués preu_fraccio = 0, la divisió rebentaria.

Solució 3

SELECT b.matricula, b.tipus,
       e.codi AS ultima_estacio, e.nom,
       t.ts_inici,
       date_trunc('minute', now() - t.ts_inici) AS temps_obert,
       p.nom || ' ' || p.cognoms AS contacte, p.telefon
  FROM trajectes t
  JOIN bicicletes b USING (bicicleta_id)
  JOIN estacions  e ON e.estacio_id = t.estacio_origen
  JOIN abonaments ab USING (abonament_id)
  JOIN persones_abonades p USING (persona_id)
 WHERE t.ts_fi IS NULL
   AND t.ts_inici < now() - INTERVAL '48 hours'
 ORDER BY t.ts_inici;
 matricula |   tipus   | ultima_estacio |     nom     |        ts_inici        |  temps_obert  | contacte
-----------+-----------+----------------+-------------+------------------------+---------------+----------
 VB-0233   | mecanica  | VB-041         | Estació 41  | 2026-06-09 19:12:04+02 | 5 days 03:41  | ...
 VB-0781   | electrica | VB-007         | Estació 7   | 2026-06-12 08:33:51+02 | 2 days 14:19  | ...

Sobre l'índex: no en cal cap de nou. El filtre ts_fi IS NULL és tremendament selectiu —com a màxim 900 files d'1,6 milions— i uq_trajecte_obert_bici ja cobreix exactament aquell predicat parcial. El planificador el pot recórrer sencer (900 entrades) i filtrar després per ts_inici; afegir un índex sobre (ts_inici) WHERE ts_fi IS NULL milloraria marginalment una consulta que s'executa una vegada al dia i afegiria manteniment a 4.400 insercions diàries. No és rendible. Aquest és el raonament que 06-03 demanava: un índex es justifica per la freqüència de la consulta i el cost d'escriptura, no per la mida de la taula.

Conclusió

Has recorregut un projecte relacional sencer: d'una transcripció de reunió de dos paràgrafs a un sistema amb onze taules, vint restriccions que codifiquen regles de negoci reals, dues transaccions concurrents correctes, cinc informes d'explotació i un pla d'operació.

El que convé endur-se no és l'esquema —el del teu pròxim projecte serà un altre— sinó la forma de les decisions. Cadascuna de les importants va tenir el mateix format: una temptació simple, una pregunta al client que la va desmuntar, dues o tres alternatives posades en una taula i un criteri explícit per triar. L'ancoratge és una entitat perquè té estat propi. El trajecte és una entitat perquè neix incomplet i té identitat. La jerarquia fa servir taula per subclasse perquè altres taules referencien la superclasse i perquè el número de sèrie de la bateria ha de ser obligatori. bicis_disponibles trenca la 3FN per velocitat i es vigila; la tarifa congelada la trenca per semàntica i vigilar-la seria un error. Cap d'aquestes frases no és una preferència estètica: totes són arguments que es poden discutir i, si calgués, rebatre.

També has vist que les eines del curs no es fan servir d'una en una. L'EXCLUDE USING gist de 04-04 imposa una regla de negoci de 04-01; l'índex únic parcial que imposa RN5 resulta ser, sense canviar una línia, l'índex que arregla la consulta més freqüent del sistema; la desnormalització de 05-04 necessita l'activador de 05-04 i l'auditoria nocturna de 05-04, les tres coses o cap. Un sistema en producció és això: peces del temari funcionant alhora i sostenint-se entre si.

I acaba amb una llista de tres fracassos, que és la part més honesta del cas. La telemetria GPS, la fitxa enriquida de l'estació i les incidències d'estructura variable no encaixen bé en aquest esquema, i forçar-les-hi produiria columnes nul·les, taules EAV i migracions cada vegada que algú inventa un tipus d'incidència. A la lliçó 08-02 agafem aquestes tres peces exactes —ni una més— i les modelem a MongoDB amb el mètode de 03-03, comprovant què s'hi guanya, què s'hi perd i per què la temptació de "migrar-ho tot a Mongo" és la resposta equivocada. El nucli transaccional que acabes de construir es queda on és: és ell qui cobra els diners.

© Copyright 2026. Tots els drets reservats