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