Files
analiticaNucleo/METABASE_QUERIES_INFORME_INGRESOS.md
josedario87 f8c53da6fc
All checks were successful
build-and-deploy / build (push) Successful in 43s
build-and-deploy / deploy (push) Successful in 4s
feat: restaurar panorama facturador con nueva arquitectura basada en Metabase
- 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
2025-10-14 10:34:27 -06:00

23 KiB

📊 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:

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

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)

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:

[
  {
    "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)

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:

[
  {
    "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')

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:

[
  {
    "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

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:

{
  "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

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:

{
  "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

// 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

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

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

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