diff --git a/PLAN_TRAZABILIDAD.md b/PLAN_TRAZABILIDAD.md new file mode 100644 index 0000000..c79e47f --- /dev/null +++ b/PLAN_TRAZABILIDAD.md @@ -0,0 +1,501 @@ +# Plan de Trazabilidad de Lotes - Seguidor de Lotes + +## Descripción General + +El **Sistema de Trazabilidad de Lotes** es una aplicación web diseñada para rastrear el flujo completo del café desde el ingreso de uva hasta el secado final. Implementa un **modelo de grafo** que permite representar operaciones complejas como: + +- **División**: Un lote se divide en varios (ej: despulpado → primera, segunda, rechazos) +- **Combinación**: Varios lotes se mezclan en uno (ej: varios reposos → un secado) +- **Transformación**: Un lote cambia de estado (ej: oreado → presecado) +- **Ajustes**: Correcciones de merma, cantidad o tipo sin alterar el historial + +--- + +## Arquitectura del Sistema + +### Stack Tecnológico + +- **Frontend**: Nuxt 4 + Nuxt UI + Vue 3 +- **Backend**: Nuxt Server API Routes +- **Base de Datos**: PostgreSQL 16 +- **Autenticación**: Authentik Proxy Outpost +- **Despliegue**: Docker + Traefik + Gitea Actions + +### Estructura del Proyecto + +``` +/home/draganel/repos/seguidorDeLotes/ +├── nuxt4/ +│ ├── app/ +│ │ ├── components/ +│ │ │ ├── lotes/ +│ │ │ │ ├── LotesTable.vue +│ │ │ │ ├── LoteForm.vue +│ │ │ │ ├── LoteCard.vue +│ │ │ │ └── TrazabilidadTree.vue +│ │ │ └── operaciones/ +│ │ │ ├── OperacionesTable.vue +│ │ │ └── OperacionForm.vue +│ │ ├── composables/ +│ │ │ └── useLotes.ts +│ │ └── app.vue +│ ├── server/ +│ │ ├── api/ +│ │ │ ├── lotes/ +│ │ │ │ ├── index.get.ts +│ │ │ │ ├── index.post.ts +│ │ │ │ ├── [id].get.ts +│ │ │ │ ├── [id].patch.ts +│ │ │ │ ├── [id].delete.ts +│ │ │ │ └── [id]/ +│ │ │ │ └── trazabilidad.get.ts +│ │ │ └── operaciones/ +│ │ │ ├── index.get.ts +│ │ │ ├── index.post.ts +│ │ │ └── [id].get.ts +│ │ ├── utils/ +│ │ │ ├── db.ts +│ │ │ └── queries.ts +│ │ └── database/ +│ │ ├── 01_schema.sql +│ │ ├── 02_seed.sql +│ │ └── README.md +│ └── package.json +├── docker-compose.yml +└── PLAN_TRAZABILIDAD.md (este archivo) +``` + +--- + +## Modelo de Datos + +### Concepto Central: Grafo de Lotes y Operaciones + +El sistema representa la trazabilidad como un **grafo dirigido acíclico (DAG)**: + +- **Nodos = Lotes**: Estados físicos del café +- **Aristas = Operaciones**: Eventos que transforman lotes + +### Tablas Principales + +#### 1. `lotes` + +Representa cualquier estado físico del café. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| `id` | UUID | Identificador único | +| `codigo` | TEXT | Código legible (ej: UVA-001, SEC-042) | +| `tipo` | TEXT | Tipo de lote (uva, despulpado_primera, oreado, etc.) | +| `fecha_creado` | TIMESTAMPTZ | Fecha de creación | +| `lugar_id` | INTEGER | Lugar donde se encuentra (opcional) | +| `cantidad_kg` | NUMERIC | Cantidad en kilogramos | +| `meta` | JSONB | Datos adicionales (humedad, notas, etc.) | + +**Tipos de lote válidos:** +- `uva` +- `despulpado_primera` +- `despulpado_segunda` +- `despulpado_rechazos` +- `oreado` +- `presecado` +- `reposo` +- `secado` + +#### 2. `operaciones` + +Representa eventos donde lotes se transforman, combinan o dividen. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| `id` | UUID | Identificador único | +| `tipo` | TEXT | Tipo de operación (despulpado, oreado, etc.) | +| `fecha` | TIMESTAMPTZ | Fecha de la operación | +| `lugar_id` | INTEGER | Lugar donde ocurrió (opcional) | +| `meta` | JSONB | Datos adicionales | + +**Tipos de operación válidos:** + +*Operaciones de proceso normal:* +- `ingreso` - Ingreso de café uva +- `despulpado` - Despulpado del café +- `oreado` - Proceso de oreado +- `presecado` - Presecado +- `reposo` - Reposo +- `secado` - Secado final +- `traslado` - Movimiento de lote +- `mezcla` - Mezcla de lotes + +*Operaciones de ajuste/corrección:* +- `ajuste_merma` - Corrección por pérdida de peso +- `ajuste_cantidad` - Corrección de cantidad registrada +- `ajuste_tipo` - Corrección de tipo de lote +- `correccion_asignacion` - Corrección de lote mal asignado +- `fusion_manual` - Fusión manual de lotes +- `division_manual` - División manual de lotes + +#### 3. `operacion_lotes` + +Relación muchos a muchos entre operaciones y lotes. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| `operacion_id` | UUID | ID de la operación | +| `lote_id` | UUID | ID del lote | +| `rol` | TEXT | 'input' o 'output' | +| `cantidad_kg` | NUMERIC | Cantidad específica utilizada/producida | + +--- + +## Diagrama del Grafo de Ejemplo + +Flujo completo incluido en los datos de ejemplo (`02_seed.sql`): + +``` + ┌───────────────────────────────┐ + │ INGRESO UVA │ + │ OP1: ingreso_uva │ + └─────────────┬─────────────────┘ + │ + ▼ + [L_UVA1] 2086 kg + │ + │ + ┌─────────────┴──────────────────┐ + │ DESPULPADO │ + │ OP2: despulpado │ + └───┬─────────┬─────────┬────────┘ + │ │ │ + ▼ ▼ ▼ + [L_PRIM1] [L_SEG1] [L_RECH1] + 1500 kg 400 kg 150 kg + │ + │ + ┌────────────┴───────────────────────────┐ + │ OREADO │ + │ OP3: oreado (registro mal) │ + └─────────────────────┬───────────────────┘ + │ + ▼ + [L_ORE1] 1500 kg + │ + │ + ┌────────────────────────┴────────────────────────────┐ + │ AJUSTE DE MERMA │ + │ OP4: ajuste_merma (1500 → 1480 kg) │ + └───────────────────────────┬──────────────────────────┘ + │ + ▼ + [L_ORE1A] 1480 kg + │ + │ + ┌───────────────────────┴───────────────────────────┐ + │ AJUSTE DE TIPO │ + │ OP5: ajuste_tipo (oreado → presecado) │ + └──────────────────────────┬─────────────────────────┘ + │ + ▼ + [L_PRE1] 1480 kg + │ + │ + ┌────────────────────┴────────────────────────┐ + │ REPOSO │ + │ OP6: reposo │ + └───────────────────┬──────────────────────────┘ + │ + ▼ + [L_REP1] 1480 kg + │ + │ (+ [L_REP2] 520 kg) + │ │ + ┌──────────────────────────┴──────┴────────────────────┐ + │ SECADO │ + │ OP7: secado (mezcla) │ + └───────────────────────┬───────────────────────────────┘ + │ + ▼ + [L_SEC1] 2000 kg +``` + +--- + +## Ejemplos de Uso de la API + +### 1. Listar todos los lotes + +```bash +GET /api/lotes + +# Con filtros +GET /api/lotes?tipo=secado&limit=10 +``` + +**Respuesta:** +```json +{ + "success": true, + "data": [ + { + "id": "uuid-here", + "codigo": "SEC-001", + "tipo": "secado", + "fecha_creado": "2025-11-21T10:00:00Z", + "cantidad_kg": 2000, + "meta": { "humedad_final": 11.5 } + } + ], + "count": 1 +} +``` + +### 2. Obtener trazabilidad completa de un lote + +```bash +GET /api/lotes/{id}/trazabilidad +``` + +**Respuesta:** +```json +{ + "success": true, + "data": { + "historial": [ + { + "lote_id": "uuid-sec-001", + "codigo": "SEC-001", + "tipo": "secado", + "cantidad_kg": 2000, + "operacion_id": "uuid-op-secado", + "operacion_tipo": "secado", + "profundidad": 0 + }, + { + "lote_id": "uuid-rep-001", + "codigo": "REP-001", + "tipo": "reposo", + "cantidad_kg": 1480, + "operacion_id": "uuid-op-reposo", + "operacion_tipo": "reposo", + "profundidad": 1 + } + // ... más ancestros + ], + "estadisticas": { + "total_ancestros": 7, + "profundidad_maxima": 6, + "kg_iniciales": 2086 + } + } +} +``` + +### 3. Crear una nueva operación (ejemplo: despulpado) + +```bash +POST /api/operaciones +Content-Type: application/json + +{ + "tipo": "despulpado", + "inputs": [ + { "lote_id": "uuid-uva-001", "cantidad_kg": 2086 } + ], + "outputs": [ + { "codigo": "PRIM-001", "tipo": "despulpado_primera", "cantidad_kg": 1500 }, + { "codigo": "SEG-001", "tipo": "despulpado_segunda", "cantidad_kg": 400 }, + { "codigo": "RECH-001", "tipo": "despulpado_rechazos", "cantidad_kg": 150 } + ], + "meta": { + "pila": 2, + "operador": "Juan Pérez" + } +} +``` + +**Respuesta:** +```json +{ + "success": true, + "data": { + "operacion": { + "id": "uuid-operacion", + "tipo": "despulpado", + "fecha": "2025-11-21T10:00:00Z", + "meta": { "pila": 2, "operador": "Juan Pérez" } + }, + "lotes_creados": [ + { "id": "uuid-prim", "codigo": "PRIM-001", "tipo": "despulpado_primera" }, + { "id": "uuid-seg", "codigo": "SEG-001", "tipo": "despulpado_segunda" }, + { "id": "uuid-rech", "codigo": "RECH-001", "tipo": "despulpado_rechazos" } + ] + } +} +``` + +--- + +## Componentes Frontend + +### Componentes de Lotes + +1. **LotesTable.vue** + - Tabla con listado de lotes + - Filtros por tipo + - Acciones: Ver, Editar, Ver Trazabilidad + +2. **LoteForm.vue** + - Formulario para crear/editar lotes + - Validación de campos + - Soporte para metadata JSON + +3. **LoteCard.vue** + - Vista de detalle de un lote + - Información completa del lote + - Acciones rápidas + +4. **TrazabilidadTree.vue** + - Visualización del historial completo + - Árbol indentado por profundidad + - Estadísticas de trazabilidad + +### Componentes de Operaciones + +1. **OperacionesTable.vue** + - Tabla con listado de operaciones + - Filtros por tipo + - Acciones: Ver detalle + +2. **OperacionForm.vue** + - Formulario multi-paso: + 1. Seleccionar tipo de operación + 2. Seleccionar lotes input + 3. Definir lotes output + - Creación transaccional + +--- + +## Consultas SQL Importantes + +### Obtener trazabilidad completa (función recursiva) + +```sql +SELECT * FROM get_trazabilidad('uuid-del-lote-final'); +``` + +Esta función CTE recursiva camina el grafo hacia atrás desde el lote final hasta los ingresos iniciales. + +### Ver lotes con su operación de origen + +```sql +SELECT * FROM vista_lotes_con_origen +ORDER BY fecha_creado DESC; +``` + +--- + +## Despliegue + +### Requisitos + +- Docker y Docker Compose +- Acceso al servidor con Traefik y Authentik configurados +- Gitea con Actions habilitado + +### Variables de Entorno + +```env +# PostgreSQL +POSTGRES_USER=seguidor +POSTGRES_PASSWORD=seguidor_password +POSTGRES_DB=seguidor_lotes + +# Aplicación +APP_NAME=seguidorDeLotes +APP_DOMAIN=lotes.nucleoriofrio.com +NUXT_PUBLIC_APP_URL=https://lotes.nucleoriofrio.com + +# Registry +REG=gitea.nucleoriofrio.com +REPO_OWNER=nucleo000 +``` + +### Proceso de Deploy + +1. **Push a main/master** → Gitea Actions se ejecuta automáticamente +2. **Build**: Construye imagen Docker +3. **Push**: Sube imagen al registry de Gitea +4. **Deploy**: Ejecuta `docker-compose up -d` en el servidor + +### Primer Inicio + +Al iniciar PostgreSQL por primera vez, ejecutará automáticamente: + +1. `01_schema.sql` - Crea tablas, índices, funciones +2. `02_seed.sql` - Inserta datos de ejemplo + +--- + +## Próximos Pasos + +### Fase 2: Visualización Avanzada + +- [ ] Integrar librería de grafos (Cytoscape.js o D3.js) +- [ ] Vista de grafo interactivo +- [ ] Zoom, pan y selección de nodos +- [ ] Colores por tipo de lote + +### Fase 3: Reportes y Análisis + +- [ ] Reporte de trazabilidad en PDF +- [ ] Estadísticas por período +- [ ] Gráficos de volumen procesado +- [ ] Análisis de mermas + +### Fase 4: Características Avanzadas + +- [ ] Gestión de lugares (patios, pilas, bodegas) +- [ ] QR codes para lotes +- [ ] Escáner móvil +- [ ] Notificaciones de eventos +- [ ] Integración con básculas + +--- + +## Troubleshooting + +### La base de datos no se inicializa + +Verificar logs del contenedor postgres: +```bash +docker logs seguidorDeLotes-postgres +``` + +### Los datos de ejemplo no se cargan + +Eliminar el volumen y recrear: +```bash +docker-compose down -v +docker-compose up -d +``` + +### Error de conexión a PostgreSQL + +Verificar que el contenedor postgres esté saludable: +```bash +docker ps +# Buscar "healthy" en la columna STATUS +``` + +--- + +## Contacto y Soporte + +- **Proyecto**: Nucleo Rio Frio +- **Desarrollador**: Dario (draganel) +- **Repositorio**: https://gitea.nucleoriofrio.com/nucleo000/seguidorDeLotes + +--- + +## Licencia + +Este proyecto es propiedad de **Nucleo Rio Frio** y está desarrollado para uso interno. diff --git a/docker-compose.yml b/docker-compose.yml index 099fd89..efb01fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,32 @@ version: '3.8' services: + postgres: + image: postgres:16-alpine + container_name: ${APP_NAME}-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./nuxt4/server/database:/docker-entrypoint-initdb.d:ro + networks: + - principal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + app: image: ${REG}/${REPO_OWNER}/${APP_NAME}:latest container_name: ${APP_NAME} restart: unless-stopped + depends_on: + postgres: + condition: service_healthy environment: # Node Environment - NODE_ENV=production @@ -12,6 +34,12 @@ services: - NUXT_PORT=3000 # Public URL - NUXT_PUBLIC_APP_URL=${NUXT_PUBLIC_APP_URL} + # Database + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_HOST=postgres + - POSTGRES_PORT=5432 networks: - principal - traefik-network @@ -53,3 +81,6 @@ networks: external: true traefik-network: external: true + +volumes: + postgres_data: diff --git a/nuxt4/app/app.vue b/nuxt4/app/app.vue index e37e66a..a746fa7 100644 --- a/nuxt4/app/app.vue +++ b/nuxt4/app/app.vue @@ -6,85 +6,45 @@
-
-

Plantilla Nuxt + Authentik

-

- Ejemplo de integración con Authentik Proxy Outpost -

+
+
+

Seguidor de Lotes

+

+ Sistema de trazabilidad de café +

+
+
+ + +
- -
- -
- - - - - - -
- - - - + +
+ + + + - - - -
- - + + +
@@ -99,14 +59,130 @@
+ + + + + + + + + + + + + + + + + + + + diff --git a/nuxt4/app/components/lotes/LoteForm.vue b/nuxt4/app/components/lotes/LoteForm.vue new file mode 100644 index 0000000..d6015c1 --- /dev/null +++ b/nuxt4/app/components/lotes/LoteForm.vue @@ -0,0 +1,147 @@ + + + diff --git a/nuxt4/app/components/lotes/LotesTable.vue b/nuxt4/app/components/lotes/LotesTable.vue new file mode 100644 index 0000000..8290639 --- /dev/null +++ b/nuxt4/app/components/lotes/LotesTable.vue @@ -0,0 +1,169 @@ + + + diff --git a/nuxt4/app/components/lotes/TrazabilidadTree.vue b/nuxt4/app/components/lotes/TrazabilidadTree.vue new file mode 100644 index 0000000..0f9bb06 --- /dev/null +++ b/nuxt4/app/components/lotes/TrazabilidadTree.vue @@ -0,0 +1,180 @@ + + + diff --git a/nuxt4/app/components/operaciones/OperacionForm.vue b/nuxt4/app/components/operaciones/OperacionForm.vue new file mode 100644 index 0000000..21c624a --- /dev/null +++ b/nuxt4/app/components/operaciones/OperacionForm.vue @@ -0,0 +1,289 @@ + + + diff --git a/nuxt4/app/components/operaciones/OperacionesTable.vue b/nuxt4/app/components/operaciones/OperacionesTable.vue new file mode 100644 index 0000000..a446d3b --- /dev/null +++ b/nuxt4/app/components/operaciones/OperacionesTable.vue @@ -0,0 +1,144 @@ + + + diff --git a/nuxt4/app/composables/useLotes.ts b/nuxt4/app/composables/useLotes.ts new file mode 100644 index 0000000..fd2753e --- /dev/null +++ b/nuxt4/app/composables/useLotes.ts @@ -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 | null +} + +export interface Operacion { + id: string + tipo: string + fecha: string + lugar_id: number | null + meta: Record | 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 + }) => { + 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 | 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 + inputs: Array<{ lote_id: string; cantidad_kg?: number }> + outputs: Array<{ + codigo?: string + tipo: string + cantidad_kg?: number + meta?: Record + }> + }) => { + 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, + } +} diff --git a/nuxt4/package-lock.json b/nuxt4/package-lock.json index fd43053..73f598e 100644 --- a/nuxt4/package-lock.json +++ b/nuxt4/package-lock.json @@ -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", diff --git a/nuxt4/package.json b/nuxt4/package.json index 84f99e2..a6b84d8 100644 --- a/nuxt4/package.json +++ b/nuxt4/package.json @@ -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" diff --git a/nuxt4/server/api/lotes/[id].delete.ts b/nuxt4/server/api/lotes/[id].delete.ts new file mode 100644 index 0000000..54c67ca --- /dev/null +++ b/nuxt4/server/api/lotes/[id].delete.ts @@ -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 }, + }) + } +}) diff --git a/nuxt4/server/api/lotes/[id].get.ts b/nuxt4/server/api/lotes/[id].get.ts new file mode 100644 index 0000000..f5649cb --- /dev/null +++ b/nuxt4/server/api/lotes/[id].get.ts @@ -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 }, + }) + } +}) diff --git a/nuxt4/server/api/lotes/[id].patch.ts b/nuxt4/server/api/lotes/[id].patch.ts new file mode 100644 index 0000000..0013e45 --- /dev/null +++ b/nuxt4/server/api/lotes/[id].patch.ts @@ -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 }, + }) + } +}) diff --git a/nuxt4/server/api/lotes/[id]/trazabilidad.get.ts b/nuxt4/server/api/lotes/[id]/trazabilidad.get.ts new file mode 100644 index 0000000..18912a3 --- /dev/null +++ b/nuxt4/server/api/lotes/[id]/trazabilidad.get.ts @@ -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 }, + }) + } +}) diff --git a/nuxt4/server/api/lotes/index.get.ts b/nuxt4/server/api/lotes/index.get.ts new file mode 100644 index 0000000..f363bfa --- /dev/null +++ b/nuxt4/server/api/lotes/index.get.ts @@ -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 }, + }) + } +}) diff --git a/nuxt4/server/api/lotes/index.post.ts b/nuxt4/server/api/lotes/index.post.ts new file mode 100644 index 0000000..ab1dfd8 --- /dev/null +++ b/nuxt4/server/api/lotes/index.post.ts @@ -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 }, + }) + } +}) diff --git a/nuxt4/server/api/operaciones/[id].get.ts b/nuxt4/server/api/operaciones/[id].get.ts new file mode 100644 index 0000000..7244196 --- /dev/null +++ b/nuxt4/server/api/operaciones/[id].get.ts @@ -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 }, + }) + } +}) diff --git a/nuxt4/server/api/operaciones/index.get.ts b/nuxt4/server/api/operaciones/index.get.ts new file mode 100644 index 0000000..349483f --- /dev/null +++ b/nuxt4/server/api/operaciones/index.get.ts @@ -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 }, + }) + } +}) diff --git a/nuxt4/server/api/operaciones/index.post.ts b/nuxt4/server/api/operaciones/index.post.ts new file mode 100644 index 0000000..544aaea --- /dev/null +++ b/nuxt4/server/api/operaciones/index.post.ts @@ -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 }, + }) + } +}) diff --git a/nuxt4/server/database/01_schema.sql b/nuxt4/server/database/01_schema.sql new file mode 100644 index 0000000..8af8c82 --- /dev/null +++ b/nuxt4/server/database/01_schema.sql @@ -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 $$; diff --git a/nuxt4/server/database/02_seed.sql b/nuxt4/server/database/02_seed.sql new file mode 100644 index 0000000..b201666 --- /dev/null +++ b/nuxt4/server/database/02_seed.sql @@ -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 $$; diff --git a/nuxt4/server/database/README.md b/nuxt4/server/database/README.md new file mode 100644 index 0000000..69d348d --- /dev/null +++ b/nuxt4/server/database/README.md @@ -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 diff --git a/nuxt4/server/utils/db.ts b/nuxt4/server/utils/db.ts new file mode 100644 index 0000000..59b560c --- /dev/null +++ b/nuxt4/server/utils/db.ts @@ -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( + text: string, + params?: any[] +): Promise> { + const pool = getPool() + const start = Date.now() + + try { + const result = await pool.query(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 { + const pool = getPool() + return await pool.connect() +} + +/** + * Cierra el pool de conexiones. + * Útil para tests o shutdown graceful. + */ +export async function closePool(): Promise { + 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 { + 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 + } +} diff --git a/nuxt4/server/utils/queries.ts b/nuxt4/server/utils/queries.ts new file mode 100644 index 0000000..32913ce --- /dev/null +++ b/nuxt4/server/utils/queries.ts @@ -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 | null +} + +export interface Operacion { + id: string + tipo: string + fecha: Date + lugar_id: number | null + meta: Record | 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 { + 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(sql, params) + return result.rows +} + +/** + * Obtiene un lote por su ID + */ +export async function getLoteById(id: string): Promise { + const result = await query( + '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 { + const result = await query( + '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 +}): Promise { + const result = await query( + `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 | null + }> +): Promise { + 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(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 { + 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 { + const result = await query(` + 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 { + 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(sql, params) + return result.rows +} + +/** + * Obtiene una operación por su ID + */ +export async function getOperacionById(id: string): Promise { + const result = await query( + '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 + outputs: Array +} | null> { + const operacion = await getOperacionById(id) + if (!operacion) return null + + // Obtener lotes de entrada + const inputsResult = await query(` + 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(` + 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 + inputs: Array<{ lote_id: string; cantidad_kg?: number }> + outputs: Array<{ codigo?: string; tipo: string; cantidad_kg?: number; meta?: Record }> +}): 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( + `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( + `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 { + const result = await query( + `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 { + const result = await query( + '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, + } +}