# Especificación funcional y técnica — Componente `GraficosKpi`

> **Módulo:** Información de Pedidos › KPI - Indicadores de Gestión
> **Componente:** [app/Http/Livewire/InfoPedidos/GraficosKpi.php](../app/Http/Livewire/InfoPedidos/GraficosKpi.php)
> **Fecha de análisis:** 2026-09-12
> **Rama analizada:** `mayala` (commit `0139b316`)

## Objetivo de este documento

Este documento cumple dos propósitos:

1. **Insumo para una V2 del módulo.** Documenta la estructura real del componente, sus
   correlaciones y las deudas técnicas identificadas, con el detalle suficiente para
   rediseñarlo sin perder reglas de negocio.
2. **Base de conocimiento para replicar/auditar los cálculos de cumplimiento de
   indicadores en otro sistema de información.** La sección
   [5. Motor de cálculo](#5-motor-de-cálculo) y la sección
   [8. Especificación canónica para replicar](#8-especificación-canónica-para-replicar-el-cálculo)
   contienen la fórmula completa, verificada contra la base de datos productiva.

Todos los datos de configuración y las pruebas de la función de días hábiles fueron
**verificados por consulta directa (solo lectura) contra las bases productivas** el
2026-09-12. Los valores citados llevan la marca ✔ *verificado*.

---

## 1. Qué hace el módulo (visión de negocio)

El módulo mide **cuántos días hábiles tarda un pedido (DO) en recorrer un tramo del
proceso logístico-aduanero**, entre una actividad inicial y una actividad final, y
clasifica cada pedido contra una **meta** expresada en días.

El resultado que ve el usuario es:

- Un **gráfico de torta** con la distribución de pedidos por rango de días.
- Una **tabla de rangos** (ej.: "Hasta 2 días", "Entre 3-6 días", "Entre 7-10 días",
  "Más de 10 días") con el número de pedidos en cada rango.
- La **meta** configurada y el **promedio** de días.
- Filas adicionales de **`error`** (pedidos cuyo tiempo no se pudo calcular) y
  **`Total`**.
- Por cada rango, capacidad de **drill-down** (ver el listado de DOs) y de
  **exportar a Excel**.

Existe una segunda vista, el **histórico** ([HistoricosKpi.php](../app/Http/Livewire/InfoPedidos/HistoricosKpi.php)),
que presenta la misma población agrupada por mes con un porcentaje de cumplimiento.
Ver [§5.6](#56-variante-histórica-historicoskpi) — **su fórmula de cumplimiento NO respeta la meta configurada**.

---

## 2. Puntos de entrada y flujo de ejecución

```
resources/views/livewire/info-pedidos/index.blade.php
   │  (JS) función procesar('buscar')  — reporte id = 7
   │  Construye un query-string con TODOS los inputs/selects del formulario,
   │  envolviendo cada valor en comillas simples:  &nombre='valor'
   │
   ├─ parametro_analizar != 2 ──► GET /pedidos/reporte_kpi?...
   │                              routes/web.php:550
   │                              InfoPedidosController@reportekpi
   │                              └─ view livewire.info-pedidos.kpi
   │                                 └─ @livewire('info-pedidos.graficos-kpi', ['request' => $request])
   │                                    └─ App\Http\Livewire\InfoPedidos\GraficosKpi   ◄── ESTE COMPONENTE
   │
   └─ parametro_analizar == 2 ──► GET /pedidos/historico_kpi?...
                                  InfoPedidosController@historicokpi
                                  └─ view livewire.info-pedidos.kpi-historico
                                     └─ @livewire('info-pedidos.historicos-kpi', ...)
                                        └─ App\Http\Livewire\InfoPedidos\HistoricosKpi
```

El panel se abre dentro de un **iframe** del shell heredado (`parent.AddPanel(...)`),
razón por la cual `kpi.blade.php` es un documento HTML completo con sus propios
`@livewireStyles` / `@livewireScripts`.

### 2.1 Contrato de parámetros de entrada

`mount($request)` lee estos parámetros del `Request` HTTP:

| Propiedad del componente | Parámetro HTTP            | Formato real recibido | Notas |
|---|---|---|---|
| `$id_grafico`        | `parametro_kpi_indicador` | `'59'`   | `graficos.idgrafica`. Llega **entre comillas simples**. |
| `$fecha_inicial`     | `filtro_fecha_inicial`    | `'2026-08-01'` | idem |
| `$fecha_final`       | `filtro_fecha_final`      | `'2026-09-12'` | idem |
| `$filtro_ciudad`     | `filtro_ciudad`           | `'4'` o `''`   | `idsucursal`. **Catálogo propio** (ver §2.2) |
| `$filtro_tipo_carga` | `parametro_tipo_carga`    | `'FCL'` o `''` | `estadodehechos.ordenes.tipocl` |
| `$parametro_cliente` | `parametro_cliente_`      | `'ACME'` o `''`| Búsqueda parcial por razón social |
| `$codProceso`        | `parametro_proceso`       | `'1'`    | `procesos.idproceso` |
| `$actividadKey`      | `actividadKey`            | —        | **Nunca se usa** (código muerto) |

> ⚠️ **Convención crítica y frágil:** el JavaScript emisor concatena
> `elemento.name + "='" + elemento.value + "'"`, de modo que **los valores llegan ya
> entrecomillados** y el componente los interpola literalmente en SQL. Por eso el código
> compara contra la cadena `"''"` para detectar "vacío"
> ([GraficosKpi.php:249](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L249)).
> Si alguien llama la URL sin las comillas, el SQL generado es sintácticamente inválido y
> el usuario ve la pantalla genérica de error.

> ⚠️ **`parametro_cliente_` (con guion bajo final)** no coincide con el `id` que genera
> `Index::controlesHTML()` para ese control (`parametro_cliente`). Verificar en la V2 si
> el filtro de cliente está llegando efectivamente o si siempre es `null`.

### 2.2 Discrepancia de catálogos de ciudad ✔ verificado

`Index::controlesHTML()` define **dos** catálogos de ciudad distintos para el mismo campo
`filtro_ciudad`:

| valor | `ciudad` (genérico) | `ciudadKpi` (el que usa este módulo) |
|---|---|---|
| 1 | BOGOTA | CARTAGENA |
| 2 | BARRANQUILLA | BARRANQUILLA |
| 3 | BUENAVENTURA | BOGOTA |
| 4 | CARTAGENA | BUENAVENTURA |
| 5 | CALI | SANTA MARTA |
| 6 | CUCUTA | MEDELLIN |

Ambos catálogos están **hardcodeados en PHP** y solo el segundo aplica al KPI. Cualquier
reimplementación debe tomar el mapeo desde la tabla de sucursales, no desde estas listas.

---

## 3. Mapa de correlaciones

### 3.1 Archivos

| Archivo | Rol |
|---|---|
| [GraficosKpi.php](../app/Http/Livewire/InfoPedidos/GraficosKpi.php) | Componente principal analizado |
| [graficos-kpi.blade.php](../resources/views/livewire/info-pedidos/graficos-kpi.blade.php) | Vista (tabla de rangos + drill-down) |
| [kpi.blade.php](../resources/views/livewire/info-pedidos/kpi.blade.php) | Layout contenedor del iframe |
| [HistoricosKpi.php](../app/Http/Livewire/InfoPedidos/HistoricosKpi.php) | Variante histórica mensual (mismo núcleo, fórmula distinta) |
| [Index.php](../app/Http/Livewire/InfoPedidos/Index.php) | Componente padre: construye filtros y dispara la navegación |
| [index.blade.php](../resources/views/livewire/info-pedidos/index.blade.php) | Catálogo **hardcodeado en JS** de qué KPI aplica a qué proceso |
| [InfoPedidosController.php](../app/Http/Controllers/InfoPedidosController.php) | Rutas `reportekpi` / `historicokpi` |
| [public/grafico/grafico_kpi.php](../public/grafico/grafico_kpi.php) | Render del PNG de torta (JpGraph, fuera de Laravel) |
| [public/grafico/grafico_kpi_barras.php](../public/grafico/grafico_kpi_barras.php) | Render del PNG de barras (histórico) |
| [ReportesPedidos.php](../app/Exports/ReportesPedidos.php) | Exportador Excel (Maatwebsite) |
| [CausalesKpiCrud.php](../app/Http/Livewire/CausalesKpiCrud.php) | CRUD que asocia *causales* a gráficas (consume `App\Models\GraficosKpi`) |

> ⚠️ **Colisión de nombres:** existen dos clases `GraficosKpi` — el componente Livewire
> (`App\Http\Livewire\InfoPedidos\GraficosKpi`) y el modelo Eloquent
> (`App\Models\GraficosKpi`, que apunta a `asercol.graficos` por la conexión `websimex`).
> El componente **no usa el modelo**: arma el SQL a mano sobre la conexión `websystem`.

### 3.2 Conexiones de base de datos

| Conexión | Host:puerto | Esquema | Usada para |
|---|---|---|---|
| `websystem` | `18.208.7.77:3307` | `asercol` | Toda la operación: `graficos`, `procesos`, `actividades`, `importaciones`, `exportaciones`, `metacliente`, `plantilla`, `calendario`, `estadodehechos.*` |
| `local` | `192.99.84.46:3306` | `asercolg_central` | `info_pedidos`, `info_meta`, `info_pedidos_where` (configuración de reportes) |
| `websimex` | `18.208.7.77:3307` | `comex` | Solo el modelo `App\Models\GraficosKpi` (CRUD de causales) |

> 🔴 **La configuración del indicador está partida entre dos servidores distintos:** la
> definición del tramo (`graficos`) vive en `websystem` y la definición de los rangos de
> cumplimiento (`info_meta`) vive en `local`. El vínculo es el entero `graficos.meta` →
> `info_meta.nrometa`, **sin integridad referencial posible**. Esto es la deuda técnica
> estructural más importante del módulo.

### 3.3 Diagrama de dependencias de datos

```
                         ┌──────────────────────────────────┐
   conexión `local`      │ info_meta                        │
   asercolg_central      │  nrometa, sqlmeta, encabezados,  │
                         │  condicion                       │
                         └────────────▲─────────────────────┘
                                      │ graficos.meta = info_meta.nrometa
                                      │ (join en PHP, sin FK, cross-server)
   conexión `websystem`  ┌────────────┴─────────────────────┐
   asercol               │ graficos                         │
                         │  idgrafica, fidproceso, meta,    │
                         │  codactini, codactfin,           │
                         │  actividadcontrol, actividades,  │
                         │  campos, encabezados             │
                         └────┬──────────────────┬──────────┘
                              │                  │
              ┌───────────────▼──┐     ┌─────────▼─────────┐
              │ procesos         │     │ metacliente       │
              │  idproceso,tabla │     │  (override de     │
              │  (I / E)         │     │   meta por NIT)   │
              └──────────────────┘     │  ⚠ CÓDIGO MUERTO  │
                                       └───────────────────┘
   Datos transaccionales
   ┌──────────────┐   ┌───────────────────────────┐   ┌──────────────┐
   │ actividades  │   │ estadodehechos.estadohechos│  │ calendario   │
   │ (población)  │   │ (fechas de cada etapa)     │  │ (días hábiles)│
   └──────┬───────┘   └─────────────┬──────────────┘  └──────┬───────┘
          │ LEFT JOIN               │  subconsultas           │
          │  importaciones (i)      │  correlacionadas        │ fn_dias_habiles_proceso()
          │  exportaciones (e)      │                         │
          │  estadodehechos.ordenes │                         │
          │  plantilla (solo Expo)  │                         │
          └─────────────────────────┴─────────────────────────┘
```

---

## 4. Modelo de configuración

### 4.1 `asercol.graficos` — definición del indicador ✔ verificado

```sql
CREATE TABLE `graficos` (
  `idgrafica`        int(6) NOT NULL AUTO_INCREMENT,
  `fidproceso`       int(11),         -- procesos.idproceso
  `reporte`          varchar(30),     -- nombre corto: "INICIO-LEVANTE"
  `codactini`        varchar(20),     -- etapa(s) inicial(es); puede ser "0112,0503"
  `actini`           varchar(20),     -- etiqueta descriptiva (no se usa en el cálculo)
  `codactfin`        varchar(20),     -- etapa final
  `actfin`           varchar(20),     -- etiqueta descriptiva
  `tablaactfinws`    varchar(30),     -- sin uso en este componente
  `campoactfinws`    varchar(40),     -- sin uso en este componente
  `meta`             int(4),          -- → info_meta.nrometa
  `ultactividad`     varchar(10),     -- sin uso en este componente
  `descreporte`      varchar(255),    -- texto explicativo mostrado al usuario
  `defecto`          char(1),
  `orden`            int(11),
  `inactivo`         int(11) DEFAULT 0,
  `campos`           longtext,        -- nombres de columna del drill-down (CSV)
  `encabezados`      longtext,        -- títulos del drill-down (CSV)
  `actividades`      longtext,        -- etapas cuya fecha se muestra en el drill-down (CSV)
  `actividadkey`     varchar(10),     -- sin uso efectivo
  `actividadcontrol` varchar(10),     -- ⚠ DEFINE LA POBLACIÓN. Ver §5.1
  PRIMARY KEY (`idgrafica`)
) ENGINE=MyISAM;
```

- **60 gráficas** definidas, **52 activas** (`inactivo = 0`). ✔
- Metas en uso: **1, 2, 3, 4, 5, 6, 9**. ✔
- `campos`, `encabezados` y `actividades` son **listas CSV posicionalmente acopladas**:
  `campos[i]` recibe la fecha de `actividades[i]`, y `encabezados` debe tener 4 títulos
  fijos (`DO, DIAS, PEDIDO, CLIENTE`) **más** un título por cada `campos[i]`. Un
  desalineamiento produce columnas corridas en el drill-down y en el Excel, sin ninguna
  validación.

Ejemplo real (gráfica 1): ✔

| campo | valor |
|---|---|
| `reporte` | `INICIO-LEVANTE` |
| `fidproceso` | `1` (IMPORTACIONES POR PUERTO) |
| `codactini` | `0112,0503` (dos etapas alternativas) |
| `codactfin` | `2001` (LEVANTE) |
| `actividadcontrol` | `2001` |
| `meta` | `2` |
| `actividades` | `0102,0112,0501,0505,0503,1512,2001,3002,3003,3504,2004,0117,0118` |

### 4.2 `asercolg_central.info_meta` — rangos de cumplimiento ✔ verificado

Es el **corazón de la clasificación**. Cada fila define, como texto SQL, cómo se reparten
los pedidos en "cubetas" de días.

| `nrometa` | `encabezados` | Cubetas (`sqlmeta`) | `prom` |
|---|---|---|---|
| 0 | `Ingreso,,,` | t1=`dias>=0` ; t2=t3=t4=0 | `ROUND(AVG,1)` |
| 1 | Hasta 1 / 2-5 / 6-10 / >10 | `0..1`, `2..5`, `6..10`, `>10` | `ROUND(AVG,1)` |
| 2 | Hasta 2 / 3-6 / 7-10 / >10 | `0..2`, `3..6`, `7..10`, `>10` | `ROUND(AVG,1)` |
| 3 | Hasta 3 / 4-6 / 7-10 / >10 | `0..3`, `4..6`, `7..10`, `>10` | `ROUND(AVG,1)` |
| 4 | Hasta 4 / 5-10 / >10 | `0..4`, `5..10`, `>10` | `ROUND(AVG,1)` |
| 5 | Hasta 5 / >5 | `0..5`, `>5` | `AVG` (sin redondeo) |
| 6 | Hasta 6 / >6 | `0..6`, `>6` | `AVG` (sin redondeo) |
| 8 | Hasta 8 / >8 | `0..8`, `>8` | `AVG` (sin redondeo) |
| 9 | Hasta 9 / >9 | `0..8`, `>9` ⚠ **hueco en `dias = 9`** | `AVG` (sin redondeo) |
| 60 | Hasta 60 / >60 | `0..60`, `>60` | `AVG` (sin redondeo) |

Plantilla literal de `sqlmeta` (caso meta = 2):

```sql
SELECT SUM(IF (dias>=0 AND dias<=2,1,0)) AS t1,
       SUM(IF(dias>2  AND dias<=6,1,0))  AS t2,
       SUM(IF(dias>6  AND dias<=10,1,0)) AS t3,
       SUM(IF(dias>10,1,0))              AS t4,
       SUM(IF(dias<0,1,0))               AS error,
       SUM(1)                            AS total,
       ROUND(AVG(dias),1)                AS prom
FROM (?tmpdias) as tmp
```

La columna `condicion` replica las mismas cubetas como **predicados WHERE**, para el
drill-down, con dos entradas extra al final (`dias<0` = error, `dias>-2` = todo):

```
dias>=0 AND dias<=2, dias>2 AND dias<=6, dias>6 AND dias<=10, dias>10, dias<0, dias>-2
```

> 🔴 `sqlmeta` y `condicion` son **dos definiciones independientes de la misma regla**.
> Nada garantiza que sigan sincronizadas; en `nrometa = 9` ya están desincronizadas con la
> etiqueta (§9.8).

### 4.3 `asercol.metacliente` — override de meta por cliente ✔ verificado

```sql
CREATE TABLE `metacliente` (
  `idmetacliente` int(6) AUTO_INCREMENT,
  `fidgrafica`    int(6),          -- graficos.idgrafica
  `fcodigo`       varchar(100),    -- NIT del cliente
  `meta`          int(4),
  `fechac`        timestamp,
  PRIMARY KEY (`idmetacliente`)
) ENGINE=MyISAM;
```

Contiene **54 filas, 18 gráficas, 18 clientes**. ✔

> 🔴 **Es código muerto.** En los tres puntos donde se consulta, la variable `$codigo`
> está inicializada como cadena vacía y nunca se asigna
> ([GraficosKpi.php:143](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L143),
> [:273](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L273),
> [:411](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L411)). El SQL generado queda:
>
> ```sql
> ifnull((select meta from metacliente where fidgrafica=59 and fcodigo IN ("")), meta) meta
> ```
>
> Ningún `fcodigo` es cadena vacía (✔ verificado: 0 vacíos, 0 nulos sobre 54 filas), así
> que la subconsulta **siempre devuelve NULL** y **siempre se aplica la meta genérica**.
> Hay 18 clientes con metas negociadas que el sistema ignora en silencio.

### 4.4 `asercol.calendario` — jornadas hábiles ✔ verificado

```sql
CREATE TABLE `calendario` (
  `fecha` date NOT NULL DEFAULT '0000-00-00',
  `horas` double DEFAULT NULL,
  `tmp`   timestamp,
  PRIMARY KEY (`fecha`)
) ENGINE=MyISAM;
```

- Rango de datos: **hasta `2026-12-31`**. **0 filas posteriores.** ✔
- Distribución de `horas`: 8 (4 539 días), 0 (1 102), 4 (950 — sábados), 8.5 (131), 3.5 (27). ✔
- En 2026: lunes–viernes = 8 h (salvo festivos = 0), **sábados = 4 h**, domingos = 0. ✔

> 🔴 Ver §9.1 y §9.2: la función de cálculo **ignora el valor de `horas`** y el
> calendario **se agota el 2026-12-31**.

### 4.5 Catálogo de gráficas por proceso — hardcodeado en JavaScript

La función `actualizaKpi()` en
[index.blade.php:274-380](../resources/views/livewire/info-pedidos/index.blade.php#L274-L380)
mantiene un arreglo literal de JS que mapea `idproceso` → lista de `idgrafica` con su
etiqueta. Es una **tercera copia** de información que ya existe en `graficos`
(`fidproceso`, `reporte`, `inactivo`, `orden`) y que debe mantenerse a mano. Los índices
vacíos (`[]` para procesos 6, 9, 10) y los saltos delatan que está desactualizado respecto
de la tabla.

---

## 5. Motor de cálculo

El cálculo se ejecuta en **tres capas anidadas**, todas construidas por concatenación de
strings en PHP y resueltas en una sola consulta MySQL:

```
┌─ Capa 3: info_meta.sqlmeta  → cubetas t1..tN, error, total, prom
│  ┌─ Capa 2: getSqlImpo() / getSqlExpo()  → una fila por DO con la columna DIAS
│  │  ┌─ Capa 1: fn_dias_habiles_proceso(fecha_ini, fecha_fin)  → entero de días hábiles
```

### 5.1 Capa 2 — Población y columna `DIAS`

#### Selección de la variante Impo/Expo

```php
$tabla = ProcesosWebsystem::select('tabla')->where('idproceso', $codProceso)->first();
$sqlDatos = ($tabla->tabla == "I") ? $this->getSqlImpo() : $this->getSqlExpo();
```

`procesos.tabla` ∈ {`I`, `E`}. ✔ Procesos con `I`: 1, 3, 4, 6, 7, 8, 11. Con `E`: 2, 5, 9, 10, 12.

#### Definición de la población (el universo de pedidos medidos)

Esta es la regla de negocio **más importante y menos evidente** del módulo:

```sql
FROM actividades act
WHERE act.proceso      = <fidproceso>
  AND act.codactividad = '<actividadcontrol>'   -- ⚠ la actividad de control, no la final
  AND act.tipoint      = 1
  AND act.fecha       >= <fecha_inicial>
  AND act.fecha       <= <fecha_final>
GROUP BY act.do
```

> 📌 **El rango de fechas se aplica sobre la fecha de registro de la `actividadcontrol`,
> NO sobre la fecha inicial del tramo.** Es decir, el indicador responde a *"de los
> pedidos que **cerraron** el tramo en este período, ¿cuánto tardaron?"* y no a
> *"de los pedidos que **iniciaron** en este período…"*. Un pedido que empezó en enero y
> cerró en marzo aparece en el reporte de marzo. Cualquier réplica en otro sistema debe
> respetar esta semántica o los números no coincidirán.

> 📌 En la práctica `actividadcontrol` coincide con `codactfin` en las gráficas
> revisadas, pero son campos independientes: **no asumir que son iguales**.

`act.tipoint = 1` filtra las actividades "normales" (excluye interrupciones).

#### Joins de contexto

```sql
LEFT  JOIN importaciones i          ON i.do     = act.do
LEFT  JOIN exportaciones e          ON e.do     = act.do
LEFT  JOIN estadodehechos.ordenes o ON o.numdo  = act.do
-- solo en la variante Expo:
INNER JOIN plantilla pl ON pl.idproceso = act.proceso AND estadistica > 0
JOIN       graficos  gr ON gr.fidproceso = act.proceso
```

> ⚠️ Los dos joins extra de la variante Expo **no aportan columnas ni filtran DOs**:
> `plantilla` tiene 5 filas con `estadistica > 0` para el proceso 2 (✔ verificado), así
> que **multiplica ×5 las filas** antes de que el `GROUP BY act.do` las colapse. Es costo
> puro. El join a `graficos` queda acotado por `gr.idgrafica = <id_grafico>` a una fila.

#### Cálculo de `DIAS` — caso `codactini` simple

```sql
fn_dias_habiles_proceso(
    (SELECT CONCAT(fecha,' ',hora) FROM estadodehechos.estadohechos
      WHERE numdo = act.do AND etapa = '<codactini>' AND tipo <> 2
      ORDER BY 1 DESC LIMIT 1),
    (SELECT CONCAT(fecha,' ',hora) FROM estadodehechos.estadohechos
      WHERE numdo = act.do AND etapa = '<codactfin>' AND tipo <> 2
      ORDER BY 1 DESC LIMIT 1)
) AS DIAS
```

Reglas implícitas:

- Se toma **el registro más reciente** de cada etapa (`ORDER BY CONCAT(fecha,' ',hora) DESC LIMIT 1`).
  Si una etapa se registró varias veces, gana la última.
- `tipo <> 2` excluye las interrupciones. Ver §5.5 para la semántica de `tipo`.
- Si cualquiera de las dos fechas es NULL, `fn_dias_habiles_proceso` devuelve **`-1`**
  (cubeta `error`).

#### Cálculo de `DIAS` — caso `codactini` múltiple (`"0112,0503"`)

Cuatro gráficas usan dos etapas iniciales alternativas (✔ verificado: gráficas 1, 6, 18, 25).
La lógica es "**el que ocurrió más tarde manda**":

```sql
IF( fecha(codIni[0]) IS NOT NULL
    AND fecha(codIni[1]) IS NOT NULL
    AND fecha(actividadcontrol) IS NOT NULL,

    IF( fecha(codIni[0]) >= fecha(codIni[1]),
        fn_dias_habiles_proceso(codIni[0], codactfin),
        fn_dias_habiles_proceso(codIni[1], codactfin) ),

    -1 )  -- si falta cualquiera de las tres fechas → error
```

> ⚠️ **Inconsistencia de criterio de desempate:** la comparación `>=` usa subconsultas que
> seleccionan **solo `fecha`** y ordenan `ORDER BY 1 DESC` (es decir, por fecha, sin hora),
> mientras que `fn_dias_habiles_proceso` recibe `CONCAT(fecha,' ',hora)` ordenado por
> fecha+hora. Cuando las dos etapas iniciales caen el mismo día, el desempate y el cálculo
> pueden referirse a registros distintos.

#### Columnas del drill-down

Para cada `campos[i]` se genera una subconsulta correlacionada adicional:

```sql
(SELECT CONCAT(fecha,' ',hora) FROM estadodehechos.estadohechos
  WHERE numdo = act.do AND etapa = '<actividades[i]>' AND tipo = 1
  ORDER BY 1 DESC LIMIT 1) AS <campos[i]>
```

Con 13 `campos` (gráfica 1), son **13 subconsultas correlacionadas por fila**, más 2–5
para el `DIAS`. Sobre una tabla de **7.16 M de filas** (✔ `estadodehechos.estadohechos`).
Existen índices `(numdo, etapa, tipo)` y `(numdo, etapa, fecha)` que las hacen viables,
pero el costo por DO es alto y **estas columnas se calculan aunque el usuario nunca abra
el drill-down**.

> 🔴 **Discrepancia de filtro `tipo`:** las columnas de fechas visibles usan **`tipo = 1`**,
> pero el cálculo de `DIAS` usa **`tipo <> 2`**. Son conjuntos distintos (ver §5.5). Un
> usuario puede ver en la tabla una fecha de LEVANTE distinta de la que se usó para el
> cálculo del indicador. En `HistoricosKpi` ambos usan `tipo <> 2`, así que **los dos
> reportes pueden mostrar fechas distintas para el mismo DO**.

### 5.2 Capa 1 — `fn_dias_habiles_proceso` ✔ definición completa verificada

Función almacenada `DETERMINISTIC` en `asercol`:

```sql
CREATE FUNCTION fn_dias_habiles_proceso(p_inicio DATETIME, p_fin DATETIME)
RETURNS int(11) DETERMINISTIC
BEGIN
    IF p_inicio IS NULL OR p_fin IS NULL THEN RETURN -1; END IF;
    IF p_inicio > p_fin                 THEN RETURN -1; END IF;

    -- Para cada día calendario entre DATE(p_inicio) y DATE(p_fin):
    --   horasDia := COALESCE((SELECT horas FROM calendario WHERE fecha = d), 0)
    --   SI horasDia > 0 ENTONCES              -- ⚠ solo se usa como bandera booleana
    --       tramoInicio := (d = primer día) ? p_inicio : d 08:00
    --       tramoFin    := (d = último día) ? p_fin    : d 18:00
    --       SI tramoInicio < tramoFin ENTONCES
    --           minutos += solapamiento(tramo, [d 08:00, d 12:00])   -- mañana
    --           minutos += solapamiento(tramo, [d 14:00, d 18:00])   -- tarde
    --
    RETURN TRUNCATE(minutos_totales / 480, 0);   -- 480 min = 8 h = 1 día
END
```

Características que **deben replicarse exactamente**:

| Regla | Detalle |
|---|---|
| Jornada | Dos ventanas fijas: **08:00–12:00** y **14:00–18:00**. El almuerzo (12:00–14:00) no cuenta. |
| Unidad | 1 día = **480 minutos** de jornada. |
| Redondeo | **`TRUNCATE` (piso), no `ROUND`.** 7 h 59 min → `0` días. 15 h 59 min → `1` día. |
| Días no hábiles | Un día cuenta si y solo si `calendario.horas > 0`. Domingos y festivos tienen `horas = 0`. |
| Valor de `horas` | **Se ignora**. Solo importa `> 0`. Ver §9.1. |
| Días sin fila en `calendario` | Se tratan como **no hábiles** (0 minutos). Ver §9.2. |
| Entradas inválidas | NULL en cualquier parámetro, o `p_inicio > p_fin` → **`-1`**. |

#### Casos de prueba ejecutados ✔ (contra producción, 2026-09-12)

| Caso | Inicio | Fin | Resultado |
|---|---|---|---|
| Mismo día, 08:00→17:00 (9 h reloj, 7 h jornada) | `2026-09-07 08:00` | `2026-09-07 17:00` | `0` |
| Lunes → martes, misma hora | `2026-09-07 08:00` | `2026-09-08 08:00` | `1` |
| Viernes → lunes (cruza fin de semana) | `2026-09-04 08:00` | `2026-09-07 08:00` | `2` |
| Sábado → domingo | `2026-09-05 08:00` | `2026-09-06 08:00` | `1` |
| Lunes → lunes (+7 días calendario) | `2026-09-07 08:00` | `2026-09-14 08:00` | `6` |
| Fechas invertidas | `2026-09-08 08:00` | `2026-09-07 08:00` | `-1` |
| Festivo 1-ene → lunes 5-ene | `2026-01-01 08:00` | `2026-01-05 08:00` | `2` |
| Fecha final NULL | `2026-09-07 08:00` | `NULL` | `-1` |
| Fecha inicial NULL | `NULL` | `2026-09-07 08:00` | `-1` |
| Ambas NULL | `NULL` | `NULL` | `-1` |

> El caso "viernes → lunes = 2" y "sábado → domingo = 1" confirman empíricamente que
> **el sábado se contabiliza como jornada completa de 8 h** pese a tener `horas = 4`
> en el calendario. Ver §9.1.

### 5.3 Capa 3 — Clasificación y agregados

`info_meta.sqlmeta` envuelve el SQL de la capa 2 reemplazando el marcador `?tmpdias`:

```php
$sqlMeta = str_replace('?tmpdias',      $sqlDatos,           $sqlMeta);
$sqlMeta = str_replace('?proceso',      $codProceso,         $sqlMeta);
$sqlMeta = str_replace('?actividad',    $actividadcontrol,   $sqlMeta);
$sqlMeta = str_replace('?fechaInicial', $fecha_inicial,      $sqlMeta);
$sqlMeta = str_replace('?fechaFinal',   $fecha_final,        $sqlMeta);
$sqlMeta = str_replace('?grafica',      $id_grafico,         $sqlMeta);
```

(De estos marcadores, las plantillas `sqlmeta` actuales solo usan `?tmpdias`; los demás
son un mecanismo de extensión sin uso.)

El resultado es **una sola fila** con las columnas `t1..tN`, `error`, `total`, `prom`.
PHP la desarma:

```php
$encabezados = explode(',', strtoupper($datosMeta[0]->encabezados));
for ($x = 1; $x <= count($encabezados); $x++) {
    $totalKpis[] = $totalKpiData[0]->{"t$x"};      // acoplamiento posicional
}
array_push($totalKpis, $totalKpiData[0]->error);
array_push($totalKpis, $totalKpiData[0]->total);
```

> ⚠️ El número de títulos en `info_meta.encabezados` **debe coincidir exactamente** con el
> número de columnas `tN` de `info_meta.sqlmeta`. No hay validación: un desajuste lanza
> excepción y el usuario ve la pantalla genérica "error de parámetros".

#### Semántica de los agregados — crítica para replicar

| Columna | Fórmula | Semántica real |
|---|---|---|
| `t1..tN` | `SUM(IF(<rango>,1,0))` | Pedidos en el rango. **Solo cuentan `dias >= 0`.** |
| `error` | `SUM(IF(dias<0,1,0))` | Pedidos **no calculables**: falta alguna fecha de etapa, o las fechas están invertidas. |
| `total` | `SUM(1)` | **Todos los pedidos, incluidos los `error`.** |
| `prom` | `AVG(dias)` | **Incluye los `-1` de los errores** → el promedio está sesgado a la baja. |

> 🔴 Consecuencia directa: `t1 + t2 + … + tN + error = total`, por lo que
> **`total` NO es el denominador correcto de un porcentaje de cumplimiento**. Si otro
> sistema calcula `%cumplimiento = t1 / total`, obtendrá un valor **menor** que si calcula
> `t1 / (total - error)`. El módulo actual **no muestra ningún porcentaje** en esta vista,
> así que la decisión está abierta — hay que fijarla explícitamente. Ver §8.3.

> 🔴 `prom` promedia incluyendo los `-1`. Con 10 pedidos de 3 días y 2 errores, muestra
> `2.17` en lugar de `3.0`. La vista histórica sí filtra `dias >= 0` (§5.6), por lo que
> **las dos pantallas muestran promedios distintos para la misma población**.

### 5.4 Drill-down y exportación

`visualizarKpi($id)` y `excelKpi($id)` reciben el **índice posicional** de la fila
pulsada y lo usan contra `info_meta.condicion`:

```php
$condiciones  = explode(',', $meta->condicion)[$id];
$sqlDetalleKpi = $this->getSqlImpo() . " where " . $condiciones;
```

Funciona porque ambos `getSql*` devuelven un `SELECT * FROM ( … ) AS alias` al que se le
puede anexar un `WHERE`.

> ⚠️ Si `info_meta.condicion` es NULL (caso `nrometa = 0` ✔), `explode(',', null)`
> devuelve `[""]` y cualquier índice > 0 provoca un error no controlado. Latente: ninguna
> gráfica activa usa meta 0.

`excelKpi` construye el Excel con `$this->cabecera` (de `graficos.encabezados`) sobre los
datos crudos del detalle — el acoplamiento posicional de §4.1 aplica aquí también.

### 5.5 Semántica de `estadodehechos.estadohechos.tipo` ✔ verificado

| `tipo` | Filas | Interpretación inferida |
|---|---|---|
| `1` | 6 551 862 | Registro normal de la etapa |
| `2` | 321 529 | Interrupción / anulación (excluido por `tipo <> 2`) |
| `3` | 289 871 | Otro tipo de registro — **incluido** por `tipo <> 2`, **excluido** por `tipo = 1` |
| `0` | 36 | — |
| `10, 17, 34, 71, 76, 114, -2, -47, -103, -115` | 1 c/u | **Basura de datos** (12 filas en total) |

> 🔴 Las 289 871 filas con `tipo = 3` son exactamente la diferencia entre los dos
> criterios de filtrado del módulo. Antes de replicar el cálculo en otro sistema **hay que
> decidir y documentar qué significa `tipo = 3`** y si debe entrar en el cómputo. Los
> valores negativos y > 3 deberían depurarse o restringirse por CHECK/ENUM.

### 5.6 Variante histórica (`HistoricosKpi`)

Reutiliza la misma capa 2 (con pequeñas diferencias) y añade `mes` = `DATE_FORMAT(act.fecha, '%Y%m')`.
Su agregación es **completamente independiente de `info_meta`**:

```sql
SELECT mes,
       COUNT(*)                                       total,
       ROUND(AVG(dias))                               promedio,
       SUM(IF(dias>=0 AND dias<=5,1,0))               cumplidos,
       COUNT(*) - SUM(IF(dias>=0 AND dias<=5,1,0))    nocumplidos,
       ROUND(SUM(IF(dias>=0 AND dias<=5,1,0)) * 100
             / IF(COUNT(*)=0,1,COUNT(*)), 2)          cumplimiento
FROM ( <capa 2> ) AS r
WHERE dias >= 0
GROUP BY mes ORDER BY mes
```

> 🔴 **El umbral de cumplimiento está hardcodeado en 5 días**
> ([HistoricosKpi.php:93](../app/Http/Livewire/InfoPedidos/HistoricosKpi.php#L93) y
> [:125](../app/Http/Livewire/InfoPedidos/HistoricosKpi.php#L125)), mientras que la vista
> imprime *"Meta de cumplimiento: {{ $meta }}"* con la meta real de la gráfica
> ([historicos-kpi.blade.php:28](../resources/views/livewire/info-pedidos/historicos-kpi.blade.php#L28)).
> Para una gráfica con meta 2, la pantalla dice "Meta de cumplimiento: 2" y calcula
> contra 5. **Es el defecto de cálculo más grave del módulo** — ver §9.3.

Diferencias adicionales respecto de `GraficosKpi`:

| Aspecto | `GraficosKpi` | `HistoricosKpi` |
|---|---|---|
| Umbral | `info_meta` por gráfica | **Fijo: 5 días** |
| Denominador | `total` (incluye errores) | `COUNT(*)` con `WHERE dias >= 0` (excluye errores) |
| Promedio | `AVG(dias)` con errores | `ROUND(AVG(dias))` sin errores, **sin decimales** |
| Filtro `tipo` en columnas de fecha | `tipo = 1` | `tipo <> 2` |
| Errores | Fila `error` en la tabla | Consulta separada `WHERE dias < 0` |

---

## 6. Reglas de visibilidad y seguridad de datos

El filtro por cliente se aplica sobre la sesión, no sobre parámetros:

```php
if (session('app_nivel_aux') == 2)                 // cliente externo
    $condiciones .= ' and i.idcliente in (' . session('app_codigo_aux') . ')';

if (session('app_nivel_aux') == 1 || session('app_nivel_aux') == 3)  // interno
    if (session('app_codigo_aux') != "")           // suplantación activa
        $condiciones .= ' and i.idcliente in (' . session('app_codigo_aux') . ')';

if ($parametro_cliente != "''" && session('app_nivel_aux') != 2)
    $condiciones .= " and ifnull(i.razon, e.razon) LIKE '%<cliente>%' ";
```

| Nivel | Significado | Efecto |
|---|---|---|
| 1 / 3 | Usuario interno | Ve todo; puede suplantar a un cliente vía `Index::cambiarIdentidad()` |
| 2 | Cliente externo | Restringido a sus `idcliente`; **no puede usar el filtro de razón social** |

`session('app_codigo_aux')` se interpola **cruda** dentro de un `IN (…)`. Aunque su origen
es la tabla `usuarios`, es una interpolación sin escape que solo funciona porque el dato
almacenado tiene el formato `nit1,nit2,…`.

> ⚠️ En la variante Expo el filtro usa `e.idcliente` y en Impo `i.idcliente`. Como ambos
> son `LEFT JOIN`, un DO sin fila en la tabla correspondiente queda fuera del reporte sin
> aviso.

---

## 7. Rendimiento observado

Por cada render del componente se ejecutan, como mínimo:

| # | Consulta | Conexión | Evitable |
|---|---|---|---|
| 1 | `InfoPedidos::where('id',7)->first()` — su resultado **se descarta inmediatamente** ([:45-46](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L45-L46)) | local | ✅ sí |
| 2 | `SELECT … FROM graficos INNER JOIN procesos …` | websystem | — |
| 3 | `SELECT * FROM info_meta WHERE nrometa = …` | local | — |
| 4 | `ProcesosWebsystem::…->first()` | websystem | ✅ (ya viene en #2 vía `procesos`) |
| 5 | **La misma consulta #2, repetida dentro de `getSqlImpo()`/`getSqlExpo()`** | websystem | ✅ sí |
| 6 | La consulta agregada pesada | websystem | — |

En `visualizarKpi()` y `excelKpi()` la consulta de `graficos` se ejecuta **tres veces más**
([:418](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L418),
[:435](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L435) —
la de la línea 435 es literalmente idéntica a la de la 418 y su resultado se descarta).

Como Livewire ejecuta `render()` en **cada** interacción, pulsar "ver detalle" reejecuta la
consulta agregada completa además de la consulta de detalle.

Otros factores:

- `actividades` y `estadohechos` son **MyISAM** (bloqueo a nivel de tabla, sin transacciones).
- `actividades` **no tiene índice sobre `proceso`** ✔ (solo `do`, `codactividad`, `fecha`).
- El `INNER JOIN plantilla` de la variante Expo multiplica las filas ×5 sin aportar nada.
- Las N subconsultas correlacionadas de `campos` se calculan siempre, aunque el
  drill-down nunca se abra.

---

## 8. Especificación canónica para replicar el cálculo

Esta sección es **autosuficiente**: define el indicador sin referencia al código PHP, para
implementarlo o auditarlo en otro sistema.

### 8.1 Entradas

| Entrada | Origen | Ejemplo |
|---|---|---|
| `idgrafica` | selección del usuario | `59` |
| `fechaInicial`, `fechaFinal` | selección del usuario | `2026-08-01`, `2026-09-12` |
| `idproceso`, `actividadControl`, `codActIni[]`, `codActFin`, `meta` | `graficos` | `1`, `2001`, `['0112','0503']`, `2001`, `2` |
| Filtros opcionales | usuario | sucursal, tipo de carga, cliente |

### 8.2 Algoritmo

```
PASO 1 — Población
  P = { DO | existe registro en `actividades` con
              proceso = idproceso
          AND codactividad = actividadControl
          AND tipoint = 1
          AND fecha ENTRE fechaInicial Y fechaFinal }
  (un DO aparece una sola vez, aunque tenga varios registros)
  Aplicar los filtros opcionales (sucursal, tipo de carga, cliente, nivel de usuario).

PASO 2 — Fechas del tramo, por cada DO
  fechaEtapa(DO, etapa) =
      MAX por (fecha, hora) de estadohechos
      DONDE numdo = DO AND etapa = <etapa> AND tipo <> 2
      → devuelve un DATETIME o NULL

  SI |codActIni| = 1:
      inicio = fechaEtapa(DO, codActIni[0])
  SI |codActIni| = 2:
      SI fechaEtapa(DO, codActIni[0]) IS NULL
         O fechaEtapa(DO, codActIni[1]) IS NULL
         O fechaEtapa(DO, actividadControl) IS NULL:
             dias = -1 ; SALTAR AL PASO 4
      inicio = MAX(fechaEtapa(DO, codActIni[0]), fechaEtapa(DO, codActIni[1]))
               -- desempate por FECHA (sin hora) en la implementación actual

  fin = fechaEtapa(DO, codActFin)

PASO 3 — Días hábiles (ver 8.4)
  dias = diasHabiles(inicio, fin)

PASO 4 — Clasificación
  SI dias < 0            → cubeta `error`
  SI NO                  → la única cubeta de `info_meta` cuyo rango contiene `dias`

PASO 5 — Agregados
  tN     = conteo de DOs en la cubeta N
  error  = conteo de DOs con dias < 0
  total  = |P|                       (incluye los `error`)
  prom   = promedio de `dias`        (incluye los -1 de `error`)  ⚠ ver 8.3
```

### 8.3 Decisiones que hay que fijar explícitamente antes de replicar

El sistema actual no define estas reglas de forma consistente. **Fijarlas y documentarlas
es requisito para que los números coincidan entre sistemas:**

| # | Decisión | Comportamiento actual | Recomendado |
|---|---|---|---|
| D1 | Denominador del % de cumplimiento | No se muestra en la vista de torta; el histórico usa `total` **excluyendo** errores | `cumplidos / (total - error)`, reportando `error` por separado |
| D2 | ¿El promedio incluye los `-1`? | Torta: **sí**. Histórico: **no**. | **No** — excluir siempre `dias < 0` |
| D3 | Umbral de cumplimiento | Torta: `info_meta`. Histórico: **fijo en 5**. | Siempre la meta de la gráfica (con override por cliente) |
| D4 | Filtro de `tipo` en estadohechos | `tipo <> 2` en el cálculo, `tipo = 1` en la visualización | Unificar; definir qué es `tipo = 3` |
| D5 | Valor del sábado | Cuenta como 8 h pese a `calendario.horas = 4` | Respetar `calendario.horas` |
| D6 | Redondeo de días | `TRUNCATE` (piso) | Mantener `TRUNCATE` por compatibilidad, o migrar con recálculo histórico anunciado |
| D7 | Anclaje temporal del período | Fecha de la **actividad de control** (cierre) | Mantener; documentarlo en la UI |

### 8.4 Pseudocódigo de referencia de `diasHabiles`

```
FUNCIÓN diasHabiles(inicio: DATETIME, fin: DATETIME) -> ENTERO

    SI inicio ES NULL O fin ES NULL  → RETORNAR -1
    SI inicio > fin                  → RETORNAR -1

    minutos = 0
    PARA CADA día d DESDE fecha(inicio) HASTA fecha(fin):

        horasDia = calendario[d].horas   (0 si la fecha no existe en el calendario)
        SI horasDia <= 0 → CONTINUAR     -- ⚠ el VALOR de horasDia no se usa más allá de esto

        tramoIni = (d == fecha(inicio)) ? inicio : d + 08:00
        tramoFin = (d == fecha(fin))    ? fin    : d + 18:00
        SI tramoIni >= tramoFin → CONTINUAR

        minutos += solape(tramoIni, tramoFin, d+08:00, d+12:00)   -- mañana
        minutos += solape(tramoIni, tramoFin, d+14:00, d+18:00)   -- tarde

    RETORNAR PISO(minutos / 480)        -- 480 min = 8 h = 1 día

FUNCIÓN solape(aIni, aFin, bIni, bFin) -> MINUTOS
    ini = MAX(aIni, bIni) ; f = MIN(aFin, bFin)
    RETORNAR (ini < f) ? minutos_entre(ini, f) : 0
```

### 8.5 Suite de validación cruzada

Usar estos casos para confirmar que la reimplementación es equivalente (resultados
verificados ✔ contra producción):

```
diasHabiles('2026-09-07 08:00', '2026-09-07 17:00')  ==  0
diasHabiles('2026-09-07 08:00', '2026-09-08 08:00')  ==  1
diasHabiles('2026-09-04 08:00', '2026-09-07 08:00')  ==  2
diasHabiles('2026-09-05 08:00', '2026-09-06 08:00')  ==  1   # sábado = jornada completa
diasHabiles('2026-09-07 08:00', '2026-09-14 08:00')  ==  6
diasHabiles('2026-01-01 08:00', '2026-01-05 08:00')  ==  2   # 1-ene festivo
diasHabiles('2026-09-08 08:00', '2026-09-07 08:00')  == -1
diasHabiles('2026-09-07 08:00',  NULL)               == -1
diasHabiles( NULL,              '2026-09-07 08:00')  == -1
diasHabiles( NULL,               NULL)               == -1
```

Además, para validar el pipeline completo, comparar cubeta por cubeta contra el sistema
actual usando: gráfica 1 (meta 2, `codactini` doble), gráfica 5 (meta 5, `codactini`
simple, Impo) y gráfica 2 (meta 4, Expo).

---

## 9. Hallazgos sobre los cálculos

Ordenados por severidad. Los marcados 🔴 afectan cifras que el negocio está usando hoy.

### 9.1 🔴 El sábado se contabiliza como día completo pese a estar configurado con 4 horas

`fn_dias_habiles_proceso` lee `calendario.horas` pero **solo lo evalúa como bandera**
(`IF horasDia > 0`). Los minutos se calculan siempre con las ventanas fijas 08:00–12:00 y
14:00–18:00 = 480 min.

- En 2026 los 52 sábados tienen `horas = 4`. ✔
- Verificación empírica: `diasHabiles('2026-09-05 08:00','2026-09-06 08:00') = 1`. ✔

**Impacto:** todo tramo que cruce un sábado acumula **8 h en vez de 4**. Los indicadores
aparecen **mejores de lo real**, y el sesgo crece con la duración del tramo. También afecta
los 131 días con `horas = 8.5` y los 27 con `horas = 3.5`.

**Corrección sugerida:** escalar los minutos del día por `horas/8`, o —mejor— modelar en
`calendario` las ventanas reales de jornada (`hora_ini_am, hora_fin_am, hora_ini_pm, hora_fin_pm`)
en vez de un total de horas.

### 9.2 🔴 El calendario se agota el 2026-12-31 — el módulo devolverá 0 días desde el 2027-01-01

`calendario` no tiene filas después del `2026-12-31` ✔. Para fechas sin fila, `horasDia`
queda en 0 y el día **no se cuenta**. A partir del 1 de enero de 2027:

- Todo tramo que ocurra íntegramente en 2027 devolverá `dias = 0`.
- Todo pedido caerá en la cubeta "Hasta N días" → **cumplimiento aparente del 100 %**.
- **No habrá ningún error, excepción ni aviso.** Falla silenciosa.

Faltan aproximadamente **3,5 meses** para que esto ocurra.

**Acciones:** (a) poblar `calendario` para 2027+ de inmediato; (b) en la V2, tratar la
ausencia de calendario como error explícito (`RETURN -1` o excepción) en vez de como día no
hábil; (c) agregar una alerta operativa cuando el horizonte del calendario baje de ~90 días.

### 9.3 🔴 `HistoricosKpi` ignora la meta configurada y mide siempre contra 5 días

`SUM(IF(dias>=0 AND dias<=5,1,0))` está hardcodeado en las tres consultas del histórico,
mientras la vista imprime la meta real de la gráfica. Para una gráfica con meta 2, el
usuario lee *"Meta de cumplimiento: 2"* junto a un porcentaje calculado contra 5 días.

**Impacto:** el porcentaje de cumplimiento histórico es **incorrecto para todas las
gráficas cuya meta ≠ 5**. Metas en uso: 1, 2, 3, 4, 5, 6, 9 ✔ — es decir, la mayoría.

### 9.4 🔴 `metacliente` no se aplica: 18 clientes con metas negociadas usan la meta genérica

Ver §4.3. La variable `$codigo` está fija en `''` en los tres puntos de consulta, de modo
que el `IN ("")` nunca casa. Hay **54 overrides configurados (18 gráficas × 18 clientes)**
que el sistema ignora. ✔

**Corrección:** poblar `$codigo` con `session('app_codigo_aux')` (el NIT del cliente en
contexto) y, en la V2, resolver la meta efectiva en un único punto:
`meta_efectiva = metacliente(idgrafica, nit) ?? graficos.meta`.

### 9.5 🟠 `prom` incluye los registros con error (`-1`), sesgando el promedio a la baja

En `GraficosKpi`, `AVG(dias)` se calcula sobre toda la población, incluidos los `-1`. En
`HistoricosKpi` se filtra `WHERE dias >= 0`. **Las dos pantallas muestran promedios
distintos para la misma población.** Ver D2 en §8.3.

### 9.6 🟠 `total` incluye los errores y no sirve como denominador de cumplimiento

`total = SUM(1)` cuenta también los pedidos no calculables. Como la vista actual no muestra
porcentajes, el error no es visible hoy — pero **cualquier sistema que consuma estas cifras
y calcule `t1/total` obtendrá un cumplimiento subestimado**. Ver D1 en §8.3.

### 9.7 🟠 Fechas mostradas ≠ fechas usadas en el cálculo (`tipo = 1` vs `tipo <> 2`)

Las columnas de fecha del drill-down de `GraficosKpi` filtran `tipo = 1`; el cálculo de
`DIAS` filtra `tipo <> 2`, que incluye además las **289 871 filas con `tipo = 3`** ✔ y 12
filas con valores basura. Un DO puede mostrar una fecha de etapa distinta de la que
originó su `DIAS`, lo que hace las cifras **imposibles de auditar manualmente**.
`HistoricosKpi` usa `tipo <> 2` en ambos lados, por lo que **los dos reportes muestran
fechas distintas para el mismo DO**.

### 9.8 🟡 `nrometa = 9`: hueco de clasificación en `dias = 9` y etiqueta incorrecta

```
t1 = SUM(IF(dias>=0 AND dias<=8, 1, 0))     -- etiqueta: "Hasta 9 dias"
t2 = SUM(IF(dias>9, 1, 0))                  -- etiqueta: "Mas de 9 dias"
```

Los pedidos con exactamente **9 días no se cuentan en ninguna cubeta**, y `t1` promete
"hasta 9" pero mide "hasta 8". `condicion` replica el mismo defecto. ✔

**Alcance actual:** solo la gráfica 60 (`LTI: MFTO - ENTREGA BODEGA`), que está
`inactivo = 1` ✔. Defecto **latente**, no activo — pero debe corregirse antes de reactivar
esa gráfica.

### 9.9 🟡 Desempate entre `codactini` múltiples por fecha sin hora

La comparación `>=` entre las dos etapas iniciales usa `SELECT fecha … ORDER BY 1 DESC`
(sin hora), mientras el cálculo usa `CONCAT(fecha,' ',hora)`. Cuando ambas etapas caen el
mismo día, el registro elegido para desempatar puede no ser el mismo que se usa para
calcular. Afecta a las gráficas 1, 6, 18 y 25. ✔

### 9.10 🟡 `HistoricosKpi::getSqlExpo()` usa una variable no definida en la rama de `codactini` simple

En [HistoricosKpi.php:378](../app/Http/Livewire/InfoPedidos/HistoricosKpi.php#L378) y
[:403](../app/Http/Livewire/InfoPedidos/HistoricosKpi.php#L403) se referencia `$codInicio[0]`,
pero `$codInicio` solo se define en la rama `else`
([:413](../app/Http/Livewire/InfoPedidos/HistoricosKpi.php#L413)). En la rama de
`codactini` simple la etapa inicial se interpola como cadena vacía → `etapa = ''` → no hay
fecha inicial → **`dias = -1` para todos los DOs**. Es decir, **el histórico de
exportaciones con `codactini` simple devuelve todo en la cubeta de error**. Afecta a las
gráficas de exportación cuya `codactini` no lleva coma (la mayoría de los procesos E). ✔

### 9.11 🟡 `TRUNCATE` hace que la meta sea más laxa de lo que aparenta

`TRUNCATE(minutos/480)` significa que "hasta 2 días" admite en realidad hasta **2 días y
7 h 59 min** de jornada. Es una decisión legítima, pero **no está documentada en ninguna
parte visible al usuario** y produce discrepancias con cualquier cálculo que use `ROUND`
o días calendario.

---

## 10. Deudas técnicas para la V2

### 10.1 Seguridad

| # | Deuda | Detalle |
|---|---|---|
| S1 | **Inyección SQL por interpolación directa** | Ni una sola consulta usa *bindings*. Todos los parámetros del usuario (`id_grafico`, `fecha_inicial`, `fecha_final`, `filtro_ciudad`, `filtro_tipo_carga`, `codProceso`, `parametro_cliente`) se concatenan al SQL. La única "sanitización" es `str_replace("'", '', …)` sobre dos campos. |
| S2 | **Propiedades Livewire públicas y manipulables** | `$id_grafico`, `$codProceso`, `$fecha_inicial`, `$fecha_final`, `$filtro_ciudad`, `$filtro_tipo_carga`, `$parametro_cliente` son `public`. Livewire las rehidrata desde el payload del navegador en cada request, de modo que **el filtrado por comillas del `mount()` puede eludirse en las interacciones posteriores** (`visualizarKpi`, `excelKpi`). Vector de SQLi post-montaje. |
| S3 | **`dd($e)` en producción** | [HistoricosKpi.php:190](../app/Http/Livewire/InfoPedidos/HistoricosKpi.php#L190) vuelca el stack trace completo —con el SQL y la configuración de conexión— al navegador ante cualquier excepción. |
| S4 | **`catch (\Exception $e)` mudo** | [GraficosKpi.php:132-135](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L132-L135) traga toda excepción y muestra "error de parámetros", **sin registrar nada**. Errores de datos, de SQL y de configuración son indistinguibles y no dejan rastro. |
| S5 | **Credenciales de BD en el repositorio** | [config/database.php](../config/database.php) trae usuarios y contraseñas como valores por defecto de `env()` (p. ej. `websystem`, `websimex`, `websimex_demo`). Deben eliminarse del código. |
| S6 | **Endpoint de gráfico sin autenticación** | `public/grafico/grafico_kpi.php` corre fuera de Laravel, sin sesión ni CSRF, aceptando `$_GET['datos']` y `$_GET['meta']` sin validar. Con `meta` fuera del `switch`, `$alto` queda indefinido y `PieGraph(370, 0)` falla. |

### 10.2 Arquitectura y mantenibilidad

| # | Deuda | Detalle |
|---|---|---|
| A1 | **SQL como dato en la base** | `info_meta.sqlmeta` y `info_meta.condicion` almacenan fragmentos de SQL ejecutable. Imposible de versionar, testear o validar; cualquier cambio es un `UPDATE` sin revisión ni despliegue. Debe convertirse en configuración declarativa (JSON de rangos) interpretada por código. |
| A2 | **Configuración repartida entre dos servidores** | `graficos` (websystem) ↔ `info_meta` (local), unidas por un entero sin FK. Ver §3.2. |
| A3 | **Triple fuente de verdad para las etiquetas de rango** | `info_meta.encabezados`, el `switch` de `grafico_kpi.php`, y los textos del histórico. Ya divergen (`meta 1`: "Hasta 1 dias" vs "Hasta 1 dia"; `meta 9`: etiqueta vs fórmula). |
| A4 | **Catálogo de gráficas hardcodeado en JS** | §4.5. Debe salir de `graficos` filtrando por `fidproceso` e `inactivo`. |
| A5 | **`getSqlImpo()` y `getSqlExpo()` duplicados ×4** | Las mismas ~100 líneas de SQL existen cuatro veces (Impo/Expo × GraficosKpi/HistoricosKpi), y ya han divergido (§9.10, §5.5). Debe existir **un solo constructor de la consulta base**. |
| A6 | **Acoplamiento posicional CSV** | `campos` ↔ `encabezados` ↔ `actividades` en `graficos`; `encabezados` ↔ `tN` en `info_meta`; índice de fila ↔ posición en `condicion`. Sin ninguna validación. |
| A7 | **Catálogos de ciudad hardcodeados y contradictorios** | §2.2. |
| A8 | **Colisión de nombres `GraficosKpi`** | Componente Livewire vs modelo Eloquent, apuntando a conexiones distintas de la misma tabla. |
| A9 | **Contrato de parámetros "valores entrecomillados"** | §2.1. Construido en JS, comparado contra la cadena `"''"`. Frágil e indocumentado. |
| A10 | **`parametro_cliente_` vs `parametro_cliente`** | Nombres desalineados entre emisor y receptor — verificar si el filtro llega. |
| A11 | **Sin ninguna prueba automatizada** | El cálculo de cumplimiento —cifra que el negocio usa— no tiene un solo test. |
| A12 | **Conflicto de merge sin resolver en el repositorio** | [Index.php:546-549](../app/Http/Livewire/InfoPedidos/Index.php#L546-L549) contiene marcadores `<<<<<<< HEAD` / `>>>>>>> origin` dentro del cuerpo de un método. |

### 10.3 Código muerto y residuos

En [GraficosKpi.php](../app/Http/Livewire/InfoPedidos/GraficosKpi.php):

- `$data = InfoPedidos::where("id",'7')->first(); $sql = $data->consulta;` ([:45-46](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L45-L46)) — consulta cuyo resultado se sobrescribe dos líneas después. Idéntico en `excelKpi()` ([:448-450](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L448-L450)) y en `HistoricosKpi`.
- `$limpio = preg_replace(...)` ([:88](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L88)) — se calcula y nunca se usa.
- `$condiciones = "";` ([:69](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L69)) — sin uso.
- `$sqlMeta = ''; $codigo = '';` ([:43-44](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L43-L44)) — `$codigo` es la causa de §9.4.
- `$suma` ([:106-112](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L106-L112)) — suma las cubetas solo para decidir si se pinta el gráfico; `total` ya sirve para eso.
- `$this->actividadKey`, `$this->contador`, `$this->page` — asignados, nunca usados.
- `PaginarArray()`, `PaginasArray()`, `previousPage()`, `nextPage()` ([:488-520](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L488-L520)) — referencian `$this->datos_consulta`, **propiedad que no existe en esta clase**: error fatal si se invocan. Nada en la vista las llama.
- `$sql = str_replace(...); $datos_consulta = DB::…select(…);` repetido idénticamente en [:434-435](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L434-L435) y [:474-475](../app/Http/Livewire/InfoPedidos/GraficosKpi.php#L474-L475) — consulta redundante cuyo resultado se descarta.
- `IF(e.pedido <> '', e.pedido, e.pedido)` e `ifnull(e.idsucursal, e.idsucursal)` en `getSqlExpo()` — tautologías (copy-paste de la variante Impo sin adaptar).
- `$datos_detalle_kpi` es `protected`: **no sobrevive entre requests de Livewire**, por lo que el drill-down desaparece ante cualquier otra interacción.

### 10.4 Infraestructura y datos

| # | Deuda |
|---|---|
| I1 | `actividades`, `estadohechos`, `graficos`, `metacliente`, `calendario` son **MyISAM**: sin transacciones, sin FKs, bloqueo de tabla. |
| I2 | `actividades` **sin índice sobre `proceso`** ✔. |
| I3 | `estadohechos.tipo` admite valores basura (12 filas con `-115`, `-103`, `-47`, `-2`, `10`, `17`, `34`, `71`, `76`, `114`) ✔. Falta CHECK/ENUM. |
| I4 | `calendario` contiene una fila con `fecha = '0000-00-00'` ✔ y **se agota el 2026-12-31** (§9.2). |
| I5 | **JpGraph** (`public/grafico/`) es una librería sin mantenimiento, incompatible con PHP moderno; el proyecto corre sobre **PHP 7.4** (EOL desde 2022). |
| I6 | Los joins `plantilla`/`graficos` de la variante Expo multiplican filas sin aportar nada (§5.1). |

---

## 11. Recomendaciones para la V2

### 11.1 Prioridad inmediata (antes de cualquier refactor)

1. **Poblar `calendario` para 2027 y siguientes.** Sin esto, el módulo deja de funcionar
   —en silencio— el 2027-01-01. *(§9.2)*
2. **Corregir el umbral hardcodeado de 5 días en `HistoricosKpi`.** Es un número incorrecto
   publicado hoy. *(§9.3)*
3. **Eliminar el `dd($e)`** de `HistoricosKpi::render()`. *(S3)*
4. **Resolver el conflicto de merge** en `Index.php`. *(A12)*

### 11.2 Diseño propuesto

```
Dominio (testeable, sin SQL embebido)
├── ValueObject  TramoKpi          (etapaInicial[], etapaFinal, actividadControl, proceso)
├── ValueObject  MetaKpi           (rangos declarativos + umbral de cumplimiento)
├── Servicio     CalendarioLaboral (jornadas reales por fecha; falla si falta calendario)
├── Servicio     CalculadorDiasHabiles  (equivalente a fn_dias_habiles_proceso, en PHP,
│                                        con la suite de pruebas de §8.5)
└── Servicio     EvaluadorKpi      (población → días → cubetas → agregados)

Persistencia
├── Repositorio  GraficasRepository   (graficos + metacliente, meta efectiva resuelta)
├── Repositorio  MetasRepository      (info_meta migrado a JSON declarativo)
└── Repositorio  PedidosKpiRepository (query builder con bindings, sin concatenación)

Presentación
├── Livewire  KpiDistribucion  (torta + tabla de rangos)
├── Livewire  KpiHistorico     (barras mensuales)
└── Gráficos  Chart.js / ApexCharts en el cliente — eliminar JpGraph y los endpoints
             PHP fuera de Laravel
```

### 11.3 Modelo de configuración propuesto

Sustituir `info_meta.sqlmeta` / `condicion` por una definición declarativa única:

```json
{
  "nrometa": 2,
  "umbral_cumplimiento_dias": 2,
  "rangos": [
    { "etiqueta": "Hasta 2 días",    "min": 0,  "max": 2    },
    { "etiqueta": "Entre 3-6 días",  "min": 3,  "max": 6    },
    { "etiqueta": "Entre 7-10 días", "min": 7,  "max": 10   },
    { "etiqueta": "Más de 10 días",  "min": 11, "max": null }
  ]
}
```

Ventajas: una sola fuente de verdad (elimina A3 y el riesgo de §9.8), validable
(rangos contiguos y sin huecos), versionable, y sin SQL ejecutable almacenado como dato (A1).

### 11.4 Cambios de cálculo recomendados

| Cambio | Justificación | ¿Rompe continuidad histórica? |
|---|---|---|
| Respetar `calendario.horas` en el cómputo | §9.1 | **Sí** — los indicadores empeorarán. Requiere anuncio y recálculo comparativo. |
| Excluir `dias < 0` del promedio | §9.5, D2 | Sí, leve |
| Usar `total - error` como denominador y publicar `error` como métrica de calidad de datos | §9.6, D1 | Sí |
| Unificar el filtro de `tipo` entre cálculo y visualización | §9.7, D4 | Depende de la decisión sobre `tipo = 3` |
| Aplicar `metacliente` | §9.4 | Sí, para 18 clientes |
| Desempatar `codactini` múltiples por fecha **y hora** | §9.9 | Marginal |
| Mantener `TRUNCATE` | Compatibilidad | No |

> 📌 Recomendación de despliegue: implementar el motor nuevo **en paralelo** y publicar
> durante un período ambas cifras (actual vs corregida) para la misma población, de modo
> que el negocio pueda dimensionar el salto antes del corte.

### 11.5 Rendimiento

- **Una sola consulta de configuración** por render (hoy son 2–5 redundantes). *(§7)*
- **Cachear** `graficos` + `info_meta` + `procesos`: son catálogos que cambian rara vez.
- **No calcular las N columnas del drill-down** en la consulta agregada; resolverlas solo al
  abrir el detalle.
- **Eliminar** el `INNER JOIN plantilla` y el `JOIN graficos` de la variante Expo.
- Añadir índice sobre `actividades(proceso, codactividad, fecha)`.
- Considerar una **tabla materializada de días por DO y gráfica**, refrescada por lote:
  convierte un reporte de minutos en uno de milisegundos y permite auditar el histórico.
- Evaluar la migración de `actividades` y `estadohechos` a **InnoDB**.

---

## 12. Anexos

### 12.1 Consultas de verificación usadas

```sql
-- Configuración de rangos (conexión `local`, asercolg_central)
SELECT * FROM info_meta ORDER BY nrometa;

-- Configuración de gráficas (conexión `websystem`, asercol)
SELECT idgrafica, fidproceso, reporte, codactini, codactfin, meta,
       actividadcontrol, actividades, campos, encabezados, inactivo
FROM graficos ORDER BY idgrafica;

-- Overrides de meta por cliente (nunca aplicados hoy)
SELECT COUNT(*), COUNT(DISTINCT fidgrafica), COUNT(DISTINCT fcodigo) FROM metacliente;

-- Definición de la función de días hábiles
SHOW CREATE FUNCTION fn_dias_habiles_proceso;

-- Horizonte y forma del calendario laboral
SELECT MIN(fecha), MAX(fecha), COUNT(*) FROM calendario;
SELECT DAYNAME(fecha) dia, horas, COUNT(*) n
FROM calendario WHERE YEAR(fecha)=2026 GROUP BY dia, horas;

-- Semántica de `tipo` en estadohechos
SELECT tipo, COUNT(*) n FROM estadodehechos.estadohechos GROUP BY tipo ORDER BY n DESC;
```

### 12.2 Resumen de hallazgos

| Ref | Severidad | Hallazgo | Efecto sobre las cifras |
|---|---|---|---|
| §9.1 | 🔴 | Sábado cuenta 8 h en vez de 4 | Indicadores mejores de lo real |
| §9.2 | 🔴 | Calendario se agota el 2026-12-31 | Desde 2027: cumplimiento 100 % falso, sin error |
| §9.3 | 🔴 | Histórico mide contra 5 días fijos | % de cumplimiento incorrecto si meta ≠ 5 |
| §9.4 | 🔴 | `metacliente` nunca se aplica | 18 clientes medidos contra la meta equivocada |
| §9.5 | 🟠 | Promedio incluye errores (`-1`) | Promedio subestimado; difiere entre las dos vistas |
| §9.6 | 🟠 | `total` incluye errores | Denominador incorrecto para % de cumplimiento |
| §9.7 | 🟠 | `tipo = 1` (vista) vs `tipo <> 2` (cálculo) | Cifras no auditables manualmente |
| §9.8 | 🟡 | Meta 9: hueco en `dias = 9` | Latente (gráfica 60 inactiva) |
| §9.9 | 🟡 | Desempate de `codactini` sin hora | Marginal; 4 gráficas |
| §9.10 | 🟡 | `$codInicio` indefinido en histórico Expo | Histórico de exportaciones: todo en `error` |
| §9.11 | 🟡 | `TRUNCATE` no documentado | Meta más laxa de lo que aparenta |
