# PROMPT DE IMPLEMENTACIÓN — CL032BACK (portal público)
## Fase 1: recepción de la réplica de catálogos de tarifa

**Proyecto:** `D:\FOURSIDES\CL001ASTU\PRE-PRODUCCION\VILLASTUR\CL032RESERVACIONESENLINEA\CL032BACK`
**Propuesta de referencia:** `../../CL034RESERVACIONESV2/docs/PROPUESTA_REPLICACION_CATALOGOS_PORTAL.md`
**Contraparte (emisor):** CL034 Fases 2–5. **Todavía no existe.**
**Entorno:** LOCAL / pre-producción.

---

> ## ▶ ESTE PROMPT VA PRIMERO, Y SE DETIENE AL TERMINAR
>
> Es la **Fase 1** de seis. El orden no es negociable: el receptor tiene que existir **antes** de
> que CL034 encienda su emisor, porque un envío contra una ruta inexistente devuelve 404, y el
> replicador de CL034 clasifica el 404 como **error permanente** y descarta la marca sin
> reintentar. Encender el emisor primero significa perder cambios en silencio.
>
> Al terminar esta fase, **PARA**. Las rutas quedan registradas pero apagadas por configuración
> (404 limpio) y no hay nada que las llame. La Fase 2 se ejecuta en CL034.
>
> **No implementes nada del emisor aquí.** El portal no envía catálogos, no los edita y no los
> crea. Solo recibe.

---

## Contexto

Este proyecto es el **portal público** de reservaciones en línea. Vende con las tarifas que hoy
viven duplicadas a mano en su base (`asturias_api`): tipos de habitación, paquetes, extras y sus
precios por año. El maestro real de esos catálogos es el sistema privado CL034
(`cl038reservacionesv2`), donde el personal del club los captura.

**El cambio:** CL034 va a empujar el catálogo completo como un **snapshot** firmado, y este
proyecto lo aplica. La dirección es única. El portal **nunca** propone cambios de catálogo hacia
CL034 y **nunca** crea filas de catálogo por su cuenta.

**Por qué importa que salga bien:** una divergencia en estas tablas se cobra en dinero — el socio
compra a un precio que el club no tiene registrado — y solo se descubre cuando alguien reclama.

**Lee la propuesta completa antes de escribir código**, en particular D2 (por qué snapshot completo
y no fila a fila), D4 (la ventana de años), D5 (por qué no se borra) y D8 (identidad de IDs).

---

## Estado verificado de la base (auditado el 2026-08-25)

Las cinco tablas ya existen en `asturias_api` y **hoy están perfectamente alineadas** con las de
CL034: 11 tipos, 31 paquetes, 2 extras, 22 precios de tipo y 438 precios de paquete, todos con el
mismo ID y el mismo contenido. **La primera réplica no debería mover un solo registro** — si mueve
algo, es un defecto de esta implementación, y ese es el mejor caso de prueba que tienes.

**Esta fase NO altera ninguna de las cinco tablas de catálogo.** La única tabla nueva es la de
estado (§3).

### Diferencia clave con CL034: aquí los ENUM son legibles

| Tabla · Columna | En CL034 (privado) | **En este proyecto** |
|---|---|---|
| `rsv_tipohabitacion.tipoHabitacionEstado` | `enum('A','I')` | `enum('Activo','Inactivo')` |
| `rsv_tipohabitacion.tipoHabitacionHotel` | `enum('V','N')` | **`enum('Hotel 1','Hotel 2')`** |
| `rsv_paquete.paqueteEstado` | `enum('A','E')` | `enum('Activo','Eliminado')` |
| `rsv_paquete.paqueteBloqueo` | `enum('Bloqueado')` | `enum('Bloqueado')` (igual) |
| `rsv_extras.extraEstado` | `enum('Activo','Inactivo')` | `enum('Activo','Inactivo')` (igual) |
| `rsv_tipohabitacionprecio.tipoHabitacionPrecioTipo` | `enum('Invitado','Socio')` | igual |

**El snapshot viaja siempre en legible**, así que aquí se escribe tal cual llega: no hay
traducción, no hay `whereEnum`, no se importa nada de `char_mappings`. La traducción es problema
de CL034.

> ⚠ **`tipoHabitacionHotel` es la trampa de este contrato.** El valor legible de `'V'` es
> **`'Hotel 1'`**, no `'V'`. Un snapshot que traiga `'V'` debe ser **rechazado**, no guardado:
> MySQL convertiría el enum inválido en cadena vacía (o lanzaría, según el `sql_mode`) y el tipo
> de habitación quedaría sin hotel. Por eso la lista blanca de §5 no es decorativa.

### Llaves de las tablas de precio

Verificadas en `asturias_api`:

- `rsv_tipohabitacionprecio` → `uk_tipo_precio_anio (idTipoHabitacion, tipoHabitacionPrecioTipo, tipoHabitacionPrecioAnioAplicacion)`
- `rsv_tipohabitacionpaquete` → `uk_tipo_paquete_anio (idTipoHabitacion, idPaquete, tipoHabitacionPaqueteAnioAplicacion)`

Esas son las llaves con las que se concilian. **Sus PK autoincrementales se ignoran por completo**
y no viajan en el snapshot: no significan nada y no tienen por qué coincidir entre sistemas.

### Las 251 filas con año `NULL`

`rsv_tipohabitacionpaquete` tiene 251 filas con `tipoHabitacionPaqueteAnioAplicacion = NULL`, en
este proyecto y en CL034. Son legado. **En producción no existen**, pero en local y
pre-producción sí, así que el código debe tratarlas bien: `NULL` nunca cae dentro de la ventana,
así que ni viajan ni se tocan. No las borres "de paso".

---

## Qué hay que construir

### 1. Configuración — `config/replicacion.php`

Agrega un bloque `catalogos` **hermano** de `inversa`, no dentro de él:

```php
'catalogos' => [
    // RECEPCIÓN. Gobierna si las rutas /reservaciones/catalogo/* se registran.
    // En false responden 404 — no 500, que abriría el cortacircuitos de CL034.
    'habilitada' => filter_var(env('REPLICACION_CATALOGOS_HABILITADA', false), FILTER_VALIDATE_BOOL),
],
```

**Arranca en `false`** y así se queda al terminar esta fase.

**No lo cuelgues de `inversa.habilitada`.** Son canales distintos con calendarios distintos: la
réplica inversa de *reservaciones* está apagada por decisión de negocio y con columnas retiradas
de la base; los catálogos no pueden quedar rehenes de eso ni encenderse de rebote el día que
aquella se reactive.

Documenta el bloque con el mismo nivel de comentario que tiene `inversa` — este archivo explica
*por qué*, no solo *qué*.

Agrega también a `.env.example`:

```
REPLICACION_CATALOGOS_HABILITADA=false
```

### 2. Rutas — `routes/modulos/reservaciones/catalogo.php`

Archivo nuevo, calcado en estilo de `replicacion.php`:

```
POST /reservaciones/catalogo/aplicar   → seguridadEscritura:cuerpo
GET  /reservaciones/catalogo/estado    → seguridadEscritura
```

- Patrón `/{modulo}/{submodulo}/{accion}` (módulo `reservaciones`, submódulo `catalogo`).
- Todo el grupo dentro de `throttle:` con `config('sincronizacion.rate_limit', '120,1')`.
- **Ambas rutas dentro de un `if ((bool) config('replicacion.catalogos.habilitada', false))`**,
  igual que `/entrante` y `/estado`. Apagadas = 404, nunca 500.
- `aplicar` firma **con cuerpo** (`seguridadEscritura:cuerpo`): el cuerpo trae los precios que van
  a sobrescribir la tarifa de venta; firmar solo el path los dejaría sin proteger.
- `estado` firma **solo el path** (no tiene cuerpo).
- **No** apliques `permiso:` — el control de acceso máquina-a-máquina es la firma HMAC + la
  allowlist, igual que en el canal existente.

Regístralo en `bootstrap/app.php`, en el `then:` de `withRouting`, junto a los otros dos canales:

```php
Route::group([], base_path('routes/modulos/reservaciones/catalogo.php'));
```

Y **añade el prefijo a `shouldRenderJsonWhen`** en `withExceptions`:

```php
|| $request->is('reservaciones/catalogo/*'),
```

Esto no es opcional. Sin ello, un 429 del `throttle` o un 404 devuelven la página HTML de error de
Laravel; del otro lado hay un cliente haciendo `json_decode` que recibe `null` y convierte un
límite de tasa —condición normal y temporal— en un "respuesta ilegible" imposible de diagnosticar.
Es exactamente el razonamiento que ya está escrito en ese archivo para los otros dos canales.

### 3. Tabla de estado — `rsv_catalogoreplicacionestado`

Una sola fila (`idCatalogoReplicacionEstado = 1`). Guarda qué snapshot se aplicó por última vez.

```sql
CREATE TABLE IF NOT EXISTS `rsv_catalogoreplicacionestado` (
  `idCatalogoReplicacionEstado` int NOT NULL AUTO_INCREMENT,
  `catalogoReplicacionEstadoHash`      char(64)    NULL DEFAULT NULL COMMENT 'sha256 del ultimo snapshot aplicado',
  `catalogoReplicacionEstadoVersiones` json        NULL COMMENT '{tipohabitacion:N, paquete:N, extra:N} del ultimo aplicado',
  `catalogoReplicacionEstadoFecha`     datetime(6) NULL DEFAULT NULL COMMENT 'Cuando se aplico',
  `catalogoReplicacionEstadoAnioDesde` smallint    NULL DEFAULT NULL COMMENT 'Ventana declarada en ese snapshot',
  `catalogoReplicacionEstadoResumen`   json        NULL COMMENT 'Ultimo {insertados, actualizados, apagados, borrados}',
  PRIMARY KEY (`idCatalogoReplicacionEstado`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci;

INSERT INTO `rsv_catalogoreplicacionestado` (`idCatalogoReplicacionEstado`) VALUES (1)
ON DUPLICATE KEY UPDATE `idCatalogoReplicacionEstado` = `idCatalogoReplicacionEstado`;
```

- Script en `database/sql/rsv_catalogoreplicacionestado.sql` **y** migración equivalente en
  `database/migrations/`, siguiendo la costumbre de este proyecto (hay pares de ambos).
- Modelo `app/Models/Modulos/Reservacion/CatalogoReplicacionEstado.php`: `$table`,
  `$primaryKey`, `$timestamps = false`, casts de los tres `json`. Sin `MappedQueryBuilder`
  (no hay ENUM aquí).

**Por qué una tabla y no caché:** un `cache:clear` en un despliegue borraría el hash, y el
siguiente snapshot idéntico se re-aplicaría entero. No sería incorrecto —es idempotente— pero
perderías la única evidencia de cuándo se aplicó qué.

### 4. Form Request — `app/Http/Requests/Reservaciones/Catalogo/AplicarCatalogoFormRequest.php`

Valida la **forma**, no las reglas de negocio (esas van en el servicio). Toma
`ReplicaEntranteFormRequest` como modelo de estilo.

```
version                  → required|integer|in:1
generadoEn               → required|date
origen                   → required|string|in:CL034
hash                     → required|string|size:64|regex:/^[a-f0-9]{64}$/
ventana.anioDesde        → required|integer|min:2000|max:2100
versiones.tipohabitacion → required|integer|min:0
versiones.paquete        → required|integer|min:0
versiones.extra          → required|integer|min:0
catalogos                → required|array
catalogos.tipoHabitacion → present|array
catalogos.paquete        → present|array
catalogos.extra          → present|array
catalogos.tipoHabitacionPrecio   → present|array
catalogos.tipoHabitacionPaquete  → present|array
```

Y por fila, las reglas de cada campo (`idTipoHabitacion → required|integer|min:1`,
`tipoHabitacionAlias → required|string|max:50`, importes `numeric|min:0`, años
`integer|min:2000|max:2100`, fechas `date|nullable`, etc.).

**`present` y no `required`** en los cinco arreglos: un catálogo legítimamente vacío es `[]`, y
`required` lo rechazaría. Pero **`present` sí importa**: si la llave falta por un bug del emisor,
la regla de conciliación apagaría todo el catálogo. La ausencia nunca debe leerse como "vacío".

### 5. Lista blanca de ENUM — validación dura

Antes de escribir nada, el servicio verifica cada valor contra esta tabla. Si aparece uno
desconocido, **se rechaza el snapshot entero** (no la fila): un ENUM nuevo en CL034 no debe entrar
mudo al portal, y aplicar medio snapshot es peor que no aplicar ninguno.

| Campo | Valores admitidos |
|---|---|
| `tipoHabitacionEstado` | `Activo`, `Inactivo` |
| `tipoHabitacionHotel` | `Hotel 1`, `Hotel 2` |
| `paqueteEstado` | `Activo`, `Eliminado` |
| `paqueteBloqueo` | `Bloqueado`, `null` |
| `extraEstado` | `Activo`, `Inactivo` |
| `tipoHabitacionPrecioTipo` | `Invitado`, `Socio` |

Déjala en una constante del servicio, junto a un comentario que diga que sale del `COLUMN_TYPE`
real de `asturias_api` y que **si algún día cambia el enum de la tabla, esta lista se actualiza en
el mismo commit**.

### 6. Servicio — `app/Services/Reservaciones/AplicarCatalogoPrivadoService.php`

Toda la lógica vive aquí; el controlador solo orquesta. Es el hermano de
`AplicarReplicaPrivadaService`.

Orden **exacto** de la aplicación, todo dentro de **una** `DB::transaction`:

1. **Tomar el candado.** `CatalogoReplicacionEstado::lockForUpdate()` sobre la fila 1. Dos envíos
   concurrentes (el job y el botón "Forzar") no pueden aplicarse encimados.
2. **Corte por hash.** Si `hash` recibido == `catalogoReplicacionEstadoHash`, salir con
   `aplicado:false, motivo:'sin_cambios'`. Sin abrir escrituras.
3. **Corte por versión.** Si **alguna** de las tres versiones recibidas es **menor** que la
   aplicada, salir con `aplicado:false, motivo:'version_anterior'`. Esto es lo que impide que un
   reintento tardío revierta un cambio nuevo. Si son iguales pero el hash cambió, se aplica
   (el emisor pudo reconstruir el snapshot).
4. **Validar la ventana.** `ventana.anioDesde` debe ser `>= YEAR(CURDATE())`. Si viene menor,
   **rechazar con 422** sin escribir nada. Esta es la defensa que impide que un bug del emisor
   pida borrar el histórico de precios.
5. **Validar ENUMs** contra §5. Rechazo total si algo no está en la lista.
6. **Validar integridad referencial del propio snapshot:** todo `idTipoHabitacion` e `idPaquete`
   citado en las dos tablas de precio debe existir en las cabeceras del **mismo** snapshot.
   Rechazar si no. Es gratis y evita depender del error de MySQL por FK.
7. **Guardia de colisión (D8.4).** Para cada cabecera cuyo ID ya existe, comparar el alias. Si
   cambió, **no bloquear** —renombrar es legítimo— pero acumularlo y devolverlo en
   `avisos.renombres[]`, además de dejarlo en `Log::info`. El día que alguien restaure una base
   equivocada, esa línea es la que va a explicar qué pasó.
8. **Aplicar cabeceras**, en este orden: `rsv_tipohabitacion` → `rsv_paquete` → `rsv_extras`.
   `upsert()` **con el ID explícito** del snapshot. Nunca un `insert` sin ID.
9. **Apagar las cabeceras ausentes.** Las que existen en la base y **no** vienen en el snapshot se
   marcan `Inactivo` (tipos y extras) o `Eliminado` (paquetes). **Jamás `DELETE`:** hay FK
   `RESTRICT` desde `rsv_tipohabitacionpaquete` y reservaciones vendidas apuntando a estos IDs.
   Un `DELETE` aquí o falla, o deja reservaciones huérfanas que ya no saben qué se vendió.
10. **Reemplazar precios dentro de la ventana**, para las dos tablas:
    - `DELETE ... WHERE anioAplicacion IS NOT NULL AND anioAplicacion >= :anioDesde`
    - `INSERT` de las filas del snapshot (todas traen año `>= anioDesde` por construcción; si
      alguna no, es un defecto del emisor → rechazar en el paso 5).
    - El `WHERE` va **en el SQL**, no en un `if` de PHP. Un filtro en PHP se olvida en el
      siguiente refactor; el `WHERE` no.
    - El `IS NOT NULL` es explícito a propósito. `NULL >= 2026` ya es `NULL` (falso) en MySQL, así
      que sería redundante — pero escribirlo deja constancia de que las 251 filas legado se
      protegen **a propósito** y no por accidente del operador.
11. **Sellar el estado:** hash, versiones, fecha, `anioDesde`, resumen de cambios.
12. **Commit.**

Sobre `upsert()` con ID explícito: MySQL avanza el `AUTO_INCREMENT` de la tabla al insertar un ID
mayor que el contador. Es inofensivo — este proyecto no inserta catálogos por ningún otro camino
(D8.2) — pero conviene que quien lea el código lo sepa y no se asuste.

Todos los rechazos usan `MonitorReservaciones::reportarUna()` con un código de incidencia propio
(sigue la numeración `R*` existente y **documenta el código nuevo donde estén documentados los
demás**), para que la alerta llegue por el canal de correo que ya está configurado —
`SINCRONIZACION_MONITOREO_CORREOS`, que se reutiliza tal cual, sin llave nueva.

### 7. Controlador — `app/Http/Controllers/Reservaciones/CatalogoReplicacionController.php`

Dos métodos, delgados. Copia la forma de `ReplicacionEntranteController`, incluido su helper
privado `rechazar()`.

**`aplicar(AplicarCatalogoFormRequest $request): JsonResponse`**

```json
{
  "success": true,
  "code": 200,
  "message": "Catálogo aplicado",
  "data": {
    "aplicado": true,
    "hash": "…",
    "cambios": { "insertados": 0, "actualizados": 3, "apagados": 1, "borrados": 22 },
    "avisos": { "renombres": [] }
  }
}
```

Cuando no hubo nada que hacer: `"aplicado": false` con `"motivo": "sin_cambios"` y **HTTP 200** —
no es un error, es el caso normal y frecuente. Devolver 4xx aquí haría que CL034 lo contara como
fallo y abriera el cortacircuitos contra un portal perfectamente sano.

**`estado(Request $request): JsonResponse`** — solo lectura, para el reconciliador y el modal
analizador de CL034:

```json
{
  "success": true, "code": 200, "message": "OK",
  "data": {
    "hash": "…",
    "versiones": { "tipohabitacion": 41, "paquete": 118, "extra": 9 },
    "aplicadoEn": "2026-08-25T11:04:03-06:00",
    "ventana": { "anioDesde": 2026 },
    "conteos": {
      "tipoHabitacion": 11, "paquete": 31, "extra": 2,
      "tipoHabitacionPrecio": 22, "tipoHabitacionPaquete": 187
    },
    "hashesPorCatalogo": { "tipoHabitacion": "…", "paquete": "…", "extra": "…" }
  }
}
```

Los `conteos` y `hashesPorCatalogo` se calculan **sobre las filas reales de la base, dentro de la
ventana** — no se leen de la tabla de estado. Si los leyeras de ahí, `estado` solo repetiría lo que
el portal *cree* haber aplicado, y el analizador dejaría de detectar precisamente el caso que
existe para detectar: que alguien tocó la base por fuera.

Los `hashesPorCatalogo` deben calcularse con **exactamente la misma canonicalización** que usa
CL034 (mismo orden, mismas llaves, mismo formato de número). Acuerda el algoritmo con la Fase 2 de
CL034 y déjalo escrito en un comentario en los dos lados: dos implementaciones que difieren en un
decimal producen un analizador que grita divergencia para siempre.

---

## Prohibiciones explícitas

1. **No crear catálogos por ningún otro camino.** Ni UI, ni endpoint, ni seeder, ni factory que
   apunte a la base real. Si el portal pudiera crear un paquete, tomaría un ID que CL034 va a
   reutilizar para otro producto distinto, y las reservaciones ya vendidas cambiarían de
   significado.
2. **No `DELETE` sobre `rsv_tipohabitacion`, `rsv_paquete` ni `rsv_extras`.** Nunca. Ver paso 9.
3. **No tocar filas de precio con año `NULL` ni con año anterior a la ventana.**
4. **No traducir ENUMs.** Llegan legibles y se escriben legibles.
5. **No inventar campos.** Las cinco tablas se quedan exactamente como están.
6. **No tocar `routes/modulos/reservaciones/replicacion.php`** ni nada del canal de reservaciones:
   está en producción.
7. **No encender `REPLICACION_CATALOGOS_HABILITADA` al terminar.** Queda en `false`.

---

## Verificación antes de dar por terminada la fase

Con `REPLICACION_CATALOGOS_HABILITADA=false`:

1. `POST /reservaciones/catalogo/aplicar` → **404** (no 500, no 403).
2. `php artisan route:list | grep catalogo` → no aparece nada.

Con la llave en `true` temporalmente y firmando desde CL034 o con un script de prueba:

3. **Snapshot idéntico al estado actual** → `aplicado:true` la primera vez con
   `cambios` todo en 0 salvo `borrados/insertados` de precios (que se reemplazan), y
   **`SELECT` posterior devuelve exactamente las mismas 504 filas de antes**. Compara contra un
   respaldo previo: si algo cambió, hay un defecto.
4. **Reenviar el mismo snapshot** → `aplicado:false, motivo:'sin_cambios'`, HTTP 200, cero
   escrituras.
5. **Snapshot con `versiones` menores** → `aplicado:false, motivo:'version_anterior'`.
6. **Snapshot con `ventana.anioDesde: 2020`** → **422**, sin escribir. Verifica con un `SELECT`
   que las filas de años anteriores y las 251 de año `NULL` siguen ahí, intactas.
7. **Snapshot con `tipoHabitacionHotel: "V"`** → rechazo total con el valor desconocido en el
   mensaje, y **cero** escrituras (comprueba que no se aplicaron ni las cabeceras que sí eran
   válidas: la transacción tiene que haber revertido todo).
8. **Snapshot al que le falta un tipo de habitación** → ese tipo queda `Inactivo`, **sigue
   existiendo**, y sus reservaciones asociadas intactas.
9. **Snapshot con un precio que cita un `idPaquete` inexistente** → rechazo en el paso 6, sin
   depender del error de FK.
10. **Dos `aplicar` concurrentes** → uno espera al otro; el estado final es coherente y el segundo
    sale por `sin_cambios`.
11. **Firma inválida / IP fuera de la allowlist** → 401 / 403 **en JSON**, no HTML.
12. `GET /reservaciones/catalogo/estado` → conteos que **coinciden con un `SELECT COUNT(*)`
    manual** sobre las tablas.

Deja la llave en `false` al terminar y anota en el `.env.example` que se enciende en la Fase 4.

---

## Entregable

Al terminar, escribe `docs/IMPLEMENTACION_RECEPCION_CATALOGOS.md` con: archivos creados y
modificados, el DDL aplicado, el resultado de los 12 puntos de verificación, y **el algoritmo
exacto de canonicalización del hash** — porque la Fase 2 de CL034 tiene que implementar el mismo,
y ese documento va a ser su contrato.
