Implementar sistema completo de trazabilidad de lotes
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 1m47s
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 1m47s
- Agregar PostgreSQL 16 con esquema completo - Crear API endpoints para lotes y operaciones - Implementar UI con Nuxt UI (tablas, formularios, trazabilidad) - Agregar datos de ejemplo del flujo completo - Documentar sistema en PLAN_TRAZABILIDAD.md
This commit is contained in:
@@ -6,85 +6,45 @@
|
||||
<UContainer class="py-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold mb-2">Plantilla Nuxt + Authentik</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Ejemplo de integración con Authentik Proxy Outpost
|
||||
</p>
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Seguidor de Lotes</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Sistema de trazabilidad de café
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<AuthUserAvatar v-if="isAuthenticated" />
|
||||
<AuthLogoutButton v-if="isAuthenticated" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Componentes de autenticación -->
|
||||
<div v-if="isAuthenticated" class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Columna izquierda -->
|
||||
<div class="space-y-6">
|
||||
<!-- Avatar y datos básicos -->
|
||||
<AuthUserAvatar />
|
||||
|
||||
<!-- Botones de acción individuales -->
|
||||
<UCard class="w-full">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Acciones de Sesión</h3>
|
||||
</template>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<AuthSessionStatusButton />
|
||||
<AuthProfileButton />
|
||||
<AuthLogoutButton />
|
||||
<AuthLoginButton />
|
||||
<!-- Contenido principal -->
|
||||
<div v-if="isAuthenticated">
|
||||
<!-- Navegación por Tabs -->
|
||||
<UTabs v-model="selectedTab" :items="tabs" class="mb-6">
|
||||
<!-- Tab: Lotes -->
|
||||
<template #lotes>
|
||||
<div class="py-4">
|
||||
<LotesLotesTable
|
||||
@create="showCreateLoteModal = true"
|
||||
@view="handleViewLote"
|
||||
@edit="handleEditLote"
|
||||
@trazabilidad="handleViewTrazabilidad"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Verificaciones Frontend/Backend -->
|
||||
<UCard class="w-full">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-cpu-chip" class="w-5 h-5" />
|
||||
<h3 class="text-lg font-semibold">Verificación de Sistema</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<AuthFrontendVerificationButton />
|
||||
<AuthBackendVerificationButton />
|
||||
<!-- Tab: Operaciones -->
|
||||
<template #operaciones>
|
||||
<div class="py-4">
|
||||
<OperacionesOperacionesTable
|
||||
@create="showCreateOperacionModal = true"
|
||||
@view="handleViewOperacion"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Columna derecha -->
|
||||
<div class="space-y-6">
|
||||
<!-- Metadatos completos -->
|
||||
<AuthUserMetadata />
|
||||
|
||||
<!-- Verificaciones de Grupos Frontend -->
|
||||
<UCard class="w-full">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user-group" class="w-5 h-5 text-purple-500" />
|
||||
<h3 class="text-lg font-semibold">Grupos (Frontend)</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<AuthCheckAuthentikAdminsButton />
|
||||
<AuthCheckGrupoPruebaButton />
|
||||
<AuthCheckLvl0Button />
|
||||
<AuthCheckPublicAccessButton />
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Verificaciones de Grupos Backend -->
|
||||
<UCard class="w-full">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-server-stack" class="w-5 h-5 text-orange-500" />
|
||||
<h3 class="text-lg font-semibold">Grupos (Backend)</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<AuthCheckAuthentikAdminsButton :verify-backend="true" />
|
||||
<AuthCheckGrupoPruebaButton :verify-backend="true" />
|
||||
<AuthCheckLvl0Button :verify-backend="true" />
|
||||
<AuthCheckPublicAccessButton :verify-backend="true" />
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje si no está autenticado -->
|
||||
@@ -99,14 +59,130 @@
|
||||
</UCard>
|
||||
</div>
|
||||
</UContainer>
|
||||
|
||||
<!-- Modal: Crear/Editar Lote -->
|
||||
<UModal v-model="showLoteFormModal">
|
||||
<LotesLoteForm
|
||||
:lote="selectedLote"
|
||||
@cancel="closeLoteFormModal"
|
||||
@success="handleLoteFormSuccess"
|
||||
/>
|
||||
</UModal>
|
||||
|
||||
<!-- Modal: Ver Detalle de Lote -->
|
||||
<UModal v-model="showLoteDetailModal">
|
||||
<LotesLoteCard
|
||||
v-if="selectedLote"
|
||||
:lote="selectedLote"
|
||||
@edit="handleEditLoteFromDetail"
|
||||
@trazabilidad="handleViewTrazabilidadFromDetail"
|
||||
/>
|
||||
</UModal>
|
||||
|
||||
<!-- Modal: Ver Trazabilidad -->
|
||||
<UModal v-model="showTrazabilidadModal" :ui="{ width: 'max-w-4xl' }">
|
||||
<LotesTrazabilidadTree
|
||||
v-if="trazabilidadLoteId"
|
||||
:lote-id="trazabilidadLoteId"
|
||||
@close="showTrazabilidadModal = false"
|
||||
/>
|
||||
</UModal>
|
||||
|
||||
<!-- Modal: Crear Operación -->
|
||||
<UModal v-model="showCreateOperacionModal" :ui="{ width: 'max-w-3xl' }">
|
||||
<OperacionesOperacionForm
|
||||
@cancel="showCreateOperacionModal = false"
|
||||
@success="handleOperacionFormSuccess"
|
||||
/>
|
||||
</UModal>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Lote, Operacion } from '~/composables/useLotes'
|
||||
|
||||
const { isAuthenticated } = useAuthentik()
|
||||
|
||||
// Navegación
|
||||
const selectedTab = ref(0)
|
||||
const tabs = [
|
||||
{ label: 'Lotes', icon: 'i-heroicons-cube', slot: 'lotes' },
|
||||
{ label: 'Operaciones', icon: 'i-heroicons-beaker', slot: 'operaciones' },
|
||||
]
|
||||
|
||||
// Estados de modales
|
||||
const showLoteFormModal = ref(false)
|
||||
const showLoteDetailModal = ref(false)
|
||||
const showTrazabilidadModal = ref(false)
|
||||
const showCreateLoteModal = ref(false)
|
||||
const showCreateOperacionModal = ref(false)
|
||||
|
||||
// Estados de datos
|
||||
const selectedLote = ref<Lote | null>(null)
|
||||
const trazabilidadLoteId = ref<string | null>(null)
|
||||
|
||||
// Handlers para Lotes
|
||||
const handleViewLote = (lote: Lote) => {
|
||||
selectedLote.value = lote
|
||||
showLoteDetailModal.value = true
|
||||
}
|
||||
|
||||
const handleEditLote = (lote: Lote) => {
|
||||
selectedLote.value = lote
|
||||
showLoteFormModal.value = true
|
||||
}
|
||||
|
||||
const handleEditLoteFromDetail = () => {
|
||||
showLoteDetailModal.value = false
|
||||
showLoteFormModal.value = true
|
||||
}
|
||||
|
||||
const handleViewTrazabilidad = (lote: Lote) => {
|
||||
trazabilidadLoteId.value = lote.id
|
||||
showTrazabilidadModal.value = true
|
||||
}
|
||||
|
||||
const handleViewTrazabilidadFromDetail = () => {
|
||||
if (selectedLote.value) {
|
||||
showLoteDetailModal.value = false
|
||||
trazabilidadLoteId.value = selectedLote.value.id
|
||||
showTrazabilidadModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const closeLoteFormModal = () => {
|
||||
showLoteFormModal.value = false
|
||||
selectedLote.value = null
|
||||
}
|
||||
|
||||
const handleLoteFormSuccess = () => {
|
||||
closeLoteFormModal()
|
||||
// La tabla se recargará automáticamente
|
||||
}
|
||||
|
||||
// Handlers para Operaciones
|
||||
const handleViewOperacion = (operacion: Operacion) => {
|
||||
// TODO: Implementar vista de detalle de operación si es necesario
|
||||
console.log('Ver operación:', operacion)
|
||||
}
|
||||
|
||||
const handleOperacionFormSuccess = () => {
|
||||
showCreateOperacionModal.value = false
|
||||
// Las tablas se recargarán automáticamente
|
||||
}
|
||||
|
||||
// Watch para crear lote
|
||||
watch(showCreateLoteModal, (value) => {
|
||||
if (value) {
|
||||
selectedLote.value = null
|
||||
showLoteFormModal.value = true
|
||||
showCreateLoteModal.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Configurar meta tags para PWA
|
||||
useHead({
|
||||
title: 'Seguidor de Lotes - Trazabilidad de Café',
|
||||
link: [
|
||||
{ rel: 'manifest', href: '/manifest.webmanifest' },
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: '/icon.svg' },
|
||||
|
||||
108
nuxt4/app/components/lotes/LoteCard.vue
Normal file
108
nuxt4/app/components/lotes/LoteCard.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">{{ lote.codigo || 'Sin código' }}</h3>
|
||||
<UBadge :color="getTipoColor(lote.tipo)" variant="subtle" class="mt-1">
|
||||
{{ getTipoLabel(lote.tipo) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton
|
||||
icon="i-heroicons-pencil"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
label="Editar"
|
||||
@click="$emit('edit')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-chart-bar"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
color="green"
|
||||
label="Ver Trazabilidad"
|
||||
@click="$emit('trazabilidad')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">ID</p>
|
||||
<p class="font-mono text-xs">{{ lote.id }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Fecha de Creación</p>
|
||||
<p class="font-medium">{{ formatDate(lote.fecha_creado) }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Cantidad</p>
|
||||
<p class="font-medium text-lg">
|
||||
{{ lote.cantidad_kg ? `${lote.cantidad_kg.toLocaleString('es-AR')} kg` : '-' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Lugar ID</p>
|
||||
<p class="font-medium">{{ lote.lugar_id || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="lote.meta" class="pt-4 border-t">
|
||||
<p class="text-sm text-gray-500 mb-2">Información Adicional</p>
|
||||
<UCard class="bg-gray-50">
|
||||
<pre class="text-xs overflow-x-auto">{{ JSON.stringify(lote.meta, null, 2) }}</pre>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Lote } from '~/composables/useLotes'
|
||||
|
||||
const props = defineProps<{
|
||||
lote: Lote
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: []
|
||||
trazabilidad: []
|
||||
}>()
|
||||
|
||||
const { TIPOS_LOTE } = useLotes()
|
||||
|
||||
const getTipoLabel = (tipo: string) => {
|
||||
const found = TIPOS_LOTE.find((t) => t.value === tipo)
|
||||
return found?.label || tipo
|
||||
}
|
||||
|
||||
const getTipoColor = (tipo: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
uva: 'purple',
|
||||
despulpado_primera: 'green',
|
||||
despulpado_segunda: 'yellow',
|
||||
despulpado_rechazos: 'red',
|
||||
oreado: 'orange',
|
||||
presecado: 'amber',
|
||||
reposo: 'blue',
|
||||
secado: 'emerald',
|
||||
}
|
||||
return colorMap[tipo] || 'gray'
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-AR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
147
nuxt4/app/components/lotes/LoteForm.vue
Normal file
147
nuxt4/app/components/lotes/LoteForm.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ lote ? 'Editar Lote' : 'Nuevo Lote' }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<UForm :state="formState" @submit="handleSubmit" class="space-y-4">
|
||||
<UFormGroup label="Código" name="codigo" help="Opcional - Si no se especifica, se generará automáticamente">
|
||||
<UInput
|
||||
v-model="formState.codigo"
|
||||
placeholder="Ej: UVA-001, SEC-042"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Tipo" name="tipo" required>
|
||||
<USelect
|
||||
v-model="formState.tipo"
|
||||
:options="TIPOS_LOTE"
|
||||
placeholder="Selecciona un tipo"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Cantidad (kg)" name="cantidad_kg">
|
||||
<UInput
|
||||
v-model.number="formState.cantidad_kg"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Lugar ID" name="lugar_id" help="Opcional - ID del lugar donde se encuentra">
|
||||
<UInput
|
||||
v-model.number="formState.lugar_id"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Información Adicional (JSON)" name="meta" help="Opcional - Datos adicionales en formato JSON">
|
||||
<UTextarea
|
||||
v-model="metaText"
|
||||
placeholder='{"humedad": 12.5, "notas": "café especial"}'
|
||||
rows="3"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<UButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
label="Cancelar"
|
||||
@click="$emit('cancel')"
|
||||
/>
|
||||
<UButton
|
||||
type="submit"
|
||||
:loading="loading"
|
||||
:label="lote ? 'Actualizar' : 'Crear'"
|
||||
/>
|
||||
</div>
|
||||
</UForm>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Lote } from '~/composables/useLotes'
|
||||
|
||||
const props = defineProps<{
|
||||
lote?: Lote | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
success: [lote: Lote]
|
||||
}>()
|
||||
|
||||
const { createLote, updateLote, TIPOS_LOTE } = useLotes()
|
||||
|
||||
const loading = ref(false)
|
||||
const metaText = ref('')
|
||||
|
||||
const formState = ref({
|
||||
codigo: '',
|
||||
tipo: '',
|
||||
cantidad_kg: undefined as number | undefined,
|
||||
lugar_id: undefined as number | undefined,
|
||||
})
|
||||
|
||||
// Si hay un lote para editar, cargar sus datos
|
||||
watchEffect(() => {
|
||||
if (props.lote) {
|
||||
formState.value = {
|
||||
codigo: props.lote.codigo || '',
|
||||
tipo: props.lote.tipo,
|
||||
cantidad_kg: props.lote.cantidad_kg || undefined,
|
||||
lugar_id: props.lote.lugar_id || undefined,
|
||||
}
|
||||
metaText.value = props.lote.meta ? JSON.stringify(props.lote.meta, null, 2) : ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// Parsear meta si existe
|
||||
let meta = null
|
||||
if (metaText.value.trim()) {
|
||||
try {
|
||||
meta = JSON.parse(metaText.value)
|
||||
} catch (err) {
|
||||
useToast().add({
|
||||
title: 'Error',
|
||||
description: 'El formato JSON de información adicional no es válido',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
codigo: formState.value.codigo || undefined,
|
||||
tipo: formState.value.tipo,
|
||||
cantidad_kg: formState.value.cantidad_kg,
|
||||
lugar_id: formState.value.lugar_id,
|
||||
meta,
|
||||
}
|
||||
|
||||
let result: Lote | null = null
|
||||
|
||||
if (props.lote) {
|
||||
// Actualizar lote existente
|
||||
result = await updateLote(props.lote.id, data)
|
||||
} else {
|
||||
// Crear nuevo lote
|
||||
result = await createLote(data)
|
||||
}
|
||||
|
||||
if (result) {
|
||||
emit('success', result)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
169
nuxt4/app/components/lotes/LotesTable.vue
Normal file
169
nuxt4/app/components/lotes/LotesTable.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">Lotes</h2>
|
||||
<p class="text-gray-500">Gestión de lotes de café</p>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-heroicons-plus"
|
||||
label="Nuevo Lote"
|
||||
@click="$emit('create')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<USelect
|
||||
v-model="filtroTipo"
|
||||
:options="[{ value: '', label: 'Todos los tipos' }, ...TIPOS_LOTE]"
|
||||
placeholder="Filtrar por tipo"
|
||||
class="w-64"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
label="Refrescar"
|
||||
variant="outline"
|
||||
@click="loadLotes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<UTable
|
||||
v-model="selected"
|
||||
:rows="lotes"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:empty-state="{
|
||||
icon: 'i-heroicons-inbox',
|
||||
label: 'No hay lotes registrados'
|
||||
}"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<template #codigo-data="{ row }">
|
||||
<span class="font-mono font-semibold">{{ row.codigo || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #tipo-data="{ row }">
|
||||
<UBadge :color="getTipoColor(row.tipo)" variant="subtle">
|
||||
{{ getTipoLabel(row.tipo) }}
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
<template #cantidad_kg-data="{ row }">
|
||||
<span v-if="row.cantidad_kg" class="font-medium">
|
||||
{{ row.cantidad_kg.toLocaleString('es-AR') }} kg
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<template #fecha_creado-data="{ row }">
|
||||
{{ formatDate(row.fecha_creado) }}
|
||||
</template>
|
||||
|
||||
<template #actions-data="{ row }">
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
icon="i-heroicons-eye"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@click="$emit('view', row)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-pencil"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@click="$emit('edit', row)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-chart-bar"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="green"
|
||||
@click="$emit('trazabilidad', row)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Lote } from '~/composables/useLotes'
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: []
|
||||
view: [lote: Lote]
|
||||
edit: [lote: Lote]
|
||||
trazabilidad: [lote: Lote]
|
||||
}>()
|
||||
|
||||
const { fetchLotes, TIPOS_LOTE } = useLotes()
|
||||
|
||||
const lotes = ref<Lote[]>([])
|
||||
const loading = ref(false)
|
||||
const selected = ref<Lote[]>([])
|
||||
const filtroTipo = ref('')
|
||||
|
||||
const columns = [
|
||||
{ key: 'codigo', label: 'Código' },
|
||||
{ key: 'tipo', label: 'Tipo' },
|
||||
{ key: 'cantidad_kg', label: 'Cantidad' },
|
||||
{ key: 'fecha_creado', label: 'Fecha Creación' },
|
||||
{ key: 'actions', label: 'Acciones' },
|
||||
]
|
||||
|
||||
const loadLotes = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
lotes.value = await fetchLotes({
|
||||
tipo: filtroTipo.value || undefined,
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (rows: Lote[]) => {
|
||||
selected.value = rows
|
||||
}
|
||||
|
||||
const getTipoLabel = (tipo: string) => {
|
||||
const found = TIPOS_LOTE.find((t) => t.value === tipo)
|
||||
return found?.label || tipo
|
||||
}
|
||||
|
||||
const getTipoColor = (tipo: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
uva: 'purple',
|
||||
despulpado_primera: 'green',
|
||||
despulpado_segunda: 'yellow',
|
||||
despulpado_rechazos: 'red',
|
||||
oreado: 'orange',
|
||||
presecado: 'amber',
|
||||
reposo: 'blue',
|
||||
secado: 'emerald',
|
||||
}
|
||||
return colorMap[tipo] || 'gray'
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-AR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// Cargar lotes al montar
|
||||
onMounted(() => {
|
||||
loadLotes()
|
||||
})
|
||||
|
||||
// Recargar cuando cambia el filtro
|
||||
watch(filtroTipo, () => {
|
||||
loadLotes()
|
||||
})
|
||||
</script>
|
||||
180
nuxt4/app/components/lotes/TrazabilidadTree.vue
Normal file
180
nuxt4/app/components/lotes/TrazabilidadTree.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">Trazabilidad de Lote</h3>
|
||||
<p class="text-sm text-gray-500">Historial completo desde los ingresos iniciales</p>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<UIcon name="i-heroicons-arrow-path" class="animate-spin w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="trazabilidad" class="space-y-6">
|
||||
<!-- Estadísticas -->
|
||||
<div class="grid grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-blue-600">{{ trazabilidad.estadisticas.total_ancestros }}</p>
|
||||
<p class="text-sm text-gray-600">Lotes ancestros</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-green-600">{{ trazabilidad.estadisticas.profundidad_maxima }}</p>
|
||||
<p class="text-sm text-gray-600">Niveles de profundidad</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-orange-600">
|
||||
{{ trazabilidad.estadisticas.kg_iniciales.toLocaleString('es-AR') }} kg
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">Kilos iniciales</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Árbol de Trazabilidad -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-lg">Historial</h4>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(item, index) in sortedHistorial"
|
||||
:key="index"
|
||||
class="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
:class="{
|
||||
'bg-blue-50': item.profundidad === 0,
|
||||
'border-l-4 border-blue-500': item.profundidad === 0,
|
||||
}"
|
||||
>
|
||||
<!-- Indicador de profundidad -->
|
||||
<div class="flex items-center gap-2 min-w-[100px]">
|
||||
<UBadge :color="getProfundidadColor(item.profundidad)" variant="subtle" size="xs">
|
||||
Nivel {{ item.profundidad }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Indentación visual -->
|
||||
<div class="flex items-center">
|
||||
<div :style="{ width: `${item.profundidad * 20}px` }" class="border-l-2 border-gray-300"></div>
|
||||
<UIcon
|
||||
v-if="item.profundidad > 0"
|
||||
name="i-heroicons-arrow-turn-down-right"
|
||||
class="w-4 h-4 text-gray-400 mx-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Información del lote -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono font-semibold">{{ item.codigo || item.lote_id.substring(0, 8) }}</span>
|
||||
<UBadge :color="getTipoColor(item.tipo)" variant="subtle">
|
||||
{{ getTipoLabel(item.tipo) }}
|
||||
</UBadge>
|
||||
<span v-if="item.cantidad_kg" class="text-sm text-gray-600">
|
||||
{{ item.cantidad_kg.toLocaleString('es-AR') }} kg
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Operación que generó este lote -->
|
||||
<div v-if="item.operacion_tipo" class="mt-1 text-sm text-gray-500">
|
||||
<UIcon name="i-heroicons-beaker" class="w-3 h-3 inline" />
|
||||
Generado por: <span class="font-medium">{{ getOperacionLabel(item.operacion_tipo) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leyenda -->
|
||||
<div class="pt-4 border-t">
|
||||
<p class="text-xs text-gray-500">
|
||||
<UIcon name="i-heroicons-information-circle" class="w-4 h-4 inline" />
|
||||
Los lotes se muestran desde el más reciente (nivel 0) hasta los ingresos iniciales.
|
||||
La profundidad indica cuántos pasos atrás se encuentra cada lote en la cadena de trazabilidad.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
No se encontró información de trazabilidad
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TrazabilidadRow } from '~/composables/useLotes'
|
||||
|
||||
const props = defineProps<{
|
||||
loteId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { fetchTrazabilidad, TIPOS_LOTE, TIPOS_OPERACION } = useLotes()
|
||||
|
||||
const loading = ref(false)
|
||||
const trazabilidad = ref<{
|
||||
historial: TrazabilidadRow[]
|
||||
estadisticas: {
|
||||
total_ancestros: number
|
||||
profundidad_maxima: number
|
||||
kg_iniciales: number
|
||||
}
|
||||
} | null>(null)
|
||||
|
||||
const sortedHistorial = computed(() => {
|
||||
if (!trazabilidad.value) return []
|
||||
return [...trazabilidad.value.historial].sort((a, b) => a.profundidad - b.profundidad)
|
||||
})
|
||||
|
||||
const loadTrazabilidad = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
trazabilidad.value = await fetchTrazabilidad(props.loteId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getTipoLabel = (tipo: string) => {
|
||||
const found = TIPOS_LOTE.find((t) => t.value === tipo)
|
||||
return found?.label || tipo
|
||||
}
|
||||
|
||||
const getTipoColor = (tipo: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
uva: 'purple',
|
||||
despulpado_primera: 'green',
|
||||
despulpado_segunda: 'yellow',
|
||||
despulpado_rechazos: 'red',
|
||||
oreado: 'orange',
|
||||
presecado: 'amber',
|
||||
reposo: 'blue',
|
||||
secado: 'emerald',
|
||||
}
|
||||
return colorMap[tipo] || 'gray'
|
||||
}
|
||||
|
||||
const getOperacionLabel = (tipo: string) => {
|
||||
const found = TIPOS_OPERACION.find((t) => t.value === tipo)
|
||||
return found?.label || tipo
|
||||
}
|
||||
|
||||
const getProfundidadColor = (profundidad: number): string => {
|
||||
if (profundidad === 0) return 'blue'
|
||||
if (profundidad <= 2) return 'green'
|
||||
if (profundidad <= 4) return 'orange'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTrazabilidad()
|
||||
})
|
||||
</script>
|
||||
289
nuxt4/app/components/operaciones/OperacionForm.vue
Normal file
289
nuxt4/app/components/operaciones/OperacionForm.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Nueva Operación</h3>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Paso 1: Tipo de Operación -->
|
||||
<div v-if="step === 1" class="space-y-4">
|
||||
<h4 class="font-medium">Paso 1: Selecciona el tipo de operación</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="tipo in TIPOS_OPERACION"
|
||||
:key="tipo.value"
|
||||
@click="formState.tipo = tipo.value"
|
||||
class="p-4 border-2 rounded-lg transition-all hover:border-primary"
|
||||
:class="{
|
||||
'border-primary bg-primary/10': formState.tipo === tipo.value,
|
||||
'border-gray-200': formState.tipo !== tipo.value,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon :name="tipo.icon" class="w-6 h-6" />
|
||||
<span class="font-medium">{{ tipo.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<UButton
|
||||
label="Cancelar"
|
||||
variant="outline"
|
||||
@click="$emit('cancel')"
|
||||
/>
|
||||
<UButton
|
||||
label="Siguiente"
|
||||
:disabled="!formState.tipo"
|
||||
@click="step = 2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paso 2: Seleccionar Lotes de Entrada (Inputs) -->
|
||||
<div v-else-if="step === 2" class="space-y-4">
|
||||
<h4 class="font-medium">Paso 2: Selecciona los lotes de entrada (inputs)</h4>
|
||||
<p class="text-sm text-gray-500">
|
||||
Estos son los lotes que se usarán en la operación de <strong>{{ getTipoLabel(formState.tipo) }}</strong>
|
||||
</p>
|
||||
|
||||
<div v-if="loadingLotes" class="text-center py-4">
|
||||
<UIcon name="i-heroicons-arrow-path" class="animate-spin w-6 h-6" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="lote in lotesDisponibles"
|
||||
:key="lote.id"
|
||||
class="p-3 border rounded-lg cursor-pointer transition-all hover:border-primary"
|
||||
:class="{
|
||||
'border-primary bg-primary/10': isLoteSeleccionado(lote.id),
|
||||
'border-gray-200': !isLoteSeleccionado(lote.id),
|
||||
}"
|
||||
@click="toggleLoteInput(lote)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-mono font-semibold">{{ lote.codigo || lote.id.substring(0, 8) }}</span>
|
||||
<UBadge :color="getTipoColor(lote.tipo)" variant="subtle" class="ml-2">
|
||||
{{ getTipoLabel(lote.tipo) }}
|
||||
</UBadge>
|
||||
<span v-if="lote.cantidad_kg" class="ml-2 text-sm text-gray-600">
|
||||
{{ lote.cantidad_kg.toLocaleString('es-AR') }} kg
|
||||
</span>
|
||||
</div>
|
||||
<UIcon
|
||||
v-if="isLoteSeleccionado(lote.id)"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<UButton
|
||||
label="Anterior"
|
||||
variant="outline"
|
||||
@click="step = 1"
|
||||
/>
|
||||
<UButton
|
||||
label="Siguiente"
|
||||
:disabled="formState.inputs.length === 0"
|
||||
@click="step = 3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paso 3: Definir Lotes de Salida (Outputs) -->
|
||||
<div v-else-if="step === 3" class="space-y-4">
|
||||
<h4 class="font-medium">Paso 3: Define los lotes de salida (outputs)</h4>
|
||||
<p class="text-sm text-gray-500">
|
||||
Estos son los lotes nuevos que se crearán como resultado de la operación
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(output, index) in formState.outputs"
|
||||
:key="index"
|
||||
class="p-4 border rounded-lg space-y-3"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<h5 class="font-medium">Lote de salida {{ index + 1 }}</h5>
|
||||
<UButton
|
||||
icon="i-heroicons-trash"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
@click="removeOutput(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormGroup label="Código (opcional)">
|
||||
<UInput v-model="output.codigo" placeholder="Ej: PRIM-001" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Tipo" required>
|
||||
<USelect v-model="output.tipo" :options="TIPOS_LOTE" placeholder="Selecciona tipo" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Cantidad (kg)">
|
||||
<UInput v-model.number="output.cantidad_kg" type="number" step="0.01" placeholder="0.00" />
|
||||
</UFormGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-heroicons-plus"
|
||||
label="Agregar lote de salida"
|
||||
variant="outline"
|
||||
block
|
||||
@click="addOutput"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<UButton
|
||||
label="Anterior"
|
||||
variant="outline"
|
||||
@click="step = 2"
|
||||
/>
|
||||
<UButton
|
||||
label="Crear Operación"
|
||||
:loading="loading"
|
||||
:disabled="formState.outputs.length === 0 || !allOutputsValid"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Lote } from '~/composables/useLotes'
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { fetchLotes, createOperacion, TIPOS_OPERACION, TIPOS_LOTE } = useLotes()
|
||||
|
||||
const step = ref(1)
|
||||
const loading = ref(false)
|
||||
const loadingLotes = ref(false)
|
||||
const lotesDisponibles = ref<Lote[]>([])
|
||||
|
||||
const formState = ref({
|
||||
tipo: '',
|
||||
inputs: [] as Array<{ lote_id: string; cantidad_kg?: number; _lote?: Lote }>,
|
||||
outputs: [] as Array<{ codigo?: string; tipo: string; cantidad_kg?: number }>,
|
||||
})
|
||||
|
||||
const allOutputsValid = computed(() => {
|
||||
return formState.value.outputs.every((output) => output.tipo)
|
||||
})
|
||||
|
||||
const loadLotes = async () => {
|
||||
loadingLotes.value = true
|
||||
try {
|
||||
lotesDisponibles.value = await fetchLotes()
|
||||
} finally {
|
||||
loadingLotes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isLoteSeleccionado = (loteId: string) => {
|
||||
return formState.value.inputs.some((input) => input.lote_id === loteId)
|
||||
}
|
||||
|
||||
const toggleLoteInput = (lote: Lote) => {
|
||||
const index = formState.value.inputs.findIndex((input) => input.lote_id === lote.id)
|
||||
|
||||
if (index >= 0) {
|
||||
formState.value.inputs.splice(index, 1)
|
||||
} else {
|
||||
formState.value.inputs.push({
|
||||
lote_id: lote.id,
|
||||
cantidad_kg: lote.cantidad_kg || undefined,
|
||||
_lote: lote,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addOutput = () => {
|
||||
formState.value.outputs.push({
|
||||
codigo: '',
|
||||
tipo: '',
|
||||
cantidad_kg: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const removeOutput = (index: number) => {
|
||||
formState.value.outputs.splice(index, 1)
|
||||
}
|
||||
|
||||
const getTipoLabel = (tipo: string) => {
|
||||
const foundOp = TIPOS_OPERACION.find((t) => t.value === tipo)
|
||||
if (foundOp) return foundOp.label
|
||||
|
||||
const foundLote = TIPOS_LOTE.find((t) => t.value === tipo)
|
||||
return foundLote?.label || tipo
|
||||
}
|
||||
|
||||
const getTipoColor = (tipo: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
uva: 'purple',
|
||||
despulpado_primera: 'green',
|
||||
despulpado_segunda: 'yellow',
|
||||
despulpado_rechazos: 'red',
|
||||
oreado: 'orange',
|
||||
presecado: 'amber',
|
||||
reposo: 'blue',
|
||||
secado: 'emerald',
|
||||
}
|
||||
return colorMap[tipo] || 'gray'
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await createOperacion({
|
||||
tipo: formState.value.tipo,
|
||||
inputs: formState.value.inputs.map((input) => ({
|
||||
lote_id: input.lote_id,
|
||||
cantidad_kg: input.cantidad_kg,
|
||||
})),
|
||||
outputs: formState.value.outputs.map((output) => ({
|
||||
codigo: output.codigo || undefined,
|
||||
tipo: output.tipo,
|
||||
cantidad_kg: output.cantidad_kg,
|
||||
})),
|
||||
})
|
||||
|
||||
if (result) {
|
||||
emit('success')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar lotes cuando se monta el componente
|
||||
onMounted(() => {
|
||||
loadLotes()
|
||||
})
|
||||
|
||||
// Agregar un output por defecto al inicio
|
||||
watch(
|
||||
() => step.value,
|
||||
(newStep) => {
|
||||
if (newStep === 3 && formState.value.outputs.length === 0) {
|
||||
addOutput()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
144
nuxt4/app/components/operaciones/OperacionesTable.vue
Normal file
144
nuxt4/app/components/operaciones/OperacionesTable.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">Operaciones</h2>
|
||||
<p class="text-gray-500">Historial de operaciones de proceso</p>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-heroicons-plus"
|
||||
label="Nueva Operación"
|
||||
@click="$emit('create')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<USelect
|
||||
v-model="filtroTipo"
|
||||
:options="[{ value: '', label: 'Todos los tipos' }, ...TIPOS_OPERACION]"
|
||||
placeholder="Filtrar por tipo"
|
||||
class="w-64"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
label="Refrescar"
|
||||
variant="outline"
|
||||
@click="loadOperaciones"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<UTable
|
||||
:rows="operaciones"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:empty-state="{
|
||||
icon: 'i-heroicons-inbox',
|
||||
label: 'No hay operaciones registradas'
|
||||
}"
|
||||
>
|
||||
<template #tipo-data="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon :name="getTipoIcon(row.tipo)" class="w-4 h-4" />
|
||||
<UBadge :color="getTipoColor(row.tipo)" variant="subtle">
|
||||
{{ getTipoLabel(row.tipo) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #fecha-data="{ row }">
|
||||
{{ formatDate(row.fecha) }}
|
||||
</template>
|
||||
|
||||
<template #actions-data="{ row }">
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
icon="i-heroicons-eye"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@click="$emit('view', row)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Operacion } from '~/composables/useLotes'
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: []
|
||||
view: [operacion: Operacion]
|
||||
}>()
|
||||
|
||||
const { fetchOperaciones, TIPOS_OPERACION } = useLotes()
|
||||
|
||||
const operaciones = ref<Operacion[]>([])
|
||||
const loading = ref(false)
|
||||
const filtroTipo = ref('')
|
||||
|
||||
const columns = [
|
||||
{ key: 'tipo', label: 'Tipo de Operación' },
|
||||
{ key: 'fecha', label: 'Fecha' },
|
||||
{ key: 'lugar_id', label: 'Lugar' },
|
||||
{ key: 'actions', label: 'Acciones' },
|
||||
]
|
||||
|
||||
const loadOperaciones = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
operaciones.value = await fetchOperaciones({
|
||||
tipo: filtroTipo.value || undefined,
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getTipoLabel = (tipo: string) => {
|
||||
const found = TIPOS_OPERACION.find((t) => t.value === tipo)
|
||||
return found?.label || tipo
|
||||
}
|
||||
|
||||
const getTipoIcon = (tipo: string): string => {
|
||||
const found = TIPOS_OPERACION.find((t) => t.value === tipo)
|
||||
return found?.icon || 'i-heroicons-cube'
|
||||
}
|
||||
|
||||
const getTipoColor = (tipo: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
ingreso: 'blue',
|
||||
despulpado: 'green',
|
||||
oreado: 'orange',
|
||||
presecado: 'amber',
|
||||
reposo: 'purple',
|
||||
secado: 'emerald',
|
||||
traslado: 'gray',
|
||||
mezcla: 'yellow',
|
||||
ajuste_merma: 'red',
|
||||
ajuste_cantidad: 'orange',
|
||||
ajuste_tipo: 'yellow',
|
||||
}
|
||||
return colorMap[tipo] || 'gray'
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-AR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOperaciones()
|
||||
})
|
||||
|
||||
watch(filtroTipo, () => {
|
||||
loadOperaciones()
|
||||
})
|
||||
</script>
|
||||
393
nuxt4/app/composables/useLotes.ts
Normal file
393
nuxt4/app/composables/useLotes.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Composable para gestión de lotes y operaciones de trazabilidad
|
||||
*/
|
||||
|
||||
export interface Lote {
|
||||
id: string
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
fecha_creado: string
|
||||
lugar_id: number | null
|
||||
cantidad_kg: number | null
|
||||
meta: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface Operacion {
|
||||
id: string
|
||||
tipo: string
|
||||
fecha: string
|
||||
lugar_id: number | null
|
||||
meta: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface TrazabilidadRow {
|
||||
lote_id: string
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
cantidad_kg: number | null
|
||||
operacion_id: string | null
|
||||
operacion_tipo: string | null
|
||||
profundidad: number
|
||||
}
|
||||
|
||||
export const useLotes = () => {
|
||||
const toast = useToast()
|
||||
|
||||
// =====================================================
|
||||
// FUNCIONES PARA LOTES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todos los lotes con filtros opcionales
|
||||
*/
|
||||
const fetchLotes = async (filtros?: {
|
||||
tipo?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => {
|
||||
try {
|
||||
const query = new URLSearchParams()
|
||||
if (filtros?.tipo) query.append('tipo', filtros.tipo)
|
||||
if (filtros?.limit) query.append('limit', filtros.limit.toString())
|
||||
if (filtros?.offset) query.append('offset', filtros.offset.toString())
|
||||
|
||||
const queryString = query.toString()
|
||||
const url = `/api/lotes${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Lote[]
|
||||
count: number
|
||||
}>(url)
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error obteniendo lotes')
|
||||
}
|
||||
|
||||
return data.value?.data || []
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching lotes:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error obteniendo lotes',
|
||||
color: 'red',
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un lote por su ID
|
||||
*/
|
||||
const fetchLoteById = async (id: string) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Lote
|
||||
}>(`/api/lotes/${id}`)
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error obteniendo lote')
|
||||
}
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching lote:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error obteniendo lote',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuevo lote
|
||||
*/
|
||||
const createLote = async (loteData: {
|
||||
codigo?: string
|
||||
tipo: string
|
||||
cantidad_kg?: number
|
||||
lugar_id?: number
|
||||
meta?: Record<string, any>
|
||||
}) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Lote
|
||||
}>('/api/lotes', {
|
||||
method: 'POST',
|
||||
body: loteData,
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error creando lote')
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'Éxito',
|
||||
description: 'Lote creado correctamente',
|
||||
color: 'green',
|
||||
})
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error creating lote:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error creando lote',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un lote existente
|
||||
*/
|
||||
const updateLote = async (
|
||||
id: string,
|
||||
updates: Partial<{
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
cantidad_kg: number | null
|
||||
lugar_id: number | null
|
||||
meta: Record<string, any> | null
|
||||
}>
|
||||
) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Lote
|
||||
}>(`/api/lotes/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: updates,
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error actualizando lote')
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'Éxito',
|
||||
description: 'Lote actualizado correctamente',
|
||||
color: 'green',
|
||||
})
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error updating lote:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error actualizando lote',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un lote
|
||||
*/
|
||||
const deleteLote = async (id: string) => {
|
||||
try {
|
||||
const { error } = await useFetch(`/api/lotes/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error eliminando lote')
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'Éxito',
|
||||
description: 'Lote eliminado correctamente',
|
||||
color: 'green',
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting lote:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error eliminando lote',
|
||||
color: 'red',
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el historial completo de trazabilidad de un lote
|
||||
*/
|
||||
const fetchTrazabilidad = async (id: string) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: {
|
||||
historial: TrazabilidadRow[]
|
||||
estadisticas: {
|
||||
total_ancestros: number
|
||||
profundidad_maxima: number
|
||||
kg_iniciales: number
|
||||
}
|
||||
}
|
||||
}>(`/api/lotes/${id}/trazabilidad`)
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error obteniendo trazabilidad')
|
||||
}
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching trazabilidad:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error obteniendo trazabilidad',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// FUNCIONES PARA OPERACIONES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todas las operaciones con filtros opcionales
|
||||
*/
|
||||
const fetchOperaciones = async (filtros?: {
|
||||
tipo?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => {
|
||||
try {
|
||||
const query = new URLSearchParams()
|
||||
if (filtros?.tipo) query.append('tipo', filtros.tipo)
|
||||
if (filtros?.limit) query.append('limit', filtros.limit.toString())
|
||||
if (filtros?.offset) query.append('offset', filtros.offset.toString())
|
||||
|
||||
const queryString = query.toString()
|
||||
const url = `/api/operaciones${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: Operacion[]
|
||||
count: number
|
||||
}>(url)
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error obteniendo operaciones')
|
||||
}
|
||||
|
||||
return data.value?.data || []
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching operaciones:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error obteniendo operaciones',
|
||||
color: 'red',
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una nueva operación con sus lotes inputs/outputs
|
||||
*/
|
||||
const createOperacion = async (operacionData: {
|
||||
tipo: string
|
||||
fecha?: string
|
||||
lugar_id?: number
|
||||
meta?: Record<string, any>
|
||||
inputs: Array<{ lote_id: string; cantidad_kg?: number }>
|
||||
outputs: Array<{
|
||||
codigo?: string
|
||||
tipo: string
|
||||
cantidad_kg?: number
|
||||
meta?: Record<string, any>
|
||||
}>
|
||||
}) => {
|
||||
try {
|
||||
const { data, error } = await useFetch<{
|
||||
success: boolean
|
||||
data: {
|
||||
operacion: Operacion
|
||||
lotes_creados: Lote[]
|
||||
}
|
||||
}>('/api/operaciones', {
|
||||
method: 'POST',
|
||||
body: operacionData,
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || 'Error creando operación')
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'Éxito',
|
||||
description: 'Operación creada correctamente',
|
||||
color: 'green',
|
||||
})
|
||||
|
||||
return data.value?.data || null
|
||||
} catch (err: any) {
|
||||
console.error('Error creating operacion:', err)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: err.message || 'Error creando operación',
|
||||
color: 'red',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// CONSTANTES ÚTILES
|
||||
// =====================================================
|
||||
|
||||
const TIPOS_LOTE = [
|
||||
{ value: 'uva', label: 'Uva' },
|
||||
{ value: 'despulpado_primera', label: 'Despulpado Primera' },
|
||||
{ value: 'despulpado_segunda', label: 'Despulpado Segunda' },
|
||||
{ value: 'despulpado_rechazos', label: 'Despulpado Rechazos' },
|
||||
{ value: 'oreado', label: 'Oreado' },
|
||||
{ value: 'presecado', label: 'Presecado' },
|
||||
{ value: 'reposo', label: 'Reposo' },
|
||||
{ value: 'secado', label: 'Secado' },
|
||||
]
|
||||
|
||||
const TIPOS_OPERACION = [
|
||||
{ value: 'ingreso', label: 'Ingreso', icon: 'i-heroicons-arrow-down-tray' },
|
||||
{ value: 'despulpado', label: 'Despulpado', icon: 'i-heroicons-beaker' },
|
||||
{ value: 'oreado', label: 'Oreado', icon: 'i-heroicons-sun' },
|
||||
{ value: 'presecado', label: 'Presecado', icon: 'i-heroicons-fire' },
|
||||
{ value: 'reposo', label: 'Reposo', icon: 'i-heroicons-pause' },
|
||||
{ value: 'secado', label: 'Secado', icon: 'i-heroicons-check-circle' },
|
||||
{ value: 'traslado', label: 'Traslado', icon: 'i-heroicons-arrow-right' },
|
||||
{ value: 'mezcla', label: 'Mezcla', icon: 'i-heroicons-beaker' },
|
||||
{ value: 'ajuste_merma', label: 'Ajuste Merma', icon: 'i-heroicons-adjustments-horizontal' },
|
||||
{ value: 'ajuste_cantidad', label: 'Ajuste Cantidad', icon: 'i-heroicons-calculator' },
|
||||
{ value: 'ajuste_tipo', label: 'Ajuste Tipo', icon: 'i-heroicons-pencil' },
|
||||
]
|
||||
|
||||
return {
|
||||
// Lotes
|
||||
fetchLotes,
|
||||
fetchLoteById,
|
||||
createLote,
|
||||
updateLote,
|
||||
deleteLote,
|
||||
fetchTrazabilidad,
|
||||
|
||||
// Operaciones
|
||||
fetchOperaciones,
|
||||
createOperacion,
|
||||
|
||||
// Constantes
|
||||
TIPOS_LOTE,
|
||||
TIPOS_OPERACION,
|
||||
}
|
||||
}
|
||||
147
nuxt4/package-lock.json
generated
147
nuxt4/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"eslint": "^9.37.0",
|
||||
"nuxt": "^4.1.3",
|
||||
"pg": "^8.13.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
@@ -15936,6 +15937,95 @@
|
||||
"integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
"pg-protocol": "^1.10.3",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -16460,6 +16550,45 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
@@ -18164,6 +18293,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/srvx": {
|
||||
"version": "0.8.16",
|
||||
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.16.tgz",
|
||||
@@ -21343,6 +21481,15 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"eslint": "^9.37.0",
|
||||
"nuxt": "^4.1.3",
|
||||
"pg": "^8.13.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
|
||||
48
nuxt4/server/api/lotes/[id].delete.ts
Normal file
48
nuxt4/server/api/lotes/[id].delete.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { deleteLote } from '~/server/utils/queries'
|
||||
|
||||
/**
|
||||
* DELETE /api/lotes/:id
|
||||
* Elimina un lote
|
||||
*
|
||||
* CUIDADO: Esta operación es irreversible y eliminará también
|
||||
* todas las relaciones en operacion_lotes (CASCADE).
|
||||
* Usar solo en casos excepcionales.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'ID de lote requerido',
|
||||
})
|
||||
}
|
||||
|
||||
const deleted = await deleteLote(id)
|
||||
|
||||
if (!deleted) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Lote no encontrado',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Lote eliminado correctamente',
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error eliminando lote:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error eliminando lote',
|
||||
data: { message: error.message },
|
||||
})
|
||||
}
|
||||
})
|
||||
44
nuxt4/server/api/lotes/[id].get.ts
Normal file
44
nuxt4/server/api/lotes/[id].get.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getLoteById } from '~/server/utils/queries'
|
||||
|
||||
/**
|
||||
* GET /api/lotes/:id
|
||||
* Obtiene un lote específico por su ID
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'ID de lote requerido',
|
||||
})
|
||||
}
|
||||
|
||||
const lote = await getLoteById(id)
|
||||
|
||||
if (!lote) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Lote no encontrado',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: lote,
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error obteniendo lote:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error obteniendo lote',
|
||||
data: { message: error.message },
|
||||
})
|
||||
}
|
||||
})
|
||||
55
nuxt4/server/api/lotes/[id].patch.ts
Normal file
55
nuxt4/server/api/lotes/[id].patch.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { updateLote } from '~/server/utils/queries'
|
||||
|
||||
/**
|
||||
* PATCH /api/lotes/:id
|
||||
* Actualiza un lote existente
|
||||
*
|
||||
* Body (todos opcionales):
|
||||
* {
|
||||
* codigo?: string | null,
|
||||
* tipo?: string,
|
||||
* cantidad_kg?: number | null,
|
||||
* lugar_id?: number | null,
|
||||
* meta?: object | null
|
||||
* }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'ID de lote requerido',
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
const lote = await updateLote(id, body)
|
||||
|
||||
if (!lote) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Lote no encontrado',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: lote,
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error actualizando lote:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error actualizando lote',
|
||||
data: { message: error.message },
|
||||
})
|
||||
}
|
||||
})
|
||||
54
nuxt4/server/api/lotes/[id]/trazabilidad.get.ts
Normal file
54
nuxt4/server/api/lotes/[id]/trazabilidad.get.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getTrazabilidad, getEstadisticasLote } from '~/server/utils/queries'
|
||||
|
||||
/**
|
||||
* GET /api/lotes/:id/trazabilidad
|
||||
* Obtiene el historial completo de trazabilidad de un lote
|
||||
*
|
||||
* Retorna todos los lotes ancestros hasta llegar a los ingresos iniciales,
|
||||
* organizado por profundidad (0 = lote actual, n = ancestros más antiguos)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'ID de lote requerido',
|
||||
})
|
||||
}
|
||||
|
||||
// Obtener trazabilidad completa
|
||||
const trazabilidad = await getTrazabilidad(id)
|
||||
|
||||
if (trazabilidad.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Lote no encontrado',
|
||||
})
|
||||
}
|
||||
|
||||
// Obtener estadísticas
|
||||
const estadisticas = await getEstadisticasLote(id)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
historial: trazabilidad,
|
||||
estadisticas,
|
||||
},
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error obteniendo trazabilidad:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error obteniendo trazabilidad',
|
||||
data: { message: error.message },
|
||||
})
|
||||
}
|
||||
})
|
||||
37
nuxt4/server/api/lotes/index.get.ts
Normal file
37
nuxt4/server/api/lotes/index.get.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getLotes } from '~/server/utils/queries'
|
||||
|
||||
/**
|
||||
* GET /api/lotes
|
||||
* Lista todos los lotes con filtros opcionales
|
||||
*
|
||||
* Query params:
|
||||
* - tipo: filtrar por tipo de lote (uva, despulpado_primera, oreado, etc.)
|
||||
* - limit: límite de resultados
|
||||
* - offset: offset para paginación
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const query = getQuery(event)
|
||||
|
||||
const filtros = {
|
||||
tipo: query.tipo as string | undefined,
|
||||
limit: query.limit ? parseInt(query.limit as string) : undefined,
|
||||
offset: query.offset ? parseInt(query.offset as string) : undefined,
|
||||
}
|
||||
|
||||
const lotes = await getLotes(filtros)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: lotes,
|
||||
count: lotes.length,
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error obteniendo lotes:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error obteniendo lotes',
|
||||
data: { message: error.message },
|
||||
})
|
||||
}
|
||||
})
|
||||
53
nuxt4/server/api/lotes/index.post.ts
Normal file
53
nuxt4/server/api/lotes/index.post.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createLote } from '~/server/utils/queries'
|
||||
|
||||
/**
|
||||
* POST /api/lotes
|
||||
* Crea un nuevo lote
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* codigo?: string,
|
||||
* tipo: string,
|
||||
* cantidad_kg?: number,
|
||||
* lugar_id?: number,
|
||||
* meta?: object
|
||||
* }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
|
||||
// Validaciones básicas
|
||||
if (!body.tipo) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'El campo "tipo" es requerido',
|
||||
})
|
||||
}
|
||||
|
||||
const lote = await createLote({
|
||||
codigo: body.codigo,
|
||||
tipo: body.tipo,
|
||||
cantidad_kg: body.cantidad_kg,
|
||||
lugar_id: body.lugar_id,
|
||||
meta: body.meta,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: lote,
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error creando lote:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error creando lote',
|
||||
data: { message: error.message },
|
||||
})
|
||||
}
|
||||
})
|
||||
44
nuxt4/server/api/operaciones/[id].get.ts
Normal file
44
nuxt4/server/api/operaciones/[id].get.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getOperacionConLotes } from '~/server/utils/queries'
|
||||
|
||||
/**
|
||||
* GET /api/operaciones/:id
|
||||
* Obtiene una operación específica con sus lotes relacionados (inputs y outputs)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'ID de operación requerido',
|
||||
})
|
||||
}
|
||||
|
||||
const operacion = await getOperacionConLotes(id)
|
||||
|
||||
if (!operacion) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Operación no encontrada',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: operacion,
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error obteniendo operación:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error obteniendo operación',
|
||||
data: { message: error.message },
|
||||
})
|
||||
}
|
||||
})
|
||||
37
nuxt4/server/api/operaciones/index.get.ts
Normal file
37
nuxt4/server/api/operaciones/index.get.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getOperaciones } from '~/server/utils/queries'
|
||||
|
||||
/**
|
||||
* GET /api/operaciones
|
||||
* Lista todas las operaciones con filtros opcionales
|
||||
*
|
||||
* Query params:
|
||||
* - tipo: filtrar por tipo de operación (ingreso, despulpado, oreado, etc.)
|
||||
* - limit: límite de resultados
|
||||
* - offset: offset para paginación
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const query = getQuery(event)
|
||||
|
||||
const filtros = {
|
||||
tipo: query.tipo as string | undefined,
|
||||
limit: query.limit ? parseInt(query.limit as string) : undefined,
|
||||
offset: query.offset ? parseInt(query.offset as string) : undefined,
|
||||
}
|
||||
|
||||
const operaciones = await getOperaciones(filtros)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: operaciones,
|
||||
count: operaciones.length,
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error obteniendo operaciones:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error obteniendo operaciones',
|
||||
data: { message: error.message },
|
||||
})
|
||||
}
|
||||
})
|
||||
96
nuxt4/server/api/operaciones/index.post.ts
Normal file
96
nuxt4/server/api/operaciones/index.post.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createOperacion } from '~/server/utils/queries'
|
||||
|
||||
/**
|
||||
* POST /api/operaciones
|
||||
* Crea una nueva operación con sus lotes de entrada y salida
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* tipo: string, // ingreso, despulpado, oreado, etc.
|
||||
* fecha?: string, // ISO date (opcional, default: ahora)
|
||||
* lugar_id?: number,
|
||||
* meta?: object,
|
||||
* inputs: [ // Lotes de entrada
|
||||
* { lote_id: string, cantidad_kg?: number }
|
||||
* ],
|
||||
* outputs: [ // Lotes de salida (se crearán)
|
||||
* { codigo?: string, tipo: string, cantidad_kg?: number, meta?: object }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
|
||||
// Validaciones básicas
|
||||
if (!body.tipo) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'El campo "tipo" es requerido',
|
||||
})
|
||||
}
|
||||
|
||||
if (!body.inputs || !Array.isArray(body.inputs)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'El campo "inputs" es requerido y debe ser un array',
|
||||
})
|
||||
}
|
||||
|
||||
if (!body.outputs || !Array.isArray(body.outputs)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'El campo "outputs" es requerido y debe ser un array',
|
||||
})
|
||||
}
|
||||
|
||||
// Validar que cada input tenga lote_id
|
||||
for (const input of body.inputs) {
|
||||
if (!input.lote_id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Cada input debe tener un "lote_id"',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Validar que cada output tenga tipo
|
||||
for (const output of body.outputs) {
|
||||
if (!output.tipo) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Cada output debe tener un "tipo"',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createOperacion({
|
||||
tipo: body.tipo,
|
||||
fecha: body.fecha ? new Date(body.fecha) : undefined,
|
||||
lugar_id: body.lugar_id,
|
||||
meta: body.meta,
|
||||
inputs: body.inputs,
|
||||
outputs: body.outputs,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
operacion: result.operacion,
|
||||
lotes_creados: result.lotes_creados,
|
||||
},
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error creando operación:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error creando operación',
|
||||
data: { message: error.message },
|
||||
})
|
||||
}
|
||||
})
|
||||
225
nuxt4/server/database/01_schema.sql
Normal file
225
nuxt4/server/database/01_schema.sql
Normal file
@@ -0,0 +1,225 @@
|
||||
-- =====================================================
|
||||
-- SISTEMA DE TRAZABILIDAD DE LOTES - ESQUEMA PRINCIPAL
|
||||
-- =====================================================
|
||||
-- Este esquema implementa un modelo de grafo para trazabilidad
|
||||
-- de café desde ingreso de uva hasta secado final.
|
||||
-- Permite rastrear divisiones, combinaciones y transformaciones.
|
||||
|
||||
-- Extensiones necesarias
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- =====================================================
|
||||
-- TABLA: lotes
|
||||
-- =====================================================
|
||||
-- Representa cualquier estado físico del café en un momento dado.
|
||||
-- Ejemplos: uva ingresada, café despulpado, café oreado, café secado, etc.
|
||||
|
||||
CREATE TABLE lotes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
codigo TEXT UNIQUE, -- Código legible: UVA-001, SEC-042, etc.
|
||||
tipo TEXT NOT NULL, -- uva, despulpado_primera, despulpado_segunda, despulpado_rechazos, oreado, presecado, reposo, secado
|
||||
fecha_creado TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
lugar_id INTEGER, -- Referencia opcional a lugares (patio 1, pila 2, etc.)
|
||||
cantidad_kg NUMERIC(10,2), -- Cantidad en kilogramos
|
||||
meta JSONB, -- Información adicional (humedad, notas, etc.)
|
||||
|
||||
CONSTRAINT lotes_cantidad_positiva CHECK (cantidad_kg IS NULL OR cantidad_kg >= 0),
|
||||
CONSTRAINT lotes_tipo_valido CHECK (tipo IN (
|
||||
'uva',
|
||||
'despulpado_primera',
|
||||
'despulpado_segunda',
|
||||
'despulpado_rechazos',
|
||||
'oreado',
|
||||
'presecado',
|
||||
'reposo',
|
||||
'secado'
|
||||
))
|
||||
);
|
||||
|
||||
-- Índices para búsquedas frecuentes
|
||||
CREATE INDEX idx_lotes_tipo ON lotes(tipo);
|
||||
CREATE INDEX idx_lotes_fecha_creado ON lotes(fecha_creado DESC);
|
||||
CREATE INDEX idx_lotes_codigo ON lotes(codigo) WHERE codigo IS NOT NULL;
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE lotes IS 'Representa cualquier estado físico del café en un momento dado';
|
||||
COMMENT ON COLUMN lotes.codigo IS 'Código legible opcional para identificar el lote (ej: UVA-001, SEC-042)';
|
||||
COMMENT ON COLUMN lotes.tipo IS 'Tipo de lote: uva, despulpado_*, oreado, presecado, reposo, secado';
|
||||
COMMENT ON COLUMN lotes.meta IS 'Datos adicionales en formato JSON (ej: {humedad: 12.5, notas: "café especial"})';
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- TABLA: operaciones
|
||||
-- =====================================================
|
||||
-- Representa un evento donde lotes se transforman, combinan o dividen.
|
||||
-- Ejemplos: ingreso de uva, despulpado, oreado, ajuste de merma, etc.
|
||||
|
||||
CREATE TABLE operaciones (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tipo TEXT NOT NULL, -- Tipo de operación
|
||||
fecha TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
lugar_id INTEGER, -- Referencia opcional a lugares
|
||||
meta JSONB, -- Información adicional específica del tipo
|
||||
|
||||
CONSTRAINT operaciones_tipo_valido CHECK (tipo IN (
|
||||
-- Operaciones de proceso normal
|
||||
'ingreso',
|
||||
'despulpado',
|
||||
'oreado',
|
||||
'presecado',
|
||||
'reposo',
|
||||
'secado',
|
||||
'traslado',
|
||||
'mezcla',
|
||||
-- Operaciones de ajuste/corrección
|
||||
'ajuste_merma',
|
||||
'ajuste_cantidad',
|
||||
'ajuste_tipo',
|
||||
'correccion_asignacion',
|
||||
'fusion_manual',
|
||||
'division_manual'
|
||||
))
|
||||
);
|
||||
|
||||
-- Índices para búsquedas frecuentes
|
||||
CREATE INDEX idx_operaciones_tipo ON operaciones(tipo);
|
||||
CREATE INDEX idx_operaciones_fecha ON operaciones(fecha DESC);
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE operaciones IS 'Eventos donde lotes se transforman, combinan o dividen';
|
||||
COMMENT ON COLUMN operaciones.tipo IS 'Tipo de operación: ingreso, despulpado, oreado, ajuste_merma, etc.';
|
||||
COMMENT ON COLUMN operaciones.meta IS 'Datos adicionales específicos del tipo de operación en formato JSON';
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- TABLA: operacion_lotes
|
||||
-- =====================================================
|
||||
-- Relación muchos a muchos entre operaciones y lotes.
|
||||
-- Define qué lotes entran (input) y salen (output) de cada operación.
|
||||
|
||||
CREATE TABLE operacion_lotes (
|
||||
operacion_id UUID NOT NULL REFERENCES operaciones(id) ON DELETE CASCADE,
|
||||
lote_id UUID NOT NULL REFERENCES lotes(id) ON DELETE CASCADE,
|
||||
rol TEXT NOT NULL, -- 'input' o 'output'
|
||||
cantidad_kg NUMERIC(10,2), -- Cantidad específica usada/producida
|
||||
|
||||
PRIMARY KEY (operacion_id, lote_id, rol),
|
||||
|
||||
CONSTRAINT operacion_lotes_rol_valido CHECK (rol IN ('input', 'output')),
|
||||
CONSTRAINT operacion_lotes_cantidad_positiva CHECK (cantidad_kg IS NULL OR cantidad_kg > 0)
|
||||
);
|
||||
|
||||
-- Índices para navegación del grafo
|
||||
CREATE INDEX idx_operacion_lotes_operacion ON operacion_lotes(operacion_id);
|
||||
CREATE INDEX idx_operacion_lotes_lote ON operacion_lotes(lote_id);
|
||||
CREATE INDEX idx_operacion_lotes_rol ON operacion_lotes(rol);
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE operacion_lotes IS 'Define qué lotes entran y salen de cada operación (grafo de trazabilidad)';
|
||||
COMMENT ON COLUMN operacion_lotes.rol IS 'input: lote usado en la operación | output: lote producido por la operación';
|
||||
COMMENT ON COLUMN operacion_lotes.cantidad_kg IS 'Cantidad en kg que participó en esta relación específica';
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- FUNCIÓN: get_trazabilidad
|
||||
-- =====================================================
|
||||
-- Obtiene el historial completo de un lote caminando el grafo hacia atrás.
|
||||
-- Retorna todos los lotes ancestros hasta llegar a los ingresos iniciales.
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_trazabilidad(lote_id_inicial UUID)
|
||||
RETURNS TABLE (
|
||||
lote_id UUID,
|
||||
codigo TEXT,
|
||||
tipo TEXT,
|
||||
cantidad_kg NUMERIC,
|
||||
operacion_id UUID,
|
||||
operacion_tipo TEXT,
|
||||
profundidad INTEGER
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH RECURSIVE trazabilidad AS (
|
||||
-- Punto de partida: el lote final
|
||||
SELECT
|
||||
l.id AS lote_id,
|
||||
l.codigo,
|
||||
l.tipo,
|
||||
l.cantidad_kg,
|
||||
ol.operacion_id,
|
||||
o.tipo AS operacion_tipo,
|
||||
0 AS profundidad
|
||||
FROM lotes l
|
||||
LEFT JOIN operacion_lotes ol ON ol.lote_id = l.id AND ol.rol = 'output'
|
||||
LEFT JOIN operaciones o ON o.id = ol.operacion_id
|
||||
WHERE l.id = lote_id_inicial
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Caminar hacia atrás: buscar lotes que fueron inputs
|
||||
SELECT
|
||||
l2.id AS lote_id,
|
||||
l2.codigo,
|
||||
l2.tipo,
|
||||
l2.cantidad_kg,
|
||||
ol2.operacion_id,
|
||||
o2.tipo AS operacion_tipo,
|
||||
t.profundidad + 1
|
||||
FROM trazabilidad t
|
||||
JOIN operacion_lotes ol_in
|
||||
ON ol_in.operacion_id = t.operacion_id
|
||||
AND ol_in.rol = 'input'
|
||||
JOIN lotes l2
|
||||
ON l2.id = ol_in.lote_id
|
||||
LEFT JOIN operacion_lotes ol2
|
||||
ON ol2.lote_id = l2.id
|
||||
AND ol2.rol = 'output'
|
||||
LEFT JOIN operaciones o2
|
||||
ON o2.id = ol2.operacion_id
|
||||
WHERE t.operacion_id IS NOT NULL -- Solo continuar si hay operación
|
||||
)
|
||||
SELECT * FROM trazabilidad
|
||||
ORDER BY profundidad, tipo, codigo;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION get_trazabilidad IS 'Obtiene el historial completo de un lote caminando el grafo hacia atrás';
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- VISTA: vista_lotes_con_origen
|
||||
-- =====================================================
|
||||
-- Vista útil que muestra cada lote con información de la operación que lo creó.
|
||||
|
||||
CREATE OR REPLACE VIEW vista_lotes_con_origen AS
|
||||
SELECT
|
||||
l.id,
|
||||
l.codigo,
|
||||
l.tipo,
|
||||
l.fecha_creado,
|
||||
l.cantidad_kg,
|
||||
l.meta,
|
||||
o.id AS operacion_id,
|
||||
o.tipo AS operacion_tipo,
|
||||
o.fecha AS operacion_fecha
|
||||
FROM lotes l
|
||||
LEFT JOIN operacion_lotes ol
|
||||
ON ol.lote_id = l.id
|
||||
AND ol.rol = 'output'
|
||||
LEFT JOIN operaciones o
|
||||
ON o.id = ol.operacion_id;
|
||||
|
||||
COMMENT ON VIEW vista_lotes_con_origen IS 'Muestra lotes con información de la operación que los creó';
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- MENSAJES DE ÉXITO
|
||||
-- =====================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✓ Esquema de trazabilidad creado exitosamente';
|
||||
RAISE NOTICE ' - Tabla lotes creada';
|
||||
RAISE NOTICE ' - Tabla operaciones creada';
|
||||
RAISE NOTICE ' - Tabla operacion_lotes creada';
|
||||
RAISE NOTICE ' - Función get_trazabilidad() creada';
|
||||
RAISE NOTICE ' - Vista vista_lotes_con_origen creada';
|
||||
END $$;
|
||||
385
nuxt4/server/database/02_seed.sql
Normal file
385
nuxt4/server/database/02_seed.sql
Normal file
@@ -0,0 +1,385 @@
|
||||
-- =====================================================
|
||||
-- DATOS DE EJEMPLO - FLUJO COMPLETO DE TRAZABILIDAD
|
||||
-- =====================================================
|
||||
-- Este script crea un ejemplo completo del flujo de café desde
|
||||
-- ingreso de uva hasta secado final, incluyendo ajustes y correcciones.
|
||||
--
|
||||
-- Flujo principal:
|
||||
-- Ingreso uva → Despulpado → Oreado → Ajuste merma → Ajuste tipo →
|
||||
-- Presecado → Reposo → Secado (mezcla con otro reposo)
|
||||
|
||||
-- Limpiar datos existentes (solo para demo/desarrollo)
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Limpiando datos de ejemplo previos...';
|
||||
END $$;
|
||||
|
||||
TRUNCATE TABLE operacion_lotes, operaciones, lotes CASCADE;
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 1: INGRESO DE UVA
|
||||
-- =====================================================
|
||||
-- Llega café uva de un productor
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
op_ingreso_id UUID;
|
||||
lote_uva_id UUID;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Creando ingreso de uva...';
|
||||
|
||||
-- Crear operación de ingreso
|
||||
INSERT INTO operaciones (tipo, fecha, meta)
|
||||
VALUES (
|
||||
'ingreso',
|
||||
NOW() - INTERVAL '10 days',
|
||||
'{"productor": "Finca El Roble", "lote_productor": "2024-11-A"}'::jsonb
|
||||
)
|
||||
RETURNING id INTO op_ingreso_id;
|
||||
|
||||
-- Crear lote de uva
|
||||
INSERT INTO lotes (codigo, tipo, fecha_creado, cantidad_kg, meta)
|
||||
VALUES (
|
||||
'UVA-001',
|
||||
'uva',
|
||||
NOW() - INTERVAL '10 days',
|
||||
2086,
|
||||
'{"variedad": "Caturra", "procedencia": "Finca El Roble"}'::jsonb
|
||||
)
|
||||
RETURNING id INTO lote_uva_id;
|
||||
|
||||
-- Relacionar: operación de ingreso → lote de uva (output)
|
||||
INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES (op_ingreso_id, lote_uva_id, 'output', 2086);
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 2: DESPULPADO
|
||||
-- =====================================================
|
||||
-- Se despulpa la uva y se obtienen tres lotes: primera, segunda y rechazos
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
op_despulpado_id UUID;
|
||||
lote_uva_id UUID;
|
||||
lote_primera_id UUID;
|
||||
lote_segunda_id UUID;
|
||||
lote_rechazos_id UUID;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Creando despulpado...';
|
||||
|
||||
-- Obtener ID del lote de uva
|
||||
SELECT id INTO lote_uva_id FROM lotes WHERE codigo = 'UVA-001';
|
||||
|
||||
-- Crear operación de despulpado
|
||||
INSERT INTO operaciones (tipo, fecha, meta)
|
||||
VALUES (
|
||||
'despulpado',
|
||||
NOW() - INTERVAL '9 days',
|
||||
'{"pila": 2, "operador": "Juan Pérez"}'::jsonb
|
||||
)
|
||||
RETURNING id INTO op_despulpado_id;
|
||||
|
||||
-- Crear lotes de salida
|
||||
INSERT INTO lotes (codigo, tipo, fecha_creado, cantidad_kg, meta)
|
||||
VALUES
|
||||
('PRIM-001', 'despulpado_primera', NOW() - INTERVAL '9 days', 1500, '{"calidad": "A"}'::jsonb),
|
||||
('SEG-001', 'despulpado_segunda', NOW() - INTERVAL '9 days', 400, '{"calidad": "B"}'::jsonb),
|
||||
('RECH-001', 'despulpado_rechazos', NOW() - INTERVAL '9 days', 150, '{"destino": "compost"}'::jsonb)
|
||||
RETURNING id INTO lote_primera_id, lote_segunda_id, lote_rechazos_id;
|
||||
|
||||
-- Relacionar: uva → despulpado (input)
|
||||
INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES (op_despulpado_id, lote_uva_id, 'input', 2086);
|
||||
|
||||
-- Relacionar: despulpado → primera, segunda, rechazos (outputs)
|
||||
INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES
|
||||
(op_despulpado_id, lote_primera_id, 'output', 1500),
|
||||
(op_despulpado_id, lote_segunda_id, 'output', 400),
|
||||
(op_despulpado_id, lote_rechazos_id, 'output', 150);
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 3: OREADO (con error en registro)
|
||||
-- =====================================================
|
||||
-- Se orea el lote de primera calidad, pero se registra mal la cantidad
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
op_oreado_id UUID;
|
||||
lote_primera_id UUID;
|
||||
lote_oreado_id UUID;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Creando oreado (con error de registro)...';
|
||||
|
||||
-- Obtener ID del lote de primera
|
||||
SELECT id INTO lote_primera_id FROM lotes WHERE codigo = 'PRIM-001';
|
||||
|
||||
-- Crear operación de oreado
|
||||
INSERT INTO operaciones (tipo, fecha, meta)
|
||||
VALUES (
|
||||
'oreado',
|
||||
NOW() - INTERVAL '8 days',
|
||||
'{"patio": 1, "inicio": "06:00", "fin": "18:00"}'::jsonb
|
||||
)
|
||||
RETURNING id INTO op_oreado_id;
|
||||
|
||||
-- Crear lote oreado (cantidad mal registrada: debería ser menos por merma)
|
||||
INSERT INTO lotes (codigo, tipo, fecha_creado, cantidad_kg, meta)
|
||||
VALUES (
|
||||
'ORE-001',
|
||||
'oreado',
|
||||
NOW() - INTERVAL '8 days',
|
||||
1500, -- Error: debería ser 1480 kg
|
||||
'{"humedad_inicial": 55, "humedad_final": 45}'::jsonb
|
||||
)
|
||||
RETURNING id INTO lote_oreado_id;
|
||||
|
||||
-- Relacionar
|
||||
INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES
|
||||
(op_oreado_id, lote_primera_id, 'input', 1500),
|
||||
(op_oreado_id, lote_oreado_id, 'output', 1500);
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 4: AJUSTE DE MERMA
|
||||
-- =====================================================
|
||||
-- Se corrige la cantidad: realmente hubo merma de 20 kg
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
op_ajuste_id UUID;
|
||||
lote_oreado_id UUID;
|
||||
lote_oreado_corr_id UUID;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Aplicando ajuste de merma...';
|
||||
|
||||
-- Obtener ID del lote oreado
|
||||
SELECT id INTO lote_oreado_id FROM lotes WHERE codigo = 'ORE-001';
|
||||
|
||||
-- Crear operación de ajuste
|
||||
INSERT INTO operaciones (tipo, fecha, meta)
|
||||
VALUES (
|
||||
'ajuste_merma',
|
||||
NOW() - INTERVAL '7 days',
|
||||
'{"motivo": "Corrección de pesaje", "merma_kg": 20}'::jsonb
|
||||
)
|
||||
RETURNING id INTO op_ajuste_id;
|
||||
|
||||
-- Crear lote corregido
|
||||
INSERT INTO lotes (codigo, tipo, fecha_creado, cantidad_kg, meta)
|
||||
VALUES (
|
||||
'ORE-001A',
|
||||
'oreado',
|
||||
NOW() - INTERVAL '7 days',
|
||||
1480,
|
||||
'{"humedad": 45, "corregido": true}'::jsonb
|
||||
)
|
||||
RETURNING id INTO lote_oreado_corr_id;
|
||||
|
||||
-- Relacionar
|
||||
INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES
|
||||
(op_ajuste_id, lote_oreado_id, 'input', 1500),
|
||||
(op_ajuste_id, lote_oreado_corr_id, 'output', 1480);
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 5: AJUSTE DE TIPO
|
||||
-- =====================================================
|
||||
-- Se descubre que en realidad era presecado, no oreado
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
op_ajuste_tipo_id UUID;
|
||||
lote_oreado_corr_id UUID;
|
||||
lote_presecado_id UUID;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Aplicando ajuste de tipo...';
|
||||
|
||||
-- Obtener ID del lote oreado corregido
|
||||
SELECT id INTO lote_oreado_corr_id FROM lotes WHERE codigo = 'ORE-001A';
|
||||
|
||||
-- Crear operación de ajuste de tipo
|
||||
INSERT INTO operaciones (tipo, fecha, meta)
|
||||
VALUES (
|
||||
'ajuste_tipo',
|
||||
NOW() - INTERVAL '6 days',
|
||||
'{"motivo": "Revisión de proceso", "tipo_anterior": "oreado", "tipo_nuevo": "presecado"}'::jsonb
|
||||
)
|
||||
RETURNING id INTO op_ajuste_tipo_id;
|
||||
|
||||
-- Crear lote con tipo correcto
|
||||
INSERT INTO lotes (codigo, tipo, fecha_creado, cantidad_kg, meta)
|
||||
VALUES (
|
||||
'PRE-001',
|
||||
'presecado',
|
||||
NOW() - INTERVAL '6 days',
|
||||
1480,
|
||||
'{"humedad": 45, "tipo_corregido": true}'::jsonb
|
||||
)
|
||||
RETURNING id INTO lote_presecado_id;
|
||||
|
||||
-- Relacionar
|
||||
INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES
|
||||
(op_ajuste_tipo_id, lote_oreado_corr_id, 'input', 1480),
|
||||
(op_ajuste_tipo_id, lote_presecado_id, 'output', 1480);
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 6: REPOSO
|
||||
-- =====================================================
|
||||
-- El presecado pasa a reposo
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
op_reposo_id UUID;
|
||||
lote_presecado_id UUID;
|
||||
lote_reposo_id UUID;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Creando reposo...';
|
||||
|
||||
-- Obtener ID del lote presecado
|
||||
SELECT id INTO lote_presecado_id FROM lotes WHERE codigo = 'PRE-001';
|
||||
|
||||
-- Crear operación de reposo
|
||||
INSERT INTO operaciones (tipo, fecha, meta)
|
||||
VALUES (
|
||||
'reposo',
|
||||
NOW() - INTERVAL '5 days',
|
||||
'{"area": "Bodega A", "dias_reposo": 3}'::jsonb
|
||||
)
|
||||
RETURNING id INTO op_reposo_id;
|
||||
|
||||
-- Crear lote en reposo
|
||||
INSERT INTO lotes (codigo, tipo, fecha_creado, cantidad_kg, meta)
|
||||
VALUES (
|
||||
'REP-001',
|
||||
'reposo',
|
||||
NOW() - INTERVAL '5 days',
|
||||
1480,
|
||||
'{"humedad": 43}'::jsonb
|
||||
)
|
||||
RETURNING id INTO lote_reposo_id;
|
||||
|
||||
-- Relacionar
|
||||
INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES
|
||||
(op_reposo_id, lote_presecado_id, 'input', 1480),
|
||||
(op_reposo_id, lote_reposo_id, 'output', 1480);
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 7: SEGUNDO FLUJO (para mezclar en secado)
|
||||
-- =====================================================
|
||||
-- Crear otro lote de reposo de un proceso paralelo
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
lote_reposo2_id UUID;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Creando segundo lote de reposo (proceso paralelo)...';
|
||||
|
||||
-- Crear lote de reposo directamente (proceso simplificado)
|
||||
INSERT INTO lotes (codigo, tipo, fecha_creado, cantidad_kg, meta)
|
||||
VALUES (
|
||||
'REP-002',
|
||||
'reposo',
|
||||
NOW() - INTERVAL '4 days',
|
||||
520,
|
||||
'{"humedad": 42, "origen": "Proceso B"}'::jsonb
|
||||
)
|
||||
RETURNING id INTO lote_reposo2_id;
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- PASO 8: SECADO (MEZCLA DE DOS REPOSOS)
|
||||
-- =====================================================
|
||||
-- Se mezclan REP-001 y REP-002 para el secado final
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
op_secado_id UUID;
|
||||
lote_reposo1_id UUID;
|
||||
lote_reposo2_id UUID;
|
||||
lote_secado_id UUID;
|
||||
BEGIN
|
||||
RAISE NOTICE 'Creando secado (mezcla de reposos)...';
|
||||
|
||||
-- Obtener IDs de los lotes de reposo
|
||||
SELECT id INTO lote_reposo1_id FROM lotes WHERE codigo = 'REP-001';
|
||||
SELECT id INTO lote_reposo2_id FROM lotes WHERE codigo = 'REP-002';
|
||||
|
||||
-- Crear operación de secado
|
||||
INSERT INTO operaciones (tipo, fecha, meta)
|
||||
VALUES (
|
||||
'secado',
|
||||
NOW() - INTERVAL '2 days',
|
||||
'{"secadora": "Solar 1", "temperatura_max": 45, "dias": 7}'::jsonb
|
||||
)
|
||||
RETURNING id INTO op_secado_id;
|
||||
|
||||
-- Crear lote secado final
|
||||
INSERT INTO lotes (codigo, tipo, fecha_creado, cantidad_kg, meta)
|
||||
VALUES (
|
||||
'SEC-001',
|
||||
'secado',
|
||||
NOW() - INTERVAL '2 days',
|
||||
2000,
|
||||
'{"humedad_final": 11.5, "calidad": "Pergamino seco"}'::jsonb
|
||||
)
|
||||
RETURNING id INTO lote_secado_id;
|
||||
|
||||
-- Relacionar: dos reposos como input
|
||||
INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES
|
||||
(op_secado_id, lote_reposo1_id, 'input', 1480),
|
||||
(op_secado_id, lote_reposo2_id, 'input', 520);
|
||||
|
||||
-- Relacionar: secado como output
|
||||
INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES (op_secado_id, lote_secado_id, 'output', 2000);
|
||||
|
||||
END $$;
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- RESUMEN DE DATOS CREADOS
|
||||
-- =====================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
total_lotes INTEGER;
|
||||
total_operaciones INTEGER;
|
||||
total_relaciones INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO total_lotes FROM lotes;
|
||||
SELECT COUNT(*) INTO total_operaciones FROM operaciones;
|
||||
SELECT COUNT(*) INTO total_relaciones FROM operacion_lotes;
|
||||
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '✓ Datos de ejemplo creados exitosamente';
|
||||
RAISE NOTICE ' - % lotes creados', total_lotes;
|
||||
RAISE NOTICE ' - % operaciones creadas', total_operaciones;
|
||||
RAISE NOTICE ' - % relaciones lote-operación creadas', total_relaciones;
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'Lote final: SEC-001 (Secado)';
|
||||
RAISE NOTICE 'Puedes consultar su trazabilidad completa con:';
|
||||
RAISE NOTICE ' SELECT * FROM get_trazabilidad((SELECT id FROM lotes WHERE codigo = ''SEC-001''));';
|
||||
END $$;
|
||||
338
nuxt4/server/database/README.md
Normal file
338
nuxt4/server/database/README.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Database - Scripts SQL
|
||||
|
||||
Este directorio contiene los scripts SQL para inicializar y gestionar la base de datos PostgreSQL del sistema de trazabilidad.
|
||||
|
||||
---
|
||||
|
||||
## Archivos
|
||||
|
||||
### `01_schema.sql`
|
||||
Crea el esquema completo de la base de datos:
|
||||
- Tablas: `lotes`, `operaciones`, `operacion_lotes`
|
||||
- Índices para optimización
|
||||
- Función `get_trazabilidad()` para queries recursivas
|
||||
- Vista `vista_lotes_con_origen`
|
||||
- Constraints y validaciones
|
||||
|
||||
### `02_seed.sql`
|
||||
Datos de ejemplo que representan un flujo completo:
|
||||
- Ingreso de uva (2086 kg)
|
||||
- Despulpado → primera, segunda, rechazos
|
||||
- Oreado (con error de registro)
|
||||
- Ajuste de merma (1500 → 1480 kg)
|
||||
- Ajuste de tipo (oreado → presecado)
|
||||
- Reposo
|
||||
- Secado final (mezcla de 2 lotes = 2000 kg)
|
||||
|
||||
---
|
||||
|
||||
## Ejecución Automática
|
||||
|
||||
Cuando usas **Docker Compose**, estos scripts se ejecutan automáticamente al iniciar PostgreSQL por primera vez gracias al montaje:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./nuxt4/server/database:/docker-entrypoint-initdb.d:ro
|
||||
```
|
||||
|
||||
PostgreSQL ejecuta todos los archivos `.sql` en orden alfabético dentro de `/docker-entrypoint-initdb.d/`.
|
||||
|
||||
**Orden de ejecución:**
|
||||
1. `01_schema.sql` - Crea estructura
|
||||
2. `02_seed.sql` - Inserta datos de ejemplo
|
||||
|
||||
---
|
||||
|
||||
## Ejecución Manual
|
||||
|
||||
### Opción 1: Desde el contenedor Docker
|
||||
|
||||
```bash
|
||||
# Conectarse al contenedor
|
||||
docker exec -it seguidorDeLotes-postgres psql -U seguidor -d seguidor_lotes
|
||||
|
||||
# Dentro de psql, ejecutar:
|
||||
\i /docker-entrypoint-initdb.d/01_schema.sql
|
||||
\i /docker-entrypoint-initdb.d/02_seed.sql
|
||||
```
|
||||
|
||||
### Opción 2: Desde tu máquina local
|
||||
|
||||
```bash
|
||||
# Asegúrate de tener psql instalado
|
||||
psql -h localhost -U seguidor -d seguidor_lotes -f 01_schema.sql
|
||||
psql -h localhost -U seguidor -d seguidor_lotes -f 02_seed.sql
|
||||
```
|
||||
|
||||
### Opción 3: Copiar y pegar en pgAdmin o DBeaver
|
||||
|
||||
Abre los archivos `.sql` en tu cliente SQL favorito y ejecútalos directamente.
|
||||
|
||||
---
|
||||
|
||||
## Reiniciar la Base de Datos
|
||||
|
||||
Si necesitas empezar desde cero:
|
||||
|
||||
```bash
|
||||
# Detener contenedores y eliminar volúmenes
|
||||
docker-compose down -v
|
||||
|
||||
# Volver a iniciar (ejecutará scripts automáticamente)
|
||||
docker-compose up -d
|
||||
|
||||
# Ver logs para confirmar
|
||||
docker logs -f seguidorDeLotes-postgres
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verificar que todo está correcto
|
||||
|
||||
### 1. Conectarse a la base de datos
|
||||
|
||||
```bash
|
||||
docker exec -it seguidorDeLotes-postgres psql -U seguidor -d seguidor_lotes
|
||||
```
|
||||
|
||||
### 2. Listar tablas
|
||||
|
||||
```sql
|
||||
\dt
|
||||
```
|
||||
|
||||
**Deberías ver:**
|
||||
```
|
||||
List of relations
|
||||
Schema | Name | Type | Owner
|
||||
--------+------------------+-------+----------
|
||||
public | lotes | table | seguidor
|
||||
public | operacion_lotes | table | seguidor
|
||||
public | operaciones | table | seguidor
|
||||
```
|
||||
|
||||
### 3. Contar registros
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM lotes) as total_lotes,
|
||||
(SELECT COUNT(*) FROM operaciones) as total_operaciones,
|
||||
(SELECT COUNT(*) FROM operacion_lotes) as total_relaciones;
|
||||
```
|
||||
|
||||
**Deberías ver algo como:**
|
||||
```
|
||||
total_lotes | total_operaciones | total_relaciones
|
||||
-------------+-------------------+------------------
|
||||
9 | 8 | 16
|
||||
```
|
||||
|
||||
### 4. Ver lotes creados
|
||||
|
||||
```sql
|
||||
SELECT codigo, tipo, cantidad_kg FROM lotes ORDER BY fecha_creado;
|
||||
```
|
||||
|
||||
**Deberías ver:**
|
||||
```
|
||||
codigo | tipo | cantidad_kg
|
||||
-----------+----------------------+-------------
|
||||
UVA-001 | uva | 2086.00
|
||||
PRIM-001 | despulpado_primera | 1500.00
|
||||
SEG-001 | despulpado_segunda | 400.00
|
||||
RECH-001 | despulpado_rechazos | 150.00
|
||||
ORE-001 | oreado | 1500.00
|
||||
ORE-001A | oreado | 1480.00
|
||||
PRE-001 | presecado | 1480.00
|
||||
REP-001 | reposo | 1480.00
|
||||
REP-002 | reposo | 520.00
|
||||
SEC-001 | secado | 2000.00
|
||||
```
|
||||
|
||||
### 5. Probar la función de trazabilidad
|
||||
|
||||
```sql
|
||||
SELECT * FROM get_trazabilidad(
|
||||
(SELECT id FROM lotes WHERE codigo = 'SEC-001')
|
||||
);
|
||||
```
|
||||
|
||||
**Deberías ver:** Todo el historial del lote `SEC-001` desde el ingreso de uva.
|
||||
|
||||
---
|
||||
|
||||
## Estructura de las Tablas
|
||||
|
||||
### Tabla `lotes`
|
||||
|
||||
```sql
|
||||
CREATE TABLE lotes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
codigo TEXT UNIQUE,
|
||||
tipo TEXT NOT NULL,
|
||||
fecha_creado TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
lugar_id INTEGER,
|
||||
cantidad_kg NUMERIC(10,2),
|
||||
meta JSONB
|
||||
);
|
||||
```
|
||||
|
||||
### Tabla `operaciones`
|
||||
|
||||
```sql
|
||||
CREATE TABLE operaciones (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tipo TEXT NOT NULL,
|
||||
fecha TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
lugar_id INTEGER,
|
||||
meta JSONB
|
||||
);
|
||||
```
|
||||
|
||||
### Tabla `operacion_lotes`
|
||||
|
||||
```sql
|
||||
CREATE TABLE operacion_lotes (
|
||||
operacion_id UUID NOT NULL REFERENCES operaciones(id) ON DELETE CASCADE,
|
||||
lote_id UUID NOT NULL REFERENCES lotes(id) ON DELETE CASCADE,
|
||||
rol TEXT NOT NULL CHECK (rol IN ('input', 'output')),
|
||||
cantidad_kg NUMERIC(10,2),
|
||||
PRIMARY KEY (operacion_id, lote_id, rol)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Queries Útiles
|
||||
|
||||
### Ver todas las operaciones de un lote
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
o.tipo AS operacion,
|
||||
o.fecha,
|
||||
ol.rol,
|
||||
ol.cantidad_kg
|
||||
FROM operacion_lotes ol
|
||||
JOIN operaciones o ON o.id = ol.operacion_id
|
||||
WHERE ol.lote_id = (SELECT id FROM lotes WHERE codigo = 'SEC-001')
|
||||
ORDER BY o.fecha;
|
||||
```
|
||||
|
||||
### Ver lotes que se usaron para crear un lote específico (inputs directos)
|
||||
|
||||
```sql
|
||||
-- Inputs directos del lote SEC-001
|
||||
SELECT
|
||||
l.codigo,
|
||||
l.tipo,
|
||||
l.cantidad_kg,
|
||||
o.tipo AS operacion_tipo
|
||||
FROM lotes l
|
||||
JOIN operacion_lotes ol_in ON ol_in.lote_id = l.id
|
||||
JOIN operacion_lotes ol_out ON ol_out.operacion_id = ol_in.operacion_id
|
||||
JOIN operaciones o ON o.id = ol_out.operacion_id
|
||||
WHERE ol_out.lote_id = (SELECT id FROM lotes WHERE codigo = 'SEC-001')
|
||||
AND ol_out.rol = 'output'
|
||||
AND ol_in.rol = 'input';
|
||||
```
|
||||
|
||||
### Ver estadísticas de un período
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
tipo,
|
||||
COUNT(*) as total,
|
||||
SUM(cantidad_kg) as kg_totales
|
||||
FROM operaciones
|
||||
WHERE fecha >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY tipo
|
||||
ORDER BY total DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migraciones Futuras
|
||||
|
||||
Cuando necesites hacer cambios al esquema en producción:
|
||||
|
||||
1. **Crear archivo de migración** (ej: `03_add_lugares_table.sql`)
|
||||
2. **NO modificar** `01_schema.sql` ni `02_seed.sql` directamente
|
||||
3. **Aplicar migración manualmente** en producción
|
||||
|
||||
Ejemplo de migración:
|
||||
|
||||
```sql
|
||||
-- 03_add_lugares_table.sql
|
||||
CREATE TABLE IF NOT EXISTS lugares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nombre TEXT NOT NULL,
|
||||
tipo TEXT, -- patio, pila, bodega, etc.
|
||||
capacidad_kg NUMERIC
|
||||
);
|
||||
|
||||
-- Agregar foreign key a lotes
|
||||
ALTER TABLE lotes
|
||||
ADD CONSTRAINT fk_lotes_lugar
|
||||
FOREIGN KEY (lugar_id) REFERENCES lugares(id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup y Restore
|
||||
|
||||
### Hacer backup
|
||||
|
||||
```bash
|
||||
docker exec seguidorDeLotes-postgres pg_dump -U seguidor seguidor_lotes > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Restaurar backup
|
||||
|
||||
```bash
|
||||
cat backup_20251121.sql | docker exec -i seguidorDeLotes-postgres psql -U seguidor -d seguidor_lotes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "relation lotes does not exist"
|
||||
|
||||
Los scripts no se ejecutaron. Verificar:
|
||||
```bash
|
||||
docker logs seguidorDeLotes-postgres
|
||||
```
|
||||
|
||||
Si ves errores, eliminar volumen y reiniciar:
|
||||
```bash
|
||||
docker-compose down -v
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### "permission denied for schema public"
|
||||
|
||||
Problema de permisos. Conectarse como superuser:
|
||||
```bash
|
||||
docker exec -it seguidorDeLotes-postgres psql -U postgres -d seguidor_lotes
|
||||
|
||||
-- Dar permisos
|
||||
GRANT ALL ON SCHEMA public TO seguidor;
|
||||
GRANT ALL ON ALL TABLES IN SCHEMA public TO seguidor;
|
||||
```
|
||||
|
||||
### Los datos de ejemplo se duplican
|
||||
|
||||
`02_seed.sql` hace `TRUNCATE` al inicio. Si no quieres perder datos, comenta esa línea.
|
||||
|
||||
---
|
||||
|
||||
## Referencias
|
||||
|
||||
- [PostgreSQL JSON Functions](https://www.postgresql.org/docs/current/functions-json.html)
|
||||
- [Recursive Queries (CTE)](https://www.postgresql.org/docs/current/queries-with.html)
|
||||
- [Docker Init Scripts](https://hub.docker.com/_/postgres)
|
||||
|
||||
---
|
||||
|
||||
**Última actualización**: 2025-11-21
|
||||
106
nuxt4/server/utils/db.ts
Normal file
106
nuxt4/server/utils/db.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
let pool: pg.Pool | null = null
|
||||
|
||||
/**
|
||||
* Obtiene o crea el pool de conexiones a PostgreSQL.
|
||||
* Usa variables de entorno para la configuración.
|
||||
*/
|
||||
export function getPool(): pg.Pool {
|
||||
if (!pool) {
|
||||
const config = {
|
||||
user: process.env.POSTGRES_USER || 'seguidor',
|
||||
password: process.env.POSTGRES_PASSWORD || 'seguidor_password',
|
||||
database: process.env.POSTGRES_DB || 'seguidor_lotes',
|
||||
host: process.env.POSTGRES_HOST || 'postgres',
|
||||
port: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
max: 20, // máximo de conexiones en el pool
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
}
|
||||
|
||||
pool = new Pool(config)
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Error inesperado en el pool de PostgreSQL:', err)
|
||||
})
|
||||
|
||||
pool.on('connect', () => {
|
||||
console.log('Nueva conexión establecida con PostgreSQL')
|
||||
})
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta una query SQL con parámetros.
|
||||
* Wrapper seguro para evitar inyección SQL.
|
||||
*
|
||||
* @param text - Query SQL con placeholders $1, $2, etc.
|
||||
* @param params - Parámetros para la query
|
||||
* @returns Resultado de la query
|
||||
*/
|
||||
export async function query<T = any>(
|
||||
text: string,
|
||||
params?: any[]
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
const pool = getPool()
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
const result = await pool.query<T>(text, params)
|
||||
const duration = Date.now() - start
|
||||
|
||||
// Log solo en desarrollo
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('Query ejecutada:', { text, duration: `${duration}ms`, rows: result.rowCount })
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Error ejecutando query:', { text, params, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un cliente del pool para ejecutar transacciones.
|
||||
* IMPORTANTE: Debes llamar a client.release() al terminar.
|
||||
*
|
||||
* @returns Cliente de PostgreSQL
|
||||
*/
|
||||
export async function getClient(): Promise<pg.PoolClient> {
|
||||
const pool = getPool()
|
||||
return await pool.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cierra el pool de conexiones.
|
||||
* Útil para tests o shutdown graceful.
|
||||
*/
|
||||
export async function closePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end()
|
||||
pool = null
|
||||
console.log('Pool de PostgreSQL cerrado')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica que la conexión a la base de datos esté funcionando.
|
||||
* Útil para health checks.
|
||||
*
|
||||
* @returns true si la conexión está OK, false en caso contrario
|
||||
*/
|
||||
export async function checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const result = await query('SELECT NOW() as now')
|
||||
return result.rows.length > 0
|
||||
} catch (error) {
|
||||
console.error('Error verificando conexión a PostgreSQL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
441
nuxt4/server/utils/queries.ts
Normal file
441
nuxt4/server/utils/queries.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { query, getClient } from './db'
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
// =====================================================
|
||||
// TIPOS TYPESCRIPT
|
||||
// =====================================================
|
||||
|
||||
export interface Lote {
|
||||
id: string
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
fecha_creado: Date
|
||||
lugar_id: number | null
|
||||
cantidad_kg: number | null
|
||||
meta: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface Operacion {
|
||||
id: string
|
||||
tipo: string
|
||||
fecha: Date
|
||||
lugar_id: number | null
|
||||
meta: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface OperacionLote {
|
||||
operacion_id: string
|
||||
lote_id: string
|
||||
rol: 'input' | 'output'
|
||||
cantidad_kg: number | null
|
||||
}
|
||||
|
||||
export interface TrazabilidadRow {
|
||||
lote_id: string
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
cantidad_kg: number | null
|
||||
operacion_id: string | null
|
||||
operacion_tipo: string | null
|
||||
profundidad: number
|
||||
}
|
||||
|
||||
export interface LoteConOrigen extends Lote {
|
||||
operacion_id: string | null
|
||||
operacion_tipo: string | null
|
||||
operacion_fecha: Date | null
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERIES PARA LOTES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todos los lotes con filtros opcionales
|
||||
*/
|
||||
export async function getLotes(filtros?: {
|
||||
tipo?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<Lote[]> {
|
||||
let sql = 'SELECT * FROM lotes 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<Lote>(sql, params)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un lote por su ID
|
||||
*/
|
||||
export async function getLoteById(id: string): Promise<Lote | null> {
|
||||
const result = await query<Lote>(
|
||||
'SELECT * FROM lotes WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un lote por su código
|
||||
*/
|
||||
export async function getLoteByCodigo(codigo: string): Promise<Lote | null> {
|
||||
const result = await query<Lote>(
|
||||
'SELECT * FROM lotes WHERE codigo = $1',
|
||||
[codigo]
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuevo lote
|
||||
*/
|
||||
export async function createLote(data: {
|
||||
codigo?: string
|
||||
tipo: string
|
||||
cantidad_kg?: number
|
||||
lugar_id?: number
|
||||
meta?: Record<string, any>
|
||||
}): Promise<Lote> {
|
||||
const result = await query<Lote>(
|
||||
`INSERT INTO lotes (codigo, tipo, cantidad_kg, lugar_id, meta)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.codigo || null,
|
||||
data.tipo,
|
||||
data.cantidad_kg || null,
|
||||
data.lugar_id || null,
|
||||
data.meta ? JSON.stringify(data.meta) : null,
|
||||
]
|
||||
)
|
||||
return result.rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un lote existente
|
||||
*/
|
||||
export async function updateLote(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
cantidad_kg: number | null
|
||||
lugar_id: number | null
|
||||
meta: Record<string, any> | null
|
||||
}>
|
||||
): Promise<Lote | null> {
|
||||
const fields: string[] = []
|
||||
const params: any[] = []
|
||||
let paramCount = 1
|
||||
|
||||
if (data.codigo !== undefined) {
|
||||
fields.push(`codigo = $${paramCount}`)
|
||||
params.push(data.codigo)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (data.tipo !== undefined) {
|
||||
fields.push(`tipo = $${paramCount}`)
|
||||
params.push(data.tipo)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (data.cantidad_kg !== undefined) {
|
||||
fields.push(`cantidad_kg = $${paramCount}`)
|
||||
params.push(data.cantidad_kg)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (data.lugar_id !== undefined) {
|
||||
fields.push(`lugar_id = $${paramCount}`)
|
||||
params.push(data.lugar_id)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (data.meta !== undefined) {
|
||||
fields.push(`meta = $${paramCount}`)
|
||||
params.push(data.meta ? JSON.stringify(data.meta) : null)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getLoteById(id)
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
|
||||
const sql = `
|
||||
UPDATE lotes
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramCount}
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
const result = await query<Lote>(sql, params)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un lote
|
||||
* CUIDADO: Solo debe usarse en casos excepcionales. Preferir marcar como inactivo.
|
||||
*/
|
||||
export async function deleteLote(id: string): Promise<boolean> {
|
||||
const result = await query(
|
||||
'DELETE FROM lotes WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los lotes con información de su operación de origen
|
||||
*/
|
||||
export async function getLotesConOrigen(): Promise<LoteConOrigen[]> {
|
||||
const result = await query<LoteConOrigen>(`
|
||||
SELECT * FROM vista_lotes_con_origen
|
||||
ORDER BY fecha_creado DESC
|
||||
`)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERIES PARA OPERACIONES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todas las operaciones con filtros opcionales
|
||||
*/
|
||||
export async function getOperaciones(filtros?: {
|
||||
tipo?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<Operacion[]> {
|
||||
let sql = 'SELECT * FROM operaciones 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 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<Operacion>(sql, params)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene una operación por su ID
|
||||
*/
|
||||
export async function getOperacionById(id: string): Promise<Operacion | null> {
|
||||
const result = await query<Operacion>(
|
||||
'SELECT * FROM operaciones WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene una operación con sus lotes relacionados (inputs y outputs)
|
||||
*/
|
||||
export async function getOperacionConLotes(id: string): Promise<{
|
||||
operacion: Operacion
|
||||
inputs: Array<Lote & { cantidad_kg_usada: number }>
|
||||
outputs: Array<Lote & { cantidad_kg_producida: number }>
|
||||
} | null> {
|
||||
const operacion = await getOperacionById(id)
|
||||
if (!operacion) return null
|
||||
|
||||
// Obtener lotes de entrada
|
||||
const inputsResult = await query<Lote & { cantidad_kg_usada: number }>(`
|
||||
SELECT l.*, ol.cantidad_kg as cantidad_kg_usada
|
||||
FROM lotes l
|
||||
JOIN operacion_lotes ol ON ol.lote_id = l.id
|
||||
WHERE ol.operacion_id = $1 AND ol.rol = 'input'
|
||||
ORDER BY l.codigo
|
||||
`, [id])
|
||||
|
||||
// Obtener lotes de salida
|
||||
const outputsResult = await query<Lote & { cantidad_kg_producida: number }>(`
|
||||
SELECT l.*, ol.cantidad_kg as cantidad_kg_producida
|
||||
FROM lotes l
|
||||
JOIN operacion_lotes ol ON ol.lote_id = l.id
|
||||
WHERE ol.operacion_id = $1 AND ol.rol = 'output'
|
||||
ORDER BY l.codigo
|
||||
`, [id])
|
||||
|
||||
return {
|
||||
operacion,
|
||||
inputs: inputsResult.rows,
|
||||
outputs: outputsResult.rows,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una nueva operación con sus lotes relacionados (TRANSACCIÓN)
|
||||
* Esta función asegura que la operación y sus relaciones se creen atómicamente.
|
||||
*/
|
||||
export async function createOperacion(data: {
|
||||
tipo: string
|
||||
fecha?: Date
|
||||
lugar_id?: number
|
||||
meta?: Record<string, any>
|
||||
inputs: Array<{ lote_id: string; cantidad_kg?: number }>
|
||||
outputs: Array<{ codigo?: string; tipo: string; cantidad_kg?: number; meta?: Record<string, any> }>
|
||||
}): Promise<{
|
||||
operacion: Operacion
|
||||
lotes_creados: Lote[]
|
||||
}> {
|
||||
const client = await getClient()
|
||||
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
|
||||
// 1. Crear la operación
|
||||
const operacionResult = await client.query<Operacion>(
|
||||
`INSERT INTO operaciones (tipo, fecha, lugar_id, meta)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.tipo,
|
||||
data.fecha || new Date(),
|
||||
data.lugar_id || null,
|
||||
data.meta ? JSON.stringify(data.meta) : null,
|
||||
]
|
||||
)
|
||||
const operacion = operacionResult.rows[0]
|
||||
|
||||
// 2. Relacionar lotes de entrada
|
||||
for (const input of data.inputs) {
|
||||
await client.query(
|
||||
`INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES ($1, $2, 'input', $3)`,
|
||||
[operacion.id, input.lote_id, input.cantidad_kg || null]
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Crear y relacionar lotes de salida
|
||||
const lotesCreados: Lote[] = []
|
||||
for (const output of data.outputs) {
|
||||
const loteResult = await client.query<Lote>(
|
||||
`INSERT INTO lotes (codigo, tipo, cantidad_kg, meta)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
output.codigo || null,
|
||||
output.tipo,
|
||||
output.cantidad_kg || null,
|
||||
output.meta ? JSON.stringify(output.meta) : null,
|
||||
]
|
||||
)
|
||||
const lote = loteResult.rows[0]
|
||||
lotesCreados.push(lote)
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES ($1, $2, 'output', $3)`,
|
||||
[operacion.id, lote.id, output.cantidad_kg || null]
|
||||
)
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
|
||||
return {
|
||||
operacion,
|
||||
lotes_creados: lotesCreados,
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK')
|
||||
throw error
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERIES PARA OPERACION_LOTES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todas las relaciones lote-operación para una operación específica
|
||||
*/
|
||||
export async function getOperacionLotes(operacionId: string): Promise<OperacionLote[]> {
|
||||
const result = await query<OperacionLote>(
|
||||
`SELECT * FROM operacion_lotes WHERE operacion_id = $1 ORDER BY rol`,
|
||||
[operacionId]
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERIES DE TRAZABILIDAD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene el historial completo de un lote usando la función recursiva de PostgreSQL
|
||||
*/
|
||||
export async function getTrazabilidad(loteId: string): Promise<TrazabilidadRow[]> {
|
||||
const result = await query<TrazabilidadRow>(
|
||||
'SELECT * FROM get_trazabilidad($1)',
|
||||
[loteId]
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estadísticas de un lote (cuántos ancestros tiene, profundidad máxima, etc.)
|
||||
*/
|
||||
export async function getEstadisticasLote(loteId: string): Promise<{
|
||||
total_ancestros: number
|
||||
profundidad_maxima: number
|
||||
kg_iniciales: number | null
|
||||
}> {
|
||||
const trazabilidad = await getTrazabilidad(loteId)
|
||||
|
||||
const profundidadMaxima = Math.max(...trazabilidad.map(t => t.profundidad))
|
||||
const totalAncestros = trazabilidad.length - 1 // -1 para no contar el lote mismo
|
||||
|
||||
// Buscar lotes de ingreso (profundidad máxima)
|
||||
const ingresos = trazabilidad.filter(t => t.profundidad === profundidadMaxima)
|
||||
const kgIniciales = ingresos.reduce((sum, t) => sum + (t.cantidad_kg || 0), 0)
|
||||
|
||||
return {
|
||||
total_ancestros: totalAncestros,
|
||||
profundidad_maxima: profundidadMaxima,
|
||||
kg_iniciales: kgIniciales,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user