# SPEC — Dashboard V2 · Torre de Control Aduanera

**Estado:** Plan de trabajo para aprobación
**Autor:** Miguel Ayala · **Fecha:** 2026-09-11
**Módulo:** Dashboard → Importaciones / Exportaciones
**Base de conocimiento:** [Dashboard.MD](Dashboard.MD)
**Diseño base:** Claude Design — proyecto *Dashboard torre de control aduanal*, `Dashboard Aduanero.dc.html`, artboard **1b** (*"Resumen ejecutivo · comparativo"*)
**Componentes actuales:** [app/Http/Livewire/Dashboard/](../app/Http/Livewire/Dashboard/)

---

## 0. Resumen ejecutivo

La V2 reemplaza la rejilla de 21 gráficos sueltos por **una sola lectura ejecutiva**: una página que
responde, en orden, *¿cuánto movimos?* → *¿cómo va el proceso?* → *¿estamos dentro de la meta?* →
*¿dónde y con quién?* → *¿qué está atascado ahora?*

Tres cambios estructurales sostienen ese salto:

1. **Se fusionan los pares `column` + `pie`.** Hoy el catálogo tiene 8 gráficos que son 4 mediciones
   dibujadas dos veces (volumen mensual en barras + participación anual en dona). El diseño los
   resuelve en **una sola tarjeta**: barras apiladas por mes + leyenda con valor y porcentaje anual.
   El catálogo activo baja de 21 a 13 piezas sin perder ni una cifra (§2.3).
2. **Entra la dimensión que hoy falta: cumplimiento contra meta.** El bloque *Cumplimiento del ANS por
   proceso* se alimenta de `asercol.graficos` + `reportes.procesos` + `estadohechos`, fuentes ya
   pobladas y hoy sin usar. El Dashboard deja de responder solo *"¿cuántas operaciones?"* y pasa a
   responder *"¿cuánto nos tardamos y estamos dentro de la meta?"*.
3. **Se retira el SQL almacenado en base de datos como mecanismo de extensión.** El intérprete de
   `graficos_dashboard` es el origen de [D-01](Dashboard.MD) (inyección SQL + salto de tenant) y de
   [D-29](Dashboard.MD) (`GROUP BY` incompleto). La V2 lo sustituye por un catálogo de indicadores
   con la consulta en código y los parámetros en datos (§5, §6).

Se entrega en **tres etapas desplegables por separado** (§8). La V2 es **completamente independiente
de la V1**: componentes, vistas, rutas y menú propios, sin modificar ni compartir un solo archivo del
módulo actual (decisión I). Las dos versiones **conviven** durante el piloto y la V1 se retira cuando
este valide.

> **Lo que la independencia implica en la práctica.** Las deudas de la V1 no se arreglan: se evitan.
> Ninguna llega al código nuevo porque el código nuevo no las hereda. El precio es que **la V1 sigue
> sirviendo sus defectos mientras esté en línea**. Se admitieron **dos excepciones**, ya implementadas:
> D-01, por ser una vulnerabilidad de seguridad (decisión L, §7.1), y un paquete de ajustes de
> rendimiento que no mueve ninguna cifra (decisión T, §7.1).

### 0.1 Decisiones

Las marcadas **2026-09-11** fueron resueltas por el negocio tras la primera lectura del plan.

| # | Decisión | Estado |
|---|---|---|
| **A** | El color del tenant sale de `grupo_empresarial.color_p` (hex real), **no** de `color_tw` (familia Tailwind). Se sanea y se deriva de él una rampa de 4 tonos | **Aprobada 2026-09-11** — es el mecanismo que preserva la identidad del cliente (§3.2) |
| **B** | El color de exportaciones es **fijo: arcilla `#b4643a`** para todos los tenants. No se usa `color_s` | **Aprobada 2026-09-11** (§3.3) |
| **C** | Highcharts se conserva para las **series** (barras apiladas mensuales) y los **mapas**. Las tarjetas, tablas, barras de participación y el bloque ANS se implementan en HTML+CSS como en el diseño | Propuesta |
| **D** | **Un solo motor de mapas.** Hoy conviven Highcharts Maps y Google GeoChart cargados a la vez; se deja **solo Google GeoChart**. *(Rectificada 2026-09-11: proponía Highcharts Maps, pero su CDN de mapdata limita las peticiones y acaba bloqueando el mapa — por eso la V1 ya había migrado. Se conserva el principio: un motor, no dos.)* | **Aprobada 2026-09-11, rectificada** (§4.4) |
| **E** | El bloque ANS se calcula **bajo demanda**, siempre partiendo de la lista de `NUMERO_DO` del tenant, nunca de un rango de fechas global (§9.1) | Propuesta |
| **F** | **PHP 7.3 estricto:** sin `match`, sin propiedades tipadas, sin `enum`, sin `?->`, sin argumentos con nombre, sin `str_contains`/`str_starts_with` nativos | Obligatoria (CLAUDE.md §2) |
| **G** | ~~Los cinco arreglos de Prioridad 0 se aplican en la V1 antes de empezar~~ → **anulada por la decisión I.** No se toca la V1; las deudas se corrigen **por construcción** en el código nuevo (§7.1) | **Anulada 2026-09-11** |
| **H** | El selector de **Importador** (hoy `$filtroCompania`) se conserva. El diseño no lo dibuja | Propuesta |
| **I** | **La V2 es completamente independiente de la V1 en todos sus componentes.** Componentes, vistas, rutas y menú propios. Se puede *reutilizar lógica* de la V1 (copiándola y adaptándola), pero **ningún archivo de la V1 se modifica ni se comparte** | **Aprobada 2026-09-11** (§7.1, §8) |
| **J** | En el bloque ANS, *Real* = **promedio de días** del periodo y *Cumplimiento* = **% de operaciones dentro de meta**. Son dos columnas distintas | **Aprobada 2026-09-11** (§4.5 · R5) |
| **K** | La corrección de **D-18** (el % de variación de impo, hoy mal calculado) **no se retro-porta a la V1**. Sale cuando salga la V2 | **Aprobada 2026-09-11** (§7.1) |
| **L** | **D-01 sí se parchea en la V1**, como única excepción a la decisión I, por ser una vulnerabilidad de seguridad explotable mientras la V1 siga en línea | **Aprobada 2026-09-11 · ya implementada** (§7.1) |
| **M** | `indicadores_kpi` e `indicadores_kpi_empresas` **se copian** al catálogo nuevo. **No se borra ni se modifica nada** en ellas: su CRUD y la operación actual siguen intactos | **Aprobada 2026-09-11** (§7.3) |
| **N** | Las metas de tiempo están en **días hábiles de 8 horas laborales**. El cálculo descuenta fines de semana, festivos y horas fuera de jornada | **Aprobada 2026-09-11** (§4.6) |
| **O** | La fila de series de exportación lleva **una sola tarjeta**. No se construye el gráfico *Tipos de carga · exportación* que dibuja el mock y que no existe en la V1 | **Aprobada 2026-09-11** (§2.1) |
| **P** | **Umbral de cumplimiento: 90 % para todos**, y operación lo ajusta después indicador por indicador. El esquema ya admite el ajuste fino, incluso por cliente (`indicador_asignacion.umbral_override`) | **Aprobada 2026-09-11** (§4.5 · R6) |
| **Q** | El indicador por defecto de **Ingresos a Zona Franca** es `AVISO LLEGADA - ENTREGA TRANSP` (`ans_72`, etapas 0501 → 3002, meta 3 días). El ERP no lo definía y los nueve del proceso tienen `orden` nulo | **Aprobada 2026-09-11** (§13.3) |
| **T** | **Ajustes de rendimiento en la V1**, como segunda excepción a la decisión I, con la condición de la decisión K: **ninguna cifra visible cambia**. Se aplican a la V1 las lecciones de §9 (detalle bajo demanda, `estadohechos` por `numdo`, payload mínimo). Se incluye el cierre de la inyección por el periodo, hallada al hacerlos | **Aprobada 2026-09-30 · implementada, pendiente de prueba en navegador** (§7.1) |

### 0.2 Lo que el análisis de datos obliga a apartarse del diseño

El mock es **estructuralmente fiel**: sus 8 procesos ANS y el número de indicadores de cada uno
(14 · 5 · 5 · 9 · 2 · 6 · 3 · 8) **coinciden exactamente** con `asercol.graficos` filtrado por
`inactivo = 0`. El diseñador trabajó sobre la estructura real. Pero tres cifras no son implementables
tal cual:

1. **Las metas del mock mezclan horas y porcentajes** (`≤ 4 h`, `≥ 60%`, `≤ 48 h`). En
   `asercol.graficos` el campo `meta` es `int(4)` y **solo toma los valores 1 a 6**
   (11 indicadores con meta 1 · 14 con 2 · 8 con 3 · 7 con 4 · 10 con 5 · 2 con 6), sin declarar
   unidad. Las **horas sí existen** en el otro catálogo, `indicadores_kpi` (§7.3); lo que no existe en
   ninguno es la **meta porcentual**. → La meta se muestra en su unidad propia (§4.5 · R1).
2. **Los nombres de indicador del mock son inventados.** El diseño dice *"Aviso de arribo registrado"*,
   *"Retiro de contenedor"*; los reales son etiquetas cortas de operación —`INICIO-LEVANTE`,
   `FINALIZACION MUISCA-RETIRO PUE`, `ZARPE-FACTURACION`— con la explicación de negocio en
   `descreporte`. → Se muestra `reporte` como nombre y `descreporte` como descripción (§4.5 · R2).
3. **Los 8 procesos no son un conjunto fijo.** `reportes.procesos` tiene 15 procesos, pero solo 8
   tienen indicadores activos: son exactamente los 8 del mock. Los otros 7 —`DEPÓSITO PRIVADO`,
   `SALIDA A TERCEROS PAÍSES DTAI`, `INGRESO ZF MCIA. NACIONAL`, `EXPORTACIÓN AEROPUERTO`,
   `PAGOS COURIER`, `PRÓRROGAS TEMPORALES`, `SIN ESPECIFICAR`— no tienen ninguno. La lista se
   construye **de los datos**, y además se cruza con los procesos que el tenant realmente opera
   (§4.5 · R3).

Cuatro bloques más del diseño **no tienen fuente hoy** y se detallan en §2.2 con su origen propuesto:
*Hallazgos*, *Flujo del proceso*, *Operaciones fuera de objetivo* y *Terceros que afectan el ciclo*.

---

## 1. Lectura del diseño — artboard 1b

### 1.1 Anatomía

Tarjeta de **1280 px**, fondo `#faf9f7`, borde `#e4e0d9`, radio 14 px, padding 30 px, `gap: 26px`.
Once bandas verticales:

| # | Banda | Contenido |
|---|---|---|
| 1 | **Encabezado** | Cintillo `AÑO DE CONSULTA 2026 · COMPARADO CON EL PERIODO ANTERIOR` + titular `Operación aduanera` (26 px/600) · a la derecha: `Año ▾`, `Todos los meses ▾`, botón `Consultar` |
| 2 | **Tres tarjetas de cabecera** | `grid-template-columns: 1fr 1fr 0.8fr` — Importaciones · Exportaciones · Totales del periodo (tarjeta rellena en color primario) |
| 3 | **Hallazgos** | 3 tarjetas con borde izquierdo de color: cintillo + titular + cuerpo |
| 4 | **Flujo del proceso · importación** | 5 etapas (Arribo → Declaración → Selectividad → Levante → Retiro) + banda de alerta de cuello de botella + 4 métricas de servicio |
| 5 | **Cumplimiento del ANS por proceso** | `grid: 330px 1fr` — lista de procesos a la izquierda, tabla de indicadores a la derecha, con panel desplegable de operaciones fuera de ANS |
| 6 | **Mapas** | 2 coropletas (origen impo / destino expo) + top-5 en barras bajo cada uno |
| 7 | **Separador `Importaciones`** | Título 18 px + regla + subtítulo derecho |
| 8 | **4 gráficos impo** | `grid 1fr 1fr` — barras apiladas por mes + leyenda con valor y % anual |
| 9 | **Agentes / Aduanas impo** | Lista de participación + tabla de aduanas con barra |
| 10 | **Separador `Exportaciones` + 2 gráficos expo + Aduanas / Navieras** | Simétrico a 8 y 9 |
| 11 | **Seguimiento operativo + pie** | `grid 1.45fr 1fr` — tabla de operaciones fuera de objetivo + tabla de terceros · pie con fuente y botones `Ver detalle` / `Exportar detalle ↓` |

### 1.2 Sistema visual

| Token | Valor en el diseño | Uso |
|---|---|---|
| `--primario` | `#14625c` | Importaciones, acentos, botón Consultar, tarjeta de totales |
| `--secundario` | `#b4643a` | Exportaciones |
| `--alerta` | `#d8b26a` | Estado "en alerta" |
| `--fondo-pagina` | `#e9e7e3` | Fondo del lienzo |
| `--fondo-tarjeta` | `#faf9f7` | Contenedor exterior |
| `--fondo-panel` | `#ffffff` | Tarjetas interiores |
| `--borde` | `#e4e0d9` | Bordes de tarjeta |
| `--borde-tenue` | `#f2f0ec` | Separadores y fondo de pista de barras |
| `--texto` | `#1c1b19` · `--texto-medio` `#57534d` · `--texto-tenue` `#6b665f` | Jerarquía tipográfica |
| Rampa primaria | `#14625c` `#4f8f89` `#9cc2bd` `#d8b26a` | Series apiladas impo |
| Rampa secundaria | `#b4643a` `#d09272` `#ecc7b3` | Series apiladas expo |

**Tipografía:** `IBM Plex Sans` (400/500/600/700) para texto e `IBM Plex Mono` (400/500/600) para
**toda cifra**. Es una regla consistente en todo el artboard: cada número va en monoespaciada, cada
etiqueta en sans. Cifra grande de tarjeta: 46 px/600/`line-height:1`.

> **Nota de implementación.** Las fuentes se cargan desde Google Fonts. El shell actual
> ([layouts.tailwind](../resources/views/layouts/tailwind.blade.php)) no las carga; hay que añadir el
> `<link>` en la vista contenedora nueva, con `system-ui, sans-serif` como respaldo real.

---

## 2. Mapa diseño → fuente de datos

Es la tabla central del plan: para cada bloque del artboard, de dónde sale el dato.

### 2.1 Bloques que ya tienen fuente

| Bloque del diseño | Fuente | Estado |
|---|---|---|
| Tarjeta **Importaciones** (844 · +848% · Marítimo/Aéreo) | [ImportacionesTotaleStats.php](../app/Http/Livewire/Dashboard/ImportacionesTotaleStats.php) — `estados_do` + `importaciones` + `medios_trasporte`, corte por `FECHA_LEVANTE`, regímenes `['I','T']` | Existe · **con el bug D-18 en el periodo anterior** |
| Tarjeta **Exportaciones** (339 · Marítimo/Terrestre/Aéreo) | [ExportacionesTotaleStats.php](../app/Http/Livewire/Dashboard/ExportacionesTotaleStats.php) — ídem + `estadohechos` etapa `3001` (zarpe) | Existe · **con el full scan D-03** |
| **Totales del periodo** — Declaraciones de importación | Total del gráfico 11 `imp_dec_impo_declaración` | Existe (agregado) |
| **Totales del periodo** — Declaración anticipada % | Serie *Anticipada* del gráfico 14 `imp_dec_impo_selectividad_anual` | Existe (agregado) |
| **Totales del periodo** — Selectividad automática % | Serie *Automática* del gráfico 50 `imp_porcentaje_selectividad` | Existe (agregado) |
| **Totales del periodo** — Unidades de carga impo | Total del gráfico 3 `imp_cantidad_unidad_carga` | Existe (agregado) |
| **Mapa** Importaciones por país de origen | Gráfico 38 `imp_pais_origen` (`tipo=map`) | Existe |
| **Mapa** Exportaciones por país destinatario | Gráfico 7 `exp_pais_expo` (`tipo=map`) | Existe |
| Gráfico impo **Tamaño de unidad de carga** | Gráficos 3 + 4 (column + pie) → **fusionados** | Existe |
| Gráfico impo **Tipos de carga marítima** | Gráficos 9 + 10 → **fusionados** | Existe |
| Gráfico impo **Tipos de declaración** | Gráficos 11 + 14 → **fusionados** | Existe |
| Gráfico impo **Tipo de selectividad** | Gráficos 49 + 50 → **fusionados** | Existe |
| **Agentes transportadores · importación** | Gráfico 36 `imp_can_conteneores_por_agente_transportador` | Existe |
| **Aduanas de nacionalización** | Gráficos 16 + 17 → **fusionados** | Existe |
| Gráfico expo **Tamaño de unidad de carga** | Gráficos 12 + 13 → **fusionados** | Existe |
| **Aduanas de exportación** | Gráficos 5 + 6 → **fusionados** | Existe |
| **Líneas navieras · exportación** | Gráfico 35 `exp_cant_contenedores_por_navieras` | Existe |
| **Cumplimiento del ANS por proceso** | `asercol.graficos` (52 activos) + `reportes.procesos` (15) + `estadodehechos.estadohechos` | **Fuente poblada, nunca usada** |

> **El segundo gráfico expo del mock no se construye (decisión O).** El diseño dibuja *"Tipos de carga ·
> Contenedorizada y suelta"* para exportación, pero **ese gráfico no existe en el catálogo**: el
> equivalente impo es el 9/10 y no hay gemelo de exportación. La fila de series expo lleva **una sola
> tarjeta**, `exp_tamano_unidad_carga`, a ancho completo.

### 2.2 Bloques del diseño sin fuente actual

| Bloque | Qué pide el diseño | Origen propuesto | Etapa |
|---|---|---|---|
| **Hallazgos** (3 tarjetas narrativas) | *"Un agente mueve 89% de los contenedores"*, *"23 unidades sin retirar tras el levante"* | **Reglas sobre los agregados ya calculados**, no texto libre: concentración (máx. participación de un agente > umbral), mezcla (% anticipada vs. objetivo), servicio (operaciones fuera de meta). Cada regla con su umbral parametrizable | 3 |
| **Flujo del proceso · importación** | 5 etapas con conteo, barra, nota y ⌀ días entre etapas | `estadohechos` sobre la lista de DO del tenant: `0501`/`0112` Arribo · declaración desde `importaciones` · selectividad desde el gráfico 49 · `2001` Levante · `3003` Retiro. Los ⌀ días son diferencias de fecha entre hitos consecutivos | 3 |
| **Operaciones fuera de objetivo** | 23 casos ordenados por horas en patio | `importaciones.FECHA_LEVANTE` sin `FECHA_RETIRO_TOTAL`, o `estadohechos` con `2001` y sin `3003`, acotado a la ventana de la meta del indicador `LEVANTE-ENTREGA ARIM TRANSPO` (meta 2 días) | 3 |
| **Terceros que afectan el ciclo** | Ciclo medio arribo→retiro por agente | Mismo cálculo de ciclo del bloque anterior, agrupado por agente transportador (el join del gráfico 36) | 3 |

> **Los cuatro comparten un motor**: el cálculo de tiempo entre dos hitos sobre la lista de DO del
> tenant. Es el mismo que necesita el bloque ANS. Por eso la Etapa 2 construye ese motor y la Etapa 3
> solo lo consume con parámetros distintos. No son cuatro desarrollos, son cuatro consumidores.

### 2.3 La fusión de los pares `column` + `pie`

Ocho gráficos del catálogo son cuatro mediciones dibujadas dos veces:

| Medición | `column`/`bar` (volumen mensual) | `pie` (participación anual) | Tarjeta V2 |
|---|---|---|---|
| Tamaño de unidad de carga impo | 3 | 4 | 1 |
| Tipos de carga marítima impo | 9 | 10 | 1 |
| Tipos de declaración impo | 11 | 14 | 1 |
| Tipo de selectividad impo | 49 | 50 | 1 |
| Aduanas de nacionalización | 16 | 17 | 1 |
| Aduanas de exportación | 5 | 6 | 1 |
| Tamaño de unidad de carga expo | 12 | 13 | 1 |
| Modo de transporte impo | 1 | 2 | → absorbido por la tarjeta de cabecera |

El diseño resuelve el par con **barras apiladas por mes + leyenda con valor y % anual** (líneas
298-329 del artboard). Una lectura, dos preguntas respondidas, la mitad de consultas.

**La dona no se pierde: se convierte en la leyenda.** Cada entrada muestra cuadro de color, etiqueta,
valor total y porcentaje — exactamente lo que comunicaba el `pie`, en menos espacio y sin un segundo
viaje a la base de datos.

**Ganancia medible:** de 21 consultas activas a 13. Y los `pie` son precisamente donde vive
[D-29](Dashboard.MD): los gráficos 1 y 2 agrupan por `NOMBRE_MEDIO_TRANS` pero seleccionan
`MONTHNAME(...) AS CATEGORIA`, y con `'strict' => false` MySQL devuelve un mes arbitrario sin
protestar. Al fusionar, el agregado anual se calcula **sumando la serie mensual en PHP**, y el
`GROUP BY` ambiguo desaparece por construcción.

---

## 3. Color por tenant

Es el punto que más análisis requirió y el que necesita decisión del negocio.

### 3.1 Lo que hay hoy

`grupo_empresarial` tiene **seis** columnas de color, con semántica solapada y datos inconsistentes:

| Columna | Tipo | Contenido real | Uso actual |
|---|---|---|---|
| `color_tw` | `varchar(100)` | Familia Tailwind: solo 6 valores distintos — `red`, `blue`, `green`, `sky`, `cyan`, `orange` | `session('color')` → `bg-{{ $color }}-600` |
| `color_p` | `varchar(100)` | **Hex primario real del cliente** | Vistas legacy |
| `color_s` | `varchar(100)` | Hex secundario | Vistas legacy |
| `color_tw_p` / `color_tw_s` | `varchar(15)` | Token Tailwind con tono (`blue-700`, `teal-500`) | Parcial |
| `color_texto_hover` | `varchar(15)` | `white` o vacío | Parcial |

**Problemas medidos sobre los 26 grupos activos:**

- `color_tw` **pierde la identidad del cliente**: colapsa 26 marcas en 6 familias. Dos clientes con
  `#021F59` (azul marino) y `#2596be` (azul claro) se dibujan idénticos.
- **`color_tw` y `color_p` se contradicen** en al menos dos grupos: `color_tw = 'red'` con
  `color_p = '#086b38'` (verde) y `color_tw = 'red'` con `color_p = '#CCCCCC'` (gris).
- **Espacios sobrantes** en el hex: `' #61a7af'` y `' #ec1324'` llegan con espacio inicial.
- `color_tw_p` a veces trae **un hex donde debería ir un token** (`'#e2262b'`), a veces `'black'`, y
  está **vacío en 5 grupos**.
- `color_s` es **`#000000` en 6 grupos** — inservible como color de serie.

### 3.2 Propuesta — decisión A

Una sola función de resolución, con saneamiento y respaldo, en `app/Support/PaletaTenant.php`:

```php
// Orden de resolución del color primario:
// 1. trim(color_p) si valida /^#[0-9A-Fa-f]{6}$/
// 2. mapeo de color_tw a su hex Tailwind-600 equivalente
// 3. respaldo del diseño: #14625c
```

De ese primario se deriva la **rampa de 4 tonos** que necesitan las series apiladas, mezclando hacia
blanco en el espacio HSL con luminosidades fijas —el mismo salto perceptual que tiene la rampa del
diseño (`#14625c` → `#4f8f89` → `#9cc2bd`)—, de modo que **cualquier** primario produzca una rampa
legible y no haya que mantener 26 paletas a mano.

La paleta se expone como **variables CSS en el `<body>`** de la vista contenedora, no como clases
Tailwind dinámicas:

```blade
<body style="--primario: {{ $paleta['primario'] }};
             --primario-2: {{ $paleta['primario2'] }};
             --primario-3: {{ $paleta['primario3'] }};
             --secundario: {{ $paleta['secundario'] }};
             --alerta: #d8b26a;">
```

**Por qué variables CSS y no clases Tailwind.** El patrón actual `bg-{{ $color }}-600` depende de que
el JIT del CDN encuentre la clase ya escrita en el DOM; con 26 marcas reducidas a 6 familias, además,
no puede expresar el color real. Las variables CSS admiten el hex exacto del cliente, se leen desde
Highcharts con `getComputedStyle` y funcionan igual en las tarjetas HTML.

### 3.3 El color de exportaciones — decisión B · **aprobada 2026-09-11**

El diseño usa **dos familias semánticas**: verde azulado para importación, arcilla para exportación.
No son primario/secundario de marca; son **dos categorías de negocio que deben distinguirse siempre**.

Usar `color_s` del tenant rompe eso en 6 de 26 grupos (`#000000`) y en los que tienen primario y
secundario casi iguales.

**Decisión:** el secundario es **fijo, arcilla `#b4643a`** para todos los tenants; el primario sale de
la marca del cliente. La identidad del cliente vive en el primario —que es el que domina la página:
cabecera, tarjeta de totales, impo, mapas, acentos— y la distinción impo/expo queda garantizada en
todos los casos.

`color_s` **no se lee** en el Dashboard V2. `--secundario` y su rampa (`#b4643a` · `#d09272` ·
`#ecc7b3`) son constantes del módulo.

> **Consecuencia a vigilar.** Un tenant cuyo `color_p` esté cerca del arcilla —el catálogo tiene
> `#ec742f`, `#faad42` y `#bb9a63`, y `#b4643a` cae entre ellos— vería impo y expo casi del mismo color.
>
> El primario **no se toca**: es la marca del cliente y moverlo anula el propósito de la decisión A.
> En ese caso se desplaza el **secundario** a un respaldo frío (pizarra `#4a5a6a`) solo para ese
> tenant. Sigue siendo un color fijo del módulo, no una paleta por cliente: son dos valores posibles,
> elegidos por una regla, no 26 configuraciones que mantener.
>
> El riesgo real es menor de lo que parece: **impo y expo nunca comparten un gráfico**. Van en tarjetas
> separadas en la cabecera y en secciones distintas más abajo, así que la distinción es de contexto, no
> de lectura simultánea. Afecta a 3 de 26 grupos y se valida en la prueba 5 de §11.

### 3.4 Trabajo derivado

Lo anterior sanea el color **para el Dashboard V2**, sin tocar el resto del sistema. Unificar las seis
columnas de `grupo_empresarial` en una sola fuente de verdad es una mejora deseable pero es **otro
alcance**: hoy `color_tw` se usa en decenas de vistas y cambiarlo es un cambio transversal. La V2 deja
`app/Support/PaletaTenant.php` listo para que esa unificación posterior tenga dónde apoyarse.

---

## 4. Qué se dibuja con Highcharts y qué con HTML

Tu requisito: **no perder la interactividad de Highcharts**, pero aceptar que algunas tarjetas van como
en el diseño. Esta es la repartición propuesta.

### 4.1 Highcharts — donde la interactividad aporta

| Pieza | Tipo Highcharts | Qué gana |
|---|---|---|
| 4 gráficos impo + 2 expo (barras apiladas por mes) | `column` con `stacking: 'normal'` | Tooltip por segmento, aislar serie al hacer clic en la leyenda, exportar PNG/CSV, `drilldown` al detalle |
| Mapa origen impo | `Highcharts.mapChart` + `world.js` | Tooltip por país con el valor, zoom, `colorAxis` |
| Mapa destino expo | ídem | ídem |

**Configuración común** (un solo `Highcharts.setOptions` con el tema del tenant, no repetido por
gráfico): tipografía IBM Plex, `colors` desde la rampa, `credits: false`, `chart.backgroundColor:
'transparent'`, `chart.style.fontFamily`, y `lang` en español. Así los 6 gráficos comparten estilo sin
duplicar JSON.

### 4.2 HTML + CSS — donde Highcharts estorba

| Pieza | Por qué no Highcharts |
|---|---|
| 3 tarjetas de cabecera (cifra grande + barras por modo) | Son 2-3 barras con texto rico al lado; un gráfico aquí pierde el texto y gana peso |
| Barras de participación (agentes, navieras, top-5 de mapas) | Listas ordenadas con etiqueta + barra + cifra: son tablas, no gráficos |
| Tablas de aduanas | Tabla con barra embebida en una celda |
| Flujo del proceso (5 etapas) | Diagrama de bandas, no una serie |
| Bloque ANS completo | Lista + tabla + barra con marcador de umbral + panel desplegable. La interactividad aquí es **navegación** (seleccionar proceso, abrir indicador), que resuelve Livewire |
| Operaciones fuera de objetivo / Terceros | Tablas |

### 4.3 El efecto de llenado

Las barras HTML entran animadas: se pintan con `width: 0` y llegan a su valor al cargar.

```css
.barra-llenado {
  width: 0;
  transition: width 900ms cubic-bezier(.22,.61,.36,1);
}
```

El disparo se hace con **Alpine**, que ya está cargado en el shell, sobre el evento de montaje del
componente, escalonando cada barra unos 40 ms respecto a la anterior (`transition-delay`) para que la
banda se lea como un barrido y no como un salto simultáneo. Las cifras grandes de las tarjetas suben
de 0 a su valor en la misma ventana de 900 ms.

Highcharts anima nativamente (`plotOptions.series.animation`), así que ambos mundos entran igual: se
alinea la duración a 900 ms para que la página completa termine de llenarse a la vez.

**Dos condiciones no negociables:**

1. **`prefers-reduced-motion`.** Con la preferencia activa, todo se pinta en su valor final sin
   transición. Es una línea de CSS y evita un problema de accesibilidad.
2. **La animación no puede ser la única señal de que hay datos.** Si una serie viene vacía, la tarjeta
   muestra su estado vacío explícito (§10.3), no una barra que se queda en cero.

### 4.4 Un motor de mapas, no dos — decisión D

Hoy la vista contenedora carga **Highmaps y Google GeoChart a la vez**, y usa GeoChart mientras 70
líneas de `mapChart()` quedaron comentadas en
[ImportacionesGraph.php](../app/Http/Livewire/Dashboard/ImportacionesGraph.php). Son dos motores, dos
contratos de datos (`country`/`country_code`/`total` para uno, series para el otro) y una dependencia
de `gstatic.com` que no aporta nada que Highmaps no dé.

**La V2 se queda con Google GeoChart.** Mi propuesta inicial era Highcharts Maps, y estaba equivocada:
su CDN de mapdata **limita el número de peticiones y termina bloqueando el mapa**. La V1 ya había
migrado a GeoChart por esa misma razón. Rectificado tras validar en el navegador el 2026-09-11.

Lo que sí se conserva de la decisión es el principio: **un solo motor**. Se retira `highmaps.js` —se
carga `highcharts.js` a secas, que es lo que piden las barras—, el `<table>` oculto que servía de CSV
al GeoChart, y jQuery 3.7.1, que la vista de la V1 carga y **no usa**.

El contrato de datos son pares `['CN', 576]` con **ISO-3166 alfa-2 en mayúscula**, que es como GeoChart
identifica las regiones. Verificado contra el ERP (§12 · P5): `paises.CODIGO_SIGLOXXI` y
`ex_paises.CODIGO_PAIS` ya son ISO-2, así que no hace falta tabla de conversión.

> **Las dos tablas de países no comparten codificación.** `paises` (importaciones) usa código numérico
> —`'215'` es China— con el ISO-2 aparte en `CODIGO_SIGLOXXI`; `ex_paises` (exportaciones) usa el ISO-2
> como llave primaria. Ninguno de sus 248 registros casa por código con `paises`. Cualquier cruce entre
> ambas debe hacerse por nombre, no por código.

### 4.5 Desviaciones del diseño en el bloque ANS

| # | Desviación | Razón |
|---|---|---|
| **R1** | ~~La columna *Meta ANS* muestra días, no horas~~ → **corregida 2026-09-11.** La meta se muestra **en su propia unidad**: días calendario, días hábiles u horas | En `asercol.graficos` la `meta` es `int(4)` con valores 1–6 y sin unidad declarada. Pero `indicadores_kpi` —catálogo del CTC hallado después (§7.3)— sí declara `unidad_medida`, y usa **DIAS HABILES en 20 de 26** indicadores y **HORAS en 4**. Las metas horarias del mock sí son implementables; lo que no existe es la meta *porcentual* |
| **R2** | El nombre del indicador es `reporte` (`INICIO-LEVANTE`), con `descreporte` como descripción bajo él o en tooltip | Los nombres del mock son inventados. `descreporte` sí trae la explicación de negocio, y es más útil que un nombre bonito sin trazabilidad |
| **R3** | La lista de procesos se construye de los datos y se cruza con lo que el tenant opera | Solo 8 de los 15 procesos tienen indicadores. Y un cliente que no hace zona franca no debe ver ese proceso |
| **R4** | El orden de los indicadores cae a `reporte` cuando `orden` viene vacío | Los 5 indicadores del proceso 2 (EXPORTACIONES) tienen `orden` vacío. Sin respaldo, el orden sería no determinista |
| **R5** | La columna *Real* muestra el **promedio de días** del periodo; *Cumplimiento* es el **% de operaciones dentro de meta**. **Decisión J, aprobada 2026-09-11** | El mock mezcla ambas lecturas sin decir cuál es cuál. Son dos preguntas distintas y las dos importan: el promedio dice cuánto tardamos, el porcentaje dice con qué consistencia. El color de la fila lo fija el **cumplimiento** contra el umbral (R6), no el promedio |
| **R6** | El marcador de umbral arranca en **90 % para todos** y queda ajustable por indicador y por cliente. **Decisión P, aprobada 2026-09-11** | El mock lo dibuja fijo en la mayoría. `asercol.graficos` no tiene columna de umbral; se añadió al catálogo V2 y se cargó en 90 (§6) |
| **R7** | El detalle de operaciones fuera de meta lleva **una columna por cada etapa del tramo**, con fecha y hora, marcando cuál fija el inicio cuando hay varias actividades iniciales. **2026-09-12** | El mock muestra solo el número de días. Sin las fechas medidas, el usuario no puede contrastar la cifra contra su propia información y el indicador se vuelve incuestionable por la vía equivocada. Es el mismo motivo por el que se publican las `noMedibles` (§4.7) |
| **R8** | Bajo esa tabla va una segunda con las **operaciones sin medir**, mismas columnas de fecha más *qué falta*. **2026-09-14** | El mock no las contempla porque el ERP no las muestra. Un conteo suelto («3 sin medir») no es accionable; el DO y la etapa faltante sí. Ver el caso Brinsa en §4.7 |

---

### 4.6 El tiempo se mide en días hábiles — decisión N

Confirmado por el negocio el 2026-09-11: **un día hábil son 8 horas laborales**. El tiempo entre dos
hitos no es una resta de fechas: hay que descontar fines de semana, festivos y las horas fuera de
jornada.

Lo resuelve [CalendarioLaboral.php](../app/Support/CalendarioLaboral.php):

| | |
|---|---|
| Jornada | 08:00–12:00 y **14:00–18:00** = 8 h. **Configurable**. Corregido el 2026-09-12 para igualar `fn_dias_habiles_proceso` del ERP; antes era 13:00–17:00 (§4.7) |
| Días no laborables | **`asercol.calendario`** (websystem), la misma tabla que lee el ERP. `dias_festivos` de websimex queda de respaldo para 2027+ — **corregido el 2026-09-14**, ver §4.7 |
| Sábado | Jornada completa de 8 h, por paridad con el ERP (§4.7) |
| Precisión | Horaria: `estadohechos` guarda `fecha` (date) y `hora` (time) en columnas separadas |
| Hora ausente | Una hora nula o `00:00:00` no significa medianoche sino *«no se registró»*: se toma la apertura de la jornada para el hito inicial y el cierre para el final |
| Metas en `HORAS` | Se comparan directo; `DIAS HABILES` se multiplica por 8 |

Verificado a mano en los casos límite: mismo día cruzando el almuerzo, viernes tarde → lunes mañana,
semana completa, cruce de año con festivos, sábado entero, orden invertido.

> **24 horas de reloj no son un día hábil.** Del viernes a las 16:00 al lunes a las 09:00 pasan 65
> horas de calendario y **2 horas hábiles**. Es la diferencia que hace que este bloque signifique algo.

#### Los tres calendarios de festivos no coinciden — y el que se usa hoy está mal

Al elegir la fuente aparecieron tres, todas con problemas distintos:

| Fuente | Cobertura | Estado |
|---|---|---|
| `dias_feriados` (CTC) | 2020 – **2026** | **Incorrecta para 2026** y se agota ese año |
| `dias_festivos` (websimex) | hasta **2049** | Cada fecha viene **duplicada** (36 filas = 18 fechas). **No está al día**: le falta el 13-jul-2026, festivo nuevo. Ver §4.7 |
| `calendario` (websystem) | 2008 – **2026** | **La que usa el ERP para medir, y la que se adoptó.** Al día; se agota el 2026-12-31 |
| [App\Festivos](../app/Festivos.php) (algoritmo) | cualquier año | Dos defectos, abajo |

> ⚠️ Esta tabla enumeraba **tres** fuentes y daba `dias_festivos` por correcta. Eran **cuatro**, y la
> que faltaba —`asercol.calendario`— es la que manda. Corregido el 2026-09-14; la decisión vive
> en §4.7.

**`dias_feriados` tiene dos fechas inventadas y le falta una real** en 2026:

- `2026-12-01` está registrada como **«Día de los Reyes Magos»** — que es en enero. Alguien escribió la
  fecha correcta (12 de **enero**, por Ley Emiliani) con el mes y el día transpuestos.
- `2026-07-13` «Virgen del Rosario de Chiquinquirá» no es festivo legal en Colombia.
- Falta `2026-01-12`, que sí lo es.

**`App\Festivos` tiene dos defectos**, y es la clase que usa
[KpiController](../app/Http/Controllers/KpiController.php):

1. Calcula la **Ascensión** como Pascua + 36 días en vez de + 39: da el 11 de mayo de 2026 cuando es el
   18.
2. `KpiController` hace `new Festivos()` y llama a `no_bussiness_days()` **sin llamar antes a
   `festivos()`**, así que el arreglo interno está vacío y `esFestivo()` devuelve siempre `false`. →
   **Los KPI actuales descuentan fines de semana pero nunca festivos.** Es deuda de otro módulo, no del
   Dashboard, pero conviene que alguien lo sepa.

Por eso la V2 lee `asercol.calendario` y, fuera de su cobertura, `dias_festivos`. No toca `dias_feriados`
ni `App\Festivos`.

---

### 4.7 Paridad con el módulo KPI del ERP — decisión O · **2026-09-12**

El bloque de ANS mide lo mismo que el módulo *KPI · Indicadores de Gestión*
([GraficosKpi](../app/Http/Livewire/InfoPedidos/GraficosKpi.php), documentado en
[GraficosKpi.MD](GraficosKpi.MD)), que es con el que el negocio audita hoy. Al contrastar ambos
motores aparecieron **seis** divergencias. Ninguna era un error de suma: eran seis reglas distintas.

Contraste sobre la misma población, periodo 2026-01 a 2026-06, añadiendo una regla por fila:

| Variante acumulativa | Gráfica 1 | Gráfica 5 | Gráfica 20 |
|---|---|---|---|
| Motor V2 antes del ajuste | 84,0 % | 54,9 % | 23,4 % |
| + hito **más tardío** de cada etapa | 85,0 | 54,7 | 23,2 |
| + jornada **08–12 / 14–18** | 84,7 | 54,7 | 23,2 |
| + **sábado hábil** de 8 h | 81,0 | 42,4 | 16,6 |
| + **truncado** al día completo | **88,4** | **53,3** | **32,9** |
| **ERP (`fn_dias_habiles_proceso`)** | **88,7** | **53,3** | **32,9** |

Las dos reglas dominantes tiran en sentidos opuestos —el sábado resta entre 3,7 y 12,3 puntos, el
truncado suma entre 7,4 y 16,3—, así que **corregir una sola empeora la divergencia**. La magnitud y
hasta el signo del desvío dependen del indicador: no existe un factor de corrección global.

#### Qué se ajustó

| # | Regla | Antes | Ahora | Referencia |
|---|---|---|---|---|
| 1 | **Población** | DO abiertos en el periodo (`estados_do.FECHA_APERTURA`) | DO cuyo **tramo arrancó** en el periodo. Pasó primero al cierre, por paridad con el ERP, y el 2026-09-15 al arranque por decisión del negocio — **ver §4.8** | §4.8 |
| 2 | Etapa repetida | Se tomaba la ocurrencia más temprana | La **más tardía**, como el `ORDER BY … DESC LIMIT 1` del ERP | §5.1 |
| 3 | Jornada | 08–12 / 13–17 | 08–12 / **14–18** | §5.2 |
| 4 | Sábado | No hábil | **Hábil, 8 h** | §9.1 |
| 5 | Evaluación de la meta | Días con decimales | **Truncado** al día completo | §9.11 |
| 6 | Redondeo intermedio | `round(…, 2)` antes de truncar | Sin redondeo: empujaba 1,9975 a 2 días | — |

El parámetro `actividad_cierre` ya estaba en el catálogo desde la carga inicial; `FuenteHitos`
simplemente no lo usaba.

> **Por qué el anclaje importa.** Anclado a `FECHA_APERTURA`, las poblaciones solapaban con el ERP
> solo un **82 %**, entraban operaciones todavía en curso —que se descartaban en silencio— y el
> promedio se sesgaba a la baja al sobre-representar las operaciones cortas: 0,82 frente a 1,63 días
> en la gráfica 1. El anclaje definitivo se fijó en §4.8.

#### Lo que se replica sabiendo que está mal

El sábado de 8 h (§9.1 de GraficosKpi.MD) y el truncado (§9.11) son **defectos documentados del ERP**:
`asercol.calendario` tiene el sábado configurado con 4 horas y la función lo ignora, y «hasta 2 días»
admite en realidad 2 días y 7 h 59 min. Se replican a propósito, para que las cifras cuadren con el
sistema con el que el negocio audita.

Ambos son **banderas, no constantes**: `CalendarioLaboral::sinSabado()` y `truncarDias()` permiten
medir la versión corregida en paralelo el día que se decida migrar. Esa migración requiere anuncio y
recálculo histórico: los indicadores **empeorarán**.

#### Lo único en lo que la V2 se aparta a propósito

Las operaciones a las que les falta una fecha del tramo **no entran al denominador**. El ERP las suma
a `total` y sesga el porcentaje a la baja (GraficosKpi.MD §9.6). Aquí se publican aparte como
`noMedibles`, que es la recomendación D1 de §8.3 y lo que ya hace la vista histórica del propio ERP.
El detalle las **lista una por una** bajo el rótulo *«operaciones sin medir»* —DO, pedido, cliente, la
fecha de cada etapa y cuál falta—, en vez de descartarlas en silencio. No es una métrica de calidad de
dato: es la lista de trabajo del área administrativa, porque cada fila se corrige registrando la etapa
que falta en el ERP y entonces la operación entra al indicador.

Caso de validación ✔ *Brinsa · agosto 2026 · Importaciones por Puerto · DESCARGUE-RETIRO (`ans_59`,
meta 5 días)*: población 18, medidas 15, cumplimiento 60 %, **3 sin medir** — los DO `266417676`,
`268119175` y `268119724`, los tres por la misma causa: la etapa **0505 (DESCARGUE)** no tiene ningún
registro en `estadohechos`, mientras que la 3003 (RETIRO) sí. No son interrupciones descartadas: el
registro no existe.

#### La fuente de festivos: manda `asercol.calendario` — corregido 2026-09-14

Al cuadrar el caso Brinsa quedaba una diferencia de 0,07 días en el promedio, de una sola operación
(DO `266417365`, 24 días según el ERP y 25 según el CTC). No era una regla distinta: era el calendario.

**El 13 de julio de 2026 es festivo —nuevo, decretado este año— y `websimex.dias_festivos` no lo
trae.** `asercol.calendario` sí. Como el Dashboard medía solo con la primera, contaba un día hábil de
más en todo tramo que cruzara esa fecha.

Esto reordena la decisión de §4.6. `dias_festivos` **no es una fuente de festivos vigente**: se generó
con las reglas de su carga y no recoge lo que se decreta después. Su única ventaja es la cobertura
(hasta 2049). La jerarquía queda:

| Orden | Fuente | Cobertura | Papel |
|---|---|---|---|
| 1 | **`asercol.calendario`** (websystem) | 2008 – **2026** | **Autoridad.** Es contra la que mide `fn_dias_habiles_proceso`: si el ERP define ahí el día hábil, el Dashboard tiene que leer lo mismo |
| 2 | `websimex.dias_festivos` | hasta 2049 | Respaldo, solo para los años que `calendario` no alcanza |

Un año sin filas en `calendario` cae al respaldo; dentro de su cobertura, una fecha sin fila se trata
como no laborable, igual que el ERP. Los huecos de `calendario` están en 2008-2010 y 2014; de 2015 en
adelante está completo.

> 🔴 **Esto vuelve urgente poblar `calendario` para 2027.** Antes era un problema del módulo KPI
> (GraficosKpi.MD §9.2); ahora también del Dashboard: a partir del 2027-01-01 caemos al respaldo, que
> es justamente el que no conoce los festivos nuevos. La diferencia ya no sería de un día suelto sino
> de todo festivo decretado desde la carga de `dias_festivos`.

Barrido día a día de 2025 y 2026 entre las dos fuentes: el 13-jul-2026 es **la única discrepancia
entre semana en dos años**.

#### Verificación

- La suite canónica de GraficosKpi.MD §8.5: **7/7**.
- 399 pares de fechas reales de `estadohechos` contra `fn_dias_habiles_proceso`, incluidos tramos que
  cruzan el 13-jul-2026: **100 %** de coincidencia.
- Caso Brinsa · agosto 2026 · `ans_59`, contra el ERP sobre la misma población: población 18,
  medidas **15/15**, cumplen **9/9**, cumplimiento **60,0 % / 60,0 %**, promedio **6,6 / 6,60**,
  sin medir **3/3**.

---

### 4.8 El periodo se ancla al ARRANQUE del tramo — decisión Q · **2026-09-15**

Validando el DO `268110077` de Brinsa apareció el último desacuerdo, y no era de cálculo: **CENTRAL lo
reporta en agosto y el Dashboard lo ponía en septiembre.** Ambos sistemas medían lo mismo —9 días
hábiles, incumple la meta de 5— pero lo asignaban a meses distintos.

| | |
|---|---|
| Apertura del DO | 29/08/2026 |
| DESCARGUE (0505) — arranca el tramo | **30/08/2026 16:00** |
| RETIRO (3003) — cierra el tramo | **10/09/2026 14:56** |

Hay tres fechas candidatas y cada una da una población distinta. Para Brinsa, agosto 2026, `ans_59`:

| Anclaje | Operaciones | Quién lo usa |
|---|---|---|
| Apertura del DO | 23 | El Dashboard antes del 2026-09-12 |
| **Arranque del tramo** | **16 medibles (19 con las incompletas)** | **CENTRAL · el Dashboard desde hoy** |
| Cierre del tramo | 18 | El módulo KPI del ERP (`GraficosKpi`) |

**Decisión del negocio: se ancla al arranque del tramo**, para que el Dashboard concilie con CENTRAL.
Cuando `actividad_inicial` trae varios códigos el tramo arranca con el **más tardío** —hay que haberlos
cumplido todos— y ese es el que decide el mes.

> 📌 **El Dashboard y el módulo KPI del ERP van a diferir en la población, a propósito.** Las reglas de
> *medición* siguen siendo idénticas (§4.7, verificado 399/399): una misma operación mide los mismos
> días en los dos. Lo que cambia es en qué mes se reporta. No es un error a corregir: es que responden
> a preguntas distintas —*«de los pedidos que empezaron este mes, cuánto tardaron»* frente a *«de los
> que terminaron»*.

**Contrapartida asumida:** un tramo aún abierto no tiene duración, así que el mes en curso siempre se
ve incompleto y sus cifras cambian a medida que las operaciones cierran. Un mes solo queda firme
cuando han cerrado todos sus tramos.

#### Los registros incompletos no desaparecen

Un DO sin la actividad inicial registrada no tiene fecha de arranque y, por tanto, no tiene mes: con el
anclaje al arranque se saldría de toda población y dejaría de verse. Para evitarlo **entra igual,
anclado por su actividad final**, y se lista entre las *operaciones sin medir* (§4.5 · R8). Es lo que
mantiene visibles los tres DO de Brinsa a los que les falta el descargue.

Por eso la población son 19 y no 16: 16 medibles más 3 incompletas.

---

### 4.9 La actividad de control es condición de validez — decisión R · **2026-09-15**

`asercol.graficos.actividadcontrol` es un campo **independiente** de `codactini` y `codactfin`, y en el
ERP cumple dos papeles distintos que conviene no confundir:

1. **Anclaje del periodo.** `GraficosKpi` define la población con
   `WHERE codactividad = actividadcontrol AND fecha BETWEEN …` (GraficosKpi.MD §5.1). **Este papel aquí
   no aplica**: desde §4.8 el Dashboard ancla al arranque del tramo.
2. **Condición de validez.** En la rama de `codactini` múltiple, el SQL del ERP exige
   `fecha(actividadcontrol) IS NOT NULL` y devuelve `-1` —error— si falta, aunque inicio y fin existan.
   El ERP lo aplica **solo a esas 4 gráficas**; aquí se generaliza a las 52.

**Regla adoptada:** una operación con inicio y fin registrados pero **sin la fecha de la actividad de
control** no se mide. Cuenta como *sin medir*, nunca como incumplimiento — el pedido no llegó al punto
que valida la medición, que no es lo mismo que haberse tardado.

En el detalle aparece con su propio motivo: *«El tramo está completo, pero falta la actividad de
control X»*, distinto de *«Falta registrar X»*.

#### El catálogo tenía el campo mal cargado — 18 de 52

Al implementar la regla salió que `parametros.actividad_cierre` **no** contenía
`graficos.actividadcontrol`. La carga inicial copió una actividad de cierre de proceso (3003, 3504…):

| | |
|---|---|
| Coincidían con `graficos` | 34 |
| **Difieren** | **18** |
| De esos, el valor correcto es `codactfin` | 17 |
| De esos, el valor correcto NO es `codactfin` | **1 — `ans_59`** |

`ans_59` (DESCARGUE-RETIRO) es el caso que hace visible la distinción: el tramo va de **0505** a
**3003**, pero controla con **0503** (FINALIZACIÓN MUISCA). El catálogo decía 3003.

La corrección va en [dashboard_v2_ans_actividad_control.sql](sql/dashboard_v2_ans_actividad_control.sql):
reescribe los 52 `parametros` y, de paso, **renombra la clave a `actividad_control`** — `actividad_cierre`
sugería que el campo cerraba el tramo, que es justo lo que no hace. `FuenteHitos` lee la clave nueva y
cae a la vieja, así que el código funciona antes y después de aplicarlo.

#### Impacto medido (Brinsa · agosto 2026 · las 25 mediciones con datos)

| Comparación | Indicadores afectados | Mediciones que se mueven |
|---|---|---|
| Sin la regla **vs** con la regla y el valor correcto | **0** | **0** |
| Con el valor erróneo **vs** con el valor correcto | 3 | **4 recuperadas** |

La regla no mueve ninguna cifra hoy: en 51 de los 52 indicadores la actividad de control coincide con
una etapa que el cálculo ya exige, así que solo actúa como salvaguarda. Lo que sí movía cifras era el
**valor mal cargado**, que exigía una actividad ajena al tramo y descartaba 4 mediciones válidas
(`ans_63`, `ans_61`, `ans_67`).

> 📌 Aplicar el SQL no empeora ningún indicador: recupera mediciones que se estaban perdiendo.

---

## 5. Arquitectura objetivo

### 5.1 Principio

**El SQL vive en código; los parámetros viven en datos.** Es la inversión respecto a la V1, donde el
SQL vivía en datos y el código era un intérprete. Esa inversión es la que elimina D-01 y D-29 de raíz:
sin texto SQL en base de datos no hay nada que interpolar ni `GROUP BY` que auditar.

### 5.1b La primera carga es acotada — decisión S · **2026-09-15**

El tablero arrancaba con **el año completo** y, en grupos multi-compañía, con **todas** las compañías.
Es la consulta más cara posible, y se pagaba entera antes de que el usuario mirara nada.

Los valores por defecto ahora son:

| Filtro | Antes | Ahora |
|---|---|---|
| Mes | Año completo | **Mes en curso** |
| Importador | Todo el grupo | **La primera compañía** |

Y, con más de una compañía, **siempre hay una seleccionada**: el selector ya no ofrece «Todos». El
encabezado dice de quién son las cifras (*«Cifras de NESTLE DE COLOMBIA S.A»*), porque un total sin
dueño se lee como si fuera el del grupo entero.

Medido sobre el grupo 41 —4 compañías, 52 indicadores ANS—:

| | Tiempo | Consultas | Operaciones evaluadas |
|---|---|---|---|
| Antes | 6,0 s | 15 | 24.126 |
| **Ahora** | **2,0 s** | **7** | **987** |

Dos matices de implementación:

- Ampliar sigue siendo posible: «Todos los meses» continúa en el selector de mes. Lo que cambió es por
  dónde se empieza, no lo que se puede pedir.
- Si el año cae a uno pasado —porque el actual no tiene operación— el mes vuelve a «año completo»:
  arrancar en un mes cualquiera de un año viejo confunde más de lo que ayuda.

---

### 5.2 Estructura de archivos

Todo es nuevo (decisión I). Ningún archivo de la V1 aparece en esta lista salvo los dos modelos con
llaves mal, que son trabajo propio sin commitear.

```
app/Http/Livewire/Dashboard/V2/
    Tablero.php                 orquestador: periodo, importador, emisión de eventos
    Cabecera.php                3 tarjetas de cabecera + totales
    Cumplimiento.php            bloque ANS: procesos, indicadores, drill-down
    Series.php                  los 6 gráficos apilados + mapas
    Participacion.php           agentes, navieras, aduanas
    Operativo.php               flujo, operaciones fuera de objetivo, terceros  (Etapa 3)

resources/views/
    Dashboard/v2/tablero-iframe.blade.php     vista contenedora (shell Bootstrap)
    Dashboard/v2/tablero.blade.php            shell propio: IBM Plex, Highmaps, variables CSS
    livewire/dashboard/v2/*.blade.php         una por componente

routes/web.php
    /dashboard/v2        -> iframe        middleware('auth')
    /dashboard/v2/ver    -> contenido     middleware('auth')
    /dashboard/v2/exportar -> Excel       middleware('auth')   (no cuelga de /api)

app/Services/Dashboard/
    CatalogoIndicadores.php     lee el catálogo y lo filtra por tenant/proceso
    ResolutorPeriodo.php        año/mes -> [fechaInicial, fechaFinal] + periodo anterior
    Fuentes/
        FuenteOperaciones.php   conteos y series sobre estados_do + importaciones
        FuenteHitos.php         motor de tiempo entre hitos sobre estadohechos
        FuenteGeografia.php     mapas
    SerieBuilder.php            normaliza a {categorias, series[], totales, porcentajes}
    ChartConfigBuilder.php      SerieBuilder -> JSON de Highcharts, con el tema del tenant

app/Support/
    PaletaTenant.php            resolución y rampa de color (§3)
    Periodo.php                 rango de fechas y su periodo anterior comparable
    ContextoTenant.php          aislamiento entre clientes (§5.4)
    CalendarioLaboral.php       días hábiles de 8 horas y festivos (§4.6)

app/Models/
    Indicador.php               catálogo V2 (tabla nueva, conexión mysql)
    ProcesosWebSimex.php        [CORREGIR] localKey 'id' -> 'idproceso'
    GraficosAsercol.php         [CORREGIR] primaryKey 'idgrafica', keyType, timestamps
```

### 5.3 Contrato único de serie

Todo lo que se dibuja pasa por la misma forma, produzca Highcharts o HTML:

```php
[
  'categorias'  => ['Enero', 'Febrero', ...],        // eje X
  'series'      => [
      ['nombre' => 'High cube', 'datos' => [58, 66, ...], 'color' => '#14625c'],
  ],
  'totales'     => ['serie' => ['High cube' => 441], 'categoria' => [...], 'general' => 1619],
  'porcentajes' => ['High cube' => 27.2],            // el pie fusionado
  'vacia'       => false,
]
```

`ChartConfigBuilder` lo convierte en JSON de Highcharts; la leyenda HTML lee `totales` y `porcentajes`
directamente. **Una consulta, dos lecturas** — es la fusión de §2.3 hecha código.

### 5.4 Aislamiento de tenant

**Se centraliza.** Hoy el filtro es manual en cada consulta y por eso existen D-01, D-01b y D-03. En la
V2 el contexto se resuelve **una vez** en el orquestador y se pasa como objeto a las fuentes:

```php
// app/Support/ContextoTenant.php
//   idGrupoEmpresarial   de session('GrupoEmpresarial')
//   importadores         array<int> de compania.id_websimex, inactivo = 0
//   importadorActivo     null | int  — validado con in_array() contra importadores
```

**Ninguna fuente recibe un id de importador suelto.** Reciben el contexto, y el contexto ya validó.
Esto cierra D-01 y D-01b por construcción: no hay camino por el que un valor de una propiedad pública
de Livewire llegue a una consulta sin pasar por `in_array()`.

> Esto **no** sustituye la regla de CLAUDE.md §1 —sigue sin haber global scope— pero reduce la
> superficie: un solo punto que revisar en vez de catorce consultas.

---

## 6. Modelo de datos del catálogo V2

Tres tablas en `asercolg_po` (conexión `mysql`), que unifican `graficos_dashboard` + `graficos_kpi` y
absorben la parametrización de `asercol.graficos`:

```
indicadores
  id                        int PK
  codigo                    varchar(60)  UNIQUE   ej. 'imp_tamano_unidad_carga'
  nombre                    varchar(150)
  descripcion               varchar(255)          <- descreporte
  bloque                    varchar(40)           cabecera|series|participacion|ans|operativo
  regimen                   char(1)               I|E
  id_proceso_ws             int NULL              -> reportes.procesos.idproceso
  fuente                    varchar(20)           operaciones|hitos|geografia
  parametros                json                  cómo consultar, NO el SQL
  unidad                    varchar(20)           operaciones|dias|contenedores|porcentaje
  meta_defecto              decimal(8,2) NULL     <- asercol.graficos.meta
  umbral_cumplimiento       decimal(5,2) NULL     % por defecto 90   <- R6
  orden                     int
  estado                    tinyint
  timestamps

indicador_visualizacion
  id, id_indicador, tipo (columna_apilada|barra_participacion|tabla|mapa|tarjeta),
  titulo, subtitulo, ancho (1|2), orden

indicador_asignacion
  id, id_indicador,
  id_grupo_empresarial  NULL = aplica a todos      <- lo que le falta a graficos_dashboard
  meta_override         NULL = usa meta_defecto     <- lo que le falta a asercol.graficos
  orden, estado
```

**Qué resuelve cada pieza:**

- `parametros` en JSON describe *qué medir* (tabla, campo de corte, dimensión de agrupación, hitos
  inicial y final), **no cómo consultarlo**. El "cómo" está en las clases `Fuentes/`. Sin texto SQL, no
  hay D-01 ni D-29.
- `indicador_visualizacion` separada permite que una medición se dibuje de más de una forma sin
  duplicar la consulta — es la fusión de §2.3 expresada en el esquema.
- `indicador_asignacion` trae de `graficos_kpi` la personalización por grupo empresarial (que
  `graficos_dashboard` no tiene) y de `asercol.graficos` la meta (que `graficos_dashboard` tampoco
  tiene), con `meta_override` para el cliente que negoció un ANS distinto.

> **Sin migraciones.** CLAUDE.md §3: el esquema se administra fuera del repositorio. Las tres tablas se
> entregan como script en `specs/sql/dashboard_v2_catalogo.sql` para aplicar en base de datos, más el
> script de carga inicial que traduce las 21 filas activas de `graficos_dashboard` y los 52 indicadores
> de `asercol.graficos` al modelo nuevo.

---

## 7. Deuda técnica

### 7.1 La V1 no se toca (decisión I)

La V2 es **independiente en todos sus componentes**: componentes Livewire, vistas, rutas y menú
propios. Se reutiliza *lógica* de la V1 —el cálculo de rango de fechas, el armado de series, los joins
de los SQL del catálogo— **copiándola y adaptándola**, nunca invocando ni modificando el archivo
original. Los cinco archivos de [app/Http/Livewire/Dashboard/](../app/Http/Livewire/Dashboard/) quedan
congelados hasta su retiro en la Etapa 3.

Esto **anula la decisión G**: las deudas de Prioridad 0 ya no son un prerequisito en la V1. Se corrigen
**por construcción** en el código nuevo:

| Deuda | Cómo queda resuelta en la V2 | Qué la resuelve |
|---|---|---|
| **D-01** 🔴 inyección SQL + salto de tenant | No hay SQL almacenado que interpolar, y `ContextoTenant` valida el importador con `in_array()` antes de que llegue a ninguna consulta. **Además ya se parcheó en la V1** — ver la excepción más abajo | §5.1, §5.4 |
| **D-18** 🔴 periodo anterior mal calculado | `FuenteOperaciones` usa `whereIn` y `COUNT(DISTINCT NUMERO_DO)` en ambos periodos, desde la primera línea | §5.2 |
| **D-40** 🔴 el periodo anterior **no es comparable** | `Periodo::anterior()` devuelve un tramo de la **misma duración**. Hallazgo de la Etapa 0, ver abajo | [Periodo.php](../app/Support/Periodo.php) |
| **D-41** 🟠 el gráfico 11 mezcla dos fechas | Filtraba por `importaciones.FECHA_LEVANTE` pero agrupaba y etiquetaba por `formularios_dis.FECHA_LEVANTE`. `FuenteOperaciones` usa la de la declaración en ambas | [FuenteOperaciones.php](../app/Services/Dashboard/Fuentes/FuenteOperaciones.php) |
| **D-42** 🟠 contenedores contados de dos formas | El gráfico 36 (agentes, impo) usa `COUNT(cr.CANTIDAD)` —cuenta filas— y su gemelo 35 (navieras, expo) usa `SUM(cr.CANTIDAD)`. Dos cifras que se presentan como lo mismo. En la V2 ambas **suman** | ídem |
| **D-43** 🟡 la cifra grande no es la suma de la barra | Un DO con dos documentos de transporte de medios distintos aparece en dos segmentos: la suma de la serie excede las operaciones distintas. La tarjeta usa `totalOperaciones()`, no el total de la serie | ídem |
| **D-45** 🔴 fechas corruptas en `estadohechos` | Hay hitos con año **0004** y **0026** entre los DO de 2026. Una sola de esas fechas arrastraba al calendario a recorrer dos mil años consultando festivos: **214 s en un único indicador**. `FuenteHitos` descarta los hitos anteriores a 2004 y el calendario acota el rango a los años que el ERP cubre | [FuenteHitos.php](../app/Services/Dashboard/Fuentes/FuenteHitos.php) · [CalendarioLaboral.php](../app/Support/CalendarioLaboral.php) |
| **D-44** 🔴 el mes en curso se comparaba contra un mes completo | Mismo defecto que D-40 en versión mensual. Detectado al validar en el navegador: septiembre (11 días) contra agosto (31) daba **−65,7 %** cuando la variación real es **+63,3 %**. `Periodo` corta el mes en curso en la fecha de hoy y recorta el anterior al mismo día | [Periodo.php](../app/Support/Periodo.php) |
| **D-03** 🔴 recorrido completo de `estadohechos` | Ninguna consulta a `estadohechos` puede omitir `numdo`: la fuente solo expone métodos que reciben una lista de DO | §9.1 |
| **D-29** 🔴 `GROUP BY` incompleto | El agregado anual se suma en PHP desde la serie mensual; el `GROUP BY` ambiguo desaparece | §2.3 |
| **D-39** 🟠 llaves mal en los modelos nuevos | Se corrige ahora: es **trabajo propio sin commitear**, no código de la V1 | abajo |

**D-39 sí se arregla ya**, y no contradice la independencia: `GraficosAsercol` está sin versionar y no
tiene ningún consumidor, y la relación `graficosAsercol()` de `ProcesosWebSimex` también es un cambio
local pendiente de commit. Corregir el propio trabajo en curso no es tocar la V1.

**Excepción aprobada: D-01 sí se parcheó en la V1** (decisión L, 2026-09-11). Era una inyección SQL con
salto de tenant explotable por cualquier usuario autenticado, y la V1 iba a seguir sirviéndola durante
todo el piloto. El parche **no cambia ninguna cifra visible**, así que no arrastra el problema que
motivó la decisión K.

**Qué se hizo** — [ConImportadorValidado.php](../app/Http/Livewire/Dashboard/ConImportadorValidado.php),
trait nuevo aplicado a los tres componentes que reciben el evento `setPeriodo`:

| Archivo | Cambio |
|---|---|
| `ConImportadorValidado.php` | **Nuevo.** `validarImportador()` y `listaImportadoresSql()` |
| [ImportacionesGraph.php](../app/Http/Livewire/Dashboard/ImportacionesGraph.php) | `$clientes = $importador` → `$clientes = $this->listaImportadoresSql($importador)` |
| [ImportacionesTotaleStats.php](../app/Http/Livewire/Dashboard/ImportacionesTotaleStats.php) | `$this->compania = [$importador]` → solo si `validarImportador()` lo acepta |
| [ExportacionesTotaleStats.php](../app/Http/Livewire/Dashboard/ExportacionesTotaleStats.php) | ídem |

**Tres decisiones del parche que conviene registrar:**

1. **La lista de importadores permitidos se lee de la base en cada validación**, desde
   `session('GrupoEmpresarial')` — **no** de `$this->compania`. Esa es una propiedad *pública* de
   Livewire, y en Livewire 2 el navegador puede enviar actualizaciones de propiedades públicas: validar
   contra ella sería validar contra un valor que el atacante controla. Es la parte no obvia del
   arreglo, y la que lo hace realmente efectivo.
2. **Un importador rechazado no vacía el tablero: cae a la lista completa del tenant** y se registra un
   `Log::warning` con el grupo, el usuario y el valor recibido. El usuario legítimo nunca ve un error;
   el ilegítimo no ve nada ajeno y queda en bitácora.
3. **Se descartan los `id_websimex` nulos o cero.** Hay una compañía activa (id 61, grupo 44) con
   `id_websimex = 0` que antes se colaba al `implode()` y producía `IN (0)`. Si el grupo no tiene
   ningún importador válido, `consultarData()` retorna vacío en vez de generar `IN ()`, que es un error
   de sintaxis.

Verificado contra la base con el grupo 41: importador propio → filtra; importador de otro tenant
(251, 617) → se ignora y se registra; cargas de inyección (`OR 1=1`, `UNION SELECT`, subconsulta) → se
rechazan; propiedad pública `$compania` manipulada → se ignora. Los 21 gráficos siguen renderizando.
`php -l` pasa en los cuatro archivos.

> El trait es **temporal y exclusivo de la V1**: se borra con ella en la Etapa 3. La V2 no lo usa —
> resuelve lo mismo en `App\Support\ContextoTenant` (§5.4).

**D-18 y K.** La corrección no se retro-porta: mientras convivan las dos versiones, el % de variación
de importaciones **diferirá entre V1 y V2**, y la V2 es la correcta. Hay que decirlo en el piloto para
que nadie lo reporte como defecto de la V2 (§11 · prueba 1).

#### Segunda excepción: rendimiento de la V1 (decisión T, 2026-09-30)

Mientras la V2 está en piloto, la V1 sigue siendo el tablero que usan los clientes, y era lenta:
**~26 s por carga** en el grupo 41 solo en gráficos y un payload de Livewire de **21 MB**. Se le
aplicaron las lecciones de §9 **sin tocar la decisión K**: todo cambio se verificó comparando la V1 de
`HEAD` con la nueva, contra la base y solo con lecturas, y **las cifras son idénticas en todos los
casos probados**.

| Archivo | Cambio | Deuda |
|---|---|---|
| [ExportacionesTotaleStats.php](../app/Http/Livewire/Dashboard/ExportacionesTotaleStats.php) | `zarpesDelTenant()`: parte de los DO de exportación del tenant en `estados_do` y consulta `estadohechos` **por lotes de 1.000 `numdo`** (string), en **una** pasada para los dos periodos. Se quitan 5 `LEFT JOIN` sin uso | D-03 |
| ídem | Con más de `UMBRAL_LOTES` = 20.000 DO en el universo (grupo 1: 29.423 desde 2010), **un** recorrido global en vez de dos, cruzado en PHP con el universo | D-03 |
| [ImportacionesGraph.php](../app/Http/Livewire/Dashboard/ImportacionesGraph.php) | `SET lc_time_names` una vez por conexión; los SQL idénticos (pares column + pie) se ejecutan una sola vez por request; `sql` y `sql_detalle` ya no viajan al navegador; propiedades de trabajo a `protected`; `consultarData()` a `protected` (era invocable como acción de Livewire) | D-25, D-36, D-05 |
| ídem | `sql_detalle` **ya no se ejecuta en cada carga** | D-25, §9.2 |
| [DetalleGraph.php](../app/Http/Livewire/Dashboard/DetalleGraph.php) + vista | Era código muerto (D-30); ahora consulta el detalle **bajo demanda**, solo el del gráfico pedido. El modal muestra hasta 500 filas; el Excel lleva todas y se genera en el servidor (`Excel::download` desde Livewire), sin pasar por `/api/download-excel`. La tabla se escapa con Blade | D-05, D-06, D-27 |
| [ImportacionesAdmin.php](../app/Http/Livewire/Dashboard/ImportacionesAdmin.php) | El `MIN/MAX(YEAR(FECHA_APERTURA))` pasa de `render()` a `mount()` | D-24 |
| [importaciones-graph.blade.php](../resources/views/livewire/dashboard/importaciones-graph.blade.php) + [dashboard-importaciones.blade.php](../resources/views/Dashboard/dashboard-importaciones.blade.php) | Sin `<script>` por gráfico: la configuración va en `data-grafico` y la dibuja el shell con `message.processed`, filtrado al componente de gráficos. Sin `json_encode($graphs)` en los botones: llevan solo el id | D-13, D-14, D-05 |
| [ConPeriodoValidado.php](../app/Http/Livewire/Dashboard/ConPeriodoValidado.php) | **Nuevo.** Valida año y mes como enteros antes de armar las fechas. Mismo formato de fecha que antes (`'2026-3-01'`, sin relleno) | **D-46** |

**D-46 🔴 — inyección SQL por el periodo.** Hallada al hacer estos ajustes. El parche de D-01 cubría el
importador, pero el **año** del payload `setPeriodo` también se interpolaba literal en el SQL de
`graficos_dashboard` (`"$anio-01-01"` → `?fechaInicial`). `explode('-')` impedía el guion, no la
comilla: un año como `2026' OR 1=1 #` se ejecutaba contra `websimex`. Mismo tratamiento que D-01: un
periodo inválido cae al año en curso y queda en `Log::warning`. Las tarjetas KPI no estaban expuestas
(van por *binding* de Eloquent) y no se tocaron.

**Medido** (mount + un cambio de filtro, es decir, dos cargas):

| Bloque | Grupo | Antes | Después |
|---|---|---:|---:|
| Tarjetas expo | 41 · 8 · 22 · 28 | ~10,8 s | 1,5 – 2,6 s |
| Tarjetas expo | 1 | 10,8 s | 5,5 s |
| Gráficos | 41 | 52,5 s · 112 consultas | 22,1 s · 38 consultas |
| Gráficos | 8 · 20 | 29,7 s · 16,6 s | 11,8 s · 6,1 s |
| Gráficos | 1 | 240 s | 164 s |
| Payload `$graphs` | 41 · 20 · 1 | 21 MB · 4,4 MB · 1,8 MB | 24 KB · 15 KB · 20 KB |

**Verificación de paridad:** tarjetas de expo en 9 combinaciones de grupo/periodo, los 21 gráficos en 4
grupos (1, 8, 20, 41) y el detalle de los 14 gráficos que lo tienen en esos mismos grupos: cero
diferencias. `php -l` y `view:cache` pasan. **Falta la prueba en navegador** (§11 · prueba 10).

**Por qué no se acotó el universo de expo por `FECHA_APERTURA`.** Habría hecho que el grupo 1 se
comportara como los demás, pero sobre los 74.627 zarpes desde 2015, **446 están registrados antes de
la apertura del DO** y hay desfases de hasta 1.137 días (más uno corrupto de 69.398). Cualquier corte
cambiaba cifras, así que se optó por el umbral.

**Lo que queda lento y por qué.** En el grupo 1, **el gráfico 12 (`exp_numero_unidad_carga`) tarda 93 s
de los 130 s** de la carga. El plan arranca por `contenedores_resumidos` completo (124.189 filas, sin
índice usable) y el SQL multiplica filas: `unidades_carga` × `contenedores_resumidos` ×
`ex_solicitudes_aut_embarques` por DO, para luego deduplicar con `COUNT(DISTINCT)`. Le siguen el 35
(23 s) y el 7 (8 s). La corrección es un `UPDATE` del SQL almacenado —`EXISTS` en vez del `INNER JOIN`
a `contenedores_resumidos`, que solo filtra, y fuera los `LEFT JOIN` sin uso—; queda como P13, porque la
prueba comparativa superó los 10 minutos contra el ERP y se detuvo.

**Primera carga acotada — P14, aplicada 2026-09-30.** La V1 arranca como la V2 (decisión S): **mes en
curso** y, con varias compañías, **la primera** (se saltan los `id_websimex` nulos o cero). El selector
de Importador ya no ofrece la opción vacía; el de mes la llama «Todos los meses». Si el año en curso no
tiene operación, cae al último año con datos, completo. Bajo los filtros se muestra *«Cifras de
&lt;compañía&gt;»*. `ImportacionesAdmin::getParametrosInicialesProperty()` pasa el periodo y el
importador a los cuatro hijos en `mount()`, para que ninguno cargue primero el año completo; las
tarjetas KPI ahora montan por el mismo camino que `setPeriodo()`. Si se consulta con importador vacío
en un grupo multi-compañía, se vuelve a la primera.

| Grupo | Compañías | Antes (año completo, grupo entero) | Ahora |
|---|---:|---:|---:|
| 1 | 2 | 123,1 s | **10,4 s** |
| 41 | 4 | 13,0 s | 6,7 s |
| 8 | 3 | 9,8 s | 6,3 s |
| 20 | 2 | 5,0 s | 4,4 s |
| 45 | 35 | 3,7 s | 3,8 s |

*(Los cuatro componentes, sin render. Solo lecturas.)*

> **Advertencia para el piloto.** Esto sí cambia **la cifra que se ve al entrar**, aunque no el cálculo.
> Las tarjetas KPI de la V1 comparan el mes en curso (incompleto) contra el mes anterior **completo**:
> es D-44, el mismo defecto que la V2 corrige. Antes, al entrar se veía D-40 (año contra un mes, p. ej.
> +801 %); ahora se ve D-44 (p. ej. −65 % a mitad de mes). Los dos son incorrectos, y la corrección
> sigue siendo de la V2 (decisión K). Va en la lista de diferencias esperadas de §11 · prueba 1.

**Sigue sin aplicarse** (P15): caché entre requests.

> Como el trait `ConImportadorValidado`, `ConPeriodoValidado` y `zarpesDelTenant()` son **exclusivos de
> la V1** y se borran con ella en la Etapa 3.

#### D-40 🔴 — el «periodo anterior» de la V1 no es comparable

Detectado al escribir [Periodo.php](../app/Support/Periodo.php). Es **más grave que D-18** y explica
las cifras absurdas del mock.

Cuando el usuario consulta el **año en curso**, la V1 fija el periodo actual en
`1-ene → hoy` pero el anterior en `1-dic → 31-dic` del año pasado
([ImportacionesTotaleStats.php:33-40](../app/Http/Livewire/Dashboard/ImportacionesTotaleStats.php#L33-L40)):
compara **nueve meses contra uno**. Medido sobre el grupo 41 al 2026-09-11:

| | Rango | Operaciones | Variación mostrada |
|---|---|---:|---:|
| Actual | 2026-01-01 → 2026-09-11 (254 d) | 1.585 | |
| Anterior **según la V1** | 2025-12-01 → 2025-12-31 (31 d) | 176 | **+801 %** |
| Anterior **comparable** | 2025-01-01 → 2025-09-11 (254 d) | 1.922 | **−18 %** |

**El signo está invertido.** Al cliente se le dice que su operación creció ocho veces cuando en
realidad cayó un 18 %. Y el `+848 %` que dibuja el mock no es un número de relleno: el diseñador copió
la salida real de la V1.

`Periodo::anterior()` lo resuelve: mes elegido → mes calendario anterior; año → **el mismo tramo del
año anterior**. Verificado también en el salto de año (enero → diciembre anterior) y en el 29 de
febrero.

Al igual que D-18, **no se retro-porta** (decisión K): es otra cifra que diferirá entre V1 y V2
durante el piloto, y va en la lista que se entrega por escrito (§11 · prueba 1).

**Línea base para la paridad.** Antes de empezar, versionar el volcado de los 25 SQL (52 KB, ya
generado) como `specs/sql/graficos_dashboard.sql`. No es para auditarlos —la V2 no los usa— sino para
tener el punto fijo contra el cual comparar cifra por cifra en la prueba 1 de §11.

### 7.2 Se salda con la V2

| Deuda | Cómo |
|---|---|
| **D-01** inyección SQL + salto de tenant | Desaparece el SQL almacenado; `ContextoTenant` valida el importador (§5.4) |
| **D-29** `GROUP BY` incompleto | El agregado anual se suma en PHP desde la serie mensual (§2.3) |
| **D-02** `compania.procesos` como CSV leído de **una sola** compañía | Sustituido por `reportes.procesos` cruzado con los procesos que el tenant opera (§4.5 · R3). Elimina el dashboard en blanco sin mensaje cuando el CSV viene vacío |
| Motor duplicado `graficos_dashboard` / `graficos_kpi` | Un solo catálogo (§6). [IndicadoresV2.php](../app/Http/Livewire/Kpi/IndicadoresV2.php) migra al mismo servicio |
| Dos motores de mapas cargados a la vez | Solo Highcharts Maps (§4.4) |
| Despacho dinámico `$this->$tipo()` sin lista blanca | `indicador_visualizacion.tipo` con conjunto cerrado validado |
| Identificador JS sin comillas en el Blade | La configuración se pasa por `data-` y se lee en JS |
| `$graphs` completo (con **todo el SQL**) serializado en cada botón de exportar | Ya no hay SQL que filtrar; el botón lleva solo el identificador del indicador |
| `{!! $graph->detalle !!}` HTML sin escapar, construido por concatenación | El detalle se renderiza en Blade con escape |
| `POST /api/download-excel` fuera de cualquier grupo de autenticación | La exportación V2 pasa por ruta con `middleware('auth')` y valida el tenant |
| Agregación pesada en `render()` (`MIN/MAX(YEAR(...))` en cada ciclo) | En la V2 se calcula en `mount()` y se cachea |
| [DetalleGraph.php](../app/Http/Livewire/Dashboard/DetalleGraph.php) código muerto | No se replica. El archivo de la V1 se borra al retirarla (Etapa 3) |

> Ninguna fila de esta tabla implica editar un archivo de la V1: son deudas que **no se replican** en el
> código nuevo. Se extinguen cuando la V1 se retira.

### 7.3 Hallazgo de la Etapa 0 — no son dos catálogos, son cinco

Al verificar colisiones de nombre antes de crear las tablas de §6 apareció un catálogo que el análisis
inicial no había visto. El recuento real:

| Catálogo | Base | Filas | Naturaleza | Motor que lo consume |
|---|---|---:|---|---|
| `graficos_dashboard` | `asercolg_po` | 25 | SQL almacenado | Dashboard V1 |
| `graficos_kpi` | `asercolg_po` | 196 | SQL almacenado | [Kpi/IndicadoresV2](../app/Http/Livewire/Kpi/IndicadoresV2.php) |
| `graficos_kpi_expo` | `asercolg_po` | 14 | SQL almacenado | — *(no localizado)* |
| `asercol.graficos` | `comex` (ERP) | 60 | **declarativo** | — *(ninguno en el CTC)* |
| **`indicadores_kpi`** + `indicadores_kpi_empresas` | `asercolg_po` | 26 + 14 | **declarativo** | **ninguno** |

**`indicadores_kpi` es, en lo esencial, el modelo que propone §6 — y ya existe en producción.** Tiene
CRUD vivo ([Kpi/Indicadores.php](../app/Http/Livewire/Kpi/Indicadores.php), menú 128 *«Def. de KPI»*,
`estado = 1`), 26 indicadores definidos con etapa inicial y final, y asignación por importador con
terceros. **Lo que no tiene es motor de cálculo**: nadie lee esa tabla para producir un número. Es una
iniciativa que se quedó a medio camino — y sus 14 asignaciones son todas del importador **2656
(YARA COLOMBIA S.A), que está inactivo**.

Aporta **dos columnas que a §6 le faltaban**, y no son menores:

| Columna | Valores reales | Por qué importa |
|---|---|---|
| `unidad_medida` | `DIAS HABILES` (20) · `DIAS CALENDARIO` (2) · `HORAS` (4) | Cambia el **cálculo**, no solo la etiqueta: contar días hábiles exige calendario laboral. Y valida las metas horarias del mock (R1) |
| `condicion_cumplimiento` | `<=` | El operador contra la meta. Sin él, «cumplir» se asume siempre como «no exceder» |

Ambas ya están absorbidas en el DDL de [specs/sql/dashboard_v2_catalogo.sql](sql/dashboard_v2_catalogo.sql),
junto con `id_importador` e `ids_terceros` de `indicadores_kpi_empresas`.

**Resuelto (decisión M, 2026-09-11): se copia, no se mueve.** El paso 4 de
[dashboard_v2_catalogo.sql](sql/dashboard_v2_catalogo.sql) lleva los 26 indicadores y las 14
asignaciones al catálogo nuevo con `INSERT … SELECT`, **sin borrar ni modificar una sola fila** de
`indicadores_kpi` ni de `indicadores_kpi_empresas`. Su CRUD y la operación actual quedan intactos, y
`indicadores.id_indicador_kpi` guarda la trazabilidad.

Once de los 26 no tienen etapa inicial y final, así que no se pueden calcular por hitos: entran con
`fuente = 'pendiente'` para que el motor los ignore y queden visibles como trabajo por definir, en vez
de aparecer siempre en cero.

> **Consecuencia a vigilar.** Durante la convivencia el catálogo queda **duplicado**: editar un
> indicador en el menú 128 no se refleja en el Dashboard V2. Es aceptable mientras dure el piloto —las
> 14 asignaciones son del importador 2656 (YARA), **inactivo**— pero hay que resolverlo antes de que
> ese menú vuelva a usarse en serio. La salida natural es apuntar el menú 128 al catálogo unificado.

### 7.4 Queda pendiente y fuera de alcance

| Tema | Por qué |
|---|---|
| Unificar las 6 columnas de color de `grupo_empresarial` | Transversal: `color_tw` se usa en decenas de vistas (§3.4) |
| `QUEUE_CONNECTION=sync` | Cambio de infraestructura. Se mitiga acotando el tamaño de la exportación (§9.3) |
| Ausencia de tests | No hay `tests/` funcional en el repositorio. Se compensa con el plan de verificación de §11 |
| El iframe de altura fija `padding-top: 56.25%` | Se corrige en la vista contenedora nueva (§10.2), pero el patrón sigue en el resto del sistema |

---

## 8. Etapas de entrega

### Etapa 0 · Preparación (no entrega interfaz)

0. ~~Parchear **D-01** en la V1~~ — **hecho 2026-09-11** (decisión L, §7.1). Se despliega **aparte y
   antes** que todo lo demás: es un arreglo de seguridad autónomo, no depende de nada de la V2 y no
   cambia ninguna cifra.
1. Corregir **D-39** en los modelos sin commitear: `localKey` `'id'` → `'idproceso'` en
   `ProcesosWebSimex::graficosAsercol()`, y `$primaryKey = 'idgrafica'` + `$keyType` +
   `$timestamps = false` en `GraficosAsercol`.
2. Versionar `specs/sql/graficos_dashboard.sql` como línea base de paridad.
3. Script de las tres tablas del catálogo + carga inicial (§6), incluida la copia de
   `indicadores_kpi` (decisión M).
4. `PaletaTenant`, `Periodo`, `ContextoTenant`, `CalendarioLaboral`.

**Verificable por:** la V1 sigue funcionando **exactamente igual que hoy** —no se le toca ni un
archivo— y `php -l` pasa en todos los archivos nuevos. La corrección de `ProcesosWebSimex` se verifica
con que `$proceso->graficosAsercol` devuelva filas en vez de una colección vacía.

> **Los scripts SQL no se ejecutan desde aquí.** CLAUDE.md §3: el esquema se administra fuera del
> repositorio, y el `.env` apunta a bases reales compartidas. Quedan escritos, verificados e
> idempotentes, para que los aplique quien administra el esquema.

### Etapa 1 · Núcleo visual y series — **entregada 2026-09-11**

Ruta y menú nuevos, en paralelo a la V1. Validada en el navegador con el grupo 41.

1. Vista contenedora + shell con IBM Plex, Highcharts Maps, variables CSS del tenant.
2. `Tablero` (periodo, importador, evento de consulta) + `Cabecera` (3 tarjetas).
3. `Series`: los 6 gráficos apilados **fusionados** + los 2 mapas en Highmaps.
4. `Participacion`: agentes, navieras, 2 tablas de aduanas, top-5 de los mapas.
5. Animación de llenado (§4.3) y estados vacíos (§10.3).

**Entrega:** el dashboard actual completo, en el lenguaje visual nuevo, con la mitad de consultas y sin
SQL almacenado. **Todas las cifras deben coincidir con la V1** salvo donde se corrija un bug
documentado.

#### Lo aprendido al validarla en el navegador

Tres cosas que el renderizado en servidor no detecta y que conviene no repetir en las Etapas 2 y 3:

1. **Livewire no reejecuta los `<script>` que viven dentro de un componente.** Funcionan en la carga
   inicial y mueren en el primer re-render: al filtrar, los gráficos desaparecían. Todo el dibujo debe
   vivir en el shell y engancharse a `Livewire.hook('message.processed')`.
2. **`Highcharts.setOptions` tiene que ir en el `<head>`.** Los componentes crean sus gráficos con
   script en línea, que corre antes que cualquier script del pie; con el tema abajo, los gráficos salían
   con el título y los créditos por defecto. El título y los créditos se apagan **además** en la
   configuración de cada gráfico, para no depender del orden.
3. **Toda búsqueda en el DOM debe acotarse al bloque.** Con `document.querySelectorAll` sin alcance, el
   componente de exportaciones repintaba las leyendas del de importaciones con la rampa de arcilla. Cada
   bloque lleva `data-regimen` en su raíz y el dibujante resuelve la rampa con `closest()`.

Y una lección de método: **la validación en navegador encontró un error de datos que el renderizado en
servidor no podía encontrar** (D-44). Las Etapas 2 y 3 deben validarse igual antes de darse por
cerradas.

### Etapa 2 · Cumplimiento del ANS

1. `FuenteHitos`: motor de tiempo entre hitos, con la estrategia de §9.1.
2. Carga del catálogo desde `asercol.graficos` con las desviaciones R1–R6.
3. `Cumplimiento`: lista de procesos + tabla de indicadores + barra con marcador de umbral.
4. Panel desplegable de operaciones fuera de ANS.
5. Exportación del listado a Excel por ruta autenticada.

**Entrega:** la pregunta nueva —*¿estamos dentro de la meta?*— que hoy el módulo no responde.
**Es la etapa de mayor riesgo técnico** (§9.1) y de mayor valor.

### Etapa 3 · Seguimiento operativo

1. Flujo del proceso · importación (5 etapas + ⌀ días).
2. Banda de cuello de botella con enlace al detalle.
3. Operaciones fuera de objetivo.
4. Terceros que afectan el ciclo.
5. Hallazgos por reglas parametrizables.
6. **Retiro de la V1**, una vez el piloto valide la paridad de §11:
   - quitar los menús 141, 142 y 146 del JSON `compania.aplicaciones` de cada cliente y ponerlos en
     `estado = 0`;
   - borrar los 5 componentes de [app/Http/Livewire/Dashboard/](../app/Http/Livewire/Dashboard/) y sus
     vistas, y las rutas `/dashboard/impo` y `/dashboard/impo/ver`;
   - marcar `graficos_dashboard` como obsoleta (no borrarla: es la línea base de la auditoría y el
     respaldo si hay que revertir);
   - borrar el trait temporal
     [ConImportadorValidado.php](../app/Http/Livewire/Dashboard/ConImportadorValidado.php), que solo
     existe para sostener la V1 hasta este momento.

---

## 9. Rendimiento

### 9.1 La restricción que manda: `estadohechos`

| Dato medido | Valor |
|---|---|
| Filas en `estadodehechos.estadohechos` | **7.160.319** |
| Índices | `PRIMARY(regn)` · `PorNumDO(numdo, etapa, fecha)` · `PorInterrup(numdo, etapa, tipo)` |
| DOs de **todos** los clientes, 2026 | 22.287 |
| DOs del tenant más grande, 2026 | 1.980 (grupo completo Nestlé ≈ 3.600) |

**Consecuencia directa:** cualquier consulta que no empiece por `numdo` recorre 7,16 M de filas. La
Etapa 2 debe, sin excepción:

1. Obtener la lista de `NUMERO_DO` del tenant desde `estados_do` (websimex), acotada por periodo y
   régimen. Son **cientos a pocos miles** de DOs, no decenas de miles.
2. Consultar `estadohechos` con `whereIn('numdo', $lote)` **en lotes de 1.000**, lo que sí usa
   `PorNumDO` y convierte el recorrido en búsquedas por índice.
3. Traer **todas las etapas necesarias de una vez** (`whereIn('etapa', [...])`), no una consulta por
   indicador. Los 14 indicadores del proceso 1 usan 13 códigos de etapa entre todos: es **una**
   consulta, no catorce.
4. Excluir `tipo = 2` (interrupciones) para los tiempos netos, como ya hace
   `Pedido::estadoHechoSinInterrupciones()`.

Con ~3.600 DOs y lotes de 1.000, son 4 consultas indexadas. **Es viable bajo demanda**; no hace falta
materializar. Se cachea el resultado por `(grupo, importador, periodo, proceso)` durante la sesión.

#### Lo medido al construir `FuenteHitos` (Etapa 2)

| | |
|---|---|
| Plan de la consulta a `estadohechos` | `type=range`, índice **`PorNumDO`**, ~6.500 filas examinadas por lote de 500 DO. **0,69 s** — la estrategia funciona |
| Primera consulta del proceso | **69 s en frío.** Cargar en memoria las páginas de índice de esos DO cuesta, y lo paga el primer usuario del día |

**El cuello de botella real: un entero donde debía ir una cadena.**

`hitosDe` acumulaba los DO pendientes en un conjunto indexado por `NUMERO_DO` y luego hacía
`array_keys()`. **PHP convierte a entero toda clave que parezca numérica**, y `'253418769'` lo parece,
así que el `whereIn('numdo', …)` ligaba enteros contra una columna `varchar(15)`. Eso obliga a MySQL a
convertir la columna fila por fila y **anula el índice `PorNumDO`**: la consulta dejaba de hacer
búsquedas por rango y recorría los 7,16 M de filas.

Es la regla de CLAUDE.md —*NUMERO_DO es string, nunca castear a int*— incumplida sin escribir un solo
cast: lo hizo PHP al usar el número como clave de arreglo. La corrección es
`array_map('strval', array_keys(...))`.

| | Antes | Después |
|---|---:|---:|
| Catálogo completo, 52 indicadores sobre 3.872 DO | **414,8 s** | **2,7 s** |

Los 37 indicadores con mediciones devuelven **exactamente los mismos valores**, lo que confirma que
ninguno de los cambios de rendimiento alteró un resultado.

#### Cómo se llegó ahí, y qué no era

Tres diagnósticos fallidos antes de acertar, todos por optimizar sin medir:

| Sospecha | Coste real | Veredicto |
|---|---:|---|
| El cálculo en PHP (52 indicadores × miles de DO) | 0,6 s | No era |
| El `ORDER BY` y el filtro `etapa IN (…)` | 1,8 s el método entero | No era, pero se quitaron: la consulta baja de 1,49 s a 0,24 s por lote |
| El bucle día a día del calendario | 0,01 ms por llamada | No era |

Lo decisivo fue **instrumentar el método por dentro** —consulta, recorrido de filas, marcado— y ver que
todo junto sumaba 1,84 s cuando el perfil externo marcaba 409 s. Esa contradicción es la que señaló que
el problema estaba en cómo se ligaba el parámetro, no en lo que hacía el código.

#### El segundo cuello: una fecha del año 4

Con el binding corregido, la página seguía dando **504** en el navegador. El bloque de cumplimiento
tardaba 293 s cuando el motor aislado tardaba 3. Instrumentando indicador por indicador apareció que
**uno solo** —`FINALIZACION MUISCA-RETIRO PUE`— se llevaba 214,8 s.

La causa: `estadohechos` contiene hitos con fechas **`0004-04-29`** y **`0026-04-30`** entre los DO de
2026. Al medir el tiempo desde una de ellas, `CalendarioLaboral` calculaba un rango de años de 4 a 2026
y recorría **2.023 años pidiendo los festivos de cada uno** al ERP.

Dos defensas, porque una sola no basta:

- **`FuenteHitos` descarta los hitos anteriores a 2004**, que es cuando arranca la tabla. Una fecha así
  no solo es lenta: produciría una medición de dos mil años de tránsito.
- **El calendario acota el rango** a los años que `dias_festivos` cubre realmente (2006-2049) y cachea
  los rangos ya armados, para que ninguna otra fecha rara pueda repetir el efecto.

| | Antes | Después |
|---|---:|---:|
| Bloque de cumplimiento | 293,5 s | **3,2 s** |
| **Página completa** | **> 300 s (504)** | **15,3 s** |

> **La lección, para las Etapas 3 y siguientes.** Perfilar ANTES de optimizar. Las dos primeras mejoras
> —agrupar indicadores, aritmética de semanas— eran correctas y hacen falta con volúmenes mayores, pero
> atacaban una parte que cuesta medio segundo. Y cuando una medición aislada y la misma operación dentro
> del código difieren en dos órdenes de magnitud, la diferencia está en el entorno de la llamada, no en
> la consulta.

Y dos errores de diseño propios, corregidos:

1. **Medir indicador por indicador repite la consulta de hitos.** Cada uno necesita etapas distintas,
   así que los 14 del proceso 1 hacían catorce rondas sobre los mismos 2.111 documentos. `medirCatalogo()`
   trae **una** lista de DO y **una** tanda de hitos con la unión de todas las etapas, y reparte por
   proceso en memoria.
2. **El calendario laboral iteraba día por día.** Una operación abierta puede abarcar meses, y el bucle
   costaba un `strtotime` por jornada multiplicado por decenas de miles de mediciones.
   `diasHabilesEntre()` lo calcula por semanas completas más el resto, descontando los festivos entre
   semana: pasa de O(días) a O(festivos del rango). Verificado que no cambia ningún resultado.

> **Si la medición en producción desmiente esto** —un tenant con muchos más DOs, o latencia alta entre
> servidores—, el respaldo es un comando `sync:indicadores-ans` nocturno que materialice los tiempos
> por DO en una tabla del CTC. Se diseña la fuente para que ese cambio sea sustituir la implementación
> detrás de `FuenteHitos`, sin tocar los componentes.

### 9.2 Presupuesto de consultas por carga

| Bloque | V1 | V2 |
|---|---|---|
| Tarjetas de cabecera | 4 + 2 full scans de 7,16 M | 4 |
| Gráficos y mapas | 21 agregados + 21 de detalle | 13 agregados (detalle bajo demanda) |
| ANS | — | 1 lista de DO + ~4 lotes |
| **Total** | **46 + 2 full scans** | **~21, todas indexadas** |

El detalle deja de precalcularse para los 21 gráficos en cada carga y pasa a consultarse **solo cuando
el usuario abre el modal**. Es el ahorro más grande de la V2 y no cuesta funcionalidad.

> La columna *V1* es la de antes de la decisión T. Desde 2026-09-30 la V1 también consulta el detalle
> bajo demanda y ya no recorre `estadohechos` completo (salvo el grupo 1, una vez en vez de dos), y los
> gráficos con SQL idéntico se ejecutan una sola vez: pasa de 112 a 38 consultas en mount + filtro (§7.1).

### 9.3 Exportación

Con `QUEUE_CONNECTION=sync` el Excel se genera dentro del request. Se acota el resultado y se avisa al
usuario cuando el listado excede el máximo, en vez de dejar que el request expire.

---

## 10. Compatibilidad y restricciones

### 10.1 PHP 7.3

Prohibido `match`, propiedades tipadas, *constructor property promotion*, `enum`, `?->`, argumentos con
nombre, `str_contains`/`str_starts_with` nativos. Verificación con `php -l` en cada archivo tocado.

**Consecuencia de diseño:** `ContextoTenant`, `Periodo` y `PaletaTenant` se escriben como clases
simples con constructor clásico y getters, no como objetos de valor modernos.

### 10.2 El iframe

El patrón vigente (CLAUDE.md) es iframe + Livewire, y se conserva. Pero
[dashboard-iframe.blade.php](../resources/views/Dashboard/dashboard-iframe.blade.php) usa
`padding-top: 56.25%` — una relación **16:9 fija**. El dashboard V2 es una página larga; en 16:9 queda
un marco recortado con doble barra de desplazamiento.

La vista contenedora nueva usa **altura de viewport con ajuste por mensaje**: el contenido publica su
altura real al padre y el iframe se ajusta. Sin esto, la Etapa 1 se ve mal desde el primer día.

### 10.3 Estados vacíos

Hoy, si `compania.procesos` viene vacío, el dashboard sale **en blanco, sin mensaje** (verificado con
EXIROS DE COLOMBIA LTDA, grupo 23). Cada bloque de la V2 declara su estado vacío con texto explícito:
*"Sin operaciones de exportación en el periodo"*, *"Este cliente no tiene indicadores parametrizados
para este proceso"*.

### 10.4 Alta en base de datos

CLAUDE.md: el módulo no aparece sin la fila en `menus` (con `ruta`, `depende`, `orden`, `perfiles`,
`estado = 1`) **y** el id del menú añadido al JSON `compania.aplicaciones` de cada cliente. Los menús
actuales del dashboard son 141, 142 y 146. La V2 estrena su propio id para poder convivir con la V1
durante el piloto.

### 10.5 Sin escrituras al ERP

`websimex` y `websystem` son **solo lectura**. Todo el catálogo V2 vive en `asercolg_po`. El único
consumo de `asercol.graficos` es lectura para la carga inicial del catálogo.

---

## 11. Verificación

No hay tests en el repositorio. La verificación es manual y por comparación:

| # | Prueba | Criterio |
|---|---|---|
| 1 | **Paridad de cifras V1 vs V2** | **Sobre un periodo CERRADO (ej. 2025), nunca el año en curso**: la base se sincroniza en vivo desde el ERP y el año actual cambia entre corridas — verificado, dos medidas del mismo dato con horas de diferencia dieron 1.585 y 1.586. Con el mismo tenant y periodo, cada cifra de la V2 coincide con la V1, salvo las que corrigen un bug documentado. **Diferencias esperadas y aceptadas:** el % de variación de impo (D-18, decisión K — la V2 es la correcta) y las categorías de los gráficos 1 y 2 (D-29). Como las dos versiones conviven, esta lista se entrega al piloto por escrito para que no reporte las diferencias como defectos. Cualquier otra diferencia es un defecto de la V2 |
| 2 | **Aislamiento de tenant** | Con usuarios de dos grupos distintos, ninguna cifra ni fila del detalle pertenece al otro. Incluye el selector de Importador |
| 3 | **Salto de tenant bloqueado** | Manipulando la propiedad de Livewire del importador a un id de otro grupo, el componente lo rechaza y registra el intento |
| 4 | **Tenant sin datos** | Grupo 23 (EXIROS) y un tenant sin exportaciones: mensaje explícito, nunca página en blanco |
| 5 | **Color por tenant** | Recorrer los 26 grupos activos y verificar que ninguno cae al color de respaldo por un hex mal formado; en particular los de `color_p` con espacio inicial y los de `color_tw` contradictorio |
| 6 | **Rendimiento** | Con el tenant más grande (importador 251, 1.980 DOs en 2026), medir el tiempo de la Etapa 2 y confirmar que ninguna consulta a `estadohechos` omite `numdo` |
| 7 | **Animación** | Con `prefers-reduced-motion: reduce`, todo se pinta en su valor final sin transición |
| 8 | **Responsive** | El iframe se ajusta a la altura del contenido, sin doble barra de desplazamiento, en 1280 px y en pantalla de portátil |
| 9 | **`php -l`** | Todo archivo tocado compila en PHP 7.3 |
| 10 | **V1 tras la decisión T** | En `/dashboard/impo`: «Consultar» redibuja todos los gráficos y mapas; «Ver detalle» abre el modal sin borrar los gráficos; «Exportar detalle» descarga el Excel desde la grilla y desde el modal; un periodo manipulado (`2026' OR 1=1 #`) cae al año en curso y queda en el log; con un grupo multi-compañía, el detalle respeta el importador elegido. **P14:** al entrar, mes en curso y primera compañía seleccionados en los filtros, «Cifras de …» visible, sin opción vacía en Importador; «Todos los meses» + Consultar amplía al año |

---

## 12. Preguntas

### 12.1 Resueltas — 2026-09-11

| # | Pregunta | Respuesta |
|---|---|---|
| **P1** | ¿El color de exportaciones es fijo (arcilla) o sale de `color_s` del tenant? | **Fijo, arcilla `#b4643a`.** → decisión B (§3.3) |
| **P2** | ¿Qué muestra la columna *Real* del ANS: promedio de días o % dentro de meta? | **Ambos, en columnas distintas:** *Real* = promedio de días, *Cumplimiento* = % dentro de meta. → decisión J (§4.5 · R5) |
| **P8** | ¿El arreglo de D-18 se despliega en la V1 o espera a la V2? | **Espera a la V2.** La V1 no se toca. → decisión K (§7.1) |
| **P9** | ¿Parcheamos D-01 en la V1 como excepción a la decisión I? | **Sí, antes de iniciar.** → decisión L. **Implementado y verificado 2026-09-11** (§7.1) |
| **P10** | ¿Qué hacemos con `indicadores_kpi`? | **Se copia al catálogo nuevo, sin borrar nada** de esa tabla, para no afectar la operación actual. → decisión M (§7.3) |
| **P11** | La meta de `asercol.graficos`, ¿días calendario o hábiles? | **Días hábiles de 8 horas laborales.** → decisión N (§4.6) |
| **P4** | El gráfico *Tipos de carga · exportación* del mock no existe. ¿Se construye? | **No. La fila expo lleva una sola tarjeta.** → decisión O (§2.1) |
| **P5** | ¿El código de país es ISO-2 o ISO-3? | **ISO-2 en ambas tablas**, verificado contra el ERP. Sin conversión (§4.4) |
| **P3** | ¿Umbral único o por indicador? ¿Y el defecto de Ingresos a Zona Franca? | **90 % para todos**, ajustable después desde operación → decisión P. Y el defecto es **`AVISO LLEGADA - ENTREGA TRANSP`** → decisión Q |
| — | *(instrucción adicional)* Independencia de la V1 | **Total en todos los componentes**, con reutilización de lógica por copia. → decisión I (§7.1, §5.2) |
| — | *(2026-09-30)* ¿Se ajusta el rendimiento de la V1 mientras la V2 está en piloto? | **Sí, sin cambiar cifras.** → decisión T (§7.1) |
| **P14** | *(2026-09-30)* ¿La V1 arranca en el mes en curso y la primera compañía, como la V2? | **Sí.** Aplicada y medida (§7.1). La caché entre requests se separa como P15 |

### 12.2 Abiertas

Ninguna bloquea el arranque: la Etapa 0 y la Etapa 1 pueden empezar sin ellas. P3, P4 y P6 se necesitan
antes de cerrar las Etapas 2 y 3.

| # | Pregunta | Se necesita para |
|---|---|---|
| **P6** | **¿Los *Hallazgos* entran como reglas automáticas o se descartan?** Es el único bloque del diseño sin dato detrás. Propongo 3 reglas con umbral parametrizable (§2.2) | Alcance de Etapa 3 |
| **P7** | **¿Qué cliente es el piloto?** Condiciona las pruebas de paridad y de rendimiento. Nestlé (grupo 41) tiene el mayor volumen; un grupo pequeño valida antes | Etapa 1, antes del despliegue |
| **P12** | **¿La jornada laboral es 08:00–12:00 y 13:00–17:00?** Es el único supuesto que puse yo. Cambia el cálculo solo en tramos del mismo día; el total de 8 h no se mueve (§4.6) | Cerrar Etapa 2 |
| **P13** | **¿Se reescribe el SQL del gráfico 12 de la V1** (93 s en el grupo 1)? Es un `UPDATE` sobre `graficos_dashboard` y requiere una prueba de paridad en una ventana acotada, porque la comparación superó los 10 minutos contra el ERP (§7.1) | V1 mientras dure el piloto |
| **P15** | **¿La V1 cachea resultados entre requests?** `CACHE_DRIVER=file`. Cambia la frescura del dato (la base se sincroniza en vivo desde el ERP): hay que fijar el TTL con el negocio | V1 mientras dure el piloto |

---

## 13. Anexos

### 13.1 Archivos del handoff

```
Dashboard torre de control aduanal-handoff.zip
  dashboard-torre-de-control-aduanal/
    README.md
    project/
      Dashboard Aduanero.dc.html    822 líneas — artboard 1b + datos de ejemplo
      choropleth-map.js              58 líneas — coropleta d3 + Natural Earth
      support.js                   1911 líneas — runtime del prototipo (no se porta)
      uploads/                        8 capturas
```

`support.js` es el motor del prototipo (`DCLogic`, `sc-for`, `sc-if`): **no se porta**. Su equivalente
en el destino es Livewire + Blade.

`choropleth-map.js` usa d3 + topojson con `world-atlas`. **No se porta**: la decisión D lo sustituye por
Highcharts Maps, que da la misma lectura con tooltip y zoom.

### 13.2 Correspondencia gráfico V1 → tarjeta V2

| V2 | Gráficos V1 absorbidos |
|---|---|
| Tarjeta Importaciones | 1, 2 |
| Tarjeta Exportaciones | 8 |
| Totales del periodo | 3, 11, 14, 50 (agregados) |
| Tamaño de unidad de carga impo | 3, 4 |
| Tipos de carga marítima impo | 9, 10 |
| Tipos de declaración impo | 11, 14 |
| Tipo de selectividad impo | 49, 50 |
| Aduanas de nacionalización | 16, 17 |
| Agentes transportadores impo | 36 |
| Mapa origen impo | 38 |
| Tamaño de unidad de carga expo | 12, 13 |
| Aduanas de exportación | 5, 6 |
| Líneas navieras expo | 35 |
| Mapa destino expo | 7 |
| *(sin equivalente V1)* | Tipos de carga expo — ver P4 |
| *(inactivos, no se migran)* | 26, 27, 28, 37 (`estado = 0`) |

### 13.3 Indicadores ANS por proceso

Medido sobre `asercol.graficos` con `inactivo = 0`. Los conteos coinciden con el mock.

| `idproceso` | Proceso | Indicadores activos | Por defecto (`defecto='S'`) |
|---:|---|---:|---|
| 1 | IMPORTACIONES POR PUERTO | 14 | `INICIO-LEVANTE` (meta 2 d) |
| 2 | EXPORTACIONES | 5 | `DCTOS-INGRESO` (meta 6 d) |
| 3 | TRÁNSITO ADUANERO | 5 | `INICIO-LEVANTE DTA` (meta 5 d) |
| 4 | INGRESOS A ZONA FRANCA | 9 | **ninguno** ⚠ |
| 5 | SALIDA A TERCEROS PAÍSES DE ZF | 2 | `DOC AUTORIZACION-INGR PUERTO` (meta 3 d) |
| 7 | IMPORTACIONES DESDE ZONA FRANCA | 6 | `DCTOS-LEVANTE` (meta 2 d) |
| 8 | IMPORTACIONES DESDE DEPÓSITO | 3 | `DCTOS-LEVANTE` (meta 2 d) |
| 11 | NACIONALIZACIÓN AEROPUERTO | 8 | `INICIO-LEVANTE` (meta 2 d) |
| 6, 9, 10, 12, 13, 14, -1 | *(resto de `reportes.procesos`)* | **0** | — |

> **`INGRESOS A ZONA FRANCA` no tenía indicador por defecto** pese a tener 9 activos, y además los nueve
> traen `orden` nulo, así que cualquier elección automática habría sido arbitraria. El negocio lo
> resolvió el 2026-09-11 (decisión Q): **`AVISO LLEGADA - ENTREGA TRANSP`** (`ans_72`, etapas
> 0501 → 3002, meta 3 días hábiles).
>
> Como `asercol.graficos` es solo lectura, el dato vive en la columna `por_defecto` del catálogo nuevo,
> que crea [dashboard_v2_ans_defectos.sql](sql/dashboard_v2_ans_defectos.sql) junto con los otros siete.

Rango de metas en todo el catálogo: **1 a 6 días**. Distribución: 1 d → 11 · 2 d → 14 · 3 d → 8 ·
4 d → 7 · 5 d → 10 · 6 d → 2.
