Files
seguidorDeLotes/nuxt4/server/utils/metabase.ts
josedario87 ce8bad68d5
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m46s
Agregar sistema de vinculaciones con registros externos de Metabase
- Nuevo schema BD para vinculaciones_externas con constraint único por período
- Cliente Metabase para consultar Ingresos, Carretas, Salidas y Rechazos
- Endpoints API para registros externos (/api/externos/*) y vinculaciones (/api/vinculaciones/*)
- Composable useRegistrosExternos con lógica de vinculación individual y masiva
- Componentes: TablaRegistros, ModalAsignar, ProgressDashboard
- Tab "Externos" en app.vue con sub-tabs y dashboard de progreso
- LotesCard.vue ahora muestra registros vinculados al lote
2025-11-29 15:25:26 -06:00

291 lines
7.2 KiB
TypeScript

/**
* Metabase API Client
*
* Proporciona funciones para consultar registros externos via Metabase API.
* Solo lectura - no modifica datos en la base externa.
*
* Basado en el patrón de analiticaNucleo.
*/
import { useRuntimeConfig } from '#imports'
// Tipos para las respuestas de Metabase
export interface MetabaseQueryResult<T = any> {
data: {
rows: T[][]
cols: { name: string; display_name: string; base_type: string }[]
rows_truncated?: number
}
database_id: number
started_at: string
status: string
row_count: number
running_time: number
}
/**
* Hace una petición autenticada a la API de Metabase
*/
export async function metabaseFetch<T = any>(
endpoint: string,
options: { method?: string; body?: any } = {}
): Promise<T> {
const config = useRuntimeConfig()
const metabaseUrl = config.metabaseUrl || 'http://metabase:3000'
const apiKey = config.metabaseApiKey
if (!apiKey) {
throw createError({
statusCode: 500,
statusMessage: 'NUXT_METABASE_API_KEY no está configurada'
})
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-API-KEY': apiKey
}
try {
const response = await $fetch<T>(`${metabaseUrl}${endpoint}`, {
method: options.method || 'GET',
headers,
body: options.body
})
return response
} catch (error: any) {
console.error(`[Metabase] Request failed for ${endpoint}:`, error?.message || error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Metabase request failed'
})
}
}
/**
* Ejecuta una consulta SQL nativa en Metabase
*/
export async function queryMetabaseSQL<T = any>(
sql: string,
databaseId?: number
): Promise<MetabaseQueryResult<T>> {
const config = useRuntimeConfig()
const dbId = databaseId || config.metabaseDatabaseId || 2
return metabaseFetch<MetabaseQueryResult<T>>('/api/dataset', {
method: 'POST',
body: {
database: dbId,
type: 'native',
native: {
query: sql
}
}
})
}
/**
* Transforma una respuesta de Metabase en un array de objetos
*/
export function transformMetabaseRows<T = Record<string, any>>(
result: MetabaseQueryResult
): T[] {
if (!result.data?.rows || !result.data?.cols) {
return []
}
const cols = result.data.cols
return result.data.rows.map((row) => {
const obj: Record<string, any> = {}
cols.forEach((col, index) => {
obj[col.name] = row[index]
})
return obj as T
})
}
// =====================================================
// CONSULTAS ESPECÍFICAS PARA REGISTROS EXTERNOS
// =====================================================
/**
* Período de cosecha en formato de fechas
*/
export function getPeriodoFechas(periodo: string = '25-26'): { inicio: string; fin: string } {
const [yearStart] = periodo.split('-').map((y) => 2000 + parseInt(y))
return {
inicio: `${yearStart}-09-10`,
fin: `${yearStart + 1}-09-09`
}
}
/**
* Obtiene los ingresos del período usando la vista existente
*/
export async function getIngresos(periodo: string = '25-26') {
const { inicio, fin } = getPeriodoFechas(periodo)
const sql = `
SELECT
v.id,
v.created_at,
v.tipo,
v.calidad,
v.humedad,
v.estado,
v.observacion,
v.sacos_total,
v.peso_bruto,
v.peso_neto,
v.peso_seco,
v.cliente_id,
v.comercio_id,
c.nombre as cliente_nombre,
com.nombre as comercio_nombre
FROM vista_detalle_ingresos v
LEFT JOIN clientes c ON c.id = v.cliente_id
LEFT JOIN comercios com ON com.id = v.comercio_id
WHERE v.created_at >= '${inicio}'
AND v.created_at < '${fin}'
AND v.estado != 'anulado'
ORDER BY v.created_at DESC
`
const result = await queryMetabaseSQL(sql)
return transformMetabaseRows(result)
}
/**
* Obtiene las carretas del período
*/
export async function getCarretas(periodo: string = '25-26') {
const { inicio, fin } = getPeriodoFechas(periodo)
const sql = `
SELECT
id,
created_at,
titulo,
estado,
total as libras_mojadas,
humedad,
ROUND((total * (100 - COALESCE(humedad, 51)) / 100.0) / 100.0, 2) as qq_seco_estimado
FROM carretas
WHERE created_at >= '${inicio}'
AND created_at < '${fin}'
ORDER BY created_at DESC
`
const result = await queryMetabaseSQL(sql)
return transformMetabaseRows(result)
}
/**
* Obtiene las salidas del período
*/
export async function getSalidas(periodo: string = '25-26') {
const { inicio, fin } = getPeriodoFechas(periodo)
const sql = `
SELECT
s.id,
s.created_at,
s.comprador,
s.observacion,
s.pesadas,
(SELECT SUM((p->>'peso')::numeric) FROM json_array_elements(s.pesadas) p) as peso_bruto,
(SELECT SUM((p->>'sacos')::numeric) FROM json_array_elements(s.pesadas) p) as sacos_total
FROM salidas s
WHERE s.created_at >= '${inicio}'
AND s.created_at < '${fin}'
ORDER BY s.created_at DESC
`
const result = await queryMetabaseSQL(sql)
// Calcular peso_neto y qq_seco en el backend
return transformMetabaseRows(result).map((row: any) => ({
...row,
peso_neto: (row.peso_bruto || 0) - ((row.sacos_total || 0) / 2),
qq_seco: ((row.peso_bruto || 0) - ((row.sacos_total || 0) / 2)) / 100
}))
}
/**
* Obtiene los rechazos del período
*/
export async function getRechazos(periodo: string = '25-26') {
const { inicio, fin } = getPeriodoFechas(periodo)
const sql = `
SELECT
r.id,
r.created_at,
r.tipo,
r.estado,
r.cantidad,
r.unidad,
r.precio_unidad,
r.observacion,
r.comprador_id,
c.nombre as comprador_nombre
FROM rechazos r
LEFT JOIN clientes c ON c.id = r.comprador_id
WHERE r.created_at >= '${inicio}'
AND r.created_at < '${fin}'
AND r.estado != 'anulado'
ORDER BY r.created_at DESC
`
const result = await queryMetabaseSQL(sql)
return transformMetabaseRows(result)
}
/**
* Obtiene el conteo de registros por tipo para un período
*/
export async function getConteoRegistros(periodo: string = '25-26') {
const { inicio, fin } = getPeriodoFechas(periodo)
const sqlIngresos = `
SELECT COUNT(*) as total
FROM vista_detalle_ingresos
WHERE created_at >= '${inicio}' AND created_at < '${fin}' AND estado != 'anulado'
`
const sqlCarretas = `
SELECT COUNT(*) as total
FROM carretas
WHERE created_at >= '${inicio}' AND created_at < '${fin}'
`
const sqlSalidas = `
SELECT COUNT(*) as total
FROM salidas
WHERE created_at >= '${inicio}' AND created_at < '${fin}'
`
const sqlRechazos = `
SELECT COUNT(*) as total
FROM rechazos
WHERE created_at >= '${inicio}' AND created_at < '${fin}' AND estado != 'anulado'
`
const [ingresos, carretas, salidas, rechazos] = await Promise.all([
queryMetabaseSQL(sqlIngresos),
queryMetabaseSQL(sqlCarretas),
queryMetabaseSQL(sqlSalidas),
queryMetabaseSQL(sqlRechazos)
])
return {
ingresos: parseInt(ingresos.data.rows[0]?.[0] || '0'),
carretas: parseInt(carretas.data.rows[0]?.[0] || '0'),
salidas: parseInt(salidas.data.rows[0]?.[0] || '0'),
rechazos: parseInt(rechazos.data.rows[0]?.[0] || '0')
}
}