Files
GestorWebFacturas/Backend/GestorFacturas.API/Services/ProcesadorFacturasService.cs

496 lines
19 KiB
C#

using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using GestorFacturas.API.Data;
using GestorFacturas.API.Models;
using GestorFacturas.API.Services.Interfaces;
using System.Data;
namespace GestorFacturas.API.Services;
/// <summary>
/// Servicio principal de procesamiento de facturas.
/// </summary>
public class ProcesadorFacturasService : IProcesadorFacturasService
{
private readonly ApplicationDbContext _context;
private readonly ILogger<ProcesadorFacturasService> _logger;
private readonly IMailService _mailService;
private readonly IEncryptionService _encryptionService;
private readonly IConfiguration _configuration;
private const int MAX_REINTENTOS = 10;
private const int DELAY_SEGUNDOS = 60;
public ProcesadorFacturasService(
ApplicationDbContext context,
ILogger<ProcesadorFacturasService> logger,
IMailService mailService,
IEncryptionService encryptionService,
IConfiguration configuration)
{
_context = context;
_logger = logger;
_mailService = mailService;
_encryptionService = encryptionService;
_configuration = configuration;
}
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);
if (config == null)
{
await RegistrarEventoAsync("No se encontró configuración del sistema", TipoEvento.Error);
return;
}
config.UltimaEjecucion = DateTime.Now;
await _context.SaveChangesAsync();
await RegistrarEventoAsync($"Iniciando proceso de facturas desde {fechaDesde:dd/MM/yyyy}", TipoEvento.Info);
try
{
var facturas = await ObtenerFacturasDesdeERPAsync(config, fechaDesde);
if (facturas.Count == 0)
{
await RegistrarEventoAsync($"No se encontraron facturas desde {fechaDesde:dd/MM/yyyy}", TipoEvento.Warning);
config.Estado = true;
await _context.SaveChangesAsync();
return;
}
await RegistrarEventoAsync($"Se encontraron {facturas.Count} facturas para procesar", TipoEvento.Info);
List<FacturaParaProcesar> pendientes = new();
List<string> detallesErroresParaMail = new();
List<Evento> eventosDeErrorParaActualizar = new();
// --- 1. Primer intento ---
foreach (var factura in facturas)
{
var resultado = await ProcesarFacturaAsync(factura, config);
switch (resultado)
{
case ResultadoProceso.Copiado:
copiadas++;
break;
case ResultadoProceso.Omitido:
omitidas++;
break;
case ResultadoProceso.Error:
pendientes.Add(factura);
break;
}
}
// --- 2. Sistema de Reintentos ---
if (pendientes.Count > 0)
{
await RegistrarEventoAsync($"{pendientes.Count} archivos no encontrados. Iniciando sistema de reintentos...", TipoEvento.Warning);
for (int intento = 1; intento <= MAX_REINTENTOS && pendientes.Count > 0; intento++)
{
await Task.Delay(TimeSpan.FromSeconds(DELAY_SEGUNDOS));
var aunPendientes = new List<FacturaParaProcesar>();
foreach (var factura in pendientes)
{
var resultado = await ProcesarFacturaAsync(factura, config);
if (resultado != ResultadoProceso.Error)
{
if (resultado == ResultadoProceso.Copiado) copiadas++;
if (resultado == ResultadoProceso.Omitido) omitidas++;
_logger.LogInformation("Archivo recuperado en reintento {intento}: {archivo}", intento, factura.NombreArchivoOrigen);
}
else
{
aunPendientes.Add(factura);
}
}
pendientes = aunPendientes;
}
// --- 3. Registro de Errores Finales ---
if (pendientes.Count > 0)
{
errores = pendientes.Count; // Aquí usamos la variable ya declarada arriba
foreach (var factura in pendientes)
{
string msgError = $"El archivo NO EXISTE después de {MAX_REINTENTOS} intentos: {factura.NombreArchivoOrigen}";
var eventoError = new Evento
{
Fecha = DateTime.Now,
Mensaje = msgError,
Tipo = TipoEvento.Error.ToString(),
Enviado = false
};
_context.Eventos.Add(eventoError);
eventosDeErrorParaActualizar.Add(eventoError);
detallesErroresParaMail.Add(factura.NombreArchivoOrigen);
}
await _context.SaveChangesAsync();
}
}
// --- 4. Actualización de Estado General ---
config.Estado = errores == 0;
await _context.SaveChangesAsync();
// --- 5. Limpieza automática ---
try
{
var fechaLimiteBorrado = DateTime.Now.AddMonths(-1);
var eventosViejos = _context.Eventos.Where(e => e.Fecha < fechaLimiteBorrado);
if (eventosViejos.Any())
{
_context.Eventos.RemoveRange(eventosViejos);
await _context.SaveChangesAsync();
}
}
catch { }
// --- 6. Evento Final ---
var mensajeFinal = $"Proceso finalizado. Nuevas: {copiadas}, Verificadas: {omitidas}, Errores: {errores}";
await RegistrarEventoAsync(mensajeFinal, errores > 0 ? TipoEvento.Warning : TipoEvento.Info);
// --- 7. Envío de Mail Inteligente ---
if (errores > 0 && config.AvisoMail && !string.IsNullOrEmpty(config.SMTPDestinatario))
{
var historialErroresEnviados = await _context.Eventos
.Where(e => e.Tipo == TipoEvento.Error.ToString() && e.Enviado == true)
.Select(e => e.Mensaje)
.ToListAsync();
var archivosNuevosParaNotificar = detallesErroresParaMail.Where(archivoFallido =>
{
bool yaFueNotificado = historialErroresEnviados.Any(msgHistorico => msgHistorico.Contains(archivoFallido));
return !yaFueNotificado;
}).ToList();
bool mailEnviado = false;
if (archivosNuevosParaNotificar.Count > 0)
{
// Pasamos 'copiadas' en lugar de 'procesadas' para el mail, o la suma de ambas si prefieres
mailEnviado = await EnviarNotificacionErroresAsync(
config.SMTPDestinatario,
copiadas + omitidas,
errores,
archivosNuevosParaNotificar
);
if (mailEnviado)
{
_logger.LogInformation("Correo de alerta enviado con {count} archivos nuevos.", archivosNuevosParaNotificar.Count);
}
}
else
{
_logger.LogInformation("Se omitió el envío de correo por errores repetidos.");
mailEnviado = true;
}
if (mailEnviado && eventosDeErrorParaActualizar.Count > 0)
{
foreach (var evento in eventosDeErrorParaActualizar)
{
evento.Enviado = true;
}
await _context.SaveChangesAsync();
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error crítico en el proceso de facturas");
string mensajeCritico = $"ERROR CRÍTICO DEL SISTEMA: {ex.Message}";
await RegistrarEventoAsync(mensajeCritico, TipoEvento.Error);
if (config != null)
{
config.Estado = false;
await _context.SaveChangesAsync();
if (config.AvisoMail && !string.IsNullOrEmpty(config.SMTPDestinatario))
{
var listaErroresCriticos = new List<string> { mensajeCritico };
await EnviarNotificacionErroresAsync(config.SMTPDestinatario, 0, 1, listaErroresCriticos);
}
}
}
}
private async Task<List<FacturaParaProcesar>> ObtenerFacturasDesdeERPAsync(Configuracion config, DateTime fechaDesde)
{
var facturas = new List<FacturaParaProcesar>();
var connectionString = ConstruirCadenaConexion(config);
try
{
using var conexion = new SqlConnection(connectionString);
await conexion.OpenAsync();
var query = @"
SELECT DISTINCT
NUMERO_FACTURA,
TIPO_FACTURA,
CLIENTE,
SUCURSAL_FACTURA,
DIVISION_FACTURA,
FECHACOMPLETA_FACTURA,
NRO_CAI
FROM VISTA_FACTURACION_ELDIA
WHERE SUCURSAL_FACTURA = '70'
AND NRO_CAI IS NOT NULL
AND NRO_CAI != ''
AND FECHACOMPLETA_FACTURA >= @FechaDesde
AND CONVERT(DATE, FECHACOMPLETA_FACTURA) <= CONVERT(DATE, GETDATE())
ORDER BY FECHACOMPLETA_FACTURA DESC";
using var comando = new SqlCommand(query, conexion);
comando.Parameters.AddWithValue("@FechaDesde", fechaDesde);
using var reader = await comando.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var factura = new FacturaParaProcesar
{
NumeroFactura = (reader["NUMERO_FACTURA"].ToString() ?? "").Trim().PadLeft(10, '0'),
TipoFactura = (reader["TIPO_FACTURA"].ToString() ?? "").Trim(),
Cliente = (reader["CLIENTE"].ToString() ?? "").Trim().PadLeft(6, '0'),
Sucursal = (reader["SUCURSAL_FACTURA"].ToString() ?? "").Trim().PadLeft(4, '0'),
CodigoEmpresa = (reader["DIVISION_FACTURA"].ToString() ?? "").Trim().PadLeft(4, '0'),
FechaFactura = Convert.ToDateTime(reader["FECHACOMPLETA_FACTURA"])
};
factura.NombreEmpresa = MapearNombreEmpresa(factura.CodigoEmpresa);
factura.NombreArchivoOrigen = ConstruirNombreArchivoOrigen(factura);
factura.NombreArchivoDestino = ConstruirNombreArchivoDestino(factura);
factura.CarpetaDestino = ConstruirRutaCarpetaDestino(factura);
facturas.Add(factura);
}
}
catch (Exception ex)
{
await RegistrarEventoAsync($"Error al conectar con el ERP: {ex.Message}", TipoEvento.Error);
throw;
}
return facturas;
}
private async Task<ResultadoProceso> ProcesarFacturaAsync(FacturaParaProcesar factura, Configuracion config)
{
try
{
string rutaBaseOrigen = config.RutaFacturas;
string rutaBaseDestino = config.RutaDestino;
string rutaOrigen = Path.Combine(rutaBaseOrigen, factura.NombreArchivoOrigen);
string carpetaDestinoFinal = Path.Combine(rutaBaseDestino, factura.CarpetaDestino);
// Si no existe origen, es un error (para reintento)
if (!File.Exists(rutaOrigen)) return ResultadoProceso.Error;
if (!Directory.Exists(carpetaDestinoFinal))
{
Directory.CreateDirectory(carpetaDestinoFinal);
}
string rutaDestinoCompleta = Path.Combine(carpetaDestinoFinal, factura.NombreArchivoDestino);
FileInfo infoOrigen = new FileInfo(rutaOrigen);
FileInfo infoDestino = new FileInfo(rutaDestinoCompleta);
if (infoDestino.Exists)
{
// 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);
return ResultadoProceso.Copiado;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Fallo al copiar {archivo}", factura.NombreArchivoOrigen);
return ResultadoProceso.Error;
}
}
// --- MÉTODOS DE MAPEO (CONFIGURABLES) ---
private string MapearNombreEmpresa(string codigoEmpresa)
{
var nombre = _configuration[$"EmpresasMapping:{codigoEmpresa}"];
return string.IsNullOrEmpty(nombre) ? "DESCONOCIDA" : nombre;
}
private string AjustarTipoFactura(string tipoOriginal)
{
if (string.IsNullOrEmpty(tipoOriginal)) return tipoOriginal;
// 1. Buscamos mapeo en appsettings.json
var tipoMapeado = _configuration[$"FacturaTiposMapping:{tipoOriginal}"];
if (!string.IsNullOrEmpty(tipoMapeado))
{
return tipoMapeado;
}
// 2. Fallback Legacy si no está mapeado
return tipoOriginal[^1].ToString();
}
// --- CONSTRUCCIÓN DE NOMBRES ---
private string ConstruirNombreArchivoOrigen(FacturaParaProcesar factura)
{
return $"{factura.Cliente}-{factura.CodigoEmpresa}-{factura.Sucursal}-{factura.TipoFactura}-{factura.NumeroFactura}.pdf";
}
private string ConstruirNombreArchivoDestino(FacturaParaProcesar factura)
{
// El archivo final conserva el Tipo ORIGINAL
return $"{factura.Cliente}-{factura.CodigoEmpresa}-{factura.Sucursal}-{factura.TipoFactura}-{factura.NumeroFactura}.pdf";
}
private string ConstruirRutaCarpetaDestino(FacturaParaProcesar factura)
{
// La carpeta usa el Tipo AJUSTADO
string tipoAjustado = AjustarTipoFactura(factura.TipoFactura);
string anioMes = factura.FechaFactura.ToString("yyyy-MM");
string nombreCarpetaFactura = $"{factura.Cliente}-{factura.CodigoEmpresa}-{factura.Sucursal}-{tipoAjustado}-{factura.NumeroFactura}";
return Path.Combine(factura.NombreEmpresa, anioMes, nombreCarpetaFactura);
}
// --- UTILIDADES ---
private string ConstruirCadenaConexion(Configuracion config)
{
var builder = new SqlConnectionStringBuilder
{
DataSource = config.DBServidor,
InitialCatalog = config.DBNombre,
IntegratedSecurity = config.DBTrusted,
TrustServerCertificate = true,
ConnectTimeout = 30
};
if (!config.DBTrusted)
{
builder.UserID = _encryptionService.Decrypt(config.DBUsuario ?? "");
builder.Password = _encryptionService.Decrypt(config.DBClave ?? "");
}
return builder.ConnectionString;
}
private async Task RegistrarEventoAsync(string mensaje, TipoEvento tipo)
{
try
{
var evento = new Evento
{
Fecha = DateTime.Now,
Mensaje = mensaje,
Tipo = tipo.ToString(),
Enviado = false
};
_context.Eventos.Add(evento);
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al registrar evento");
}
}
private async Task<bool> EnviarNotificacionErroresAsync(string destinatario, int procesadas, int errores, List<string> detalles)
{
try
{
var asunto = errores == 1 && detalles.Count > 0 && detalles[0].StartsWith("ERROR CRÍTICO")
? "ALERTA CRÍTICA: Fallo del Sistema Gestor de Facturas"
: "Alerta: Errores en Procesamiento de Facturas";
string listaArchivosHtml = "";
if (detalles != null && detalles.Count > 0)
{
listaArchivosHtml = "<h3>Detalle de Errores:</h3><ul>";
foreach (var archivo in detalles)
{
listaArchivosHtml += $"<li>{archivo}</li>";
}
listaArchivosHtml += "</ul>";
}
var cuerpo = $@"
<html>
<body style='font-family: Arial, sans-serif;'>
<h2 style='color: #d9534f;'>{asunto}</h2>
<p><strong>Fecha de Ejecución:</strong> {DateTime.Now:dd/MM/yyyy HH:mm:ss}</p>
<div style='background-color: #f8f9fa; padding: 15px; border-radius: 5px; border: 1px solid #dee2e6;'>
<p><strong>Facturas procesadas exitosamente:</strong> {procesadas}</p>
<p><strong>Facturas con error:</strong> <span style='color: red; font-weight: bold;'>{errores}</span></p>
</div>
<div style='margin-top: 20px;'>{listaArchivosHtml}</div>
<hr style='margin-top: 30px;'>
<p style='color: #6c757d; font-size: 12px;'>Sistema Gestor de Facturas El Día.</p>
</body>
</html>";
return await _mailService.EnviarCorreoAsync(destinatario, asunto, cuerpo, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al preparar notificación de errores");
return false;
}
}
}
/// <summary>
/// Clase auxiliar para representar una factura a procesar
/// </summary>
public class FacturaParaProcesar
{
public string NumeroFactura { get; set; } = string.Empty;
public string TipoFactura { get; set; } = string.Empty;
public string Cliente { get; set; } = string.Empty;
public string Sucursal { get; set; } = string.Empty;
public string CodigoEmpresa { get; set; } = string.Empty;
public string NombreEmpresa { get; set; } = string.Empty;
public DateTime FechaFactura { get; set; }
public string NombreArchivoOrigen { get; set; } = string.Empty;
public string NombreArchivoDestino { 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ó
}