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