# Módulo Dashboard (Estadísticas Impo/Expo) — Base de conocimiento para la V2

> **Propósito de este documento.** Levantamiento completo del módulo `Dashboard` tal como existe hoy
> (código, fuentes de datos, modelos, motor de gráficos, front-end), con el inventario de deudas
> técnicas y riesgos identificados, y el mapa de fuentes adicionales disponibles para conectar en la V2.
>
> **Fecha del levantamiento:** 2026-09-11 · **Rama:** `mayala` · **Stack:** Laravel 8.76.2 · PHP 7.3 · Livewire 2.
>
> Documentos hermanos: [SPEC_PurchaseOrderV2.MD](specs/SPEC_PurchaseOrderV2.MD) · [SPEC_SeguimientoRIM_V2.MD](specs/SPEC_SeguimientoRIM_V2.MD) · [ConocimientoControlTower.MD](ConocimientoControlTower.MD)

---

## Tabla de contenido

1. [Resumen ejecutivo](#1-resumen-ejecutivo)
2. [Mapa del módulo](#2-mapa-del-módulo)
3. [Arquitectura y flujo de datos](#3-arquitectura-y-flujo-de-datos)
4. [Componentes en detalle](#4-componentes-en-detalle)
5. [El motor de gráficos configurable (`graficos_dashboard`)](#5-el-motor-de-gráficos-configurable-graficos_dashboard)
6. [Fuentes de información actuales](#6-fuentes-de-información-actuales)
7. [Modelos Eloquent asociados](#7-modelos-eloquent-asociados)
8. [Renderizado en el front-end](#8-renderizado-en-el-front-end)
9. [Aislamiento multi-cliente (tenant)](#9-aislamiento-multi-cliente-tenant)
10. [Deudas técnicas y riesgos](#10-deudas-técnicas-y-riesgos)
11. [Fuentes adicionales disponibles para la V2](#11-fuentes-adicionales-disponibles-para-la-v2)
12. [Recomendaciones de arquitectura para la V2](#12-recomendaciones-de-arquitectura-para-la-v2)
13. [Checklist de migración y compatibilidad](#13-checklist-de-migración-y-compatibilidad)
14. [Anexos](#14-anexos)

---

## 1. Resumen ejecutivo

El Dashboard es el módulo de **estadísticas agregadas** del Control Tower. Muestra, para el grupo
empresarial en sesión y un periodo (año, o año+mes):

- **Tarjetas KPI** de volumen de operaciones por medio de transporte, separadas en Importaciones y
  Exportaciones, con comparativo contra un "periodo anterior".
- **Una grilla de gráficos** (columna, barra, torta y mapa mundial) cuyo contenido **no está en el
  código**: cada gráfico es una fila de la tabla `graficos_dashboard` que guarda su SQL literal.
- Un **modal de detalle** por gráfico (la tabla de DOs que compone el agregado) y su exportación a Excel.

**La característica arquitectónica dominante es que el módulo es un intérprete de SQL almacenado en
base de datos.** Agregar un gráfico no requiere desplegar código: se inserta una fila en
`graficos_dashboard` con su `sql`, su `sql_detalle`, el `tipo` de gráfico y a qué proceso (`I`/`E`)
aplica. Esta decisión es simultáneamente la mayor fortaleza del módulo (velocidad para el negocio) y
el origen de casi todas sus deudas técnicas (sin tipado, sin caché, sin validación, con interpolación
de strings en el SQL).

**Estado actual de contenido:** 25 gráficos configurados, 21 activos (`estado = 1`) — 14 de
importaciones y 7 de exportaciones.

---

## 2. Mapa del módulo

### 2.1 Archivos

| Capa | Archivo | Líneas | Rol |
|---|---|---:|---|
| Ruta contenedora | [routes/web.php:835](routes/web.php#L835) | — | `GET /dashboard/impo` → shell Bootstrap + iframe |
| Ruta de contenido | [routes/web.php:836](routes/web.php#L836) | — | `GET /dashboard/impo/ver` → contenido Tailwind |
| Vista iframe | [resources/views/Dashboard/dashboard-iframe.blade.php](resources/views/Dashboard/dashboard-iframe.blade.php) | 20 | `@extends('layouts.planti')` + `<iframe>` |
| Vista contenido | [resources/views/Dashboard/dashboard-importaciones.blade.php](resources/views/Dashboard/dashboard-importaciones.blade.php) | 46 | HTML propio (**no** usa `layouts.tailwind`), carga CDNs y monta el orquestador |
| Orquestador | [app/Http/Livewire/Dashboard/ImportacionesAdmin.php](app/Http/Livewire/Dashboard/ImportacionesAdmin.php) | 123 | Filtros (importador/año/mes) y emisión del evento `setPeriodo` |
| — vista | [resources/views/livewire/dashboard/importaciones-admin.blade.php](resources/views/livewire/dashboard/importaciones-admin.blade.php) | 125 | Grid y montaje de los 3 hijos |
| KPI Impo | [app/Http/Livewire/Dashboard/ImportacionesTotaleStats.php](app/Http/Livewire/Dashboard/ImportacionesTotaleStats.php) | 199 | Tarjetas por medio de transporte (importación) |
| — vista | [resources/views/livewire/dashboard/importaciones-totale-stats.blade.php](resources/views/livewire/dashboard/importaciones-totale-stats.blade.php) | 49 | |
| KPI Expo | [app/Http/Livewire/Dashboard/ExportacionesTotaleStats.php](app/Http/Livewire/Dashboard/ExportacionesTotaleStats.php) | 234 | Tarjetas por modo de transporte (exportación) |
| — vista | [resources/views/livewire/dashboard/exportaciones-totale-stats.blade.php](resources/views/livewire/dashboard/exportaciones-totale-stats.blade.php) | 59 | |
| **Motor de gráficos** | [app/Http/Livewire/Dashboard/ImportacionesGraph.php](app/Http/Livewire/Dashboard/ImportacionesGraph.php) | 521 | Lee `graficos_dashboard`, ejecuta SQL, arma JSON de Highcharts/GeoChart |
| — vista | [resources/views/livewire/dashboard/importaciones-graph.blade.php](resources/views/livewire/dashboard/importaciones-graph.blade.php) | 262 | Grid de gráficos, modal de detalle, JS de exportación |
| Detalle (**muerto**) | [app/Http/Livewire/Dashboard/DetalleGraph.php](app/Http/Livewire/Dashboard/DetalleGraph.php) | 27 | Nunca se instancia ni recibe su evento — ver [D-30](#d-30) |
| — vista | [resources/views/livewire/dashboard/detalle-graph.blade.php](resources/views/livewire/dashboard/detalle-graph.blade.php) | 3 | |
| API export | [app/Http/Controllers/Api/v2/DownloadExcelController.php](app/Http/Controllers/Api/v2/DownloadExcelController.php) | 26 | `POST /api/download-excel` — recibe cabecera+filas y devuelve xlsx |
| Exportador | [app/Exports/ReporteSemanalRpExport.php](app/Exports/ReporteSemanalRpExport.php) | — | Clase genérica reutilizada (`FromArray`) |

### 2.2 Entradas de menú (tabla `menus`, BD `asercolg_po`)

| id | nombre | nivel | depende | ruta | perfiles |
|---:|---|---:|---:|---|---|
| 141 | `Dashboard` | 2 | 132 | *(null — es agrupador)* | 58 perfiles |
| **142** | `Estadisticas` | 3 | 141 | `/dashboard/impo` | 34 perfiles |
| **146** | `Dashboard.` | 3 | 3 | `/dashboard/impo` | 9 perfiles |
| 75 | `Mi Dashboard` | 3 | 26 | `/rexdashboard` | legacy, otro módulo |

> Hay **dos entradas de menú apuntando a la misma ruta** (142 y 146) con listas de perfiles distintas, colgando
> de padres distintos. Cualquier cambio de permisos debe tocar ambas.

### 2.3 Nomenclatura engañosa

Rutas, vistas y componentes dicen "importaciones" (`/dashboard/impo`, `dashboard-importaciones.blade.php`,
`ImportacionesAdmin`, `ImportacionesGraph`) pero el módulo **muestra importaciones y exportaciones
juntas**. `ImportacionesGraph` renderiza indistintamente los gráficos `proceso = 'I'` y `proceso = 'E'`.
Esto rompe la convención de "gemelos" descrita en CLAUDE.md §7: aquí no hay gemelo `ExDashboard`, hay
un solo componente que sirve ambos regímenes.

---

## 3. Arquitectura y flujo de datos

```
 Navegador
    │
    │ GET /dashboard/impo                     [middleware: auth]
    ▼
 Dashboard/dashboard-iframe.blade.php  ──►  layouts.planti  (shell Bootstrap del CTC)
    │   <iframe src="{{ route('dashboard-impo') }}">  (relación de aspecto fija 16:9)
    ▼
 GET /dashboard/impo/ver                     [middleware: auth]
 Dashboard/dashboard-importaciones.blade.php
    │   CDNs: tailwindcss · alpinejs 3.12 · highmaps + data/exporting/export-data/accessibility
    │         jquery 3.7.1 (no se usa) · gstatic charts loader (geochart) · fontawesome kit
    ▼
 @livewire('dashboard.importaciones-admin')            ← ORQUESTADOR
    │
    ├── filtros: idCompania · anio · mes    (wire:model.defer)
    │   botón "Consultar" → consultarData()
    │       └─► $this->emit('setPeriodo', json_encode(['periodo'=>"$anio-$mes", 'importador'=>$idCompania]))
    │
    ├── @livewire('dashboard.importaciones-totale-stats')   ← escucha setPeriodo
    ├── @livewire('dashboard.exportaciones-totale-stats')   ← escucha setPeriodo
    └── @livewire('dashboard.importaciones-graph')          ← escucha setPeriodo
                    │
                    ├─ SELECT * FROM graficos_dashboard WHERE estado=1 AND proceso IN (...)   [conexión mysql]
                    │
                    └─ por cada gráfico:
                         1. SET lc_time_names = 'es_ES'                       [conexión $graph->conexion]
                         2. ejecuta $graph->sql                                [conexión $graph->conexion]
                         3. ejecuta $graph->sql_detalle                        [SIEMPRE websimex — ver D-04]
                         4. arma el JSON de Highcharts / GeoChart
                         5. arma el <table> HTML del detalle (string concatenado)
```

### 3.1 Contrato del evento `setPeriodo`

Emitido por `ImportacionesAdmin::consultarData()` ([línea 89](app/Http/Livewire/Dashboard/ImportacionesAdmin.php#L89)),
escuchado por los tres componentes hijos. **Payload:** un *string JSON* (no un array), con forma:

```json
{ "periodo": "2026-3", "importador": "2435" }
```

- `periodo` = `"$anio-$mes"`. Si no se eligió mes queda `"2026-"` (con guion final y segundo segmento vacío).
- `importador` = `id_websimex` de una compañía del grupo, o `""` si no se filtró.

**Cada uno de los tres hijos re-implementa por su cuenta el parseo del periodo** (≈40 líneas duplicadas
tres veces, con variantes sutiles entre ellas). Ver [D-17](#d-17).

### 3.2 Reglas de resolución del rango de fechas

| Caso | `fechaAperturaInicial` | `fechaAperturaFinal` |
|---|---|---|
| Año actual, sin mes | `AAAA-01-01` | **hoy** |
| Año actual, con mes | `AAAA-MM-01` | `AAAA-MM-<último día>` |
| Año anterior, sin mes | `AAAA-01-01` | `AAAA-12-31` |
| Año anterior, con mes | `AAAA-MM-01` | `AAAA-MM-<último día>` |

El "periodo anterior" (solo en las tarjetas KPI) se calcula **siempre** como
`fechaAperturaInicial - 1 mes`, del día 1 al último día de ese mes. Ver [D-20](#d-20).

---

## 4. Componentes en detalle

### 4.1 `ImportacionesAdmin` — orquestador

[app/Http/Livewire/Dashboard/ImportacionesAdmin.php](app/Http/Livewire/Dashboard/ImportacionesAdmin.php)

| Propiedad | Tipo | Origen |
|---|---|---|
| `$idGrupoEmpresarial` | int | `session('GrupoEmpresarial')` |
| `$color` | string | `session('color')` — tema del cliente |
| `$compania` | array | `compania.id_websimex` donde `id_grupo_empresarial = tenant` y `inactivo = 0` |
| `$lstCompanias` | array | `id_websimex => razon_social` para el `<select>` |
| `$filtroCompania` | bool | `true` solo si el grupo tiene más de una compañía |
| `$anio` / `$mes` / `$idCompania` | string | binding de los tres selects |

**Datos para los selects** (se calculan en `render()`, no en `mount()` — ver [D-24](#d-24)):

```sql
-- conexión: websimex
SELECT MIN(YEAR(FECHA_APERTURA)) AS MINIMA, MAX(YEAR(FECHA_APERTURA)) AS MAXIMA
FROM estados_do
WHERE ANULADO = 'N' AND ID_IMPORTADOR IN (<compañías del grupo>)
```

El listado de meses es un array fijo de 12 en `render()`. Existe el método
`obtenerMesesHastaFechaActual()` que limitaría los meses al mes en curso del año actual, pero
**no se invoca** ([línea 92](app/Http/Livewire/Dashboard/ImportacionesAdmin.php#L92)) — código muerto.

### 4.2 `ImportacionesTotaleStats` — tarjetas KPI de importación

[app/Http/Livewire/Dashboard/ImportacionesTotaleStats.php](app/Http/Livewire/Dashboard/ImportacionesTotaleStats.php)

Consulta con **Eloquent** (modelo `App\Models\Pedido`, conexión `websimex`, tabla `estados_do`):

```
estados_do edo
  ├── importadores          ON ID_IMPORTADOR
  ├── sucursales            ON CODIGO_SUCURSAL_OP = CODIGO_SUCURSAL
  ├── importadores AS i     ON ID_IMPORTADOR          ← join redundante, no se usa
  ├── importaciones         ON NUMERO_DO
  ├── documentos_trasporte  ON NUMERO_DO              (sic: "trasporte", sin 'n')
  └── medios_trasporte      ON CODIGO_MEDIO_TRANS

WHERE ANULADO = 'N'
  AND ID_IMPORTADOR IN (<compañías>)
  AND TIPO_REGIMEN IN ('I','T')                      ← Importación y Tránsito
  AND importaciones.FECHA_LEVANTE BETWEEN <ini 00:00:00> AND <fin 23:59:59>
GROUP BY medios_trasporte.NOMBRE_MEDIO_TRANS
SELECT NOMBRE_MEDIO_TRANS, COUNT(DISTINCT NUMERO_DO) AS TOTAL
```

**Fecha de corte: `importaciones.FECHA_LEVANTE`** (fecha de levante aduanero).

La consulta del periodo anterior es una copia con dos diferencias que son bugs confirmados
([D-18](#d-18)): usa `where()` con un array en vez de `whereIn()`, y `COUNT(*)` en vez de
`COUNT(DISTINCT NUMERO_DO)`.

**Salida por tarjeta:** `['medio','total','total_ant','diferencia','p_diferencia','svg']`.
Los íconos son SVG **inline hardcodeados** en un `switch` sobre el nombre del medio devuelto por el
ERP: `'AEREO'`, `'MARITIMO.'` (con punto final), `'TERRESTRE'`, más un default.

### 4.3 `ExportacionesTotaleStats` — tarjetas KPI de exportación

[app/Http/Livewire/Dashboard/ExportacionesTotaleStats.php](app/Http/Livewire/Dashboard/ExportacionesTotaleStats.php)

Es el gemelo del anterior, pero **el criterio temporal viene de otra base de datos**:

```php
// conexión websystem, tabla estadodehechos.estadohechos
$zarpePeriodo = EstadoHecho::where('etapa', '3001')                    // 3001 = ZARPE
    ->whereBetween('fecha', [$fechaInicial, $fechaFinal])
    ->pluck('numdo')->toArray();                                      // ← SIN filtro de tenant
```

Y luego `whereIn('estados_do.NUMERO_DO', $zarpePeriodo)` contra `websimex`. Es un **join manual
entre dos servidores MySQL** materializado en PHP. Ver [D-03](#d-03) (riesgo de volumen).

Joins (todos `leftJoin`, a diferencia de impo):

```
estados_do edo
  ├── importadores
  ├── sucursales
  ├── importadores AS i                    ← redundante
  ├── exportaciones                        ← joineada pero no usada en el SELECT
  ├── documentos_transportes_expos         ON NUMERO_DO
  ├── viajes_motonaves                     ← joineada pero no usada
  ├── ex_solicitudes_aut_embarques exsae   ← joineada pero no usada
  └── ex_modos_transporte                  ON CODIGO_MODO_TRANSPORTE

WHERE ANULADO='N' AND TIPO_REGIMEN='E' AND ID_IMPORTADOR IN (...) AND NUMERO_DO IN (<zarpes>)
GROUP BY ex_modos_transporte.NOMBRE_MODO_TRANSPORTE
```

Los nombres del catálogo de expo son distintos a los de impo (`'Transporte Aéreo'`,
`'Transporte Marítimo'`, `'Transporte carretero'`) y se **re-mapean a mano** a las etiquetas de impo
(`AEREO`, `MARITIMO.`, `TERRESTRE`) para que las dos filas de tarjetas se vean homogéneas.

### 4.4 `ImportacionesGraph` — motor de gráficos

[app/Http/Livewire/Dashboard/ImportacionesGraph.php](app/Http/Livewire/Dashboard/ImportacionesGraph.php) — el corazón del módulo.

**`mount()`** siembra tenant + color, y llama `consultarData(date('Y') . '-')` (año actual, sin mes).

**`consultarData($periodo, $importador = null)`** ([línea 63](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L63)):

1. Resuelve el rango de fechas (misma lógica duplicada).
2. Determina qué gráficos mostrar:
   ```php
   $this->procesos = Compania::where('id_grupo_empresarial', $tenant)->where('inactivo',0)->first();
   $procesos = explode(',', $this->procesos->procesos);        // 'I,E' → ['I','E']
   $graphs = DB::table('graficos_dashboard')
               ->where('estado', 1)->whereIn('proceso', $procesos)
               ->orderBy('proceso', 'DESC')->get()->toArray();
   ```
   > La columna `compania.procesos` es un CSV (`'I'`, `'E'`, `'I,E'`, o **vacío**). Se toma la de la
   > **primera** compañía del grupo. Ver [D-02](#d-02).
3. Por cada gráfico, sustituye los tres marcadores por **interpolación literal de strings**:
   ```php
   $sql = str_replace('?fechaInicial', "'".$this->fechaAperturaInicial."'", $sql);
   $sql = str_replace('?fechaFinal',   "'".$this->fechaAperturaFinal."'",   $sql);
   $sql = str_replace('?idImportador', $clientes, $sql);      // ← SIN comillas, SIN binding
   ```
   Ver [D-01](#d-01) — **vulnerabilidad crítica**.
4. Ejecuta `SET lc_time_names='es_ES'` y luego el SQL.
5. Si hay `sql_detalle`, lo ejecuta y construye: `$graph->detalle` (HTML de la tabla) y
   `$graph->detalleExportar` (JSON con `cabecera` + `detalle`).
6. Normaliza el resultado a series/categorías (ver §5.2) y llama al método homónimo al `tipo`
   (`$this->$tipo()`) que devuelve el JSON de configuración del gráfico:
   ```php
   $graphs[$index]->grafico = $this->$tipo();       // llamada dinámica: line|bar|column|pie|map
   ```
   > `$tipo` viene de base de datos sin whitelist. Un `tipo` inválido produce `Call to undefined method`.

**Propiedades públicas problemáticas.** `$graphs`, `$series`, `$data`, `$csv`, `$dataCsv`,
`$tablaDetalle` son **públicas**, por lo que Livewire las serializa completas en `wire:initial-data`
y en cada payload de request. `$graphs` contiene el SQL crudo de los 21 gráficos, el HTML de todos
los detalles y los JSON de configuración. Ver [D-36](#d-36).

**Imports sin usar:** `ReporteSemanalRpExport`, `Http`, `Excel`, `Pedido` ([líneas 6-11](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L6-L11))
— residuos de una implementación anterior de la exportación.

**`mostrarDetalle($json)`** ([línea 497](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L497)):
construye un `<table>` Tailwind por concatenación de strings, sin escapar. Detecta fechas con
`strtotime($valor) !== false` — heurística que reformatea por error valores como `"10"` o `"2024"`.
Ver [D-06](#d-06) y [D-07](#d-07).

### 4.5 `DetalleGraph` — código muerto

[app/Http/Livewire/Dashboard/DetalleGraph.php](app/Http/Livewire/Dashboard/DetalleGraph.php) escucha
el evento `detalleGrafico`. Búsqueda en todo el repositorio: **no existe ningún `@livewire('dashboard.detalle-graph')`
ni ningún `emit('detalleGrafico', ...)`**. El detalle terminó renderizándose inline dentro de
`importaciones-graph.blade.php`. Eliminar en la V2.

---

## 5. El motor de gráficos configurable (`graficos_dashboard`)

### 5.1 Esquema de la tabla

Conexión `mysql` (BD `asercolg_po`). **No tiene modelo Eloquent**; se accede con `DB::table()`.

| Columna | Tipo | Comentario en BD | Notas |
|---|---|---|---|
| `id` | `int(11)` | | PK |
| `proceso` | `char(50)` | `Importacion: I - Exportacion: E` | se cruza contra el CSV `compania.procesos` |
| `slug` | `char(50)` | | **se usa como `id` del `<div>` y como identificador JS** |
| `titulo` | `varchar(200)` | | |
| `subtitulo` | `varchar(200)` | | |
| `titulo_eje` | `varchar(200)` | | título del eje Y / nombre de la serie en `pie` |
| `tipo` | `varchar(10)` | `pie - bar - line` | valores reales: `column`, `bar`, `pie`, `map` (`line` no se usa) |
| `conexion` | `varchar(50)` | | nombre de la conexión Laravel; hoy siempre `websimex` |
| `sql` | `longtext` | | consulta del agregado |
| `sql_detalle` | `longtext` | | consulta del drill-down (opcional) |
| `estado` | `smallint(6)` | | `1` visible, `0` oculto |

**Lo que NO tiene y hace falta en la V2:** `id_grupo_empresarial` (no se puede personalizar por
cliente), `orden` (el orden lo da `ORDER BY proceso DESC`), `meta`/`objetivo`, `ancho`/`layout`,
`formato` de número, `created_at`/`updated_at`, `activo_desde`.

### 5.2 Contrato de datos que debe cumplir el `sql`

Según el `tipo`, el motor espera columnas con **nombres exactos, en mayúsculas**:

| `tipo` | Columnas requeridas | Cómo se consume |
|---|---|---|
| `column`, `bar`, `line` | `CATEGORIA`, `NAME`, `DATA` | `CATEGORIA` → eje X; `NAME` → serie; `DATA` → valor |
| `pie` | `NAME`, `DATA` (y `CATEGORIA` ignorada) | suma `DATA` agrupada por `NAME` |
| `map` | `country`, `country_code`, `total` | **minúsculas**, esquema totalmente distinto; `country_code` = ISO-3166 alfa-2 |

**Marcadores sustituibles** (los tres, tanto en `sql` como en `sql_detalle`):

| Marcador | Se reemplaza por | Comillas |
|---|---|---|
| `?fechaInicial` | `'AAAA-MM-DD'` | sí, las pone el motor |
| `?fechaFinal` | `'AAAA-MM-DD'` | sí, las pone el motor |
| `?idImportador` | lista CSV de `id_websimex` | **no** — por eso se escribe `IN (?idImportador)` |

**Post-proceso de categorías** ([líneas 146-157](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L146-L157)):
el motor suma el `DATA` de cada categoría y reescribe la etiqueta como `"<CATEGORIA> - (<total>)"`.
Luego, para armar las series, recupera la categoría original con `trim(explode("-", $cat)[0])`
([línea 173](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L173)) — **se rompe si la categoría
contiene un guion**. Ver [D-09](#d-09).

### 5.3 Catálogo actual (25 filas, 21 activas)

| id | proceso | slug | tipo | detalle | estado | título |
|---:|:---:|---|---|:---:|:---:|---|
| 1 | I | `imp_volumen_medio_trans` | column | ✓ | 1 | Modo de Transporte Importaciones |
| 2 | I | `imp_porcentaje_medio_trans` | pie | ✓ | 1 | Porcentaje Modo de Transporte Importaciones |
| 3 | I | `imp_cantidad_unidad_carga` | column | ✓ | 1 | Cantidad Tamaño Unidad de Carga Importaciones |
| 4 | I | `imp_cantidad_unidad_carga_anual` | pie | — | 1 | Porcentaje Tamaño Unidad de Carga Importaciones |
| 5 | E | `imp_volumen_aduana_expo` | bar | ✓ | 1 | Volumen por Aduanas Exportaciones |
| 6 | E | `imp_volumen_aduana_expo_anual` | pie | — | 1 | Volumen por Aduanas Anual Exportaciones |
| 7 | E | `exp_pais_expo` | **map** | ✓ | 1 | Exportaciones x Países Destinatario |
| 8 | E | `exp_volumen_medio_trans` | pie | — | 1 | Volumen Modo de Transporte Exportaciones |
| 9 | I | `imp_tipo_unidad_de_carga` | column | ✓ | 1 | Tipos de Carga Importaciones Marítimo |
| 10 | I | `imp_porcentaje_tipo_de_carga` | pie | — | 1 | Porcentaje Tipos de Carga Importaciones Marítimo |
| 11 | I | `imp_dec_impo_declaración` ⚠ | column | ✓ | 1 | Tipos Declaración de Importación |
| 12 | E | `exp_numero_unidad_carga` | bar | ✓ | 1 | Cantidad Tipo de Carga Expo |
| 13 | E | `exp_cantidad_unidad_carga` | column | ✓ | 1 | Cantidad Tamaño Unidad de Carga Expo |
| 14 | I | `imp_dec_impo_selectividad_anual` | pie | — | 1 | Porcentaje Declaración de Importación Anual |
| 16 | I | `imp_volumen_aduana` | bar | ✓ | 1 | Volumen por Aduanas Importaciones |
| 17 | I | `imp_volumen_aduana_anual` | pie | — | 1 | Porcentaje por Aduanas Anual Importaciones |
| 26 | I | `imp_demoras` | pie | — | **0** | Cargos por Almacenaje / demoras por sucursal |
| 27 | I | `imp_volumen_aduana_mes` | bar | ✓ | **0** | Volumen por Mes Importaciones |
| 28 | E | `ex_cargos_almacenajes` | pie | — | **0** | Expo Cargos por Almacenajes |
| 35 | E | `exp_cant_contenedores_por_navieras` | pie | ✓ | 1 | Participación de líneas navieras por contenedores |
| 36 | I | `imp_can_conteneores_por_agente_transportador` ⚠ | pie | ✓ | 1 | Agente Transportador por número de contenedores |
| 37 | I | `imp_participación_proveedores_por_pedidos` ⚠ | pie | — | **0** | Pedidos por Proveedores |
| 38 | I | `imp_pais_origen` | **map** | ✓ | 1 | Importaciones x País Origen |
| 49 | I | `imp_cantidad_selectividad` | bar | ✓ | 1 | Tipo de Selectividad por declaración de Importación |
| 50 | I | `imp_porcentaje_selectividad` | pie | — | 1 | Porcentaje Tipo de Selectividad |

⚠ = slug con tilde o con typo (`conteneores`). Los slugs se emiten **como identificadores JavaScript
crudos**, ver [D-13](#d-13).

**Patrón observable:** los gráficos vienen casi siempre en pareja `column|bar` (volumen) + `pie`
(porcentaje) **sobre el mismo SQL duplicado**. De los 21 activos, ~8 son duplicados exactos de la
consulta de su pareja, con solo el `tipo` cambiado. En la V2 esto debería ser un solo indicador con
selector de representación.

### 5.4 Ejemplo comentado (gráfico id=1)

```sql
SELECT
  MONTHNAME(i.FECHA_LEVANTE)     AS CATEGORIA,
  mt.NOMBRE_MEDIO_TRANS          AS NAME,
  COUNT(DISTINCT edo.NUMERO_DO)  AS DATA
FROM estados_do edo
LEFT JOIN importadores       imp ON imp.ID_IMPORTADOR    = edo.ID_IMPORTADOR
LEFT JOIN importaciones      i   ON i.NUMERO_DO          = edo.NUMERO_DO
LEFT JOIN documentos_trasporte dt ON dt.NUMERO_DO        = edo.NUMERO_DO
LEFT JOIN medios_trasporte   mt  ON mt.codigo_medio_trans = dt.codigo_medio_trans
LEFT JOIN reportes.procesos  pr  ON pr.idproceso          = edo.subtipo_do
WHERE edo.ANULADO = 'N' AND edo.tipo_regimen = 'I'
  AND imp.ID_IMPORTADOR IN (?idImportador)
  AND i.FECHA_LEVANTE IS NOT NULL
  AND DATE(i.FECHA_LEVANTE) BETWEEN ?fechaInicial AND ?fechaFinal
GROUP BY mt.NOMBRE_MEDIO_TRANS;          -- ⚠ selecciona MONTHNAME pero NO agrupa por mes
```

**Bug de datos documentado:** el `SELECT` declara `MONTHNAME(...)` como `CATEGORIA` pero el
`GROUP BY` es solo por medio de transporte. Con `strict => false` en
[config/database.php](config/database.php) (sin `ONLY_FULL_GROUP_BY`), MySQL devuelve un mes
**arbitrario** por grupo. El gráfico de columnas "por mes" en realidad pinta una sola columna.
Ver [D-29](#d-29) — **hay que auditar los 21 SQL activos**, no solo este.

Nótese también que estos SQL ya hacen `JOIN reportes.procesos` — es decir, el catálogo de procesos
del ERP que la V2 va a explotar (§11) **ya está presente en las consultas, sin usarse en el agregado**.

---

## 6. Fuentes de información actuales

| # | Conexión | BD | Tablas usadas | Qué aporta | Escritura |
|---:|---|---|---|---|---|
| 1 | `mysql` *(default)* | `asercolg_po` | `graficos_dashboard`, `compania`, `menus` | Catálogo de gráficos, tenant, permisos | lectura |
| 2 | `websimex` | `comex` | `estados_do`, `importadores`, `importaciones`, `exportaciones`, `documentos_trasporte`, `documentos_transportes_expos`, `medios_trasporte`, `ex_modos_transporte`, `ex_paises`, `ex_solicitudes_aut_embarques`, `sucursales`, `unidades_carga`, `tamanos_contenedores`, `formularios_dis`, `viajes_motonaves`, `cl_dex`, `reportes.procesos` | **Toda la data transaccional aduanera** | SOLO LECTURA |
| 3 | `websystem` | `asercol` | `estadodehechos.estadohechos` | Hitos/etapas por DO (solo etapa `3001` = zarpe, en Expo) | SOLO LECTURA |

### 6.1 Campos de corte temporal (clave para entender los números)

| Superficie | Régimen | Campo de fecha | Base |
|---|---|---|---|
| Tarjetas KPI Impo | `I`, `T` | `importaciones.FECHA_LEVANTE` | websimex |
| Tarjetas KPI Expo | `E` | `estadohechos.fecha` con `etapa = '3001'` | **websystem** |
| Gráficos Impo | `I` | `importaciones.FECHA_LEVANTE` | websimex |
| Gráficos Expo | `E` | `exportaciones.FECHA_ZARPE` | **websimex** |

> **El mismo concepto ("zarpe") se lee de dos sistemas distintos** según la superficie. Si
> `estadohechos.etapa=3001` y `exportaciones.FECHA_ZARPE` no están perfectamente sincronizados, la
> tarjeta y el gráfico de exportaciones mostrarán totales diferentes para el mismo periodo. Ver [D-22](#d-22).

### 6.2 Llave de integración

`NUMERO_DO` (**string**, nunca castear a int) une `asercolg_po` ↔ `comex` ↔ `asercol`.
`compania.id_websimex` ↔ `estados_do.ID_IMPORTADOR` es la llave de tenant.

---

## 7. Modelos Eloquent asociados

| Modelo | Conexión | Tabla | Uso en el Dashboard |
|---|---|---|---|
| [App\Compania](app/Compania.php) | `mysql` | `compania` | resolución de tenant en los 4 componentes |
| [App\Models\Pedido](app/Models/Pedido.php) | `websimex` | `estados_do` | base de las dos consultas de tarjetas KPI |
| [App\Models\EstadoHecho](app/Models/EstadoHecho.php) | `websystem` | `estadodehechos.estadohechos` | zarpes en `ExportacionesTotaleStats` |
| — | `mysql` | `graficos_dashboard` | **sin modelo**, se usa `DB::table()` |

Notas:

- `App\Models\Pedido` es un modelo muy grande (≈40 relaciones). El Dashboard **no usa ninguna de sus
  relaciones**: arma los joins a mano con `join()`/`leftJoin()`. Las relaciones existentes que la V2
  podría aprovechar: `estadoHecho()`, `estadoHechoSinInterrupciones()`, `importaciones()`,
  `documentos_tr()`, `documento_transporte_expo()`, `unidad_carga()`, `declaracion_impo()`,
  `proceso()` (→ `ProcesosWebSimex`), `sub_proceso()`, `sucursales()`, `importadores()`.
- `App\Compania` es el modelo **legacy** (`App\`, no `App\Models\`). Sin `$fillable`, sin scopes.
  Es el que usan los 4 componentes. Respetar esta elección o migrar el módulo completo, no a medias
  (CLAUDE.md §4).
- `EstadoHecho` declara `$table = 'estadodehechos.estadohechos'` — referencia cross-schema dentro de
  la conexión `websystem`. Mismo patrón que `reportes.procesos` y `asercol.graficos` en `websimex`.

### 7.1 Trabajo en curso no commiteado (rama `mayala`)

Hay dos archivos en el working tree que apuntan hacia la V2:

```
M  app/Models/ProcesosWebSimex.php     (+ relación graficosAsercol)
?? app/Models/GraficosAsercol.php      (nuevo, tabla asercol.graficos)
```

**Ambos tienen defectos que hay que corregir antes de construir sobre ellos** — ver [D-39](#d-39):

```php
// app/Models/ProcesosWebSimex.php  — tabla reportes.procesos, PK real = idproceso
public function graficosAsercol(){
    return $this->hasMany(GraficosAsercol::class, 'fidproceso', 'id');   // ⚠ 'id' NO existe
}

// app/Models/GraficosAsercol.php — tabla asercol.graficos, PK real = idgrafica
class GraficosAsercol extends Model { /* falta $primaryKey, $keyType, $timestamps = false */ }
```

---

## 8. Renderizado en el front-end

### 8.1 Librerías cargadas

[resources/views/Dashboard/dashboard-importaciones.blade.php](resources/views/Dashboard/dashboard-importaciones.blade.php)

| Librería | CDN | ¿Se usa? |
|---|---|---|
| Tailwind CSS | `cdn.tailwindcss.com` (JIT runtime) | sí |
| Alpine.js 3.12 | jsdelivr | sí (`x-data` del overlay de carga) |
| **Highmaps** (`maps/highmaps.js`) | code.highcharts.com | sí — provee `Highcharts.chart()` |
| Highcharts modules: `data`, `exporting`, `export-data`, `accessibility` | code.highcharts.com | parcial |
| jQuery 3.7.1 | code.jquery.com | **no** |
| Google Charts loader + `geochart` | gstatic.com | sí (mapas) |
| FontAwesome kit | kit.fontawesome.com | no verificado |

> Se cargan **dos motores de mapas** (Highmaps y Google GeoChart). El código de mapas de Highcharts
> está comentado (70 líneas, [líneas 410-478](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L410-L478))
> porque se migró a GeoChart, pero la librería sigue cargándose — y de hecho ahora es la que provee
> `Highcharts.chart()` para el resto de gráficos. Ninguna versión está pineada.

### 8.2 Generadores de configuración

Cinco métodos en `ImportacionesGraph` devuelven `json_encode(...)` con el objeto de opciones:

| Método | Motor | Notas |
|---|---|---|
| `line()` | Highcharts | **sin `chart.type`** — funciona por ser el default; ningún gráfico lo usa |
| `bar()` | Highcharts | `borderRadius: '50%'`, `dataLabels` on |
| `column()` | Highcharts | `crosshair`, `dataLabels` on |
| `pie()` | Highcharts | dona (`innerSize: '75%'`), doble `dataLabels` (nombre fuera, % dentro) |
| `map()` | Google GeoChart | devuelve `{data, title, mainColor}`; la vista arma las opciones |

El color del GeoChart sale de `session('color')` con un mapeo puntual: `'sky' → 'blue'`, en otro caso
el propio valor de sesión, con fallback `'#2e7d32'`. Mezcla nombres Tailwind y hex en la misma
variable ([línea 493](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L493)).

### 8.3 Inyección en el DOM

```blade
<div id="{{ $graph->slug }}" style="height: 500px; width:600px;">
  <script>
    Highcharts.chart(@php echo $graph->slug; @endphp, @php echo $graph->grafico; @endphp);
  </script>
</div>
```

Tres problemas en cuatro líneas: el `slug` se emite **sin comillas** como identificador JS (funciona
solo porque los `id` del DOM crean globals en `window`), el tamaño es **fijo en píxeles** (no
responsive, dentro de un grid de 2 columnas), y el `<script>` vive **dentro del `@foreach` y dentro
del nodo que Livewire reemplaza** en cada re-render. Ver [D-13](#d-13), [D-14](#d-14), [D-15](#d-15).

### 8.4 Drill-down y exportación a Excel

- **Ver detalle:** `toggleDetalle(slug)` quita el atributo `hidden` de un modal cuyo contenido es el
  HTML pre-renderizado en `$graph->detalle`. Sin paginación: si el detalle son 50.000 filas, están
  todas en el HTML de la página desde la carga inicial.
- **Exportar detalle:** `POST /api/download-excel` con `{cabecera, cuerpoReporte}` → `ReporteSemanalRpExport`
  → blob → `<a download>`. El botón embebe el payload en el atributo `onclick`:
  ```blade
  onclick="exportarDetalleExcel({{ json_encode($graph->detalleExportar) }}, {{ json_encode($graphs) }}, '{{ $graph->slug }}')"
  ```
  El **segundo argumento (`$graphs` completo, con el SQL de todos los gráficos) no se usa** dentro de
  la función JS. Ver [D-05](#d-05).
- La ruta [routes/api.php:119](routes/api.php#L119) está **fuera de cualquier grupo `auth`**
  (solo aplica `throttle:60,1` + `bindings`). No consulta la BD — devuelve lo que se le envía — pero es
  un endpoint público de generación de archivos. Ver [D-05](#d-05).

---

## 9. Aislamiento multi-cliente (tenant)

El módulo aplica el patrón estándar del CTC (CLAUDE.md §1): los 4 componentes leen
`session('GrupoEmpresarial')` en `mount()` y derivan la lista de `id_websimex`.

```php
$this->idGrupoEmpresarial = session()->get('GrupoEmpresarial');
$compania = Compania::where('id_grupo_empresarial', $this->idGrupoEmpresarial)
                    ->where('inactivo', 0)->get();
$this->compania = $compania->pluck('id_websimex')->toArray();
```

**Dónde se rompe:**

| Punto | Riesgo |
|---|---|
| `ImportacionesGraph::consultarData($periodo, $importador)` | `$importador` sustituye la lista de tenant **sin validarse** contra `$this->compania`, y se interpola literal en el SQL → [D-01](#d-01) |
| `ImportacionesTotaleStats::setPeriodo()` línea 153 | `$this->compania = [$importador]` sin validar → salto de tenant (vía Eloquent, sin inyección) |
| `ExportacionesTotaleStats::setPeriodo()` línea 183 | idéntico |
| `EstadoHecho::where('etapa','3001')...->pluck('numdo')` | consulta **sin filtro de tenant alguno**; se acota después por `whereIn` sobre `estados_do`, pero el conjunto intermedio incluye DOs de todos los clientes de Asercol → [D-03](#d-03) |
| `$this->compania` es propiedad **pública** de Livewire | el array de `id_websimex` viaja al navegador y es manipulable en el payload |

> **Estas tres entradas (`importador` en los tres componentes) son propiedades públicas de Livewire
> enlazadas a un `<select>`; su valor lo controla el navegador.** El `<select>` solo se muestra si el
> grupo tiene más de una compañía, pero el evento se puede emitir igual.

---

## 10. Deudas técnicas y riesgos

Clasificación: 🔴 crítico · 🟠 alto · 🟡 medio · ⚪ bajo.

### Seguridad

<a id="d-01"></a>**D-01 🔴 Inyección SQL + salto de tenant en el motor de gráficos.**
[ImportacionesGraph.php:71-73,125](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L71-L73)

```php
if (!is_null($importador) && !empty($importador)) { $clientes = $importador; }
...
$sql = str_replace('?idImportador', $clientes, $sql);     // sin comillas, sin binding, sin validación
```
`$importador` viene del payload del evento `setPeriodo`, originado en una propiedad pública de
Livewire (`ImportacionesAdmin::$idCompania`). Un valor como `2435) OR 1=1 -- ` o
`0 UNION SELECT ...` se ejecuta tal cual contra `websimex` con el usuario `ucomex`. Resultado: lectura
de datos de **cualquier cliente** de Asercol, y potencialmente de cualquier tabla del ERP.
**Corrección mínima inmediata:** validar `in_array($importador, $this->compania, true)` antes de usarlo,
y construir la lista con `implode(',', array_map('intval', $ids))`.

<a id="d-02b"></a>**D-01b 🟠 Salto de tenant en las tarjetas KPI.**
[ImportacionesTotaleStats.php:152](app/Http/Livewire/Dashboard/ImportacionesTotaleStats.php#L152) ·
[ExportacionesTotaleStats.php:182](app/Http/Livewire/Dashboard/ExportacionesTotaleStats.php#L182).
Mismo `$importador` sin validar. Aquí va por binding de Eloquent (no hay inyección), pero permite
consultar los totales de un `ID_IMPORTADOR` ajeno al grupo.

<a id="d-05"></a>**D-05 🟠 Fuga del SQL de negocio al HTML + peso de página.**
[importaciones-graph.blade.php:155](resources/views/livewire/dashboard/importaciones-graph.blade.php#L155).
`json_encode($graphs)` se serializa en el `onclick` de **cada** botón "Exportar detalle" (uno por
gráfico, hasta 21 veces), e incluye `sql`, `sql_detalle`, `detalle` (HTML completo) y `grafico` de
**todos** los gráficos. El argumento ni siquiera se usa en la función JS. Además, `$graphs` es
propiedad pública, así que el mismo contenido ya viaja en `wire:initial-data`.

<a id="d-06"></a>**D-06 🟡 XSS potencial en el detalle.**
[ImportacionesGraph.php:497-520](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L497-L520) concatena
valores del ERP en HTML sin `htmlspecialchars`, y la vista lo imprime con `{!! !!}`. El origen es el
ERP (baja probabilidad), pero basta un nombre de proveedor con `<` para romper la tabla.

**D-05b 🟡 Endpoint de exportación sin autenticación.**
[routes/api.php:119](routes/api.php#L119) — `POST /api/download-excel` fuera de todo grupo `auth`.
No lee la BD, pero permite a cualquiera generar archivos xlsx en el servidor (sync, ver D-27).

<a id="d-46"></a>**D-46 🔴 Inyección SQL por el periodo en el motor de gráficos.** *(Hallada 2026-09-30.)*
El **año** del payload `setPeriodo` se interpolaba literal en `?fechaInicial`/`?fechaFinal`. `explode('-')`
impedía el guion pero no la comilla: `2026' OR 1=1 #` llegaba a `websimex`. **Corregida en la V1** con
`ConPeriodoValidado` (decisión T, [SPEC_Dashboard_V2.MD §7.1](SPEC_Dashboard_V2.MD)).

> **2026-09-30 — decisión T.** D-03, D-05, D-06, D-13, D-14, D-24, D-25 (detalle), D-36 y D-46 quedaron
> resueltas en la V1 sin cambiar ninguna cifra. Detalle y mediciones en SPEC_Dashboard_V2.MD §7.1.

### Corrección de datos

<a id="d-18"></a>**D-18 🔴 El "periodo anterior" de Importaciones está mal calculado (dos bugs).**
[ImportacionesTotaleStats.php:74,77](app/Http/Livewire/Dashboard/ImportacionesTotaleStats.php#L74)

```php
->where('estados_do.ID_IMPORTADOR', $this->compania)     // ⚠ where() con un ARRAY (debe ser whereIn)
->selectRaw('... count(*) as TOTAL')                     // ⚠ debe ser count(distinct NUMERO_DO)
```
La consulta del periodo actual (líneas 60 y 63) sí usa `whereIn` y `COUNT(DISTINCT ...)`. El
`COUNT(*)` infla el total por los joins con `documentos_trasporte`, de modo que **el porcentaje de
variación que ve el cliente es incorrecto**. Prioridad de corrección alta: es un número visible.

<a id="d-29"></a>**D-29 🔴 `GROUP BY` incompleto en los SQL configurados.**
Al menos los gráficos id=1 e id=2 hacen `SELECT MONTHNAME(...) AS CATEGORIA ... GROUP BY NOMBRE_MEDIO_TRANS`.
Con `'strict' => false` en [config/database.php](config/database.php) MySQL no rechaza la consulta y
devuelve un mes arbitrario → el gráfico "por mes" pinta una sola categoría.
**Acción V2: auditar los 21 SQL activos uno por uno** (ver anexo 14.1 con el volcado completo).

<a id="d-20"></a>**D-20 🟠 El "periodo anterior" no es comparable con el periodo consultado.**
Los tres componentes calculan el anterior como `fechaInicial - 1 mes`, del día 1 al último día.
Si el usuario consulta **el año 2025 completo** (12 meses), el comparativo es **diciembre 2024**
(1 mes). El porcentaje de variación resultante no significa nada. Debe ser periodo homólogo:
mismo largo, mes anterior o mismo mes del año anterior (decisión de negocio a tomar en la V2).

<a id="d-22"></a>**D-22 🟠 Dos fuentes para el mismo hecho (zarpe).**
Tarjeta Expo → `websystem.estadohechos` etapa `3001`. Gráficos Expo → `websimex.exportaciones.FECHA_ZARPE`.
Descuadres garantizados si los dos sistemas no van sincronizados. Definir **una** fuente canónica por hecho.

<a id="d-09"></a>**D-09 🟠 Categorías rotas por el guion separador.**
[ImportacionesGraph.php:156,173](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L156). La
etiqueta se construye como `"$cat - ($total)"` y se re-parsea con `explode("-", $cat)[0]`.
Cualquier categoría que contenga `-` (nombres de aduana, códigos, rangos) pierde su valor y la serie
sale en 0. Riesgo real en `imp_volumen_aduana`, `exp_pais_expo`, `imp_cantidad_unidad_carga`.

<a id="d-07"></a>**D-07 🟡 Heurística de fecha falsamente positiva en el detalle.**
[ImportacionesGraph.php:512](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L512):
`strtotime($valor) !== false ? date('Y-m-d', strtotime($valor)) : $valor`. Valores numéricos
(`"10"`, `"2024"`, `"now"`) se convierten a fecha. Debe decidirse por el **tipo de columna**, no por
el contenido.

**D-08 🟡 Parseo de periodo frágil.**
[ImportacionesGraph.php:82-83](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L82):
`(!is_null($per[1]) || !empty($per[1]))` es siempre `true` si el índice existe; y
`$this->mes ?? str_pad(...)` nunca aplica el padding porque `$this->mes` es `''`, no `null`. El mes
llega a MySQL como `'2026-3-01'` — funciona por tolerancia de MySQL, no por diseño. Además
`explode("-", $periodo)` no valida que exista `$per[1]`.

<a id="d-11"></a>**D-11 🟡 El tipo `map` funciona por accidente.**
El SQL de mapa devuelve `country/country_code/total`, no `CATEGORIA/NAME/DATA`. Por eso
`collect($data)->pluck('NAME')` devuelve `[null]` y el `foreach` de series se ejecuta exactamente una
vez, que es lo que el `case 'map'` necesita. Con dos series el `$this->dataCsv` se reiniciaría.

**D-19 ⚪ Variación 0 % indistinguible de "sin comparativo".**
Si no hay registro en el periodo anterior, `diferencia = 0` y `p_diferencia = 0.00` se pintan con la
flecha **verde** de crecimiento. Debe existir un estado "sin dato comparativo".

### Rendimiento

<a id="d-03"></a>**D-03 🔴 Join entre servidores materializado en PHP sin filtro de tenant.**
[ExportacionesTotaleStats.php:58-59](app/Http/Livewire/Dashboard/ExportacionesTotaleStats.php#L58):
`EstadoHecho::where('etapa','3001')->whereBetween('fecha',[...])->pluck('numdo')` trae **todos los
zarpes de todos los clientes de Asercol** del periodo (para un año, potencialmente decenas de miles
de filas), los carga en memoria PHP y los envía de vuelta en un `whereIn(...)` de tamaño equivalente.
Se hace **dos veces** (periodo actual + anterior) en cada carga y en cada cambio de filtro.

**D-25 🟠 N+1 estructural: 45-65 consultas por carga.**
Por cada uno de los 21 gráficos activos se ejecutan hasta 3 consultas (`SET lc_time_names`, `sql`,
`sql_detalle`), más 4 de las tarjetas, más las del orquestador. Todas contra el ERP, **sin caché**,
en `mount()` y otra vez completas en cada `setPeriodo`.

<a id="d-36"></a>**D-36 🟠 Payload de Livewire desmedido.**
`$graphs`, `$series`, `$data`, `$csv`, `$dataCsv`, `$tablaDetalle` son propiedades **públicas**:
Livewire 2 las serializa en `wire:initial-data` y las reenvía en cada request. Incluyen el HTML
completo del detalle de los 21 gráficos. Deben ser `protected` y pasarse por `render()`.

<a id="d-24"></a>**D-24 🟡 Agregación pesada en `render()`.**
[ImportacionesAdmin.php:51](app/Http/Livewire/Dashboard/ImportacionesAdmin.php#L51):
`MIN/MAX(YEAR(FECHA_APERTURA))` sobre `estados_do` se ejecuta en **cada ciclo de render** de Livewire.
El código correcto está en `mount()`, comentado (líneas 36-44).

<a id="d-27"></a>**D-27 🟡 Sin caché y con `QUEUE_CONNECTION=sync`.**
Ningún resultado se cachea. La generación de Excel corre dentro del request (CLAUDE.md, "Trampas
frecuentes"). Un detalle grande = timeout.

**D-28 🟡 Sin manejo de errores.**
Si un SQL configurado falla (typo, tabla renombrada en el ERP, columna eliminada), la excepción no se
captura y **el dashboard completo queda en blanco**. Cada gráfico debería fallar de forma aislada.

### Arquitectura y mantenibilidad

<a id="d-02"></a>**D-02 🟠 `compania.procesos` decide el catálogo, y se lee de una sola compañía.**
[ImportacionesGraph.php:68,74](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L68):
`Compania::where(...)->first()->procesos`. Si un grupo tiene compañías con `procesos` distintos, gana
la primera por orden de `id`. Y hay compañías con `procesos` **vacío** (verificado: `EXIROS DE
COLOMBIA LTDA`, grupo 23) → `explode(',', '')` devuelve `['']` → `whereIn('proceso', [''])` →
**cero gráficos, dashboard en blanco sin mensaje**.

<a id="d-17"></a>**D-17 🟠 Triplicación de la lógica de periodo y duplicación de las tarjetas.**
El bloque de resolución de fechas (~40 líneas) está copiado en `ImportacionesGraph::consultarData()`,
`ImportacionesTotaleStats::setPeriodo()` y `ExportacionesTotaleStats::setPeriodo()`, con divergencias
(`empty($this->mes)` vs `is_null($mes) || empty($mes)`). Los dos componentes de tarjetas son ~90 %
idénticos, incluidos los SVG inline repetidos carácter por carácter en ambos archivos.

**D-32 🟠 Catálogo sin dimensión de cliente, y su hermano con duplicación masiva.**
`graficos_dashboard` **no tiene** `id_grupo_empresarial`: los 21 gráficos son iguales para todos los
clientes; no se puede personalizar ni ocultar uno para un cliente concreto. Su hermano `graficos_kpi`
(mismo motor, ver §11.4) **sí** lo tiene, y por eso tiene **196 filas para 16 grupos** — el mismo SQL
copiado decenas de veces. Los dos extremos del mismo error de modelado. La V2 necesita
**catálogo + tabla de asignación**.

<a id="d-04"></a>**D-04 🟡 `sql_detalle` ignora `$graph->conexion`.**
[ImportacionesGraph.php:137](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L137) está
hardcodeado a `DB::connection('websimex')`, mientras el agregado sí respeta `$conexion` (línea 131).
Hoy no se nota porque los 25 gráficos usan `websimex`; se romperá con el primer gráfico de `cti` o `central`.

**D-33 🟡 `graficos_dashboard` sin modelo Eloquent.** Se accede con `DB::table()`, sin casts, sin
scopes, sin timestamps, sin validación. `graficos_kpi` está igual.

<a id="d-30"></a>**D-30 🟡 Código muerto.**
`DetalleGraph` + su vista (nunca instanciados). `ImportacionesAdmin::obtenerMesesHastaFechaActual()`
(nunca llamado). `ImportacionesGraph::line()` (ningún gráfico usa `line`). El bloque `map()` de
Highcharts comentado (70 líneas). `emit('refreshComponent')` en
[ImportacionesGraph.php:234](app/Http/Livewire/Dashboard/ImportacionesGraph.php#L234) — **ningún
componente del Dashboard escucha ese evento** (sus listeners son `setPeriodo` y `loadComponent`).
Imports sin usar. Filas con `estado = 0` en el catálogo (ids 26, 27, 28, 37).

**D-35 🟡 Propiedades dinámicas sobre `stdClass`.**
`$graphs[$index]->detalle`, `->detalleExportar`, `->csv`, `->grafico` se agregan a objetos que vienen
de `DB::table()->get()->toArray()`. Es *deprecated* en PHP 8.2 y **bloquea el upgrade de PHP**, que hoy
está fijado en 7.3 (CLAUDE.md §2). En la V2 usar DTOs explícitos.

<a id="d-39"></a>**D-39 🟠 Los modelos nuevos de la V2 (sin commitear) tienen la llave mal.**
`ProcesosWebSimex::graficosAsercol()` declara `localKey = 'id'`, pero `reportes.procesos` **no tiene
columna `id`** (sus columnas son `idproceso, nombre, nombrecorto, tabla, gdocumen`) → la relación
lanzará `Unknown column 'procesos.id'`. Debe ser `'idproceso'`. Y `GraficosAsercol` no declara
`$primaryKey = 'idgrafica'`, `$keyType`, ni `$timestamps = false` (la tabla no tiene `created_at`).

### Front-end

<a id="d-13"></a>**D-13 🟠 El `slug` se emite como identificador JavaScript sin comillas.**
[importaciones-graph.blade.php:108](resources/views/livewire/dashboard/importaciones-graph.blade.php#L108):
`Highcharts.chart(@php echo $graph->slug; @endphp, ...)`. Funciona solo porque los `id` del DOM crean
variables globales. Hay slugs con tildes (`imp_dec_impo_declaración`,
`imp_participación_proveedores_por_pedidos`). Un slug con guion, espacio o que empiece por dígito
rompe la página entera con un `SyntaxError`. Debe ser `Highcharts.chart('{{ $graph->slug }}', ...)`
o, mejor, `document.getElementById(...)`.

<a id="d-14"></a>**D-14 🟠 `<script>` dentro del nodo que Livewire re-renderiza.**
Los scripts de inicialización están dentro del `@foreach` **y dentro del div del gráfico**. Livewire 2
no re-ejecuta de forma fiable los `<script>` al aplicar el diff del DOM, de ahí el `wire:ignore`
ausente y el `emit('refreshComponent')` huérfano. La V2 debe inicializar desde un único
`Livewire.hook('message.processed')` o usar Alpine con `x-init`.

<a id="d-15"></a>**D-15 🟡 Gráficos de tamaño fijo dentro de un grid responsive.**
`style="height: 500px; width:600px;"` en un `grid grid-cols-2`. En pantallas menores a ~1300 px los
gráficos se desbordan o se recortan. Ya se marcó `col-span-2` para los mapas como parche.

**D-16 🟡 El iframe fija una relación de aspecto 16:9.**
[dashboard-iframe.blade.php](resources/views/Dashboard/dashboard-iframe.blade.php) usa el truco
`padding-top: 56.25%`, lo que obliga a scroll interno en un dashboard que crece verticalmente con el
número de gráficos.

**D-12 🟡 Dos motores de mapas cargados, ninguno pineado.**
Highmaps + Google GeoChart. Se depende de la versión *latest* de los CDN de Highcharts: un cambio
mayor upstream rompe producción sin desplegar nada.

**D-26 🟡 Clases Tailwind dinámicas.**
`bg-{{ $color }}-500`, `hover:bg-{{ $color }}-500`, `text-{{ $item['diferencia'] >= 0 ? 'green':'red' }}-600`.
Funcionan porque Tailwind se carga por CDN (JIT en runtime). **Si alguna vez se compila Tailwind, se
pierden todas.** Migrar a mapa explícito de clases o a variables CSS.

**D-21 ⚪ Enlaces muertos.** "View all" con `href="#"` en las tarjetas de ambos componentes.

**D-23 ⚪ Se pueden seleccionar meses futuros** (el select siempre trae los 12), aunque existe el
método que lo evitaría (ver D-30).

**D-31 ⚪ La vista contenedora no usa `layouts.tailwind`.**
[dashboard-importaciones.blade.php](resources/views/Dashboard/dashboard-importaciones.blade.php) es
HTML suelto que reimplementa lo que ya hace [resources/views/layouts/tailwind.blade.php](resources/views/layouts/tailwind.blade.php)
(que también carga Highmaps y los mismos módulos). Rompe el patrón documentado en CLAUDE.md
"Cómo agregar un módulo", paso 3.

---

## 11. Fuentes adicionales disponibles para la V2

Esta es la sección que responde al objetivo de *"conectar con otras fuentes de información adicionales
a las que ya están en este módulo"*. Todo lo siguiente **ya existe y está poblado**.

### 11.1 `asercol.graficos` (conexión `websimex`) — catálogo de indicadores de tiempo con meta

**60 filas, 52 activas.** Es un motor de indicadores **completamente distinto** al de
`graficos_dashboard`: en vez de guardar SQL, describe **declarativamente** un indicador de
*tiempo transcurrido entre dos actividades*, con una meta en días.

| Columna | Tipo | Significado |
|---|---|---|
| `idgrafica` | `int(6)` | PK |
| `fidproceso` | `int(11)` | FK → `reportes.procesos.idproceso` |
| `reporte` | `varchar(30)` | nombre corto (`INICIO-LEVANTE`, `ZARPE-FACTURACION`) |
| `codactini` / `actini` | `varchar(20)` | **código(s)** y etiqueta(s) de la actividad inicial (CSV) |
| `codactfin` / `actfin` | `varchar(20)` | código y etiqueta de la actividad final |
| `tablaactfinws` / `campoactfinws` | `varchar` | tabla y campo de **websimex** donde leer la fecha final |
| `meta` | `int(4)` | **objetivo en días** |
| `ultactividad` | `varchar(10)` | código de la actividad de cierre del proceso |
| `descreporte` | `varchar(255)` | descripción de negocio para el usuario final |
| `defecto` | `char(1)` | `'S'` = gráfico por defecto del proceso |
| `orden` | `int(11)` | orden de presentación |
| `inactivo` | `int(11)` | `0` = activo |
| `campos` | `longtext` | CSV de columnas del detalle |
| `encabezados` | `longtext` | CSV de encabezados legibles del detalle |
| `actividades` | `longtext` | CSV de códigos de etapa a materializar como columnas |
| `actividadkey` / `actividadcontrol` | `varchar(10)` | etapa llave / de control |

**Ejemplo real (idgrafica = 1):**

```
reporte      : INICIO-LEVANTE
fidproceso   : 1  (IMPORTACIONES POR PUERTO)
codactini    : 0112,0503        actini : ETA,DCTOS,FINALIZACI
codactfin    : 2001             actfin : LEVANTE
tablaactfinws: importaciones    campoactfinws: FECHA_LEVANTE
meta         : 2 días           ultactividad : 3003
defecto      : S                orden: 7
descreporte  : "Mide el tiempo transcurrido entre el INICIO de la operación hasta la obtención
                del levante. Se considera INICIO cuando se han cumplido 2 eventos: Documentos
                Completos y Finalización en Muisca"
actividades  : 0102,0112,0501,0505,0503,1512,2001,3002,3003,3504,2004,0117,0118
campos       : APERTURA_DO,DCTOS_COMPLETOS,AVISO_LLEGADA,DESCARGUE,FINALIZACION_MUISCA,PAGO_IMPTOS,
                LEVANTE,DCTOS_TRANSP,RETIRO,FACTURA,INGRESO_FX,SOLICITUD,APROBACION
encabezados  : DO,DIAS,PEDIDO,CLIENTE,APERTURA DO,DCTOS COMPLETOS,... (mismos + 4 de contexto)
```

**Por qué importa para la V2:** aporta la dimensión que hoy **falta por completo** en el Dashboard —
**cumplimiento contra meta / SLA**. Hoy el módulo solo responde *"¿cuántas operaciones?"*; con esta
fuente puede responder *"¿cuánto nos tardamos y estamos dentro de la meta?"*. Y lo hace de forma
declarativa, sin SQL almacenado, lo que resuelve de raíz [D-01](#d-01).

**Advertencia:** `codactini`/`actini` son CSV con cardinalidades distintas entre sí
(`'0112,0503'` = 2 códigos vs `'ETA,DCTOS,FINALIZACI'` = 3 etiquetas). No son listas paralelas.
Hay que entender la semántica con el negocio antes de codificar.

### 11.2 `reportes.procesos` (conexión `websimex`) — taxonomía real de procesos

**15 filas.** Es la clasificación fina de operación del ERP, que se cruza con `estados_do.SUBTIPO_DO`.
Hoy el Dashboard la joinea en varios SQL **pero nunca la usa** para segmentar.

| idproceso | nombre | nombrecorto | tabla |
|---:|---|---|:---:|
| -1 | SIN ESPECIFICAR | SIN_ESPE | |
| 1 | IMPORTACIONES POR PUERTO | IMP PUE | I |
| 2 | EXPORTACIONES | EXP | E |
| 3 | TRÁNSITO ADUANERO | TA | I |
| 4 | INGRESOS A ZONA FRANCA | ING ZF | I |
| 5 | SALIDA A TERCEROS PAÍSES DE ZF | SAL ZF | E |
| 6 | DEPÓSITO PRIVADO | DEP_PRI | I |
| 7 | IMPORTACIONES DESDE ZONA FRANCA | IMP ZF | I |
| 8 | IMPORTACIONES DESDE DEPÓSITO | IMP DEP | I |
| 9 | SALIDA A TERCEROS PAÍSES DTAI | SAL DTAI | I |
| 10 | INGRESO ZF MCIA. NACIONAL | ING ZF MN | E |
| 11 | NACIONALIZACIÓN AEROPUERTO | NAC AERO | I |
| 12 | EXPORTACIÓN AEROPUERTO | EXP AERO | E |
| 13 | PAGOS COURIER | COURIER | I |
| 14 | PRÓRROGAS TEMPORALES | PRORROGAS | I |

**Sustituye con ventaja a `compania.procesos`** (el CSV `'I,E'` de [D-02](#d-02)): la columna `tabla`
da el régimen (I/E) y el `idproceso` da la granularidad real. La V2 debería permitir filtrar el
dashboard por proceso (ej. "solo Zona Franca") en vez de solo por régimen.

Modelo ya existente: [App\Models\ProcesosWebSimex](app/Models/ProcesosWebSimex.php) (corregir la
relación, [D-39](#d-39)). Ya está enlazado desde `Pedido::proceso()` por `SUBTIPO_DO`.

### 11.3 `estadodehechos.estadohechos` (conexión `websystem`) — trazabilidad por hitos

Hoy se usa **una sola etapa** (`3001` = zarpe) y solo en las tarjetas de exportación. Es la tabla que
alimenta los indicadores de `asercol.graficos`. Códigos observados en el catálogo:

| Código | Hito |
|---|---|
| `0102` | Apertura DO |
| `0112` | ETA / Aviso de llegada |
| `0117` / `0118` | Solicitud / Aprobación |
| `0501` | Aviso de llegada |
| `0503` | Documentos completos |
| `0505` | Descargue (`scopeDescargado` en el modelo) |
| `1512` | Pago de impuestos |
| `2001` | **Levante** |
| `2004` | Ingreso FX |
| `3001` | **Zarpe** |
| `3002` | Documentos de transporte |
| `3003` | Retiro / cierre (`scopeLiberado` excluye `3003`) |
| `3504` | Factura |

Modelo: [App\Models\EstadoHecho](app/Models/EstadoHecho.php) (hoy mínimo: 2 `$fillable`, 2 scopes).
Relaciones ya disponibles en `Pedido`: `estadoHecho()` y `estadoHechoSinInterrupciones()`
(esta última excluye `tipo = 2`, las interrupciones — **dato clave para calcular tiempos netos**).

> ⚠ Es la fuente más pesada del conjunto y la que hoy causa [D-03](#d-03). Cualquier uso en la V2 debe
> partir de `NUMERO_DO` acotado por tenant, nunca de un rango de fechas global.

### 11.4 `graficos_kpi` (conexión `mysql`) — el motor gemelo, ya con cumplimiento

Usado por [app/Http/Livewire/Kpi/IndicadoresV2.php](app/Http/Livewire/Kpi/IndicadoresV2.php).
**196 filas (180 activas) para 16 grupos empresariales.** Esquema **casi idéntico** a
`graficos_dashboard`, con tres columnas extra:

| Columna extra | Tipo | Aporte |
|---|---|---|
| `id_grupo_empresarial` | `int(11)` | **personalización por cliente** (lo que le falta a `graficos_dashboard`) |
| `obs_cumplimiento` | `int(11)` | umbral/observación de cumplimiento |
| `campo_llave_cumplimiento` | `varchar(50)` | columna del resultado que define el cumplimiento |

`IndicadoresV2` replica el mismo intérprete (mismos marcadores, mismo `DB::connection($conexion)->select($sql)`,
mismo `SET lc_time_names`, mismos generadores de JSON). **Son dos implementaciones del mismo motor
que han divergido.** Unificarlas es probablemente la mayor ganancia estructural de la V2.

### 11.5 Otras fuentes ya integradas en el CTC (no usadas por el Dashboard)

| Fuente | Dónde vive | Qué aportaría |
|---|---|---|
| `App\Services\ShipsgoService` | [app/Services/ShipsgoService.php](app/Services/ShipsgoService.php) | tracking marítimo en tiempo real, ETA vs ETD reales, demoras de naviera |
| Órdenes de compra CTC | `PO` / `ex_po` + `App\Models\Po` | vista comercial: pedidos vs operaciones, cobertura |
| `documentos_aws` (conexión `cti`) | `App\Models\DocumentosAwsCti` | completitud documental por DO |
| `pendientes_operacion_do` | `Pedido::pendientes()` | cuellos de botella operativos |
| `MovimientoDo` (bodegaje) | `Pedido::movimientosBodegaje()` | costos de almacenaje/demoras — resucitaría los gráficos 26 y 28, hoy en `estado = 0` |
| `DiasDevolucionContenedor` | `Pedido::diasDevolucionContenedor()` | detención/demurrage |
| `unidades_carga` + `inspecciones` | `Pedido::unidad_carga()` | inspecciones y aforos |
| `App\JMetodos` | [app/JMetodos.php](app/JMetodos.php) | motor de columnas y **semáforos configurables** por cliente — reutilizable para los umbrales de meta |

---

## 12. Recomendaciones de arquitectura para la V2

### 12.1 Prioridad 0 — corregir antes de construir encima

| # | Acción | Deuda |
|---|---|---|
| 1 | Validar `$importador` contra `$this->compania` y bindear/castear el CSV de importadores | [D-01](#d-01), D-01b |
| 2 | `whereIn` + `COUNT(DISTINCT)` en el periodo anterior de impo | [D-18](#d-18) |
| 3 | Acotar el `pluck('numdo')` de `estadohechos` por DOs del tenant | [D-03](#d-03) |
| 4 | Auditar el `GROUP BY` de los 21 SQL activos | [D-29](#d-29) |
| 5 | Corregir `localKey` de `ProcesosWebSimex::graficosAsercol()` y las llaves de `GraficosAsercol` | [D-39](#d-39) |

Estos cinco son corregibles **en la V1 actual**, sin esperar a la V2. Los tres primeros son visibles
para el cliente o afectan su seguridad.

### 12.2 Modelo de datos propuesto para el catálogo

Unificar `graficos_dashboard` + `graficos_kpi` en un catálogo con asignación, en `asercolg_po`:

```
indicadores                          (el QUÉ — un indicador, una sola vez)
  id, codigo(slug único), nombre, descripcion,
  fuente        ENUM('sql','actividades','api')      ← 'actividades' = motor asercol.graficos
  conexion, sql_agregado, sql_detalle,               ← solo si fuente='sql'
  id_proceso_ws                                      ← FK reportes.procesos (reemplaza proceso I/E)
  unidad, formato, meta_defecto, obs_cumplimiento, campo_llave_cumplimiento
  estado, created_at, updated_at, created_by

indicador_visualizacion              (el CÓMO — resuelve los pares column+pie duplicados)
  id, id_indicador, tipo(column|bar|line|pie|map|stat|gauge),
  titulo, subtitulo, titulo_eje, ancho(1..12), orden

indicador_asignacion                 (el A QUIÉN — reemplaza compania.procesos y las 196 filas de graficos_kpi)
  id, id_indicador, id_grupo_empresarial NULL(=todos), id_compania NULL,
  meta_override, orden, estado
```

Con esto se resuelven [D-02](#d-02), D-32, D-33 y la duplicación column/pie de §5.3.

### 12.3 Separación de responsabilidades

```
app/Services/Dashboard/
  ├── CatalogoIndicadoresService.php   lee el catálogo y lo filtra por tenant/proceso/periodo
  ├── Fuentes/
  │     ├── FuenteSql.php              ejecuta sql_agregado con BINDINGS (no str_replace)
  │     ├── FuenteActividades.php      motor asercol.graficos + estadohechos (tiempos y meta)
  │     └── FuenteServicio.php         Shipsgo u otras APIs
  ├── SerieBuilder.php                 normaliza a un DTO SerieResultado (categoria, serie, valor, meta)
  └── ChartConfigBuilder.php           DTO → JSON de la librería (Highcharts/ECharts)

app/Support/Periodo.php                value object: rango, rango homólogo, etiquetas   [resuelve D-17, D-20]
app/Models/Indicador.php               modelo Eloquent del catálogo                     [resuelve D-33]
```

Reglas duras para la V2:

- **Cero interpolación de strings en SQL.** Marcadores → *named bindings* de PDO:
  `?fechaInicial` → `:fecha_inicial`, `?idImportador` → `whereIn` con array bindeado.
  Si el SQL almacenado debe seguir siendo texto libre, validarlo contra una whitelist de tablas y
  ejecutarlo con un **usuario MySQL de solo lectura distinto**.
- **Whitelist de `tipo`** antes de `$this->$tipo()` (hoy es invocación dinámica libre desde BD).
- **Propiedades de Livewire `protected`** salvo las que realmente enlazan inputs. Los datasets van por
  `render()`. [D-36](#d-36)
- **Un gráfico que falla no tumba el dashboard:** `try/catch` por indicador, tarjeta de error en su
  lugar, log con el `id` del indicador. [D-28](#d-28)
- **Caché:** `Cache::remember("dash:{$tenant}:{$indicador}:{$periodo}", 15*60, ...)` con invalidación
  por los comandos `sync:*`. El periodo cerrado (meses pasados) puede cachearse por días.
- **Carga diferida:** montar los gráficos con `wire:init` / lazy, no los 21 en el `mount()`.
- **Detalle paginado por demanda:** no pre-renderizar el HTML del drill-down; cargarlo al abrir el
  modal, con paginación y con la exportación resuelta **en el servidor** a partir del `id` del
  indicador + periodo, no reenviando las filas desde el navegador. [D-05](#d-05), [D-27](#d-27)

### 12.4 Front-end

- **Un solo motor de gráficos.** Elegir Highcharts (ya presente, con licencia a verificar) o migrar a
  ECharts/Chart.js, y **pinear la versión**. Eliminar jQuery y uno de los dos motores de mapas. [D-12](#d-12)
- Inicializar desde `Livewire.hook('message.processed')` o Alpine `x-init` con `wire:ignore` en el
  contenedor del gráfico; nunca `<script>` dentro del `@foreach`. [D-14](#d-14)
- Contenedores `width: 100%` + `aspect-ratio`, y `chart.reflow()` al redimensionar. [D-15](#d-15)
- Si se mantiene el patrón iframe (CLAUDE.md "Cómo agregar un módulo"), usar `layouts.tailwind` en la
  vista de contenido y alto dinámico en vez de `padding-top: 56.25%`. [D-16](#d-16), [D-31](#d-31)
- Sustituir las clases Tailwind interpoladas por un mapa explícito de clases o variables CSS. [D-26](#d-26)

### 12.5 Capacidades nuevas que habilitan las fuentes adicionales

| Capacidad | Fuente | Estado hoy |
|---|---|---|
| Cumplimiento contra meta (SLA) por proceso, con semáforo | `asercol.graficos` + `estadohechos` | **inexistente** |
| Tiempos de ciclo (lead time) y su tendencia | `estadohechos` (excluyendo interrupciones `tipo=2`) | inexistente |
| Segmentación por proceso aduanero (ZF, depósito, courier…) | `reportes.procesos` | inexistente (solo I/E) |
| Comparativo homólogo correcto (mes vs mes, año vs año) | cálculo | incorrecto ([D-20](#d-20)) |
| Costos de almacenaje / demoras | `MovimientoDo`, `DiasDevolucionContenedor` | gráficos 26 y 28 **apagados** |
| Tracking marítimo / desviaciones de ETA | `ShipsgoService` | inexistente |
| Completitud documental | `documentos_aws` (cti) | inexistente |
| Pendientes operativos / cuellos de botella | `pendientes_operacion_do` | inexistente |
| Personalización del tablero por cliente | `indicador_asignacion` (propuesto) | inexistente |

---

## 13. Checklist de migración y compatibilidad

**Restricciones que la V2 debe respetar** (CLAUDE.md):

- [ ] **PHP 7.3**: sin `match`, sin propiedades tipadas, sin promoción de constructor, sin `enum`,
      sin `?->`, sin argumentos con nombre, sin `str_contains`/`str_starts_with` nativos.
- [ ] **No ejecutar `php artisan migrate`**: los cambios de esquema (`indicadores`,
      `indicador_visualizacion`, `indicador_asignacion`) se aplican **directamente en BD** y se
      documentan en [specs/sql/](specs/sql/), como ya se hizo para los otros módulos.
- [ ] **`websimex` y `websystem` son SOLO LECTURA.** El catálogo nuevo va en `asercolg_po` (`mysql`).
- [ ] **`NUMERO_DO` es string.** Nunca castear a int.
- [ ] **Todo query filtra por tenant.** No hay global scope; es manual.
- [ ] **Español** en código, tablas, comentarios y UI.
- [ ] **Verificar el gemelo**: aquí el "gemelo" no es un componente aparte sino el régimen `E` dentro
      del mismo motor. Todo cambio debe probarse con un cliente `I,E`, uno solo `I` y uno solo `E`.
- [ ] **Alta en `menus`**: si la V2 estrena ruta, insertar en `menus` y añadir el `id` al JSON
      `compania.aplicaciones` de cada cliente. Recordar que hoy hay **dos** menús (142 y 146)
      apuntando a `/dashboard/impo`.

**Verificación manual** (no hay tests):

```bash
php -l app/Http/Livewire/Dashboard/<Componente>.php
php artisan route:list | grep dashboard
php artisan config:clear
```

Luego, en el navegador, con usuarios de al menos **tres** grupos empresariales distintos:

- [ ] un grupo con una sola compañía (sin selector de importador),
- [ ] un grupo multi-compañía (con selector) — **verificar explícitamente que al elegir una compañía
      no aparecen datos de otro grupo**,
- [ ] un grupo con `compania.procesos` vacío (ej. grupo 23) — debe mostrar un mensaje, no una página
      en blanco.

Y en cada caso: año actual sin mes, año actual con mes, año anterior sin mes, año anterior con mes.

**Casos de prueba de regresión numérica:** antes de cambiar cualquier SQL, capturar los totales
actuales de un cliente conocido para un periodo cerrado, y verificar que la V2 los reproduce
(salvo donde se corrija deliberadamente un bug documentado — [D-18](#d-18), [D-29](#d-29), [D-20](#d-20) —
que **deben comunicarse al negocio** porque cambian números ya vistos por el cliente).

---

## 14. Anexos

### 14.1 Volcado completo de los SQL configurados

El volcado íntegro de las 25 filas de `graficos_dashboard` (con `sql` y `sql_detalle` completos,
~52 KB) se obtiene con:

```bash
php artisan tinker --execute="
foreach (DB::table('graficos_dashboard')->orderBy('id')->get() as \$r) {
    echo '===== id '.\$r->id.' | '.\$r->slug.' | tipo='.\$r->tipo.' | proceso='.\$r->proceso.PHP_EOL;
    echo \$r->sql.PHP_EOL.'-- DETALLE --'.PHP_EOL.(\$r->sql_detalle ?: '(vacio)').PHP_EOL.PHP_EOL;
}"
```

> Se recomienda versionar ese volcado en `specs/sql/graficos_dashboard.sql` antes de iniciar la V2,
> para tener la línea base de la auditoría de [D-29](#d-29).

### 14.2 Tablas de `websimex` referenciadas en los SQL del catálogo

`estados_do` · `importadores` · `importaciones` · `exportaciones` · `documentos_trasporte` (sic) ·
`documentos_transportes_expos` · `medios_trasporte` (sic) · `ex_modos_transporte` · `ex_paises` ·
`ex_solicitudes_aut_embarques` · `ex_docs_soportes_grales` · `cl_dex` · `unidades_carga` ·
`tamanos_contenedores` · `formularios_dis` · `viajes_motonaves` · `sucursales` · `reportes.procesos`

> Los typos `documentos_trasporte` y `medios_trasporte` (sin la `n`) son **reales en el ERP**.
> No "corregirlos".

### 14.3 Resumen de deudas por severidad

| Severidad | Ids |
|---|---|
| 🔴 Crítico | [D-01](#d-01) · [D-18](#d-18) · [D-29](#d-29) · [D-03](#d-03) |
| 🟠 Alto | D-01b · [D-05](#d-05) · [D-20](#d-20) · [D-22](#d-22) · [D-09](#d-09) · D-25 · [D-36](#d-36) · [D-02](#d-02) · [D-17](#d-17) · D-32 · [D-39](#d-39) · [D-13](#d-13) · [D-14](#d-14) |
| 🟡 Medio | [D-06](#d-06) · D-05b · [D-07](#d-07) · D-08 · [D-11](#d-11) · [D-24](#d-24) · [D-27](#d-27) · D-28 · [D-04](#d-04) · D-33 · [D-30](#d-30) · D-35 · [D-15](#d-15) · D-16 · [D-12](#d-12) · [D-26](#d-26) |
| ⚪ Bajo | D-19 · D-21 · D-23 · D-31 |

### 14.4 Preguntas abiertas para el negocio

1. **¿Cuál es el comparativo correcto?** ¿Mes anterior, mismo mes del año anterior, o periodo
   homólogo de igual longitud? Hoy es siempre "el mes anterior a la fecha inicial" ([D-20](#d-20)).
2. **¿Zarpe = `estadohechos.3001` o = `exportaciones.FECHA_ZARPE`?** Hay que elegir una ([D-22](#d-22)).
3. **¿Los gráficos apagados (26 `imp_demoras`, 27 `imp_volumen_aduana_mes`, 28 `ex_cargos_almacenajes`,
   37 `imp_participación_proveedores_por_pedidos`) deben revivir en la V2** o se descartan?
4. **¿El catálogo debe ser personalizable por cliente?** (habilita `indicador_asignacion`, §12.2).
5. **¿Se integra `asercol.graficos` (SLA con meta) al Dashboard, o sigue siendo un módulo aparte
   junto con `graficos_kpi`/`Kpi\IndicadoresV2`?** Es la decisión de alcance más importante de la V2.
6. **Semántica de `codactini` vs `actini`** en `asercol.graficos`: las listas CSV tienen
   cardinalidades distintas (§11.1). Requiere aclaración funcional antes de codificar.
