# Programación de Arribos — Análisis técnico

**Componente:** `App\Http\Livewire\Importaciones\ProgramacionArribos`
**Fecha del análisis:** 2026-10-09
**Rutas:** `GET /impo/arribos` (iframe, `arribos-iframe`) → `GET /impo/programacion-arribos` (contenido, `programacion-arribos`)

## 0. Alcance

| Pieza | Archivo |
|---|---|
| Componente principal | `app/Http/Livewire/Importaciones/ProgramacionArribos.php` |
| Vista principal | `resources/views/livewire/importaciones/programacion-arribos.blade.php` |
| Contenedor / iframe | `resources/views/importaciones/arribos-iframe.blade.php`, `resources/views/importaciones/programacion-arribos.blade.php` |
| Componente hijo (gráficas) | `app/Http/Livewire/Importaciones/GraficosArribos.php` + vista |
| Componente hijo (KPIs) | `app/Http/Livewire/Importaciones/DashboardArribos.php` + vista |
| Detalle por DO (fetch JS) | `app/Http/Controllers/Importaciones/DetalleImportacionController.php` (`GET /detalle-importacion`) |
| Modelos | `Pedido` (websimex.`estados_do`), `PedidoTransporte` (mysql.`po_transporte`), `UnidadesCargaWS` (websimex.`unidades_carga`), `Transportador` (cti.`transportadores`), `ControlContenedoresCIA` (cti.`do_contenedores`), `EstadoHecho`, `DefControlTower`, `DefinicionStatus`, `PendientesOperacion` |

No existe gemelo de exportaciones (`Ex*`) para este módulo. `App\Http\Livewire\Po\ProgramacionArribos` es otro módulo (V1/visibilidad), no comparte código.

---

## 1. Error "413 Content Too Large"

### 1.1 Qué es exactamente

La pantalla del error ("Oops! An Error Occurred / The server returned a "413 Content Too Large"") es el renderizador HTML de Symfony que usa Laravel cuando `APP_DEBUG=false` y no existe la vista `errors/413`. **No es nginx**, cuyo 413 se ve distinto ("413 Request Entity Too Large").

El origen es el middleware global `\Illuminate\Foundation\Http\Middleware\ValidatePostSize` (`app/Http/Kernel.php:18`). Este lanza `PostTooLargeException` (HTTP 413) cuando el `Content-Length` del request supera `post_max_size` de PHP (15M en el entorno local de Herd; validar el valor en producción). Livewire 2 muestra la respuesta de error en el modal superpuesto de la captura.

**Conclusión:** el navegador está enviando en el POST de Livewire (`/livewire/message/importaciones.programacion-arribos`) un cuerpo de más de `post_max_size` bytes.

### 1.2 Por qué el request es tan grande

En Livewire 2, **cada propiedad pública viaja completa en cada request** (`serverMemo.data`), en ambos sentidos. Además, los argumentos de `wire:click="metodo(...)"` viajan en `updates[].payload.params`. El componente comete los siguientes errores, que se suman:

| # | Fuente del peso | Ubicación | Tamaño relativo |
|---|---|---|---|
| A | `public $pedidosNotificacion` guarda el grafo completo de Eloquent: `PedidoTransporte → unidades_carga → pedido → (importadores, importaciones, exportaciones, documentos_tr→5 relaciones, declaracion_impo→3 relaciones, **estadoHecho = historial completo de etapas**)` + `seguimientoContenedor → (citas, cliente, fase8, fase3, cita, cedi, lugarDevolucion, transportador)`. Esto se repite **por cada contenedor**, y el mismo `pedido` se duplica tantas veces como contenedores tenga el DO. | `ProgramacionArribos.php:252-275` + `PedidoTransporte::unidades_carga()` + `UnidadesCargaWS::pedido()` / `seguimientoContenedor()` | **Muy alto. Principal culpable.** |
| B | `public $pedidosCurso` guarda todas las filas sin paginar, cada una con ~45 claves, incluida `'TRANSPORTADOR' => $infoTransporte` (un modelo Eloquent serializado que la vista no usa). | `:37`, `:515-562`, `:566` | Alto (crece con el número de DOs abiertos) |
| C | `wire:click="descargarPedidos({{ json_encode($pedidosCurso) }})"`: el dataset completo se incrusta en el HTML **y se reenvía como parámetro**. Al pulsar "Descargar", el request lleva el dataset **dos veces** (memo + parámetro). | vista `:122` | Alto |
| D | `wire:click="enviarNotificaciones({{ json_encode($item) }})"`: el grupo de notificación del transportador (con el grafo de A) viaja como parámetro, además de viajar en el memo. | vista `:913` | Alto |
| E | `wire:click="seleccionarPedido({{ json_encode($ped) }})"` en cada fila: el HTML crece en N × fila, y la fila completa se guarda en `public $registroSeleccionado`. | vista `:530`, `:589` | Medio |
| F | `emitTo('importaciones.graficos-arribos', ..., $this->pedidosCurso)`: el dataset viaja en la respuesta; luego el navegador lo **reenvía** al hijo como parámetro del evento, y el hijo lo guarda en su `public $pedidos` (memo). El hijo también recibe en `mount` `json_encode($pedidos)`. | `:569`, `GraficosArribos.php:9,20-23,152` | Alto (en el request del hijo) |

Cualquier acción del usuario (Consultar, ordenar por ETA, abrir el modal del camión, Enviar o Descargar) dispara un POST con A + B + E, más C/D según la acción. Con un cliente que tenga muchos DOs abiertos o muchos contenedores asignados sin notificar, el payload supera fácilmente los 15 MB. El caso típico de la captura es un cliente con `admin_transporte = 1` (el título muestra "- Notificación al Transporte"), que es justo cuando se llena `pedidosNotificacion`.

### 1.3 Solución recomendada (de raíz)

Principio: **el estado público de Livewire solo debe contener filtros e identificadores escalares. Los datos se recalculan en el servidor.**

1. **`pedidosNotificacion` → solo resumen escalar.**
   ```php
   // Lo que viaja al cliente
   $this->pedidosNotificacion = [
       ['id_transporte' => 12, 'nombre_transportador' => 'X', 'num_pedidos' => 3, 'num_unidades_carga' => 7],
       ...
   ];
   ```
   Para los conteos, usar `withCount`/`count()` en lugar de cargar `unidades_carga` con todas sus relaciones.
   `enviarNotificaciones($idTransporte)` recibe **solo el id** y reconstruye en el servidor la lista de DOs/contenedores (filtrando por tenant, ver §3). Al construir el correo, cargar únicamente lo que usa la tabla del email: `NUMERO_PEDIDO`, `NUMERO_ID_CONTENEDOR`, `tamano.NOMBRE_TAMANO_CONTENEDOR`, `PESO_BRUTO`, `NUMERO_BULTOS`, `FECHA_ESTIMADA_ARRIBO`, `CODIGO_SUCURSAL_CO` y `cedi.descripcion`. Conviene crear relaciones "lite" con `select` explícito, en lugar de reutilizar `UnidadesCargaWS::pedido()`, que arrastra 6 relaciones (y `estadoHecho` completo).

2. **`pedidosCurso` fuera del memo + paginación.**
   - Convertirlo en `protected` (Livewire 2 no lo serializa) y calcularlo en `render()`, o devolverlo directamente como variable de vista.
   - Activar la paginación que ya está declarada (`$page`, `$perPage` en `$queryString`, hoy sin uso), con `WithPagination` o con `forPage()` sobre la colección procesada. El HTML deja de crecer sin límite.
   - Quitar de cada fila las claves que la vista no usa: `TRANSPORTADOR` (modelo), `pendientes`, `totalPendientes`, `detallePendientesCliente`, `PENDIENTE`, `PO`, `NIT`, `FECHA_LEVANTE`, `FECHA_LIBERACION`, `FECHA_ENTREGA_TRANSPORTE`, `ANALISTA_CLIENTE`, `ETAPA`, `colorStatus`, `STATUS`, `DESC_STATUS` (se usan solo en columnas comentadas). Verificar en el Excel cuáles siguen haciendo falta.

3. **`descargarPedidos()` sin parámetros.** Recalcular la consulta en el servidor con los filtros actuales. Eliminar `json_encode($pedidosCurso)` de la vista. Esto además cierra la manipulación del Excel desde el cliente.

4. **`seleccionarPedido($numeroDo)` con un string.** Cargar los 5 datos del modal (pedido, documento de transporte, sucursal, ETA, tipo/unidades) en el servidor. `registroSeleccionado` debe guardar solo esos campos.

5. **Gráficas: emitir agregados, no filas.** El padre calcula los tres `groupBy()->count()` (`TIPO_CARGA`, `SUCURSAL_OP`, `RAZON_SOCIAL_TRANSPORTE`) y emite tres arrays de unas pocas decenas de bytes. `GraficosArribos` deja de tener `public $pedidos` y `mount($pedidos)` recibe las series ya agregadas.

Con 1–5 el payload pasa de ser proporcional a *(DOs × contenedores × historial de etapas)* a unos pocos KB constantes.

### 1.4 Paliativos (no sustituyen la solución)

- Subir `post_max_size` (y `client_max_body_size` en nginx si aplica) en producción. Solo pospone el error: el tiempo de serialización/deserialización, la verificación del checksum y la memoria siguen creciendo, y la página se vuelve lenta antes de fallar.
- Crear `resources/views/errors/413.blade.php` con un mensaje en español ("La consulta devolvió demasiada información, aplique filtros de ETA o búsqueda"). Mejora la experiencia, pero no corrige la causa.
- Como mitigación inmediata y de bajo riesgo, se puede aplicar solo el punto 1 (`pedidosNotificacion`), que es el mayor contribuyente.

---

## 2. Rendimiento

### 2.1 Consulta principal (`consultarPedidos`, `:178-248`)

| Problema | Detalle | Propuesta |
|---|---|---|
| Joins redundantes | `importadores` se une dos veces (`importadores` y `importadores as i`); `i` solo se usa para unir `tipos_importador as imp`, que no se selecciona. | Eliminar `i` e `imp`. |
| Joins muertos | `dtas` y `dtas_forms` solo alimentan las ramas `TIPO_REGIMEN = 'T'` del `CASE`, pero el `where` fija `TIPO_REGIMEN IN ('I')`. `depositos` no se selecciona. | Eliminar los tres joins y simplificar los `CASE`. |
| Posible duplicación de filas | `leftJoin` a tablas 1:N (`documentos_trasporte`, `dtas_forms`) puede duplicar DOs. | Al quitar los joins muertos, verificar la cardinalidad de `documentos_trasporte`; si es 1:N, usar una subconsulta o `groupBy('estados_do.NUMERO_DO')`. |
| Eager loads sobredimensionados | `unidad_carga` trae `tipoEquipo→tipoCarga, tipoCarga, tamano, inspecciones`, pero solo se usan `count()` y `[0]->CARGA_EN_CONTENEDOR`. `documentos_aws` trae `tipo_documento`, que no se usa. `declaracion_impo` trae `pais_origen` y `acuerdo`, que no se usan. **`auditoria_eta` se carga y nunca se usa.** `estadoHecho` trae todo el historial cuando solo se necesitan la última etapa y las etapas `3003`, `3504` y `0112`. | Cargar con `select` mínimo y sin relaciones anidadas: `unidad_carga:NUMERO_DO,CARGA_EN_CONTENEDOR`, `documentos_aws:do,tipo,modo,estado`, `estadoHecho` filtrado por etapas relevantes + última. Quitar `auditoria_eta`. |
| Búsqueda no indexable | `LIKE '%x%'` sobre 4 columnas con `OR`. | Aceptable con pocos datos; considerar `LIKE 'x%'` sobre `NUMERO_DO`/`NUMERO_PEDIDO`, o exigir un mínimo de 3 caracteres. |
| Sin límite | Rango fijo `FECHA_APERTURA` desde `2023-03-19` hasta hoy, sin paginación. | Paginación (§1.3.2) y revisar si la fecha fija sigue teniendo sentido. |

### 2.2 Procesamiento en PHP (`:286-564`)

- **Bucle anidado O(N × M)** para resolver la descripción de la etapa (`:349-354`): por cada pedido recorre todas las etapas del sistema. Indexar una vez: `$etapas = $this->consultarEtapas()->keyBy(fn => proceso.'|'.codigo)` (en PHP 7.3, con `function ($e) { return $e->proceso.'|'.$e->codigo; }`).
- `consultarEtapas()` trae la tabla completa `estadodehechos.etapas` en cada consulta. Se puede cachear (`Cache::remember`, p. ej. 1 h) porque es un catálogo.
- `$infoTransportadores->where('id', ...)` por fila: usar `keyBy('id')` y acceso directo.
- Los 3 `documentos_aws->where()->where()->where()->first()` por fila se pueden reducir a un solo `filter` + `pluck('tipo')`.
- `$lstStatus` (`:175`) se consulta y nunca se usa. Eliminar.

### 2.3 `render()` (`:152-161`)

Consulta `reportes.procesos` y `sucursales` **en cada request de Livewire** (cada clic, cada ordenamiento). Moverlas a `mount()` como propiedades o cachearlas, porque son catálogos estáticos.

### 2.4 `filtroCerrados` (`:277-281`)

Cuando no hay filtro de ETA (el caso por defecto), `fechaAperturaInicial` y `fechaAperturaFinal` están vacíos y el rango queda `[" 00:00:00", " 23:59:59"]`. La consulta no devuelve nada y **el filtro de DOs cerrados (etapa 3504) queda desactivado silenciosamente**. Además, filtra por la fecha de grabado de la etapa usando el rango de ETA, lo cual no tiene relación semántica. Revisar la intención: probablemente debe excluir cualquier DO con etapa `3504`, sin rango de fechas. El `whereIn('numdo', ...)` con miles de valores sobre `websystem` también es costoso; mejor resolverlo con `whereNotExists` en la consulta principal, si la conexión lo permite (bases distintas en el mismo servidor).

### 2.5 Escritura dentro de una lectura (`:362-377`)

`consultarPedidos()` hace `PedidoTransporte::create()` mientras lista: migra transportadores desde `fase3.jdata`. Problemas:
- Se ejecuta en cada consulta (en la práctica solo la primera vez por DO, pero en cada request se revisa).
- Si `txtTransportador` tiene varios valores, crea varias filas para el mismo `numero_do` y `hasOne` toma una arbitraria.
- Es una escritura en la BD real compartida, disparada por el simple hecho de abrir la pantalla.

Propuesta: mover esta migración a un comando `sync:*` programado o a un script único.

### 2.6 `DashboardArribos`

- `whereMonth('FECHA_LLEGADA_MCIA', now()->month)` **sin año**: mezcla el mismo mes de todos los años y no puede usar índice. Usar `whereBetween` con el inicio y fin del mes actual.
- Carga los modelos completos (`join` sin `select`) + `documentos_aws` para calcular 4 promedios. Seleccionar solo las columnas usadas.
- `Carbon::parse(...)->diffInDays(now()) <= 2` es valor absoluto: cuenta como "en riesgo" también los DOs cuya ETA pasó hace 2 días.

### 2.7 `GraficosArribos`

- `render()` llama a `generarGrafica()` en cada render. Con el cambio §1.3.5 desaparece el costo.
- Los `<script>Highcharts.chart(...)</script>` dentro de la vista Livewire no se re-ejecutan de forma confiable tras un morph del DOM. Conviene redibujar con `dispatchBrowserEvent` + listener JS.
- `pie()` tiene la clave `'size'` duplicada (`'70%'` y luego `'100%'`), y `size`/`center`/`dataLabels` están mal ubicadas dentro de `plotOptions` (deberían ir en `plotOptions.pie`).

---

## 3. Seguridad y aislamiento entre clientes

La regla nº 1 del repositorio (filtrar todo por tenant) se incumple en las acciones de escritura, y el diseño de "pasar el registro completo desde el navegador" lo empeora: **cualquier dato que llega como parámetro de `wire:click` o como propiedad pública puede ser modificado por el usuario.**

| Severidad | Hallazgo | Ubicación |
|---|---|---|
| Alta | `enviarNotificaciones($transporte)` confía en el array que manda el navegador: los **destinatarios del correo** (`contactos[].email`), el contenido de la tabla y la lista de DOs. Un usuario puede enviar correos con el remitente corporativo a cualquier dirección. | `:658-739` |
| Alta | `PedidoTransporte::whereIn('numero_do', ...)` y `ControlContenedoresCIA::whereIn('id_contenedor_ws', ...)` sin filtro de `id_grupo_empresarial` ni de importador: es posible marcar como notificados DOs/contenedores de otro cliente. | `:710`, `:720` |
| Alta | `asignarTransporte()`: `$numeroDo` es una propiedad pública modificable. `updateOrCreate(['numero_do' => ...])` sin tenant, y `ControlContenedoresCIA::where('numero_do')` también sin tenant: es posible reasignar el transportador o la planta de un DO de otro cliente. Tampoco valida que `idTransporte` pertenezca a `lstTransportes` ni que `idPlanta` pertenezca a `lstPlantas`. | `:601-639` |
| Media | `seleccionarPedido`: consulta `PedidoTransporte` y `ControlContenedoresCIA` solo por `numero_do`. | `:590-591` |
| Media | `GET /detalle-importacion` **sin `middleware('auth')`** y sin validar que el DO pertenezca al tenant. Devuelve HTML con el detalle, las facturas y las unidades de carga de cualquier DO. Además, concatena `numeroDo` a la URL de la API externa sin `rawurlencode`. | `routes/web.php:878`, `DetalleImportacionController.php:19` |
| Media | `descargarPedidos($pedidosCurso)` genera el Excel con lo que mande el navegador (datos falsificables con apariencia oficial). Además, `$cuerpo[0]` revienta si la lista viene vacía. | `:741-777` |
| Baja | `ordenarInfo($campo, ...)` y `orderBy` en `$queryString` aceptan cualquier nombre de columna (Laravel lo escapa, pero un valor inválido produce un 500). Usar una lista blanca. | `:579-584`, `:66` |
| Baja | El cuerpo del correo concatena valores sin `e()`: inyección de HTML en el email. | `:664-693` |
| Baja | CC hardcodeado `jpardo@asercol.com`. Mover a `config()` o a una configuración por cliente. | `:705` |
| Baja | En `mount()`, `segmentos_clientes` se filtra con `session('companias')` aunque el usuario no sea tercero. Debería usar `$this->compania`. | `:144` |

**Patrón de corrección:** todas las acciones reciben solo identificadores. Antes de leer o escribir, se valida en el servidor:
```php
$valido = Pedido::where('NUMERO_DO', $numeroDo)
    ->whereIn('ID_IMPORTADOR', $this->compania)
    ->exists();
abort_unless($valido, 403);
```
Y toda escritura en `po_transporte` incluye `->where('id_grupo_empresarial', $this->idGrupoEmpresarial)`.

---

## 4. Bugs funcionales y código muerto

- **Filtro "Tipo Declaración" no hace nada:** `$tipoDeclaracion` está ligado a la vista, pero nunca se aplica en la consulta.
- `$status`, `$delayProcess`, `$lstStatus`, `$lstDelayProcess`, `$dinamicos`, `$dashboard`, `$unidadesCargaPedido`, `$etapasEh` (pública y serializada en cada request: es la tabla completa de etapas), `$page`, `$perPage` y `$totalRegistros` están declaradas o consultadas pero no se usan en la vista. `$etapasEh` en particular **también suma peso al payload** y debe dejar de ser pública.
- `'totalPendientes' => collect($pendientes)->where('status', 0)->count()`: `$pendientes` es un string, así que el resultado siempre es 0.
- `$diferenciaEta`, `$ehEnvioFacturar`, `$ehDocumentosCompletos`, `$facturas`, `$unidadesCarga` y `$fechaRetiroTotal` se calculan y no se usan.
- Si `consultarPedidos()` no devuelve filas, `$this->pedidosCurso` queda en `[]`, pero **no se emite** el refresco de gráficas (el `emitTo` está dentro del `if`): las gráficas siguen mostrando los datos de la consulta anterior.
- Los `wire:model.defer` de filtros no persisten en la URL, mientras que `orderBy`/`perPage`/`status`/`delayProcess` sí lo hacen sin usarse. Alinear `$queryString` con los filtros reales.
- La vista JS `toggleDetalle` escribe en `document.getElementById('tabDetalle')` en el `catch`, pero ese id no existe (debería ser la variable `tabDetalle`).
- Imports sin uso: `Illuminate\Support\Facades\Http`. `DefControlTower` solo alimenta el `$lstStatus` muerto.
- `Mail::...->queue()` con `QUEUE_CONNECTION=sync` envía dentro del request: el usuario espera el SMTP.

---

## 5. Plan de implementación sugerido

| Fase | Cambio | Impacto en 413 | Riesgo |
|---|---|---|---|
| 1 | `pedidosNotificacion` → resumen escalar; `enviarNotificaciones($idTransporte)` reconstruye en el servidor con filtro de tenant | Elimina el mayor contribuyente | Bajo |
| 2 | `descargarPedidos()` sin parámetros; `seleccionarPedido($numeroDo)` | Elimina C y E | Bajo |
| 3 | Gráficas con series agregadas; `GraficosArribos` sin `$pedidos` público | Elimina F | Bajo |
| 4 | `pedidosCurso` y `etapasEh` fuera de propiedades públicas + paginación + filas reducidas | Elimina B; el payload queda constante | Medio (cambia la UX: aparece la paginación) |
| 5 | Validación de tenant en `asignarTransporte`, `seleccionarPedido` y `/detalle-importacion` (+ `auth`) | — | Bajo |
| 6 | Optimizar la consulta: joins y eager loads mínimos, índices en memoria, catálogos cacheados, corrección de `filtroCerrados` | Menor tiempo de respuesta | Medio (validar resultados contra la versión actual) |
| 7 | Sacar la migración `fase3 → po_transporte` del listado hacia un comando | — | Medio |
| 8 | Vista `errors/413.blade.php` en español | UX | Nulo |

### Estado de implementación (2026-10-09)

| Fase | Estado | Notas |
|---|---|---|
| 1 | ✅ | `pedidosNotificacion` = `[id_transporte, nombre_transportador, num_pedidos, num_unidades_carga]`. Para el grupo 32 (170 DOs abiertos) pesa **509 bytes**. `enviarNotificaciones($idTransporte)` reconstruye los DOs, los contenedores, la planta y los contactos en el servidor. El CC pasa a `config('arribos.cc_prealerta_transporte')` (`ARRIBOS_CC_PREALERTA_TRANSPORTE`, valor por defecto el correo anterior). |
| 2 | ✅ | `descargarPedidos()` y `seleccionarPedido($numeroDo)`. |
| 3 | ✅ | `GraficosArribos` recibe `series` (~360 bytes) y redibuja con el evento de navegador `graficos-arribos:actualizar` (destruye la gráfica previa). |
| 4 | ✅ | El listado se guarda en cache (`file`, 10 min) con clave = usuario + grupo + compañías + `filtrosAplicados`, calculada siempre con datos de sesión. Paginación de 25 filas con `WithPagination`. Los filtros solo se aplican al presionar Consultar (`filtrosAplicados`). Se eliminó `$etapasEh` (la tabla de etapas ya no se usa, porque las columnas que la requerían están comentadas en la vista y no salen en el Excel). |
| 5 | ✅ | Tenant en `asignarTransporte`, `seleccionarPedido`, `enviarNotificaciones` y en el eager load `pedido_transporte`. `idTransporte`/`idPlanta` se validan contra `lstTransportes`/`lstPlantas`. El hook `updating()` bloquea con 403 la escritura desde el navegador de `compania`, `idGrupoEmpresarial`, `numeroDo`, listas, etc. `/detalle-importacion` tiene ahora `auth` + validación de que el DO pertenece al cliente + `rawurlencode`. `ordenarInfo` usa lista blanca. El correo escapa los valores con `e()`. |
| 6 | ✅ | Se quitaron los joins `i`, `imp`, `depositos`, `dtas` y `dtas_forms`, y los eager loads `auditoria_eta`, `estadoHecho`, `fase3` e `importacionCTC`. Los demás eager loads van con `without()` + `select` mínimo. Catálogos de `render()` cacheados 1 h. Transportadores indexados por id. **`filtroCerrados` ahora excluye todo DO con etapa 3504 (sin rango de fechas)**: es un cambio visible, porque antes, sin filtro de ETA, esos DOs sí aparecían. Se agregó el filtro de Tipo de Declaración (`whereExists` sobre `formularios_dis`/`tipos_declaracion`). `DashboardArribos`: mes + año con `whereBetween`, `select` mínimo, "en riesgo" = ETA entre hoy y +2 días. |
| 7 | ⚠️ Parcial | `po_transporte.usuario_asigna` es `NOT NULL` y un comando programado no tiene usuario autenticado. Por eso la migración sigue ejecutándose al consultar, pero aislada en `migrarTransporteFase3()`: carga `fases` solo para los DOs sin transportador y hace `firstOrCreate` por (grupo, DO), así que crea **una** fila por DO y no varias. Pendiente: definir el usuario de sistema y mover la migración a un comando `sync:*`. |
| 8 | ✅ | `resources/views/errors/413.blade.php`. |

Pendientes que no se tocaron:
- `po_transporte` no tiene índice sobre `numero_do` / `id_grupo_empresarial` (todas las consultas filtran por esas columnas). Sugerido: `INDEX (id_grupo_empresarial, numero_do)`, a aplicar fuera del repositorio.
- Posible duplicación de filas por un `leftJoin` 1:N en `documentos_trasporte` (sin verificar).

### Verificación

```bash
php -l app/Http/Livewire/Importaciones/ProgramacionArribos.php
php -l app/Http/Livewire/Importaciones/GraficosArribos.php
php artisan route:list | grep -E "arribos|detalle-importacion"
```
En el navegador (DevTools → Network → `livewire/message/importaciones.programacion-arribos`):
- Medir el tamaño del request antes y después, con el cliente que reproduce el error. Objetivo: < 100 KB independientemente del número de DOs.
- Probar Consultar, ordenar por ETA, abrir el modal del camión y guardar, Enviar prealerta y Descargar.
- Con un usuario de otro grupo empresarial, intentar `asignarTransporte` sobre un DO ajeno (editando `serverMemo` o el parámetro) y confirmar que responde 403.
- Recordar que el `.env` apunta a BD real: probar el envío de prealertas con un transportador de prueba cuyos contactos sean internos.
