Agregar sistema de vinculaciones con registros externos de Metabase
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:
2025-11-29 15:25:26 -06:00
parent 1c96b696fa
commit ce8bad68d5
38 changed files with 2987 additions and 1 deletions

View File

@@ -23,13 +23,16 @@ export default defineEventHandler(async (event) => {
await client.query('BEGIN')
// Eliminar completamente las tablas (DROP) para que el workflow las recree
await client.query('DROP TABLE IF EXISTS vinculaciones_externas CASCADE')
await client.query('DROP TABLE IF EXISTS operacion_lotes CASCADE')
await client.query('DROP TABLE IF EXISTS operaciones CASCADE')
await client.query('DROP TABLE IF EXISTS lotes CASCADE')
// También eliminar la función y vista si existen
// También eliminar las funciones y vistas si existen
await client.query('DROP FUNCTION IF EXISTS get_trazabilidad CASCADE')
await client.query('DROP FUNCTION IF EXISTS get_estadisticas_vinculacion CASCADE')
await client.query('DROP VIEW IF EXISTS vista_lotes_con_origen CASCADE')
await client.query('DROP VIEW IF EXISTS vista_lotes_con_vinculaciones CASCADE')
await client.query('COMMIT')

View File

@@ -25,9 +25,11 @@ export default defineEventHandler(async (event) => {
// Leer los archivos SQL
const schemaPath = join(process.cwd(), 'server', 'database', '01_schema.sql')
const seedPath = join(process.cwd(), 'server', 'database', '02_seed.sql')
const vinculacionesPath = join(process.cwd(), 'server', 'database', '03_vinculaciones_externas.sql')
const schemaSQL = await readFile(schemaPath, 'utf-8')
const seedSQL = await readFile(seedPath, 'utf-8')
const vinculacionesSQL = await readFile(vinculacionesPath, 'utf-8')
await client.query('BEGIN')
@@ -46,12 +48,18 @@ export default defineEventHandler(async (event) => {
// Si no existen las tablas, ejecutar schema completo
console.log('📋 Las tablas no existen. Creando schema...')
await client.query(schemaSQL)
console.log('📋 Creando schema de vinculaciones externas...')
await client.query(vinculacionesSQL)
} else {
console.log('📋 Las tablas ya existen. Limpiando datos existentes...')
// Si las tablas existen, limpiar datos antes de cargar seed
await client.query('TRUNCATE TABLE vinculaciones_externas CASCADE')
await client.query('TRUNCATE TABLE operacion_lotes CASCADE')
await client.query('TRUNCATE TABLE operaciones CASCADE')
await client.query('TRUNCATE TABLE lotes CASCADE')
// Asegurar que el schema de vinculaciones esté actualizado
console.log('📋 Actualizando schema de vinculaciones externas...')
await client.query(vinculacionesSQL)
}
// Ejecutar seed (inserta datos de ejemplo)

View File

@@ -0,0 +1,52 @@
/**
* GET /api/externos/carretas
*
* Obtiene las carretas del período de cosecha desde Metabase.
* Incluye información de estado de vinculación.
*/
import { getCarretas } from '../../utils/metabase'
import { getRegistrosVinculados } from '../../utils/queries'
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const periodo = (query.periodo as string) || '25-26'
const soloSinVincular = query.sinVincular === 'true'
// Obtener carretas desde Metabase
const carretas = await getCarretas(periodo)
// Obtener IDs ya vinculados desde nuestra BD local
const vinculados = await getRegistrosVinculados('carreta', periodo)
const vinculadosSet = new Set(vinculados)
// Marcar cada carreta con su estado de vinculación
const carretasConEstado = carretas.map((carreta: any) => ({
...carreta,
vinculado: vinculadosSet.has(carreta.id),
}))
// Filtrar si solo se quieren los no vinculados
const resultado = soloSinVincular
? carretasConEstado.filter((c: any) => !c.vinculado)
: carretasConEstado
return {
success: true,
data: resultado,
meta: {
total: resultado.length,
vinculados: carretasConEstado.filter((c: any) => c.vinculado).length,
sinVincular: carretasConEstado.filter((c: any) => !c.vinculado).length,
periodo,
},
}
} catch (error: any) {
console.error('[API] Error obteniendo carretas:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Error obteniendo carretas',
})
}
})

View File

@@ -0,0 +1,52 @@
/**
* GET /api/externos/ingresos
*
* Obtiene los ingresos del período de cosecha desde Metabase.
* Incluye información de estado de vinculación.
*/
import { getIngresos } from '../../utils/metabase'
import { getRegistrosVinculados } from '../../utils/queries'
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const periodo = (query.periodo as string) || '25-26'
const soloSinVincular = query.sinVincular === 'true'
// Obtener ingresos desde Metabase
const ingresos = await getIngresos(periodo)
// Obtener IDs ya vinculados desde nuestra BD local
const vinculados = await getRegistrosVinculados('ingreso', periodo)
const vinculadosSet = new Set(vinculados)
// Marcar cada ingreso con su estado de vinculación
const ingresosConEstado = ingresos.map((ingreso: any) => ({
...ingreso,
vinculado: vinculadosSet.has(ingreso.id),
}))
// Filtrar si solo se quieren los no vinculados
const resultado = soloSinVincular
? ingresosConEstado.filter((i: any) => !i.vinculado)
: ingresosConEstado
return {
success: true,
data: resultado,
meta: {
total: resultado.length,
vinculados: ingresosConEstado.filter((i: any) => i.vinculado).length,
sinVincular: ingresosConEstado.filter((i: any) => !i.vinculado).length,
periodo,
},
}
} catch (error: any) {
console.error('[API] Error obteniendo ingresos:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Error obteniendo ingresos',
})
}
})

View File

@@ -0,0 +1,52 @@
/**
* GET /api/externos/rechazos
*
* Obtiene los rechazos del período de cosecha desde Metabase.
* Incluye información de estado de vinculación.
*/
import { getRechazos } from '../../utils/metabase'
import { getRegistrosVinculados } from '../../utils/queries'
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const periodo = (query.periodo as string) || '25-26'
const soloSinVincular = query.sinVincular === 'true'
// Obtener rechazos desde Metabase
const rechazos = await getRechazos(periodo)
// Obtener IDs ya vinculados desde nuestra BD local
const vinculados = await getRegistrosVinculados('rechazo', periodo)
const vinculadosSet = new Set(vinculados)
// Marcar cada rechazo con su estado de vinculación
const rechazosConEstado = rechazos.map((rechazo: any) => ({
...rechazo,
vinculado: vinculadosSet.has(rechazo.id),
}))
// Filtrar si solo se quieren los no vinculados
const resultado = soloSinVincular
? rechazosConEstado.filter((r: any) => !r.vinculado)
: rechazosConEstado
return {
success: true,
data: resultado,
meta: {
total: resultado.length,
vinculados: rechazosConEstado.filter((r: any) => r.vinculado).length,
sinVincular: rechazosConEstado.filter((r: any) => !r.vinculado).length,
periodo,
},
}
} catch (error: any) {
console.error('[API] Error obteniendo rechazos:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Error obteniendo rechazos',
})
}
})

View File

@@ -0,0 +1,52 @@
/**
* GET /api/externos/salidas
*
* Obtiene las salidas del período de cosecha desde Metabase.
* Incluye información de estado de vinculación.
*/
import { getSalidas } from '../../utils/metabase'
import { getRegistrosVinculados } from '../../utils/queries'
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const periodo = (query.periodo as string) || '25-26'
const soloSinVincular = query.sinVincular === 'true'
// Obtener salidas desde Metabase
const salidas = await getSalidas(periodo)
// Obtener IDs ya vinculados desde nuestra BD local
const vinculados = await getRegistrosVinculados('salida', periodo)
const vinculadosSet = new Set(vinculados)
// Marcar cada salida con su estado de vinculación
const salidasConEstado = salidas.map((salida: any) => ({
...salida,
vinculado: vinculadosSet.has(salida.id),
}))
// Filtrar si solo se quieren los no vinculados
const resultado = soloSinVincular
? salidasConEstado.filter((s: any) => !s.vinculado)
: salidasConEstado
return {
success: true,
data: resultado,
meta: {
total: resultado.length,
vinculados: salidasConEstado.filter((s: any) => s.vinculado).length,
sinVincular: salidasConEstado.filter((s: any) => !s.vinculado).length,
periodo,
},
}
} catch (error: any) {
console.error('[API] Error obteniendo salidas:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Error obteniendo salidas',
})
}
})

View File

@@ -0,0 +1,81 @@
/**
* GET /api/externos/stats
*
* Obtiene estadísticas de vinculación para el dashboard.
* Combina datos de Metabase (totales) con nuestra BD local (vinculados).
*/
import { getConteoRegistros } from '../../utils/metabase'
import { getEstadisticasVinculacion } from '../../utils/queries'
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const periodo = (query.periodo as string) || '25-26'
// Obtener conteos totales desde Metabase
const totales = await getConteoRegistros(periodo)
// Obtener conteos de vinculados desde nuestra BD local
const vinculados = await getEstadisticasVinculacion(periodo)
// Calcular estadísticas completas
const stats = {
ingresos: {
total: totales.ingresos,
vinculados: vinculados.ingreso.vinculados,
sinVincular: totales.ingresos - vinculados.ingreso.vinculados,
porcentaje: totales.ingresos > 0
? Math.round((vinculados.ingreso.vinculados / totales.ingresos) * 100)
: 0,
},
carretas: {
total: totales.carretas,
vinculados: vinculados.carreta.vinculados,
sinVincular: totales.carretas - vinculados.carreta.vinculados,
porcentaje: totales.carretas > 0
? Math.round((vinculados.carreta.vinculados / totales.carretas) * 100)
: 0,
},
salidas: {
total: totales.salidas,
vinculados: vinculados.salida.vinculados,
sinVincular: totales.salidas - vinculados.salida.vinculados,
porcentaje: totales.salidas > 0
? Math.round((vinculados.salida.vinculados / totales.salidas) * 100)
: 0,
},
rechazos: {
total: totales.rechazos,
vinculados: vinculados.rechazo.vinculados,
sinVincular: totales.rechazos - vinculados.rechazo.vinculados,
porcentaje: totales.rechazos > 0
? Math.round((vinculados.rechazo.vinculados / totales.rechazos) * 100)
: 0,
},
resumen: {
total: totales.ingresos + totales.carretas + totales.salidas + totales.rechazos,
vinculados: vinculados.total_vinculados,
sinVincular: (totales.ingresos + totales.carretas + totales.salidas + totales.rechazos) - vinculados.total_vinculados,
porcentaje: 0,
},
periodo,
}
// Calcular porcentaje general
stats.resumen.porcentaje = stats.resumen.total > 0
? Math.round((stats.resumen.vinculados / stats.resumen.total) * 100)
: 0
return {
success: true,
data: stats,
}
} catch (error: any) {
console.error('[API] Error obteniendo estadísticas:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Error obteniendo estadísticas',
})
}
})

View File

@@ -0,0 +1,40 @@
/**
* DELETE /api/vinculaciones/:id
*
* Elimina una vinculación por su ID.
*/
import { deleteVinculacion } from '../../utils/queries'
export default defineEventHandler(async (event) => {
try {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'ID requerido',
})
}
const eliminado = await deleteVinculacion(id)
if (!eliminado) {
throw createError({
statusCode: 404,
statusMessage: 'Vinculación no encontrada',
})
}
return {
success: true,
message: 'Vinculación eliminada exitosamente',
}
} catch (error: any) {
console.error('[API] Error eliminando vinculación:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || error.message || 'Error eliminando vinculación',
})
}
})

View File

@@ -0,0 +1,38 @@
/**
* GET /api/vinculaciones
*
* Lista vinculaciones con filtros opcionales.
*/
import { getVinculaciones } from '../../utils/queries'
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const filtros = {
tipo_registro: query.tipo as string | undefined,
lote_id: query.lote_id as string | undefined,
periodo_cosecha: (query.periodo as string) || '25-26',
limit: query.limit ? parseInt(query.limit as string) : undefined,
offset: query.offset ? parseInt(query.offset as string) : undefined,
}
const vinculaciones = await getVinculaciones(filtros)
return {
success: true,
data: vinculaciones,
meta: {
total: vinculaciones.length,
filtros,
},
}
} catch (error: any) {
console.error('[API] Error obteniendo vinculaciones:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Error obteniendo vinculaciones',
})
}
})

View File

@@ -0,0 +1,131 @@
/**
* POST /api/vinculaciones
*
* Crea una o múltiples vinculaciones.
*
* Body para vinculación individual:
* {
* tipo_registro: 'ingreso' | 'carreta' | 'salida' | 'rechazo',
* registro_id: number,
* lote_id: string,
* observaciones?: string,
* datos_cache?: object
* }
*
* Body para vinculación masiva:
* {
* masivo: true,
* items: Array<{
* tipo_registro: string,
* registro_id: number,
* lote_id: string,
* observaciones?: string,
* datos_cache?: object
* }>
* }
*/
import { createVinculacion, createVinculacionesMasivas } from '../../utils/queries'
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
// Obtener usuario de Authentik si está disponible
const headers = getHeaders(event)
const usuarioId = headers['x-authentik-username'] || headers['x-authentik-uid'] || null
// Validar body
if (!body) {
throw createError({
statusCode: 400,
statusMessage: 'Body requerido',
})
}
// Vinculación masiva
if (body.masivo && Array.isArray(body.items)) {
if (body.items.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Se requiere al menos un item para vinculación masiva',
})
}
// Validar cada item
for (const item of body.items) {
if (!item.tipo_registro || !item.registro_id || !item.lote_id) {
throw createError({
statusCode: 400,
statusMessage: 'Cada item requiere tipo_registro, registro_id y lote_id',
})
}
}
const vinculaciones = await createVinculacionesMasivas(
body.items.map((item: any) => ({
tipo_registro: item.tipo_registro,
registro_id: item.registro_id,
lote_id: item.lote_id,
usuario_id: usuarioId,
observaciones: item.observaciones,
datos_cache: item.datos_cache,
periodo_cosecha: item.periodo_cosecha || '25-26',
}))
)
return {
success: true,
data: vinculaciones,
message: `${vinculaciones.length} vinculaciones creadas exitosamente`,
}
}
// Vinculación individual
if (!body.tipo_registro || !body.registro_id || !body.lote_id) {
throw createError({
statusCode: 400,
statusMessage: 'Se requiere tipo_registro, registro_id y lote_id',
})
}
const vinculacion = await createVinculacion({
tipo_registro: body.tipo_registro,
registro_id: body.registro_id,
lote_id: body.lote_id,
usuario_id: usuarioId,
observaciones: body.observaciones,
datos_cache: body.datos_cache,
periodo_cosecha: body.periodo_cosecha || '25-26',
})
return {
success: true,
data: vinculacion,
message: 'Vinculación creada exitosamente',
}
} catch (error: any) {
console.error('[API] Error creando vinculación:', error)
// Error de constraint único (registro ya vinculado)
if (error.code === '23505') {
throw createError({
statusCode: 409,
statusMessage: 'Este registro ya está vinculado a un lote en este período',
})
}
// Error de FK (lote no existe)
if (error.code === '23503') {
throw createError({
statusCode: 400,
statusMessage: 'El lote especificado no existe',
})
}
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || error.message || 'Error creando vinculación',
})
}
})

View File

@@ -0,0 +1,52 @@
/**
* GET /api/vinculaciones/por-lote/:loteId
*
* Obtiene todas las vinculaciones de un lote específico.
*/
import { getVinculacionesByLote } from '../../../utils/queries'
export default defineEventHandler(async (event) => {
try {
const loteId = getRouterParam(event, 'loteId')
if (!loteId) {
throw createError({
statusCode: 400,
statusMessage: 'loteId requerido',
})
}
const vinculaciones = await getVinculacionesByLote(loteId)
// Agrupar por tipo de registro
const agrupados = {
ingresos: vinculaciones.filter(v => v.tipo_registro === 'ingreso'),
carretas: vinculaciones.filter(v => v.tipo_registro === 'carreta'),
salidas: vinculaciones.filter(v => v.tipo_registro === 'salida'),
rechazos: vinculaciones.filter(v => v.tipo_registro === 'rechazo'),
}
return {
success: true,
data: vinculaciones,
agrupados,
meta: {
lote_id: loteId,
total: vinculaciones.length,
por_tipo: {
ingresos: agrupados.ingresos.length,
carretas: agrupados.carretas.length,
salidas: agrupados.salidas.length,
rechazos: agrupados.rechazos.length,
},
},
}
} catch (error: any) {
console.error('[API] Error obteniendo vinculaciones del lote:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Error obteniendo vinculaciones del lote',
})
}
})

View File

@@ -0,0 +1,128 @@
-- =====================================================
-- SISTEMA DE TRAZABILIDAD - VINCULACIONES EXTERNAS
-- =====================================================
-- Este esquema permite vincular registros de sistemas externos
-- (Ingresos, Carretas, Salidas, Rechazos) con los lotes del sistema.
-- Los registros externos se consultan via Metabase API (solo lectura).
-- =====================================================
-- TABLA: vinculaciones_externas
-- =====================================================
-- Relaciona registros de tablas externas con lotes locales.
-- Cardinalidad: N registros → 1 lote
CREATE TABLE IF NOT EXISTS vinculaciones_externas (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Identificación del registro externo
tipo_registro TEXT NOT NULL, -- 'ingreso', 'carreta', 'salida', 'rechazo'
registro_id BIGINT NOT NULL, -- ID del registro en la tabla externa
-- Vinculación al lote local
lote_id UUID NOT NULL REFERENCES lotes(id) ON DELETE CASCADE,
-- Metadatos de la vinculación
fecha_vinculacion TIMESTAMPTZ NOT NULL DEFAULT NOW(),
usuario_id TEXT, -- Usuario de Authentik que hizo la vinculación
observaciones TEXT,
-- Cache de datos del registro externo (para evitar consultas repetidas)
datos_cache JSONB, -- Snapshot del registro al momento de vincular
-- Periodo de cosecha (para filtros y unicidad)
periodo_cosecha TEXT NOT NULL DEFAULT '25-26', -- '25-26' para cosecha 2025-2026
-- Un registro externo solo puede vincularse una vez por periodo
CONSTRAINT vinculaciones_registro_unico UNIQUE (tipo_registro, registro_id, periodo_cosecha),
CONSTRAINT vinculaciones_tipo_valido CHECK (tipo_registro IN (
'ingreso',
'carreta',
'salida',
'rechazo'
))
);
-- Índices para consultas frecuentes
CREATE INDEX IF NOT EXISTS idx_vinculaciones_lote ON vinculaciones_externas(lote_id);
CREATE INDEX IF NOT EXISTS idx_vinculaciones_tipo ON vinculaciones_externas(tipo_registro);
CREATE INDEX IF NOT EXISTS idx_vinculaciones_periodo ON vinculaciones_externas(periodo_cosecha);
CREATE INDEX IF NOT EXISTS idx_vinculaciones_registro ON vinculaciones_externas(tipo_registro, registro_id);
CREATE INDEX IF NOT EXISTS idx_vinculaciones_fecha ON vinculaciones_externas(fecha_vinculacion DESC);
-- Comentarios
COMMENT ON TABLE vinculaciones_externas IS 'Vincula registros de sistemas externos (Ingresos, Carretas, Salidas, Rechazos) con lotes locales';
COMMENT ON COLUMN vinculaciones_externas.tipo_registro IS 'Tipo de registro externo: ingreso, carreta, salida, rechazo';
COMMENT ON COLUMN vinculaciones_externas.registro_id IS 'ID del registro en la tabla externa (Metabase)';
COMMENT ON COLUMN vinculaciones_externas.lote_id IS 'FK al lote local al que se vincula';
COMMENT ON COLUMN vinculaciones_externas.datos_cache IS 'Snapshot JSONB del registro al momento de vincular (para evitar consultas repetidas)';
COMMENT ON COLUMN vinculaciones_externas.periodo_cosecha IS 'Periodo de cosecha en formato YY-YY (ej: 25-26)';
-- =====================================================
-- VISTA: vista_lotes_con_vinculaciones
-- =====================================================
-- Muestra cada lote con el conteo de vinculaciones por tipo.
CREATE OR REPLACE VIEW vista_lotes_con_vinculaciones AS
SELECT
l.id,
l.codigo,
l.tipo,
l.fecha_creado,
l.cantidad_kg,
l.meta,
l.lugar_id,
COUNT(*) FILTER (WHERE v.tipo_registro = 'ingreso') AS ingresos_vinculados,
COUNT(*) FILTER (WHERE v.tipo_registro = 'carreta') AS carretas_vinculadas,
COUNT(*) FILTER (WHERE v.tipo_registro = 'salida') AS salidas_vinculadas,
COUNT(*) FILTER (WHERE v.tipo_registro = 'rechazo') AS rechazos_vinculados,
COUNT(v.id) AS total_vinculaciones
FROM lotes l
LEFT JOIN vinculaciones_externas v ON v.lote_id = l.id
GROUP BY l.id, l.codigo, l.tipo, l.fecha_creado, l.cantidad_kg, l.meta, l.lugar_id;
COMMENT ON VIEW vista_lotes_con_vinculaciones IS 'Muestra lotes con conteo de registros vinculados por tipo';
-- =====================================================
-- FUNCIÓN: get_estadisticas_vinculacion
-- =====================================================
-- Obtiene estadísticas de vinculación para un periodo dado.
CREATE OR REPLACE FUNCTION get_estadisticas_vinculacion(p_periodo TEXT DEFAULT '25-26')
RETURNS TABLE (
tipo_registro TEXT,
total BIGINT,
vinculados BIGINT,
sin_vincular BIGINT,
porcentaje NUMERIC
) AS $$
BEGIN
RETURN QUERY
SELECT
v.tipo_registro,
COUNT(*)::BIGINT AS total,
COUNT(*)::BIGINT AS vinculados,
0::BIGINT AS sin_vincular, -- Se calculará con datos de Metabase
100.0 AS porcentaje
FROM vinculaciones_externas v
WHERE v.periodo_cosecha = p_periodo
GROUP BY v.tipo_registro
ORDER BY v.tipo_registro;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION get_estadisticas_vinculacion IS 'Retorna estadísticas de vinculación por tipo de registro para un periodo';
-- =====================================================
-- MENSAJES DE ÉXITO
-- =====================================================
DO $$
BEGIN
RAISE NOTICE '✓ Esquema de vinculaciones externas creado exitosamente';
RAISE NOTICE ' - Tabla vinculaciones_externas creada';
RAISE NOTICE ' - Vista vista_lotes_con_vinculaciones creada';
RAISE NOTICE ' - Función get_estadisticas_vinculacion() creada';
END $$;

View 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')
}
}

View File

@@ -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
}