Feat: Implementa auditoría de envíos masivos y mejora de procesos
Se introduce un sistema completo para auditar los envíos masivos de correos durante el cierre mensual y se refactoriza la interfaz de usuario de procesos para una mayor claridad y escalabilidad. Además, se mejora la lógica de negocio para la gestión de bajas de suscripciones. ### ✨ Nuevas Características - **Auditoría de Envíos Masivos (Cierre Mensual):** - Se crea una nueva tabla `com_LotesDeEnvio` para registrar cada ejecución del proceso de facturación mensual. - El `FacturacionService` ahora crea un "lote" al iniciar el cierre, registra el resultado de cada envío de email individual asociándolo a dicho lote, y actualiza las estadísticas finales (enviados, fallidos) al terminar. - Se implementa un nuevo `LotesEnvioController` con un endpoint para consultar los detalles de cualquier lote de envío histórico. ### 🔄 Refactorización y Mejoras - **Rediseño de la Página de Procesos:** - La antigua página "Facturación" se renombra a `CierreYProcesosPage` y se rediseña completamente utilizando una interfaz de Pestañas (Tabs). - **Pestaña "Procesos Mensuales":** Aisla las acciones principales (Generar Cierre, Archivo de Débito, Procesar Respuesta), mostrando un resumen del resultado del último envío. - **Pestaña "Historial de Cierres":** Muestra una tabla con todos los lotes de envío pasados y permite al usuario ver los detalles de cada uno en un modal. - **Filtros para el Historial de Cierres:** - Se añaden filtros por Mes y Año a la pestaña de "Historial de Cierres", permitiendo al usuario buscar y auditar procesos pasados de manera eficiente. El filtrado se realiza en el backend para un rendimiento óptimo. - **Lógica de `FechaFin` Obligatoria para Bajas:** - Se implementa una regla de negocio crucial: al cambiar el estado de una suscripción a "Pausada" o "Cancelada", ahora es obligatorio establecer una `FechaFin`. - **Frontend:** El modal de suscripciones ahora gestiona esto automáticamente, haciendo el campo `FechaFin` requerido y visible según el estado seleccionado. - **Backend:** Se añade una validación en `SuscripcionService` como segunda capa de seguridad para garantizar la integridad de los datos. ### 🐛 Corrección de Errores - **Reporte de Distribución:** Se corrigió un bug en la generación del PDF donde la columna de fecha no mostraba la "Fecha de Baja" para las suscripciones finalizadas. Ahora se muestra la fecha correcta según la sección (Altas o Bajas). - **Errores de Compilación y Dependencias:** Se solucionaron varios errores de compilación en el backend, principalmente relacionados con la falta de registro de los nuevos repositorios (`ILoteDeEnvioRepository`, `IEmailLogService`, etc.) en el contenedor de inyección de dependencias (`Program.cs`). - **Errores de Tipado en Frontend:** Se corrigieron múltiples errores de TypeScript en `CierreYProcesosPage` debidos a la inconsistencia entre `PascalCase` (C#) y `camelCase` (JSON/TypeScript), asegurando un mapeo correcto de los datos de la API.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace GestionIntegral.Api.Controllers.Comunicaciones
|
||||
{
|
||||
[Route("api/lotes-envio")]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class LotesEnvioController : ControllerBase
|
||||
{
|
||||
private readonly IEmailLogService _emailLogService;
|
||||
|
||||
public LotesEnvioController(IEmailLogService emailLogService)
|
||||
{
|
||||
_emailLogService = emailLogService;
|
||||
}
|
||||
|
||||
// GET: api/lotes-envio/123/detalles
|
||||
[HttpGet("{idLote:int}/detalles")]
|
||||
[ProducesResponseType(typeof(IEnumerable<EmailLogDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetDetallesLote(int idLote)
|
||||
{
|
||||
// Reutilizamos un permiso existente, ya que esta es una función de auditoría relacionada.
|
||||
var tienePermiso = User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == "SU006");
|
||||
if (!tienePermiso)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var detalles = await _emailLogService.ObtenerDetallesPorLoteId(idLote);
|
||||
|
||||
// Devolvemos OK con un array vacío si no hay resultados, el frontend lo manejará.
|
||||
return Ok(detalles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,63 +49,103 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
|
||||
{
|
||||
container.PaddingTop(10).Column(column =>
|
||||
{
|
||||
column.Spacing(20);
|
||||
foreach (var empresa in Model.DatosAgrupados)
|
||||
column.Spacing(20); // Espacio entre elementos principales (sección de altas y sección de bajas)
|
||||
|
||||
// --- Sección 1: Altas y Activas ---
|
||||
column.Item().Column(colAltas =>
|
||||
{
|
||||
column.Item().Element(c => ComposeEmpresa(c, empresa));
|
||||
colAltas.Item().Text("Altas y Suscripciones Activas en el Período").Bold().FontSize(14).Underline();
|
||||
colAltas.Item().PaddingBottom(10).Text("Listado de suscriptores que deben recibir entregas en el período seleccionado.");
|
||||
|
||||
if (!Model.DatosAgrupadosAltas.Any())
|
||||
{
|
||||
colAltas.Item().PaddingTop(10).Text("No se encontraron suscripciones activas para este período.").Italic();
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var empresa in Model.DatosAgrupadosAltas)
|
||||
{
|
||||
colAltas.Item().Element(c => ComposeTablaEmpresa(c, empresa, esBaja: false));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Sección 2: Bajas ---
|
||||
if (Model.DatosAgrupadosBajas.Any())
|
||||
{
|
||||
column.Item().PageBreak(); // Salto de página para separar las secciones
|
||||
column.Item().Column(colBajas =>
|
||||
{
|
||||
colBajas.Item().Text("Bajas de Suscripciones en el Período").Bold().FontSize(14).Underline().FontColor(Colors.Red.Medium);
|
||||
colBajas.Item().PaddingBottom(10).Text("Listado de suscriptores cuya suscripción finalizó. NO se les debe entregar a partir de su 'Fecha de Baja'.");
|
||||
|
||||
foreach (var empresa in Model.DatosAgrupadosBajas)
|
||||
{
|
||||
colBajas.Item().Element(c => ComposeTablaEmpresa(c, empresa, esBaja: true));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ComposeEmpresa(IContainer container, GrupoEmpresa empresa)
|
||||
void ComposeTablaEmpresa(IContainer container, GrupoEmpresa empresa, bool esBaja)
|
||||
{
|
||||
container.Column(column =>
|
||||
{
|
||||
// Cabecera de la EMPRESA (ej. EL DIA)
|
||||
column.Item().Background(Colors.Grey.Lighten2).Padding(5).Text(empresa.NombreEmpresa).Bold().FontSize(12);
|
||||
column.Item().Column(colPub =>
|
||||
|
||||
// Contenedor para las tablas de las publicaciones de esta empresa
|
||||
column.Item().PaddingTop(5).Column(colPub =>
|
||||
{
|
||||
colPub.Spacing(10);
|
||||
colPub.Spacing(10); // Espacio entre cada tabla de publicación
|
||||
foreach (var publicacion in empresa.Publicaciones)
|
||||
{
|
||||
colPub.Item().Element(c => ComposePublicacion(c, publicacion));
|
||||
colPub.Item().Element(c => ComposeTablaPublicacion(c, publicacion, esBaja));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void ComposePublicacion(IContainer container, GrupoPublicacion publicacion)
|
||||
void ComposeTablaPublicacion(IContainer container, GrupoPublicacion publicacion, bool esBaja)
|
||||
{
|
||||
container.Table(table =>
|
||||
// Se envuelve la tabla en una columna para poder ponerle un título simple arriba.
|
||||
container.Column(column =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
column.Item().PaddingLeft(2).PaddingBottom(2).Text(publicacion.NombrePublicacion).SemiBold().FontSize(10);
|
||||
column.Item().Table(table =>
|
||||
{
|
||||
columns.RelativeColumn(2.5f); // Nombre
|
||||
columns.RelativeColumn(3); // Dirección
|
||||
columns.RelativeColumn(1.5f); // Teléfono
|
||||
columns.RelativeColumn(1.5f); // Días
|
||||
columns.RelativeColumn(2.5f); // Observaciones
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2.5f); // Nombre
|
||||
columns.RelativeColumn(3); // Dirección
|
||||
columns.RelativeColumn(1.5f); // Teléfono
|
||||
columns.ConstantColumn(65); // Fecha Inicio / Baja
|
||||
columns.RelativeColumn(1.5f); // Días
|
||||
columns.RelativeColumn(2.5f); // Observaciones
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Suscriptor").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Dirección").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Teléfono").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text(esBaja ? "Fecha de Baja" : "Fecha Inicio").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Días Entrega").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Observaciones").SemiBold();
|
||||
});
|
||||
|
||||
foreach (var item in publicacion.Suscripciones)
|
||||
{
|
||||
table.Cell().Padding(2).Text(item.NombreSuscriptor);
|
||||
table.Cell().Padding(2).Text(item.Direccion);
|
||||
table.Cell().Padding(2).Text(item.Telefono ?? "-");
|
||||
var fecha = esBaja ? item.FechaFin : item.FechaInicio;
|
||||
table.Cell().Padding(2).Text(fecha?.ToString("dd/MM/yyyy") ?? "-");
|
||||
table.Cell().Padding(2).Text(item.DiasEntrega);
|
||||
table.Cell().Padding(2).Text(item.Observaciones ?? "-");
|
||||
}
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().ColumnSpan(5).Background(Colors.Grey.Lighten4).Padding(3)
|
||||
.Text(publicacion.NombrePublicacion).SemiBold().FontSize(10);
|
||||
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Suscriptor").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Dirección").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Teléfono").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Días Entrega").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Observaciones").SemiBold();
|
||||
});
|
||||
|
||||
foreach (var item in publicacion.Suscripciones)
|
||||
{
|
||||
table.Cell().Padding(2).Text(item.NombreSuscriptor);
|
||||
table.Cell().Padding(2).Text(item.Direccion);
|
||||
table.Cell().Padding(2).Text(item.Telefono ?? "-");
|
||||
table.Cell().Padding(2).Text(item.DiasEntrega);
|
||||
table.Cell().Padding(2).Text(item.Observaciones ?? "-");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1727,16 +1727,16 @@ namespace GestionIntegral.Api.Controllers
|
||||
{
|
||||
if (!TienePermiso(PermisoVerReporteDistSuscripciones)) return Forbid();
|
||||
|
||||
var (data, error) = await _reportesService.ObtenerReporteDistribucionSuscripcionesAsync(fechaDesde, fechaHasta);
|
||||
var (altas, bajas, error) = await _reportesService.ObtenerReporteDistribucionSuscripcionesAsync(fechaDesde, fechaHasta);
|
||||
if (error != null) return BadRequest(new { message = error });
|
||||
if (data == null || !data.Any())
|
||||
if ((altas == null || !altas.Any()) && (bajas == null || !bajas.Any()))
|
||||
{
|
||||
return NotFound(new { message = "No se encontraron suscripciones activas para el período seleccionado." });
|
||||
return NotFound(new { message = "No se encontraron suscripciones activas ni bajas para el período seleccionado." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var viewModel = new DistribucionSuscripcionesViewModel(data)
|
||||
var viewModel = new DistribucionSuscripcionesViewModel(altas ?? Enumerable.Empty<DistribucionSuscripcionDto>(), bajas ?? Enumerable.Empty<DistribucionSuscripcionDto>())
|
||||
{
|
||||
FechaDesde = fechaDesde.ToString("dd/MM/yyyy"),
|
||||
FechaHasta = fechaHasta.ToString("dd/MM/yyyy"),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using GestionIntegral.Api.Services.Suscripciones;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -54,11 +53,10 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// POST: api/facturacion/{idFactura}/enviar-factura-pdf
|
||||
[HttpPost("{idFactura:int}/enviar-factura-pdf")]
|
||||
public async Task<IActionResult> EnviarFacturaPdf(int idFactura)
|
||||
{
|
||||
if (!TienePermiso("SU009")) return Forbid();
|
||||
if (!TienePermiso(PermisoEnviarEmail)) return Forbid();
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
var (exito, error, emailDestino) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura, userId.Value);
|
||||
@@ -72,31 +70,6 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
return Ok(new { message = mensajeExito });
|
||||
}
|
||||
|
||||
// POST: api/facturacion/{anio}/{mes}/suscriptor/{idSuscriptor}/enviar-aviso
|
||||
[HttpPost("{anio:int}/{mes:int}/suscriptor/{idSuscriptor:int}/enviar-aviso")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> EnviarAvisoPorEmail(int anio, int mes, int idSuscriptor)
|
||||
{
|
||||
// Usamos el permiso de enviar email
|
||||
if (!TienePermiso("SU009")) return Forbid();
|
||||
|
||||
var (exito, error) = await _facturacionService.EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor);
|
||||
|
||||
if (!exito)
|
||||
{
|
||||
if (error != null && (error.Contains("no encontrada") || error.Contains("no es válido")))
|
||||
{
|
||||
return NotFound(new { message = error });
|
||||
}
|
||||
return BadRequest(new { message = error });
|
||||
}
|
||||
|
||||
return Ok(new { message = "Email consolidado para el suscriptor ha sido enviado a la cola de procesamiento." });
|
||||
}
|
||||
|
||||
// GET: api/facturacion/{anio}/{mes}
|
||||
[HttpGet("{anio:int}/{mes:int}")]
|
||||
public async Task<IActionResult> GetFacturas(
|
||||
int anio, int mes,
|
||||
@@ -111,7 +84,6 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
return Ok(resumenes);
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("{anio:int}/{mes:int}")]
|
||||
public async Task<IActionResult> GenerarFacturacion(int anio, int mes)
|
||||
{
|
||||
@@ -119,16 +91,34 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El año y el mes proporcionados no son válidos." });
|
||||
var (exito, mensaje, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
|
||||
|
||||
var (exito, mensaje, resultadoEnvio) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
|
||||
|
||||
if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje });
|
||||
return Ok(new { message = mensaje, facturasGeneradas });
|
||||
|
||||
return Ok(new { message = mensaje, resultadoEnvio });
|
||||
}
|
||||
|
||||
[HttpGet("historial-lotes-envio")]
|
||||
[ProducesResponseType(typeof(IEnumerable<LoteDeEnvioHistorialDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetHistorialLotesEnvio([FromQuery] int? anio, [FromQuery] int? mes)
|
||||
{
|
||||
if (!TienePermiso("SU006")) return Forbid();
|
||||
var historial = await _facturacionService.ObtenerHistorialLotesEnvio(anio, mes);
|
||||
return Ok(historial);
|
||||
}
|
||||
|
||||
// Endpoint para el historial de envíos de una factura individual
|
||||
[HttpGet("{idFactura:int}/historial-envios")]
|
||||
[ProducesResponseType(typeof(IEnumerable<EmailLogDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetHistorialEnvios(int idFactura)
|
||||
{
|
||||
var historial = await _emailLogService.ObtenerHistorialPorReferencia("Factura-" + idFactura);
|
||||
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); // Reutilizamos el permiso
|
||||
|
||||
// Construimos la referencia que se guarda en el log
|
||||
string referencia = $"Factura-{idFactura}";
|
||||
var historial = await _emailLogService.ObtenerHistorialPorReferencia(referencia);
|
||||
|
||||
return Ok(historial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
|
||||
public async Task CreateAsync(EmailLog log)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT INTO dbo.com_EmailLogs
|
||||
(FechaEnvio, DestinatarioEmail, Asunto, Estado, Error, IdUsuarioDisparo, Origen, ReferenciaId)
|
||||
VALUES
|
||||
(@FechaEnvio, @DestinatarioEmail, @Asunto, @Estado, @Error, @IdUsuarioDisparo, @Origen, @ReferenciaId);";
|
||||
INSERT INTO dbo.com_EmailLogs
|
||||
(FechaEnvio, DestinatarioEmail, Asunto, Estado, Error, IdUsuarioDisparo, Origen, ReferenciaId, IdLoteDeEnvio)
|
||||
VALUES
|
||||
(@FechaEnvio, @DestinatarioEmail, @Asunto, @Estado, @Error, @IdUsuarioDisparo, @Origen, @ReferenciaId, @IdLoteDeEnvio);";
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(sql, log);
|
||||
@@ -42,5 +42,24 @@ namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
|
||||
return Enumerable.Empty<EmailLog>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<EmailLog>> GetByLoteIdAsync(int idLoteDeEnvio)
|
||||
{
|
||||
// Ordenamos por Estado descendente para que los 'Fallidos' aparezcan primero
|
||||
const string sql = @"
|
||||
SELECT * FROM dbo.com_EmailLogs
|
||||
WHERE IdLoteDeEnvio = @IdLoteDeEnvio
|
||||
ORDER BY Estado DESC, FechaEnvio DESC;";
|
||||
try
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<EmailLog>(sql, new { IdLoteDeEnvio = idLoteDeEnvio });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener logs de email por IdLoteDeEnvio: {IdLoteDeEnvio}", idLoteDeEnvio);
|
||||
return Enumerable.Empty<EmailLog>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,12 @@ namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
|
||||
/// <param name="referenciaId">El identificador de la entidad (ej. "Factura-59").</param>
|
||||
/// <returns>Una colección de registros de log de email.</returns>
|
||||
Task<IEnumerable<EmailLog>> GetByReferenceAsync(string referenciaId);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene todos los registros de log de email que pertenecen a un lote de envío masivo.
|
||||
/// </summary>
|
||||
/// <param name="idLoteDeEnvio">El ID del lote de envío.</param>
|
||||
/// <returns>Una colección de registros de log de email.</returns>
|
||||
Task<IEnumerable<EmailLog>> GetByLoteIdAsync(int idLoteDeEnvio);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
|
||||
{
|
||||
public interface ILoteDeEnvioRepository
|
||||
{
|
||||
Task<LoteDeEnvio> CreateAsync(LoteDeEnvio lote);
|
||||
Task<bool> UpdateAsync(LoteDeEnvio lote);
|
||||
Task<IEnumerable<LoteDeEnvio>> GetAllAsync(int? anio, int? mes);
|
||||
Task<LoteDeEnvio?> GetByIdAsync(int id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
|
||||
{
|
||||
public class LoteDeEnvioRepository : ILoteDeEnvioRepository
|
||||
{
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
public LoteDeEnvioRepository(DbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<LoteDeEnvio> CreateAsync(LoteDeEnvio lote)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT INTO dbo.com_LotesDeEnvio (FechaInicio, Periodo, Origen, Estado, IdUsuarioDisparo)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES (@FechaInicio, @Periodo, @Origen, @Estado, @IdUsuarioDisparo);";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<LoteDeEnvio>(sql, lote);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(LoteDeEnvio lote)
|
||||
{
|
||||
const string sql = @"
|
||||
UPDATE dbo.com_LotesDeEnvio SET
|
||||
FechaFin = @FechaFin,
|
||||
Estado = @Estado,
|
||||
TotalCorreos = @TotalCorreos,
|
||||
TotalEnviados = @TotalEnviados,
|
||||
TotalFallidos = @TotalFallidos
|
||||
WHERE IdLoteDeEnvio = @IdLoteDeEnvio;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
var rows = await connection.ExecuteAsync(sql, lote);
|
||||
return rows == 1;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LoteDeEnvio>> GetAllAsync(int? anio, int? mes)
|
||||
{
|
||||
var sqlBuilder = new StringBuilder("SELECT * FROM dbo.com_LotesDeEnvio WHERE 1=1");
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
if (anio.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND YEAR(FechaInicio) = @Anio");
|
||||
parameters.Add("Anio", anio.Value);
|
||||
}
|
||||
if (mes.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND MONTH(FechaInicio) = @Mes");
|
||||
parameters.Add("Mes", mes.Value);
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY FechaInicio DESC;");
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<LoteDeEnvio>(sqlBuilder.ToString(), parameters);
|
||||
}
|
||||
|
||||
public async Task<LoteDeEnvio?> GetByIdAsync(int id)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.com_LotesDeEnvio WHERE IdLoteDeEnvio = @Id;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<LoteDeEnvio>(sql, new { Id = id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
|
||||
Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
|
||||
Task<IEnumerable<ListadoDistCanMensualPubDto>> GetReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
|
||||
Task<IEnumerable<FacturasParaReporteDto>> GetDatosReportePublicidadAsync(string periodo);
|
||||
Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta);
|
||||
Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesActivasAsync(DateTime fechaDesde, DateTime fechaHasta);
|
||||
Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesBajasAsync(DateTime fechaDesde, DateTime fechaHasta);
|
||||
}
|
||||
}
|
||||
@@ -593,33 +593,25 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta)
|
||||
public async Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesActivasAsync(DateTime fechaDesde, DateTime fechaHasta)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT
|
||||
e.Nombre AS NombreEmpresa,
|
||||
p.Nombre AS NombrePublicacion,
|
||||
sus.NombreCompleto AS NombreSuscriptor,
|
||||
sus.Direccion,
|
||||
sus.Telefono,
|
||||
s.FechaInicio,
|
||||
s.FechaFin,
|
||||
s.DiasEntrega,
|
||||
s.Observaciones
|
||||
e.Nombre AS NombreEmpresa, p.Nombre AS NombrePublicacion,
|
||||
sus.NombreCompleto AS NombreSuscriptor, sus.Direccion, sus.Telefono,
|
||||
s.FechaInicio, s.FechaFin, s.DiasEntrega, s.Observaciones
|
||||
FROM dbo.susc_Suscripciones s
|
||||
JOIN dbo.susc_Suscriptores sus ON s.IdSuscriptor = sus.IdSuscriptor
|
||||
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
|
||||
JOIN dbo.dist_dtEmpresas e ON p.Id_Empresa = e.Id_Empresa
|
||||
WHERE
|
||||
s.Estado = 'Activa'
|
||||
AND sus.Activo = 1
|
||||
-- La suscripción debe haber comenzado ANTES de que termine el rango de fechas
|
||||
-- --- INICIO DE LA CORRECCIÓN ---
|
||||
-- Se asegura de que SOLO se incluyan suscripciones y suscriptores ACTIVOS.
|
||||
s.Estado = 'Activa' AND sus.Activo = 1
|
||||
-- --- FIN DE LA CORRECCIÓN ---
|
||||
AND s.FechaInicio <= @FechaHasta
|
||||
-- Y debe terminar DESPUÉS de que comience el rango de fechas (o no tener fecha de fin)
|
||||
AND (s.FechaFin IS NULL OR s.FechaFin >= @FechaDesde)
|
||||
ORDER BY
|
||||
e.Nombre, p.Nombre, sus.NombreCompleto;
|
||||
";
|
||||
ORDER BY e.Nombre, p.Nombre, sus.NombreCompleto;";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -628,7 +620,36 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener datos para el Reporte de Distribución de Suscripciones.");
|
||||
_logger.LogError(ex, "Error al obtener datos para Reporte de Distribución (Activas).");
|
||||
return Enumerable.Empty<DistribucionSuscripcionDto>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesBajasAsync(DateTime fechaDesde, DateTime fechaHasta)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT
|
||||
e.Nombre AS NombreEmpresa, p.Nombre AS NombrePublicacion,
|
||||
sus.NombreCompleto AS NombreSuscriptor, sus.Direccion, sus.Telefono,
|
||||
s.FechaInicio, s.FechaFin, s.DiasEntrega, s.Observaciones
|
||||
FROM dbo.susc_Suscripciones s
|
||||
JOIN dbo.susc_Suscriptores sus ON s.IdSuscriptor = sus.IdSuscriptor
|
||||
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
|
||||
JOIN dbo.dist_dtEmpresas e ON p.Id_Empresa = e.Id_Empresa
|
||||
WHERE
|
||||
-- La lógica aquí es correcta: buscamos cualquier suscripción cuya fecha de fin
|
||||
-- caiga dentro del rango de fechas seleccionado.
|
||||
s.FechaFin BETWEEN @FechaDesde AND @FechaHasta
|
||||
ORDER BY e.Nombre, p.Nombre, s.FechaFin, sus.NombreCompleto;";
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _dbConnectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<DistribucionSuscripcionDto>(sql, new { FechaDesde = fechaDesde, FechaHasta = fechaHasta });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener datos para Reporte de Distribución (Bajas).");
|
||||
return Enumerable.Empty<DistribucionSuscripcionDto>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ namespace GestionIntegral.Api.Models.Comunicaciones
|
||||
public int? IdUsuarioDisparo { get; set; }
|
||||
public string? Origen { get; set; }
|
||||
public string? ReferenciaId { get; set; }
|
||||
public int? IdLoteDeEnvio { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace GestionIntegral.Api.Models.Comunicaciones
|
||||
{
|
||||
public class LoteDeEnvio
|
||||
{
|
||||
public int IdLoteDeEnvio { get; set; }
|
||||
public DateTime FechaInicio { get; set; }
|
||||
public DateTime? FechaFin { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public string Origen { get; set; } = string.Empty;
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public int TotalCorreos { get; set; }
|
||||
public int TotalEnviados { get; set; }
|
||||
public int TotalFallidos { get; set; }
|
||||
public int IdUsuarioDisparo { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace GestionIntegral.Api.Dtos.Comunicaciones
|
||||
{
|
||||
// DTO para el feedback inmediato
|
||||
public class LoteDeEnvioResumenDto
|
||||
{
|
||||
public int IdLoteDeEnvio { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public int TotalCorreos { get; set; }
|
||||
public int TotalEnviados { get; set; }
|
||||
public int TotalFallidos { get; set; }
|
||||
public List<EmailLogDto> ErroresDetallados { get; set; } = new();
|
||||
}
|
||||
|
||||
// DTO para la tabla de historial
|
||||
public class LoteDeEnvioHistorialDto
|
||||
{
|
||||
public int IdLoteDeEnvio { get; set; }
|
||||
public DateTime FechaInicio { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public int TotalCorreos { get; set; }
|
||||
public int TotalEnviados { get; set; }
|
||||
public int TotalFallidos { get; set; }
|
||||
public string NombreUsuarioDisparo { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
|
||||
public class LoteDeEnvioResumenDto
|
||||
{
|
||||
public int IdLoteDeEnvio { get; set; }
|
||||
public required string Periodo { get; set; }
|
||||
public int TotalCorreos { get; set; }
|
||||
public int TotalEnviados { get; set; }
|
||||
public int TotalFallidos { get; set; }
|
||||
public List<EmailLogDto> ErroresDetallados { get; set; } = new();
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
|
||||
{
|
||||
// Clases internas para la agrupación
|
||||
/// <summary>
|
||||
/// Representa una agrupación de suscripciones por publicación para el reporte.
|
||||
/// </summary>
|
||||
public class GrupoPublicacion
|
||||
{
|
||||
public string NombrePublicacion { get; set; } = string.Empty;
|
||||
public IEnumerable<DistribucionSuscripcionDto> Suscripciones { get; set; } = Enumerable.Empty<DistribucionSuscripcionDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Representa una agrupación de publicaciones por empresa para el reporte.
|
||||
/// </summary>
|
||||
public class GrupoEmpresa
|
||||
{
|
||||
public string NombreEmpresa { get; set; } = string.Empty;
|
||||
@@ -15,28 +20,36 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
|
||||
|
||||
public class DistribucionSuscripcionesViewModel
|
||||
{
|
||||
public IEnumerable<GrupoEmpresa> DatosAgrupados { get; }
|
||||
public IEnumerable<GrupoEmpresa> DatosAgrupadosAltas { get; }
|
||||
public IEnumerable<GrupoEmpresa> DatosAgrupadosBajas { get; }
|
||||
public string FechaDesde { get; set; } = string.Empty;
|
||||
public string FechaHasta { get; set; } = string.Empty;
|
||||
public string FechaGeneracion { get; set; } = string.Empty;
|
||||
|
||||
public DistribucionSuscripcionesViewModel(IEnumerable<DistribucionSuscripcionDto> suscripciones)
|
||||
public DistribucionSuscripcionesViewModel(IEnumerable<DistribucionSuscripcionDto> altas, IEnumerable<DistribucionSuscripcionDto> bajas)
|
||||
{
|
||||
DatosAgrupados = suscripciones
|
||||
.GroupBy(s => s.NombreEmpresa)
|
||||
.Select(gEmpresa => new GrupoEmpresa
|
||||
{
|
||||
NombreEmpresa = gEmpresa.Key,
|
||||
Publicaciones = gEmpresa
|
||||
.GroupBy(s => s.NombrePublicacion)
|
||||
.Select(gPub => new GrupoPublicacion
|
||||
{
|
||||
NombrePublicacion = gPub.Key,
|
||||
Suscripciones = gPub.OrderBy(s => s.NombreSuscriptor).ToList()
|
||||
})
|
||||
.OrderBy(p => p.NombrePublicacion)
|
||||
})
|
||||
.OrderBy(e => e.NombreEmpresa);
|
||||
// Función local para evitar repetir el código de agrupación
|
||||
Func<IEnumerable<DistribucionSuscripcionDto>, IEnumerable<GrupoEmpresa>> agruparDatos = (suscripciones) =>
|
||||
{
|
||||
return suscripciones
|
||||
.GroupBy(s => s.NombreEmpresa)
|
||||
.Select(gEmpresa => new GrupoEmpresa
|
||||
{
|
||||
NombreEmpresa = gEmpresa.Key,
|
||||
Publicaciones = gEmpresa
|
||||
.GroupBy(s => s.NombrePublicacion)
|
||||
.Select(gPub => new GrupoPublicacion
|
||||
{
|
||||
NombrePublicacion = gPub.Key,
|
||||
Suscripciones = gPub.OrderBy(s => s.NombreSuscriptor).ToList()
|
||||
})
|
||||
.OrderBy(p => p.NombrePublicacion)
|
||||
})
|
||||
.OrderBy(e => e.NombreEmpresa);
|
||||
};
|
||||
|
||||
DatosAgrupadosAltas = agruparDatos(altas);
|
||||
DatosAgrupadosBajas = agruparDatos(bajas);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailS
|
||||
builder.Services.AddTransient<IEmailService, EmailService>();
|
||||
builder.Services.AddScoped<IEmailLogRepository, EmailLogRepository>();
|
||||
builder.Services.AddScoped<IEmailLogService, EmailLogService>();
|
||||
builder.Services.AddScoped<ILoteDeEnvioRepository, LoteDeEnvioRepository>();
|
||||
|
||||
// --- SERVICIO DE HEALTH CHECKS ---
|
||||
// Añadimos una comprobación específica para SQL Server.
|
||||
|
||||
@@ -49,5 +49,39 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
: "Sistema"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio)
|
||||
{
|
||||
var logs = await _emailLogRepository.GetByLoteIdAsync(idLoteDeEnvio);
|
||||
if (!logs.Any())
|
||||
{
|
||||
return Enumerable.Empty<EmailLogDto>();
|
||||
}
|
||||
|
||||
// Reutilizamos la misma lógica de optimización N+1 que ya teníamos
|
||||
var idsUsuarios = logs
|
||||
.Where(l => l.IdUsuarioDisparo.HasValue)
|
||||
.Select(l => l.IdUsuarioDisparo!.Value)
|
||||
.Distinct();
|
||||
|
||||
var usuariosDict = new Dictionary<int, string>();
|
||||
if (idsUsuarios.Any())
|
||||
{
|
||||
var usuarios = await _usuarioRepository.GetByIdsAsync(idsUsuarios);
|
||||
usuariosDict = usuarios.ToDictionary(u => u.Id, u => $"{u.Nombre} {u.Apellido}");
|
||||
}
|
||||
|
||||
return logs.Select(log => new EmailLogDto
|
||||
{
|
||||
FechaEnvio = log.FechaEnvio,
|
||||
Estado = log.Estado,
|
||||
Asunto = log.Asunto,
|
||||
DestinatarioEmail = log.DestinatarioEmail,
|
||||
Error = log.Error,
|
||||
NombreUsuarioDisparo = log.IdUsuarioDisparo.HasValue
|
||||
? usuariosDict.GetValueOrDefault(log.IdUsuarioDisparo.Value, "Usuario Desconocido")
|
||||
: "Sistema"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
public EmailService(
|
||||
IOptions<MailSettings> mailSettings,
|
||||
ILogger<EmailService> logger,
|
||||
IEmailLogRepository emailLogRepository) // Inyectar el nuevo repositorio
|
||||
IEmailLogRepository emailLogRepository)
|
||||
{
|
||||
_mailSettings = mailSettings.Value;
|
||||
_logger = logger;
|
||||
@@ -26,7 +26,8 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
public async Task EnviarEmailAsync(
|
||||
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
|
||||
byte[]? attachment = null, string? attachmentName = null,
|
||||
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null)
|
||||
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null,
|
||||
int? idLoteDeEnvio = null)
|
||||
{
|
||||
var email = new MimeMessage();
|
||||
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
|
||||
@@ -41,14 +42,14 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
}
|
||||
email.Body = builder.ToMessageBody();
|
||||
|
||||
// Llamar al método centralizado de envío y logging
|
||||
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo);
|
||||
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
|
||||
}
|
||||
|
||||
public async Task EnviarEmailConsolidadoAsync(
|
||||
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
|
||||
List<(byte[] content, string name)> adjuntos,
|
||||
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null)
|
||||
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null,
|
||||
int? idLoteDeEnvio = null)
|
||||
{
|
||||
var email = new MimeMessage();
|
||||
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
|
||||
@@ -66,14 +67,10 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
}
|
||||
email.Body = builder.ToMessageBody();
|
||||
|
||||
// Llamar al método centralizado de envío y logging
|
||||
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo);
|
||||
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Método privado que centraliza el envío de correo y el registro de logs.
|
||||
/// </summary>
|
||||
private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo)
|
||||
private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo, int? idLoteDeEnvio)
|
||||
{
|
||||
var destinatario = emailMessage.To.Mailboxes.FirstOrDefault()?.Address ?? "desconocido";
|
||||
|
||||
@@ -84,7 +81,8 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
Asunto = emailMessage.Subject,
|
||||
Origen = origen,
|
||||
ReferenciaId = referenciaId,
|
||||
IdUsuarioDisparo = idUsuarioDisparo
|
||||
IdUsuarioDisparo = idUsuarioDisparo,
|
||||
IdLoteDeEnvio = idLoteDeEnvio
|
||||
};
|
||||
|
||||
using var smtp = new SmtpClient();
|
||||
@@ -97,11 +95,8 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
log.Estado = "Enviado";
|
||||
_logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
|
||||
}
|
||||
// Capturamos excepciones específicas de MailKit para obtener errores más detallados.
|
||||
catch (SmtpCommandException scEx)
|
||||
{
|
||||
// Este error ocurre cuando el servidor SMTP rechaza un comando.
|
||||
// Es el caso más común para direcciones de email inválidas que son rechazadas inmediatamente.
|
||||
_logger.LogError(scEx, "Error de comando SMTP al enviar a {Destinatario}. StatusCode: {StatusCode}", destinatario, scEx.StatusCode);
|
||||
log.Estado = "Fallido";
|
||||
log.Error = $"Error del servidor: ({scEx.StatusCode}) {scEx.Message}";
|
||||
@@ -109,13 +104,12 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
}
|
||||
catch (AuthenticationException authEx)
|
||||
{
|
||||
// Error específico de autenticación.
|
||||
_logger.LogError(authEx, "Error de autenticación con el servidor SMTP.");
|
||||
log.Estado = "Fallido";
|
||||
log.Error = "Error de autenticación. Revise las credenciales de correo.";
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) // Captura genérica para cualquier otro problema (conexión, etc.)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
|
||||
log.Estado = "Fallido";
|
||||
@@ -129,7 +123,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
await smtp.DisconnectAsync(true);
|
||||
}
|
||||
|
||||
// Guardar el log en la base de datos, sin importar el resultado del envío
|
||||
try
|
||||
{
|
||||
await _emailLogRepository.CreateAsync(log);
|
||||
|
||||
@@ -5,5 +5,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
public interface IEmailLogService
|
||||
{
|
||||
Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId);
|
||||
Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
/// <param name="origen">Identificador del proceso que dispara el email (ej. "EnvioManualPDF"). Para logging.</param>
|
||||
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Factura-59"). Para logging.</param>
|
||||
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
|
||||
/// <param name="idLoteDeEnvio">ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging.</param>
|
||||
Task EnviarEmailAsync(
|
||||
string destinatarioEmail,
|
||||
string destinatarioNombre,
|
||||
@@ -24,7 +25,8 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
string? attachmentName = null,
|
||||
string? origen = null,
|
||||
string? referenciaId = null,
|
||||
int? idUsuarioDisparo = null);
|
||||
int? idUsuarioDisparo = null,
|
||||
int? idLoteDeEnvio = null);
|
||||
|
||||
/// <summary>
|
||||
/// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar múltiples archivos.
|
||||
@@ -38,6 +40,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
/// <param name="origen">Identificador del proceso que dispara el email (ej. "FacturacionMensual"). Para logging.</param>
|
||||
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Suscriptor-3"). Para logging.</param>
|
||||
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
|
||||
/// <param name="idLoteDeEnvio">ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging.</param>
|
||||
Task EnviarEmailConsolidadoAsync(
|
||||
string destinatarioEmail,
|
||||
string destinatarioNombre,
|
||||
@@ -46,6 +49,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
List<(byte[] content, string name)> adjuntos,
|
||||
string? origen = null,
|
||||
string? referenciaId = null,
|
||||
int? idUsuarioDisparo = null);
|
||||
int? idUsuarioDisparo = null,
|
||||
int? idLoteDeEnvio = null);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,6 @@ namespace GestionIntegral.Api.Services.Reportes
|
||||
Task<(IEnumerable<ListadoDistCanMensualDiariosDto> Data, string? Error)> ObtenerReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
|
||||
Task<(IEnumerable<ListadoDistCanMensualPubDto> Data, string? Error)> ObtenerReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
|
||||
Task<(IEnumerable<FacturasParaReporteDto> Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes);
|
||||
Task<(IEnumerable<DistribucionSuscripcionDto> Data, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta);
|
||||
Task<(IEnumerable<DistribucionSuscripcionDto> Altas, IEnumerable<DistribucionSuscripcionDto> Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta);
|
||||
}
|
||||
}
|
||||
@@ -551,21 +551,27 @@ namespace GestionIntegral.Api.Services.Reportes
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<DistribucionSuscripcionDto> Data, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta)
|
||||
public async Task<(IEnumerable<DistribucionSuscripcionDto> Altas, IEnumerable<DistribucionSuscripcionDto> Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta)
|
||||
{
|
||||
if (fechaDesde > fechaHasta)
|
||||
{
|
||||
return (Enumerable.Empty<DistribucionSuscripcionDto>(), "La fecha 'Desde' no puede ser mayor que la fecha 'Hasta'.");
|
||||
return (Enumerable.Empty<DistribucionSuscripcionDto>(), Enumerable.Empty<DistribucionSuscripcionDto>(), "La fecha 'Desde' no puede ser mayor que la fecha 'Hasta'.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = await _reportesRepository.GetDistribucionSuscripcionesAsync(fechaDesde, fechaHasta);
|
||||
return (data, null);
|
||||
// Ejecutamos ambas consultas en paralelo para mayor eficiencia
|
||||
var altasTask = _reportesRepository.GetDistribucionSuscripcionesActivasAsync(fechaDesde, fechaHasta);
|
||||
var bajasTask = _reportesRepository.GetDistribucionSuscripcionesBajasAsync(fechaDesde, fechaHasta);
|
||||
|
||||
await Task.WhenAll(altasTask, bajasTask);
|
||||
|
||||
return (await altasTask, await bajasTask, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error en servicio al obtener datos para reporte de distribución de suscripciones.");
|
||||
return (new List<DistribucionSuscripcionDto>(), "Error interno al generar el reporte.");
|
||||
return (Enumerable.Empty<DistribucionSuscripcionDto>(), Enumerable.Empty<DistribucionSuscripcionDto>(), "Error interno al generar el reporte.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,21 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Distribucion;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
|
||||
using GestionIntegral.Api.Data.Repositories.Usuarios;
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public class FacturacionService : IFacturacionService
|
||||
{
|
||||
private readonly ILoteDeEnvioRepository _loteDeEnvioRepository;
|
||||
private readonly IUsuarioRepository _usuarioRepository;
|
||||
private readonly ISuscripcionRepository _suscripcionRepository;
|
||||
private readonly IFacturaRepository _facturaRepository;
|
||||
private readonly IEmpresaRepository _empresaRepository;
|
||||
@@ -41,8 +47,12 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
IPublicacionRepository publicacionRepository,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<FacturacionService> logger,
|
||||
IConfiguration configuration)
|
||||
IConfiguration configuration,
|
||||
ILoteDeEnvioRepository loteDeEnvioRepository,
|
||||
IUsuarioRepository usuarioRepository)
|
||||
{
|
||||
_loteDeEnvioRepository = loteDeEnvioRepository;
|
||||
_usuarioRepository = usuarioRepository;
|
||||
_suscripcionRepository = suscripcionRepository;
|
||||
_facturaRepository = facturaRepository;
|
||||
_empresaRepository = empresaRepository;
|
||||
@@ -58,12 +68,23 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
_facturasPdfPath = configuration.GetValue<string>("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF";
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
|
||||
public async Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
|
||||
{
|
||||
var periodoActual = new DateTime(anio, mes, 1);
|
||||
var periodoActualStr = periodoActual.ToString("yyyy-MM");
|
||||
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodoActualStr, idUsuario);
|
||||
|
||||
// --- INICIO: Creación del Lote de Envío ---
|
||||
var lote = await _loteDeEnvioRepository.CreateAsync(new LoteDeEnvio
|
||||
{
|
||||
FechaInicio = DateTime.Now,
|
||||
Periodo = periodoActualStr,
|
||||
Origen = "FacturacionMensual",
|
||||
Estado = "Iniciado",
|
||||
IdUsuarioDisparo = idUsuario
|
||||
});
|
||||
// --- FIN: Creación del Lote de Envío ---
|
||||
|
||||
var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync();
|
||||
if (ultimoPeriodoFacturadoStr != null)
|
||||
{
|
||||
@@ -71,11 +92,16 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
if (periodoActual != ultimoPeriodo.AddMonths(1))
|
||||
{
|
||||
var periodoEsperado = ultimoPeriodo.AddMonths(1).ToString("MMMM 'de' yyyy", new CultureInfo("es-ES"));
|
||||
return (false, $"Error: No se puede generar la facturación de {periodoActual:MMMM 'de' yyyy}. El siguiente período a generar es {periodoEsperado}.", 0);
|
||||
return (false, $"Error: No se puede generar la facturación de {periodoActual:MMMM 'de' yyyy}. El siguiente período a generar es {periodoEsperado}.", null);
|
||||
}
|
||||
}
|
||||
|
||||
var facturasCreadas = new List<Factura>();
|
||||
int facturasGeneradas = 0;
|
||||
int emailsEnviados = 0;
|
||||
int emailsFallidos = 0;
|
||||
var erroresDetallados = new List<EmailLogDto>();
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
@@ -85,10 +111,13 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction);
|
||||
if (!suscripcionesActivas.Any())
|
||||
{
|
||||
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0);
|
||||
// Si no hay nada que facturar, consideramos el proceso exitoso pero sin resultados.
|
||||
lote.Estado = "Completado";
|
||||
lote.FechaFin = DateTime.Now;
|
||||
await _loteDeEnvioRepository.UpdateAsync(lote);
|
||||
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", null);
|
||||
}
|
||||
|
||||
// 1. Enriquecer las suscripciones con el IdEmpresa de su publicación
|
||||
var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>();
|
||||
foreach (var s in suscripcionesActivas)
|
||||
{
|
||||
@@ -99,30 +128,23 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Agrupar por la combinación (Suscriptor, Empresa)
|
||||
var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa });
|
||||
|
||||
int facturasGeneradas = 0;
|
||||
foreach (var grupo in gruposParaFacturar)
|
||||
{
|
||||
int idSuscriptor = grupo.Key.IdSuscriptor;
|
||||
int idEmpresa = grupo.Key.IdEmpresa; // <-- Ya tenemos la empresa del grupo
|
||||
|
||||
int idEmpresa = grupo.Key.IdEmpresa;
|
||||
decimal importeBrutoTotal = 0;
|
||||
decimal descuentoPromocionesTotal = 0;
|
||||
var detallesParaFactura = new List<FacturaDetalle>();
|
||||
|
||||
// 3. Calcular el importe para cada suscripción DENTRO del grupo
|
||||
foreach (var item in grupo)
|
||||
{
|
||||
var suscripcion = item.Suscripcion;
|
||||
decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
|
||||
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction);
|
||||
decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones);
|
||||
|
||||
importeBrutoTotal += importeBrutoSusc;
|
||||
descuentoPromocionesTotal += descuentoSusc;
|
||||
|
||||
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion);
|
||||
detallesParaFactura.Add(new FacturaDetalle
|
||||
{
|
||||
@@ -133,24 +155,12 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
ImporteNeto = importeBrutoSusc - descuentoSusc
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Aplicar ajustes. Ahora se buscan por Suscriptor Y por Empresa.
|
||||
var ultimoDiaDelMes = periodoActual.AddMonths(1).AddDays(-1);
|
||||
var ajustesPendientes = await _ajusteRepository.GetAjustesPendientesHastaFechaAsync(idSuscriptor, idEmpresa, ultimoDiaDelMes, transaction);
|
||||
decimal totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto);
|
||||
|
||||
// Verificamos si este grupo es el "primero" para este cliente para no aplicar ajustes varias veces
|
||||
bool esPrimerGrupoParaCliente = !facturasCreadas.Any(f => f.IdSuscriptor == idSuscriptor);
|
||||
if (esPrimerGrupoParaCliente)
|
||||
{
|
||||
totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto);
|
||||
}
|
||||
|
||||
var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes;
|
||||
if (importeFinal < 0) importeFinal = 0;
|
||||
if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue;
|
||||
|
||||
// 5. Crear UNA factura por cada grupo (Suscriptor + Empresa)
|
||||
var nuevaFactura = new Factura
|
||||
{
|
||||
IdSuscriptor = idSuscriptor,
|
||||
@@ -163,60 +173,109 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
EstadoPago = "Pendiente",
|
||||
EstadoFacturacion = "Pendiente de Facturar"
|
||||
};
|
||||
|
||||
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
|
||||
if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}");
|
||||
|
||||
facturasCreadas.Add(facturaCreada);
|
||||
|
||||
foreach (var detalle in detallesParaFactura)
|
||||
{
|
||||
detalle.IdFactura = facturaCreada.IdFactura;
|
||||
await _facturaDetalleRepository.CreateAsync(detalle, transaction);
|
||||
}
|
||||
|
||||
if (ajustesPendientes.Any())
|
||||
{
|
||||
await _ajusteRepository.MarcarAjustesComoAplicadosAsync(ajustesPendientes.Select(a => a.IdAjuste), facturaCreada.IdFactura, transaction);
|
||||
}
|
||||
facturasGeneradas++;
|
||||
}
|
||||
// --- FIN DE LA LÓGICA DE AGRUPACIÓN ---
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr);
|
||||
|
||||
// --- INICIO DE LA LÓGICA DE ENVÍO CONSOLIDADO AUTOMÁTICO ---
|
||||
int emailsEnviados = 0;
|
||||
if (facturasCreadas.Any())
|
||||
{
|
||||
// Agrupamos las facturas creadas por suscriptor para enviar un solo email
|
||||
var suscriptoresAnotificar = facturasCreadas.Select(f => f.IdSuscriptor).Distinct().ToList();
|
||||
_logger.LogInformation("Iniciando envío automático de avisos para {Count} suscriptores.", suscriptoresAnotificar.Count);
|
||||
|
||||
foreach (var idSuscriptor in suscriptoresAnotificar)
|
||||
{
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); // Necesitamos el objeto suscriptor
|
||||
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email))
|
||||
{
|
||||
emailsFallidos++;
|
||||
erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor?.NombreCompleto ?? $"ID Suscriptor {idSuscriptor}", Error = "Suscriptor sin email válido." });
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (envioExitoso, _) = await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor);
|
||||
if (envioExitoso) emailsEnviados++;
|
||||
await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor, lote.IdLoteDeEnvio, idUsuario);
|
||||
emailsEnviados++;
|
||||
}
|
||||
catch (Exception exEmail)
|
||||
{
|
||||
emailsFallidos++;
|
||||
erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor.Email, Error = exEmail.Message });
|
||||
_logger.LogError(exEmail, "Falló el envío automático de email para el suscriptor ID {IdSuscriptor}", idSuscriptor);
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("{EmailsEnviados} avisos de vencimiento enviados automáticamente.", emailsEnviados);
|
||||
}
|
||||
|
||||
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas y se enviaron {emailsEnviados} notificaciones.", facturasGeneradas);
|
||||
lote.Estado = "Completado";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
lote.Estado = "Fallido";
|
||||
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodoActualStr);
|
||||
return (false, "Error interno del servidor al generar la facturación.", 0);
|
||||
return (false, "Error interno del servidor al generar la facturación.", null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lote.FechaFin = DateTime.Now;
|
||||
lote.TotalCorreos = emailsEnviados + emailsFallidos;
|
||||
lote.TotalEnviados = emailsEnviados;
|
||||
lote.TotalFallidos = emailsFallidos;
|
||||
await _loteDeEnvioRepository.UpdateAsync(lote);
|
||||
}
|
||||
|
||||
var resultadoEnvio = new LoteDeEnvioResumenDto
|
||||
{
|
||||
IdLoteDeEnvio = lote.IdLoteDeEnvio,
|
||||
Periodo = periodoActualStr,
|
||||
TotalCorreos = lote.TotalCorreos,
|
||||
TotalEnviados = lote.TotalEnviados,
|
||||
TotalFallidos = lote.TotalFallidos,
|
||||
ErroresDetallados = erroresDetallados
|
||||
};
|
||||
|
||||
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", resultadoEnvio);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes)
|
||||
{
|
||||
var lotes = await _loteDeEnvioRepository.GetAllAsync(anio, mes);
|
||||
if (!lotes.Any())
|
||||
{
|
||||
return Enumerable.Empty<LoteDeEnvioHistorialDto>();
|
||||
}
|
||||
|
||||
var idsUsuarios = lotes.Select(l => l.IdUsuarioDisparo).Distinct();
|
||||
var usuarios = (await _usuarioRepository.GetByIdsAsync(idsUsuarios)).ToDictionary(u => u.Id);
|
||||
|
||||
return lotes.Select(l => new LoteDeEnvioHistorialDto
|
||||
{
|
||||
IdLoteDeEnvio = l.IdLoteDeEnvio,
|
||||
FechaInicio = l.FechaInicio,
|
||||
Periodo = l.Periodo,
|
||||
Estado = l.Estado,
|
||||
TotalCorreos = l.TotalCorreos,
|
||||
TotalEnviados = l.TotalEnviados,
|
||||
TotalFallidos = l.TotalFallidos,
|
||||
NombreUsuarioDisparo = usuarios.TryGetValue(l.IdUsuarioDisparo, out var user)
|
||||
? $"{user.Nombre} {user.Apellido}"
|
||||
: "Usuario Desconocido"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion)
|
||||
@@ -317,91 +376,101 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor)
|
||||
/// <summary>
|
||||
/// Construye y envía un email consolidado con el resumen de todas las facturas de un suscriptor para un período.
|
||||
/// Este método está diseñado para ser llamado desde un proceso masivo como la facturación mensual.
|
||||
/// </summary>
|
||||
private async Task EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor, int idLoteDeEnvio, int idUsuarioDisparo)
|
||||
{
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
try
|
||||
|
||||
// La lógica de try/catch ahora está en el método llamador (GenerarFacturacionMensual)
|
||||
// para poder contar los fallos y actualizar el lote de envío.
|
||||
|
||||
var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo);
|
||||
if (!facturasConEmpresa.Any())
|
||||
{
|
||||
var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo);
|
||||
if (!facturasConEmpresa.Any()) return (false, "No se encontraron facturas para este suscriptor en el período.");
|
||||
// Si no hay facturas, no hay nada que enviar. Esto no debería ocurrir si se llama desde GenerarFacturacionMensual.
|
||||
_logger.LogWarning("Se intentó enviar aviso para Suscriptor ID {IdSuscriptor} en período {Periodo}, pero no se encontraron facturas.", idSuscriptor, periodo);
|
||||
return;
|
||||
}
|
||||
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor);
|
||||
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.");
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor);
|
||||
// La validación de si el suscriptor tiene email ya se hace en el método llamador.
|
||||
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email))
|
||||
{
|
||||
// Lanzamos una excepción para que el método llamador la capture y la cuente como un fallo.
|
||||
throw new InvalidOperationException($"El suscriptor ID {idSuscriptor} no es válido o no tiene una dirección de email registrada.");
|
||||
}
|
||||
|
||||
var resumenHtml = new StringBuilder();
|
||||
var adjuntos = new List<(byte[] content, string name)>();
|
||||
var resumenHtml = new StringBuilder();
|
||||
var adjuntos = new List<(byte[] content, string name)>();
|
||||
|
||||
foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada"))
|
||||
foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada"))
|
||||
{
|
||||
var factura = item.Factura;
|
||||
var nombreEmpresa = item.NombreEmpresa;
|
||||
var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
|
||||
|
||||
resumenHtml.Append($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen para {nombreEmpresa}</h4>");
|
||||
resumenHtml.Append("<table style='width: 100%; border-collapse: collapse; font-size: 0.9em;'>");
|
||||
|
||||
foreach (var detalle in detalles)
|
||||
{
|
||||
var factura = item.Factura;
|
||||
var nombreEmpresa = item.NombreEmpresa;
|
||||
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee;'>{detalle.Descripcion}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right;'>${detalle.ImporteNeto:N2}</td></tr>");
|
||||
}
|
||||
|
||||
var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
|
||||
|
||||
resumenHtml.Append($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen para {nombreEmpresa}</h4>");
|
||||
resumenHtml.Append("<table style='width: 100%; border-collapse: collapse; font-size: 0.9em;'>");
|
||||
|
||||
foreach (var detalle in detalles)
|
||||
var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura);
|
||||
if (ajustes.Any())
|
||||
{
|
||||
foreach (var ajuste in ajustes)
|
||||
{
|
||||
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee;'>{detalle.Descripcion}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right;'>${detalle.ImporteNeto:N2}</td></tr>");
|
||||
}
|
||||
|
||||
var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura);
|
||||
if (ajustes.Any())
|
||||
{
|
||||
foreach (var ajuste in ajustes)
|
||||
{
|
||||
bool esCredito = ajuste.TipoAjuste == "Credito";
|
||||
string colorMonto = esCredito ? "#5cb85c" : "#d9534f";
|
||||
string signo = esCredito ? "-" : "+";
|
||||
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee; font-style: italic;'>Ajuste: {ajuste.Motivo}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right; color: {colorMonto}; font-style: italic;'>{signo} ${ajuste.Monto:N2}</td></tr>");
|
||||
}
|
||||
}
|
||||
|
||||
resumenHtml.Append($"<tr style='font-weight: bold;'><td style='padding: 5px;'>Subtotal</td><td style='padding: 5px; text-align: right;'>${factura.ImporteFinal:N2}</td></tr>");
|
||||
resumenHtml.Append("</table>");
|
||||
|
||||
if (!string.IsNullOrEmpty(factura.NumeroFactura))
|
||||
{
|
||||
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
|
||||
if (File.Exists(rutaCompleta))
|
||||
{
|
||||
byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta);
|
||||
string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
|
||||
adjuntos.Add((pdfBytes, pdfFileName));
|
||||
_logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
|
||||
}
|
||||
bool esCredito = ajuste.TipoAjuste == "Credito";
|
||||
string colorMonto = esCredito ? "#5cb85c" : "#d9534f";
|
||||
string signo = esCredito ? "-" : "+";
|
||||
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee; font-style: italic;'>Ajuste: {ajuste.Motivo}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right; color: {colorMonto}; font-style: italic;'>{signo} ${ajuste.Monto:N2}</td></tr>");
|
||||
}
|
||||
}
|
||||
|
||||
var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal);
|
||||
string asunto = $"Resumen de Cuenta - Período {periodo}";
|
||||
string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral);
|
||||
resumenHtml.Append($"<tr style='font-weight: bold;'><td style='padding: 5px;'>Subtotal</td><td style='padding: 5px; text-align: right;'>${factura.ImporteFinal:N2}</td></tr>");
|
||||
resumenHtml.Append("</table>");
|
||||
|
||||
// Añadir los parámetros de contexto aquí también
|
||||
await _emailService.EnviarEmailConsolidadoAsync(
|
||||
destinatarioEmail: suscriptor.Email,
|
||||
destinatarioNombre: suscriptor.NombreCompleto,
|
||||
asunto: asunto,
|
||||
cuerpoHtml: cuerpoHtml,
|
||||
adjuntos: adjuntos,
|
||||
origen: "FacturacionMensual",
|
||||
referenciaId: $"Suscriptor-{idSuscriptor}",
|
||||
idUsuarioDisparo: null); // Es null porque es un proceso automático del sistema
|
||||
if (!string.IsNullOrEmpty(factura.NumeroFactura))
|
||||
{
|
||||
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
|
||||
if (File.Exists(rutaCompleta))
|
||||
{
|
||||
byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta);
|
||||
string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
|
||||
adjuntos.Add((pdfBytes, pdfFileName));
|
||||
_logger.LogInformation("PDF adjuntado para envío a Suscriptor ID {IdSuscriptor}: {FileName}", idSuscriptor, pdfFileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _emailService.EnviarEmailConsolidadoAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, adjuntos);
|
||||
_logger.LogInformation("Email consolidado para Suscriptor ID {IdSuscriptor} enviado para el período {Periodo}.", idSuscriptor, periodo);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Falló el envío de email consolidado para el suscriptor ID {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo);
|
||||
return (false, "Ocurrió un error al intentar enviar el email consolidado.");
|
||||
}
|
||||
var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal);
|
||||
string asunto = $"Resumen de Cuenta - Período {periodo}";
|
||||
string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral);
|
||||
|
||||
await _emailService.EnviarEmailConsolidadoAsync(
|
||||
destinatarioEmail: suscriptor.Email,
|
||||
destinatarioNombre: suscriptor.NombreCompleto,
|
||||
asunto: asunto,
|
||||
cuerpoHtml: cuerpoHtml,
|
||||
adjuntos: adjuntos,
|
||||
origen: "FacturacionMensual",
|
||||
referenciaId: $"Suscriptor-{idSuscriptor}",
|
||||
idUsuarioDisparo: idUsuarioDisparo, // Se pasa el ID del usuario que inició el cierre
|
||||
idLoteDeEnvio: idLoteDeEnvio // Se pasa el ID del lote
|
||||
);
|
||||
|
||||
// El logging de éxito o fallo ahora lo hace el EmailService, por lo que este log ya no es estrictamente necesario,
|
||||
// pero lo mantenemos para tener un registro de alto nivel en el log del FacturacionService.
|
||||
_logger.LogInformation("Llamada a EmailService completada para Suscriptor ID {IdSuscriptor} en el período {Periodo}.", idSuscriptor, periodo);
|
||||
}
|
||||
|
||||
private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public interface IFacturacionService
|
||||
{
|
||||
Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
|
||||
Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
|
||||
Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes);
|
||||
Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion);
|
||||
Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor);
|
||||
Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario);
|
||||
Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,15 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
return (null, "La publicación no existe.");
|
||||
if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio)
|
||||
return (null, "La fecha de fin no puede ser anterior a la fecha de inicio.");
|
||||
if ((createDto.Estado == "Cancelada" || createDto.Estado == "Pausada") && !createDto.FechaFin.HasValue)
|
||||
{
|
||||
return (null, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'.");
|
||||
}
|
||||
|
||||
if (createDto.Estado == "Activa")
|
||||
{
|
||||
createDto.FechaFin = null;
|
||||
}
|
||||
|
||||
var nuevaSuscripcion = new Suscripcion
|
||||
{
|
||||
@@ -130,6 +139,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion);
|
||||
if (existente == null) return (false, "Suscripción no encontrada.");
|
||||
|
||||
// Validación de lógica de negocio en el backend
|
||||
if ((updateDto.Estado == "Cancelada" || updateDto.Estado == "Pausada") && !updateDto.FechaFin.HasValue)
|
||||
{
|
||||
return (false, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'.");
|
||||
}
|
||||
|
||||
// Si el estado es 'Activa', nos aseguramos de que la FechaFin sea nula.
|
||||
if (updateDto.Estado == "Activa")
|
||||
{
|
||||
updateDto.FechaFin = null;
|
||||
}
|
||||
|
||||
if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio)
|
||||
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user