Compare commits

...

3 Commits

3 changed files with 72 additions and 45 deletions

View File

@@ -37,6 +37,12 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
public async Task EjecutarProcesoAsync(DateTime fechaDesde) public async Task EjecutarProcesoAsync(DateTime fechaDesde)
{ {
// Contadores inicializados AL PRINCIPIO
int copiadas = 0;
int omitidas = 0;
int errores = 0;
// No declarar 'int procesadas = 0' porque es redundante con copiadas/omitidas
var config = await _context.Configuraciones.FirstOrDefaultAsync(c => c.Id == 1); var config = await _context.Configuraciones.FirstOrDefaultAsync(c => c.Id == 1);
if (config == null) if (config == null)
{ {
@@ -44,7 +50,6 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
return; return;
} }
// Actualizamos la fecha de ejecución
config.UltimaEjecucion = DateTime.Now; config.UltimaEjecucion = DateTime.Now;
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
@@ -64,20 +69,27 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
await RegistrarEventoAsync($"Se encontraron {facturas.Count} facturas para procesar", TipoEvento.Info); await RegistrarEventoAsync($"Se encontraron {facturas.Count} facturas para procesar", TipoEvento.Info);
int procesadas = 0;
int errores = 0;
List<FacturaParaProcesar> pendientes = new(); List<FacturaParaProcesar> pendientes = new();
List<string> detallesErroresParaMail = new(); List<string> detallesErroresParaMail = new();
// Lista para rastrear las entidades de Evento y actualizar su flag 'Enviado' luego
List<Evento> eventosDeErrorParaActualizar = new(); List<Evento> eventosDeErrorParaActualizar = new();
// --- 1. Primer intento --- // --- 1. Primer intento ---
foreach (var factura in facturas) foreach (var factura in facturas)
{ {
bool exito = await ProcesarFacturaAsync(factura, config); var resultado = await ProcesarFacturaAsync(factura, config);
if (exito) procesadas++;
else pendientes.Add(factura); switch (resultado)
{
case ResultadoProceso.Copiado:
copiadas++;
break;
case ResultadoProceso.Omitido:
omitidas++;
break;
case ResultadoProceso.Error:
pendientes.Add(factura);
break;
}
} }
// --- 2. Sistema de Reintentos --- // --- 2. Sistema de Reintentos ---
@@ -93,11 +105,14 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
foreach (var factura in pendientes) foreach (var factura in pendientes)
{ {
bool exito = await ProcesarFacturaAsync(factura, config); var resultado = await ProcesarFacturaAsync(factura, config);
if (exito)
if (resultado != ResultadoProceso.Error)
{ {
procesadas++; if (resultado == ResultadoProceso.Copiado) copiadas++;
_logger.LogInformation("Archivo encontrado en reintento {intento}: {archivo}", intento, factura.NombreArchivoOrigen); if (resultado == ResultadoProceso.Omitido) omitidas++;
_logger.LogInformation("Archivo recuperado en reintento {intento}: {archivo}", intento, factura.NombreArchivoOrigen);
} }
else else
{ {
@@ -110,7 +125,7 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
// --- 3. Registro de Errores Finales --- // --- 3. Registro de Errores Finales ---
if (pendientes.Count > 0) if (pendientes.Count > 0)
{ {
errores = pendientes.Count; errores = pendientes.Count; // Aquí usamos la variable ya declarada arriba
foreach (var factura in pendientes) foreach (var factura in pendientes)
{ {
string msgError = $"El archivo NO EXISTE después de {MAX_REINTENTOS} intentos: {factura.NombreArchivoOrigen}"; string msgError = $"El archivo NO EXISTE después de {MAX_REINTENTOS} intentos: {factura.NombreArchivoOrigen}";
@@ -124,8 +139,6 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
}; };
_context.Eventos.Add(eventoError); _context.Eventos.Add(eventoError);
// Guardamos referencias
eventosDeErrorParaActualizar.Add(eventoError); eventosDeErrorParaActualizar.Add(eventoError);
detallesErroresParaMail.Add(factura.NombreArchivoOrigen); detallesErroresParaMail.Add(factura.NombreArchivoOrigen);
} }
@@ -151,40 +164,31 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
catch { } catch { }
// --- 6. Evento Final --- // --- 6. Evento Final ---
var mensajeFinal = $"Proceso finalizado. Procesadas: {procesadas}, Errores: {errores}"; var mensajeFinal = $"Proceso finalizado. Nuevas: {copiadas}, Verificadas: {omitidas}, Errores: {errores}";
await RegistrarEventoAsync(mensajeFinal, errores > 0 ? TipoEvento.Warning : TipoEvento.Info); await RegistrarEventoAsync(mensajeFinal, errores > 0 ? TipoEvento.Warning : TipoEvento.Info);
// --- 7. Envío de Mail Inteligente (Solo 1 vez por archivo) --- // --- 7. Envío de Mail Inteligente ---
if (errores > 0 && config.AvisoMail && !string.IsNullOrEmpty(config.SMTPDestinatario)) if (errores > 0 && config.AvisoMail && !string.IsNullOrEmpty(config.SMTPDestinatario))
{ {
// 1. Buscamos en la historia de la DB si estos archivos ya fueron reportados previamente.
// Buscamos en TODOS los logs disponibles (que suelen ser los últimos 30 días según la limpieza).
// Filtramos por Tipo Error y Enviado=true.
var historialErroresEnviados = await _context.Eventos var historialErroresEnviados = await _context.Eventos
.Where(e => e.Tipo == TipoEvento.Error.ToString() && e.Enviado == true) .Where(e => e.Tipo == TipoEvento.Error.ToString() && e.Enviado == true)
.Select(e => e.Mensaje) .Select(e => e.Mensaje)
.ToListAsync(); .ToListAsync();
// 2. Filtramos la lista actual:
// Solo queremos los archivos que NO aparezcan en ningún mensaje del historial.
var archivosNuevosParaNotificar = detallesErroresParaMail.Where(archivoFallido => var archivosNuevosParaNotificar = detallesErroresParaMail.Where(archivoFallido =>
{ {
// El mensaje en BD es: "El archivo NO EXISTE...: nombre_archivo.pdf"
// Chequeamos si el nombre del archivo está contenido en algún mensaje viejo.
bool yaFueNotificado = historialErroresEnviados.Any(msgHistorico => msgHistorico.Contains(archivoFallido)); bool yaFueNotificado = historialErroresEnviados.Any(msgHistorico => msgHistorico.Contains(archivoFallido));
return !yaFueNotificado; return !yaFueNotificado;
}).ToList(); }).ToList();
// 3. Decidir si enviar mail
bool mailEnviado = false; bool mailEnviado = false;
if (archivosNuevosParaNotificar.Count > 0) if (archivosNuevosParaNotificar.Count > 0)
{ {
// Si hay archivos NUEVOS, enviamos mail SOLO con esos. // Pasamos 'copiadas' en lugar de 'procesadas' para el mail, o la suma de ambas si prefieres
// Nota: Pasamos 'errores' (total técnico) y 'archivosNuevosParaNotificar' (detalle visual)
mailEnviado = await EnviarNotificacionErroresAsync( mailEnviado = await EnviarNotificacionErroresAsync(
config.SMTPDestinatario, config.SMTPDestinatario,
procesadas, copiadas + omitidas,
errores, errores,
archivosNuevosParaNotificar archivosNuevosParaNotificar
); );
@@ -196,14 +200,10 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
} }
else else
{ {
_logger.LogInformation("Se omitió el envío de correo: Los {count} errores ya fueron notificados anteriormente.", errores); _logger.LogInformation("Se omitió el envío de correo por errores repetidos.");
// Simulamos que se "envió" (se gestionó) para marcar los flags en BD
mailEnviado = true; mailEnviado = true;
} }
// 4. Actualizar flag en BD (CRÍTICO)
// Si gestionamos la notificación correctamente (ya sea enviándola o detectando que ya estaba enviada),
// marcamos los eventos actuales como Enviado=true para que pasen al historial y no se vuelvan a procesar.
if (mailEnviado && eventosDeErrorParaActualizar.Count > 0) if (mailEnviado && eventosDeErrorParaActualizar.Count > 0)
{ {
foreach (var evento in eventosDeErrorParaActualizar) foreach (var evento in eventosDeErrorParaActualizar)
@@ -296,7 +296,7 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
return facturas; return facturas;
} }
private async Task<bool> ProcesarFacturaAsync(FacturaParaProcesar factura, Configuracion config) private async Task<ResultadoProceso> ProcesarFacturaAsync(FacturaParaProcesar factura, Configuracion config)
{ {
try try
{ {
@@ -306,7 +306,8 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
string rutaOrigen = Path.Combine(rutaBaseOrigen, factura.NombreArchivoOrigen); string rutaOrigen = Path.Combine(rutaBaseOrigen, factura.NombreArchivoOrigen);
string carpetaDestinoFinal = Path.Combine(rutaBaseDestino, factura.CarpetaDestino); string carpetaDestinoFinal = Path.Combine(rutaBaseDestino, factura.CarpetaDestino);
if (!File.Exists(rutaOrigen)) return false; // Si no existe origen, es un error (para reintento)
if (!File.Exists(rutaOrigen)) return ResultadoProceso.Error;
if (!Directory.Exists(carpetaDestinoFinal)) if (!Directory.Exists(carpetaDestinoFinal))
{ {
@@ -319,16 +320,21 @@ public class ProcesadorFacturasService : IProcesadorFacturasService
if (infoDestino.Exists) if (infoDestino.Exists)
{ {
if (infoDestino.Length == infoOrigen.Length) return true; // Si ya existe y es igual, lo OMITIMOS
if (infoDestino.Length == infoOrigen.Length)
{
return ResultadoProceso.Omitido;
}
} }
// Si llegamos acá, copiamos
File.Copy(rutaOrigen, rutaDestinoCompleta, overwrite: true); File.Copy(rutaOrigen, rutaDestinoCompleta, overwrite: true);
return true; return ResultadoProceso.Copiado;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogDebug(ex, "Fallo al copiar {archivo}", factura.NombreArchivoOrigen); _logger.LogDebug(ex, "Fallo al copiar {archivo}", factura.NombreArchivoOrigen);
return false; return ResultadoProceso.Error;
} }
} }
@@ -481,3 +487,10 @@ public class FacturaParaProcesar
public string NombreArchivoDestino { get; set; } = string.Empty; public string NombreArchivoDestino { get; set; } = string.Empty;
public string CarpetaDestino { get; set; } = string.Empty; public string CarpetaDestino { get; set; } = string.Empty;
} }
public enum ResultadoProceso
{
Copiado, // Era nuevo y se copió
Omitido, // Ya existía y estaba bien
Error // No se encontró o falló
}

View File

@@ -37,6 +37,6 @@ services:
container_name: gestor_facturas_web container_name: gestor_facturas_web
restart: always restart: always
ports: ports:
- "80:80" - "8080:80"
depends_on: depends_on:
- backend - backend

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react'; // Importar useState y useEffect
import { Clock, PauseCircle, Hourglass } from 'lucide-react'; import { Clock, PauseCircle, Hourglass } from 'lucide-react';
import { format, formatDistanceToNow, isPast, addSeconds } from 'date-fns'; import { format, formatDistanceToNow, isPast, addSeconds } from 'date-fns';
import { es } from 'date-fns/locale'; import { es } from 'date-fns/locale';
@@ -9,15 +10,28 @@ interface ExecutionCardProps {
} }
export default function ExecutionCard({ ultimaEjecucion, proximaEjecucion, enEjecucion }: ExecutionCardProps) { export default function ExecutionCard({ ultimaEjecucion, proximaEjecucion, enEjecucion }: ExecutionCardProps) {
// Estado para forzar la actualización de la UI cada X segundos
const [, setTick] = useState(0);
useEffect(() => {
// Actualizar cada 5 segundos para que el "hace X tiempo" y el estado "En cola"
// se sientan en tiempo real sin esperar al refetch del backend.
const interval = setInterval(() => {
setTick(t => t + 1);
}, 5000);
return () => clearInterval(interval);
}, []);
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return format(new Date(dateString), "dd/MM, HH:mm", { locale: es }); // Formato más corto return format(new Date(dateString), "dd/MM, HH:mm", { locale: es });
}; };
const formatRelative = (dateString: string) => { const formatRelative = (dateString: string) => {
return formatDistanceToNow(new Date(dateString), { addSuffix: true, locale: es }); return formatDistanceToNow(new Date(dateString), { addSuffix: true, locale: es });
}; };
// Esta lógica se recalculará cada 5 segundos gracias al setTick
const isOverdue = proximaEjecucion ? isPast(addSeconds(new Date(proximaEjecucion), -10)) : false; const isOverdue = proximaEjecucion ? isPast(addSeconds(new Date(proximaEjecucion), -10)) : false;
return ( return (
@@ -25,10 +39,10 @@ export default function ExecutionCard({ ultimaEjecucion, proximaEjecucion, enEje
{/* Barra de estado */} {/* Barra de estado */}
<div className={`h-1 w-full ${enEjecucion ? 'bg-green-500' : 'bg-gray-300'}`} /> <div className={`h-1 w-full ${enEjecucion ? 'bg-green-500' : 'bg-gray-300'}`} />
<div className="p-4 flex-1 flex flex-col"> {/* Padding reducido a p-4 */} <div className="p-4 flex-1 flex flex-col">
{/* Encabezado Compacto */} {/* Encabezado Compacto */}
<div className="flex justify-between items-center mb-3"> {/* Margin reducido */} <div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-400" /> <Clock className="w-4 h-4 text-gray-400" />
<span className="text-xs font-bold text-gray-500 uppercase tracking-wider"> <span className="text-xs font-bold text-gray-500 uppercase tracking-wider">
@@ -44,7 +58,7 @@ export default function ExecutionCard({ ultimaEjecucion, proximaEjecucion, enEje
</span> </span>
</div> </div>
<div className="space-y-4 pl-1 flex-1 flex flex-col justify-center"> {/* Espaciado reducido a space-y-4 */} <div className="space-y-4 pl-1 flex-1 flex flex-col justify-center">
{/* ÚLTIMA EJECUCIÓN */} {/* ÚLTIMA EJECUCIÓN */}
<div className="relative pl-5 border-l-2 border-gray-100"> <div className="relative pl-5 border-l-2 border-gray-100">