# 12 — Vínculo de la solicitud de invitados en la réplica (lado PÚBLICO)

Fecha: 20-ago-2026
Ámbito: **CL032 (portal público)**. La contraparte del privado está en
`CL034RESERVACIONESV2/docs/PROPUESTA_VINCULO_SOLICITUD_REPLICA_PORTAL.md` y **las dos se
implementan juntas**.
Módulos: Reservaciones · Replicación · Invitados

---

## 1. Qué está mal hoy

`SnapshotReservacionService::cabecera()` manda la PK **local** de la solicitud:

```php
// app/Services/Reservaciones/SnapshotReservacionService.php:62
'idSolicitud' => $reservacion->idSolicitud !== null ? (int) $reservacion->idSolicitud : null,
```

y Reservaciones V2 la escribe tal cual en `rsv_reservacion.idSolicitud`, que es **FK de su propia
`inv_solicitud`**. Son dos bases distintas: el puntero acaba señalando la solicitud de otro
socio, o ninguna. La réplica responde `OK` y nadie se entera.

**No es un dato feo en una columna.** Del otro lado, `ReservacionController` de Reservaciones V2 hace
`Solicitud::findOrFail($reservacion->idSolicitud)` → `aprobar()` → `AprobarSolicitudSicamJob`:
se está **aprobando en SICAM la solicitud equivocada**.

### 1.1 La cadena real

```
CL032 (portal)                  CL001SICAM (Tlalpan)             Reservaciones V2 (Villastur privado)
inv_solicitud                   inv_solicitud                    inv_solicitud
  idSolicitud        (PK)  ──►    idSolicitud        (PK)  ──►     idSolicitud        (PK)
  idSolicitudTlalpan ─────────────┘                                idSolicitudRemoto ─┘
```

La única llave común entre el portal y Reservaciones V2 es **la PK de Tlalpan**:

> `CL032.inv_solicitud.idSolicitudTlalpan` == `Reservaciones V2.inv_solicitud.idSolicitudRemoto`

El `idSolicitud` local de Reservaciones V2 **solo se puede resolver allá**. El portal no lo conoce y no
puede conocerlo: R1 prohíbe que el público inicie una conexión hacia el privado.

Es el mismo criterio que ya se aplica a la membresía (`membresiaNo` como clave natural) y al
beneficiario (`beneficiarioTitular` como bandera). El `idSolicitud` es la única pieza del
snapshot que todavía viaja como id crudo.

### 1.2 Esto ya está resuelto en el canal INVERSO

No hay nada que inventar. [`ReplicacionEntranteController`](../../app/Http/Controllers/Reservaciones/ReplicacionEntranteController.php)
—la réplica Reservaciones V2 → portal— **ya recibe `idSolicitudTlalpan` y lo resuelve localmente**:

```php
$solicitud = Solicitud::where('idMembresia', $membresia->idMembresia)
    ->where('idSolicitudTlalpan', $idSolicitudTlalpan)
    ->first();
```

con este comentario al lado, que es la clave de todo:

> *"Se acota por membresía a propósito: `idSolicitudTlalpan` es un id de OTRO servidor y
> buscarlo suelto podría emparejar la solicitud de otro socio."*

**El canal de ida es el único que no lo hace.** Esta propuesta lo pone a la par:

* mismo nombre de campo en el cable (`idSolicitudTlalpan`), en ambas direcciones;
* misma búsqueda acotada por membresía, ahora del lado de Reservaciones V2;
* la incidencia `R17` **ya existe** para el caso simétrico (`solicitud_no_replicada`), así que
  la nueva de Reservaciones V2 es `R18`.

> **Una diferencia deliberada**: el canal inverso trata "la solicitud aún no está" como una
> carrera transitoria y responde **409** para que se reintente con backoff. El de ida
> **cancela** en vez de esperar (§2). No es incoherencia: allá el que espera es un snapshot en
> una cola y no le cuesta nada a nadie; aquí el que espera es cupo apartado por el socio que
> el maestro no conoce.

---

## 2. La decisión: liberar el cupo, no esperar

Los dos saltos son asíncronos, así que hay una ventana real en la que la solicitud existe aquí
y en Tlalpan pero **todavía no en Reservaciones V2**.

Se evaluó diferir la réplica y reintentar hasta que llegara. **Se descartó**: mientras el
portal espera, mantiene un **hold fantasma**. Aparta las habitaciones y las descuenta de su
propia disponibilidad, pero Reservaciones V2 nunca se enteró — así que el mostrador **sigue vendiendo
esas mismas habitaciones**, con toda la razón. Media hora después el veredicto sería
`SIN_CUPO`, y entre tanto las dos bases divergen justo sobre el recurso que este canal existe
para proteger.

**El diseño adoptado**: cuando Reservaciones V2 no encuentra la solicitud, no escribe nada, dispara una
sincronización contra Tlalpan para traerla, y devuelve `SIN_SOLICITUD`. El portal **libera el
cupo de inmediato** y le dice al socio que reintente en unos minutos.

Cancelar cuesta casi nada, y ése es el punto: **la primera réplica ocurre al agregar la
PRIMERA habitación**, no al pagar. El veredicto llega ~6 s después, con el socio todavía
eligiendo. Lleva un clic invertido, no una captura completa. Es el mismo argumento ya escrito
para `avisarMembresia`.

---

## 3. Cambios

### 3.1 `SnapshotReservacionService` — mandar la llave natural

`armar()` carga la solicitud junto con la membresía, y `cabecera()` cambia:

```php
// Reservación de INVITADOS: el vínculo con inv_solicitud de Reservaciones V2 NO puede viajar como
// nuestra PK — son bases distintas. La única llave común es la PK de Tlalpan, que allá
// vive en inv_solicitud.idSolicitudRemoto. Mismo criterio que membresiaNo.
//
// TRANSICIÓN: 'idSolicitud' se conserva mientras Reservaciones V2 viejo pueda estar en producción.
// Se retira en la limpieza (§7). Ver la §7 de la propuesta de Reservaciones V2: allá se distingue
// la clave AUSENTE (portal viejo) del valor null (solicitud sin transferir), y esa
// distinción es lo único que hace seguro el despliegue escalonado.
'idSolicitud'        => $reservacion->idSolicitud !== null ? (int) $reservacion->idSolicitud : null,
'idSolicitudPortal'  => $reservacion->idSolicitud !== null ? (int) $reservacion->idSolicitud : null,
'idSolicitudTlalpan' => $solicitud?->idSolicitudTlalpan !== null ? (int) $solicitud->idSolicitudTlalpan : null,
```

* `idSolicitudPortal` viaja **solo para trazas y correlación**; Reservaciones V2 no lo escribe nunca.
* `idSolicitudTlalpan` en `null` **es información**, no ausencia de dato: significa que la
  solicitud todavía no salió de aquí hacia Tlalpan.
* La clave debe emitirse **siempre** que el tipo sea `Invitado`, incluso con valor `null`.
  Omitirla haría que Reservaciones V2 activara su respaldo de compatibilidad y se replicara el id
  equivocado.

### 3.2 Dos veredictos nuevos y su motivo

```php
// app/Http/Requests/Reservaciones/Replicacion/ProcesarVeredictoFormRequest.php
'estado' => 'required|string|in:OK,ERROR,SIN_CUPO,SIN_SOLICITUD,SOLICITUD_OCUPADA',

// Slug del motivo. DEBE declararse: validated() descarta lo no declarado, así que sin
// esta regla el motivo llegaría de Reservaciones V2 y se tiraría en silencio — y sin él no se puede
// distinguir "espera unos minutos" de "comunícate con el hotel".
'motivo' => 'nullable|string|in:sincronizando,no_existe_en_origen,sin_transferir,ocupada_firme,ocupada_en_curso',

// Datos de la reservación que YA ocupa la solicitud (§3.3bis). Solo viaja con
// SOLICITUD_OCUPADA. Es lo que convierte un mensaje genérico en uno accionable: sin el
// folio, al socio le decimos "ya está reservada" y no puede hacer nada con eso.
'contexto'                          => 'nullable|array',
'contexto.reservacionNo'            => 'nullable|integer|min:1',
'contexto.reservacionFechaLlegada'  => 'nullable|string|max:30',
'contexto.reservacionFechaSalida'   => 'nullable|string|max:30',
```

`SIN_SOLICITUD` y `SOLICITUD_OCUPADA` son veredictos **distintos** porque el desenlace lo es:
el primero se resuelve solo con el tiempo y el socio debe reintentar; el segundo **no se
resuelve nunca por esperar** —esa solicitud ya se usó— y el bloqueo puede ser permanente.

> El motivo viaja como **slug**, no como texto. `AvisoMembresiaService` hoy clasifica por el
> texto del veredicto y su propio comentario reconoce que "lo correcto sería que viajaran los
> slugs". Aquí se hace bien desde el principio.

### 3.3 `ReplicacionController::veredicto()` — nueva rama

```php
case 'SIN_SOLICITUD':
    // La solicitud del socio todavía no existe en el privado. Reservaciones V2 ya disparó su
    // sincronización contra Tlalpan; aquí se libera el cupo y se le explica al socio.
    $this->resolverSinSolicitud(
        (int) $datos['idReservacion'],
        $datos['motivo'] ?? AvisoSolicitudService::MOTIVO_SINCRONIZANDO
    );
    Replicacion::marcarProcesado($idReplicacion);
    break;
```

Y el resolvedor:

```php
/**
 * SIN_SOLICITUD: el privado no pudo vincular la solicitud de invitados, así que NO
 * escribió nada. Aquí se suelta el cupo que nadie está reteniendo del otro lado y se le
 * dice al socio qué pasó y qué hacer.
 *
 * SE CANCELA COMPLETA, y es lo correcto aunque suene duro: sin solicitud no hay
 * reservación de invitados posible, ni entera ni parcial. La diferencia con SIN_CUPO
 * —donde se quitan solo las habitaciones afectadas— es que allá el problema es de
 * inventario y aquí es de la propia solicitud: no hay nada que salvar quitando cuartos.
 *
 * Y cancelar es justo lo que EVITA el daño: mientras esta Temporal siga viva, retiene
 * cupo que Reservaciones V2 no conoce y que el mostrador está vendiendo en paralelo.
 */
private function resolverSinSolicitud(int $idReservacion, string $motivo): void
{
    $reservacion = Reservacion::find($idReservacion);
    if (! $reservacion) {
        return;
    }

    // La solicitud queda marcada ANTES de cancelar: es lo que bloquea el reintento
    // inmediato (§3.5) y lo que sostiene el aviso tras un F5. Va aunque la cancelación
    // no proceda — el dato sobre la solicitud es cierto igual.
    if ($reservacion->idSolicitud) {
        Solicitud::marcarBloqueada(
            (int) $reservacion->idSolicitud,
            $motivo,
            now()->addSeconds(Solicitud::BLOQUEO_ESPERA_SEGUNDOS)   // SIN_SOLICITUD siempre caduca
        );
    }

    // DINERO EN VUELO: una 'Confirmando' NO se cancela nunca. El pago es por
    // transferencia y pudo haber salido ya; cancelar dejaría al socio cobrado y sin
    // reservación. Se queda quieta y `reservaciones:alertar-confirmando` (R1) la reporta
    // para que una persona decida. Mismo criterio que ese comando.
    //
    // Con el veredicto llegando a los ~6 s de la primera habitación esto es casi
    // imposible, pero "casi" no basta cuando hay un cargo de por medio.
    if ($reservacion->reservacionEstado !== Reservacion::ESTADO_TEMPORAL) {
        Log::warning('replicacion.sinsolicitud | no se cancela: la reservación ya no es Temporal', [
            'idReservacion'     => $idReservacion,
            'reservacionEstado' => $reservacion->reservacionEstado,
            'motivo'            => $motivo,
        ]);

        return;
    }

    (new ReservacionService())->cancelar($reservacion);

    $this->avisarSinSolicitud($reservacion, $motivo);
}
```

### 3.3bis `SOLICITUD_OCUPADA` — una solicitud, una reservación

**Regla de negocio**: una solicitud de invitados admite **una sola** reservación. Si esa
reservación se cancela, la solicitud queda libre y puede volver a reservarse — por eso el
filtro de Reservaciones V2 es "**distinta de `Cancelado`**" y no "existe alguna".

El portal ya aplica esta regla sobre **sus propios** datos: `Reservacion::asociadaDeSolicitud()`
es la compuerta de entrada en `ReservacionController:115`, y su comentario ya documenta esta
misma semántica ("las `Cancelado` se omiten a propósito… el socio tiene que poder reservar de
nuevo esa misma solicitud").

**Lo que el portal NO puede ver es el otro lado.** Los casos reales que hoy se cuelan:

* la reservación de esa solicitud **se capturó en el mostrador**, no aquí;
* la hizo otro beneficiario de la misma membresía desde otro dispositivo;
* o nació aquí, se replicó, la maestría pasó al privado y el portal ya no manda sobre ella.

En los tres, el socio arma su reservación sin obstáculo y la venta se duplicaría sobre la
misma solicitud. Por eso la comprobación la hace **quien tiene los datos completos** —el
maestro— y viaja como veredicto. Lo de este lado es el **recuerdo** de ese veredicto, no una
verdad paralela.

```php
case 'SOLICITUD_OCUPADA':
    // La solicitud ya tiene una reservación viva en el privado (mostrador, otro
    // dispositivo, o una ya sellada). NO es un fallo del canal ni del socio: es la regla
    // de negocio. Se libera el cupo y se le explica con el folio en la mano.
    $this->resolverSolicitudOcupada(
        (int) $datos['idReservacion'],
        $datos['motivo'] ?? AvisoSolicitudService::MOTIVO_OCUPADA_FIRME,
        (array) ($datos['contexto'] ?? [])
    );
    Replicacion::marcarProcesado($idReplicacion);
    break;
```

`resolverSolicitudOcupada()` es gemelo de `resolverSinSolicitud()` —mismo orden, misma guarda
de `Confirmando`, misma cancelación— con una sola diferencia: **el bloqueo de la solicitud no
caduca** cuando el motivo es `ocupada_firme`.

```php
// ocupada_firme  → la reservación ya está asentada con folio. Esperar no la va a cambiar,
//                  así que el bloqueo NO vence: dejar que el socio reintente cada dos
//                  minutos para recibir el mismo "no" es hostigarlo.
// ocupada_en_curso → la están capturando en el club AHORA. Puede caerse sola (su TTL) y
//                  entonces el socio sí podrá reservar. Bloqueo temporal, como el resto.
Solicitud::marcarBloqueada(
    (int) $reservacion->idSolicitud,
    $motivo,
    $vence: $motivo === AvisoSolicitudService::MOTIVO_OCUPADA_FIRME ? null : /* +120 s */
);
```

> **El bloqueo permanente se levanta solo por el camino correcto**: si esa reservación se
> cancela en el privado, la réplica **inversa** la trae y el portal la verá `Cancelado`. En
> ese momento se limpia la marca (`Solicitud::liberarBloqueo()` desde
> `AplicarReplicaPrivadaService`) y el socio puede reservar de nuevo. Es la mitad que hace que
> "permanente" no signifique "para siempre".
>
> Si el canal inverso estuviera apagado —hoy lo está—, la marca se puede limpiar a mano y el
> socio reintenta. **No se pierde nada**: sin la marca simplemente vuelve a recibir el
> veredicto del maestro, que es la verdad. El bloqueo es una cortesía para no hacerle perder
> el tiempo, nunca la fuente de autoridad.

> **Sobre el orden de las marcas**: `ReservacionService::cancelar()` llama a
> `Replicacion::marcar(OP_CANCELACION)`, que **no** reutiliza la marca en curso —está en
> `Procesando`, fuera de `ESTADOS_ABIERTOS`— sino que abre una nueva. Por eso el
> `marcarProcesado()` de arriba cierra la que trajo el veredicto sin pisar la cancelación,
> exactamente igual que ya ocurre en la rama `SIN_CUPO`. Esa cancelación **sí** viaja a
> Reservaciones V2, que la deja pasar sin exigir solicitud (§4.3 de la propuesta del privado).

### 3.4 `AvisoSolicitudService` — los cinco desenlaces

El bucle tiene que terminar: si al socio siempre se le dice *"espera unos minutos"*, esperará
para siempre. Reservaciones V2 distingue tres situaciones y aquí se traducen a tres mensajes distintos.

```php
class AvisoSolicitudService
{
    public const MOTIVO_SINCRONIZANDO       = 'sincronizando';
    public const MOTIVO_NO_EXISTE_EN_ORIGEN = 'no_existe_en_origen';
    public const MOTIVO_SIN_TRANSFERIR      = 'sin_transferir';
    public const MOTIVO_OCUPADA_FIRME       = 'ocupada_firme';
    public const MOTIVO_OCUPADA_EN_CURSO    = 'ocupada_en_curso';
}
```

| Motivo | Qué pasó | Aviso al socio |
|---|---|---|
| `sincronizando` | No estaba en el privado; se disparó la sincronización con el club. | **Estamos actualizando tu información**<br>Necesitamos sincronizar los datos de tu solicitud de invitados con el club. Suele tardar un par de minutos y no tienes que hacer nada. Liberamos las habitaciones que habías elegido para no apartarlas mientras tanto — vuelve a intentarlo en unos minutos. |
| `no_existe_en_origen` | Ya se sincronizó y **sigue** sin existir. Esperar no la va a traer. | **No pudimos encontrar tu solicitud**<br>Tu solicitud de invitados no aparece en los registros del club. Comunícate con el hotel para revisarla antes de reservar. |
| `sin_transferir` | Ni siquiera salió del portal hacia Tlalpan. | **Tu solicitud aún se está procesando**<br>Tu solicitud de invitados todavía está en revisión y aún no podemos usarla para reservar. Te avisamos en cuanto esté lista. |
| `ocupada_firme` | Esa solicitud ya tiene una reservación asentada. | **Esta solicitud ya tiene una reservación**<br>La solicitud de invitados que estás usando ya fue reservada con el folio **{reservacionNo}**, del {llegada} al {salida}. Liberamos las habitaciones que habías elegido. Si no reconoces esta reservación o necesitas modificarla, comunícate con el hotel. |
| `ocupada_en_curso` | La están capturando en el club en este momento. | **Esta solicitud se está reservando ahora**<br>Alguien está generando la reservación de esta solicitud de invitados en este momento. Liberamos las habitaciones que habías elegido para no duplicarla. Consulta tus reservaciones en unos minutos: si ya aparece, no tienes que hacer nada. |

**Por qué `ocupada_firme` lleva folio y fechas y `ocupada_en_curso` no.** El folio es lo que
convierte el aviso en algo accionable: el socio puede buscar esa reservación, reconocerla como
suya o llamar al hotel citándola. Una captura en curso todavía no tiene folio
(`reservacionNo = 0`), y mandar "folio 0" es peor que no mandar nada — por eso Reservaciones V2 lo envía
como `null` y la plantilla omite la frase completa, no la imprime vacía.

**Por qué `ocupada_en_curso` no dice "espera 2 minutos y reintenta".** Porque probablemente no
haga falta reintentar: si quien está capturando es el propio hotel para este socio, la
reservación va a aparecer sola en su lista. Mandarlo a reintentar produciría dos reservaciones
compitiendo por la misma solicitud, que es justo lo que este filtro evita.

Reglas de redacción, en línea con lo ya establecido en el módulo:

* **No se dice "no se realizó ningún cargo".** El pago es por transferencia y la pantalla
  anterior pide hacerla antes de confirmar: el dinero pudo haber salido ya, y el portal no
  tiene forma de saberlo. Decirle que no hubo cargo es justo lo que haría que no reclame.
* **No se promete disponibilidad.** "Vuelve a intentarlo en unos minutos", nunca "las
  habitaciones seguirán ahí": otro socio puede tomarlas y sería mentir.
* **No se culpa al socio.** Ninguno de los tres textos sugiere que hizo algo mal, porque no lo
  hizo.
* El texto lo redacta **el portal**, nunca Reservaciones V2. Del maestro se toma el HECHO (el slug), no
  la redacción. Mismo criterio que `AvisoMembresiaService`.

### 3.4.1 Cómo llega el modal — por el canal que ya existe

**No hace falta un `Event` nuevo.** `NotificationService::notificarMembresia()` ya publica por
Reverb en el canal privado de la membresía (`notifications.membresia.{id}`), y el front decide
qué hacer según `datos.evento`. Es exactamente el mecanismo con el que ya funciona
`reservacion.sincupo`:

```php
(new NotificationService())->notificarMembresia(
    $aviso['titulo'],
    $aviso['mensaje'],
    (int) $reservacion->idMembresia,
    (int) $reservacion->idIdentificador,
    [
        // El front abre MODAL con este evento (no un toast): la reservación del socio
        // acaba de desaparecer de su pantalla y eso exige un acuse explícito.
        'evento'        => 'reservacion.solicitud_ocupada',
        'motivo'        => $motivo,
        'idSolicitud'   => base64_encode((string) $reservacion->idSolicitud),
        'idReservacion' => base64_encode((string) $reservacion->idReservacion),
        // Ya interpolados en el mensaje; viajan aparte por si el front quiere pintarlos
        // como dato destacado dentro del modal.
        'reservacionNo' => $contexto['reservacionNo'] ?? null,
    ],
    (int) $reservacion->idReservacion
);
```

Los `SIN_SOLICITUD` usan el evento `reservacion.solicitud_pendiente`, y son los únicos dos
eventos nuevos que necesita el front.

Tres decisiones sobre este canal:

* **Se usa `notificarMembresia` y no un `ShouldBroadcastNow` a secas** —como
  `ReplicacionResuelta`— porque este mensaje **sí debe persistirse** en `wd_notificacion`. El
  socio va a querer releerlo: le acaban de cancelar una reservación y el folio que necesita
  para llamar al hotel está ahí. `ReplicacionResuelta` es una señal de sincronización que se
  puede perder sin consecuencia; ésta no.
* **El WebSocket es best-effort, así que no puede ser el único camino.** Si el socio tenía la
  pestaña dormida, se perdió el modal. Por eso la compuerta de entrada (§3.5) vuelve a contar
  la misma historia cuando reintente, y la notificación queda en su campana. Nunca depender de
  que un mensaje en vivo llegue.
* **Modal y no toast** para los `ocupada_*` y para `no_existe_en_origen`: son desenlaces
  definitivos que borran trabajo del socio. Un toast que se desvanece en 4 segundos deja a la
  persona mirando una pantalla vacía sin saber qué pasó.

Todo va en `try/catch` con `contarFalloDeAviso()`, como los otros tres avisos del controlador:
un canal de notificaciones caído no puede tumbar un veredicto ya asentado.

### 3.5 La compuerta de entrada — que el segundo intento cueste cero

Sin esto, el socio vuelve a elegir habitación, vuelve a esperar 6 s y vuelve a que se la
cancelen. Con esto, la pantalla ya abre diciendo qué pasa.

**Migración** sobre `inv_solicitud` — tres columnas que sirven a **los dos** bloqueos
(`SIN_SOLICITUD` y `SOLICITUD_OCUPADA`), por eso el nombre es genérico y no habla de
sincronización:

| Columna | Tipo | Para qué |
|---|---|---|
| `solicitudBloqueoMotivo` | `VARCHAR(40) NULL` | El slug del último veredicto. `NULL` = sin bloqueo. |
| `solicitudBloqueoFecha` | `DATETIME NULL` | Cuándo se registró. Para diagnóstico y para el `segundosRestantes`. |
| `solicitudBloqueoVence` | `DATETIME NULL` | Cuándo deja de aplicar. **`NULL` con motivo presente = no caduca** (`ocupada_firme`). |

> El tercer campo es lo que evita tener dos mecanismos. Sin él, `ocupada_firme` necesitaría su
> propia bandera y habría que acordarse de consultar las dos en cada punto de entrada — que es
> como se cuelan los bloqueos que no bloquean.

**Modelo `Solicitud`**:

```php
/** Ventana de los bloqueos que SÍ caducan (todos menos `ocupada_firme`). */
public const BLOQUEO_ESPERA_SEGUNDOS = 120;

/** $vence = null ⇒ el bloqueo no caduca (la solicitud ya se usó y eso no cambia solo). */
public static function marcarBloqueada(int $idSolicitud, string $motivo, ?Carbon $vence): void

/** Limpia el bloqueo: la reservación que ocupaba la solicitud se canceló en el privado. */
public static function liberarBloqueo(int $idSolicitud): void

/** @return array{motivo:string, segundosRestantes:?int}|null  null = se puede reservar */
public function bloqueoVigente(): ?array
```

`bloqueoVigente()` devuelve `segundosRestantes = null` para los bloqueos permanentes, que es
la señal para que el front muestre el modal **sin cuenta regresiva** — un contador que nunca
llega a cero es peor que ninguno.

> ⚠️ **Constante acoplada entre servidores.** Los 120 s de aquí deben ser **menores** que el
> enfriamiento de Reservaciones V2 (`SincronizarMembresiaSicamJob::ENFRIAMIENTO_SEGUNDOS`, 300 s). Esa
> relación es lo que hace coherente el ciclo: cuando el socio reintenta a los 2 minutos, la
> sincronización ya corrió, así que o la solicitud llegó (reserva bien) o Reservaciones V2 responde
> `no_existe_en_origen` con conocimiento de causa. Si el portal esperara **más** que el
> enfriamiento del privado, se dispararía una segunda sincronización idéntica e inútil. Si
> esperara mucho menos, el socio recibiría `no_existe_en_origen` antes de que la
> sincronización terminara — un "comunícate con el hotel" prematuro y falso.
>
> Documentar la relación en ambos lados; no hay forma automática de detectar que se rompió.

**La compuerta**, en `ReservacionService::reservacionTemporal()`, junto a las dos guardas que
ya existen para solicitudes (`firmeDeSolicitud`, `temporalDeSolicitud`):

```php
if ($datos['reservacionTipo'] === Reservacion::TIPO_INVITADO && $idSolicitud > 0) {
    // ... firmeDeSolicitud() / temporalDeSolicitud() como hoy ...

    // El privado ya dijo que esta solicitud no se puede usar —no está, o ya tiene
    // reservación—. Se corta ANTES de crear nada: sin esta guarda el socio vuelve a
    // elegir habitación para que se la cancelen 6 segundos después. El primer intento
    // cuesta un clic; del segundo en adelante, cero.
    $bloqueo = Solicitud::find($idSolicitud)?->bloqueoVigente();

    if ($bloqueo !== null) {
        throw new SolicitudBloqueadaException($bloqueo['motivo'], $bloqueo['segundosRestantes']);
    }
}
```

El controlador la traduce a **409** con el mismo `AvisoSolicitudService` (titulo, mensaje,
motivo, `segundosRestantes`), para que el front muestre la pantalla de espera con cuenta
regresiva en vez de un error genérico.

La marca **caduca sola**. No hace falta que Reservaciones V2 avise "ya quedó": al vencer los 120 s el
socio entra normal y, si la solicitud llegó, reserva sin fricción.

---

## 4. Ciclo de vida completo

**Caso A — la solicitud aún no llegó al privado (`SIN_SOLICITUD`)**

| Momento | Estado de la reservación | Qué ve el socio |
|---|---|---|
| `t=0` | Agrega su 1.ª habitación → `Temporal`, cupo apartado | Su reservación en curso |
| `t≈6 s` | Reservaciones V2 no encuentra la solicitud → `SIN_SOLICITUD` | — |
| `t≈7 s` | **Cancelada**; cupo liberado; solicitud bloqueada 120 s | Modal: "Estamos actualizando tu información…" |
| `t≈7 s … 2 min` | — | La pantalla bloquea el reintento con cuenta regresiva |
| `t≈30 s` | (En Reservaciones V2) el job trajo la solicitud desde Tlalpan | — |
| `t≈2 min` | El socio reintenta: el bloqueo caducó | Reserva normal; `idSolicitud` correcto de punta a punta |

Y si la solicitud **no** estaba en Tlalpan: el reintento a los 2 min recibe
`no_existe_en_origen` → "comunícate con el hotel". El bucle termina.

**Caso B — la solicitud ya tiene reservación (`SOLICITUD_OCUPADA`)**

| Momento | Estado de la reservación | Qué ve el socio |
|---|---|---|
| `t=0` | Agrega su 1.ª habitación → `Temporal`, cupo apartado | Su reservación en curso |
| `t≈6 s` | Reservaciones V2 encuentra la solicitud, **y una reservación viva sobre ella** | — |
| `t≈7 s` | **Cancelada**; cupo liberado; solicitud bloqueada **sin caducidad** | Modal: "Esta solicitud ya tiene una reservación (folio 12345)…" |
| después | — | Al reintentar, la compuerta bloquea de entrada, sin cuenta regresiva |
| si la cancelan en el club | La réplica inversa la trae `Cancelado` → `liberarBloqueo()` | Puede reservar de nuevo |

La diferencia entre los dos casos es la única que importa para el socio: **en A tiene que
esperar; en B tiene que llamar.** Por eso son veredictos distintos y mensajes distintos.

---

## 5. Lo que NO cambia (y por qué importa)

* **El TTL de 30 min y `reservaciones:expirar-temporales`**: intactos. Al cancelar en el acto
  no hay ninguna Temporal esperando, así que desaparece la tensión entre el TTL y la espera
  de la réplica. Éste fue el argumento de peso contra la versión anterior de la propuesta.
* **`cupoRatificado` y el bloqueo del botón de pago**: intactos. La reservación se cancela
  antes de llegar al pago.
* **`AvisoMembresiaService`**: se queda como está. El nuevo `AvisoSolicitudService` es un
  servicio aparte con su propia tabla de motivos. Fundirlos obligaría a que uno clasifique por
  texto y el otro por slug.
* **`SIN_CUPO`, `ERROR` y `OK`**: sin tocar.
* **`Reservacion::asociadaDeSolicitud()` y su uso en `ReservacionController:115`**: intactos.
  Sigue siendo la compuerta de entrada sobre los datos del portal. El veredicto
  `SOLICITUD_OCUPADA` **no la sustituye, la complementa**: aquélla ve las reservaciones de
  aquí, éste las del privado. Quitar la local porque "ya la valida el maestro" haría que el
  socio tuviera que elegir habitación y esperar 6 s para enterarse de algo que el portal ya
  sabía.

---

## 6. Hueco relacionado que conviene cerrar en el mismo release

`ReservacionService::confirmar()` valida bajo candado el estado, el importe y que haya
habitaciones — pero **no valida `cupoRatificado`**. La compuerta del pago vive **solo en el
front**: un front con datos viejos, una pestaña abierta o una carrera la cruzan.

Ya es cierto hoy con `SIN_CUPO` y con el error de membresía; `SIN_SOLICITUD` solo amplía la
ventana. Son tres líneas dentro del candado que ya existe:

```php
if (Replicacion::hayVeredictoPendiente($reservacion->idReservacion)) {
    return self::CONFIRMAR_SIN_RATIFICAR;   // 409: el maestro aún no acepta este estado
}
```

**No es requisito de esta propuesta**, pero es donde vive el único escenario en que un socio
podría acabar pagando una reservación que se va a cancelar.

---

## 7. Orden de despliegue

**Reservaciones V2 sale primero.** Su respaldo de compatibilidad distingue la clave `idSolicitudTlalpan`
**ausente** (portal viejo → resuelve como hoy) del valor **`null`** (portal nuevo → la
solicitud no salió a Tlalpan). Si el portal saliera primero, mandaría una llave que nadie lee.

1. Reservaciones V2 con el respaldo activo.
2. **CL032** (esta propuesta): migración → snapshot → veredicto → avisos → compuerta.
3. Limpieza en el siguiente release: retirar `idSolicitud` del payload y dejar solo
   `idSolicitudPortal` / `idSolicitudTlalpan`. La señal de que ya se puede es que el
   `Log::warning` de "portal sin llave natural" dejó de aparecer en Reservaciones V2.

---

## 8. Pruebas

### 8.1 `tests/Feature/Reservaciones/VeredictoSinSolicitudTest.php`

Estilo `MaestriaReplicacionTest`: SQLite con DDL a mano, `ReflectionMethod` para los privados
del controlador, `NotificationService` espiado.

| # | Caso | Aserción |
|---|---|---|
| 1 | `SIN_SOLICITUD` sobre una `Temporal` | Reservación → `Cancelado`; sus habitaciones y paquetes → `Cancelado`; el cupo local queda libre. |
| 2 | Caso 1 | La marca que trajo el veredicto queda `Procesado` **y** existe una marca **nueva** `Pendiente` con `OP_CANCELACION`. Es la prueba de que la cancelación sí va a viajar a Reservaciones V2. |
| 3 | `SIN_SOLICITUD` sobre una `Confirmando` | **NO** se cancela; el estado sigue `Confirmando`; se registra el `warning`. Con dinero en vuelo la decisión es humana. |
| 4 | Motivo `sincronizando` | La notificación lleva el título/mensaje de "estamos actualizando". |
| 5 | Motivo `no_existe_en_origen` | La notificación lleva el de "comunícate con el hotel". Distinta del caso 4. |
| 6 | Motivo `sin_transferir` | La notificación lleva el de "aún se está procesando". |
| 7 | Veredicto sin `motivo` (Reservaciones V2 antiguo) | Cae a `sincronizando`; no revienta. |
| 8 | `inv_solicitud` tras el veredicto | `solicitudBloqueoMotivo`, `…Fecha` y `…Vence` quedan escritos, con `Vence` a +120 s. |
| 9 | El canal de notificación lanza | El veredicto se procesa igual (best-effort) y `contarFalloDeAviso` cuenta el fallo. |
| 10 | `SIN_SOLICITUD` de una reservación inexistente | No lanza; la marca se cierra igual. |

### 8.2 `tests/Feature/Reservaciones/VeredictoSolicitudOcupadaTest.php`

| # | Caso | Aserción |
|---|---|---|
| 11 | `SOLICITUD_OCUPADA` con `ocupada_firme` sobre una `Temporal` | Reservación → `Cancelado`; la notificación lleva el título "Esta solicitud ya tiene una reservación" **y el folio del `contexto`** interpolado en el texto. |
| 12 | Caso 11 | `solicitudBloqueoVence` queda en **`NULL`** (bloqueo permanente) y `…Motivo` en `ocupada_firme`. |
| 13 | `ocupada_en_curso` | Bloqueo **con** vencimiento a +120 s; mensaje distinto del caso 11; **sin folio** en el texto. |
| 14 | `contexto.reservacionNo` ausente o `null` | El mensaje se arma **sin** la frase del folio; nunca imprime "folio 0" ni deja el hueco `{reservacionNo}`. |
| 15 | `SOLICITUD_OCUPADA` sobre una `Confirmando` | **NO** se cancela (dinero en vuelo); se registra el `warning`; el bloqueo sí se escribe. |
| 16 | Veredicto sin `motivo` | Cae a `ocupada_firme` — el desenlace conservador: bloquea de más antes que dejar duplicar una venta. |
| 17 | `liberarBloqueo()` tras ver la reservación `Cancelado` por réplica inversa | La marca se limpia y la solicitud vuelve a admitir reservación. |

### 8.3 `tests/Feature/Reservaciones/CompuertaSolicitudBloqueadaTest.php`

| # | Caso | Aserción |
|---|---|---|
| 18 | Reservar con la solicitud recién marcada (bloqueo temporal) | Se rechaza con 409; **no se crea** ninguna `rsv_reservacion`; la respuesta trae `motivo` y `segundosRestantes`. |
| 19 | Reservar pasados los 120 s | Se permite; `bloqueoVigente()` devuelve `null`. |
| 20 | Reservar con bloqueo **permanente** (`solicitudBloqueoVence = NULL`) | Se rechaza **siempre**, sin importar cuánto tiempo pase; `segundosRestantes` viaja como `null` para que el front no pinte una cuenta regresiva que nunca llega a cero. |
| 21 | Reservar tipo `Socio` con una solicitud bloqueada de por medio | **No** se bloquea: la compuerta es solo para `Invitado`. |
| 22 | Solicitud sin marca | No se bloquea (camino normal, sin regresión). |
| 23 | `BLOQUEO_ESPERA_SEGUNDOS` < enfriamiento de Reservaciones V2 | Prueba de **constante**, con el número del privado escrito explícito y un comentario que explique por qué (§3.5). Barata y evita un desajuste silencioso. |

### 8.4 `tests/Feature/Reservaciones/SnapshotSolicitudTest.php`

| # | Caso | Aserción |
|---|---|---|
| 24 | Snapshot de una `Invitado` con `idSolicitudTlalpan` poblado | La cabecera trae `idSolicitudTlalpan` con el valor de Tlalpan y `idSolicitudPortal` con la PK local. **Distintos entre sí**, para que la prueba detecte si alguien vuelve a mandar el mismo id en ambos. |
| 25 | Snapshot de una `Invitado` **sin** transferir | `idSolicitudTlalpan` presente con valor `null` — **la clave debe existir**. Es lo que impide que Reservaciones V2 active su respaldo (§3.1). |
| 26 | Snapshot de una `Socio` | Sin solicitud; `idSolicitudTlalpan` en `null`. |

### 8.5 `ProcesarVeredictoFormRequest`

| # | Caso | Aserción |
|---|---|---|
| 27 | `estado = SIN_SOLICITUD` con motivo válido | Pasa la validación y `validated()` **conserva** `motivo`. |
| 28 | `estado = SOLICITUD_OCUPADA` con `contexto` | `validated()` **conserva** `contexto.reservacionNo` y las fechas. Sin esta regla el folio llega y se tira en silencio, y el mensaje al socio queda sin el único dato accionable. |
| 29 | `motivo` fuera del catálogo | 422. |
| 30 | Los cinco estados del enum | `OK`, `ERROR`, `SIN_CUPO`, `SIN_SOLICITUD`, `SOLICITUD_OCUPADA` aceptados; cualquier otro, 422. |

### 8.6 Integración manual (obligatoria antes de liberar)

Los ids cross-servidor no se simulan con fixtures. Se corre con la contraparte de Reservaciones V2
(§9.3 de su propuesta) y se verifica **desde la pantalla del socio**:

1. La reservación se cancela sola y el aviso aparece con el texto correcto.
2. El cupo vuelve a estar disponible **en las dos bases**.
3. El reintento inmediato queda bloqueado con la cuenta regresiva.
4. Pasados un par de minutos, la reservación se completa y —comprobado en la base de
   Reservaciones V2— `rsv_reservacion.idSolicitud` apunta a la solicitud **del mismo socio**.

Y para el filtro de solicitud ocupada:

5. Con esa misma solicitud, **capturar y asentar la reservación en el mostrador**. Intentar
   reservarla desde el portal: debe aparecer el **modal** con el folio real, y la Temporal del
   socio debe quedar cancelada.
6. Reintentar de inmediato: la compuerta debe bloquear **sin** cuenta regresiva (es
   permanente).
7. **Cancelar** esa reservación en el mostrador y reintentar desde el portal: **debe
   permitirse**. Es la mitad de la regla de negocio que se rompe si alguien "simplifica" el
   filtro, y no hay forma de detectarlo salvo probándolo.
8. Reservar normal y **agregar una segunda habitación**: debe funcionar. Si la segunda réplica
   devuelve `SOLICITUD_OCUPADA`, falta la exclusión de la propia reservación en Reservaciones V2 (§4.3 de
   su propuesta).

---

## 9. Archivos a tocar (CL032)

| Archivo | Cambio |
|---|---|
| `app/Services/Reservaciones/SnapshotReservacionService.php` | `armar()` carga la solicitud; `cabecera()` emite `idSolicitudPortal` + `idSolicitudTlalpan` (§3.1). |
| `app/Http/Requests/Reservaciones/Replicacion/ProcesarVeredictoFormRequest.php` | `SIN_SOLICITUD` y `SOLICITUD_OCUPADA` en el enum + reglas de `motivo` y `contexto` (§3.2). |
| `app/Http/Controllers/Reservaciones/ReplicacionController.php` | Ramas `SIN_SOLICITUD` y `SOLICITUD_OCUPADA`, `resolverSinSolicitud()`, `resolverSolicitudOcupada()`, `avisarSolicitud()` (§3.3, §3.3bis). |
| `app/Services/Reservaciones/AvisoSolicitudService.php` | **Nuevo** — los cinco motivos y sus textos, con interpolación de folio y fechas (§3.4). |
| `app/Exceptions/Reservaciones/SolicitudBloqueadaException.php` | **Nuevo** (§3.5). |
| `database/migrations/2026_08_20_*_add_bloqueo_to_inv_solicitud.php` | **Nuevo** — las tres columnas (§3.5). |
| `app/Models/Modulos/Invitados/Solicitud.php` | `marcarBloqueada()`, `liberarBloqueo()`, `bloqueoVigente()`, `BLOQUEO_ESPERA_SEGUNDOS`, `$fillable` (§3.5). |
| `app/Services/Reservaciones/ReservacionService.php` | Compuerta en `reservacionTemporal()` (§3.5); *opcional*: guarda de `cupoRatificado` en `confirmar()` (§6). |
| `app/Http/Controllers/Reservaciones/ReservacionController.php` | Traducir la excepción a 409 con el aviso (§3.5). |
| `app/Services/Reservaciones/AplicarReplicaPrivadaService.php` | `liberarBloqueo()` al recibir esa reservación como `Cancelado` desde el privado (§3.3bis). |
| `tests/Feature/Reservaciones/VeredictoSinSolicitudTest.php` | **Nuevo** (§8.1, casos 1–10). |
| `tests/Feature/Reservaciones/VeredictoSolicitudOcupadaTest.php` | **Nuevo** (§8.2, casos 11–17). |
| `tests/Feature/Reservaciones/CompuertaSolicitudBloqueadaTest.php` | **Nuevo** (§8.3, casos 18–23). |
| `tests/Feature/Reservaciones/SnapshotSolicitudTest.php` | **Nuevo** (§8.4, casos 24–26). |

> **Front**: la pantalla de espera con cuenta regresiva (§3.5) y el aviso del socio (§3.4) son
> trabajo de CL032FRONT. El backend entrega `motivo`, `titulo`, `mensaje` y
> `segundosRestantes` ya listos para pintar — no hay lógica de negocio que replicar allá.
