diff --git a/nuxt4/app/app.vue b/nuxt4/app/app.vue index 32e8b34..69c4dd6 100644 --- a/nuxt4/app/app.vue +++ b/nuxt4/app/app.vue @@ -185,6 +185,90 @@ + + + @@ -328,6 +412,14 @@ + + + @@ -336,6 +428,9 @@ import type { Lote, Operacion } from '~/composables/useLotes' const { isAuthenticated } = useAuthentik() const { fetchLotes: fetchLotesComposable, TIPOS_LOTE, TIPOS_OPERACION } = useLotes() +const { fetchIngresos, fetchCarretas, fetchSalidas, fetchRechazos, TIPOS_REGISTRO } = useRegistrosExternos() +import type { TipoRegistro } from '~/composables/useRegistrosExternos' +import type { ColumnDef } from '@tanstack/vue-table' // Navegación const selectedTab = ref('lotes') @@ -343,6 +438,7 @@ const tabs = [ { label: 'Lotes', icon: 'i-heroicons-cube', slot: 'lotes', value: 'lotes' }, { label: 'Operaciones', icon: 'i-heroicons-beaker', slot: 'operaciones', value: 'operaciones' }, { label: 'Grafos', icon: 'i-heroicons-share', slot: 'grafos', value: 'grafos' }, + { label: 'Externos', icon: 'i-heroicons-link', slot: 'externos', value: 'externos' }, ] // Estados de modales @@ -445,6 +541,168 @@ const graphSelectItems = computed(() => })) ) +// ===================================================== +// SECCIÓN: REGISTROS EXTERNOS Y VINCULACIONES +// ===================================================== + +const externosSubTab = ref('dashboard') +const externosSubTabs = [ + { label: 'Dashboard', icon: 'i-heroicons-chart-pie', slot: 'dashboard', value: 'dashboard' }, + { label: 'Ingresos', icon: 'i-heroicons-inbox-arrow-down', slot: 'ingresos', value: 'ingresos' }, + { label: 'Carretas', icon: 'i-heroicons-truck', slot: 'carretas', value: 'carretas' }, + { label: 'Salidas', icon: 'i-heroicons-arrow-up-tray', slot: 'salidas', value: 'salidas' }, + { label: 'Rechazos', icon: 'i-heroicons-x-circle', slot: 'rechazos', value: 'rechazos' }, +] + +// Estados de datos externos +const ingresosData = ref([]) +const ingresosLoading = ref(false) +const ingresosMeta = ref(null) +const soloSinVincularIngresos = ref(false) + +const carretasData = ref([]) +const carretasLoading = ref(false) +const carretasMeta = ref(null) +const soloSinVincularCarretas = ref(false) + +const salidasData = ref([]) +const salidasLoading = ref(false) +const salidasMeta = ref(null) +const soloSinVincularSalidas = ref(false) + +const rechazosData = ref([]) +const rechazosLoading = ref(false) +const rechazosMeta = ref(null) +const soloSinVincularRechazos = ref(false) + +// Modal de vinculación +const showVinculacionModal = ref(false) +const registrosParaVincular = ref([]) +const tipoRegistroParaVincular = ref('ingreso') + +// Columnas para tablas +const ingresosColumns: ColumnDef[] = [ + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'created_at', header: 'Fecha' }, + { accessorKey: 'tipo', header: 'Tipo' }, + { accessorKey: 'cliente_nombre', header: 'Cliente' }, + { accessorKey: 'peso_seco', header: 'Peso Seco' }, + { accessorKey: 'calidad', header: 'Calidad' }, +] + +const carretasColumns: ColumnDef[] = [ + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'created_at', header: 'Fecha' }, + { accessorKey: 'titulo', header: 'Título' }, + { accessorKey: 'qq_seco_estimado', header: 'qqSeco Est.' }, + { accessorKey: 'estado', header: 'Estado' }, +] + +const salidasColumns: ColumnDef[] = [ + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'created_at', header: 'Fecha' }, + { accessorKey: 'comprador', header: 'Comprador' }, + { accessorKey: 'qq_seco', header: 'qqSeco' }, +] + +const rechazosColumns: ColumnDef[] = [ + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'created_at', header: 'Fecha' }, + { accessorKey: 'tipo', header: 'Tipo' }, + { accessorKey: 'cantidad', header: 'Cantidad' }, + { accessorKey: 'comprador_nombre', header: 'Comprador' }, +] + +// Funciones para cargar datos externos +const cargarIngresos = async () => { + ingresosLoading.value = true + try { + const result = await fetchIngresos({ sinVincular: soloSinVincularIngresos.value }) + ingresosData.value = result.data + ingresosMeta.value = result.meta + } finally { + ingresosLoading.value = false + } +} + +const cargarCarretas = async () => { + carretasLoading.value = true + try { + const result = await fetchCarretas({ sinVincular: soloSinVincularCarretas.value }) + carretasData.value = result.data + carretasMeta.value = result.meta + } finally { + carretasLoading.value = false + } +} + +const cargarSalidas = async () => { + salidasLoading.value = true + try { + const result = await fetchSalidas({ sinVincular: soloSinVincularSalidas.value }) + salidasData.value = result.data + salidasMeta.value = result.meta + } finally { + salidasLoading.value = false + } +} + +const cargarRechazos = async () => { + rechazosLoading.value = true + try { + const result = await fetchRechazos({ sinVincular: soloSinVincularRechazos.value }) + rechazosData.value = result.data + rechazosMeta.value = result.meta + } finally { + rechazosLoading.value = false + } +} + +// Handlers para vinculación +const handleVincularRegistro = (registro: any) => { + registrosParaVincular.value = [registro] + tipoRegistroParaVincular.value = externosSubTab.value as TipoRegistro + showVinculacionModal.value = true +} + +const handleVincularSeleccionados = (registros: any[]) => { + registrosParaVincular.value = registros + tipoRegistroParaVincular.value = externosSubTab.value as TipoRegistro + showVinculacionModal.value = true +} + +const handleVerDetalleRegistro = (registro: any) => { + // TODO: Implementar modal de detalle + console.log('Ver detalle:', registro) +} + +const handleSeleccionarTipoExterno = (tipo: TipoRegistro) => { + const tabMap: Record = { + ingreso: 'ingresos', + carreta: 'carretas', + salida: 'salidas', + rechazo: 'rechazos', + } + externosSubTab.value = tabMap[tipo] +} + +const handleVinculacionSuccess = () => { + showVinculacionModal.value = false + // Recargar el tab actual + if (externosSubTab.value === 'ingresos') cargarIngresos() + else if (externosSubTab.value === 'carretas') cargarCarretas() + else if (externosSubTab.value === 'salidas') cargarSalidas() + else if (externosSubTab.value === 'rechazos') cargarRechazos() +} + +// Cargar datos cuando cambia el sub-tab +watch(externosSubTab, (newTab) => { + if (newTab === 'ingresos' && ingresosData.value.length === 0) cargarIngresos() + else if (newTab === 'carretas' && carretasData.value.length === 0) cargarCarretas() + else if (newTab === 'salidas' && salidasData.value.length === 0) cargarSalidas() + else if (newTab === 'rechazos' && rechazosData.value.length === 0) cargarRechazos() +}) + const loadGraphLotes = async () => { graphLoading.value = true graphError.value = null diff --git a/nuxt4/app/components/externos/TablaRegistros.vue b/nuxt4/app/components/externos/TablaRegistros.vue new file mode 100644 index 0000000..9ae999b --- /dev/null +++ b/nuxt4/app/components/externos/TablaRegistros.vue @@ -0,0 +1,233 @@ + + + diff --git a/nuxt4/app/components/lotes/Card.vue b/nuxt4/app/components/lotes/Card.vue index f43bd00..11f05d0 100644 --- a/nuxt4/app/components/lotes/Card.vue +++ b/nuxt4/app/components/lotes/Card.vue @@ -53,6 +53,85 @@ + +
+
+

Registros Externos Vinculados

+ + +
+ + +
+ + Cargando vinculaciones... +
+ + +
+ +
+ +

No hay registros externos vinculados

+
+ + +
+ +
+
+ +

{{ count }}

+

{{ tipo }}

+
+
+ + +
+ +
+
+
+
+

Información Adicional

@@ -67,6 +146,7 @@ diff --git a/nuxt4/app/components/vinculaciones/ModalAsignar.vue b/nuxt4/app/components/vinculaciones/ModalAsignar.vue new file mode 100644 index 0000000..773ac92 --- /dev/null +++ b/nuxt4/app/components/vinculaciones/ModalAsignar.vue @@ -0,0 +1,257 @@ + + + diff --git a/nuxt4/app/components/vinculaciones/ProgressDashboard.vue b/nuxt4/app/components/vinculaciones/ProgressDashboard.vue new file mode 100644 index 0000000..8d2cec3 --- /dev/null +++ b/nuxt4/app/components/vinculaciones/ProgressDashboard.vue @@ -0,0 +1,215 @@ + + + diff --git a/nuxt4/app/composables/useRegistrosExternos.ts b/nuxt4/app/composables/useRegistrosExternos.ts new file mode 100644 index 0000000..199f6b9 --- /dev/null +++ b/nuxt4/app/composables/useRegistrosExternos.ts @@ -0,0 +1,534 @@ +/** + * Composable para gestión de registros externos y vinculaciones + * + * Registros externos: Ingresos, Carretas, Salidas, Rechazos + * Estos registros vienen de Metabase (solo lectura) y se vinculan a lotes locales. + */ + +// ===================================================== +// TIPOS +// ===================================================== + +export interface Ingreso { + id: number + created_at: string + tipo: string + calidad: string + humedad: number | null + estado: string + observacion: string | null + sacos_total: number + peso_bruto: number + peso_neto: number + peso_seco: number + cliente_id: number | null + comercio_id: number | null + cliente_nombre: string | null + comercio_nombre: string | null + vinculado: boolean +} + +export interface Carreta { + id: number + created_at: string + titulo: string + estado: string + libras_mojadas: number + humedad: number + qq_seco_estimado: number + vinculado: boolean +} + +export interface Salida { + id: number + created_at: string + comprador: string + observacion: string | null + peso_bruto: number + sacos_total: number + peso_neto: number + qq_seco: number + vinculado: boolean +} + +export interface Rechazo { + id: number + created_at: string + tipo: string + estado: string + cantidad: number + unidad: string + precio_unidad: number | null + observacion: string | null + comprador_id: number | null + comprador_nombre: string | null + vinculado: boolean +} + +export interface Vinculacion { + id: string + tipo_registro: 'ingreso' | 'carreta' | 'salida' | 'rechazo' + registro_id: number + lote_id: string + fecha_vinculacion: string + usuario_id: string | null + observaciones: string | null + datos_cache: Record | null + periodo_cosecha: string +} + +export interface EstadisticasVinculacion { + ingresos: { total: number; vinculados: number; sinVincular: number; porcentaje: number } + carretas: { total: number; vinculados: number; sinVincular: number; porcentaje: number } + salidas: { total: number; vinculados: number; sinVincular: number; porcentaje: number } + rechazos: { total: number; vinculados: number; sinVincular: number; porcentaje: number } + resumen: { total: number; vinculados: number; sinVincular: number; porcentaje: number } + periodo: string +} + +export type TipoRegistro = 'ingreso' | 'carreta' | 'salida' | 'rechazo' + +// ===================================================== +// COMPOSABLE +// ===================================================== + +export const useRegistrosExternos = () => { + const toast = process.client ? useToast() : null + + // ===================================================== + // FETCH DE REGISTROS EXTERNOS + // ===================================================== + + /** + * Obtiene ingresos del período + */ + const fetchIngresos = async (opts?: { periodo?: string; sinVincular?: boolean }) => { + try { + const query = new URLSearchParams() + if (opts?.periodo) query.append('periodo', opts.periodo) + if (opts?.sinVincular) query.append('sinVincular', 'true') + + const { data, error } = await useFetch<{ + success: boolean + data: Ingreso[] + meta: { total: number; vinculados: number; sinVincular: number } + }>(`/api/externos/ingresos?${query.toString()}`) + + if (error.value) { + throw new Error(error.value.message || 'Error obteniendo ingresos') + } + + return { + data: data.value?.data || [], + meta: data.value?.meta, + } + } catch (err: any) { + console.error('Error fetching ingresos:', err) + toast?.add({ + title: 'Error', + description: err.message || 'Error obteniendo ingresos', + color: 'red', + }) + return { data: [], meta: null } + } + } + + /** + * Obtiene carretas del período + */ + const fetchCarretas = async (opts?: { periodo?: string; sinVincular?: boolean }) => { + try { + const query = new URLSearchParams() + if (opts?.periodo) query.append('periodo', opts.periodo) + if (opts?.sinVincular) query.append('sinVincular', 'true') + + const { data, error } = await useFetch<{ + success: boolean + data: Carreta[] + meta: { total: number; vinculados: number; sinVincular: number } + }>(`/api/externos/carretas?${query.toString()}`) + + if (error.value) { + throw new Error(error.value.message || 'Error obteniendo carretas') + } + + return { + data: data.value?.data || [], + meta: data.value?.meta, + } + } catch (err: any) { + console.error('Error fetching carretas:', err) + toast?.add({ + title: 'Error', + description: err.message || 'Error obteniendo carretas', + color: 'red', + }) + return { data: [], meta: null } + } + } + + /** + * Obtiene salidas del período + */ + const fetchSalidas = async (opts?: { periodo?: string; sinVincular?: boolean }) => { + try { + const query = new URLSearchParams() + if (opts?.periodo) query.append('periodo', opts.periodo) + if (opts?.sinVincular) query.append('sinVincular', 'true') + + const { data, error } = await useFetch<{ + success: boolean + data: Salida[] + meta: { total: number; vinculados: number; sinVincular: number } + }>(`/api/externos/salidas?${query.toString()}`) + + if (error.value) { + throw new Error(error.value.message || 'Error obteniendo salidas') + } + + return { + data: data.value?.data || [], + meta: data.value?.meta, + } + } catch (err: any) { + console.error('Error fetching salidas:', err) + toast?.add({ + title: 'Error', + description: err.message || 'Error obteniendo salidas', + color: 'red', + }) + return { data: [], meta: null } + } + } + + /** + * Obtiene rechazos del período + */ + const fetchRechazos = async (opts?: { periodo?: string; sinVincular?: boolean }) => { + try { + const query = new URLSearchParams() + if (opts?.periodo) query.append('periodo', opts.periodo) + if (opts?.sinVincular) query.append('sinVincular', 'true') + + const { data, error } = await useFetch<{ + success: boolean + data: Rechazo[] + meta: { total: number; vinculados: number; sinVincular: number } + }>(`/api/externos/rechazos?${query.toString()}`) + + if (error.value) { + throw new Error(error.value.message || 'Error obteniendo rechazos') + } + + return { + data: data.value?.data || [], + meta: data.value?.meta, + } + } catch (err: any) { + console.error('Error fetching rechazos:', err) + toast?.add({ + title: 'Error', + description: err.message || 'Error obteniendo rechazos', + color: 'red', + }) + return { data: [], meta: null } + } + } + + /** + * Obtiene estadísticas de vinculación + */ + const fetchEstadisticas = async (periodo?: string) => { + try { + const query = periodo ? `?periodo=${periodo}` : '' + const { data, error } = await useFetch<{ + success: boolean + data: EstadisticasVinculacion + }>(`/api/externos/stats${query}`) + + if (error.value) { + throw new Error(error.value.message || 'Error obteniendo estadísticas') + } + + return data.value?.data || null + } catch (err: any) { + console.error('Error fetching estadisticas:', err) + toast?.add({ + title: 'Error', + description: err.message || 'Error obteniendo estadísticas', + color: 'red', + }) + return null + } + } + + // ===================================================== + // GESTIÓN DE VINCULACIONES + // ===================================================== + + /** + * Vincula un registro externo a un lote + */ + const vincular = async ( + tipo: TipoRegistro, + registroId: number, + loteId: string, + opts?: { observaciones?: string; datosCache?: Record } + ) => { + try { + const { data, error } = await useFetch<{ + success: boolean + data: Vinculacion + message: string + }>('/api/vinculaciones', { + method: 'POST', + body: { + tipo_registro: tipo, + registro_id: registroId, + lote_id: loteId, + observaciones: opts?.observaciones, + datos_cache: opts?.datosCache, + }, + }) + + if (error.value) { + throw new Error(error.value.message || 'Error creando vinculación') + } + + toast?.add({ + title: 'Vinculado', + description: 'Registro vinculado correctamente al lote', + color: 'green', + }) + + return data.value?.data || null + } catch (err: any) { + console.error('Error vinculando:', err) + toast?.add({ + title: 'Error', + description: err.message || 'Error creando vinculación', + color: 'red', + }) + return null + } + } + + /** + * Vincula múltiples registros a un lote + */ + const vincularMasivo = async ( + vinculaciones: Array<{ + tipo: TipoRegistro + registroId: number + loteId: string + observaciones?: string + datosCache?: Record + }> + ) => { + try { + const { data, error } = await useFetch<{ + success: boolean + data: Vinculacion[] + message: string + }>('/api/vinculaciones', { + method: 'POST', + body: { + masivo: true, + items: vinculaciones.map((v) => ({ + tipo_registro: v.tipo, + registro_id: v.registroId, + lote_id: v.loteId, + observaciones: v.observaciones, + datos_cache: v.datosCache, + })), + }, + }) + + if (error.value) { + throw new Error(error.value.message || 'Error creando vinculaciones') + } + + toast?.add({ + title: 'Vinculados', + description: `${data.value?.data?.length || 0} registros vinculados correctamente`, + color: 'green', + }) + + return data.value?.data || [] + } catch (err: any) { + console.error('Error vinculando masivo:', err) + toast?.add({ + title: 'Error', + description: err.message || 'Error creando vinculaciones', + color: 'red', + }) + return [] + } + } + + /** + * Elimina una vinculación + */ + const desvincular = async (vinculacionId: string) => { + try { + const { error } = await useFetch(`/api/vinculaciones/${vinculacionId}`, { + method: 'DELETE', + }) + + if (error.value) { + throw new Error(error.value.message || 'Error eliminando vinculación') + } + + toast?.add({ + title: 'Desvinculado', + description: 'Vinculación eliminada correctamente', + color: 'green', + }) + + return true + } catch (err: any) { + console.error('Error desvinculando:', err) + toast?.add({ + title: 'Error', + description: err.message || 'Error eliminando vinculación', + color: 'red', + }) + return false + } + } + + /** + * Obtiene vinculaciones de un lote + */ + const fetchVinculacionesByLote = async (loteId: string) => { + try { + const { data, error } = await useFetch<{ + success: boolean + data: Vinculacion[] + agrupados: { + ingresos: Vinculacion[] + carretas: Vinculacion[] + salidas: Vinculacion[] + rechazos: Vinculacion[] + } + meta: { + lote_id: string + total: number + por_tipo: { + ingresos: number + carretas: number + salidas: number + rechazos: number + } + } + }>(`/api/vinculaciones/por-lote/${loteId}`) + + if (error.value) { + throw new Error(error.value.message || 'Error obteniendo vinculaciones') + } + + return { + data: data.value?.data || [], + agrupados: data.value?.agrupados, + meta: data.value?.meta, + } + } catch (err: any) { + console.error('Error fetching vinculaciones:', err) + toast?.add({ + title: 'Error', + description: err.message || 'Error obteniendo vinculaciones', + color: 'red', + }) + return { data: [], agrupados: null, meta: null } + } + } + + // ===================================================== + // CONSTANTES + // ===================================================== + + const TIPOS_REGISTRO = [ + { value: 'ingreso', label: 'Ingresos', labelSingular: 'Ingreso', icon: 'i-heroicons-inbox-arrow-down', color: 'purple' }, + { value: 'carreta', label: 'Carretas', labelSingular: 'Carreta', icon: 'i-heroicons-truck', color: 'blue' }, + { value: 'salida', label: 'Salidas', labelSingular: 'Salida', icon: 'i-heroicons-arrow-up-tray', color: 'green' }, + { value: 'rechazo', label: 'Rechazos', labelSingular: 'Rechazo', icon: 'i-heroicons-x-circle', color: 'red' }, + ] as const + + const PERIODOS_COSECHA = [ + { value: '25-26', label: 'Cosecha 2025-2026 (Sep 2025 - Sep 2026)' }, + { value: '24-25', label: 'Cosecha 2024-2025 (Sep 2024 - Sep 2025)' }, + ] as const + + // ===================================================== + // HELPERS + // ===================================================== + + /** + * Obtiene el label de un tipo de registro + */ + const getTipoLabel = (tipo: TipoRegistro, plural = true) => { + const found = TIPOS_REGISTRO.find((t) => t.value === tipo) + return plural ? found?.label || tipo : found?.labelSingular || tipo + } + + /** + * Obtiene el icono de un tipo de registro + */ + const getTipoIcon = (tipo: TipoRegistro) => { + const found = TIPOS_REGISTRO.find((t) => t.value === tipo) + return found?.icon || 'i-heroicons-document' + } + + /** + * Obtiene el color de un tipo de registro + */ + const getTipoColor = (tipo: TipoRegistro) => { + const found = TIPOS_REGISTRO.find((t) => t.value === tipo) + return found?.color || 'gray' + } + + /** + * Formatea una fecha para mostrar + */ + const formatFecha = (fecha: string) => { + return new Date(fecha).toLocaleDateString('es-HN', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + } + + /** + * Formatea un número como peso + */ + const formatPeso = (peso: number | null, unidad = 'qq') => { + if (peso === null || peso === undefined) return '-' + return `${peso.toFixed(2)} ${unidad}` + } + + return { + // Fetch registros externos + fetchIngresos, + fetchCarretas, + fetchSalidas, + fetchRechazos, + fetchEstadisticas, + + // Vinculaciones + vincular, + vincularMasivo, + desvincular, + fetchVinculacionesByLote, + + // Constantes + TIPOS_REGISTRO, + PERIODOS_COSECHA, + + // Helpers + getTipoLabel, + getTipoIcon, + getTipoColor, + formatFecha, + formatPeso, + } +} diff --git a/nuxt4/nuxt.config.ts b/nuxt4/nuxt.config.ts index 1f00c14..6460411 100644 --- a/nuxt4/nuxt.config.ts +++ b/nuxt4/nuxt.config.ts @@ -16,6 +16,10 @@ export default defineNuxtConfig({ runtimeConfig: { postgresUrl: process.env.NUXT_POSTGRES_URL || '', + // Metabase API para consultar registros externos + metabaseUrl: process.env.NUXT_METABASE_URL || 'http://metabase:3000', + metabaseApiKey: process.env.NUXT_METABASE_API_KEY || '', + metabaseDatabaseId: parseInt(process.env.NUXT_METABASE_DATABASE_ID || '2'), public: { authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com' } diff --git a/nuxt4/server/api/debug/reset-database.post.ts b/nuxt4/server/api/debug/reset-database.post.ts index 83626a6..d267bae 100644 --- a/nuxt4/server/api/debug/reset-database.post.ts +++ b/nuxt4/server/api/debug/reset-database.post.ts @@ -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') diff --git a/nuxt4/server/api/debug/seed-database.post.ts b/nuxt4/server/api/debug/seed-database.post.ts index f03d925..0947434 100644 --- a/nuxt4/server/api/debug/seed-database.post.ts +++ b/nuxt4/server/api/debug/seed-database.post.ts @@ -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) diff --git a/nuxt4/server/api/externos/carretas.get.ts b/nuxt4/server/api/externos/carretas.get.ts new file mode 100644 index 0000000..b21bc9f --- /dev/null +++ b/nuxt4/server/api/externos/carretas.get.ts @@ -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', + }) + } +}) diff --git a/nuxt4/server/api/externos/ingresos.get.ts b/nuxt4/server/api/externos/ingresos.get.ts new file mode 100644 index 0000000..d75d4a6 --- /dev/null +++ b/nuxt4/server/api/externos/ingresos.get.ts @@ -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', + }) + } +}) diff --git a/nuxt4/server/api/externos/rechazos.get.ts b/nuxt4/server/api/externos/rechazos.get.ts new file mode 100644 index 0000000..b93697f --- /dev/null +++ b/nuxt4/server/api/externos/rechazos.get.ts @@ -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', + }) + } +}) diff --git a/nuxt4/server/api/externos/salidas.get.ts b/nuxt4/server/api/externos/salidas.get.ts new file mode 100644 index 0000000..e76e6ce --- /dev/null +++ b/nuxt4/server/api/externos/salidas.get.ts @@ -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', + }) + } +}) diff --git a/nuxt4/server/api/externos/stats.get.ts b/nuxt4/server/api/externos/stats.get.ts new file mode 100644 index 0000000..7e6882f --- /dev/null +++ b/nuxt4/server/api/externos/stats.get.ts @@ -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', + }) + } +}) diff --git a/nuxt4/server/api/vinculaciones/[id].delete.ts b/nuxt4/server/api/vinculaciones/[id].delete.ts new file mode 100644 index 0000000..2642316 --- /dev/null +++ b/nuxt4/server/api/vinculaciones/[id].delete.ts @@ -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', + }) + } +}) diff --git a/nuxt4/server/api/vinculaciones/index.get.ts b/nuxt4/server/api/vinculaciones/index.get.ts new file mode 100644 index 0000000..0cabef1 --- /dev/null +++ b/nuxt4/server/api/vinculaciones/index.get.ts @@ -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', + }) + } +}) diff --git a/nuxt4/server/api/vinculaciones/index.post.ts b/nuxt4/server/api/vinculaciones/index.post.ts new file mode 100644 index 0000000..8d26736 --- /dev/null +++ b/nuxt4/server/api/vinculaciones/index.post.ts @@ -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', + }) + } +}) diff --git a/nuxt4/server/api/vinculaciones/por-lote/[loteId].get.ts b/nuxt4/server/api/vinculaciones/por-lote/[loteId].get.ts new file mode 100644 index 0000000..ff46e82 --- /dev/null +++ b/nuxt4/server/api/vinculaciones/por-lote/[loteId].get.ts @@ -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', + }) + } +}) diff --git a/nuxt4/server/database/03_vinculaciones_externas.sql b/nuxt4/server/database/03_vinculaciones_externas.sql new file mode 100644 index 0000000..bcecd57 --- /dev/null +++ b/nuxt4/server/database/03_vinculaciones_externas.sql @@ -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 $$; diff --git a/nuxt4/server/utils/metabase.ts b/nuxt4/server/utils/metabase.ts new file mode 100644 index 0000000..f81043d --- /dev/null +++ b/nuxt4/server/utils/metabase.ts @@ -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 { + 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( + endpoint: string, + options: { method?: string; body?: any } = {} +): Promise { + 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 = { + 'Content-Type': 'application/json', + 'X-API-KEY': apiKey + } + + try { + const response = await $fetch(`${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( + sql: string, + databaseId?: number +): Promise> { + const config = useRuntimeConfig() + const dbId = databaseId || config.metabaseDatabaseId || 2 + + return metabaseFetch>('/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>( + 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 = {} + 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') + } +} diff --git a/nuxt4/server/utils/queries.ts b/nuxt4/server/utils/queries.ts index 9450762..f6f0eb2 100644 --- a/nuxt4/server/utils/queries.ts +++ b/nuxt4/server/utils/queries.ts @@ -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 | 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 { + 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(sql, params) + return result.rows +} + +/** + * Obtiene vinculaciones por lote + */ +export async function getVinculacionesByLote(loteId: string): Promise { + const result = await query( + '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 { + 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 + periodo_cosecha?: string +}): Promise { + const result = await query( + `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 + periodo_cosecha?: string + }> +): Promise { + const client = await getClient() + + try { + await client.query('BEGIN') + + const resultados: VinculacionExterna[] = [] + + for (const v of vinculaciones) { + const result = await client.query( + `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 { + 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 { + 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(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 +} diff --git a/pwa_assets/icon-128x128.png b/pwa_assets/icon-128x128.png new file mode 100644 index 0000000..dc9d118 Binary files /dev/null and b/pwa_assets/icon-128x128.png differ diff --git a/pwa_assets/icon-144x144.png b/pwa_assets/icon-144x144.png new file mode 100644 index 0000000..ead3a5f Binary files /dev/null and b/pwa_assets/icon-144x144.png differ diff --git a/pwa_assets/icon-152x152.png b/pwa_assets/icon-152x152.png new file mode 100644 index 0000000..e9214cd Binary files /dev/null and b/pwa_assets/icon-152x152.png differ diff --git a/pwa_assets/icon-167x167.png b/pwa_assets/icon-167x167.png new file mode 100644 index 0000000..760fe1f Binary files /dev/null and b/pwa_assets/icon-167x167.png differ diff --git a/pwa_assets/icon-16x16.png b/pwa_assets/icon-16x16.png new file mode 100644 index 0000000..3db09b6 Binary files /dev/null and b/pwa_assets/icon-16x16.png differ diff --git a/pwa_assets/icon-180x180.png b/pwa_assets/icon-180x180.png new file mode 100644 index 0000000..74448ef Binary files /dev/null and b/pwa_assets/icon-180x180.png differ diff --git a/pwa_assets/icon-192x192.png b/pwa_assets/icon-192x192.png new file mode 100644 index 0000000..42bbedd Binary files /dev/null and b/pwa_assets/icon-192x192.png differ diff --git a/pwa_assets/icon-256x256.png b/pwa_assets/icon-256x256.png new file mode 100644 index 0000000..14cb153 Binary files /dev/null and b/pwa_assets/icon-256x256.png differ diff --git a/pwa_assets/icon-32x32.png b/pwa_assets/icon-32x32.png new file mode 100644 index 0000000..d6ba382 Binary files /dev/null and b/pwa_assets/icon-32x32.png differ diff --git a/pwa_assets/icon-384x384.png b/pwa_assets/icon-384x384.png new file mode 100644 index 0000000..0cfd694 Binary files /dev/null and b/pwa_assets/icon-384x384.png differ diff --git a/pwa_assets/icon-48x48.png b/pwa_assets/icon-48x48.png new file mode 100644 index 0000000..9f7f2f4 Binary files /dev/null and b/pwa_assets/icon-48x48.png differ diff --git a/pwa_assets/icon-512x512.png b/pwa_assets/icon-512x512.png new file mode 100644 index 0000000..a7dba26 Binary files /dev/null and b/pwa_assets/icon-512x512.png differ diff --git a/pwa_assets/icon-64x64.png b/pwa_assets/icon-64x64.png new file mode 100644 index 0000000..5dd7824 Binary files /dev/null and b/pwa_assets/icon-64x64.png differ diff --git a/pwa_assets/icon-72x72.png b/pwa_assets/icon-72x72.png new file mode 100644 index 0000000..245f607 Binary files /dev/null and b/pwa_assets/icon-72x72.png differ diff --git a/pwa_assets/icon-96x96.png b/pwa_assets/icon-96x96.png new file mode 100644 index 0000000..111520b Binary files /dev/null and b/pwa_assets/icon-96x96.png differ diff --git a/pwa_assets/manifest.json b/pwa_assets/manifest.json new file mode 100644 index 0000000..1f1bfc5 --- /dev/null +++ b/pwa_assets/manifest.json @@ -0,0 +1,101 @@ +{ + "name": "Mi App PWA", + "short_name": "MiApp", + "description": "Aplicación progresiva generada automáticamente.", + "start_url": "/", + "display": "standalone", + "background_color": "#00000000", + "theme_color": "#000000", + "icons": [ + { + "src": "icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-256x256.png", + "sizes": "256x256", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-180x180.png", + "sizes": "180x180", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-167x167.png", + "sizes": "167x167", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-64x64.png", + "sizes": "64x64", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-32x32.png", + "sizes": "32x32", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-16x16.png", + "sizes": "16x16", + "type": "image/png", + "purpose": "any" + } + ] +} \ No newline at end of file diff --git a/pwa_assets/service-worker.js b/pwa_assets/service-worker.js new file mode 100644 index 0000000..4ac28c7 --- /dev/null +++ b/pwa_assets/service-worker.js @@ -0,0 +1,9 @@ +self.addEventListener('install', event => { + self.skipWaiting(); +}); +self.addEventListener('activate', event => { + event.waitUntil(self.clients.claim()); +}); +self.addEventListener('fetch', event => { + // Puedes personalizar la gestión de las solicitudes aquí +});