# Distribución automática de invitados y extras automáticos — CL032BACK

Trabaja en `D:\FOURSIDES\CL001ASTU\PRE-PRODUCCION\VILLASTUR\CL032RESERVACIONESENLINEA\CL032BACK`.
Sigue ARQUITECTURA_GENERAL.md y ARQUITECTURA_PROYECTO-1.md del proyecto.

## Contexto

La pantalla `/reservaciones/reservacion/invitado?solicitud=...` del portal público (CL032FRONT)
no está cobrando correctamente los extras automáticos de "persona adicional", y además obliga
al usuario a distribuir a mano a los invitados habitación por habitación.

El sistema maestro es **Reservaciones V2** (`app/Models/Modulos/Reservaciones/
ReservacionHabitacion.php`). La regla de cobro que rige es la suya y NO se negocia: el portal
replica sus reservaciones a Reservaciones V2, así que cualquier divergencia produce un importe distinto
entre portal y hotel.

## Diagnóstico ya realizado (no lo repitas, verifícalo)

1. `app/Library/FSG/Leyendas.php:142` — `extrasRequeridos()` **ya es correcta** e idéntica al
   maestro. NO la modifiques; es la fuente de verdad.
2. `app/Services/Reservaciones/ReservacionService.php:807` — `sincronizarExtras()` es correcta,
   pero **solo se invoca desde `actualizarPersonas()`** (línea 323). Al dar de alta o de baja
   una habitación (`agregarHabitacion`, `quitarHabitacion`) nunca se redistribuye a las
   personas ni se resincronizan extras.
3. `ReservacionService::agregarHabitacion()` crea la habitación con
   `reservacionHabitacionNoAdultos = 0` y `reservacionHabitacionNoMenores = 0` (líneas 84-85).
   El usuario tiene que teclear toda la distribución a mano.
4. `ReservacionService::habitacionesVigentes()` (línea 199) devuelve **solo**
   `idReservacionHabitacion` e `idTipoHabitacion`. El front no recibe personas, ni extras, ni
   importe por habitación, y por eso reimplementó la fórmula por su cuenta — mal.

## Regla de negocio canónica (copiada del maestro Reservaciones V2)

Con `c = reservacionHabitacionCantidad` (en el portal siempre 1):

```
A_inc = config('reservacion.adultos_para_extra_automatico')     * c   // 2
P_inc = config('reservacion.personas_incluidas_por_habitacion') * c   // 4

extrasPorAdultos = max(0, A - A_inc)
adultosEnCupo    = min(A, A_inc)
extrasPorTotal   = max(0, (adultosEnCupo + M) - P_inc)

extras(A, M, c) = extrasPorAdultos + extrasPorTotal
```

Matiz crítico: el adulto que excede `A_inc` paga UNA sola vez (por la vía de adultos) y su
lugar deja de contar en el cupo de ocupación — de ahí el `min()`. Sin ese tope habría doble
cobro.

Tabla de referencia para `c = 1` (úsala tal cual como casos de prueba):

| A | M | Total | extrasPorAdultos | adultosEnCupo | extrasPorTotal | Extras |
|--:|--:|------:|-----------------:|--------------:|---------------:|-------:|
| 1 | 0 | 1 | 0 | 1 | 0 | 0 |
| 1 | 3 | 4 | 0 | 1 | 0 | 0 |
| 1 | 4 | 5 | 0 | 1 | 1 | 1 |
| 0 | 4 | 4 | 0 | 0 | 0 | 0 |
| 0 | 5 | 5 | 0 | 0 | 1 | 1 |
| 2 | 2 | 4 | 0 | 2 | 0 | 0 |
| 2 | 3 | 5 | 0 | 2 | 1 | 1 |
| 2 | 4 | 6 | 0 | 2 | 2 | 2 |
| 3 | 0 | 3 | 1 | 2 | 0 | 1 |
| 3 | 1 | 4 | 1 | 2 | 0 | 1 |
| 3 | 2 | 5 | 1 | 2 | 0 | 1 |
| 3 | 3 | 6 | 1 | 2 | 1 | 2 |
| 4 | 0 | 4 | 2 | 2 | 0 | 2 |
| 4 | 2 | 6 | 2 | 2 | 0 | 2 |
| 4 | 3 | 7 | 2 | 2 | 1 | 3 |
| 5 | 0 | 5 | 3 | 2 | 0 | 3 |

Los dos frentes de cobro son asimétricos y ambos deben cumplirse:
- por **adultos**: cobra desde el **3.º adulto**, aunque haya solo 3 personas;
- por **ocupación**: cobra desde la **5.ª persona**, cuando `A ≤ 2`.

Precio del extra: `rsv_extras.extraPrecioInvitado × noches`, una fila por extra con
`reservacionHabitacionExtraCantidad = 1` (ya implementado en `crearExtraPersona()`).

## Objetivo

1. Que el portal distribuya **automáticamente** a los invitados de la solicitud cada vez que el
   usuario agrega o quita una habitación, minimizando los extras.
2. Que el número de extras persistido sea SIEMPRE `Σ extrasRequeridos(A_i, M_i, 1)` sobre las
   habitaciones vigentes, sin importar el camino que siguió el usuario.
3. Que el backend devuelva la distribución, los extras y los importes ya calculados, para que
   el front deje de calcularlos por su cuenta.

## Tareas

### 1. Distribuidor determinista (nuevo)

Crea `App\Services\Reservaciones\DistribucionInvitadosService` (o método privado del
`ReservacionService`, según lo que encaje con ARQUITECTURA_GENERAL §5.2). Recibe el total de
adultos `A` y menores `M` de la solicitud y la lista de habitaciones vigentes ordenada por
`idReservacionHabitacion ASC`, y devuelve el reparto `[{idReservacionHabitacion, adultos,
menores}]`.

Algoritmo (debe alcanzar el mínimo teórico de extras):

```
H = nº de habitaciones vigentes
A_inc = 2, P_inc = 4    (por habitación, desde config)

Paso 1 — adultos base: recorre las habitaciones asignando min(A_inc, adultosRestantes).
Paso 2 — adultos sobrantes: reparte 1×1 en round-robin sobre las mismas habitaciones.
Paso 3 — menores base: para cada habitación, capacidad = P_inc - min(a_i, A_inc)
         (OJO: es min(a_i, A_inc), NO a_i — un adulto excedente ya no ocupa cupo).
         Asigna min(capacidad, menoresRestantes).
Paso 4 — menores sobrantes: reparte 1×1 en round-robin.
```

Invariante que DEBES verificar con un test:

```
Σ extrasRequeridos(a_i, m_i, 1)  ==  extrasRequeridos(A, M, H)
```

es decir, repartir nunca cuesta más que agrupar. Y además `Σ a_i == A`, `Σ m_i == M`.

Regla adicional: si `A + M >= H`, ninguna habitación puede quedar en 0 personas (el maestro
Reservaciones V2 rechaza habitaciones vacías en `validarDistribucionInvitados()`). Si `A + M < H` deja las
sobrantes en 0 y no falles: la validación de `guardar` ya avisará.

### 2. Resincronización total de extras (nuevo)

Extrae de `sincronizarExtras()` un método público `sincronizarExtrasDeReservacion(Reservacion
$reservacion)` que recorra TODAS las habitaciones vigentes de tipo Invitado y ajuste sus extras
al objetivo por diferencia (alta/baja solo del delta, nunca borrar y recrear todo: eso rompe
importes ya calculados y ensucia la réplica).

Reutiliza la mecánica que ya existe: `ReservacionHabitacionExtra::delTipoEnHabitacion()` +
`crearExtraPersona()` + `$extra->eliminar()`.

### 3. Enganchar en alta y baja de habitación

En `ReservacionService::agregarHabitacion()` y `ReservacionService::quitarHabitacion()`, cuando
`reservacionTipo === Reservacion::TIPO_INVITADO`, **dentro de la misma transacción** y **antes**
de `recalcularTotales()` y de `Replicacion::marcar()`:

```
1. redistribuir invitados sobre las habitaciones vigentes (tarea 1)
2. persistir a_i / m_i con ReservacionHabitacion::actualizarPersonas()
3. sincronizarExtrasDeReservacion()      (tarea 2)
4. recalcularImporteHabitacion() por cada habitación tocada
5. recalcularTotales($reservacion)
6. Replicacion::marcar(...)   ← ya existe, NO lo dupliques
```

Los totales `A` y `M` salen de la solicitud por el camino que ya usa el controlador:
`ReservacionController::cuposSolicitud()` (`adultosTotal`, `menoresTotal`, con la regla de edad
de `Leyendas` evaluada a la fecha de llegada). Si ese cálculo hoy vive en el controlador,
muévelo o expónlo para que el service lo pueda consumir sin duplicar la regla de edad.

**Decisión de diseño explícita:** la redistribución recalcula el reparto ÍNTEGRO desde cero y
por tanto pisa los ajustes manuales previos del usuario. Es lo que se pidió (redistribuir al
agregar/quitar habitaciones), y es la única forma de garantizar el mínimo de extras. Los
ajustes manuales vía `/personas` siguen intactos hasta la siguiente alta o baja. Protege el
comportamiento con un flag `'distribucion_automatica_invitado' => (bool) env(
RESERVACION_DISTRIBUCION_AUTOMATICA, true)` en `config/reservacion.php`, documentado con el
porqué.

### 4. `/personas` sigue siendo manual (ajuste, no regresión)

`actualizarPersonas()` NO debe redistribuir: es el ajuste fino del usuario. Solo cámbialo para
que llame a `sincronizarExtrasDeReservacion()` en vez de a la versión por habitación, de modo
que exista un único camino de sincronización. El comportamiento observable no debe cambiar.

### 5. Contrato de salida — que el front deje de calcular

Amplía `ReservacionService::habitacionesVigentes()` para devolver por habitación:

```
idReservacionHabitacion   (base64, como hoy)
idTipoHabitacion
reservacionHabitacionNoAdultos
reservacionHabitacionNoMenores
extras                    // entero, extrasRequeridos(a, m, 1)
extrasImporte             // extras * extraPrecioInvitado * noches
reservacionHabitacionImporte   // Σ paquetes + Σ extras (ya persistido)
```

Y añade al bloque de estado que ya viaja en cada alta/baja (`agregarHabitacion`,
`quitarHabitacion`, `personas`, `reanudar`, `sincronizar`) los totales:
`totalAdultosAsignados`, `totalMenoresAsignados`, `totalExtras`, `reservacionImporte`.

En `catalogoInvitado`, junto a `extraPrecioInvitado`, publica también las reglas para que el
front pueda hacer preview optimista sin inventar nada:
`adultosIncluidosPorHabitacion` y `personasIncluidasPorHabitacion` (desde config).

Mantén la compatibilidad: agrega campos, no renombres ni elimines los existentes.

### 6. Pruebas (obligatorias)

`tests/` con PHPUnit, sin tocar la BD de desarrollo — usa la BD `_testing` del proyecto o
pruebas unitarias puras donde sea posible.

- **Unitaria de `extrasRequeridos`**: la tabla completa de arriba, caso por caso. Es un test de
  regresión contra el maestro; debe fallar si alguien toca la fórmula.
- **Unitaria del distribuidor**: para cada `(A, M, H)` con `A ∈ 0..8`, `M ∈ 0..8`, `H ∈ 1..4`,
  verifica `Σ a_i == A`, `Σ m_i == M`, `Σ extrasRequeridos(a_i,m_i,1) == extrasRequeridos(A,M,H)`
  y que ninguna habitación quede vacía cuando `A + M >= H`.
- **Feature de alta/baja**: alta de 2.ª habitación con 4 adultos → cada habitación queda 2/0 y
  **0 extras** (antes: 1 extra si el reparto quedaba 3/1). Baja de una habitación con 2A+6M en
  2 habitaciones → queda 1 habitación con 2/6 y **4 extras**, y el importe de la reservación lo
  refleja.
- **Idempotencia**: dos altas seguidas y una baja dejan exactamente los mismos extras que
  calcular el estado final desde cero.

## Restricciones

- NO modifiques `Leyendas::extrasRequeridos()` ni los valores de `config/reservacion.php`
  (`adultos_para_extra_automatico = 2`, `personas_incluidas_por_habitacion = 4`).
- NO inventes columnas: el esquema está en `cl038reservacionesv2.sql` (raíz de
  CL032RESERVACIONESENLINEA).
- Toda escritura dentro de la transacción existente; no abras transacciones anidadas.
- `Replicacion::marcar()` debe seguir siendo atómico con el cambio — no lo saques de la
  transacción ni lo llames dos veces por operación.
- Respeta el candado de cupo (`lockForUpdate` por tipo) de `agregarHabitacion`: la
  redistribución va DENTRO, después de que el cupo quedó decidido.
- No toques el flujo Socio: la redistribución y los extras automáticos son exclusivos de
  `reservacionHabitacionTipo === 'Invitado'`.

## Criterio de aceptación

Para cualquier secuencia de altas, bajas y ajustes manuales en la pantalla de invitado, el
número de filas de extra (idExtra = `config('reservacion.extra_persona_id')`) de la reservación
debe ser exactamente `Σ extrasRequeridos(a_i, m_i, 1)` sobre las habitaciones vigentes, y
`reservacionImporte` debe coincidir centavo a centavo con lo que Reservaciones V2 calcularía para esa misma
reservación tras la réplica.

## Fuera de alcance (repórtalo, no lo implementes aquí)

El front `CL032FRONT/src/stores/reservaciones/reservacionStore.js:107-109` tiene la fórmula
`extrasDe` MAL (`max(0,A-2) + max(0,M-2)`), que sobrecobra en casos como `0A+3M` o `1A+4M`.
Debe pasar a consumir `extras` / `totalExtras` del backend. Déjalo anotado en tu reporte final
con la lista exacta de campos nuevos que el front debe usar.
