# 02 · Esquema de base de datos

Todo lo que el canal necesita en `asturias_api`, clasificado por **a qué canal pertenece**.
Esa clasificación es lo que permite retirar el inverso sin tocar el de ida.

---

## 2.1 Mapa rápido

| Objeto | Canal | Estado hoy | ¿Se puede quitar? |
|---|---|---|---|
| `rsv_replicacion` (tabla) | Ida | 🟢 Activo | ❌ Nunca |
| `sp_rsv_marcar_replicacion` + 12 triggers | Ida | 🟢 Activo | ❌ Nunca |
| `rsv_reservacion.reservacionMaestro` | **Frontera** | 🟢 Activo | ❌ **Rompe el sistema** |
| `rsv_reservacion.reservacionFechaMaestro` | **Frontera** | 🟢 Activo | ❌ Auditoría |
| `rsv_reservacion.idReservacionPrivado` | Ida (veredicto) | 🟢 Activo | ⚠️ Obligaría a tocar código vivo |
| `rsv_reservacion.reservacionNoPrivado` | Ida (veredicto) | 🟢 Activo | ⚠️ Idem (decisión D1) |
| `rsv_bitacorareplicacion` (tabla) | Observabilidad | 🟢 Activo | ❌ Deja ciego el canal de ida |
| `rsv_reservacion.reservacionVersionReplica` | **Inverso** | 🔴 Retirada | ✅ Sí |
| `rsv_reservacion.reservacionHashReplica` | **Inverso** | 🔴 Retirada | ✅ Sí |
| `rsv_reservacion.reservacionFechaReplica` | **Inverso** | 🔴 Retirada | ✅ Sí |
| `rsv_reservacionhabitacion.idReservacionHabitacionPrivado` + `uq_rh_privado` | **Inverso** | 🔴 Retirada | ✅ Sí |
| `rsv_reservacionhabitacionpaquete.idReservacionHabitacionPaquetePrivado` + `uq_rhp_privado` | **Inverso** | 🔴 Retirada | ✅ Sí |
| `rsv_reservacionhabitacionextra.idReservacionHabitacionExtraPrivado` + `uq_rhe_privado` | **Inverso** | 🔴 Retirada | ✅ Sí |

---

## 2.2 ⚠️ La columna que NO se puede tocar

```sql
rsv_reservacion.reservacionMaestro  enum('Portal','Privado') NOT NULL DEFAULT 'Portal'
```

`sp_rsv_marcar_replicacion` la **lee** en cada disparo de los 12 triggers:

```sql
SELECT `reservacionMaestro` INTO v_maestro
  FROM `rsv_reservacion` WHERE `idReservacion` = p_idReservacion LIMIT 1;

IF v_maestro = 'Portal' THEN
    -- ... marca el outbox ...
END IF;
```

Si la columna desaparece, ese `SELECT` falla **dentro de un trigger**, y un error en un trigger
**aborta la sentencia de negocio**. Resultado: el portal no puede insertar, actualizar ni
borrar en las cuatro tablas de la reservación. **El socio no puede capturar.**

Y aunque no rompiera nada, sigue siendo necesaria: es lo que impide que el portal destruya lo
que el club edita en Reservaciones V2, y ese riesgo existe **hoy**, con el canal inverso apagado.

---

## 2.3 Canal de ida — lo que está y se queda

### `rsv_replicacion` — el outbox

```sql
CREATE TABLE `rsv_replicacion` (
  `idReplicacion`           bigint NOT NULL AUTO_INCREMENT,
  `idReservacion`           int    NOT NULL,
  `replicacionOperacion`    enum('Actualizacion','Cancelacion','Confirmacion','Eliminacion') NOT NULL,
  `replicacionEstado`       enum('Pendiente','Procesando','Procesado','Error') NOT NULL DEFAULT 'Pendiente',
  `replicacionIntentos`     int      NOT NULL DEFAULT 0,
  `replicacionFecha`        datetime NOT NULL,
  `replicacionFechaProceso` datetime NULL,
  `replicacionError`        varchar(500) NULL,
  PRIMARY KEY (`idReplicacion`),
  INDEX `idx_replicacion_pendiente` (`replicacionEstado`, `idReplicacion`),
  INDEX `idx_replicacion_reservacion` (`idReservacion`)
) ENGINE = InnoDB;
```

**Origen:** `database/migrations/2026_07_24_130000_create_rsv_replicacion_table.php`

### Frontera de maestría

```sql
ALTER TABLE `rsv_reservacion`
  ADD COLUMN `reservacionMaestro` ENUM('Portal','Privado') NOT NULL DEFAULT 'Portal'
      AFTER `reservacionEstado`,
  ADD COLUMN `reservacionFechaMaestro` DATETIME NULL DEFAULT NULL
      AFTER `reservacionMaestro`,
  ADD COLUMN `idReservacionPrivado` INT NULL DEFAULT NULL
      AFTER `reservacionFechaMaestro`,
  ADD COLUMN `reservacionNoPrivado` INT NULL DEFAULT NULL
      AFTER `idReservacionPrivado`,
  ADD UNIQUE INDEX `uq_reservacion_privado` (`idReservacionPrivado`),
  ADD INDEX `idx_reservacion_maestro` (`reservacionMaestro`, `idReservacion`);
```

**Origen:** `database/sql/rsv_reservacion_add_maestria.sql` ·
`database/migrations/2026_08_18_120000_add_maestria_to_rsv_reservacion.php`

> **`idReservacionPrivado` y `reservacionNoPrivado` no son del canal inverso**, aunque lo
> parezcan. Los escribe `sellarMaestriaPrivada()` con lo que trae el veredicto **del canal de
> ida**. Se quedan.

### Triggers y procedimiento

12 triggers (AI/AU/AD × 4 tablas) que llaman a `sp_rsv_marcar_replicacion`.
**Los triggers no cambian nunca**; la regla vive solo en el procedimiento.

**Origen:** `database/manual/2026_08_18_triggers_rsv_replicacion_maestria.sql`

> ⚠️ Ese script **debe reinstalarse después** de cualquier restauración de respaldo que
> recree las tablas. Y su requisito previo es que `reservacionMaestro` exista.

### `rsv_bitacorareplicacion` — la observabilidad

```sql
CREATE TABLE IF NOT EXISTS `rsv_bitacorareplicacion` (
  `idBitacoraReplicacion`      bigint       NOT NULL AUTO_INCREMENT,
  `bitacoraReplicacionFecha`   datetime(6)  NOT NULL,
  `bitacoraReplicacionProceso` varchar(80)  NOT NULL,
  `bitacoraReplicacionTipo`    varchar(10)  NOT NULL COMMENT 'R1..R15',
  `bitacoraReplicacionMensaje` varchar(500) NOT NULL,
  `bitacoraReplicacionDatos`   json         NULL,
  `bitacoraReplicacionFirma`   varchar(120) NOT NULL,
  `bitacoraReplicacionAvisada` tinyint(1)   NOT NULL DEFAULT 0,
  `idReservacion`              int          NULL DEFAULT NULL,
  PRIMARY KEY (`idBitacoraReplicacion`),
  KEY `idx_bitrep_tipo_fecha` (`bitacoraReplicacionTipo`, `bitacoraReplicacionFecha`),
  KEY `idx_bitrep_reservacion` (`idReservacion`)
) ENGINE = InnoDB;
```

**Origen:** `database/sql/rsv_bitacorareplicacion.sql`

Tres decisiones y su motivo:

- **`bitacoraReplicacionAvisada`** distingue "no pasó nada" de "pasó y se silenció". Es justo
  la pregunta después de un reclamo.
- **Sin claves foráneas** e `idReservacion` nullable: debe poder registrar incidencias de
  reservaciones que ya se borraron o que nunca existieron. Una FK convertiría el registro del
  error en un segundo error.
- Se escribe **siempre**, esté el correo throttleado o el monitoreo apagado.

**Se queda aunque el canal inverso esté apagado**: la usan `R1` (venta esperando veredicto),
`R3` (fallo del canal de escritura) y `R6`/`R15` (rezago y leases del outbox) — todas del
canal de ida.

---

## 2.4 Canal inverso — lo retirado

Esto es lo que devuelve [`sql/activar_replica_inversa.sql`](sql/activar_replica_inversa.sql).

### Estado de réplica en la cabecera

```sql
ALTER TABLE `rsv_reservacion`
  ADD COLUMN `reservacionVersionReplica` BIGINT NOT NULL DEFAULT 0
      COMMENT 'Ultima replicaSalidaVersion de Reservaciones V2 aplicada. Rechaza versiones obsoletas (409).'
      AFTER `reservacionNoPrivado`,
  ADD COLUMN `reservacionHashReplica` CHAR(64) NULL DEFAULT NULL
      COMMENT 'sha256 del ultimo snapshot aplicado. Corta reintentos con 200 sin_cambios.'
      AFTER `reservacionVersionReplica`,
  ADD COLUMN `reservacionFechaReplica` DATETIME NULL DEFAULT NULL
      AFTER `reservacionHashReplica`;
```

- **`bigint` en la versión**: en Reservaciones V2 sube con cada cambio y no se reinicia nunca.
- **Sin índice**: se leen siempre por la PK, ya resuelta vía `idReservacionPrivado` (que sí
  tiene su UNIQUE). Un índice aquí sería peso muerto en escritura sin lectura que lo aproveche.

### Punteros de origen del detalle

```sql
ALTER TABLE `rsv_reservacionhabitacion`
  ADD COLUMN `idReservacionHabitacionPrivado` INT NULL DEFAULT NULL AFTER `idReservacion`,
  ADD UNIQUE INDEX `uq_rh_privado` (`idReservacion`, `idReservacionHabitacionPrivado`);

ALTER TABLE `rsv_reservacionhabitacionpaquete`
  ADD COLUMN `idReservacionHabitacionPaquetePrivado` INT NULL DEFAULT NULL AFTER `idReservacionHabitacion`,
  ADD UNIQUE INDEX `uq_rhp_privado` (`idReservacionHabitacion`, `idReservacionHabitacionPaquetePrivado`);

ALTER TABLE `rsv_reservacionhabitacionextra`
  ADD COLUMN `idReservacionHabitacionExtraPrivado` INT NULL DEFAULT NULL AFTER `idReservacionHabitacion`,
  ADD UNIQUE INDEX `uq_rhe_privado` (`idReservacionHabitacion`, `idReservacionHabitacionExtraPrivado`);
```

**El UNIQUE es COMPUESTO con el padre, no suelto.** El puntero solo debe ser único dentro de
su reservación (o de su habitación). Un índice global convertiría un payload mal formado en un
`500` por violación de índice, en vez de un rechazo controlado y bitacorado.

**`NULL` = "aún no adoptada".** MySQL admite múltiples `NULL` en un `UNIQUE`, así que todas
las filas anteriores conviven sin conflicto.

---

## 2.5 Lo que NO existe en el portal (y no debe crearse)

| Objeto | Dónde vive |
|---|---|
| `rsv_replicacionsalida` | **Solo Reservaciones V2.** Es el outbox del privado |
| `reservacionReplicaPortal` | **Solo Reservaciones V2.** Discriminante de ámbito |
| `rsv_replicacionentrante` | **Solo Reservaciones V2.** Su inbox, con `replicacionEntranteMaestro` |
| Triggers `trg_rsv_salida_*` | **Solo Reservaciones V2** |

Ver [11 · Contraparte Reservaciones V2](11-CONTRAPARTE-CL034.md).

---

## 2.6 Una columna que se eliminó y no vuelve

`rsv_reservacionhabitacion.idPaquete` **se retiró del portal** (migración
`2026_08_18_150000`). No tiene que ver con la replicación:

- Estaba en `NULL` en las 135 habitaciones del portal y en las 186,522 de Reservaciones V2.
- No podría representar la verdad aunque se llenara: el paquete es de **un tramo** de la
  estancia, no de la habitación. Hay 13 habitaciones con **dos paquetes distintos** —estancias
  que cruzan temporada o año—. Un solo id tendría que elegir uno y mentir sobre el otro.

**En Reservaciones V2 se conserva** por decisión explícita. El snapshot no la transporta en ninguna
dirección, así que el contrato entre los dos sistemas no cambia.

---

**Anterior:** [01 · Arquitectura](01-ARQUITECTURA-Y-CONCEPTOS.md) ·
**Siguiente:** [03 · Configuración y `.env`](03-CONFIGURACION-Y-ENV.md)
