- Crear endpoint /api/metabase/panorama.post.ts que ejecuta las 9 queries en paralelo - Restaurar y adaptar panorama.vue para usar el nuevo endpoint - Crear componentes auxiliares: SecosVendidos, TotalesIngresoCompra, TotalesMonetarios, TotalesVerde, MetricBox, RechazosRechazoCard - Adaptar RechazosSubproductos para recibir data directamente de Metabase - Toda la transformación de datos ocurre en las queries SQL de Metabase - Sin uso de stores ni composables de métricas - Agregar documentación de queries en archivos MD
687 lines
23 KiB
Markdown
687 lines
23 KiB
Markdown
# 📊 QUERIES DE METABASE PARA INFORME INGRESOS
|
|
|
|
## 🎯 FILOSOFÍA
|
|
|
|
**Metabase calcula TODO. Vue solo renderiza.**
|
|
|
|
Cada query devuelve exactamente los valores que un componente necesita mostrar, ya calculados y listos para usar.
|
|
|
|
---
|
|
|
|
## 📋 PARÁMETROS GLOBALES
|
|
|
|
Todas las queries aceptan estos parámetros (con soporte para múltiples valores):
|
|
|
|
| Parámetro | Tipo | Default | Descripción |
|
|
|-----------|------|---------|-------------|
|
|
| `fecha_desde` | Date | `null` | Fecha inicio (filtra por `created_at`) |
|
|
| `fecha_hasta` | Date | `null` | Fecha fin (filtra por `created_at`) |
|
|
| `incluir_anulados` | Boolean | `false` | Si `false`, excluye `estado='anulado'` o `fecha_anulado IS NOT NULL` |
|
|
| `cliente_ids` | Array[Number] | `[]` | Array de IDs de clientes. Si vacío, incluye todos |
|
|
| `tipos` | Array[String] | `[]` | Array de tipos: 'uva', 'mojado', 'oreado', 'verde'. Si vacío, todos |
|
|
| `estados` | Array[String] | `[]` | Array de estados: 'pagado', 'pendiente'. Si vacío, todos |
|
|
| `ubicaciones` | Array[String] | `[]` | Array de ubicaciones de clientes. Si vacío, todas |
|
|
| `calidades` | Array[String] | `[]` | Array de calidades de café. Si vacío, todas |
|
|
|
|
**Condición SQL estándar:**
|
|
```sql
|
|
WHERE
|
|
-- Filtro de anulados
|
|
({{incluir_anulados}} OR (i.estado != 'anulado' AND i.fecha_anulado IS NULL))
|
|
|
|
-- Filtro de fechas
|
|
AND ({{fecha_desde}} IS NULL OR i.created_at >= {{fecha_desde}})
|
|
AND ({{fecha_hasta}} IS NULL OR i.created_at <= {{fecha_hasta}})
|
|
|
|
-- Filtro de clientes (si se proporciona)
|
|
AND (CARDINALITY({{cliente_ids}}) = 0 OR i.cliente_id = ANY({{cliente_ids}}))
|
|
|
|
-- Filtro de tipos (si se proporciona)
|
|
AND (CARDINALITY({{tipos}}) = 0 OR i.tipo = ANY({{tipos}}))
|
|
|
|
-- Filtro de estados (si se proporciona)
|
|
AND (CARDINALITY({{estados}}) = 0 OR i.estado = ANY({{estados}}))
|
|
|
|
-- Filtro de ubicaciones vía join con clientes (si se proporciona)
|
|
AND (CARDINALITY({{ubicaciones}}) = 0 OR c.ubicacion = ANY({{ubicaciones}}))
|
|
|
|
-- Filtro de calidades (si se proporciona)
|
|
AND (CARDINALITY({{calidades}}) = 0 OR i.calidad = ANY({{calidades}}))
|
|
```
|
|
|
|
---
|
|
|
|
## 🔁 QUERIES REUTILIZADAS DE PANORAMA
|
|
|
|
Las siguientes queries son **idénticas** a las de panorama, solo cambia que aceptan más parámetros de filtrado:
|
|
|
|
### Query 1: `informe_totales_ingreso_compra`
|
|
**Reutiliza:** `panorama_totales_ingreso_compra`
|
|
**Componente:** `TotalesIngresoCompra.vue`
|
|
|
|
```sql
|
|
SELECT
|
|
-- === TOTALES GENERALES (Pagado + Pendiente) ===
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'uva' THEN i.peso_neto ELSE 0 END), 0) as total_lb_uva_ingresada,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'uva' THEN i.peso_seco ELSE 0 END), 0) as total_qq_seco_uva_ingresado,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'mojado' THEN i.peso_seco ELSE 0 END), 0) as total_qq_seco_mojado_ingresado,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'oreado' THEN i.peso_seco ELSE 0 END), 0) as total_qq_seco_oreado_ingresado,
|
|
COALESCE(SUM(i.peso_seco), 0) as total_qq_seco_ingresado,
|
|
|
|
-- === SOLO PAGADOS ===
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'uva' AND i.estado = 'pagado' THEN i.peso_neto ELSE 0 END), 0) as total_lb_uva_pagada,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'uva' AND i.estado = 'pagado' THEN i.peso_seco ELSE 0 END), 0) as total_qq_seco_uva_pagado,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'mojado' AND i.estado = 'pagado' THEN i.peso_seco ELSE 0 END), 0) as total_qq_seco_mojado_pagado,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'oreado' AND i.estado = 'pagado' THEN i.peso_seco ELSE 0 END), 0) as total_qq_seco_oreado_pagado,
|
|
COALESCE(SUM(CASE WHEN i.estado = 'pagado' THEN i.peso_seco ELSE 0 END), 0) as total_qq_seco_comprado,
|
|
|
|
-- === INVENTARIO EN DEPÓSITO (Pendiente) ===
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'uva' AND i.estado = 'pendiente' THEN i.peso_neto ELSE 0 END), 0) as total_lb_uva_deposito,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'mojado' AND i.estado = 'pendiente' THEN i.peso_seco ELSE 0 END), 0) as total_qq_mojado_deposito,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'oreado' AND i.estado = 'pendiente' THEN i.peso_seco ELSE 0 END), 0) as total_qq_oreado_deposito,
|
|
COALESCE(SUM(CASE WHEN i.estado = 'pendiente' THEN i.peso_seco ELSE 0 END), 0) as total_qq_seco_deposito
|
|
|
|
FROM vista_detalle_ingresos i
|
|
LEFT JOIN clientes c ON i.cliente_id = c.id
|
|
WHERE
|
|
({{incluir_anulados}} OR (i.estado != 'anulado' AND i.fecha_anulado IS NULL))
|
|
AND ({{fecha_desde}} IS NULL OR i.created_at >= {{fecha_desde}})
|
|
AND ({{fecha_hasta}} IS NULL OR i.created_at <= {{fecha_hasta}})
|
|
AND (CARDINALITY({{cliente_ids}}) = 0 OR i.cliente_id = ANY({{cliente_ids}}))
|
|
AND (CARDINALITY({{tipos}}) = 0 OR i.tipo = ANY({{tipos}}))
|
|
AND (CARDINALITY({{estados}}) = 0 OR i.estado = ANY({{estados}}))
|
|
AND (CARDINALITY({{ubicaciones}}) = 0 OR c.ubicacion = ANY({{ubicaciones}}))
|
|
AND (CARDINALITY({{calidades}}) = 0 OR i.calidad = ANY({{calidades}}));
|
|
```
|
|
|
|
---
|
|
|
|
### Query 2: `informe_totales_monetarios`
|
|
**Reutiliza:** `panorama_totales_monetarios`
|
|
**Componente:** `TotalesMonetarios.vue`
|
|
|
|
*(SQL idéntico a Query 3 de panorama, pero con los filtros adicionales como en Query 1)*
|
|
|
|
---
|
|
|
|
### Query 3: `informe_totales_verde`
|
|
**Reutiliza:** `panorama_totales_verde`
|
|
**Componente:** `TotalesVerde.vue`
|
|
|
|
*(SQL idéntico a Query 4 de panorama, pero con los filtros adicionales)*
|
|
|
|
---
|
|
|
|
## 📊 QUERIES NUEVAS PARA INFORME INGRESOS
|
|
|
|
### Query 4: `informe_lista_ingresos`
|
|
**Componente:** `IngresosVistaTablaIngresos.vue`
|
|
**Devuelve:** Múltiples filas (todos los ingresos filtrados con info del cliente)
|
|
|
|
```sql
|
|
SELECT
|
|
i.id,
|
|
i.cliente_id,
|
|
i.created_at,
|
|
i.fecha,
|
|
i.tipo,
|
|
i.estado,
|
|
i.peso_neto,
|
|
i.peso_seco,
|
|
i.precio,
|
|
i.calidad,
|
|
i.observaciones,
|
|
i.fecha_anulado,
|
|
|
|
-- Calcular total a pagar
|
|
CASE
|
|
WHEN i.tipo IN ('verde', 'uva') THEN i.precio * i.peso_neto
|
|
WHEN i.tipo IN ('oreado', 'mojado') THEN (i.precio / 2) * i.peso_seco
|
|
ELSE 0
|
|
END as total_a_pagar,
|
|
|
|
-- Datos del cliente (para mostrar en la tabla)
|
|
c.name as cliente_nombre,
|
|
c.cedula as cliente_cedula,
|
|
c.ubicacion as cliente_ubicacion,
|
|
c.telefono as cliente_telefono
|
|
|
|
FROM vista_detalle_ingresos i
|
|
LEFT JOIN clientes c ON i.cliente_id = c.id
|
|
WHERE
|
|
({{incluir_anulados}} OR (i.estado != 'anulado' AND i.fecha_anulado IS NULL))
|
|
AND ({{fecha_desde}} IS NULL OR i.created_at >= {{fecha_desde}})
|
|
AND ({{fecha_hasta}} IS NULL OR i.created_at <= {{fecha_hasta}})
|
|
AND (CARDINALITY({{cliente_ids}}) = 0 OR i.cliente_id = ANY({{cliente_ids}}))
|
|
AND (CARDINALITY({{tipos}}) = 0 OR i.tipo = ANY({{tipos}}))
|
|
AND (CARDINALITY({{estados}}) = 0 OR i.estado = ANY({{estados}}))
|
|
AND (CARDINALITY({{ubicaciones}}) = 0 OR c.ubicacion = ANY({{ubicaciones}}))
|
|
AND (CARDINALITY({{calidades}}) = 0 OR i.calidad = ANY({{calidades}}))
|
|
ORDER BY i.created_at DESC, i.id DESC
|
|
LIMIT 1000;
|
|
```
|
|
|
|
**Respuesta esperada:**
|
|
```json
|
|
[
|
|
{
|
|
"id": 1234,
|
|
"cliente_id": 42,
|
|
"created_at": "2025-01-15T10:30:00Z",
|
|
"fecha": "2025-01-15",
|
|
"tipo": "uva",
|
|
"estado": "pagado",
|
|
"peso_neto": 2550.00,
|
|
"peso_seco": 25.50,
|
|
"precio": 4.50,
|
|
"calidad": "primera",
|
|
"observaciones": null,
|
|
"fecha_anulado": null,
|
|
"total_a_pagar": 11475.00,
|
|
"cliente_nombre": "Juan Pérez",
|
|
"cliente_cedula": "0801199012345",
|
|
"cliente_ubicacion": "El Rosario",
|
|
"cliente_telefono": "99887766"
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
### Query 5: `informe_lista_clientes_con_totales`
|
|
**Componente:** `ClientesVistaTablaClientes.vue`, `IngresosTopClientes.vue`
|
|
**Devuelve:** Múltiples filas (un cliente por fila con sus totales agregados)
|
|
|
|
```sql
|
|
SELECT
|
|
c.id as cliente_id,
|
|
c.name as cliente_nombre,
|
|
c.cedula as cliente_cedula,
|
|
c.ubicacion as cliente_ubicacion,
|
|
c.telefono as cliente_telefono,
|
|
c.avatar_url as cliente_avatar,
|
|
c.grupo_estudio as cliente_grupo_estudio,
|
|
|
|
-- === TOTALES POR CLIENTE ===
|
|
|
|
-- Contadores
|
|
COUNT(i.id) as num_ingresos,
|
|
|
|
-- Totales de peso
|
|
COALESCE(SUM(i.peso_seco), 0) as total_qq_seco,
|
|
COALESCE(SUM(i.peso_neto), 0) as total_lb_neto,
|
|
|
|
-- Totales por tipo
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'uva' THEN i.peso_seco ELSE 0 END), 0) as total_qq_uva,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'mojado' THEN i.peso_seco ELSE 0 END), 0) as total_qq_mojado,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'oreado' THEN i.peso_seco ELSE 0 END), 0) as total_qq_oreado,
|
|
COALESCE(SUM(CASE WHEN i.tipo = 'verde' THEN i.peso_neto ELSE 0 END), 0) as total_lb_verde,
|
|
|
|
-- Total pagado (inversión)
|
|
COALESCE(SUM(
|
|
CASE
|
|
WHEN i.estado = 'pagado' AND i.tipo IN ('verde', 'uva') THEN i.precio * i.peso_neto
|
|
WHEN i.estado = 'pagado' AND i.tipo IN ('oreado', 'mojado') THEN (i.precio / 2) * i.peso_seco
|
|
ELSE 0
|
|
END
|
|
), 0) as total_pagado,
|
|
|
|
-- Total pendiente de pagar
|
|
COALESCE(SUM(
|
|
CASE
|
|
WHEN i.estado = 'pendiente' AND i.tipo IN ('verde', 'uva') THEN i.precio * i.peso_neto
|
|
WHEN i.estado = 'pendiente' AND i.tipo IN ('oreado', 'mojado') THEN (i.precio / 2) * i.peso_seco
|
|
ELSE 0
|
|
END
|
|
), 0) as total_pendiente,
|
|
|
|
-- Precio promedio ponderado por qq
|
|
CASE
|
|
WHEN SUM(CASE WHEN i.estado = 'pagado' THEN i.peso_seco ELSE 0 END) > 0
|
|
THEN SUM(
|
|
CASE
|
|
WHEN i.estado = 'pagado' AND i.tipo IN ('verde', 'uva') THEN i.precio * i.peso_neto
|
|
WHEN i.estado = 'pagado' AND i.tipo IN ('oreado', 'mojado') THEN (i.precio / 2) * i.peso_seco
|
|
ELSE 0
|
|
END
|
|
) / SUM(CASE WHEN i.estado = 'pagado' THEN i.peso_seco ELSE 0 END)
|
|
ELSE 0
|
|
END as precio_promedio_qq,
|
|
|
|
-- Fecha primer ingreso
|
|
MIN(i.created_at) as primer_ingreso,
|
|
|
|
-- Fecha último ingreso
|
|
MAX(i.created_at) as ultimo_ingreso
|
|
|
|
FROM clientes c
|
|
LEFT JOIN vista_detalle_ingresos i ON c.id = i.cliente_id
|
|
AND ({{incluir_anulados}} OR (i.estado != 'anulado' AND i.fecha_anulado IS NULL))
|
|
AND ({{fecha_desde}} IS NULL OR i.created_at >= {{fecha_desde}})
|
|
AND ({{fecha_hasta}} IS NULL OR i.created_at <= {{fecha_hasta}})
|
|
AND (CARDINALITY({{tipos}}) = 0 OR i.tipo = ANY({{tipos}}))
|
|
AND (CARDINALITY({{estados}}) = 0 OR i.estado = ANY({{estados}}))
|
|
AND (CARDINALITY({{calidades}}) = 0 OR i.calidad = ANY({{calidades}}))
|
|
|
|
WHERE
|
|
-- Filtro de clientes seleccionados
|
|
(CARDINALITY({{cliente_ids}}) = 0 OR c.id = ANY({{cliente_ids}}))
|
|
|
|
-- Filtro de ubicaciones de clientes
|
|
AND (CARDINALITY({{ubicaciones}}) = 0 OR c.ubicacion = ANY({{ubicaciones}}))
|
|
|
|
GROUP BY c.id, c.name, c.cedula, c.ubicacion, c.telefono, c.avatar_url, c.grupo_estudio
|
|
HAVING COUNT(i.id) > 0
|
|
ORDER BY total_pagado DESC;
|
|
```
|
|
|
|
**Respuesta esperada:**
|
|
```json
|
|
[
|
|
{
|
|
"cliente_id": 42,
|
|
"cliente_nombre": "Juan Pérez",
|
|
"cliente_cedula": "0801199012345",
|
|
"cliente_ubicacion": "El Rosario",
|
|
"cliente_telefono": "99887766",
|
|
"cliente_avatar": null,
|
|
"cliente_grupo_estudio": null,
|
|
"num_ingresos": 45,
|
|
"total_qq_seco": 450.00,
|
|
"total_lb_neto": 45000.00,
|
|
"total_qq_uva": 200.00,
|
|
"total_qq_mojado": 150.00,
|
|
"total_qq_oreado": 100.00,
|
|
"total_lb_verde": 5000.00,
|
|
"total_pagado": 1234567.89,
|
|
"total_pendiente": 50000.00,
|
|
"precio_promedio_qq": 2743.71,
|
|
"primer_ingreso": "2024-11-01T08:00:00Z",
|
|
"ultimo_ingreso": "2025-01-15T14:30:00Z"
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
### Query 6: `informe_serie_temporal_acumulada`
|
|
**Componente:** `GraficaAcumuladoresUva.vue`, `GraficaDinamicaPagadoDeposito.vue`
|
|
**Devuelve:** Múltiples filas (agrupadas por fecha y tipo, con acumulación)
|
|
**Parámetros adicionales:** `granularidad` (valores: 'dia', 'semana', 'mes')
|
|
|
|
```sql
|
|
WITH datos_agrupados AS (
|
|
SELECT
|
|
-- Agrupar por granularidad
|
|
CASE
|
|
WHEN {{granularidad}} = 'dia' THEN DATE(i.created_at)
|
|
WHEN {{granularidad}} = 'semana' THEN DATE_TRUNC('week', i.created_at)::date
|
|
WHEN {{granularidad}} = 'mes' THEN DATE_TRUNC('month', i.created_at)::date
|
|
ELSE DATE(i.created_at)
|
|
END as fecha_grupo,
|
|
|
|
i.tipo,
|
|
i.estado,
|
|
|
|
-- Totales del período
|
|
SUM(i.peso_seco) as peso_seco_periodo,
|
|
SUM(i.peso_neto) as peso_neto_periodo,
|
|
SUM(
|
|
CASE
|
|
WHEN i.tipo IN ('verde', 'uva') THEN i.precio * i.peso_neto
|
|
WHEN i.tipo IN ('oreado', 'mojado') THEN (i.precio / 2) * i.peso_seco
|
|
ELSE 0
|
|
END
|
|
) as inversion_periodo,
|
|
COUNT(*) as num_ingresos_periodo
|
|
|
|
FROM vista_detalle_ingresos i
|
|
LEFT JOIN clientes c ON i.cliente_id = c.id
|
|
WHERE
|
|
({{incluir_anulados}} OR (i.estado != 'anulado' AND i.fecha_anulado IS NULL))
|
|
AND ({{fecha_desde}} IS NULL OR i.created_at >= {{fecha_desde}})
|
|
AND ({{fecha_hasta}} IS NULL OR i.created_at <= {{fecha_hasta}})
|
|
AND (CARDINALITY({{cliente_ids}}) = 0 OR i.cliente_id = ANY({{cliente_ids}}))
|
|
AND (CARDINALITY({{tipos}}) = 0 OR i.tipo = ANY({{tipos}}))
|
|
AND (CARDINALITY({{estados}}) = 0 OR i.estado = ANY({{estados}}))
|
|
AND (CARDINALITY({{ubicaciones}}) = 0 OR c.ubicacion = ANY({{ubicaciones}}))
|
|
AND (CARDINALITY({{calidades}}) = 0 OR i.calidad = ANY({{calidades}}))
|
|
GROUP BY fecha_grupo, i.tipo, i.estado
|
|
)
|
|
SELECT
|
|
fecha_grupo,
|
|
tipo,
|
|
estado,
|
|
peso_seco_periodo,
|
|
peso_neto_periodo,
|
|
inversion_periodo,
|
|
num_ingresos_periodo,
|
|
|
|
-- Acumulados hasta esta fecha (por tipo y estado)
|
|
SUM(peso_seco_periodo) OVER (
|
|
PARTITION BY tipo, estado
|
|
ORDER BY fecha_grupo
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
) as peso_seco_acumulado,
|
|
|
|
SUM(peso_neto_periodo) OVER (
|
|
PARTITION BY tipo, estado
|
|
ORDER BY fecha_grupo
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
) as peso_neto_acumulado,
|
|
|
|
SUM(inversion_periodo) OVER (
|
|
PARTITION BY tipo, estado
|
|
ORDER BY fecha_grupo
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
) as inversion_acumulada
|
|
|
|
FROM datos_agrupados
|
|
ORDER BY fecha_grupo, tipo, estado;
|
|
```
|
|
|
|
**Respuesta esperada:**
|
|
```json
|
|
[
|
|
{
|
|
"fecha_grupo": "2025-01-15",
|
|
"tipo": "uva",
|
|
"estado": "pagado",
|
|
"peso_seco_periodo": 25.50,
|
|
"peso_neto_periodo": 2550.00,
|
|
"inversion_periodo": 11475.00,
|
|
"num_ingresos_periodo": 5,
|
|
"peso_seco_acumulado": 450.00,
|
|
"peso_neto_acumulado": 45000.00,
|
|
"inversion_acumulada": 202500.00
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
### Query 7: `informe_opciones_filtros`
|
|
**Propósito:** Obtener listas de valores únicos para los filtros dinámicos
|
|
**Devuelve:** 1 objeto con arrays de opciones
|
|
|
|
```sql
|
|
SELECT
|
|
-- Ubicaciones únicas de clientes que tienen ingresos
|
|
(
|
|
SELECT JSON_AGG(DISTINCT c.ubicacion ORDER BY c.ubicacion)
|
|
FROM clientes c
|
|
WHERE c.ubicacion IS NOT NULL
|
|
) as ubicaciones_disponibles,
|
|
|
|
-- Calidades únicas de ingresos
|
|
(
|
|
SELECT JSON_AGG(DISTINCT i.calidad ORDER BY i.calidad)
|
|
FROM vista_detalle_ingresos i
|
|
WHERE i.calidad IS NOT NULL
|
|
) as calidades_disponibles,
|
|
|
|
-- Tipos de café (hardcoded pero por consistencia)
|
|
ARRAY['uva', 'mojado', 'oreado', 'verde']::text[] as tipos_cafe,
|
|
|
|
-- Estados (hardcoded)
|
|
ARRAY['pagado', 'pendiente']::text[] as estados_disponibles;
|
|
```
|
|
|
|
**Respuesta esperada:**
|
|
```json
|
|
{
|
|
"ubicaciones_disponibles": ["El Rosario", "Las Flores", "Belén", "Santa Rosa"],
|
|
"calidades_disponibles": ["primera", "segunda", "tercera", "especial"],
|
|
"tipos_cafe": ["uva", "mojado", "oreado", "verde"],
|
|
"estados_disponibles": ["pagado", "pendiente"]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Query 8: `informe_contadores_filtros`
|
|
**Propósito:** Para el footer que muestra "X ingresos filtrados de Y totales"
|
|
**Devuelve:** 1 fila con contadores
|
|
|
|
```sql
|
|
SELECT
|
|
-- Total ingresos (sin filtros)
|
|
(SELECT COUNT(*) FROM vista_detalle_ingresos) as total_ingresos,
|
|
|
|
-- Ingresos filtrados
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM vista_detalle_ingresos i
|
|
LEFT JOIN clientes c ON i.cliente_id = c.id
|
|
WHERE
|
|
({{incluir_anulados}} OR (i.estado != 'anulado' AND i.fecha_anulado IS NULL))
|
|
AND ({{fecha_desde}} IS NULL OR i.created_at >= {{fecha_desde}})
|
|
AND ({{fecha_hasta}} IS NULL OR i.created_at <= {{fecha_hasta}})
|
|
AND (CARDINALITY({{cliente_ids}}) = 0 OR i.cliente_id = ANY({{cliente_ids}}))
|
|
AND (CARDINALITY({{tipos}}) = 0 OR i.tipo = ANY({{tipos}}))
|
|
AND (CARDINALITY({{estados}}) = 0 OR i.estado = ANY({{estados}}))
|
|
AND (CARDINALITY({{ubicaciones}}) = 0 OR c.ubicacion = ANY({{ubicaciones}}))
|
|
AND (CARDINALITY({{calidades}}) = 0 OR i.calidad = ANY({{calidades}}))
|
|
) as ingresos_filtrados,
|
|
|
|
-- Total clientes (sin filtros)
|
|
(SELECT COUNT(*) FROM clientes) as total_clientes,
|
|
|
|
-- Clientes con ingresos filtrados
|
|
(
|
|
SELECT COUNT(DISTINCT i.cliente_id)
|
|
FROM vista_detalle_ingresos i
|
|
LEFT JOIN clientes c ON i.cliente_id = c.id
|
|
WHERE
|
|
({{incluir_anulados}} OR (i.estado != 'anulado' AND i.fecha_anulado IS NULL))
|
|
AND ({{fecha_desde}} IS NULL OR i.created_at >= {{fecha_desde}})
|
|
AND ({{fecha_hasta}} IS NULL OR i.created_at <= {{fecha_hasta}})
|
|
AND (CARDINALITY({{cliente_ids}}) = 0 OR i.cliente_id = ANY({{cliente_ids}}))
|
|
AND (CARDINALITY({{tipos}}) = 0 OR i.tipo = ANY({{tipos}}))
|
|
AND (CARDINALITY({{estados}}) = 0 OR i.estado = ANY({{estados}}))
|
|
AND (CARDINALITY({{ubicaciones}}) = 0 OR c.ubicacion = ANY({{ubicaciones}}))
|
|
AND (CARDINALITY({{calidades}}) = 0 OR i.calidad = ANY({{calidades}}))
|
|
) as clientes_con_ingresos_filtrados;
|
|
```
|
|
|
|
**Respuesta esperada:**
|
|
```json
|
|
{
|
|
"total_ingresos": 1250,
|
|
"ingresos_filtrados": 230,
|
|
"total_clientes": 85,
|
|
"clientes_con_ingresos_filtrados": 42
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 RESUMEN DE QUERIES
|
|
|
|
| # | Nombre | Componente | Tipo de respuesta | Reutiliza? |
|
|
|---|--------|------------|-------------------|------------|
|
|
| 1 | `informe_totales_ingreso_compra` | TotalesIngresoCompra | 1 fila | ✅ Panorama Q2 |
|
|
| 2 | `informe_totales_monetarios` | TotalesMonetarios | 1 fila | ✅ Panorama Q3 |
|
|
| 3 | `informe_totales_verde` | TotalesVerde | 1 fila | ✅ Panorama Q4 |
|
|
| 4 | `informe_lista_ingresos` | VistaTablaIngresos | Múltiples filas | ❌ Nueva |
|
|
| 5 | `informe_lista_clientes_con_totales` | VistaTablaClientes, TopClientes | Múltiples filas | ❌ Nueva |
|
|
| 6 | `informe_serie_temporal_acumulada` | Gráficas acumuladas | Múltiples filas | ❌ Nueva |
|
|
| 7 | `informe_opciones_filtros` | Selectores de filtros | 1 objeto JSON | ❌ Nueva |
|
|
| 8 | `informe_contadores_filtros` | Footer de filtros | 1 fila | ❌ Nueva |
|
|
|
|
**Total: 8 Queries (3 reutilizadas + 5 nuevas)**
|
|
|
|
---
|
|
|
|
## 🔧 CONFIGURACIÓN DE PARÁMETROS EN METABASE
|
|
|
|
### Parámetros Simples (igual que panorama):
|
|
- `fecha_desde`: Date, opcional
|
|
- `fecha_hasta`: Date, opcional
|
|
- `incluir_anulados`: Boolean, default `false`
|
|
|
|
### Parámetros de Array (NUEVO):
|
|
|
|
#### `cliente_ids`
|
|
- **Tipo:** Text (se parsea como array)
|
|
- **Widget Type:** Text
|
|
- **Default:** `[]`
|
|
- **Required:** No
|
|
- **Variable Name:** `cliente_ids`
|
|
- **SQL Type:** `INTEGER[]`
|
|
|
|
#### `tipos`
|
|
- **Tipo:** Text
|
|
- **Widget Type:** Text o Dropdown (multi-select)
|
|
- **Default:** `[]`
|
|
- **Required:** No
|
|
- **Options:** `['uva', 'mojado', 'oreado', 'verde']`
|
|
- **SQL Type:** `TEXT[]`
|
|
|
|
#### `estados`
|
|
- **Tipo:** Text
|
|
- **Widget Type:** Text o Dropdown (multi-select)
|
|
- **Default:** `[]`
|
|
- **Required:** No
|
|
- **Options:** `['pagado', 'pendiente']`
|
|
- **SQL Type:** `TEXT[]`
|
|
|
|
#### `ubicaciones`
|
|
- **Tipo:** Text
|
|
- **Widget Type:** Text
|
|
- **Default:** `[]`
|
|
- **Required:** No
|
|
- **SQL Type:** `TEXT[]`
|
|
|
|
#### `calidades`
|
|
- **Tipo:** Text
|
|
- **Widget Type:** Text
|
|
- **Default:** `[]`
|
|
- **Required:** No
|
|
- **SQL Type:** `TEXT[]`
|
|
|
|
#### `granularidad` (solo Query 6)
|
|
- **Tipo:** Text
|
|
- **Widget Type:** Dropdown
|
|
- **Default:** `'dia'`
|
|
- **Required:** Yes
|
|
- **Options:** `['dia', 'semana', 'mes']`
|
|
|
|
---
|
|
|
|
## 🚀 INTEGRACIÓN EN VUE
|
|
|
|
```typescript
|
|
// informe-ingresos.vue
|
|
|
|
const filters = ref({
|
|
fecha_desde: null,
|
|
fecha_hasta: null,
|
|
incluir_anulados: false,
|
|
cliente_ids: [], // Array de números
|
|
tipos: [], // Array de strings
|
|
estados: [], // Array de strings
|
|
ubicaciones: [], // Array de strings
|
|
calidades: [], // Array de strings
|
|
granularidad: 'dia' // Solo para Query 6
|
|
})
|
|
|
|
async function loadAllData() {
|
|
const [
|
|
totalesIngresoCompra,
|
|
totalesMonetarios,
|
|
totalesVerde,
|
|
listaIngresos,
|
|
listaClientes,
|
|
serieAcumulada,
|
|
opcionesFiltros,
|
|
contadores
|
|
] = await Promise.all([
|
|
$fetch('/api/metabase/question/101', { query: filters.value }),
|
|
$fetch('/api/metabase/question/102', { query: filters.value }),
|
|
$fetch('/api/metabase/question/103', { query: filters.value }),
|
|
$fetch('/api/metabase/question/104', { query: filters.value }),
|
|
$fetch('/api/metabase/question/105', { query: filters.value }),
|
|
$fetch('/api/metabase/question/106', { query: filters.value }),
|
|
$fetch('/api/metabase/question/107'), // Sin filtros, siempre trae todo
|
|
$fetch('/api/metabase/question/108', { query: filters.value })
|
|
])
|
|
|
|
// Asignar directamente
|
|
metrics.value = {
|
|
totalesIngresoCompra: totalesIngresoCompra.data.rows[0],
|
|
totalesMonetarios: totalesMonetarios.data.rows[0],
|
|
totalesVerde: totalesVerde.data.rows[0],
|
|
ingresos: listaIngresos.data.rows,
|
|
clientes: listaClientes.data.rows,
|
|
serieAcumulada: serieAcumulada.data.rows,
|
|
opcionesFiltros: opcionesFiltros.data.rows[0],
|
|
contadores: contadores.data.rows[0]
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## ⚠️ NOTAS IMPORTANTES
|
|
|
|
### 1. Soporte de Arrays en Metabase
|
|
|
|
Metabase no tiene soporte nativo para arrays en parámetros. Hay dos opciones:
|
|
|
|
**Opción A: Usar PostgreSQL arrays con CARDINALITY**
|
|
```sql
|
|
WHERE (CARDINALITY({{cliente_ids}}) = 0 OR i.cliente_id = ANY({{cliente_ids}}))
|
|
```
|
|
El parámetro se pasa como: `{1,2,3}` o `ARRAY[1,2,3]`
|
|
|
|
**Opción B: Usar múltiples OR con unnest**
|
|
```sql
|
|
WHERE ({{cliente_ids}} = '' OR i.cliente_id IN (SELECT unnest(string_to_array({{cliente_ids}}, ','))))
|
|
```
|
|
El parámetro se pasa como string CSV: `"1,2,3"`
|
|
|
|
**Recomendación:** Usar Opción A si Metabase/PostgreSQL lo soporta, caso contrario usar Opción B.
|
|
|
|
### 2. Performance
|
|
|
|
- Query 4 (lista de ingresos) tiene LIMIT 1000 para evitar cargas masivas
|
|
- Query 5 (clientes con totales) puede ser lenta si hay muchos ingresos
|
|
- Query 6 (serie acumulada) usa window functions, puede requerir índices en `created_at`
|
|
|
|
### 3. Índices Recomendados
|
|
|
|
```sql
|
|
CREATE INDEX idx_ingresos_created_at ON vista_detalle_ingresos(created_at);
|
|
CREATE INDEX idx_ingresos_cliente_id ON vista_detalle_ingresos(cliente_id);
|
|
CREATE INDEX idx_ingresos_tipo ON vista_detalle_ingresos(tipo);
|
|
CREATE INDEX idx_ingresos_estado ON vista_detalle_ingresos(estado);
|
|
CREATE INDEX idx_clientes_ubicacion ON clientes(ubicacion);
|
|
```
|
|
|
|
### 4. Diferencias con Panorama
|
|
|
|
- Panorama usa filtros simples (un valor por parámetro)
|
|
- Informe usa filtros múltiples (arrays de valores)
|
|
- Informe tiene query de lista completa de ingresos (para tabla)
|
|
- Informe tiene agregación por cliente (para ranking y tabla de clientes)
|
|
|
|
---
|
|
|
|
## 📝 PRÓXIMOS PASOS
|
|
|
|
1. ✅ Crear estas 8 queries en Metabase
|
|
2. ✅ Configurar parámetros (especialmente los de tipo array)
|
|
3. ✅ Probar queries con diferentes combinaciones de filtros
|
|
4. ✅ Obtener los Question IDs
|
|
5. ✅ Adaptar `informe-ingresos.vue` para usar las nuevas queries
|
|
6. ✅ Eliminar composables de métricas (reutilizar estructura de panorama)
|
|
7. ✅ Adaptar componentes para recibir props directamente
|
|
8. ✅ Probar performance con datasets grandes
|
|
|
|
---
|
|
|
|
**Documento creado:** 2025-10-14
|
|
**Autor:** Claude Code
|
|
**Proyecto:** Analítica Núcleo - Informe Ingresos
|