# 06 · Componentes de código

Qué hace cada pieza y **dónde tocar si algo falla**. Todo el código del canal inverso está
presente en el repositorio; lo único apagado es el interruptor.

---

## 6.1 Inventario

| Archivo | Canal | Papel |
|---|---|---|
| `app/Models/Modulos/Reservacion/Reservacion.php` | Ambos | Maestría, folio visible, estado de réplica |
| `app/Models/Modulos/Reservacion/Replicacion.php` | Ida | Outbox: `marcar()`, lease, veredicto |
| `app/Models/Modulos/Reservacion/BitacoraReplicacion.php` | Observabilidad | Persistencia de incidencias |
| `app/Models/Modulos/Reservacion/ReservacionHabitacion.php` | Ambos | Upsert por puntero, cancelar ausentes |
| `…/ReservacionHabitacionPaquete.php` · `…Extra.php` | Inverso | Upsert por puntero, borrar ausentes |
| `app/Library/FSG/MonitorReservaciones.php` | Ambos | Catálogo `R1…R15`, throttle, correo |
| `app/Library/FSG/HmacSigner.php` | Ambos | Firma; `*ConCuerpo` para `/entrante` |
| `app/Http/Middleware/SeguridadEscritura.php` | Ambos | Las 5 capas, parámetro `$modo` |
| `app/Http/Controllers/…/ReplicacionController.php` | Ida | `/pendientes`, `/veredicto` |
| `app/Http/Controllers/…/ReplicacionEntranteController.php` | **Inverso** | `/entrante`, `/estado` |
| `app/Http/Requests/…/ProcesarVeredictoFormRequest.php` | Ida | Contrato del veredicto |
| `app/Http/Requests/…/ReplicaEntranteFormRequest.php` | **Inverso** | Contrato del snapshot |
| `app/Services/Reservaciones/ReservacionService.php` | Ambos | `finalizarConfirmacion()` — el sello |
| `app/Services/Reservaciones/SnapshotReservacionService.php` | Ida | Arma el snapshot de salida |
| `app/Services/Reservaciones/MapeoReplicaService.php` | **Inverso** | Fija punteros del veredicto |
| `app/Services/Reservaciones/AplicarReplicaPrivadaService.php` | **Inverso** | Upsert de tres vías |
| `routes/modulos/reservaciones/replicacion.php` | Ambos | Las 4 rutas |
| `config/replicacion.php` | Ambos | Interruptor y monitoreo |

---

## 6.2 🔧 Los dos puntos donde vive el interruptor

Son los únicos sitios que hay que tocar para encender o apagar el canal. Si algo se comporta
raro con el interruptor, mira **aquí primero**.

### `routes/modulos/reservaciones/replicacion.php`

```php
if ((bool) config('replicacion.inversa.habilitada', false)) {
    Route::post('/entrante', [ReplicacionEntranteController::class, 'entrante'])
        ->name('entrante')->middleware('seguridadEscritura:cuerpo');

    Route::get('/estado', [ReplicacionEntranteController::class, 'estado'])
        ->name('estado')->middleware('seguridadEscritura');
}
```

Apagado, las rutas **no existen** → `404`. Registrarlas apagadas devolvería `500` al escribir
en columnas inexistentes, y `500` abre el cortacircuitos de Reservaciones V2.

### `ReservacionService::finalizarConfirmacion()`

```php
if ($mapeo !== [] && (bool) config('replicacion.inversa.habilitada', false)) {
    $fijados = (new MapeoReplicaService())->aplicar($reservacion, $mapeo);
    // ...
}
```

**El interruptor va antes de mirar el mapeo, no después.** Con las columnas retiradas,
escribirlas reventaría este commit — y este commit es el que asienta la venta.

Cubierto por `MapeoReplicaTest::test_con_el_canal_inverso_apagado_el_veredicto_ignora_el_mapeo`.

---

## 6.3 `Reservacion` — el modelo central

### Maestría (canal de ida — siempre activo)

```php
public const MAESTRO_PORTAL  = 'Portal';
public const MAESTRO_PRIVADO = 'Privado';

public function esMaestroPortal(): bool                  // instancia
public static function maestroEsPortal(int $id): bool    // por id, para el outbox
public function sellarMaestriaPrivada(?int $idPrivado, ?int $noPrivado): void
```

`sellarMaestriaPrivada()` **es LA FRONTERA**. Se invoca una sola vez, dentro de la transacción
de `finalizarConfirmacion()`, y no tiene inversa en el flujo normal: recuperar la maestría es
un acto administrativo explícito, nunca un efecto secundario.

`maestroEsPortal()` devuelve `false` si la reservación no existe —no `true`—: un optimista
sobre una fila inexistente crearía marcas huérfanas.

### Folio visible (D1)

```php
public function folioVisible(): int
public function esOperablePorElSocio(): bool
```

Viven aquí y no repartidos entre quienes los consultan: dos copias de esas expresiones son dos
folios posibles y dos respuestas posibles sobre si el socio puede editar.

### Canal inverso

```php
public function aplicarReplicaPrivada(array $datos, int $version, string $hash): void
public static function nuevaDesdePrivado(array $datos, int $idPrivado): self
public static function porPunteroPrivado(int $idPrivado): ?self
public static function estadoReplicaDe(array $idsPrivados)
```

`nuevaDesdePrivado()` crea la reservación **ya bajo maestría privada**. Si naciera `Portal`,
sus propios triggers la marcarían y el portal le propondría cambios al maestro sobre una
reservación que no conoce.

---

## 6.4 Los tres modelos del detalle

Siguiendo `ARQUITECTURA_GENERAL §6.3 #4`, **toda mutación es un método del modelo**:

```php
// Canal inverso
ReservacionHabitacion::upsertPorPuntero(int $idReservacion, int $idPrivado, array $datos): self
ReservacionHabitacion::cancelarAusentesDelSnapshot(int $idReservacion, array $vigentes): int
ReservacionHabitacion::fijarPunterosPrivados(int $idReservacion, array $punteros): int
ReservacionHabitacion::idsDe(int $idReservacion): array

ReservacionHabitacionPaquete::upsertPorPuntero(int $idHabitacion, int $idPrivado, array $datos): self
ReservacionHabitacionPaquete::borrarAusentesDelSnapshot(int $idHabitacion, array $vigentes): int
ReservacionHabitacionPaquete::fijarPunterosPrivados(int $idHabitacion, array $punteros): int
// ReservacionHabitacionExtra: idéntico
```

**La habitación se CANCELA; el paquete y el extra se BORRAN.** El front, los PDFs y las
notificaciones que el socio ya recibió referencian `idReservacionHabitacion`: borrarla los
dejaría huérfanos. Un paquete o un extra que el club quitó no debe seguir viéndose, y su id no
lo referencia ningún documento emitido.

### ⚠️ La guarda de pertenencia vive en el `WHERE`

```php
self::where('idReservacionHabitacion', $idLocal)
    ->where('idReservacion', $idReservacion)   // ← la guarda, en la sentencia
    ->update([...]);
```

**No en el llamador.** Si estuviera solo en el servicio, cualquier código futuro podría
escribir un puntero en la reservación de otro socio — y la siguiente réplica le sobrescribiría
su reservación con datos ajenos. Es peor que duplicar: el socio afectado no hizo nada y no se
entera hasta llegar al hotel.

Cubierto por `MapeoReplicaTest::test_nunca_escribe_un_puntero_en_la_reservacion_de_otro_socio`.

---

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

```php
public function aplicar(Reservacion $reservacion, array $payload): array
// → ['habitaciones' => int, 'paquetes' => int, 'extras' => int,
//    'canceladas' => int, 'adopcion' => bool]
```

**No usa borrar-y-reinsertar**, que es como lo hace el camino inverso en Reservaciones V2. Allá es
correcto porque el destino es esclavo puro y sus ids no le importan a nadie. Aquí no sirve:

1. Los ids del portal son **visibles**: front, PDFs y notificaciones los referencian.
2. **No es idempotente ante fallo parcial**: un timeout entre el `DELETE` y el `INSERT`, o dos
   réplicas cruzadas, y aparece el duplicado.
3. Cambiar un extra no debe costar N deletes + N inserts de toda la reservación.

### Adopción por respaldo

```php
private function requiereAdopcion(Reservacion $reservacion): bool
```

Pregunta por la **ausencia TOTAL** de punteros, no por la de alguno. Una habitación agregada en
el privado después del veredicto tampoco tiene puntero local todavía; confundir ese caso con la
adopción reemplazaría el detalle entero **cada vez que el gerente agrega una habitación**.

### Validación de importes (`R13`)

```php
private function validarImportes(Reservacion $reservacion, array $payload): void
```

Tres reglas, y la segunda existe porque arreglar la primera casi tapa un caso real:

| Snapshot | Resultado |
|---|---|
| `Cancelado`/`Finalizado` **sin detalle** | **Nada.** No hay nada que cuadrar |
| `Reservado` **sin detalle** | `R13` con **su propio mensaje**: el socio la vería activa y vacía |
| Detalle que no suma | `R13` de descuadre |

> **Historia real:** la primera versión comparaba la cabecera contra un detalle vacío y lo
> llamaba descuadre. Como Reservaciones V2 filtra las habitaciones canceladas del snapshot, **las 57
> reservaciones canceladas del portal disparaban una alerta cada una — 48 correos de un solo
> backfill.** Justo el ruido que apaga un canal de avisos.

El contexto lleva `membresiaNo` y `socio` para que el correo sirva para actuar, y para que la
firma se ancle a la membresía y varias reservaciones del mismo socio compartan aviso.

---

## 6.6 `MapeoReplicaService` — tolerante por diseño

```php
public function aplicar(Reservacion $reservacion, array $mapeo): array
```

**Un mapeo incompleto, ausente o mal formado NO es un error.** El portal se queda sin punteros
y la primera réplica cae al camino de respaldo, que también es correcto. Reventar aquí tumbaría
un veredicto **ya asentado** y dejaría al socio cobrado y en `Confirmando`.

Ningún camino de esta clase lanza. Lo peor que pasa es un `Log::warning` y que se fijen los
punteros que sí venían bien.

Resuelve **una vez** el conjunto de habitaciones que sí son de la reservación: los hijos
cuelgan de `idReservacionHabitacion`, no de la reservación, así que sin esa lista un mapeo mal
formado fijaría punteros en los paquetes de otro socio y el `WHERE` del hijo no lo notaría.

---

## 6.7 `MonitorReservaciones` — la observabilidad

```php
MonitorReservaciones::incidencia(string $tipo, string $mensaje, array $datos = [], ?string $firma = null): array
MonitorReservaciones::reportar(string $proceso, array $incidencias): void      // procesos de CICLO
MonitorReservaciones::reportarUna(string $proceso, array $incidencia): void    // procesos de EVENTO
MonitorReservaciones::reportarFallo(string $proceso, Throwable $e, array $ctx = []): void
MonitorReservaciones::limpiarAviso(string $firma): void
MonitorReservaciones::purgar(int $dias): int
```

Orden dentro de `reportar()`, **no negociable**:

```
1. Bitácora — SIEMPRE, throttle o no, correo habilitado o no
2. Log con el conteo por tipo
3. Monitoreo deshabilitado → return (la bitácora ya guardó todo)
4. Throttle por firma
5. UN correo con las frescas, diciendo cuántas se omitieron y dónde consultarlas
```

**Dos formas de proceso, dos formas de reportar.** Los procesos de ciclo (comandos) acumulan y
llaman `reportar()` una vez, en el `finally`. Los de evento (controladores, jobs) no tienen
ciclo: usan `reportarUna()`, y **la agregación la da el throttle**.

`reportarFallo()` firma por **clase de excepción**, no por mensaje: un `Connection refused` con
puerto distinto en cada intento generaría una firma nueva cada vez y esquivaría el throttle.

---

## 6.8 Qué NO se toca al activar o desactivar

| Archivo | Por qué |
|---|---|
| `SnapshotReservacionService.php` | Arma el snapshot de **salida**. El canal de ida no cambia |
| `ReplicacionController.php` | `/pendientes` y `/veredicto`. Solo lee `esMaestroPortal()` |
| `Replicacion.php` | El outbox de ida |
| `HmacSigner::verificar()` / `firmar()` | **Firma congelada.** Reservaciones V2 está en producción |
| `config/sincronizacion.php` | Gobierna el canal vivo |
| `database/manual/2026_08_18_triggers_*.sql` | Los 12 triggers, del canal de ida |

---

**Anterior:** [05 · Contrato](05-CONTRATO-DE-API.md) ·
**Siguiente:** [07 · Activación paso a paso](07-ACTIVACION-PASO-A-PASO.md)
