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

@@ -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<string, any> | 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<string, any> }
) => {
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<string, any>
}>
) => {
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,
}
}