Implementar sistema completo de trazabilidad de lotes
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 1m47s
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 1m47s
- Agregar PostgreSQL 16 con esquema completo - Crear API endpoints para lotes y operaciones - Implementar UI con Nuxt UI (tablas, formularios, trazabilidad) - Agregar datos de ejemplo del flujo completo - Documentar sistema en PLAN_TRAZABILIDAD.md
This commit is contained in:
106
nuxt4/server/utils/db.ts
Normal file
106
nuxt4/server/utils/db.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
let pool: pg.Pool | null = null
|
||||
|
||||
/**
|
||||
* Obtiene o crea el pool de conexiones a PostgreSQL.
|
||||
* Usa variables de entorno para la configuración.
|
||||
*/
|
||||
export function getPool(): pg.Pool {
|
||||
if (!pool) {
|
||||
const config = {
|
||||
user: process.env.POSTGRES_USER || 'seguidor',
|
||||
password: process.env.POSTGRES_PASSWORD || 'seguidor_password',
|
||||
database: process.env.POSTGRES_DB || 'seguidor_lotes',
|
||||
host: process.env.POSTGRES_HOST || 'postgres',
|
||||
port: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
max: 20, // máximo de conexiones en el pool
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
}
|
||||
|
||||
pool = new Pool(config)
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Error inesperado en el pool de PostgreSQL:', err)
|
||||
})
|
||||
|
||||
pool.on('connect', () => {
|
||||
console.log('Nueva conexión establecida con PostgreSQL')
|
||||
})
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta una query SQL con parámetros.
|
||||
* Wrapper seguro para evitar inyección SQL.
|
||||
*
|
||||
* @param text - Query SQL con placeholders $1, $2, etc.
|
||||
* @param params - Parámetros para la query
|
||||
* @returns Resultado de la query
|
||||
*/
|
||||
export async function query<T = any>(
|
||||
text: string,
|
||||
params?: any[]
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
const pool = getPool()
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
const result = await pool.query<T>(text, params)
|
||||
const duration = Date.now() - start
|
||||
|
||||
// Log solo en desarrollo
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('Query ejecutada:', { text, duration: `${duration}ms`, rows: result.rowCount })
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Error ejecutando query:', { text, params, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un cliente del pool para ejecutar transacciones.
|
||||
* IMPORTANTE: Debes llamar a client.release() al terminar.
|
||||
*
|
||||
* @returns Cliente de PostgreSQL
|
||||
*/
|
||||
export async function getClient(): Promise<pg.PoolClient> {
|
||||
const pool = getPool()
|
||||
return await pool.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cierra el pool de conexiones.
|
||||
* Útil para tests o shutdown graceful.
|
||||
*/
|
||||
export async function closePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end()
|
||||
pool = null
|
||||
console.log('Pool de PostgreSQL cerrado')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica que la conexión a la base de datos esté funcionando.
|
||||
* Útil para health checks.
|
||||
*
|
||||
* @returns true si la conexión está OK, false en caso contrario
|
||||
*/
|
||||
export async function checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const result = await query('SELECT NOW() as now')
|
||||
return result.rows.length > 0
|
||||
} catch (error) {
|
||||
console.error('Error verificando conexión a PostgreSQL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
441
nuxt4/server/utils/queries.ts
Normal file
441
nuxt4/server/utils/queries.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { query, getClient } from './db'
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
// =====================================================
|
||||
// TIPOS TYPESCRIPT
|
||||
// =====================================================
|
||||
|
||||
export interface Lote {
|
||||
id: string
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
fecha_creado: Date
|
||||
lugar_id: number | null
|
||||
cantidad_kg: number | null
|
||||
meta: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface Operacion {
|
||||
id: string
|
||||
tipo: string
|
||||
fecha: Date
|
||||
lugar_id: number | null
|
||||
meta: Record<string, any> | null
|
||||
}
|
||||
|
||||
export interface OperacionLote {
|
||||
operacion_id: string
|
||||
lote_id: string
|
||||
rol: 'input' | 'output'
|
||||
cantidad_kg: number | null
|
||||
}
|
||||
|
||||
export interface TrazabilidadRow {
|
||||
lote_id: string
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
cantidad_kg: number | null
|
||||
operacion_id: string | null
|
||||
operacion_tipo: string | null
|
||||
profundidad: number
|
||||
}
|
||||
|
||||
export interface LoteConOrigen extends Lote {
|
||||
operacion_id: string | null
|
||||
operacion_tipo: string | null
|
||||
operacion_fecha: Date | null
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERIES PARA LOTES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todos los lotes con filtros opcionales
|
||||
*/
|
||||
export async function getLotes(filtros?: {
|
||||
tipo?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<Lote[]> {
|
||||
let sql = 'SELECT * FROM lotes WHERE 1=1'
|
||||
const params: any[] = []
|
||||
let paramCount = 1
|
||||
|
||||
if (filtros?.tipo) {
|
||||
sql += ` AND tipo = $${paramCount}`
|
||||
params.push(filtros.tipo)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
sql += ' ORDER BY fecha_creado DESC'
|
||||
|
||||
if (filtros?.limit) {
|
||||
sql += ` LIMIT $${paramCount}`
|
||||
params.push(filtros.limit)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (filtros?.offset) {
|
||||
sql += ` OFFSET $${paramCount}`
|
||||
params.push(filtros.offset)
|
||||
}
|
||||
|
||||
const result = await query<Lote>(sql, params)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un lote por su ID
|
||||
*/
|
||||
export async function getLoteById(id: string): Promise<Lote | null> {
|
||||
const result = await query<Lote>(
|
||||
'SELECT * FROM lotes WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un lote por su código
|
||||
*/
|
||||
export async function getLoteByCodigo(codigo: string): Promise<Lote | null> {
|
||||
const result = await query<Lote>(
|
||||
'SELECT * FROM lotes WHERE codigo = $1',
|
||||
[codigo]
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuevo lote
|
||||
*/
|
||||
export async function createLote(data: {
|
||||
codigo?: string
|
||||
tipo: string
|
||||
cantidad_kg?: number
|
||||
lugar_id?: number
|
||||
meta?: Record<string, any>
|
||||
}): Promise<Lote> {
|
||||
const result = await query<Lote>(
|
||||
`INSERT INTO lotes (codigo, tipo, cantidad_kg, lugar_id, meta)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.codigo || null,
|
||||
data.tipo,
|
||||
data.cantidad_kg || null,
|
||||
data.lugar_id || null,
|
||||
data.meta ? JSON.stringify(data.meta) : null,
|
||||
]
|
||||
)
|
||||
return result.rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un lote existente
|
||||
*/
|
||||
export async function updateLote(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
codigo: string | null
|
||||
tipo: string
|
||||
cantidad_kg: number | null
|
||||
lugar_id: number | null
|
||||
meta: Record<string, any> | null
|
||||
}>
|
||||
): Promise<Lote | null> {
|
||||
const fields: string[] = []
|
||||
const params: any[] = []
|
||||
let paramCount = 1
|
||||
|
||||
if (data.codigo !== undefined) {
|
||||
fields.push(`codigo = $${paramCount}`)
|
||||
params.push(data.codigo)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (data.tipo !== undefined) {
|
||||
fields.push(`tipo = $${paramCount}`)
|
||||
params.push(data.tipo)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (data.cantidad_kg !== undefined) {
|
||||
fields.push(`cantidad_kg = $${paramCount}`)
|
||||
params.push(data.cantidad_kg)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (data.lugar_id !== undefined) {
|
||||
fields.push(`lugar_id = $${paramCount}`)
|
||||
params.push(data.lugar_id)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (data.meta !== undefined) {
|
||||
fields.push(`meta = $${paramCount}`)
|
||||
params.push(data.meta ? JSON.stringify(data.meta) : null)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getLoteById(id)
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
|
||||
const sql = `
|
||||
UPDATE lotes
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramCount}
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
const result = await query<Lote>(sql, params)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un lote
|
||||
* CUIDADO: Solo debe usarse en casos excepcionales. Preferir marcar como inactivo.
|
||||
*/
|
||||
export async function deleteLote(id: string): Promise<boolean> {
|
||||
const result = await query(
|
||||
'DELETE FROM lotes WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los lotes con información de su operación de origen
|
||||
*/
|
||||
export async function getLotesConOrigen(): Promise<LoteConOrigen[]> {
|
||||
const result = await query<LoteConOrigen>(`
|
||||
SELECT * FROM vista_lotes_con_origen
|
||||
ORDER BY fecha_creado DESC
|
||||
`)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERIES PARA OPERACIONES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todas las operaciones con filtros opcionales
|
||||
*/
|
||||
export async function getOperaciones(filtros?: {
|
||||
tipo?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<Operacion[]> {
|
||||
let sql = 'SELECT * FROM operaciones WHERE 1=1'
|
||||
const params: any[] = []
|
||||
let paramCount = 1
|
||||
|
||||
if (filtros?.tipo) {
|
||||
sql += ` AND tipo = $${paramCount}`
|
||||
params.push(filtros.tipo)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
sql += ' ORDER BY fecha DESC'
|
||||
|
||||
if (filtros?.limit) {
|
||||
sql += ` LIMIT $${paramCount}`
|
||||
params.push(filtros.limit)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (filtros?.offset) {
|
||||
sql += ` OFFSET $${paramCount}`
|
||||
params.push(filtros.offset)
|
||||
}
|
||||
|
||||
const result = await query<Operacion>(sql, params)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene una operación por su ID
|
||||
*/
|
||||
export async function getOperacionById(id: string): Promise<Operacion | null> {
|
||||
const result = await query<Operacion>(
|
||||
'SELECT * FROM operaciones WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene una operación con sus lotes relacionados (inputs y outputs)
|
||||
*/
|
||||
export async function getOperacionConLotes(id: string): Promise<{
|
||||
operacion: Operacion
|
||||
inputs: Array<Lote & { cantidad_kg_usada: number }>
|
||||
outputs: Array<Lote & { cantidad_kg_producida: number }>
|
||||
} | null> {
|
||||
const operacion = await getOperacionById(id)
|
||||
if (!operacion) return null
|
||||
|
||||
// Obtener lotes de entrada
|
||||
const inputsResult = await query<Lote & { cantidad_kg_usada: number }>(`
|
||||
SELECT l.*, ol.cantidad_kg as cantidad_kg_usada
|
||||
FROM lotes l
|
||||
JOIN operacion_lotes ol ON ol.lote_id = l.id
|
||||
WHERE ol.operacion_id = $1 AND ol.rol = 'input'
|
||||
ORDER BY l.codigo
|
||||
`, [id])
|
||||
|
||||
// Obtener lotes de salida
|
||||
const outputsResult = await query<Lote & { cantidad_kg_producida: number }>(`
|
||||
SELECT l.*, ol.cantidad_kg as cantidad_kg_producida
|
||||
FROM lotes l
|
||||
JOIN operacion_lotes ol ON ol.lote_id = l.id
|
||||
WHERE ol.operacion_id = $1 AND ol.rol = 'output'
|
||||
ORDER BY l.codigo
|
||||
`, [id])
|
||||
|
||||
return {
|
||||
operacion,
|
||||
inputs: inputsResult.rows,
|
||||
outputs: outputsResult.rows,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una nueva operación con sus lotes relacionados (TRANSACCIÓN)
|
||||
* Esta función asegura que la operación y sus relaciones se creen atómicamente.
|
||||
*/
|
||||
export async function createOperacion(data: {
|
||||
tipo: string
|
||||
fecha?: Date
|
||||
lugar_id?: number
|
||||
meta?: Record<string, any>
|
||||
inputs: Array<{ lote_id: string; cantidad_kg?: number }>
|
||||
outputs: Array<{ codigo?: string; tipo: string; cantidad_kg?: number; meta?: Record<string, any> }>
|
||||
}): Promise<{
|
||||
operacion: Operacion
|
||||
lotes_creados: Lote[]
|
||||
}> {
|
||||
const client = await getClient()
|
||||
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
|
||||
// 1. Crear la operación
|
||||
const operacionResult = await client.query<Operacion>(
|
||||
`INSERT INTO operaciones (tipo, fecha, lugar_id, meta)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.tipo,
|
||||
data.fecha || new Date(),
|
||||
data.lugar_id || null,
|
||||
data.meta ? JSON.stringify(data.meta) : null,
|
||||
]
|
||||
)
|
||||
const operacion = operacionResult.rows[0]
|
||||
|
||||
// 2. Relacionar lotes de entrada
|
||||
for (const input of data.inputs) {
|
||||
await client.query(
|
||||
`INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES ($1, $2, 'input', $3)`,
|
||||
[operacion.id, input.lote_id, input.cantidad_kg || null]
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Crear y relacionar lotes de salida
|
||||
const lotesCreados: Lote[] = []
|
||||
for (const output of data.outputs) {
|
||||
const loteResult = await client.query<Lote>(
|
||||
`INSERT INTO lotes (codigo, tipo, cantidad_kg, meta)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
output.codigo || null,
|
||||
output.tipo,
|
||||
output.cantidad_kg || null,
|
||||
output.meta ? JSON.stringify(output.meta) : null,
|
||||
]
|
||||
)
|
||||
const lote = loteResult.rows[0]
|
||||
lotesCreados.push(lote)
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO operacion_lotes (operacion_id, lote_id, rol, cantidad_kg)
|
||||
VALUES ($1, $2, 'output', $3)`,
|
||||
[operacion.id, lote.id, output.cantidad_kg || null]
|
||||
)
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
|
||||
return {
|
||||
operacion,
|
||||
lotes_creados: lotesCreados,
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK')
|
||||
throw error
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERIES PARA OPERACION_LOTES
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene todas las relaciones lote-operación para una operación específica
|
||||
*/
|
||||
export async function getOperacionLotes(operacionId: string): Promise<OperacionLote[]> {
|
||||
const result = await query<OperacionLote>(
|
||||
`SELECT * FROM operacion_lotes WHERE operacion_id = $1 ORDER BY rol`,
|
||||
[operacionId]
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// QUERIES DE TRAZABILIDAD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Obtiene el historial completo de un lote usando la función recursiva de PostgreSQL
|
||||
*/
|
||||
export async function getTrazabilidad(loteId: string): Promise<TrazabilidadRow[]> {
|
||||
const result = await query<TrazabilidadRow>(
|
||||
'SELECT * FROM get_trazabilidad($1)',
|
||||
[loteId]
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estadísticas de un lote (cuántos ancestros tiene, profundidad máxima, etc.)
|
||||
*/
|
||||
export async function getEstadisticasLote(loteId: string): Promise<{
|
||||
total_ancestros: number
|
||||
profundidad_maxima: number
|
||||
kg_iniciales: number | null
|
||||
}> {
|
||||
const trazabilidad = await getTrazabilidad(loteId)
|
||||
|
||||
const profundidadMaxima = Math.max(...trazabilidad.map(t => t.profundidad))
|
||||
const totalAncestros = trazabilidad.length - 1 // -1 para no contar el lote mismo
|
||||
|
||||
// Buscar lotes de ingreso (profundidad máxima)
|
||||
const ingresos = trazabilidad.filter(t => t.profundidad === profundidadMaxima)
|
||||
const kgIniciales = ingresos.reduce((sum, t) => sum + (t.cantidad_kg || 0), 0)
|
||||
|
||||
return {
|
||||
total_ancestros: totalAncestros,
|
||||
profundidad_maxima: profundidadMaxima,
|
||||
kg_iniciales: kgIniciales,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user