Agregar sistema de vinculaciones con registros externos de Metabase
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m46s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m46s
- 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
This commit is contained in:
290
nuxt4/server/utils/metabase.ts
Normal file
290
nuxt4/server/utils/metabase.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user