# 11 · Contraparte Reservaciones V2

Lo que debe existir **del otro lado**. Sin esto no hay canal, por muy bien configurado que esté
el portal.

> Este documento vive en el portal y describe Reservaciones V2 para que quien active el canal no tenga que
> saltar entre repositorios. La fuente autorizada es
> `CL034RESERVACIONESV2/docs/PROMPT_IMPLEMENTACION_REPLICA_AL_PORTAL.md`.

---

## 11.1 Estado de Reservaciones V2 (verificado 2026-08-18)

| Fase | Qué | Estado |
|---|---|---|
| −1 | Observabilidad: `MonitorReservaciones`, `rsv_bitacorareplicacion`, config | ✅ |
| 1 | Defensa de maestría: `rsv_replicacionentrante` + guarda + `R10` + `R2` | ✅ |
| 2 | Emitir `mapeo` y `reservacionNoRemoto` en el veredicto | ✅ |
| 3 | Outbox de salida, ámbito, captura (3 triggers + observadores) | ✅ |
| 4 | Consumidor `replicacion:portal`, breaker, firma con cuerpo | ✅ (apagado) |

**Reservaciones V2 está listo.** Lo único pendiente de su lado es encender
`REPLICACION_PORTAL_HABILITADA` — y **solo después** de que el portal tenga su endpoint.

---

## 11.2 Esquema que debe existir en `cl038reservacionesv2`

```sql
-- Inbox con la marca de maestría (Fase 1)
--   2026_08_18_rsv_replicacionentrante_maestria.sql
rsv_replicacionentrante.replicacionEntranteMaestro       varchar(10) NOT NULL DEFAULT 'Portal'
rsv_replicacionentrante.replicacionEntranteFechaMaestro  datetime NULL

-- Outbox de salida (Fase 3)
--   2026_08_18_rsv_replicacionsalida.sql
rsv_replicacionsalida  -- 11 columnas, UNIQUE(idReservacion), InnoDB

-- Discriminante de ámbito (Fase 3)
--   2026_08_18_rsv_reservacion_ambito_portal.sql
rsv_reservacion.reservacionReplicaPortal  enum('S','N') NULL DEFAULT NULL
INDEX idx_rsv_replicaportal (reservacionReplicaPortal, idReservacion)

-- Bitácora (Fase −1)
--   2026_08_18_rsv_bitacorareplicacion.sql
rsv_bitacorareplicacion

-- Captura (Fase 3) — 3 triggers, SOLO sobre la cabecera
--   2026_08_18_triggers_rsv_replicacionsalida.sql
trg_rsv_salida_reservacion_ai / _au / _ad
```

Comprobación:

```bash
cd CL034RESERVACIONESV2
php artisan tinker --execute="
use Illuminate\Support\Facades\DB;
echo 'salida: '.count(DB::select(\"SHOW TABLES LIKE 'rsv_replicacionsalida'\")).PHP_EOL;
echo 'ambito: '.count(DB::select(\"SHOW COLUMNS FROM rsv_reservacion LIKE 'reservacionReplicaPortal'\")).PHP_EOL;
echo 'triggers: '.DB::selectOne(\"SELECT COUNT(*) n FROM information_schema.TRIGGERS
  WHERE TRIGGER_SCHEMA=DATABASE() AND TRIGGER_NAME LIKE '%salida%'\")->n.PHP_EOL;
echo 'maestria entrante: '.count(DB::select(\"SHOW COLUMNS FROM rsv_replicacionentrante LIKE 'replicacionEntranteMaestro'\")).PHP_EOL;
"
# → 1 · 1 · 3 · 1
```

---

## 11.3 Por qué Reservaciones V2 usa 3 triggers y no 12

La propuesta original pedía 12 (AI/AU/AD sobre las cuatro tablas). **Se midió con volumen real
—156k reservaciones, 185k habitaciones— y no pasó el criterio del 3 %:**

| Cuerpo del trigger | Sobrecoste por fila |
|---|---|
| Solo la invocación (`CALL` a un procedimiento vacío) | 12.4 µs |
| 1 búsqueda + upsert | 42 µs |
| 2 búsquedas + upsert (paquetes y extras) | 82 µs |
| **Mirando `NEW`, sin ninguna búsqueda** | **6.4 µs** ← el diseño actual |

Impacto con los 12: un `UPDATE` masivo de 2000 extras pasaba de **6.9 ms a 172.6 ms (+2403 %)**.
El coste no estaba en el `CALL` sino en **ejecutar sentencias dentro del cuerpo del trigger**,
una vez por fila. Y lo pagaba toda escritura de las cuatro tablas —check-in, devengado,
facturación— estuviera o no la reservación en ámbito, porque para saberlo había que buscarlo.

**Lo que hace en su lugar, en tres capas:**

1. **Aplicación** — observadores de Eloquent sobre habitaciones, paquetes y extras.
2. **Base de datos** — 1 trigger sobre la cabecera, que mira `NEW.reservacionReplicaPortal` y
   no busca nada. Cubre lo que no pase por Eloquent.
3. **Reconciliador horario** — caza lo que escape a las dos.

Resultado medido: **−5.2 % fuera del ámbito** (ruido) y **+16.6 % dentro**, que lo pagan solo
las reservaciones que el socio ve en el portal.

### ⚠️ La consecuencia para el portal

**El reconciliador dejó de ser un lujo y es parte del contrato.** Los `update()` masivos por
query builder **no disparan eventos de modelo**, y el trigger de cabecera solo los caza si tocan
la cabecera. La tercera capa es `GET /reservaciones/replicacion/estado`, que vive en el portal.

Por eso ese endpoint no es opcional, y por eso `R11` es la métrica de salud del canal: **en
régimen debe ser cero**. Sostenidamente distinto de cero significa que hay un punto de escritura
que las capas 1 y 2 no ven — y hay que encontrarlo, no subir la frecuencia del reconciliador.

---

## 11.4 Configuración de Reservaciones V2

```dotenv
# Interruptor del consumidor. HOY debe estar en false.
REPLICACION_PORTAL_HABILITADA=false

# Válvula de emergencia de la captura. Apagarla degrada la LATENCIA, no la corrección:
# el trigger de cabecera y el reconciliador siguen cubriendo el canal.
REPLICACION_PORTAL_CAPTURA=true

REPLICACION_PORTAL_URL=http://<ip-zerotier-del-portal>
REPLICACION_PORTAL_ALCANCE=portal          # 'portal' | 'todas'
REPLICACION_PORTAL_MODO=sync               # 'sync' | 'cola'
REPLICACION_PORTAL_LOTE=50
REPLICACION_PORTAL_LOTE_SEGUNDOS=45        # DEBE ser menor que el intervalo del cron
REPLICACION_PORTAL_LEASE=300               # DEBE ser mayor que el tope de envío
REPLICACION_PORTAL_INTENTOS=5
REPLICACION_PORTAL_TIMEOUT=30

REPLICACION_PORTAL_MONITOREO=true
REPLICACION_PORTAL_MONITOREO_CORREOS=soporte@tudominio.mx
```

**El secreto HMAC se comparte con el canal de ida** (`SINCRONIZACION_HMAC_SECRET`). No hay
secreto nuevo que repartir.

> Dos relaciones que si se invierten rompen el canal en silencio:
> `LOTE_SEGUNDOS` **<** intervalo del cron (o `withoutOverlapping` se salta el turno entero), y
> `LEASE` **>** tope de envío (o una marca en vuelo se da por huérfana y se envía dos veces).

---

## 11.5 Alcance: `portal` vs `todas`

| Valor | Qué entra | Cuándo |
|---|---|---|
| `portal` | Solo las que **nacieron en el portal** y ahora se editan en el privado | **Arranque.** Volumen y riesgo mínimos |
| `todas` | Además, toda reservación de socio cuya membresía existe en el portal | Cuando el negocio pida que el socio vea **todas** sus reservaciones |

Quién pinta el discriminante:

- **Automático:** `ReplicarReservacionEnLinea` pone `'S'` a toda reservación que llega del
  portal, dentro de la transacción del veredicto. Coste cero para el alcance `portal`.
- **Comando:** `replicacion:portal-ambito`, que aplica el alcance configurado.

Regalo del diseño: como el trigger mira `NEW`, **el propio `UPDATE` que mete una reservación al
ámbito la marca**. No hace falta sembrar el outbox aparte al ampliar el alcance.

> Con `alcance=todas`, las reservaciones que nacen en el mostrador llegan al portal y **nacen
> directamente `Privado`** (`Reservacion::nuevaDesdePrivado()`). Si nacieran `Portal`, sus
> propios triggers las marcarían y el portal le propondría cambios al maestro sobre una
> reservación que no conoce.

---

## 11.6 Comandos de Reservaciones V2

| Comando | Cron | Qué hace |
|---|---|---|
| `replicacion:portal` | cada minuto, `withoutOverlapping()` | El consumidor del outbox |
| `replicacion:portal-ambito` | cada 10 min | Pinta `reservacionReplicaPortal` según el alcance |
| `replicacion:portal-reconciliar` | cada hora | Compara contra `GET /estado` y re-ensucia lo divergente |
| `replicacion:portal-backfill --desde= --hasta=` | manual | Pinta ámbito y siembra el outbox. **Nunca envía por su cuenta** |

El backfill no envía a propósito: deja que el consumidor, con su breaker y su lease, haga el
trabajo. Enviar desde el backfill saltaría las dos protecciones justo cuando más carga hay.

---

## 11.7 Riesgos conocidos de Reservaciones V2, sin maquillar

Del propio `DECISION_CAPTURA_FASE3.md`:

1. **Los `update()` masivos por query builder no disparan eventos de modelo.** Es el hueco real
   de la capa 1. Lo tapan el trigger de cabecera (si tocan la cabecera, que es lo normal) y el
   reconciliador (siempre, con hasta una hora de retraso).
2. **Si `rsv_replicacionsalida` desaparece o falla, las escrituras sobre la cabecera abortan.**
   Verificado: con la tabla renombrada, el observador se traga el fallo y la habitación se
   guarda, pero el `UPDATE` de la cabecera falla por el trigger. **Por eso la reversa siempre
   quita triggers primero.**
3. **`rsv_reservacionhabitacionpaquete` es MyISAM.** No participa en transacciones: un
   `DB::transaction` que falle no revierte sus filas. Inofensivo para la réplica —que es por
   estado, así que reenviar el mismo snapshot es idempotente— pero conviene saberlo.

---

## 11.8 Lo que Reservaciones V2 **no** debe cambiar para desactivar el canal

**Nada.** Puede seguir enviando `mapeo` y `reservacionNoRemoto` en el veredicto: el portal los
ignora sin error.

Lo único que no debe hacer es encender `REPLICACION_PORTAL_HABILITADA`, porque `/entrante`
devuelve `404` y su replicador lo clasificaría como permanente, **descartando las marcas sin
reintentar**.

---

**Anterior:** [10 · Pruebas de aceptación](10-PRUEBAS-DE-ACEPTACION.md) ·
**Volver al** [índice](README.md)
