# Tasks: Blindaje del Backend ante Datos Incompletos y Fuera de Rango

**Feature**: `013-blindaje-backend-integridad-datos`
**Plan**: `plan.md`
**Total de tareas**: 68 (+ 10 de verificación)

> Formato: `- [ ] TXXX [PN] [USX] Descripción — archivo exacto`
> El orden de fases sigue la tabla "Orden de ejecución recomendado" de `plan.md`.
> Estimación de horas por actividad: ver `PLAN_BLINDAJE_BACKEND.md` (raíz del repo).

---

## FASE 1 — Quick wins transversales (US2, US3) 🟢 Riesgo Bajo

- [x] T001 [P3] [US2] `auth()->user()->name` → `auth()->user()?->name` — `app/Http/Controllers/ContratosController.php` (~L448)
- [x] T002 [P3] [US2] `auth()->user()->` → `?->` en los dos puntos citados — `app/Http/Controllers/IntegracionController.php` (~L57, L112)
- [x] T003 [P3] [US2] `auth()->user()->` → `?->` — `app/Http/Controllers/Api/DataController.php` (~L494)
- [x] T004 [P3] [US2] `auth()->user()->` → `?->` — `app/Http/Controllers/UsersController.php` (~L53)
- [x] T005 [P1] [US2] Validar que `$servicio` exista antes de indexar `$serviciosDisponibles[$servicio]['url']` — `app/Services/AudaxEnergyService.php` (L41-42)
- [x] T006 [P1] [US2] Mismo guard de clave de array — `app/Traits/IntegracionTraits.php` (~L944)
- [x] T007 [P2] [US2] Sustituir `$body['data']['error_file_url'] ?? null` por `data_get($body, 'data.error_file_url')` — `app/Jobs/ProcesarCargaGlobalAudaxJob.php`
- [x] T008 [P2] [US2] Corregir orden de acceso de `tipo_contrato` (`??` antes de `?:`) — `app/Services/Tarifas/AdxTarifasService.php::mapRequestPayload` (L137-140; ruta real difiere de la citada originalmente en `plan.md`, que decía `app/Services/AdxTarifasService.php`)
- [x] T009 [P1] [US2] Sanitizar los `catch (\Throwable $e)` que exponen `$e->getMessage()` al cliente: loguear detalle completo, responder mensaje genérico — `app/Traits/CupsTraits.php` (L801, 855, 894, 932, y L1150 — un 5º punto idéntico no citado en el informe original, encontrado durante la implementación; el `Log::error` con el detalle ya existía en los 5 casos)
- [ ] T010 [P2] [US2] Búsqueda global de otros `catch (\Throwable $e)` con `$e->getMessage()` expuesto al cliente y aplicar el mismo tratamiento — **NO ejecutado**: la búsqueda global encontró ~50 ocurrencias repartidas en ~25 archivos (`IntegracionTraits`, `AnexoProductoTraits`, `AnexoCambioPotenciaTitularTraits`, `CargaGlobalAudax`, `ContactosTraits`, `PdfFormFillerService`, `ConfiguracionController`, etc.), muchas de ellas en archivos que Grupos A/B/G de `plan.md` ya van a tocar por otros motivos. Aplicar esto ahora como "quick win" excede el riesgo/alcance de la Fase 1 tal como está escrita; se recomienda tratarlo como parte de cada grupo específico (A, B, G) cuando se toque cada archivo, no como tarea transversal aislada. Pendiente de decisión.
- [x] T011 [P3] [US6] `maxLength={22}` en el input de CUPS — `resources/js/Pages/Eneon/Sips/components/Adx/AdxSipsPanel.tsx` / `resources/js/Pages/Eneon/Sips/components/Audax/AudaxSipsPanel.tsx` (2 inputs de CUPS distintos, uno por pestaña)
- [x] T012 [P3] [US3] `ObserCuenBan`: `max:255` → `max:100` — `app/Http/Requests/UpdateCuentaBancariaRequest.php` (+ `maxLength={100}` espejo añadido en `CrearCuentaBancariaModal.tsx`/`EditarCuentaBancariaModal.tsx`, que hoy no tenían ningún `maxLength`, para cumplir FR-008)

---

## FASE 2 — Grupo A: Anexos y documentos no fallan por datos incompletos (US1) 🟢 Riesgo Bajo

- [x] T013 [P1] [US1] Mover `if (!$propuesta)` **antes** de `->load([...])` — `app/Traits/auxiliares/AnexoCambioPotenciaTitularTraits.php` (L37-59)
- [x] T014 [P1] [US1] Mismo guard (hallazgo nuevo no incluido en el informe original) — `app/Traits/auxiliares/AnexoGenerarContratoDual.php` (L37-39)
- [x] T015 [P1] [US1] Mismo guard en los 6 puntos con el patrón `find()`+`load()` — `app/Traits/AnexoProductoTraits.php` (L559-573, 727, 830, 961, 1205, 1573). El punto de L559-573 no tenía guard alguno (ni siquiera mal ubicado) — se añadió desde cero, no solo se movió.
- [x] T016 [P1] [US1] Sustituir cadenas `->a->b->c` por `?->` en cada eslabón — `app/Traits/AnexoProductoTraits.php` (L1368-1369, 1691, 1734, 1739 en la numeración actual — drift respecto al plan original)
- [x] T017 [P1] [US1] Mismo tratamiento de cadenas sin `?->` — `app/Traits/auxiliares/AnexoCambioPotenciaTitularTraits.php` (L451/748, 706-709, 720-721, 726-727, 735 — 2 puntos adicionales encontrados: hay dos funciones casi idénticas, potencia y titular, con el mismo bug duplicado)
- [x] T018 [P1] [US1] Comprobar `->propuestaComercialCliente->isEmpty()` antes de indexar `[0]` — `app/Traits/IntegracionTraits.php` (L511-521)
- [x] T019 [P1] [US1] Reforzar accesos relacionados con el mismo patrón de guard temprano — `app/Traits/IntegracionTraits.php` (líneas con `Cliente::where('CodCli', $cliente->CodCli)`, `$contactoCliente->NomConCli`, `$cliente->CPLocFis/TelFijCli/EmaCli` sin `?->`)
- [x] T020 [P2] [US1] Guard tras `find()` antes de `update()` — `app/Http/Controllers/OrderController.php` (ecommerce legacy)
- [x] T021 [P2] [US1] `isset()` en configuración de plantillas para los tipos de anexo hoy no cubiertos — `app/Traits/PdfFormFillable.php`. El bloque `contrato`/`generales`/`particulares`/etc. no tenía NINGÚN `isset()` (a diferencia de los otros 3 casos del mismo método, que sí los tienen) — accedía directo a `$productoConfig['plantillas'][$tarifa]` y `['carpeta']`. También se añadió `isset()` a `$comercializadoraConfig['carpeta']` y `$energiaConfig['carpeta']`, mismo patrón, no citados explícitamente por el plan pero en el mismo método y con el mismo riesgo.
- [ ] T022 [US1] Prueba manual: generar los 4 tipos de anexo (potencia, titular, producto, contrato dual) con `propuestaId` inexistente → mensaje de negocio, no error 500; repetir con la propuesta real sin cliente asociado — **pendiente, requiere entorno con datos reales**

---

## FASE 3 — Grupo B: Guard centralizado e integración sin exponer errores (US2) 🟡 Riesgo Medio

- [x] T023 [P1] [US2] Crear método reutilizable para el patrón `handleTraitRedirect`/`processTraitResponse` — implementado como trait `app/Traits/HandlesTraitRedirects.php` (no como método `guardTraitResponse()` aislado que devuelve `JsonResponse`, como sugería `plan.md`: ese diseño no encajaba con los call sites reales, que esperan un `RedirectResponse` con flash message, no JSON). El trait hace `processTraitResponse()`/`handleTraitRedirect()` null-safe: si `json_decode()` falla, loguea y devuelve un error genérico en vez de propagar warnings de PHP8 por acceder a `->success`/`->message` en `null`.
- [x] T024 [P1] [US2] Aplicar el guard como piloto — `app/Http/Controllers/CupsController.php` (ahora usa el trait, se eliminó la copia privada)
- [x] T025 [P1] [US2] Replicar en los controllers restantes — **6 de 7** reemplazados por el trait (`ClientesController`, `ComercializadoraController`, `ProductoController`, `AnexoProductoController`, `UploadFileController`, y `CupsController` de T024). Los otros 2 citados por el plan resultaron ser casos distintos tras inspección:
  - `ContactosController` **ya era null-safe** (`$data->success ?? false`, `isset($data->errors[0])`) — no se tocó para no alterar su comportamiento de mensaje de error (usa `$data->errors[0]` para mostrar el detalle SQL al usuario, un problema distinto de sanitización de mensajes, no de null-safety, fuera de alcance de esta tarea).
  - `PuntoSuministroController` **no usa este patrón en absoluto** — sus métodos `activar`/`suspender` hacen `json_decode()` inline y comparan `$data->status === 200/404`. Se corrigió ahí mismo con `($data->status ?? null) === 200/404` en vez de aplicar el trait (no encaja con esa forma de código).
- [x] T026 [P2] [US2] Guard tras `find()` — `app/Http/Controllers/ContratosController.php::okCommercialContract`. Reordenado: el chequeo `!$propuesta` ahora ocurre **antes** de la validación de campos (antes estaba después), así se evita depender del `??` que ya mitigaba el acceso a `$propuesta->TipProCom`. Nota: esto cambia el resultado en el caso borde de que el `propuesta_id` sea inválido Y la validación falle a la vez (antes redirigía al formulario de edición con errores; ahora redirige a `contratos.index` con "propuesta no encontrada") — decisión deliberada, más correcta semánticamente.
- [x] T027 [P2] [US2] No tragar la excepción de `create()` de `PuntoSuministro` — `app/Traits/CargaGlobalAudax.php`. Cambiado `catch(\Exception)` a `catch(\Throwable)` (para no dejar pasar un `TypeError`/`Error`), y ahora cuando falla o `$direccionCups` queda `null`, se registra en `$erroresGlobales` y se **omiten** los bloques de CUPS eléctrico/gas de esa fila en vez de insertarlos con un `CodPunSum` nulo (huérfanos).
- [x] T028 [P2] [US2] Validar `first()` de tarifa antes de usarlo — `app/Traits/CargaGlobalAudax.php`. `$tarifaEle`/`$tarifaGas` ahora se comprueban antes de leer `->CodTarElec`/`->CodTarGas`; si no existen, se omite esa fila para ese CUPS y se registra el motivo. **Hallazgo**: `tarifa_luz` sí se valida contra la BD en Fase 1 (línea ~567), pero `tarifa_gas` NO — Fase 1 solo valida que sea un valor de una lista permitida, no que exista como `TarifaGas` real. Por eso este bug era real y más probable en gas que en luz.
- [x] T029 [P2] [US2] Asegurar que la resolución de tipo de vía siempre asigna un valor antes de continuar — `app/Traits/CargaGlobalAudax.php`. **Hallazgo más importante de esta fase**: `tipovia_cups` y `tipovia_contacto` usaban `firstOrFail()` sin validación previa en Fase 1 (a diferencia de `tipovia_cliente`, que sí se valida). Un valor no reconocido lanzaba `ModelNotFoundException` **no capturada**, que subía hasta el único `catch(Throwable)` de toda la función y abortaba **el lote completo** (todas las filas, no solo la fila con el dato malo). Cambiado a `first()` + guard explícito que omite solo el punto de suministro / contacto de esa fila y continúa con el resto. Se añadió `$data['_nFila']` al pasar de Fase 1 a Fase 2 para poder referenciar la fila en los nuevos mensajes de error.
- [x] T030 [P2] [US2] `??` en `$cup['tarifa']`/`$cup['consumo_kw']`; `Auth::user()->id` → `auth()->id()` — `app/Traits/ContratoTraits.php` (8 ocurrencias de `Auth::user()->id`, no solo la citada por el plan; también se eliminó el `use Illuminate\Support\Facades\Auth;` que quedó sin uso).
- [ ] T031 [US2] Prueba manual: forzar una respuesta interna nula/inválida en el guardado de un CUPS → mensaje genérico al usuario, detalle técnico completo en el log — **pendiente, requiere entorno con datos reales**

---

## FASE 4 — Grupo E: Puntos de Suministro y CUPS (US4) 🟡 Riesgo Medio

- [x] T032 [P1] [US4] Añadir `max:` donde no existe ninguno: `NomViaPunSum`(100), Bloque(4), Escalera(5), Planta(5), Puerta(5), `RefCasPunSum`(20), `ObsPunSum`(200) — `app/Http/Requests/SavePuntoSuministroRequest.php`
- [x] T033 [P1] [US4] Corregir `cupsElectrico.*.cups`(24) y `cupsGas.*.cups`(20), hoy `max:50` — en ambas variantes camelCase y snake_case (`cupsElectrico`/`cups_electrico`, `cupsGas`/`cups_gas`), 4 reglas en total, no 2
- [x] T034 [P1] [US4] Espejar `maxLength` — `EditarPunto.tsx` + 2 modales del Dashboard. **Hallazgo**: ambos modales ya tenían `maxLength={3}` en Bloque/Escalera/Planta/Puerta, pero el límite real de BD es 4/5/5/5 — estaban *más restrictivos* de lo debido (bloqueaban valores válidos). Corregido a los valores reales. `RefCasPunSum` no tiene ningún input en ninguno de los 3 formularios (solo existe en el estado JS) — no aplica `maxLength`.
- [x] T035 [P1] [US4] Crear `StoreCupsElectricoRequest.php` / `StoreCupsGasRequest.php`. **Open Question resuelta**: el informe confirma `CodPunSum int NOT NULL` en la BD real → `required|exists:T_PuntoSuministro,CodPunSum` no rompe ningún caso legítimo (la BD ya lo exige hoy, solo que como `QueryException` en vez de validación). CUPS gas: `regex:/^ES\d{16}[A-Z]{2}$/` (sin el sufijo opcional de 0-2 dígitos que sí tiene el eléctrico, porque `varchar(20)` no deja espacio para él: 2+16+2=20 exacto).
- [x] T036 [P1] [US4] Sustituir el `Request` plano — `CupsController.php::store/update` ahora llama `$request->validate(...)` con las reglas del `FormRequest` correspondiente según `tipo` **antes** de delegar en `CupsTraits` (no se tocó `CupsTraits.php`: las validaciones ya cortan el flujo antes de llegar al trait, que sigue recibiendo `$request` sin cambios).
- [x] T037 [P1] [US4] `required` + `maxLength` + patrón CUPS (HTML5 `pattern`) en `EditarCups.tsx`, más un guard en `handleSubmit` que bloquea el envío si no se seleccionó `CodPunSum` (el Typeahead no soporta bien `required` nativo).
- [ ] T038 [US4] Prueba manual: CUPS eléctrico sin código → rechazado en el navegador; `CodPunSum` inexistente → rechazado por el backend — **pendiente, requiere entorno con datos reales**

---

## FASE 5 — Grupo D: Clientes, Contactos y Cuentas Bancarias (US3) 🟢 Riesgo Bajo

- [x] T039 [P1] [US3] Ajustar `max:` a la columna real (`RazSocCli` 50, `NomComCli` 50, `TelFijCli` 14, `TelMovCli` 14, `EmaCliOpc` 70, `WebCli` 50, `ObsCli` 200, `NomViaDomFis` 100, `BloDomFis` 3, `EscDomFis` 10, `PlaDomFis` 10, `DireccionBBDD` 150) — `app/Http/Requests/UpdateClienteRequest.php`. Se dejaron sin tocar `EmaCli`/`PueDomFis`/`CPLocFis` por no estar en la lista del hallazgo (ya alineados o más estrictos que la BD, sin riesgo).
- [x] T040 [P1] [US3] Espejar `maxLength` — `EditarCliente.tsx` + `EditarClienteModal.tsx`. **Hallazgo**: en `EditarClienteModal.tsx`, `EscDomFis`/`PlaDomFis` tenían `maxLength={3}` (real: 10) — igual que en Fase 4, más restrictivos de lo debido; corregido. `BloDomFis` ya estaba correcto en ambos formularios (3).
- [x] T041 [P2] [US3] Corregir `isFormValid` — añadido `requiredFields` (alineado con `UpdateClienteRequest::rules()`) + `useEffect` que recalcula en cada cambio de `data`, en vez de quedar fijo en `true`.
- [x] T042 [P1] [US3] Ajustar `max:` — `StoreContactoRequest.php` / `UpdateContactoRequest.php`, ambos idénticos.
- [x] T043 [P1] [US3] Espejar `maxLength` — `CrearContacto.tsx` (componente compartido: `EditarContacto.tsx` es un wrapper delgado sobre él, así que un solo cambio cubre alta y edición) + los 2 modales del Dashboard. **Hallazgo**: `CrearContactoModal.tsx`/`EditarContactoModal.tsx` ya tenían `maxLength={9}` correcto en teléfonos pero `maxLength={20}` en `NIFConCli` (real: 10) — corregido.
- [x] T044 [P1] [US3] Unificado: `handleGuardar` en `CrearContacto.tsx` ahora llama a `api.data.iban.validate` (mismo endpoint que `EditarCuentaBancariaModal.tsx`) antes de enviar, si hay IBAN; si el endpoint falla, no bloquea el guardado (el backend igual valida longitud). Tope bajado de 34→26 en frontend y backend (coincide con `T_ContactoCliente.iban varchar(26)`).
- [ ] T045 [US3] Prueba manual: >200 caracteres en observaciones de cliente → bloqueado antes de enviar; IBAN UE de 27+ caracteres → rechazado con mensaje de formato — **pendiente, requiere entorno con datos reales**

---

## FASE 6 — Grupo F: Contratos (US5) 🟡 Riesgo Medio

- [x] T046 [P2] [US5] Añadir `max:`/tipo — **Hallazgo de arquitectura**: solo existen 2 `FormRequest` de alta, no 3. Las rutas `save-contrato` (UniClienteUniPunto) y `save-contrato-multi-punto` (UniClienteMultiPunto) apuntan **al mismo** `ContratosController::storeUnicoClienteMultiPunto` y por tanto comparten `SaveContratoUniClienteMultiPuntoRequest`; `save-contrato-multi-cliente-multi-punto` usa `SaveContratoMultiClienteMultiPuntoRequest`. Añadido `max:50`/`max:9` a `razon_social`/`documento_fiscal`, y por fila (`electrico.*`/`gas.*`) `comentarios`/`comentarioGas` (max:5000, `T_Propuesta_Comercial_CUPs.ObsCup`), `corpoGo` (`nullable|numeric`, antes string libre sobre columna `decimal(26,2)`), `Id_Tarifa_Automatica`(100)/`nombretarifa`(100)/`productCode`(50)/`version`(50) — en ambos `FormRequest`.
- [x] T047 [P2] [US5] `after:fechaInicio` en `fechaFin` — **Hallazgo**: `SaveContratoMultiClienteMultiPuntoRequest` no tenía `fechaInicio`/`fechaFin` en `rules()` en absoluto (solo en `messages()`, código muerto) — no es que le faltara el `after`, es que no validaba la fecha para nada. Verificado antes de añadir `required` que el frontend (`useValidateCupsBeforeSave` en `useContratoMultiClienteMultiPunto.ts`) ya exige ambos campos por fila, así que no bloquea envíos que hoy pasan.
- [x] T048 [P2] [US5] `maxLength` en los `Form.Control` de comentarios libres: `CupsElectricSectionUnipunto.tsx`/`CupsGasSectionUnipunto.tsx` (UniClienteUniPunto) y `ReferenciasCodigo/CupsElectricSection.tsx`/`CupsGasSection.tsx` (compartidos por UniClienteMultiPunto). `razon_social`/`documento_fiscal` se dejaron sin tocar: se rellenan desde un Typeahead de clientes existentes, no texto libre — el backend ya los valida en el submit. MultiClienteMultiPunto no renderiza `comentarios` como campo de texto en sus tablas; no se encontró dónde aplicar el `maxLength` ahí.
- [x] T049 [P2] [US5] `max:100/100/50/50` en `Id_Tarifa_Automatica`/`nombretarifa`/`productCode`/`version` — `ContratosController::updatePropuestaComercialCups`.
- [x] T050 [P3] [US5] `maxLength` preventivo — `UploadDocumentoModal.tsx` (Observaciones, 200) + `promptComercialCanal.ts` (`maxlength="50"` en los 2 `<input>` del SweetAlert2, origen real de los prompts `comercial`/`canal`).
- [ ] T051 [US5] Prueba manual: `fechaFin` anterior a `fechaInicio` → rechazado de forma consistente en los 3 flujos de alta — **pendiente, requiere entorno con datos reales**

---

## FASE 7 — Grupo G: Comercializadoras, Productos, Anexos de Producto y Carga Masiva (US6) 🟡 Riesgo Medio

- [x] T052 [P1] [US6] Ajustar `max:` en prácticamente todos los campos — `UpdateComercializadoraRequest.php`. Confirmado el caso extremo real: `EscDirCom`/`PlaDirCom` eran `max:50` sobre `varchar(2)` (25×). También corregidos `RazSocCom`(50), `NomComCom`(50), `NumCifCom`(10), `NomViaDirCom`(30), `NumViaDirCom`(3), `BloDirCom`(3), `PueDirCom`(4), `TelFijCom`(9), `EmaCom`(50), `PagWebCom`(50), `NomConCom`/`CarConCom`(50), `ObsCom`(200, antes sin `max`).
- [x] T053 [P1] [US6] Espejar `maxLength` — `EditarComercializadora.tsx`. No tenía ningún `maxLength` en ningún campo. `Bloque/Escalera/Planta/Puerta` no se renderizan en este formulario (solo se tocan por API/carga masiva), así que no aplica ahí.
- [x] T054 [P1] [US6] `CodigoServicio` `max:2` (antes 50 en Store, sin `max` en Update) y `ObsPro` `max:200` (antes 1000) — `StoreProductoRequest.php` / `UpdateProductoRequest.php`.
- [x] T055 [P1] [US6] `ObsAnePro` `max:200` (antes 1000, ambos) y `DocAnePro` en `update` `max:255` (antes 500) — `StoreAnexoProductoRequest.php` / `UpdateAnexoProductoRequest.php`.
- [x] T056 [P1] [US6] Espejar `maxLength` — **Hallazgo**: existen dos archivos `EditarProducto.tsx` (`Eneon/Productos/` y `Eneon/Comerciales/Productos/`); solo el segundo está enrutado por `ProductoController` — se editó únicamente ese, el otro parece código muerto. `EditarAnexoProducto.tsx` también actualizado (`DocAnePro` 255, `ObsAnePro` 200).
- [x] T057/T058 [P1] [US6] `CargaGlobalAudax.php`: nuevo helper `validarLongitudCampo()` en Fase 1, aplicado a los campos realmente presentes en el Excel activo (`nif_cliente`, `razon_social`, `direccion_cliente`, `cups_luz`, `cups_gas`, `direccion_cups`, `escalera_cups`, `puerta_cups`, `pisodire_cups`, `nif_contacto`, `nombreApellidos_contacto`, `cargo_contacto`, `telefono_contacto`, IBAN compuesto). **Hallazgo**: la columna "Bloque" que cita el plan no existe en el listado activo de columnas del Excel (`$columnasEsperadas`, L293-295) — ni para cliente ni para CUPS; solo existe en un array de columnas comentado/no usado. El reporte de filas rechazadas ya lo entrega gratis el mecanismo existente de Fase 1 (`$erroresGlobales`/`$erroresExcel` + `$datosValidados`), que ya separaba filas válidas de inválidas antes de esta tarea — no hizo falta construir uno nuevo.
- [ ] T059 [US6] Prueba manual: Excel con una fila con "Bajo Izquierda" en Puerta (>5 car.) mezclada con filas válidas → válidas guardadas, inválida reportada individualmente — **pendiente, requiere entorno con datos reales**

---

## FASE 8 — Grupo C: Relaciones de datos del dominio (US7) 🟠 Riesgo Moderado

> Ver advertencia en `plan.md` § User Review Required antes de ejecutar T062 y T063.

- [x] T060 [P2] [US7] Corregir `contacto()` — `PropuestaComercialCliente.php`. **Hallazgo más grave de esta fase**: no era solo una FK incorrecta, sino un vínculo entre dos dominios de ID sin relación (`CodCli` de cliente vs `CodConCli`, PK propia de contacto). Redefinido como `hasOneThrough(Contacto::class, ContactoDetalleCliente::class, ...)` filtrando `EsRepLeg=1`, recuperando la lógica ya esbozada (y comentada) por un desarrollador anterior. Además corregido `contactoContrato($CodCli)`, un método hermano que hacía `Contacto::where('CodConCli', $CodCli)->where('EsRepLeg', 1)` — `EsRepLeg` no existe en absoluto en `T_ContactoCliente` (vive en `T_ContactoDetalleCliente`), así que esa línea lanzaba `QueryException` (columna inexistente) el 100% de las veces que se invocaba. Es una llamada real y activa desde `AnexoGenerarContratoDual.php:348`. Auditoría de impacto: `->contacto` se usa en 10 archivos; dado que la relación antigua solo podía devolver `null` o un contacto por coincidencia numérica accidental de ID, el cambio solo puede *añadir* datos correctos donde antes había `null`, nunca corromper un dato que ya fuera correcto — riesgo asimétrico a favor del cambio.
- [x] T061 [P2] [US7] `provincia()` — `PuntoSuministro.php`. Era literalmente idéntica a `localidad()` (`belongsTo(Localidad::class, 'CodLoc', 'CodLoc')` en ambas) — no apuntaba a la tabla equivocada, solo estaba mal nombrada (devolvía una `Localidad`, no una `Provincia`). Redefinida como `hasOneThrough(Provincia::class, Localidad::class, ...)` para que devuelva la Provincia real. Auditoría: el único uso en todo el código es un eager-load (`'...puntoSuministro.provincia'` en `AnexoProductoTraits.php`) cuyo resultado **nunca se lee** — riesgo de regresión nulo.
- [x] T062 [P2] [US7] `comercializadora()` de `hasOne` a `belongsTo` — `PropuestaComercial.php`. **Open Question del plan resuelta**: al tener la FK (`CodCom`) y la clave del lado relacionado el mismo nombre, `hasOne(..., 'CodCom', 'CodCom')` y `belongsTo(..., 'CodCom', 'CodCom')` generan el SQL **idéntico** — no hay ningún reporte/export que pueda depender de una diferencia de resultados porque no existe tal diferencia en lectura. Verificado además que ningún sitio del código usa `->comercializadora()->save/create/associate/dissociate()` (los únicos casos donde `hasOne` vs `belongsTo` sí se comportan distinto). Cambio de riesgo prácticamente nulo, aplicado.
- [x] T063 [P2] [US7] `?->` sistemático en `comercializadora`/`producto`/`anexoProducto` de `PropuestaComercialCups` — corregidas ~20 ocurrencias en 8 archivos (`AnexoProductoTraits`, `IntegracionTraits`, `AuxiliarCondicionesParticularesTraits`, `AuxiliarCondicionesGeneralesTraits`, `AuxiliarAnexoProductoTraitsLegacy`, `AuxiliarAnexoProductoTraits`, `AnexoGenerarContratoDual`, `AnexoCambioPotenciaTitularTraits`). Todas ya tenían `?? $fallback` inmediatamente después, así que no eran crashes — solo warnings de PHP8 por acceder a propiedad de `null` (ruido de log en ~48% de los casos según el informe). Se extendió el mismo tratamiento a `tarifaElectrica`/`tarifaGas` (mismo patrón, mismo modelo, no citados explícitamente por el plan pero encontrados al lado de cada corrección).
- [x] T064 [P3] [US7] Casts booleanos que "enmascaran null como false" (`EstRen`, `RenMod`, `servicioBasico`, `servicioPremium`) — **investigado a fondo, no requiere cambio**: se leyó el código fuente real de `Illuminate\Database\Eloquent\Concerns\HasAttributes::castAttribute()` (Laravel 10, la versión de este proyecto) y se confirmó que `'boolean'` está en `$primitiveCastTypes`, para el cual el método devuelve `null` sin tocarlo *antes* de aplicar `(bool)` — es decir, en esta versión de Laravel el cast `boolean` **ya preserva `null`** en vez de convertirlo a `false`. El hallazgo del informe describe un comportamiento de versiones antiguas de Laravel que no aplica aquí. No se tocó ningún archivo.
- [ ] T065 [US7] Prueba de regresión de PDFs/anexos y listados que dependan de estas relaciones — **pendiente, requiere entorno con datos reales**. Dado que T060/T061/T062 tocan relaciones muy usadas, esta prueba manual es la más importante de verificar antes de fusionar a `develop_eneon`.

---

## FASE 9 — Grupo H: Infraestructura preventiva (US8) 🟢 Riesgo Bajo

- [x] T066 [P3] [US8] Comando `php artisan validate:column-lengths` — `app/Console/Commands/VerifyFormRequestColumnLengths.php`. No es introspección 100% automática de reflexión: cada `FormRequest` se registra con un mapa explícito campo→[tabla, columna] (parseando `rules()` por texto vía regex, no ejecutando la clase, para no depender de contexto HTTP/ruta que varios `FormRequest` de este proyecto sí usan en `prepareForValidation()`). Cubre los 12 `FormRequest` tocados por esta feature. Verificado que el comando se registra correctamente en `artisan list` y que llega hasta emitir la consulta SQL contra `information_schema.columns` (confirmado con un intento de conexión real que falló solo por falta de MySQL en este entorno, no por un error del comando).
- [ ] T067 [P3] [US8] Ejecutar el comando y corregir discrepancias — **no ejecutable en este entorno**: no hay conexión MySQL disponible (mismo problema que ya advertía `PLAN_BLINDAJE_BACKEND.md` sobre el informe original). Pendiente de ejecutarse contra la BD real `eneon`.
- [x] T068 [US8] Actualizado `INFORME_ANALISIS_NULLPOINTER_BACKEND.md` e `INFORME_ANALISIS_VALIDACIONES_FRONTEND.md` con una nota de estado que remite a este `tasks.md` como registro detallado de resolución, en vez de anotar hallazgo por hallazgo dentro de los informes originales (más de 300 líneas cada uno).

---

## Verificación por historia de usuario

- [ ] V01 [US1] `find()`/`load()` con `propuestaId` inválido en los 4 tipos de anexo → error de negocio, no interrupción no controlada
- [ ] V02 [US1] Propuesta real sin cliente asociado → tramitación se detiene con mensaje claro, sin afectar otras tramitaciones
- [ ] V03 [US2] Respuesta interna nula/inválida forzada en un guardado → mensaje genérico al usuario, detalle técnico completo en el log
- [ ] V04 [US3] Observaciones de cliente >200 caracteres → bloqueado en el navegador antes de enviar
- [ ] V05 [US3] IBAN de contacto con formato UE de 27+ caracteres → rechazado con mensaje de formato, no error 500
- [ ] V06 [US4] CUPS sin código → rechazado en el navegador; `CodPunSum` inexistente → rechazado por el backend
- [ ] V07 [US5] `fechaFin` anterior a `fechaInicio` → rechazado de forma consistente en los 3 flujos de alta de contrato
- [ ] V08 [US6] Carga masiva con una fila inválida mezclada con válidas → las válidas se guardan, la inválida se reporta individualmente al final
- [ ] V09 [US7] Contacto de un cliente con contacto real registrado y provincia/localidad de un punto de suministro conocido → dato correcto en ambos casos
- [ ] V10 [US8] `php artisan validate:column-lengths` tras completar Fases 4-7 → cero discrepancias reportadas
