# PROMPT DE IMPLEMENTACIÓN — CL032BACK (portal público)
## Maestría de la reservación + recepción de la réplica inversa

**Proyecto:** `D:\FOURSIDES\CL001ASTU\PRE-PRODUCCION\VILLASTUR\CL032RESERVACIONESENLINEA\CL032BACK`
**Propuesta de referencia:** `../PROPUESTA_REPLICACION_BIDIRECCIONAL_RESERVACIONES.md`
**Contraparte:** `../../../CL034RESERVACIONESV2/docs/PROMPT_IMPLEMENTACION_REPLICA_AL_PORTAL.md`
**Entorno:** LOCAL, sin producción. Se pueden agregar columnas libremente.

---

> ## ▶ AQUÍ SE EMPIEZA — pero NO ejecutes este prompt de corrido
>
> De los dos prompts, **este va primero**: el proyecto es local (riesgo cero) y su Fase 0 cumple
> sola el requisito duro de que el portal deje de replicar reservaciones ya entregadas. Además no
> depende de ningún cambio en Reservaciones V2: `idReservacionRemoto` ya viaja hoy en el veredicto
> (`ProcesarVeredictoFormRequest.php:26`, `nullable|integer`), que es todo lo que la Fase 0
> necesita para sellar la maestría.
>
> **Las fases se intercalan con las de Reservaciones V2.** Orden completo en §15.1 de la propuesta:
>
> | Paso | Proyecto | Fase |
> |---|---|---|
> | **1** | **← ESTE PROMPT** | **−1 y 0** |
> | 1b | Reservaciones V2 (en paralelo) | −1 |
> | 2 | Reservaciones V2 | 1 |
> | 3 | Reservaciones V2 → **este prompt** | 2 |
> | 4 | Reservaciones V2 | 3 |
> | **5** | **← ESTE PROMPT** | **4** |
> | 6 | Reservaciones V2 | 4 |
> | 7 | Reservaciones V2 | 5 |
> | 8 | CL032FRONT | 6 |
>
> **PARA al terminar la Fase 0** y espera a que Reservaciones V2 despliegue su Fase 1. Si Reservaciones V2 la despliega
> antes que esta Fase 0, su guarda rechazará la segunda vuelta de réplica **en cada venta** y
> disparará una incidencia `R10` —que va sin throttle— por cada una. Ver "Regla de orden #1".
>
> **PARA otra vez al terminar la Fase 4** (endpoint `/entrante`): tiene que existir **antes** de
> que Reservaciones V2 encienda su consumidor, o cada envío dará 404 y el replicador lo clasificará como
> permanente, descartando las marcas sin reintentar.

---

## Contexto para quien ejecute este prompt

Actúa como desarrollador senior. Este proyecto es el **portal público** de reservaciones en
línea. Hoy es el maestro de la reservación durante toda la captura y la replica a Reservaciones V2
(privado) por un outbox (`rsv_replicacion`) alimentado por dos capas: la aplicación
(`Replicacion::marcar()`) y 12 triggers de base de datos.

**El cambio:** cuando Reservaciones V2 acepta la venta y la reservación pasa a `Reservado`, la **maestría se
transfiere al privado**. A partir de ese instante el portal NO debe volver a proponer ningún
cambio hacia Reservaciones V2, y en cambio debe **recibir** los cambios que el privado haga.

**Por qué es estricto:** Reservaciones V2 aplica el snapshot con `ReservacionHabitacion::reemplazarDesdeSnapshot()`,
que BORRA extras, paquetes y habitaciones locales antes de reinsertar. Un snapshot tardío del
portal sobre una reservación ya editada en el privado **destruye datos**.

**Lee la propuesta completa antes de escribir código.** En particular §3 (por qué `Confirmando`
NO se corta), §4 (por qué maestría y no `reservacionEstado`), §6 (las cuatro capas) y §8 (la regla
anti-duplicados).

### Convenciones del proyecto que debes respetar

- Nombres de campo **idénticos a la columna** de la tabla. Solo los punteros entre sistemas
  llevan sufijo (`idReservacionPrivado`, `idReservacionHabitacionPrivado`).
- Toda mutación vive en métodos del modelo, no en el controlador.
- Migraciones Laravel **+ su equivalente en `database/sql/`** (el proyecto mantiene ambos; ver
  `rsv_reservacion_add_metodopago.sql` como plantilla).
- Respuestas JSON con el envoltorio `{success, code, message, data}`.
- Comentarios en español, explicando **por qué**, no qué. Sigue el tono de
  `SnapshotReservacionService.php` y `Replicacion.php`.
- No inventes campos que no existan en el esquema. Si necesitas uno nuevo, créalo por migración
  y documenta el motivo en el docblock de la migración.

---

# FASE −1 — Observabilidad (VA PRIMERO)

**Ningún error de este canal puede ser silencioso.** El modelo es
`CL001SICAMV2/app/Library/FSG/InvitadosPortal/MonitorInvitados.php`. **Léelo completo antes de
escribir esta fase**: no lo reinventes, cópiale la forma.

Va primero porque el resto de las fases entregan sus incidencias a través de esto, y porque
contiene un arreglo que **no depende de nada más de esta propuesta y hoy está costando dinero**
(§0.1).

## −1.1 El hueco que se tapa primero: `reservaciones:alertar-confirmando`

`app/Console/Commands/ReservacionesAlertarConfirmando.php` vigila las reservaciones atascadas en
`Confirmando` — es decir, **el socio pagó y el veredicto de Reservaciones V2 nunca llegó**. Su propio docblock
dice que existe para *"garantizar que nadie se entere por el socio"*.

Hoy solo hace `Log::warning`. Con un log en disco que nadie abre, se enteran por el socio.

Cámbialo para que emita la incidencia **`R1`** por `MonitorReservaciones`, con el contexto
completo (reservación, membresía, socio, importe, desde cuándo espera). **No cambies su lógica:**
sigue siendo solo-alerta, no cancela ni confirma nada, y ese comentario debe permanecer.

## −1.2 Tabla `rsv_bitacorareplicacion`

Migración + gemelo en `database/sql/`. Estructura exacta en §14bis.5 de la propuesta.

Docblock de la migración, con estos tres porqués:

```
 * bitacoraReplicacionAvisada distingue "no paso nada" de "paso y se silencio". Sin esa columna,
 * revisar la bitacora no permite saber si alguien fue notificado — y esa es justo la pregunta
 * despues de un reclamo del socio.
 *
 * SIN claves foraneas, y idReservacion nullable: la bitacora debe poder registrar incidencias de
 * reservaciones que ya se borraron o que nunca llegaron a existir. Una FK convertiria el registro
 * del error en un segundo error.
 *
 * El correo avisa; la bitacora permite reconstruir. Se escribe SIEMPRE, este el correo
 * throttleado o el monitoreo apagado. Es lo que separa "no nos avisaron" de "no quedo rastro".
```

## −1.3 `app/Library/FSG/MonitorReservaciones.php`

Estructura calcada de `MonitorInvitados`:

```php
/**
 * Alertas del canal de replicacion de reservaciones (portal <-> Reservaciones V2).
 *
 * Con crontab y colas en vez de un operador mirando la pantalla, el correo NO es un extra: es EL
 * mecanismo de deteccion. Una reservacion que no se replica es invisible hasta que el socio
 * reclama — y enterarse por el socio es el peor desenlace posible.
 *
 * Tres reglas que hacen que el correo se lea en vez de ignorarse:
 *
 *   1. UN SOLO CORREO POR EJECUCION, agregando todas las incidencias. Nunca uno por reservacion.
 *   2. THROTTLE POR FIRMA. Sin esto, un cron de un minuto con Reservaciones V2 caido produce 1,440 correos
 *      al dia y para el tercero nadie los abre.
 *   3. BITACORA SIEMPRE, este el correo throttleado o el monitoreo apagado.
 *
 * NO sustituye a MonitoreoReplicacion: esa clase sigue sirviendo a sus llamadores actuales y se
 * migra despues, sin prisa. Lo que aquella no hace —y por lo que existe esta— es agregar,
 * tipificar y PERSISTIR. Sin persistencia, una incidencia throttleada desaparece, que es
 * exactamente el fallo silencioso que hay que eliminar.
 */
class MonitorReservaciones
```

**Constantes del catálogo** (§14bis.2). Las que le tocan al portal: `R1`, `R3`, `R4`, `R6`, `R9`,
`R12`, `R13`, `R14`, `R15`. Declara **todas** las del catálogo de todos modos, con su título: los
dos proyectos comparten el vocabulario y un correo con `R10` debe ser legible aquí también.

Cada constante lleva docblock explicando **cuándo dispara y por qué importa**, como hace
`MonitorInvitados` con A7 y A8. En particular:

```php
/**
 * VENTA ESPERANDO VEREDICTO. Es la alerta mas importante del sistema.
 *
 * El socio ya pago y su reservacion no existe en ninguna parte como venta firme. Hasta hoy esto
 * solo escribia un Log::warning en `reservaciones:alertar-confirmando`, asi que el unico camino
 * real de deteccion era la llamada del socio.
 *
 * Por eso su ventana de throttle es de 10 min y no de 60: cuando hay dinero cobrado, esperar una
 * hora para el segundo aviso es inaceptable.
 */
public const R1 = 'R1';
```

**Métodos** (misma firma que `MonitorInvitados`, para que quien conozca uno conozca el otro):

| Método | Qué hace |
|---|---|
| `incidencia(string $tipo, string $mensaje, array $datos = [], ?string $firma = null): array` | Construye la incidencia. Firma por defecto anclada a `idMembresia` → `membresiaNo` → `idReservacion` → `global`. |
| `reportar(string $proceso, array $incidencias): void` | Bitácora **siempre** → log → throttle por firma → **un** correo agregado. |
| `reportarFallo(string $proceso, Throwable $e, array $contexto = []): void` | Atajo para `R3`. **Firma por clase de excepción y proceso, no por el mensaje**: un `Connection refused` con puerto distinto en cada intento no debe esquivar el throttle. |
| `reportarUna(string $proceso, array $incidencia): void` | Para caminos de evento (controlador, job), que no tienen ciclo. Ver −1.4. |
| `limpiarAviso(string $firma): void` | Suelta el throttle al normalizarse. **Esto `MonitorInvitados` no lo tiene** y `ReservacionesReconciliarCupo` sí: es lo que hace visible el fallo **intermitente**, el más caro de diagnosticar. Consérvalo. |
| `purgar(int $dias): int` | Borra bitácora anterior a N días. |

**Orden dentro de `reportar()`, calcado de `MonitorInvitados::reportar()`:**

```
1. Bitacora — SIEMPRE, throttle o no, correo habilitado o no.
2. Log::warning con el conteo por tipo, para el diagnostico del dia a dia.
3. Si el monitoreo esta deshabilitado -> return (la bitacora ya guardo todo).
4. Throttle por firma: solo sobreviven las incidencias no avisadas en la ventana.
5. Un correo con las frescas, indicando cuantas se omitieron y donde consultarlas.
```

**Throttle por código** (§14bis.3) — esto sí es distinto de `MonitorInvitados`:

```php
// config/replicacion.php
'monitoreo' => [
    'habilitado' => filter_var(env('REPLICACION_MONITOREO_HABILITADO', false), FILTER_VALIDATE_BOOL),
    'correos'    => /* coma-separados */,
    'throttle_minutos' => [
        'defecto' => 60,
        'R1'      => 10,   // dinero cobrado sin reservacion: una hora es inaceptable
        'R10'     => 0,    // nunca se silencia: cada intento de pisar al maestro debe verse
        'R7'      => 0,    // dispara en la TRANSICION, que ya es un evento unico
    ],
    'bitacora_dias' => (int) env('REPLICACION_BITACORA_DIAS', 180),
],
```

> `0` = sin throttle. **Es seguro solo donde se usa.** `R7` dispara en la transición a
> `Descartado` (evento único por marca) y `R10` no debe ocurrir jamás en régimen normal. Ponerle
> `0` a `R6` sería un error: ese sí se repite cada ciclo.

**Correo accionable.** Copia `construirHtml()` con su tabla de etiquetas legibles. Etiquetas de
este canal:

```php
'idReservacion' => 'Reservación (portal)',   'idReservacionPrivado' => 'Reservación (privado)',
'reservacionNo' => 'Folio',                  'membresiaNo' => 'Membresía No.',
'socio' => 'Socio',                          'reservacionEstado' => 'Estado',
'reservacionMaestro' => 'Maestro',           'reservacionFechaLlegada' => 'Fecha de llegada',
'reservacionImporte' => 'Importe',           'replicaVersion' => 'Versión de réplica',
'intentos' => 'Intentos',                    'ultimoError' => 'Último error',
'motivo' => 'Motivo devuelto',               'endpoint' => 'Endpoint',
'excepcion' => 'Excepción',                  'esperandoMinutos' => 'Minutos esperando veredicto',
```

Un correo que solo diga *"falló la réplica"* no pasa la revisión. El pie debe decir cuántas se
omitieron por throttle **y dónde consultarlas** (`rsv_bitacorareplicacion`).

**Aviso del throttle en caché.** `MonitorInvitados` lo documenta y aquí aplica igual: el throttle
vive en `Cache`. Si el driver es `file` y `storage/framework/cache` no es escribible, **el throttle
deja de funcionar en silencio**. Escríbelo en el docblock de la clase. La mitigación ya está por
diseño: **la bitácora no depende de la caché.**

## −1.4 Caminos de evento

Los controladores y los jobs no tienen ciclo, así que "un correo por ejecución" no aplica. Usan
`reportarUna()`: bitácora inmediata + throttle por firma. **La agregación aquí la da el propio
throttle**: diez veredictos fallidos de la misma causa comparten firma y producen un correo.

Escríbelo como comentario en `reportarUna()`, o alguien "arreglará" la asimetría más adelante.

## −1.5 Los caminos que dejan de ser mudos

| Archivo | Hoy | Debe |
|---|---|---|
| `ReservacionesAlertarConfirmando` | `Log::warning` | **`R1`** (−1.1) |
| `ReplicacionController::pendientes()` catch | `registrar_error` + 500 | `+ R3` |
| `ReplicacionController::veredicto()` catch | `registrar_error` + 500 | `+ R3` |
| `ReservacionesReconciliarCupo` | `MonitoreoReplicacion` directo | `R6`/`R15` por `MonitorReservaciones`, **conservando su `limpiarAviso()`** al normalizarse |

**No toques** `avisarSinCupo`, `avisarMembresia` ni `avisarReplicacionResuelta`: individualmente
son best-effort y así deben seguir — un canal de avisos caído no puede tumbar un veredicto. Lo que
falta es detectar el fallo **sostenido**: lleva un contador en caché y, al superar N fallos en una
ventana, emite **`R14`** una vez. El fallo aislado sigue siendo solo `registrar_error`.

---

# FASE 0 — Corte público → privado (prioridad máxima)

Esta fase **por sí sola** cumple el requisito duro: el portal deja de replicar reservaciones ya
entregadas al privado. Es independiente del canal inverso y debe entregarse primero.

## 0.1 Migración: columnas de maestría

Crea `database/migrations/2026_08_18_120000_add_maestria_to_rsv_reservacion.php` y su gemelo
`database/sql/rsv_reservacion_add_maestria.sql`.

```sql
ALTER TABLE `rsv_reservacion`
  ADD COLUMN `reservacionMaestro` enum('Portal','Privado')
      CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'Portal'
      COMMENT 'Quien manda sobre esta reservacion. Se sella a Privado al asentarse la venta y no retrocede.'
      AFTER `reservacionEstado`,
  ADD COLUMN `reservacionFechaMaestro` datetime NULL DEFAULT NULL
      COMMENT 'Instante exacto de la transferencia de maestria al privado. Auditoria de reclamos.'
      AFTER `reservacionMaestro`,
  ADD COLUMN `idReservacionPrivado` int NULL DEFAULT NULL
      COMMENT 'PK de rsv_reservacion en Reservaciones V2. Puntero de la reservacion en el maestro.'
      AFTER `reservacionFechaMaestro`,
  ADD COLUMN `reservacionNoPrivado` int NULL DEFAULT NULL
      COMMENT 'Folio que emitio el maestro. Ver decision D1 de la propuesta.'
      AFTER `idReservacionPrivado`,
  ADD UNIQUE INDEX `uq_reservacion_privado` (`idReservacionPrivado`),
  ADD INDEX `idx_reservacion_maestro` (`reservacionMaestro`, `idReservacion`);
```

El `down()` revierte con `DROP INDEX` + `DROP COLUMN` en orden inverso.

> **Docblock obligatorio de la migración:** explica que `reservacionEstado` no sirve como eje de
> maestría porque un `Cancelado` a veces debe replicarse (desde `Temporal`) y a veces no (desde
> `Reservado`), y porque un estado puede retroceder mientras que la maestría no. Referencia §4 de
> la propuesta.

## 0.2 Modelo `Reservacion` (`app/Models/Modulos/Reservacion/Reservacion.php`)

Agrega, junto a las constantes de estado existentes:

```php
/**
 * Quien manda sobre la reservacion. NO es un estado de negocio: es gobierno del dato.
 *
 * Se separa deliberadamente de `reservacionEstado` porque la regla de replica no es
 * expresable por estado. Un `Cancelado` que viene de `Temporal` SI debe replicarse (libera
 * cupo en el maestro); uno que viene de `Reservado` no puede ni originarse aqui. Y un estado
 * puede retroceder por una correccion manual, mientras que la maestria, una vez entregada, no.
 */
public const MAESTRO_PORTAL  = 'Portal';
public const MAESTRO_PRIVADO = 'Privado';
```

Añade los cuatro campos nuevos a `$fillable` y a `$casts` (`reservacionFechaMaestro` →
`datetime`).

Métodos nuevos:

```php
/**
 * ¿El portal sigue siendo dueño de esta reservacion?
 *
 * Vive aqui y no repartido entre quienes lo consultan (Replicacion::marcar, el controlador de
 * replicacion, el front) porque dos copias de esta expresion son dos verdades posibles sobre
 * quien puede escribir. Es la misma razon por la que `cupoRatificado` vive en Replicacion.
 */
public function esMaestroPortal(): bool
{
    return $this->reservacionMaestro === self::MAESTRO_PORTAL;
}

/** Version por id, para los caminos que no tienen el modelo cargado (outbox, triggers de app). */
public static function maestroEsPortal(int $idReservacion): bool
{
    if ($idReservacion <= 0) {
        return false;   // sin reservacion no hay nada que replicar
    }

    return self::where('idReservacion', $idReservacion)
        ->where('reservacionMaestro', self::MAESTRO_PORTAL)
        ->exists();
}

/**
 * LA FRONTERA. Entrega la reservacion al privado. Se invoca UNA sola vez, dentro de la
 * transaccion de `finalizarConfirmacion`, y no tiene inversa en el flujo normal: recuperar la
 * maestria es un acto administrativo explicito, nunca un efecto secundario.
 */
public function sellarMaestriaPrivada(?int $idReservacionPrivado, ?int $reservacionNoPrivado): void
{
    $this->reservacionMaestro      = self::MAESTRO_PRIVADO;
    $this->reservacionFechaMaestro = Carbon::now();

    if ($idReservacionPrivado !== null && $idReservacionPrivado > 0) {
        $this->idReservacionPrivado = $idReservacionPrivado;
    }
    if ($reservacionNoPrivado !== null && $reservacionNoPrivado > 0) {
        $this->reservacionNoPrivado = $reservacionNoPrivado;
    }

    $this->save();
}
```

## 0.3 Capa 2 — `Replicacion::marcar()` (`app/Models/Modulos/Reservacion/Replicacion.php`)

Antepone la guarda. **No cambies nada más del método.**

```php
public static function marcar(int $idReservacion, string $operacion): void
{
    // FRONTERA DE MAESTRIA. Una vez que la venta se asento y el privado tomo el control, el
    // portal no vuelve a proponer cambios. No es cosmetico: Reservaciones V2 aplica el snapshot con
    // `reemplazarDesdeSnapshot`, que BORRA extras, paquetes y habitaciones antes de reinsertar.
    // Una marca de mas aqui es el extra que el gerente acaba de capturar, borrado.
    if (! Reservacion::maestroEsPortal($idReservacion)) {
        return;
    }

    // ... el resto del metodo, sin cambios ...
}
```

## 0.4 Capa 3 — `ReplicacionController::recolectar()`

**Ésta es la garantía definitiva**: es lo último antes del cable.

```php
foreach ($marcas as $marca) {
    $reservacion = Reservacion::find((int) $marca->idReservacion);

    if ($reservacion === null) {
        Replicacion::marcarProcesado((int) $marca->idReplicacion);   // ya no existe
        continue;
    }

    // LA CARRERA REAL, y por eso esta guarda no es opcional: puede existir una marca creada
    // ANTES del veredicto y drenada DESPUES del sello. La capa de aplicacion no la ve porque
    // ya estaba escrita, y el trigger tampoco porque ya no se dispara. Sin este corte, ese
    // snapshot viejo borra el detalle que el privado acaba de editar.
    if (! $reservacion->esMaestroPortal()) {
        Replicacion::marcarProcesado((int) $marca->idReplicacion);
        Log::channel('sicam')->info('replicacion.salida | marca descartada por maestria privada', [
            'idReplicacion' => (int) $marca->idReplicacion,
            'idReservacion' => (int) $marca->idReservacion,
        ]);
        continue;
    }

    $snapshot = $servicio->armar((int) $marca->idReservacion);
    // ... resto igual ...
}
```

> Reusa el `$reservacion` ya cargado: hoy `SnapshotReservacionService::armar()` vuelve a
> buscarlo. No hace falta refactorizar eso ahora, pero no dupliques la consulta dentro del
> mismo bucle si puedes evitarlo.

## 0.5 `ReservacionService::finalizarConfirmacion()` — el sello

Éste es el cambio de comportamiento central. Estado actual (`ReservacionService.php:~735-752`):

```php
DB::transaction(function () use ($reservacion) {
    $reservacion->marcarReservado();
    Replicacion::marcar($reservacion->idReservacion, Replicacion::OP_CONFIRMACION);
});
```

Reemplázalo por:

```php
/**
 * Veredicto OK de Reservaciones V2: Confirmando -> Reservado, folio, y TRANSFERENCIA DE MAESTRIA.
 *
 * Idempotente: si la reservacion ya no esta en Confirmando (veredicto duplicado, reintento del
 * worker) no hace nada.
 *
 * SE RETIRO LA SEGUNDA VUELTA DE REPLICA. El comentario anterior decia que servia para llevar
 * el folio real a Reservaciones V2, pero `Reservacion::replicarSnapshot()` del maestro NUNCA lee
 * `reservacionNo` del snapshot: genera el suyo con `max(reservacionNo) + 1`. Esa segunda vuelta
 * reescribia exactamente el mismo estado, y a partir de aqui seria ademas destructiva, porque
 * el privado ya puede haber editado el detalle. Ver PROPUESTA §2 (H2).
 */
public function finalizarConfirmacion(
    Reservacion $reservacion,
    ?int $idReservacionPrivado = null,
    ?int $reservacionNoPrivado = null,
    array $mapeo = []
): bool {
    if ($reservacion->reservacionEstado !== Reservacion::ESTADO_CONFIRMANDO) {
        return false;
    }

    DB::transaction(function () use ($reservacion, $idReservacionPrivado, $reservacionNoPrivado, $mapeo) {
        $reservacion->marcarReservado();

        // TODO en el MISMO commit que el cambio de estado. Si el sello quedara fuera, existiria
        // una ventana —por corta que sea— en la que la reservacion ya es Reservado y el portal
        // todavia se cree su dueno. En esa ventana un trigger marcaria y el snapshot saldria.
        $reservacion->sellarMaestriaPrivada($idReservacionPrivado, $reservacionNoPrivado);

        // Punteros del detalle (Fase 2). Sin ellos la primera replica inversa cae al respaldo.
        if ($mapeo !== []) {
            (new MapeoReplicaService())->aplicar($reservacion, $mapeo);
        }
    });

    return true;
}
```

Actualiza la llamada en `ReplicacionController::resolverAceptada()` para pasar
`idReservacionRemoto`, `reservacionNoRemoto` y `mapeo` del veredicto.

## 0.6 `ProcesarVeredictoFormRequest` — campos aditivos

Agrega, **todos opcionales** para que un Reservaciones V2 que aún no los envíe siga funcionando igual:

```php
'reservacionNoRemoto'                 => 'nullable|integer|min:1',
'mapeo'                               => 'nullable|array',
'mapeo.habitaciones'                  => 'nullable|array',
'mapeo.habitaciones.*.portal'         => 'required_with:mapeo.habitaciones|integer|min:1',
'mapeo.habitaciones.*.privado'        => 'required_with:mapeo.habitaciones|integer|min:1',
'mapeo.habitaciones.*.paquetes'       => 'nullable|array',
'mapeo.habitaciones.*.paquetes.*.portal'  => 'required_with:mapeo.habitaciones.*.paquetes|integer|min:1',
'mapeo.habitaciones.*.paquetes.*.privado' => 'required_with:mapeo.habitaciones.*.paquetes|integer|min:1',
'mapeo.habitaciones.*.extras'         => 'nullable|array',
'mapeo.habitaciones.*.extras.*.portal'    => 'required_with:mapeo.habitaciones.*.extras|integer|min:1',
'mapeo.habitaciones.*.extras.*.privado'   => 'required_with:mapeo.habitaciones.*.extras|integer|min:1',
```

`idReservacionRemoto` ya se valida hoy; verifícalo.

## 0.7 Capa 1 — reinstalar `sp_rsv_marcar_replicacion`

Crea `database/manual/2026_08_18_triggers_rsv_replicacion_maestria.sql`. **Es una revisión del
script existente, no un script paralelo**: parte de
`database/manual/2026_07_24_triggers_rsv_replicacion.sql`, conserva íntegros los 12 triggers y
cambia solo el procedimiento.

```sql
DROP PROCEDURE IF EXISTS `sp_rsv_marcar_replicacion`;

DELIMITER $$
CREATE PROCEDURE `sp_rsv_marcar_replicacion`(
    IN p_idReservacion INT,
    IN p_operacion     VARCHAR(20)
)
BEGIN
    DECLARE v_maestro VARCHAR(10) DEFAULT NULL;

    IF p_idReservacion IS NOT NULL THEN

        -- FRONTERA DE MAESTRIA (una sola condicion, un solo lugar).
        --
        -- Los 12 triggers NO cambian: la regla vive aqui porque los triggers nieto
        -- (paquete/extra) tendrian que saltar dos tablas solo para leer un estado, y serian
        -- 12 sitios donde la regla puede divergir.
        --
        -- NULL (la reservacion ya no existe) tampoco marca. Es correcto y mas seguro que
        -- antes: el AFTER DELETE de la cabecera dejaba una marca que
        -- ReplicacionController::recolectar() cerraba igualmente sin replicar nada.
        SELECT `reservacionMaestro` INTO v_maestro
          FROM `rsv_reservacion`
         WHERE `idReservacion` = p_idReservacion
         LIMIT 1;

        IF v_maestro = 'Portal' THEN

            IF EXISTS (
                SELECT 1 FROM `rsv_replicacion`
                 WHERE `idReservacion` = p_idReservacion
                   AND `replicacionEstado` IN ('Pendiente', 'Error')
            ) THEN
                UPDATE `rsv_replicacion`
                   SET `replicacionOperacion` = p_operacion,
                       `replicacionEstado`    = 'Pendiente',
                       `replicacionFecha`     = NOW(),
                       `replicacionError`     = NULL
                 WHERE `idReservacion` = p_idReservacion
                   AND `replicacionEstado` IN ('Pendiente', 'Error');
            ELSE
                INSERT INTO `rsv_replicacion`
                    (`idReservacion`, `replicacionOperacion`, `replicacionEstado`,
                     `replicacionIntentos`, `replicacionFecha`)
                VALUES
                    (p_idReservacion, p_operacion, 'Pendiente', 0, NOW());
            END IF;

        END IF;
    END IF;
END$$
DELIMITER ;
```

Incluye en el archivo, con el mismo estilo del script original:

- **Bitácora / propósito** al inicio, explicando el cambio y su motivo.
- **Requisito previo:** la migración de maestría ya aplicada. Si `reservacionMaestro` no existe,
  el procedimiento fallará en tiempo de ejecución y **abortará las escrituras de negocio**. Ponlo
  en mayúsculas.
- **Verificación** post-instalación:
  ```sql
  -- El procedimiento existe y los 12 triggers siguen ahi
  SHOW PROCEDURE STATUS WHERE Db = DATABASE() AND Name = 'sp_rsv_marcar_replicacion';
  SELECT COUNT(*) FROM information_schema.TRIGGERS
   WHERE TRIGGER_SCHEMA = DATABASE() AND EVENT_OBJECT_TABLE LIKE 'rsv_reservacion%';  -- = 12

  -- Prueba funcional: una Reservado NO debe generar marca
  SET @idr := (SELECT idReservacion FROM rsv_reservacion
                WHERE reservacionMaestro = 'Privado' ORDER BY idReservacion DESC LIMIT 1);
  SELECT COUNT(*) INTO @antes FROM rsv_replicacion WHERE idReservacion = @idr;
  UPDATE rsv_reservacion SET reservacionNotas = reservacionNotas WHERE idReservacion = @idr;
  SELECT COUNT(*) = @antes AS debe_ser_1 FROM rsv_replicacion WHERE idReservacion = @idr;
  ```
- **Reversa** comentada al final.

---

# FASE 2 — Punteros de origen (`MapeoReplicaService`)

## 2.1 Migración de punteros del detalle

`database/migrations/2026_08_18_130000_add_punteros_privado_to_rsv_detalle.php` + gemelo SQL.

```sql
ALTER TABLE `rsv_reservacionhabitacion`
  ADD COLUMN `idReservacionHabitacionPrivado` int NULL DEFAULT NULL
      COMMENT 'PK de rsv_reservacionhabitacion en Reservaciones V2. NULL = aun no adoptada.'
      AFTER `idReservacion`,
  ADD UNIQUE INDEX `uq_rh_privado` (`idReservacion`, `idReservacionHabitacionPrivado`);

ALTER TABLE `rsv_reservacionhabitacionpaquete`
  ADD COLUMN `idReservacionHabitacionPaquetePrivado` int NULL DEFAULT NULL
      COMMENT 'PK de rsv_reservacionhabitacionpaquete en Reservaciones V2.'
      AFTER `idReservacionHabitacion`,
  ADD UNIQUE INDEX `uq_rhp_privado` (`idReservacionHabitacion`, `idReservacionHabitacionPaquetePrivado`);

ALTER TABLE `rsv_reservacionhabitacionextra`
  ADD COLUMN `idReservacionHabitacionExtraPrivado` int NULL DEFAULT NULL
      COMMENT 'PK de rsv_reservacionhabitacionextra en Reservaciones V2.'
      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/habitación; un índice global convertiría un payload mal formado en un 500 en vez
> de un rechazo controlado y bitacorado. Mismo criterio que
> `2026_08_17_120000_add_unique_replica_to_inv_invitado.php`.

## 2.2 `app/Services/Reservaciones/MapeoReplicaService.php`

```php
/**
 * Fija los punteros de origen que Reservaciones V2 devuelve en el veredicto OK.
 *
 * Existe para que la PRIMERA replica inversa pueda ADOPTAR las filas que el socio ya tiene en
 * lugar de insertar unas nuevas al lado. Sin adopcion, una reservacion de 3 habitaciones pasaria
 * a mostrar 6 la primera vez que el privado la edite — que es exactamente el reclamo que este
 * canal existe para evitar.
 *
 * TOLERANTE POR DISENO: un mapeo incompleto o ausente NO es un error. El portal se queda sin
 * punteros y la primera replica inversa cae al camino de respaldo (reemplazo completo del
 * detalle, §8.3 de la propuesta), que tambien es correcto. Reventar aqui tumbaria un veredicto
 * ya asentado por un dato de conveniencia.
 */
public function aplicar(Reservacion $reservacion, array $mapeo): void
```

Comportamiento:
- Recorre `mapeo.habitaciones`. Por cada entrada, ubica la habitación local **por su PK**
  (`portal`) y **verifica que pertenezca a esta reservación** antes de escribir. Si no pertenece,
  registra `Log::warning` y sigue: nunca escribas un puntero en la reservación de otro socio.
- Idem para paquetes y extras dentro de cada habitación.
- Todo en un solo `UPDATE` por tabla cuando sea posible (`whereIn` + `CASE`), o filas sueltas si
  resulta más legible: el volumen es de unidades, no de miles.

---

# FASE 4 — Recepción de la réplica inversa

## 4.1 Firma HMAC del cuerpo

`app/Library/FSG/HmacSigner.php` — añade un método **nuevo**, sin tocar los existentes:

```php
/**
 * Verificacion de firma para endpoints que RECIBEN datos (no solo punteros).
 *
 * El payload firmado es PATH + "\n" + sha256(cuerpo crudo). Los dos endpoints actuales del canal
 * de escritura firman SOLO el path (convencion congelada, CONTRATO §2) y ahi es aceptable:
 * /pendientes no lleva cuerpo y /veredicto lleva punteros. Este endpoint trae los datos que van
 * a SOBRESCRIBIR la reservacion del socio, y para eso firmar el path no basta.
 *
 * Se agrega como metodo APARTE, no se modifica `verificar()`: cambiar la firma existente
 * rompería el canal en produccion sin ganar nada.
 */
public static function verificarConCuerpo(
    string $path, string $cuerpo, string $firma, string $timestamp,
    string $nonce, string $secret, int $ventana
): array
```

`app/Http/Middleware/SeguridadEscritura.php` — parametriza el middleware:

```php
// Ruta:  ->middleware('seguridadEscritura:cuerpo')
public function handle(Request $request, Closure $next, string $modo = 'path'): Response
```

Con `$modo === 'cuerpo'` usa `verificarConCuerpo($path, $request->getContent(), …)`. Las capas
1, 2, 3 y 5 (allowlist, secreto, headers, nonce) no cambian.

## 4.2 Ruta

En `routes/modulos/reservaciones/replicacion.php`, dentro del grupo existente:

```php
// /reservaciones/replicacion/entrante — replica PRIVADO -> PUBLICO (Reservaciones V2 inicia, R1 se respeta)
Route::post('/entrante', [ReplicacionEntranteController::class, 'entrante'])
    ->name('entrante')
    ->middleware('seguridadEscritura:cuerpo');

// /reservaciones/replicacion/estado — lectura para el reconciliador de Reservaciones V2
Route::get('/estado', [ReplicacionEntranteController::class, 'estado'])->name('estado');
```

> El grupo ya aplica `seguridadEscritura` y `throttle`. El `:cuerpo` de la ruta **sustituye** al
> del grupo para ese endpoint; verifica en pruebas que no se apliquen ambos.

## 4.3 `ReplicaEntranteFormRequest`

Sigue el criterio de estrictez de `Invitados/Transferencia/ReplicaFormRequest.php` — no es
uniforme, y es deliberado:

- **ESTRICTO** en identidad y veredicto: `idReservacionPrivado`, `replicaVersion`,
  `reservacionHashReplica`, `membresiaNo`, `reservacionEstado`, fechas, punteros del detalle,
  `idTipoHabitacion`/`idPaquete`/`idExtra`. Un error aquí escribe la reservación de un socio
  sobre la de otro.
- **TOLERANTE** en lo descriptivo: notas, `beneficiarioNombreCompleto`, `reservacionRecibo`.
  Rechazar un snapshot entero porque falta un nombre deja al socio sin ver su reservación por un
  dato que no cambia ninguna decisión.

```php
'idReservacionPrivado'   => 'required|integer|min:1',
'replicaVersion'         => 'required|integer|min:1',
'reservacionHashReplica' => 'required|string|size:64',

'reservacion.reservacionEstado' => ['required', Rule::in([
    Reservacion::ESTADO_RESERVADO, Reservacion::ESTADO_FINALIZADO, Reservacion::ESTADO_CANCELADO,
])],
'reservacion.membresiaNo'        => 'required|string|max:15',
'reservacion.reservacionNo'      => 'required|integer|min:0',
'reservacion.reservacionTipo'    => ['required', Rule::in([Reservacion::TIPO_SOCIO, Reservacion::TIPO_INVITADO])],
'reservacion.reservacionFechaLlegada' => 'required|date',
'reservacion.reservacionFechaSalida'  => 'required|date|after_or_equal:reservacion.reservacionFechaLlegada',
'reservacion.reservacionImporte' => 'required|numeric|min:0',
'reservacion.reservacionNoHabitaciones' => 'required|integer|min:0',
'reservacion.reservacionNotas'   => 'nullable|string',
'reservacion.beneficiarioNombreCompleto' => 'nullable|string|max:250',

'habitaciones' => 'present|array',   // vacio es legitimo: reservacion sin habitaciones vigentes
'habitaciones.*.idReservacionHabitacionPrivado' => 'required|integer|min:1',
'habitaciones.*.idTipoHabitacion'               => 'required|integer|min:1',
'habitaciones.*.reservacionHabitacionEstado'    => ['required', Rule::in(['Activo','Temporal','Cancelado'])],
// ... resto del detalle, paquetes y extras
```

`failedValidation()` devuelve el cuerpo `{success:false, code:422, motivo:'contrato', errores}`,
**igual que el canal de invitados**: Reservaciones V2 ramifica por `motivo`.

## 4.4 `ReplicacionEntranteController::entrante()`

Orden de operaciones. **Cada corte va antes de cualquier escritura, y en este orden**:

1. **Resolver la membresía** por `membresiaNo` en `wd_membresia`. Sin destino no hay nada que
   escribir → `422 membresia_no_localizada`.
2. **Localizar la reservación local** por `idReservacionPrivado`. Si no existe, buscar por el
   inverso: una reservación de esa membresía con `reservacionMaestro='Privado'` y ese
   `idReservacionPrivado`. Si tampoco, es **alta** (caso alcance `todas`).
3. **Corte por maestría.** Si la reservación local existe y sigue en `reservacionMaestro='Portal'`
   → `409 maestria_portal`. Es una carrera con el veredicto; Reservaciones V2 debe reintentar, no descartar.
4. **Corte por versión.** Si `replicaVersion <= reservacionVersionReplica` → `409 version_obsoleta`.
   **Va antes que el hash**: una versión obsoleta con el mismo hash es igualmente una respuesta
   incorrecta que cerrar como "aplicada".
5. **Corte por hash.** Si `reservacionHashReplica` coincide con el almacenado →
   `200 {aplicada:false, motivo:'sin_cambios'}`, sin tocar absolutamente nada. Corta los
   reintentos sin reescribir filas ni disparar notificaciones.
6. **Validar catálogos.** Todos los `idTipoHabitacion`, `idPaquete`, `idExtra` del snapshot deben
   existir en los espejos locales (`rsv_tipohabitacion`, `rsv_paquete`, `rsv_extras`). Si falta
   alguno → `422 catalogo_desconocido` con la lista de ids. **No insertes un id huérfano**: el
   socio vería una habitación "ID: 47" en vez de un nombre. Es además el aviso de que el pipeline
   de catálogos se quedó atrás.
7. **Transacción:** cabecera → habitaciones → paquetes/extras → sello (`hash`, `version`,
   `fechaReplica`). Si algo revienta a media reconciliación, la reservación NO queda con la mitad
   del detalle: el snapshot vuelve completo en el ciclo siguiente.
8. **Fuera de la transacción:** notificar al socio si el cambio lo amerita. Un canal de avisos
   caído no puede tumbar una réplica ya asentada.

## 4.5 `AplicarReplicaPrivadaService` — el corazón anti-duplicados

**Lee §8 de la propuesta antes de escribir este archivo.**

```php
/**
 * Aplica un snapshot del maestro (Reservaciones V2) sobre la reservacion local.
 *
 * NO usa borrar-y-reinsertar, que es como lo hace el camino inverso
 * (Reservaciones V2: ReservacionHabitacion::reemplazarDesdeSnapshot). Alla es correcto porque el destino es
 * esclavo puro y sus ids no le importan a nadie. Aqui no sirve por tres razones:
 *
 *   1. Los ids del portal son visibles: el front, los PDFs y las notificaciones referencian
 *      idReservacionHabitacion. Regenerarlos en cada replica los deja huerfanos.
 *   2. No es idempotente ante fallo parcial: un timeout entre el DELETE y el INSERT, o dos
 *      replicas cruzadas, y aparece el duplicado.
 *   3. Cambiar un extra no debe costar N deletes + N inserts de toda la reservacion.
 *
 * En su lugar: UPSERT POR PUNTERO DE ORIGEN, con reconciliacion de tres vias.
 */
```

Regla, por nivel:

```
para cada fila ENTRANTE:
    ubicar la local por (padre, idPrivado)
        existe → UPDATE
        no existe → INSERT fijando el puntero

para cada fila LOCAL cuyo puntero NO viene en el snapshot:
    habitacion → reservacionHabitacionEstado = 'Cancelado'   (conserva historia e ids)
    paquete    → DELETE
    extra      → DELETE
```

**Regla dura, escríbela como comentario en el código:**

```php
// El emparejamiento es SIEMPRE por puntero, NUNCA por (tipo, fechas, importe). Dos habitaciones
// identicas del mismo tipo y las mismas fechas son legitimas, y ambas casarian con la misma fila:
// ese es el camino exacto a duplicarle al socio su reservacion.
```

**Camino de adopción (primera réplica sin punteros):**

```php
// Si la reservacion ya es Privado pero NINGUNA de sus habitaciones tiene puntero, el mapeo del
// veredicto no llego (veredicto perdido, Reservaciones V2 antiguo). Como la maestria YA es del privado, no
// hay nada local que preservar: se reemplaza el detalle completo desde el snapshot fijando los
// punteros de una vez, y se levanta incidencia.
//
// NO se intenta adivinar el emparejamiento por posicion ni por atributos. Adivinar es
// precisamente como se duplica. Ocurre a lo sumo una vez por reservacion.
```

**Importes — regla innegociable:**

```php
// El portal TRANSCRIBE los importes del maestro. NO los recalcula con sus tarifas espejo.
// Un catalogo espejo desactualizado por un dia bastaria para que el socio vea $12,400 y el club
// $12,650 por la misma reservacion. Se valida solo que la suma del detalle cuadre con la
// cabecera (tolerancia de un centavo); si no cuadra se APLICA IGUAL —el maestro manda— y se
// levanta incidencia.
```

## 4.6 `ReplicacionEntranteController::estado()`

`GET /reservaciones/replicacion/estado?ids=163290,163291` → por cada `idReservacionPrivado`
devuelve `{idReservacionPrivado, idReservacionPortal, reservacionHashReplica,
reservacionVersionReplica, reservacionEstado}`. Acota `ids` a 200 por petición.

---

# FASE 6 — Front (`CL032FRONT`)

> El front está en **JavaScript**, no TypeScript. Los módulos nuevos van en JS.

- El back expone `reservacionMaestro` (y `reservacionNoPrivado`) en los recursos de reservación
  que consume el front.
- Con `reservacionMaestro === 'Privado'`: ocultar editar, cancelar, agregar/quitar habitación,
  extras y pago. Mostrar un distintivo del tipo *"Gestionada por el club"*.
- **No** deducir el modo de solo lectura desde `reservacionEstado`. Es el mismo error que §4
  argumenta en el back: dos fuentes de verdad para la misma pregunta acaban divergiendo.

---

# Verificación antes de dar por terminada cada fase

## Fase −1

- Reservación atascada en `Confirmando` sobre el umbral → **llega correo `R1`**, no solo un
  renglón en el log.
- `monitoreo.habilitado = false` + incidencia → **no** llega correo, pero la fila **sí** está en
  `rsv_bitacorareplicacion` con `bitacoraReplicacionAvisada = 0`. *(que "silenciado" no sea
  "perdido")*
- Misma incidencia 20 veces en la ventana → **un** correo, **20** filas de bitácora.
- Un ciclo con tres tipos distintos → **un solo correo** con los tres.
- Diez reservaciones de la **misma membresía** con el mismo problema → **un** aviso.
- Incidencia que se resuelve y reaparece dentro de la ventana → **vuelve a avisar** (el ciclo sano
  llamó `limpiarAviso()`). *(el patrón intermitente)*
- Romper la escritura de bitácora a propósito → el proceso **no se cae**, queda constancia en log
  y el correo se envía igual.
- Caché caída → el throttle deja de filtrar pero **la bitácora sigue completa**.
- Cada correo trae el contexto accionable de −1.3. *(uno que solo diga "falló la réplica" no
  pasa)*

## Fase 0

```sql
-- 1. Una Reservado NO genera marca aunque se toque directo en la BD
UPDATE rsv_reservacion SET reservacionNotas = CONCAT(IFNULL(reservacionNotas,''),' ')
 WHERE reservacionMaestro = 'Privado' LIMIT 1;
-- rsv_replicacion no debe tener filas nuevas

-- 2. Una Temporal SI genera marca
UPDATE rsv_reservacion SET reservacionNotas = CONCAT(IFNULL(reservacionNotas,''),' ')
 WHERE reservacionEstado = 'Temporal' LIMIT 1;
-- rsv_replicacion debe tener una marca Pendiente

-- 3. Los 12 triggers siguen instalados
SELECT COUNT(*) FROM information_schema.TRIGGERS
 WHERE TRIGGER_SCHEMA = DATABASE() AND EVENT_OBJECT_TABLE LIKE 'rsv_reservacion%';  -- 12
```

Además (pruebas de Feature en `tests/Feature/Reservaciones/`):

- `Confirmando` **sí** se replica (si esto se rompe, el socio queda cobrado sin reservación).
- Un `Cancelado` desde `Temporal` **sí** se replica (libera cupo en el maestro).
- Veredicto OK → `Reservado` + `reservacionMaestro='Privado'` + `reservacionFechaMaestro` no nula
  + **cero marcas nuevas**.
- Marca `Pendiente` creada antes del sello y drenada después → `recolectar()` la cierra **sin**
  incluirla en la respuesta.

## Fase 4

- Reenviar el mismo snapshot 5 veces → a partir del 2.º, `sin_cambios`; el detalle no cambia.
- v7 y v8 entregados **en orden inverso** → queda aplicado v8; v7 responde `409 version_obsoleta`.
- Reservación con **dos habitaciones del mismo tipo y mismas fechas**; el snapshot quita una →
  se cancela la del puntero correcto y sobrevive la otra. *(la prueba que valida el diseño)*
- Snapshot con `idTipoHabitacion` inexistente → `422 catalogo_desconocido`, **sin escribir nada**.
- Snapshot con `membresiaNo` desconocido → `422 membresia_no_localizada`.
- Snapshot sobre una reservación aún `Portal` → `409 maestria_portal`.

---

# Lo que NO debes hacer

- **No** condiciones los triggers por `reservacionEstado`. Lee §4 de la propuesta: la regla no es
  expresable por estado y no es monótona.
- **No** modifiques `HmacSigner::verificar()` ni la firma de los dos endpoints existentes. La
  convención de firma está congelada (CONTRATO §2) y Reservaciones V2 está en producción.
- **No** uses borrar-y-reinsertar en `AplicarReplicaPrivadaService`.
- **No** empareces filas por atributos. Solo por puntero.
- **No** recalcules importes de una reservación bajo maestría privada.
- **No** devuelvas `500` ante un error de datos en `/entrante`: Reservaciones V2 lo clasifica como
  transitorio, reintenta cinco veces y **abre el circuit breaker**, deteniendo las réplicas sanas
  de todas las demás reservaciones. Errores de datos → `4xx` con `motivo`.
- **No** dejes que una notificación fallida tumbe una réplica ya asentada. Todo aviso al socio es
  best-effort y va fuera de la transacción.
- **No** dejes ningún `catch` que solo escriba al log en los caminos de §−1.5. Un log que nadie
  abre no es vigilancia.
- **No** mandes un correo por incidencia. Agregado por ciclo, o throttleado por firma en los
  caminos de evento.
- **No** omitas la bitácora cuando el correo esté throttleado o el monitoreo apagado. Esa es
  justamente la fila que hará falta después de un reclamo.
- **No** alertes por permanencia en un estado (una marca que *sigue* descartada). Solo por la
  transición: con un cron de un minuto, la diferencia es 1 correo contra 1,440 al día.
- **No** dejes que un fallo del monitoreo tumbe el proceso que lo invoca. Bitácora, log y correo
  son best-effort **en ese orden de prioridad**.

---

# Entregables

| # | Archivo | Fase |
|---|---|---|
| 0a | `database/migrations/…_create_rsv_bitacorareplicacion.php` + gemelo SQL | −1 |
| 0b | `app/Models/Modulos/Reservacion/BitacoraReplicacion.php` | −1 |
| 0c | `app/Library/FSG/MonitorReservaciones.php` | −1 |
| 0d | `config/replicacion.php` (bloque `monitoreo`) | −1 |
| 0e | `app/Console/Commands/ReservacionesAlertarConfirmando.php` (emite `R1`) | −1 |
| 0f | `app/Console/Commands/ReservacionesReconciliarCupo.php` (migra a `R6`/`R15`) | −1 |
| 1 | `database/migrations/2026_08_18_120000_add_maestria_to_rsv_reservacion.php` + `database/sql/rsv_reservacion_add_maestria.sql` | 0 |
| 2 | `app/Models/Modulos/Reservacion/Reservacion.php` (constantes + 3 métodos) | 0 |
| 3 | `app/Models/Modulos/Reservacion/Replicacion.php` (guarda en `marcar`) | 0 |
| 4 | `app/Http/Controllers/Reservaciones/ReplicacionController.php` (guarda en `recolectar`, veredicto ampliado) | 0 |
| 5 | `app/Services/Reservaciones/ReservacionService.php` (`finalizarConfirmacion`) | 0 |
| 6 | `app/Http/Requests/Reservaciones/Replicacion/ProcesarVeredictoFormRequest.php` | 0 |
| 7 | `database/manual/2026_08_18_triggers_rsv_replicacion_maestria.sql` | 0 |
| 8 | `database/migrations/2026_08_18_130000_add_punteros_privado_to_rsv_detalle.php` + gemelo SQL | 2 |
| 9 | `app/Services/Reservaciones/MapeoReplicaService.php` | 2 |
| 10 | `app/Library/FSG/HmacSigner.php` (`verificarConCuerpo`) | 4 |
| 11 | `app/Http/Middleware/SeguridadEscritura.php` (parámetro `$modo`) | 4 |
| 12 | `app/Http/Controllers/Reservaciones/ReplicacionEntranteController.php` | 4 |
| 13 | `app/Http/Requests/Reservaciones/Replicacion/ReplicaEntranteFormRequest.php` | 4 |
| 14 | `app/Services/Reservaciones/AplicarReplicaPrivadaService.php` | 4 |
| 15 | `routes/modulos/reservaciones/replicacion.php` (2 rutas) | 4 |
| 16 | Pruebas en `tests/Feature/Reservaciones/` | 0 y 4 |

**Decisiones pendientes de confirmación del negocio** (D1 folio, D3 `reservacionImporte`
`double(18,0)` → `decimal(18,2)`, D4 `idBeneficiario = 0`): están en §16 de la propuesta. No las
implementes sin respuesta; déjalas señaladas con un `TODO` que cite la decisión.
