# 01 · Arquitectura y conceptos

> Empieza aquí si no conoces el sistema. Los documentos 02 a 11 dan por sabido lo que hay
> en éste.

---

## 1.1 Los dos canales

```
   ┌──────────────────── PORTAL PÚBLICO (CL032BACK) ─────────────────────┐
   │                                                                     │
   │  reservacionMaestro = 'Portal'   │  reservacionMaestro = 'Privado'  │
   │  ─────────────────────────────   │  ──────────────────────────────  │
   │  Temporal · Confirmando          │  Reservado · Finalizado ·        │
   │  (y su Cancelado)                │  Cancelado post-venta            │
   │                                  │                                  │
   │  ESCRIBE el socio                │  SOLO LECTURA para el socio      │
   │  outbox rsv_replicacion  ──────► │  ◄────── endpoint /entrante      │
   └────────────────────┬─────────────────────────────▲──────────────────┘
                        │                             │
      (1) Reservaciones V2 JALA por long-poll          (2) Reservaciones V2 EMPUJA firmado
          GET  /replicacion/pendientes          POST /replicacion/entrante
          POST /replicacion/veredicto                🔴 HOY APAGADO
                        │                             │
   ┌────────────────────▼─────────────────────────────┴──────────────────┐
   │                 PRIVADO / MAESTRO (Reservaciones V2)                │
   └─────────────────────────────────────────────────────────────────────┘
```

**Regla R1, en las dos direcciones: Reservaciones V2 SIEMPRE inicia la conexión.** El portal nunca abre
hacia el privado. En el canal inverso eso significa que Reservaciones V2 hace el `POST`; el portal solo
expone el endpoint y responde.

---

## 1.2 Maestría: el concepto central

**La maestría responde a "¿quién manda sobre esta fila?" — no a "¿en qué punto del negocio
está?".** Son dos ejes distintos y mezclarlos es el error que este diseño evita.

```
t0  el socio pulsa Confirmar     → Temporal → Confirmando   (maestro: Portal)
t1  se marca el outbox            → rsv_replicacion         (maestro: Portal)
t2  Reservaciones V2 jala, toma candado, aplica el snapshot, asienta la venta
t3  Reservaciones V2 responde el veredicto {estado: OK, idReservacionRemoto, ...}
t4  el portal, EN UN SOLO COMMIT:
      · reservacionEstado       := 'Reservado'
      · reservacionNo           := folio
      · idReservacionPrivado    := el id del maestro
      · reservacionMaestro      := 'Privado'   ◄── LA FRONTERA
      · reservacionFechaMaestro := NOW()
t5  a partir de aquí el portal NO vuelve a proponer cambios sobre esa reservación
```

### Por qué no se condiciona por `reservacionEstado`

Cuatro razones, y la primera basta:

1. **La regla no es expresable por estado.** Un `Cancelado` que viene de `Temporal` **sí**
   debe replicarse —libera cupo en el maestro— y uno que viene de `Reservado` no puede ni
   originarse en el portal. El mismo estado destino, dos respuestas opuestas.
2. **El estado no es monótono.** Una corrección manual que devolviera una fila a `Temporal`
   le regresaría la maestría al portal en silencio.
3. **Los triggers nieto no tienen el estado a mano.** Un trigger sobre un extra tendría que
   saltar extra → habitación → reservación solo para leerlo. Por 12 triggers, son 12 lugares
   donde la regla puede divergir.
4. **No sería auditable.** `reservacionFechaMaestro` responde "¿desde cuándo mandaba el
   privado?" ante un reclamo.

### Qué pasa si la frontera falla

Reservaciones V2 aplica el snapshot con `ReservacionHabitacion::reemplazarDesdeSnapshot()`, que **borra**
extras, paquetes y habitaciones locales antes de reinsertar. Una marca tardía del portal sobre
una reservación que el gerente acaba de editar **le borra ese trabajo sin dejar rastro**.

Por eso el corte vive en cuatro capas:

| Capa | Dónde | Qué cubre |
|---|---|---|
| 1 | `sp_rsv_marcar_replicacion` (BD) | Escritura manual, scripts, migraciones — todo lo que no pase por la app |
| 2 | `Replicacion::marcar()` (PHP) | Redundante a propósito: si se reinstala un script de triggers viejo, la app sigue tapando |
| 3 | `ReplicacionController::recolectar()` | **La garantía definitiva.** Marca creada ANTES del sello y drenada DESPUÉS |
| 4 | `ReplicarReservacionEnLinea` (Reservaciones V2) | El maestro no depende de que el esclavo se porte bien |

La capa 3 cubre una carrera real: el socio confirma con una marca `Pendiente` de un cambio
anterior todavía en el outbox, llega el veredicto, se sella `Privado`, y el siguiente
long-poll aún ve esa marca vieja.

---

## 1.3 El canal inverso, en concepto

Cuando la maestría pasa al privado, el socio deja de poder editar — pero **debe seguir viendo
su reservación al día**. Si el gerente le agrega una habitación en el mostrador, el portal
tiene que reflejarlo.

### El problema difícil: no duplicar

La primera vez que llega una réplica, las filas locales del socio ya existen. Si se insertaran
las entrantes al lado, una reservación de 3 habitaciones pasaría a mostrar 6.

**La solución es emparejar por PUNTERO DE ORIGEN, nunca por atributos.** Cada fila local
guarda la PK que su gemela tiene en Reservaciones V2 (`idReservacionHabitacionPrivado` y sus hermanas).

> **Regla dura:** dos habitaciones del mismo tipo, mismas fechas y mismo importe son
> perfectamente legítimas, y ambas casarían con la misma fila entrante. Emparejar por
> atributos es el camino exacto a duplicarle al socio su reservación.

Reconciliación de tres vías, por nivel:

```
para cada fila ENTRANTE del snapshot:
    ¿existe local con ese idPrivado, bajo el mismo padre?
        SÍ  → UPDATE          (nunca INSERT)
        NO  → INSERT fijando el puntero

para cada fila LOCAL cuyo puntero NO viene en el snapshot:
    habitación → estado := 'Cancelado'   (conserva historia e ids)
    paquete    → DELETE                   (el socio no debe verlo)
    extra      → DELETE                   (el socio no debe verlo)
```

La habitación se **cancela** en vez de borrarse porque el front, los PDFs y las
notificaciones que el socio ya recibió referencian `idReservacionHabitacion`.

### De dónde salen los punteros la primera vez

Del **veredicto**. Dentro de `reemplazarDesdeSnapshot()`, Reservaciones V2 tiene los dos ids a la vez
—el del portal, que le llegó en el snapshot, y el suyo recién insertado— y los devuelve en el
campo `mapeo`. El portal los escribe **en el mismo commit del sello de maestría**.

Si el mapeo no llega (veredicto perdido, Reservaciones V2 antiguo), hay un **camino de respaldo**: como
la maestría ya es del privado y no hay nada local que preservar, se reemplaza el detalle
completo fijando los punteros de una vez, y se levanta la incidencia `R12`. Ocurre a lo sumo
una vez por reservación.

> Nunca se adivina el emparejamiento por posición ni por atributos. Adivinar es precisamente
> cómo se duplica.

---

## 1.4 Versión y hash: por qué hacen falta los dos

El lease por sí solo no ordena nada.

- **`replicaVersion`** — Reservaciones V2 la sube con cada cambio. El portal **rechaza con `409` cualquier
  snapshot cuya versión sea ≤ la ya aplicada**. Sin esto, dos jobs encolados que lleguen en
  orden inverso harían que el socio viera un cambio deshacerse solo.
- **`reservacionHashReplica`** — corta reintentos sin reescribir. Si el hash coincide con el
  último aplicado, se responde `200 sin_cambios`: ni se tocan filas ni se notifica al socio.

**El corte por versión va ANTES que el de hash.** Una versión obsoleta con el mismo hash sigue
siendo una respuesta incorrecta si se cierra como "aplicada".

Consecuencia útil: reenviar *literalmente* el mismo snapshot da `409 version_obsoleta`, no
`sin_cambios`. `sin_cambios` es para **versión mayor con contenido idéntico** — el caso real,
porque la versión sube con cualquier escritura en Reservaciones V2 (un pago, un devengado) aunque no
cambie nada replicable.

---

## 1.5 El portal transcribe, no calcula

**Bajo maestría privada el portal NUNCA recalcula importes con sus tarifas espejo.** Los
transcribe tal cual los manda el maestro.

Si recalculara, bastaría un catálogo espejo desactualizado un día para que el socio vea
$12,400 y el club $12,650 por la misma reservación. Un solo caso así cuesta más que todo el
canal.

Lo único que valida es que la suma del detalle cuadre con la cabecera dentro de un centavo. Si
no cuadra, **aplica igual** (el maestro manda) y levanta `R13`.

---

## 1.6 El orden entre proyectos, y por qué no es intercambiable

**Regla #1 — el corte del portal va antes que la guarda de Reservaciones V2.**
Si Reservaciones V2 despliega su guarda de maestría antes de que el portal deje de emitir la segunda
vuelta de réplica, la rechazará en **cada venta** y disparará una incidencia `R10` por cada
una. `R10` va sin throttle a propósito —en régimen normal no debe sonar jamás—, así que se
enterraría la señal real bajo ruido desde el primer día.

**Regla #2 — el endpoint del portal va antes que el consumidor de Reservaciones V2.**
Si se enciende `replicacion:portal` y `/entrante` no existe, cada envío da `404`. El `404` es
`4xx`, así que el replicador lo clasifica como **permanente** y manda la marca a `Descartado`
sin reintentar. El canal nacería descartando todo lo que produce.

Esa segunda regla es la razón por la que hoy, con el canal apagado, **las rutas no se
registran en absoluto** en vez de registrarse y fallar: `404` es la respuesta correcta para
"este canal no existe todavía".

---

**Siguiente:** [02 · Esquema de base de datos](02-ESQUEMA-BASE-DE-DATOS.md)
