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

**Feature Branch**: `013-blindaje-backend-integridad-datos`

**Created**: 2026-09-18

**Status**: Draft

**Input**: `spec.md` + `INFORME_ANALISIS_NULLPOINTER_BACKEND.md` + `INFORME_ANALISIS_VALIDACIONES_FRONTEND.md` + `PLAN_BLINDAJE_BACKEND.md` (verificados línea por línea contra el código el 18 sep 2026)

> **Constitution (II. Skinny Controllers, III. Contratos e Integraciones, IV. Inertia.js Frontera)**:
> Este plan define CÓMO se implementarán los requisitos del spec en Laravel (Controllers, Traits,
> Services, Models) y React/Inertia (FormRequest ↔ `maxLength` espejo). Toda lógica de guard y
> validación va en `app/Traits/` o `app/Http/Requests/` existentes — no se introducen controladores
> nuevos ni se rompe el límite de ~15 líneas por acción.

---

## Resumen técnico

Dos frentes que comparten la misma causa raíz (el sistema no verifica que un dato tiene la forma
esperada antes de usarlo) y se corrigen con el mismo tipo de intervención — un *guard* explícito
justo después del punto donde el dato se obtiene:

1. **Null-safety en Traits/Controllers/Services** (US1, US2, US7): mover comprobaciones `if (!$x)`
   a la posición correcta (antes del primer uso, no después), y sustituir accesos a arrays externos
   sin verificar (`$data->success`, `$serviciosDisponibles[$servicio]`) por comprobaciones explícitas.
2. **Alineación de límites de longitud** (US3–US6): hacer que `FormRequest` (backend) y `maxLength`
   (frontend) reflejen el límite real de la columna de destino, y cerrar dos rutas de escritura sin
   ninguna validación (`CupsTraits`, `CargaGlobalAudax`).
3. **Infraestructura preventiva** (US8): un comando Artisan de verificación cruzada para que este
   problema no vuelva a crecer sin detección.

No hay cambios de esquema de base de datos ni migraciones nuevas — todas las columnas ya existen
con su longitud real documentada en los informes de origen.

---

## User Review Required

> [!WARNING]
> **Grupo C (US7 — relaciones Eloquent):** cambiar `PropuestaComercial::comercializadora()` de
> `hasOne` a `belongsTo`, y corregir la FK de `PropuestaComercialCliente::contacto()`, puede alterar
> el resultado de queries que hoy dependen (aunque sea incorrectamente) del comportamiento actual.
> Requiere auditar todos los usos existentes antes de aplicar el cambio y probar en un entorno con
> datos reales antes de mezclar a `develop_eneon`.

> [!WARNING]
> **Grupo B (US2 — guard centralizado en Controllers):** introducir un único helper para el patrón
> `handleTraitRedirect`/`processTraitResponse` toca 8 controladores distintos. Se recomienda
> implementarlo primero en un controlador (`CupsController`, ya es el de mayor riesgo) y validarlo
> en staging antes de replicarlo al resto.

> [!CAUTION]
> **Grupo E (US4 — nuevo `FormRequest` para CUPS):** hoy `CupsController`/`CupsTraits` no validan
> nada; añadir `required`/`exists:` puede bloquear un flujo de integración externo que hoy depende
> de poder guardar datos parciales. Confirmar con negocio si existe algún caso legítimo de guardado
> parcial antes de endurecer la regla a `required`.

---

## Open Questions

- ¿Existe algún caso de negocio legítimo donde se guarda un CUPS sin `CodPunSum` (punto de
  suministro) de forma intencional? Condiciona si la regla `exists:T_PuntoSuministro,CodPunSum`
  puede ser `required` o debe admitir `nullable`.
- ¿El catálogo de tarifas externo (`Id_Tarifa_Automatica`, `productCode`, `version`) puede en algún
  caso legítimo superar 100/50 caracteres? Si es así, el límite de columna debería ampliarse en vez
  de solo validarse en el punto de entrada.
- Para el Grupo C (relaciones Eloquent): ¿hay algún reporte o export existente que dependa
  explícitamente de que `PropuestaComercial::comercializadora()` sea `hasOne` (por ejemplo, algún
  `with()` que asuma una colección en vez de un modelo único)? Auditar antes de tocar.

---

## Proposed Changes

### Grupo A (US1) — Anexos y documentos no fallan por datos incompletos

#### [MODIFY] `app/Traits/auxiliares/AnexoCambioPotenciaTitularTraits.php` (L37-59)

**Problema:** `PropuestaComercial::find($propuestaId)` seguido de `->load([...])` antes del
`if (!$propuesta)` — el guard existe pero es inalcanzable.

**Cambio:**

```diff
  $propuesta = PropuestaComercial::find($propuestaId);
+ if (!$propuesta) {
+     // manejar error de negocio: propuesta no encontrada
+     return $this->respuestaError('Propuesta comercial no encontrada.');
+ }
  $propuesta->load([
      'propuestaComercialCliente',
      'comercializadora',
      // ...
  ]);
- if (!$propuesta) {
-     // inalcanzable: el crash ya ocurrió en el ->load() anterior
- }
```

#### [MODIFY] `app/Traits/auxiliares/AnexoGenerarContratoDual.php` (L37-39)

Mismo patrón exacto, no incluido en el informe original — detectado en la verificación de código
del 18 sep 2026. Mismo fix que el archivo anterior.

#### [MODIFY] `app/Traits/AnexoProductoTraits.php`

Mismo patrón `find()`+`load()` sin guard en **6 puntos**: L559-573, 727, 830, 961 (citados en el
informe original) más **L1205 y L1573** (detectados en la verificación). Aplicar el mismo guard en
los 6 puntos.

Además, cadenas de acceso `->a->b->c` sin operador null-safe en L1352
(`$cup->puntoSuministro->cliente->NomComCli`), L1675 (`$cup->puntoSuministro->cupsGas[0]->CupsGas`)
y L1693-1694 — sustituir cada eslabón por `?->`.

#### [MODIFY] `app/Traits/auxiliares/AnexoCambioPotenciaTitularTraits.php` (~L451, 708-727)

Mismas cadenas sin `?->` que en el archivo anterior — mismo tratamiento.

#### [MODIFY] `app/Traits/IntegracionTraits.php` (L511-521, 632, 714-716, 737-744)

**Problema:** `$propuesta->propuestaComercialCliente[0]` sin comprobar que la colección no esté
vacía.

**Cambio:**

```diff
- $propuestaCliente = $propuesta->propuestaComercialCliente[0];
+ if ($propuesta->propuestaComercialCliente->isEmpty()) {
+     return $this->respuestaError('La propuesta no tiene ningún cliente asociado.');
+ }
+ $propuestaCliente = $propuesta->propuestaComercialCliente->first();
```

Reforzar los accesos relacionados en L632, 714-716, 737-744 con el mismo patrón de guard temprano.

#### [MODIFY] `app/Http/Controllers/OrderController.php` (módulo ecommerce legacy)

`Orders::find($request->id)->update(...)` sin comprobar `null` (hoy mitigado solo por un `catch`
genérico). Añadir guard explícito tras el `find()`.

#### [MODIFY] `app/Traits/PdfFormFillable.php` (~L818-823, 704-712)

Añadir `isset()` en la lectura de configuración de plantillas para los tipos de anexo hoy no
cubiertos.

---

### Grupo B (US2) — La integración no falla en cascada ni expone errores internos

#### [NEW] Guard centralizado para el patrón `handleTraitRedirect`/`processTraitResponse`

**Problema:** en `CupsController` (L61-78) y en `ClientesController`, `ComercializadoraController`,
`ProductoController`, `AnexoProductoController`, `UploadFileController`, `PuntoSuministroController`,
`ContactosController`, se hace `$data = $this->processTraitResponse($response); if ($data->success)`
sin comprobar que `$data` no sea `null` (ocurre si `json_decode()` recibe un JSON inválido).

**Cambio:** añadir un método de guard reutilizable en el trait/base común de estos controllers:

```php
protected function guardTraitResponse($data): ?JsonResponse
{
    if (!$data || !($data->success ?? false)) {
        return response()->json([
            'success' => false,
            'message' => $data->message ?? 'Error al procesar la solicitud.',
        ], 422);
    }
    return null;
}
```

Aplicar en `CupsController` primero (mayor riesgo), validar en staging, luego replicar en los 7
controllers restantes.

#### [MODIFY] `app/Http/Controllers/ContratosController.php` (`okCommercialContract`, ~L394-400)

Guard tras `find()` — reclasificado de P0 a P1 en la verificación: hoy el único acceso entre el
`find()` y el guard es una lectura de propiedad protegida con `??` (warning, no fatal en PHP 8),
pero conviene corregirlo por consistencia con el resto del guard centralizado.

#### [MODIFY] `app/Services/AudaxEnergyService.php` (L41-42) y `app/Traits/IntegracionTraits.php` (~L944)

**Problema:** `$this->serviciosDisponibles[$servicio]['url']` sin validar que `$servicio` sea una
clave conocida (`audax`/`adx`).

**Cambio:** `if (!isset($this->serviciosDisponibles[$servicio])) { throw new \InvalidArgumentException("Servicio de integración desconocido: {$servicio}"); }` antes del acceso.

#### [MODIFY] `app/Jobs/ProcesarCargaGlobalAudaxJob.php`

`$body['data']['error_file_url'] ?? null` no protege si `data` existe pero es explícitamente
`null`. Sustituir por `data_get($body, 'data.error_file_url')`.

#### [MODIFY] `app/Services/AdxTarifasService.php` (`mapRequestPayload`, ~L137-140)

Corregir el orden de acceso: `$requestData['tipo_contrato'] ?? null` antes de aplicar `?:`.

#### [MODIFY] `app/Http/Controllers/ContratosController.php`, `IntegracionController.php`,
`app/Http/Controllers/Api/DataController.php`, `app/Http/Controllers/UsersController.php`

`auth()->user()->name` / `->tenant_id` → `auth()->user()?->name`, o preferiblemente `auth()->id()`
donde solo se necesita el id.

#### [MODIFY] `app/Traits/CargaGlobalAudax.php` (~L755-814, 813-856, 958-967)

No tragar la excepción de `create()` de `PuntoSuministro` (hoy el `catch` la ignora y el código
sigue usando `$direccionCups` como si existiera); comprobar `first()` de tarifa antes de usarlo;
asegurar que la resolución de código postal/tipo de vía siempre asigna un valor antes de continuar.

#### [MODIFY] `app/Traits/ContratoTraits.php` (~L279-280)

`$cup['tarifa']`, `$cup['consumo_kw']` → `$cup['tarifa'] ?? null`; `Auth::user()->id` → `auth()->id()`.

#### [MODIFY] `app/Traits/CupsTraits.php` (`catch (\Throwable $e)` en L801, 855, 894, 929)

**Problema:** los `catch` devuelven `'Error interno del servidor: '.$e->getMessage()` al cliente,
exponiendo el mensaje interno de la excepción (incluye detalle de esquema de BD en errores de
`QueryException`).

**Cambio:**

```diff
  } catch (\Throwable $e) {
-     return response()->json(['success' => false, 'message' => 'Error interno del servidor: '.$e->getMessage()], 500);
+     Log::error('Error al guardar CUPS', ['exception' => $e, 'request' => $request->all()]);
+     return response()->json(['success' => false, 'message' => 'No se pudo guardar el CUPS. Verifique los datos e inténtelo de nuevo.'], 500);
  }
```

Aplicar el mismo tratamiento a cualquier otro `catch (\Throwable $e)` con `$e->getMessage()`
expuesto al cliente que se encuentre durante la implementación (búsqueda global recomendada como
primer paso de este grupo).

---

### Grupo C (US7) — Relaciones de datos del dominio

> Ver advertencia en **User Review Required** — auditar usos existentes antes de aplicar.

#### [MODIFY] `app/Models/PropuestaComercialCliente.php` (`contacto()`, L35)

`belongsTo(Contacto::class, 'CodCli', 'CodConCli')` compara dominios de identificador distintos.
Corregir la FK a la columna real de vínculo entre `T_Propuesta_Comercial_Clientes` y `T_ContactoCliente`.

#### [MODIFY] `app/Models/PuntoSuministro.php` (`provincia()`, L50-54)

Renombrar a `localidad()` (o documentar explícitamente que apunta a `Localidad`, no a un modelo
`Provincia`) — `belongsTo(Localidad::class, 'CodLoc', 'CodLoc')`.

#### [MODIFY] `app/Models/PropuestaComercial.php` (`comercializadora()`, L68-71)

Cambiar de `hasOne` a `belongsTo(Comercializadora::class, 'CodCom', 'CodCom')` — semánticamente
correcto dado que la FK vive en el propio modelo `PropuestaComercial`.

#### [MODIFY] `app/Models/PropuestaComercialCups.php`

Añadir `?->` en todos los usos de `comercializadora`, `producto`, `anexoProducto` detectados
durante Grupo A/B (relaciones frecuentemente `null`, confirmado por el análisis original).

#### [MODIFY] Casts booleanos que enmascaran `null`

Revisar `EstRen`, `RenMod`, `servicioBasico`, `servicioPremium` y campos equivalentes — documentar
la decisión de negocio (¿"sin dato" debe seguir tratándose como `false`?) o migrar a un cast
`nullable boolean` explícito donde la distinción importe.

---

### Grupo D (US3) — Clientes, Contactos y Cuentas Bancarias

#### [MODIFY] `app/Http/Requests/UpdateClienteRequest.php`

Ajustar `max:` a la columna real: `RazSocCli`→50, `NomComCli`→50, `TelFijCli`→14, `TelMovCli`→14,
`EmaCliOpc`→70, `WebCli`→50, `ObsCli`→200 (hoy sin `max`), `NomViaDomFis`→100, `BloDomFis`→3,
`EscDomFis`→10, `PlaDomFis`→10, `DireccionBBDD`→150.

#### [MODIFY] `resources/js/Pages/Eneon/Clientes/EditarCliente.tsx` + `.../Dashboard/Editar/EditarClienteModal.tsx`

Espejar `maxLength` en cada `Form.Control` afectado. Corregir `isFormValid` (L37, fijo en `true` y
nunca recalculado) para que refleje el resultado real de validación antes de habilitar "Guardar".

#### [MODIFY] `app/Http/Requests/StoreContactoRequest.php` / `UpdateContactoRequest.php`

Ajustar `max:`: `NomConCli`→50, `CarConCli`→50, `TelFijConCli`/`TelCelConCli`→9,
`NIFConCli`→10, `nrcomercialacreditado(adx)`→5, `iban`→26 con la misma regla de formato que
`T_CuentaBancaria.NumIBan`.

#### [MODIFY] `resources/js/Pages/Eneon/Contactos/CrearContacto.tsx` / `EditarContacto.tsx` (+ modal del Dashboard)

Espejar `maxLength`; unificar la validación de `iban` con el endpoint `api.data.iban.validate` ya
usado en Cuentas Bancarias. `EditarContacto.tsx` no tiene ningún `maxLength` hoy — mayor prioridad
dentro de este grupo.

#### [MODIFY] `app/Http/Requests/UpdateCuentaBancariaRequest.php`

`ObserCuenBan`: `max:255` → `max:100`.

---

### Grupo E (US4) — Puntos de Suministro y CUPS

#### [MODIFY] `app/Http/Requests/SavePuntoSuministroRequest.php`

Añadir `max:` donde no existe ninguno: `NomViaPunSum`→100, Bloque→4, Escalera→5, Planta→5,
Puerta→5, `RefCasPunSum`→20, `ObsPunSum`→200. Corregir `cupsElectrico.*.cups`/`cupsGas.*.cups` de
`max:50` a 24/20 respectivamente (L49, L76).

#### [MODIFY] `resources/js/Pages/Eneon/PuntosSuministro/EditarPunto.tsx` + 2 modales del Dashboard

Espejar `maxLength` en todos los campos listados arriba.

#### [NEW] `app/Http/Requests/StoreCupsElectricoRequest.php` / `StoreCupsGasRequest.php`

**Problema:** `CupsController::store/update` (L236-273) delega en `CupsTraits::storeCupsElectricoTrait`/
`storeCupsGasTrait`/`updateCupsElectricoTrait`/`updateCupsGasTrait` (L756-937), que reciben `$request`
plano y hacen `$request->only([...])->fill()->save()` sin ninguna validación — confirmado en la
verificación de código (única ruta de escritura del sistema sin ninguna regla).

**Reglas mínimas:**

```php
'CUPsEle' => ['required', 'string', 'max:24', 'regex:/^ES\d{16}[A-Z]{2}\d{0,2}$/'],
'CodPunSum' => ['required', 'exists:T_PuntoSuministro,CodPunSum'], // ver Open Questions sobre nullable
```

Equivalente para `CupsGas` (`max:20`).

#### [MODIFY] `app/Http/Controllers/CupsController.php` + `app/Traits/CupsTraits.php`

Sustituir el `Request` plano por los nuevos `FormRequest` en `store`/`update`.

#### [MODIFY] `resources/js/Pages/Eneon/Cups/EditarCups.tsx`

Añadir `required` + `maxLength` + patrón de CUPS en el frontend (hoy: cero validación, confirmado
en la verificación).

---

### Grupo F (US5) — Contratos

#### [MODIFY] `app/Http/Requests/SaveContratoUniClienteMultiPuntoRequest.php` / `SaveContratoMultiClienteMultiPuntoRequest.php`

Añadir `max:`/tipo a `documento_fiscal`, `razon_social`, `comentarios`/`comentarioGas`, `corpoGo`
(validar numérico), campos de tarifario. Añadir `after:fechaInicio` a `fechaFin` en los flujos
multipunto/multicliente (hoy solo cubierto en unipunto).

#### [MODIFY] `resources/js/Pages/Eneon/Contratos/UniClienteUniPunto/`, `UniClienteMultiPunto/`, `MultiClienteMultiPunto/`

Añadir `maxLength`/`pattern` en los `Form.Control` de los tres flujos — ninguno usa hoy validación
HTML nativa.

#### [MODIFY] `app/Http/Controllers/ContratosController.php` (`updatePropuestaComercialCups`, L296-312)

Añadir `max:` a `Id_Tarifa_Automatica`(100)/`nombretarifa`(100)/`productCode`(50)/`version`(50) en
el `validate()` inline (L303-306) — confirmado sin `max` en la verificación de código.

#### [MODIFY] `resources/js/Pages/Eneon/.../UploadDocumentoModal.tsx` + prompts `comercial`/`canal`

`maxLength` preventivo (riesgo bajo, coste mínimo).

---

### Grupo G (US6) — Comercializadoras, Productos, Anexos de Producto y Carga Masiva

#### [MODIFY] `app/Http/Requests/UpdateComercializadoraRequest.php`

Ajustar `max:` a la columna real en prácticamente todos los campos — caso más extremo del análisis:
`EscDirCom`/`PlaDirCom` de `max:50` a 2.

#### [MODIFY] `resources/js/Pages/Eneon/Comerciales/Comercializadoras/EditarComercializadora.tsx`

Espejar `maxLength`.

#### [MODIFY] `app/Http/Requests/StoreProductoRequest.php` / `UpdateProductoRequest.php`

`CodigoServicio`: BD `varchar(2)`, hoy sin `max` real en `update` — corregir a `max:2`. `ObsPro`:
`max:1000` → `max:200`.

#### [MODIFY] `app/Http/Requests/StoreAnexoProductoRequest.php` / `UpdateAnexoProductoRequest.php`

`ObsAnePro`: `max:1000` → `max:200`. `DocAnePro` en `update`: `max:500` → `max:255`.

#### [MODIFY] `resources/js/Pages/Eneon/Comerciales/Productos/EditarProducto.tsx`, `.../AnexosProducto/EditarAnexoProducto.tsx`

Espejar `maxLength`.

#### [MODIFY] `app/Traits/CargaGlobalAudax.php`

Validar longitud/tipo por fila **antes** del `insert`/`update` (CIF, Bloque/Escalera/Planta/Puerta
son las columnas más cortas y con mayor probabilidad real de overflow en datos de Excel). Devolver
un reporte estructurado de filas rechazadas con su motivo, sin abortar el resto del lote.

#### [MODIFY] `resources/js/Pages/Eneon/Sips/AdxSipsPanel.tsx` / `AudaxSipsPanel.tsx`

`maxLength={22}` en el input de CUPS (módulo de solo lectura, coste mínimo, incluido aquí por
cercanía funcional con la validación de formato de CUPS del Grupo E).

---

### Grupo H (US8) — Infraestructura preventiva

#### [NEW] `app/Console/Commands/VerifyFormRequestColumnLengths.php`

Comando Artisan que recorre las clases `FormRequest` de `app/Http/Requests/`, extrae las reglas
`max:N` declaradas, las compara contra `information_schema.columns.CHARACTER_MAXIMUM_LENGTH` de la
tabla/columna de destino (inferida por convención de nombre o mapeo explícito), y reporta cualquier
discrepancia (`max:` mayor al límite real, o ausencia de `max:` en una columna `varchar`/`char`).

```
php artisan validate:column-lengths [--fix-dry-run]
```

Salida esperada: tabla con `FormRequest | Campo | max: declarado | Límite real BD | Estado`.

---

## Orden de ejecución recomendado

| Orden | Grupo | User Story | Riesgo | Horas ref. (`PLAN_BLINDAJE_BACKEND.md`) |
|---|---|---|---|---|
| 1 | Quick wins transversales (auth `?->`, sanitizar `catch`, ajustes puntuales de `max:`) | US2, US3 | 🟢 Bajo | Fase 0 — 11.5h |
| 2 | Grupo A — Traits de anexos/documentos | US1 | 🟢 Bajo (bug puro, sin cambio de comportamiento observable) | Fase 1 — 27.5h |
| 3 | Grupo B — Guard centralizado + integración | US2 | 🟡 Medio (toca 8 controllers) | Fase 2 — 28h |
| 4 | Grupo E — CUPS y Puntos de Suministro | US4 | 🟡 Medio (nueva validación en ruta hoy sin reglas — ver Open Questions) | Fase 5 — 24h |
| 5 | Grupo D — Clientes/Contactos/Cuentas | US3 | 🟢 Bajo | Fase 4 — 17h |
| 6 | Grupo F — Contratos | US5 | 🟡 Medio | Fase 6 — 18h |
| 7 | Grupo G — Comerciales + Carga Masiva | US6 | 🟡 Medio | Fase 7 — 23h |
| 8 | Grupo C — Relaciones Eloquent | US7 | 🟠 Moderado (mayor superficie de regresión, ver User Review Required) | Fase 3 — 22.5h |
| 9 | Grupo H — Infraestructura preventiva | US8 | 🟢 Bajo | Fase 8 — 20h |

El desglose de horas por actividad individual y el cálculo de totales/calendario ya está en
`PLAN_BLINDAJE_BACKEND.md` (raíz del repo) — este plan no lo repite, solo lo referencia por grupo.

---

## Verification Plan

### Automated Tests

- No hay suite de tests automatizados para estos Traits/Controllers hoy. Si se desea cobertura
  para los casos P0 del Grupo A (recomendado dado que son los de mayor impacto), añadir tests
  Pest/PHPUnit dirigidos: `AnexoCambioPotenciaTitularTraits` y `AnexoGenerarContratoDual` con un
  `propuestaId` inexistente → debe devolver un error de negocio, no lanzar `Error`.
- `./vendor/bin/pint` antes de cerrar cada grupo (estándar del proyecto).

### Manual Verification

1. **Grupo A:** solicitar generación de cada tipo de anexo (potencia, titular, producto, contrato
   dual) con un `propuestaId` inexistente → mensaje de negocio, no error 500. Repetir con la
   propuesta real conocida sin cliente asociado (ver informe original, 1 de 14 286 casos).
2. **Grupo B:** forzar una respuesta interna nula/inválida en el guardado de un CUPS → mensaje
   genérico al usuario; verificar en el log que el detalle técnico completo quedó registrado.
3. **Grupo C:** consultar el contacto de un cliente con contacto real registrado → dato correcto,
   no vacío. Consultar la provincia/localidad de un punto de suministro conocido → dato correcto.
4. **Grupo D:** escribir >200 caracteres en observaciones de cliente → bloqueado en el navegador
   antes de enviar. Probar IBAN de contacto de 27+ caracteres (formato UE válido) → rechazado con
   mensaje de formato, no error 500.
5. **Grupo E:** intentar guardar un CUPS eléctrico sin código → rechazado en el navegador. Intentar
   asociarlo a un `CodPunSum` inexistente → rechazado por el backend con mensaje claro.
6. **Grupo F:** en los 3 flujos de alta de contrato, introducir `fechaFin` anterior a `fechaInicio`
   → rechazado de forma consistente en los tres.
7. **Grupo G:** cargar un Excel de puntos de suministro con una fila con "Bajo Izquierda" en el
   campo Puerta (>5 car.) mezclada con filas válidas → las válidas se guardan, la inválida se
   reporta al final con su motivo.
8. **Grupo H:** ejecutar `php artisan validate:column-lengths` tras completar los Grupos D-G →
   reporta cero discrepancias.
