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
535 lines
14 KiB
TypeScript
535 lines
14 KiB
TypeScript
/**
|
|
* 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,
|
|
}
|
|
}
|