Implementar sistema completo de trazabilidad de lotes
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:
2025-11-21 18:39:04 -06:00
parent e5456bf522
commit ee3dffa38e
26 changed files with 4223 additions and 74 deletions

View 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 },
})
}
})

View 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 },
})
}
})

View 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 },
})
}
})

View 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 },
})
}
})

View 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 },
})
}
})

View 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 },
})
}
})

View 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 },
})
}
})

View 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 },
})
}
})

View 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 },
})
}
})

View 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 $$;

View 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 $$;

View 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
View 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
}
}

View 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,
}
}