# PROPUESTA — Visibilidad del Hotel 2 por ocupación y por temporada

**Proyecto:** CL032BACK (portal público) · base `asturias_api`
**Estado:** PROPUESTA. No se ha modificado código.
**Fecha:** 2026-09-30
**Referencias:** [ARQUITECTURA_GENERAL.md](../ARQUITECTURA_GENERAL.md) · `app/Services/Reservaciones/VisibilidadHotelService.php` · `config/reservacion.php` (bloque `visibilidad_hotel`)

---

## 0. Resumen

El cliente pidió dos reglas para habilitar el Hotel 2 en el portal:

1. **Automática:** el Hotel 2 se habilita cuando las habitaciones **ocupadas** del Hotel 1 son **más de 150**.
2. **A criterio, por temporada:** en ciertas fechas, el personal decide que el Hotel 2 esté **siempre habilitado** o que se habilite con **otro umbral de ocupación**.

La propuesta tiene dos fases que se pueden liberar por separado:

| Fase | Qué resuelve | Cambio |
|---|---|---|
| **1** | Regla 1: "ocupadas > 150" | Nuevo modo `ocupacion` en `VisibilidadHotelService` + `.env` |
| **2** | Regla 2: temporadas | Tabla `rsv_temporadahotel` + módulo de administración + paso nuevo en `evaluar()` |

Ninguna de las dos cambia **cuánto cupo** hay: la regla sigue decidiendo solamente **qué hoteles se publican**. El cupo sigue saliendo de `DisponibilidadService::disponiblesPorTipo()`.

---

## 1. Situación actual

### 1.1 Cómo decide hoy

`VisibilidadHotelService::evaluar()` es el **único punto de decisión**. Lo consultan:

- La pantalla: `DisponibilidadService::armar()` → `tiposOcultos()`
- El alta de habitación, dentro del candado: `ReservacionService::agregarHabitacion()` → `tiposOcultosParaEstancia()`
- El refresco de contadores: `ReservacionService::disponibilidadesPorTipo()` → `tiposOcultosParaEstancia()`

Orden actual de la decisión:

```
1. regla inactiva / configuración inválida      → visible
2. la estancia no tiene tipos del Hotel 2       → nada que ocultar
3. la captura ya tiene tipos del Hotel 2        → visible
4. inventario del Hotel 1 = 0                   → visible
5. válvula: Hotel 1 agotado (real)              → visible
6. umbral: disponibles SIN Temporales ≤ umbral  → visible; si no, se oculta el Hotel 2
```

`disponiblesPrincipal` es la **suma, por tipo del Hotel 1, del mínimo por noche** de `cupoSinTemporales`: cuántas habitaciones del Hotel 1 se pueden vender para toda la estancia.

### 1.2 Configuración vigente (`.env`)

```dotenv
RESERVACION_VISIBILIDAD_HOTEL_ACTIVA=true
RESERVACION_VISIBILIDAD_HOTEL_UMBRAL=170
# RESERVACION_VISIBILIDAD_HOTEL_MODO no está definido → 'cantidad'
```

Esto significa: **el Hotel 2 se habilita cuando el Hotel 1 tiene 170 habitaciones disponibles o menos.**

### 1.3 Diferencia con lo que pide el cliente

| | Hoy | Cliente |
|---|---|---|
| Qué se mide | Habitaciones **disponibles** | Habitaciones **ocupadas** |
| Comparación | `≤ 170` | `> 150` |

Las dos reglas coinciden **solo si el inventario activo del Hotel 1 es de 321 habitaciones** (321 − 170 = 151 ocupadas). Con cualquier otro inventario, hoy el Hotel 2 se abre en un momento distinto al que el cliente espera.

> **Verificación previa obligatoria:**
> ```sql
> SELECT SUM(tipoHabitacionCantidad) AS inventarioHotel1
>   FROM rsv_tipohabitacion
>  WHERE tipoHabitacionHotel = 'Hotel 1'
>    AND tipoHabitacionEstado = 'Activo';
> ```
> El servicio solo cuenta los tipos que además vienen en el feed de disponibilidad, así que el número real puede ser menor si algún tipo activo no está publicado.

---

## 2. Decisiones que el cliente debe confirmar

Sin estas respuestas la Fase 1 se puede programar, pero no configurar correctamente.

| # | Pregunta | Supuesto de esta propuesta | Impacto |
|---|---|---|---|
| D1 | ¿"Más de 150" es **> 150** o **≥ 150**? | `>` (estricto): la habitación 151 abre el Hotel 2 | Una habitación de diferencia |
| D2 | ¿Las reservaciones **Temporales** (apartadas, sin confirmar) cuentan como ocupadas? | **No**, igual que hoy (`cupoSinTemporales`) | **Alto.** En días de mucha demanda puede haber decenas de Temporales |
| D3 | ¿Las habitaciones **bloqueadas o en mantenimiento** cuentan como ocupadas? | **Sí**: `ocupadas = inventario − disponibles`, así que cualquier habitación no vendible cuenta | Medio |
| D4 | ¿La temporada aplica igual a **socios e invitados**? | **Sí**, a todos | Si no, se agrega un campo en la Fase 2 |
| D5 | ¿Quién administra las temporadas y desde qué sistema? | Personal, desde el **panel de CL032BACK** (ver §5.1) | Define dónde vive la pantalla |

---

## 3. Fase 1 — Modo `ocupacion` (la regla de 150)

### 3.1 Alternativa sin código (solo como parche temporal)

Usar el modo `cantidad` con el valor equivalente:

```
UMBRAL = inventarioHotel1 − 151
```

**No se recomienda como solución final.** El número depende del inventario: si se da de alta o de baja un tipo del Hotel 1, la regla se desplaza sin que nadie lo note, y `UMBRAL=149` no le dice a nadie que significa "150 ocupadas".

### 3.2 Cambio propuesto

Agregar un **tercer modo** a los dos que ya existen. Los modos `cantidad` y `porcentaje` no cambian, así que el cambio es **retrocompatible**: para regresar basta con volver el `.env` al valor anterior.

#### `app/Services/Reservaciones/VisibilidadHotelService.php`

**a) Constante nueva**

```php
public const MODO_CANTIDAD   = 'cantidad';
public const MODO_PORCENTAJE = 'porcentaje';
public const MODO_OCUPACION  = 'ocupacion';
```

**b) Comparación del umbral** (hoy en las líneas 205-209)

```php
// Antes
$umbral = $config['umbral'];
$habilitar = $config['modo'] === self::MODO_PORCENTAJE
    // Aritmética entera: disponibles/inventario ≤ umbral/100 sin flotantes.
    ? $disponiblesPrincipal * 100 <= $umbral * $inventario
    : $disponiblesPrincipal <= $umbral;
```

```php
// Propuesto
$umbral = $config['umbral'];
$habilitar = match ($config['modo']) {
    // Aritmética entera: disponibles/inventario ≤ umbral/100 sin flotantes.
    self::MODO_PORCENTAJE => $disponiblesPrincipal * 100 <= $umbral * $inventario,
    // Ocupadas = lo que el Hotel 1 ya no puede vender para esta estancia. Estricto (>):
    // con umbral 150, la habitación 151 es la que abre el Hotel 2.
    self::MODO_OCUPACION  => ($inventario - $disponiblesPrincipal) > $umbral,
    default               => $disponiblesPrincipal <= $umbral,
};
```

> Si el cliente responde **≥** en D1, se cambia `>` por `>=` en esta única línea y en su prueba.

**c) Validación** (hoy en las líneas 286-290)

```php
return match ($config['modo']) {
    self::MODO_CANTIDAD,
    self::MODO_OCUPACION  => true,
    self::MODO_PORCENTAJE => $config['umbral'] <= 100,
    default               => false,
};
```

No se valida `umbral ≤ inventario` en la configuración, porque el inventario solo se conoce al evaluar. Si el umbral es mayor que el inventario, el Hotel 2 nunca se abre por umbral y solo lo abre la válvula. Ese caso se deja en el log (ver §3.3).

**d) Docblock de la clase:** agregar `ocupacion` al paso 6 del orden de la decisión.

#### `config/reservacion.php`

Solo el comentario del bloque `visibilidad_hotel`; la llave `modo` ya lee el `.env`:

```php
|   modo          'cantidad', 'porcentaje' u 'ocupacion' (uno solo).
|   umbral        entero. El hotel secundario se habilita cuando:
|                   cantidad   → disponibles del principal ≤ `umbral`.
|                   porcentaje → disponibles del principal ≤ `umbral` % del inventario (0 a 100).
|                   ocupacion  → ocupadas del principal > `umbral`
|                                (ocupadas = inventario − disponibles sin Temporales).
```

#### `.env` / `.env-pro`

```dotenv
RESERVACION_VISIBILIDAD_HOTEL_ACTIVA=true
RESERVACION_VISIBILIDAD_HOTEL_MODO=ocupacion
RESERVACION_VISIBILIDAD_HOTEL_UMBRAL=150
```

Después: `php artisan config:clear` (o `config:cache` en producción).

### 3.3 Observabilidad

Agregar al resultado de `evaluar()` el valor `ocupadasPrincipal` (junto a `disponiblesPrincipal` e `inventarioPrincipal`), para que el diagnóstico de "¿por qué no aparece el Hotel 2?" se pueda leer en los mismos términos que usa el cliente.

### 3.4 Pruebas — `tests/Unit/Reservaciones/VisibilidadHotelServiceTest.php`

| Prueba | Datos | Esperado |
|---|---|---|
| `test_ocupacion_oculta_el_hotel_2_en_el_umbral` | inventario 10, disponibles 5 → ocupadas 5, umbral 5 | Hotel 2 **oculto** |
| `test_ocupacion_habilita_el_hotel_2_al_superar_el_umbral` | inventario 10, disponibles 4 → ocupadas 6, umbral 5 | Hotel 2 **visible**, motivo `umbral_alcanzado` |
| `test_ocupacion_no_cuenta_temporales` | `cupoSinTemporales` > `cupoLibre` | Decide con `cupoSinTemporales` |
| `test_ocupacion_respeta_la_valvula` | umbral 999, Hotel 1 agotado | Visible, motivo `hotel_principal_agotado` |
| `test_ocupacion_es_configuracion_valida` | `modo=ocupacion`, `umbral=0` | No cae en `configuracion_invalida` |

Las pruebas de Feature (`tests/Feature/Reservaciones/VisibilidadHotelTest.php`) no cambian; si se quiere, se agrega un caso de pantalla con `modo=ocupacion`.

### 3.5 Lo que NO cambia

- `DisponibilidadService`, `ReservacionService` y el front: siguen llamando a los mismos métodos.
- La válvula, la regla de "captura con Hotel 2", el interruptor `ACTIVA` y el comportamiento ante errores (se muestran los dos hoteles).

---

## 4. Fase 2 — Temporadas

### 4.1 Regla de negocio

Una **temporada** es un rango de **noches** en el que el personal fija cómo se habilita el Hotel 2:

| Opción en pantalla | Efecto durante esas noches |
|---|---|
| ☑ **Hotel 2 siempre habilitado** (valor por defecto) | El Hotel 2 se publica sin importar la ocupación del Hotel 1 |
| ☐ desmarcado → *"Habilitar Hotel 2 a partir de ___ habitaciones ocupadas"* | Se usa ese umbral en lugar del del `.env` |

Fuera de cualquier temporada rige la regla de la Fase 1 (`.env`).

**Por qué una casilla y no "umbral 0 = siempre":** con la regla "ocupadas **>** umbral", un umbral 0 significa "ocupadas > 0". Con el Hotel 1 vacío (fechas lejanas al inicio de temporada) el Hotel 2 quedaría **oculto**, justo lo contrario de lo que se quiere. Además, un 0 con significado especial se malinterpreta con el tiempo. Con la casilla desmarcada, el 0 conserva su significado normal: "habilitar en cuanto haya al menos una habitación ocupada".

### 4.2 Orden de la decisión con temporadas

```
1. regla inactiva / configuración inválida            → visible
2. la estancia no tiene tipos del Hotel 2             → nada que ocultar
3. la captura ya tiene tipos del Hotel 2              → visible
4. NUEVO · alguna noche en temporada "siempre"        → visible  (motivo: temporada_siempre_habilitado)
5. inventario del Hotel 1 = 0                         → visible
6. válvula: Hotel 1 agotado (real)                    → visible
7. umbral efectivo de la estancia                     → visible / oculto
      umbral efectivo = el MENOR de los umbrales de sus noches
                        (umbral de la temporada si la noche tiene una; si no, el del .env)
```

**Estancia que cruza el borde de una temporada** (por ejemplo, entra el 30 de marzo y la temporada empieza el 1 de abril): basta con que **una** noche esté en temporada. Es la misma lógica que ya usa la regla, que mide la noche más apretada, y evita perder ventas justo en los bordes.

**Por qué el umbral más bajo y no una evaluación noche por noche:** la métrica actual (`disponiblesPrincipal`) ya es de toda la estancia, no de cada noche. Tomar el umbral más permisivo de las noches mantiene una sola métrica y el mismo criterio de "cualquier noche".

### 4.3 Tabla `rsv_temporadahotel`

Convención (ARQUITECTURA_GENERAL §2): prefijo del módulo `rsv_` + entidad `temporadahotel`. Todos los campos inician con `temporadaHotel`, salvo la PK `idTemporadaHotel` y la FK `idUsuario`.

| Campo | Tipo | Nulo | Default | Notas |
|---|---|---|---|---|
| `idTemporadaHotel` | int AUTO_INCREMENT | No | — | PK |
| `temporadaHotelAlias` | varchar(75) | No | — | "Semana Santa 2027", "Verano 2027" |
| `temporadaHotelFechaInicio` | date | No | — | **Primera noche** de la temporada (inclusiva) |
| `temporadaHotelFechaFin` | date | No | — | **Última noche** de la temporada (inclusiva). No es la fecha de salida |
| `temporadaHotelSiempreHabilitado` | tinyint(1) | No | `1` | 1 = Hotel 2 siempre habilitado. Es la casilla de la pantalla |
| `temporadaHotelUmbral` | int | Sí | `NULL` | Habitaciones **ocupadas** del Hotel 1 a partir de las cuales se habilita. NULL si `SiempreHabilitado = 1` |
| `temporadaHotelEstado` | enum('Activo','Inactivo','Eliminado') | No | `'Activo'` | Solo `Activo` participa en la regla |
| `temporadaHotelFecha` | datetime | No | — | Fecha de captura |
| `temporadaHotelFechaModificacion` | datetime | Sí | `NULL` | Última edición o cambio de estado |
| `idUsuario` | int | No | — | FK a `seg_usuario`: quién la capturó o modificó por última vez |

#### DDL — `database/sql/rsv_temporadahotel.sql`

```sql
-- =====================================================================
--  rsv_temporadahotel
--  Temporadas en las que el personal decide cómo se habilita el Hotel 2.
--
--  Proyecto : CL032BACK (Reservaciones en Línea)
--  Archivo  : database/sql/rsv_temporadahotel.sql
--
--  Una fila por temporada. Fuera de toda temporada rige la regla del .env
--  (config/reservacion.php → visibilidad_hotel). La consulta
--  VisibilidadHotelService::evaluar().
--
--  Las fechas son NOCHES, ambas inclusivas: una temporada del 2027-03-28
--  al 2027-04-04 cubre la noche del 4 de abril (salida el 5).
--
--  ES RE-EJECUTABLE: CREATE TABLE IF NOT EXISTS.
--  Si el código se despliega antes que este script, el portal sigue
--  vendiendo: la regla ignora las temporadas y lo deja en el log.
-- =====================================================================

SET NAMES utf8mb4;

CREATE TABLE IF NOT EXISTS `rsv_temporadahotel` (
  `idTemporadaHotel`                int          NOT NULL AUTO_INCREMENT,
  `temporadaHotelAlias`             varchar(75)  CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Nombre de la temporada: Semana Santa 2027, Verano 2027, ...',
  `temporadaHotelFechaInicio`       date         NOT NULL COMMENT 'Primera noche de la temporada (inclusiva)',
  `temporadaHotelFechaFin`          date         NOT NULL COMMENT 'Última noche de la temporada (inclusiva). No es la fecha de salida',
  `temporadaHotelSiempreHabilitado` tinyint(1)   NOT NULL DEFAULT 1 COMMENT '1 = Hotel 2 siempre habilitado; 0 = se habilita por temporadaHotelUmbral',
  `temporadaHotelUmbral`            int          NULL DEFAULT NULL COMMENT 'Habitaciones OCUPADAS del Hotel 1 a partir de las cuales (>) se habilita el Hotel 2. NULL si SiempreHabilitado = 1',
  `temporadaHotelEstado`            enum('Activo','Inactivo','Eliminado') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'Activo',
  `temporadaHotelFecha`             datetime     NOT NULL COMMENT 'Fecha de captura',
  `temporadaHotelFechaModificacion` datetime     NULL DEFAULT NULL COMMENT 'Última edición o cambio de estado',
  `idUsuario`                       int          NOT NULL COMMENT 'FK a seg_usuario: quién capturó o modificó por última vez',
  PRIMARY KEY (`idTemporadaHotel`) USING BTREE,
  INDEX `idx_temporadahotel_estado_fechas` (`temporadaHotelEstado`, `temporadaHotelFechaInicio`, `temporadaHotelFechaFin`) USING BTREE,
  INDEX `idx_temporadahotel_usuario` (`idUsuario`) USING BTREE,
  CONSTRAINT `fk_temporadahotel_usuario`
    FOREIGN KEY (`idUsuario`) REFERENCES `seg_usuario` (`idUsuario`)
    ON DELETE RESTRICT ON UPDATE CASCADE,
  CONSTRAINT `chk_temporadahotel_fechas`
    CHECK (`temporadaHotelFechaFin` >= `temporadaHotelFechaInicio`),
  CONSTRAINT `chk_temporadahotel_umbral`
    CHECK (
      (`temporadaHotelSiempreHabilitado` = 1 AND `temporadaHotelUmbral` IS NULL)
      OR
      (`temporadaHotelSiempreHabilitado` = 0 AND `temporadaHotelUmbral` IS NOT NULL AND `temporadaHotelUmbral` >= 0)
    )
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_general_ci
  ROW_FORMAT = Dynamic
  COMMENT = 'Temporadas que definen cómo se habilita el Hotel 2 en el portal';
```

> **Nota sobre la FK a `seg_usuario`:** esa tabla está en `utf8mb3`, pero la FK es `int` contra `int`, así que la diferencia de collation no afecta.
>
> **Los CHECK** requieren MySQL 8.0.16 o superior. En versiones anteriores se aceptan pero no se aplican; en ese caso la regla la garantiza el Form Request (§4.5).

#### Migración equivalente

`database/migrations/2026_10_XX_120000_create_rsv_temporadahotel_table.php`, con `DB::statement(...)` y el mismo DDL, igual que `create_rsv_catalogoreplicacionestado_table.php`. `down()`: `DROP TABLE IF EXISTS`.

#### Traslapes

**No se permiten dos temporadas `Activo` que compartan noches.** MySQL no puede impedirlo con un índice, así que:

1. El Form Request lo valida (`withValidator` → `after`) para dar un mensaje claro.
2. El controlador lo vuelve a verificar dentro de la transacción, con `lockForUpdate()` sobre las temporadas activas que traslapan, para que dos usuarios guardando al mismo tiempo no creen un traslape.

Condición de traslape entre `[inicio, fin]` y una fila existente:

```sql
temporadaHotelEstado = 'Activo'
AND temporadaHotelFechaInicio <= :fin
AND temporadaHotelFechaFin    >= :inicio
AND idTemporadaHotel          <> :idTemporadaHotel   -- al editar
```

### 4.4 Artefactos derivados (ARQUITECTURA_GENERAL §3)

Tabla `rsv_temporadahotel` → módulo `Reservaciones` (convención ya usada en el proyecto: controladores en `Reservaciones`, modelos en `Reservacion`) → entidad `TemporadaHotel`.

| Componente | Ruta |
|---|---|
| Modelo | `app/Models/Modulos/Reservacion/TemporadaHotel.php` |
| Controlador | `app/Http/Controllers/Reservaciones/TemporadaHotelController.php` |
| Rutas | `routes/modulos/reservaciones/temporadaHotel.php` (incluido desde `routes/web.php`) |
| Form Requests | `app/Http/Requests/Reservaciones/TemporadaHotel/ListarFormRequest.php` · `GuardarFormRequest.php` · `CambiarEstadoFormRequest.php` |
| Resources | `app/Http/Resources/Reservaciones/TemporadaHotel/ListaResource.php` · `DetalleResource.php` |
| Vista | `resources/views/modulos/reservaciones/temporadaHotel/index.blade.php` |
| DDL | `database/sql/rsv_temporadahotel.sql` + migración |
| Bitácora | Nuevas acciones 26 y 27 en `database/sql/rsv_bitacoraaccion_portal.sql` |

#### Modelo — solo persistencia simple (§6)

```php
namespace App\Models\Modulos\Reservacion;

use Illuminate\Database\Eloquent\Model;
use App\Models\Modulos\Seguridad\Usuario;   // modelo existente de seg_usuario

class TemporadaHotel extends Model
{
    protected $table      = 'rsv_temporadahotel';
    protected $primaryKey = 'idTemporadaHotel';
    public    $timestamps = false;

    protected $fillable = [
        'temporadaHotelAlias',
        'temporadaHotelFechaInicio',
        'temporadaHotelFechaFin',
        'temporadaHotelSiempreHabilitado',
        'temporadaHotelUmbral',
        'temporadaHotelEstado',
        'temporadaHotelFecha',
        'temporadaHotelFechaModificacion',
        'idUsuario',
    ];

    protected $casts = [
        'temporadaHotelFechaInicio'       => 'date',
        'temporadaHotelFechaFin'          => 'date',
        'temporadaHotelSiempreHabilitado' => 'boolean',
        'temporadaHotelUmbral'            => 'integer',
    ];

    public function usuario()
    {
        return $this->belongsTo(Usuario::class, 'idUsuario');
    }

    // lista(), detalle(), nuevo(), editar(), cambiarEstado()   → CRUD del panel
    // activasEnRango(string $llegada, string $ultimaNoche)      → lo que lee la regla
    // traslapadas(inicio, fin, ?idTemporadaHotel)                → validación de traslapes
}
```

`activasEnRango()` es una consulta simple (estado `Activo` + condición de traslape). La decisión de qué hacer con las filas vive en `VisibilidadHotelService`, no en el modelo (§5.2 #4.b).

#### Rutas (§4)

```
GET  /reservaciones/temporada-hotel/                → index
POST /reservaciones/temporada-hotel/listar          → listar
POST /reservaciones/temporada-hotel/guardar         → guardar
GET  /reservaciones/temporada-hotel/detalle/{id}    → detalle
POST /reservaciones/temporada-hotel/cambiar-estado  → cambiarEstado
```

Todas bajo `auth:fsg` (sesión del panel). **Hoy el proyecto no tiene el middleware `permiso:`**: las rutas del panel solo exigen `auth:fsg`. Hay que decidir si se implementa el middleware de permisos que pide §4.4.3 o si se documenta la excepción, como ya se hizo con los canales máquina a máquina.

#### Form Request `GuardarFormRequest` — reglas

```php
'temporadaHotelAlias'             => 'required|string|max:75',
'temporadaHotelFechaInicio'       => 'required|date_format:Y-m-d',
'temporadaHotelFechaFin'          => 'required|date_format:Y-m-d|after_or_equal:temporadaHotelFechaInicio',
'temporadaHotelSiempreHabilitado' => 'required|boolean',
'temporadaHotelUmbral'            => 'nullable|required_if:temporadaHotelSiempreHabilitado,0|integer|min:0',
```

En `withValidator()->after(...)`:

- Si `SiempreHabilitado = 1`, forzar `temporadaHotelUmbral = null` (en `prepareForValidation`).
- **Traslape** con otra temporada activa → error en `temporadaHotelFechaInicio`.
- **Umbral ≥ inventario activo del Hotel 1** → error: con ese valor el Hotel 2 nunca se abriría por umbral.
- **Fecha fin en el pasado** → error. No tiene sentido capturar temporadas ya terminadas.

Mensajes en español (§7.3).

#### Controlador `guardar` (§5)

- `DB::beginTransaction()` → verificación de traslape con `lockForUpdate()` → `nuevo()` / `editar()` → bitácora → `DB::commit()`.
- **Después del commit:** `Cache::forget('visibilidad_hotel:temporadas')` (ver §4.6).
- Respuesta JSON estándar (`success`, `code`, `message`, `data`).

#### Bitácora

`rsv_bitacora` ya acepta `idMembresia` nulo, así que sirve para las acciones del personal. Nuevas acciones en `rsv_bitacoraaccion_portal.sql` (y `AUTO_INCREMENT = 28`):

| id | Alias | Entidad | Campo |
|---|---|---|---|
| 26 | Guardar temporada Hotel 2 | `rsv_temporadahotel` | `idTemporadaHotel` |
| 27 | Cambiar estado temporada Hotel 2 | `rsv_temporadahotel` | `idTemporadaHotel` |

En `bitacoraDetalle` se guarda el antes y el después (fechas, casilla, umbral y estado), para poder reconstruir quién abrió el Hotel 2 y cuándo.

> `rsv_bitacora` no tiene columna para el usuario del panel; `idUsuario` queda en la propia fila de `rsv_temporadahotel` (último que la modificó). Si se necesita el historial de **todos** los usuarios, se agrega el `usuarioAlias` en `bitacoraDetalle`.

### 4.5 Pantalla (panel de CL032BACK)

El panel de CL032BACK ya existe (login de `seg_usuario`, guard `fsg`, vistas Blade en `resources/views/modulos/`). La pantalla sigue ese mismo modo (Blade, **Modo A**) y el **FSG UI Kit 2.4**.

Contenido:

- **Lista:** alias, noches (del … al …), "Siempre habilitado" o "A partir de N ocupadas", estado, usuario y fecha de modificación. Ordenada por fecha de inicio; por defecto, solo temporadas vigentes y futuras.
- **Formulario:**
  - Nombre de la temporada
  - Primera noche / Última noche. Mostrar debajo, en texto: *"Cubre las noches del 28 de marzo al 4 de abril; salida el 5 de abril"*.
  - ☑ **Hotel 2 siempre habilitado en esta temporada** (marcada por defecto)
  - Si se desmarca: *"Habilitar Hotel 2 a partir de ___ habitaciones ocupadas del Hotel 1"* (mostrar el inventario actual del Hotel 1 como referencia)
- **Acciones:** editar, desactivar o activar, eliminar (baja lógica, `Eliminado`).

### 4.6 Cambios en `VisibilidadHotelService`

1. **Lectura de temporadas con caché.** Leer **todas** las temporadas `Activo` cuya última noche sea de hoy en adelante (son pocas filas) y guardarlas en caché con la clave `visibilidad_hotel:temporadas`:
   - TTL de respaldo de 5 minutos.
   - Invalidación explícita al guardar o cambiar estado (§4.4).
   - La regla corre en cada pantalla y en cada alta **dentro del candado**, así que no debe agregar una consulta por petición.
2. **Paso 4 nuevo** (siempre habilitado): si alguna noche de la estancia (`llegada` a `salida − 1`) cae en una temporada con `SiempreHabilitado = 1`, se regresa visible con el motivo `temporada_siempre_habilitado` y el `idTemporadaHotel`.
3. **Umbral efectivo:** para cada noche, el umbral de su temporada (si tiene) o el del `.env`; se usa el **menor**. En modo `ocupacion` se compara con `inventario − disponiblesPrincipal`.
   - El umbral de temporada está en **ocupadas**. Si el `.env` no está en modo `ocupacion`, se convierte a ocupadas antes de comparar (`cantidad`: `inventario − umbral − 1`) o, más simple, **se exige `modo=ocupacion` para usar temporadas con umbral** y se registra una advertencia si no. **Recomendación:** exigirlo. La Fase 1 lo deja así.
4. **Falla hacia el comportamiento de siempre:** si la lectura de temporadas falla (tabla inexistente, error de BD o de caché), se registra `Log::error` y **se evalúa sin temporadas** (solo la regla del `.env`). No se muestran los dos hoteles por esto: una falla de la tabla nueva no debe cambiar la regla vigente.
5. **`evaluar()` sigue siendo puro:** recibe las temporadas como parámetro, igual que hoy recibe `$filas` y `$tiposRetenidos`. La lectura (BD y caché) se hace en `tiposOcultos()` / `tiposOcultosParaEstancia()`. Así las pruebas unitarias no necesitan base de datos.

### 4.7 Pruebas Fase 2

**Unitarias (`evaluar()` con temporadas en memoria):**

| Escenario | Esperado |
|---|---|
| Estancia completa en temporada "siempre", Hotel 1 vacío | Visible, `temporada_siempre_habilitado` |
| Estancia cruza el borde (1 noche fuera, 2 dentro) | Visible |
| Temporada que termina la noche anterior a la llegada | No aplica; decide la regla del `.env` |
| Temporada que empieza el día de salida | No aplica (el día de salida no es noche) |
| Temporada con umbral 100, ocupadas 120 | Visible |
| Temporada con umbral 100, ocupadas 90 | Oculto |
| Temporada con umbral 200 y `.env` con 150 en la misma estancia | Se usa el menor (150) |
| Captura con Hotel 2 retenido, sin temporada | Visible (paso 3 intacto) |
| Temporada `Inactivo` o `Eliminado` | Se ignora |

**Feature:**

- Guardar temporada traslapada → 422 con mensaje en español.
- Guardar con `SiempreHabilitado = 0` sin umbral → 422.
- Guardar invalida la caché: la siguiente consulta de disponibilidad ya refleja la temporada.
- Tabla inexistente → el portal sigue vendiendo con la regla del `.env` y queda `Log::error`.

---

## 5. Escenarios

| # | Escenario | Comportamiento |
|---|---|---|
| E1 | Sin temporada, Hotel 1 con 80 ocupadas | Hotel 2 oculto |
| E2 | Sin temporada, Hotel 1 con 151 ocupadas | Hotel 2 visible |
| E3 | Sin temporada, Hotel 1 con exactamente 150 | Hotel 2 oculto (`>`; depende de D1) |
| E4 | Temporada "siempre", Hotel 1 vacío | Hotel 2 visible |
| E5 | Temporada con umbral 100, Hotel 1 con 120 | Hotel 2 visible |
| E6 | Estancia que cruza el borde de una temporada | Visible si **alguna** noche está en temporada "siempre"; umbral = el menor de las noches |
| E7 | Dos temporadas traslapadas | Imposible: lo impiden el Form Request y el controlador |
| E8 | Se crea una temporada mientras un socio captura | La siguiente consulta la refleja (caché invalidada) |
| E9 | Se desactiva una temporada con capturas en curso en el Hotel 2 | El socio conserva lo apartado (paso 3) |
| E10 | Reservaciones confirmadas en el Hotel 2 al terminar la temporada | No se tocan: la regla solo decide qué se publica |
| E11 | Oscilación en la frontera: el socio ve el Hotel 2 con 151, alguien cancela y al dar de alta hay 150 | El alta rechaza con mensaje y la pantalla se refresca. Es poco frecuente; la histéresis queda como mejora opcional (§7) |
| E12 | Cancelaciones que bajan la ocupación | El Hotel 2 se vuelve a ocultar para capturas nuevas; lo vendido se queda |
| E13 | Hotel 1 lleno de Temporales | La válvula abre el Hotel 2 (sin cambios) |
| E14 | Cambio de fechas de una reservación | Se reevalúa con las fechas nuevas; lo retenido del Hotel 2 se respeta |
| E15 | Socio, invitado, membresía I0 | Aplica igual a todos (D4) |
| E16 | Personal vendiendo directo en Reservaciones V2 | No le afecta: la regla es solo del portal |
| E17 | Falla la tabla o la caché de temporadas | Se evalúa sin temporadas (regla del `.env`) + `Log::error` |
| E18 | `ACTIVA=false` | Se muestran los dos hoteles, con o sin temporadas (interruptor general) |
| E19 | Cambia el inventario del Hotel 1 | El modo `ocupacion` usa el inventario activo del momento. El umbral **no** se desplaza, a diferencia de `cantidad` |
| E20 | Umbral de temporada mayor que el inventario | Lo impide el Form Request |
| E21 | Pre-producción contra producción | Las temporadas viven en la base de cada ambiente; se capturan por separado |
| E22 | "Abrir el Hotel 2 ya, desde hoy" | Crear una temporada "siempre" de hoy a la fecha X. Sin despliegue y queda en bitácora |

---

## 6. Plan de liberación

### Fase 0 — Sin código
1. Ejecutar la consulta de inventario de §1.3 en pre-producción y producción.
2. Obtener respuesta del cliente a D1–D5.

### Fase 1
1. Cambios de §3.2 + pruebas de §3.4.
2. Desplegar el código: con el `.env` sin cambios se comporta **igual que hoy**.
3. Cambiar el `.env` (`MODO=ocupacion`, `UMBRAL=150`) y ejecutar `config:cache`.
4. **Rollback:** regresar el `.env` a `MODO=cantidad`, `UMBRAL=170` y ejecutar `config:cache`.

### Fase 2
1. Ejecutar `database/sql/rsv_temporadahotel.sql` y las acciones 26 y 27 de bitácora (usuario administrador, **antes** del código).
2. Desplegar modelo, controlador, rutas, pantalla y los cambios del servicio.
3. Sin temporadas capturadas, el portal se comporta igual que en la Fase 1.
4. **Rollback:** desactivar las temporadas desde la pantalla, o revertir el código; la tabla puede quedarse porque nadie la lee.

---

## 7. Fuera de alcance / mejoras opcionales

| Mejora | Cuándo considerarla |
|---|---|
| **Histéresis** (abrir en > 150 y cerrar en < 140) | Solo si en producción se ve con frecuencia el caso E11. Requiere guardar estado por noche |
| **Temporada por tipo de reservación** (`temporadaHotelAplicaA`: socio / invitado / ambos) | Solo si el cliente responde "no" en D4 |
| **Administrar las temporadas en Reservaciones V2** y replicarlas al portal por el canal de catálogos | Si el cliente prefiere un solo sistema de administración. Hoy el canal de catálogos está apagado (`REPLICACION_CATALOGOS_HABILITADA=false`), por lo que no se recomienda como primera versión |
| **Acción "cerrar el Hotel 2 a la fuerza"** | **No se recomienda.** Cerrar inventario le corresponde a Reservaciones V2 (bloqueos, mantenimiento). Si esta regla también cierra, habría dos lugares que cierran cupo y uno sería invisible para el maestro |
