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')
|
||||
}
|
||||
}
|
||||
@@ -450,3 +450,269 @@ export async function getEstadisticasLote(loteId: string): Promise<{
|
||||
kg_iniciales: kgIniciales,
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// TIPOS PARA VINCULACIONES EXTERNAS
|
||||
// =====================================================
|
||||
|
||||
export interface VinculacionExterna {
|
||||
id: string
|
||||
tipo_registro: 'ingreso' | 'carreta' | 'salida' | 'rechazo'
|
||||
registro_id: number
|
||||
lote_id: string
|
||||
fecha_vinculacion: Date
|
||||
usuario_id: string | null
|
||||
observaciones: string | null
|
||||
datos_cache: Record<string, any> | null
|
||||
periodo_cosecha: string
|
||||
}
|
||||
|
||||
export interface LoteConVinculaciones extends Lote {
|
||||
ingresos_vinculados: number
|
||||
carretas_vinculadas: number
|
||||
salidas_vinculadas: number
|
||||
rechazos_vinculados: number
|
||||
total_vinculaciones: number
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERIES PARA VINCULACIONES EXTERNAS
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todas las vinculaciones con filtros opcionales
|
||||
*/
|
||||
export async function getVinculaciones(filtros?: {
|
||||
tipo_registro?: string
|
||||
lote_id?: string
|
||||
periodo_cosecha?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<VinculacionExterna[]> {
|
||||
let sql = 'SELECT * FROM vinculaciones_externas WHERE 1=1'
|
||||
const params: any[] = []
|
||||
let paramCount = 1
|
||||
|
||||
if (filtros?.tipo_registro) {
|
||||
sql += ` AND tipo_registro = $${paramCount}`
|
||||
params.push(filtros.tipo_registro)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (filtros?.lote_id) {
|
||||
sql += ` AND lote_id = $${paramCount}`
|
||||
params.push(filtros.lote_id)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (filtros?.periodo_cosecha) {
|
||||
sql += ` AND periodo_cosecha = $${paramCount}`
|
||||
params.push(filtros.periodo_cosecha)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
sql += ' ORDER BY fecha_vinculacion DESC'
|
||||
|
||||
if (filtros?.limit) {
|
||||
sql += ` LIMIT $${paramCount}`
|
||||
params.push(filtros.limit)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (filtros?.offset) {
|
||||
sql += ` OFFSET $${paramCount}`
|
||||
params.push(filtros.offset)
|
||||
}
|
||||
|
||||
const result = await query<VinculacionExterna>(sql, params)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene vinculaciones por lote
|
||||
*/
|
||||
export async function getVinculacionesByLote(loteId: string): Promise<VinculacionExterna[]> {
|
||||
const result = await query<VinculacionExterna>(
|
||||
'SELECT * FROM vinculaciones_externas WHERE lote_id = $1 ORDER BY tipo_registro, fecha_vinculacion DESC',
|
||||
[loteId]
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene IDs de registros ya vinculados por tipo
|
||||
*/
|
||||
export async function getRegistrosVinculados(
|
||||
tipo: string,
|
||||
periodo: string = '25-26'
|
||||
): Promise<number[]> {
|
||||
const result = await query<{ registro_id: number }>(
|
||||
'SELECT registro_id FROM vinculaciones_externas WHERE tipo_registro = $1 AND periodo_cosecha = $2',
|
||||
[tipo, periodo]
|
||||
)
|
||||
return result.rows.map(r => r.registro_id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una nueva vinculación
|
||||
*/
|
||||
export async function createVinculacion(data: {
|
||||
tipo_registro: string
|
||||
registro_id: number
|
||||
lote_id: string
|
||||
usuario_id?: string
|
||||
observaciones?: string
|
||||
datos_cache?: Record<string, any>
|
||||
periodo_cosecha?: string
|
||||
}): Promise<VinculacionExterna> {
|
||||
const result = await query<VinculacionExterna>(
|
||||
`INSERT INTO vinculaciones_externas
|
||||
(tipo_registro, registro_id, lote_id, usuario_id, observaciones, datos_cache, periodo_cosecha)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.tipo_registro,
|
||||
data.registro_id,
|
||||
data.lote_id,
|
||||
data.usuario_id || null,
|
||||
data.observaciones || null,
|
||||
data.datos_cache ? JSON.stringify(data.datos_cache) : null,
|
||||
data.periodo_cosecha || '25-26'
|
||||
]
|
||||
)
|
||||
return result.rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea múltiples vinculaciones en una transacción
|
||||
*/
|
||||
export async function createVinculacionesMasivas(
|
||||
vinculaciones: Array<{
|
||||
tipo_registro: string
|
||||
registro_id: number
|
||||
lote_id: string
|
||||
usuario_id?: string
|
||||
observaciones?: string
|
||||
datos_cache?: Record<string, any>
|
||||
periodo_cosecha?: string
|
||||
}>
|
||||
): Promise<VinculacionExterna[]> {
|
||||
const client = await getClient()
|
||||
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
|
||||
const resultados: VinculacionExterna[] = []
|
||||
|
||||
for (const v of vinculaciones) {
|
||||
const result = await client.query<VinculacionExterna>(
|
||||
`INSERT INTO vinculaciones_externas
|
||||
(tipo_registro, registro_id, lote_id, usuario_id, observaciones, datos_cache, periodo_cosecha)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
v.tipo_registro,
|
||||
v.registro_id,
|
||||
v.lote_id,
|
||||
v.usuario_id || null,
|
||||
v.observaciones || null,
|
||||
v.datos_cache ? JSON.stringify(v.datos_cache) : null,
|
||||
v.periodo_cosecha || '25-26'
|
||||
]
|
||||
)
|
||||
resultados.push(result.rows[0])
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
return resultados
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK')
|
||||
throw error
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina una vinculación
|
||||
*/
|
||||
export async function deleteVinculacion(id: string): Promise<boolean> {
|
||||
const result = await query(
|
||||
'DELETE FROM vinculaciones_externas WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene lotes con conteo de vinculaciones
|
||||
*/
|
||||
export async function getLotesConVinculaciones(filtros?: {
|
||||
tipo?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<LoteConVinculaciones[]> {
|
||||
let sql = 'SELECT * FROM vista_lotes_con_vinculaciones WHERE 1=1'
|
||||
const params: any[] = []
|
||||
let paramCount = 1
|
||||
|
||||
if (filtros?.tipo) {
|
||||
sql += ` AND tipo = $${paramCount}`
|
||||
params.push(filtros.tipo)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
sql += ' ORDER BY fecha_creado DESC'
|
||||
|
||||
if (filtros?.limit) {
|
||||
sql += ` LIMIT $${paramCount}`
|
||||
params.push(filtros.limit)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (filtros?.offset) {
|
||||
sql += ` OFFSET $${paramCount}`
|
||||
params.push(filtros.offset)
|
||||
}
|
||||
|
||||
const result = await query<LoteConVinculaciones>(sql, params)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estadísticas de vinculación para un período
|
||||
*/
|
||||
export async function getEstadisticasVinculacion(periodo: string = '25-26'): Promise<{
|
||||
ingreso: { vinculados: number }
|
||||
carreta: { vinculados: number }
|
||||
salida: { vinculados: number }
|
||||
rechazo: { vinculados: number }
|
||||
total_vinculados: number
|
||||
}> {
|
||||
const result = await query<{ tipo_registro: string; count: string }>(
|
||||
`SELECT tipo_registro, COUNT(*) as count
|
||||
FROM vinculaciones_externas
|
||||
WHERE periodo_cosecha = $1
|
||||
GROUP BY tipo_registro`,
|
||||
[periodo]
|
||||
)
|
||||
|
||||
const stats = {
|
||||
ingreso: { vinculados: 0 },
|
||||
carreta: { vinculados: 0 },
|
||||
salida: { vinculados: 0 },
|
||||
rechazo: { vinculados: 0 },
|
||||
total_vinculados: 0
|
||||
}
|
||||
|
||||
for (const row of result.rows) {
|
||||
const count = parseInt(row.count)
|
||||
if (row.tipo_registro === 'ingreso') stats.ingreso.vinculados = count
|
||||
else if (row.tipo_registro === 'carreta') stats.carreta.vinculados = count
|
||||
else if (row.tipo_registro === 'salida') stats.salida.vinculados = count
|
||||
else if (row.tipo_registro === 'rechazo') stats.rechazo.vinculados = count
|
||||
stats.total_vinculados += count
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user