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:
2025-08-11 11:14:03 -03:00
parent 7dc0940001
commit 1a288fcfa5
32 changed files with 1175 additions and 512 deletions

View File

@@ -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);
}
}
}

View File

@@ -49,51 +49,88 @@ 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 =>
{
column.Item().PaddingLeft(2).PaddingBottom(2).Text(publicacion.NombrePublicacion).SemiBold().FontSize(10);
column.Item().Table(table =>
{
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().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(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();
});
@@ -103,10 +140,13 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
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 ?? "-");
}
});
});
}
}
}

View File

@@ -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"),

View File

@@ -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);
}
}

View File

@@ -17,9 +17,9 @@ namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
{
const string sql = @"
INSERT INTO dbo.com_EmailLogs
(FechaEnvio, DestinatarioEmail, Asunto, Estado, Error, IdUsuarioDisparo, Origen, ReferenciaId)
(FechaEnvio, DestinatarioEmail, Asunto, Estado, Error, IdUsuarioDisparo, Origen, ReferenciaId, IdLoteDeEnvio)
VALUES
(@FechaEnvio, @DestinatarioEmail, @Asunto, @Estado, @Error, @IdUsuarioDisparo, @Origen, @ReferenciaId);";
(@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>();
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 });
}
}
}

View File

@@ -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);
}
}

View File

@@ -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>();
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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,14 +20,18 @@ 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
// 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
{
@@ -37,6 +46,10 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
.OrderBy(p => p.NombrePublicacion)
})
.OrderBy(e => e.NombreEmpresa);
};
DatosAgrupadosAltas = agruparDatos(altas);
DatosAgrupadosBajas = agruparDatos(bajas);
}
}
}

View File

@@ -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.

View File

@@ -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"
});
}
}
}

View File

@@ -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);

View File

@@ -5,5 +5,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones
public interface IEmailLogService
{
Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId);
Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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,16 +376,32 @@ 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()) return (false, "No se encontraron facturas para este suscriptor en el período.");
if (!facturasConEmpresa.Any())
{
// 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.");
// 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)>();
@@ -335,7 +410,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
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>");
@@ -369,7 +443,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta);
string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
adjuntos.Add((pdfBytes, pdfFileName));
_logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName);
_logger.LogInformation("PDF adjuntado para envío a Suscriptor ID {IdSuscriptor}: {FileName}", idSuscriptor, pdfFileName);
}
else
{
@@ -382,7 +456,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
string asunto = $"Resumen de Cuenta - Período {periodo}";
string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral);
// Añadir los parámetros de contexto aquí también
await _emailService.EnviarEmailConsolidadoAsync(
destinatarioEmail: suscriptor.Email,
destinatarioNombre: suscriptor.NombreCompleto,
@@ -391,17 +464,13 @@ namespace GestionIntegral.Api.Services.Suscripciones
adjuntos: adjuntos,
origen: "FacturacionMensual",
referenciaId: $"Suscriptor-{idSuscriptor}",
idUsuarioDisparo: null); // Es null porque es un proceso automático del sistema
idUsuarioDisparo: idUsuarioDisparo, // Se pasa el ID del usuario que inició el cierre
idLoteDeEnvio: idLoteDeEnvio // Se pasa el ID del lote
);
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.");
}
// 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)

View File

@@ -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);
}

View File

@@ -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.");

View File

@@ -0,0 +1,88 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Box, Dialog, DialogTitle, DialogContent, IconButton, Tabs, Tab, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Paper, Chip, CircularProgress, Alert } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto';
import facturacionService from '../../../services/Suscripciones/facturacionService';
interface ResultadoEnvioModalProps {
open: boolean;
onClose: () => void;
loteId: number | null;
periodo: string;
}
const ResultadoEnvioModal: React.FC<ResultadoEnvioModalProps> = ({ open, onClose, loteId, periodo }) => {
const [activeTab, setActiveTab] = useState(0);
const [logs, setLogs] = useState<EmailLogDto[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open && loteId) {
const fetchDetails = async () => {
setLoading(true);
setError(null);
try {
const data = await facturacionService.getDetallesLoteEnvio(loteId);
setLogs(data);
} catch (err) {
setError('No se pudieron cargar los detalles del envío.');
} finally {
setLoading(false);
}
};
fetchDetails();
}
}, [open, loteId]);
const filteredLogs = useMemo(() => {
if (activeTab === 1) return logs.filter(log => log.estado === 'Enviado');
if (activeTab === 2) return logs.filter(log => log.estado === 'Fallido');
return logs; // Tab 0 es 'Todos'
}, [logs, activeTab]);
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
Detalle del Lote de Envío - Período {periodo}
<IconButton onClick={onClose} sx={{ position: 'absolute', right: 8, top: 8 }}><CloseIcon /></IconButton>
</DialogTitle>
<DialogContent dividers>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={activeTab} onChange={(_e, newValue) => setActiveTab(newValue)}>
<Tab label={`Todos (${logs.length})`} />
<Tab label={`Enviados (${logs.filter(l => l.estado === 'Enviado').length})`} />
<Tab label={`Fallidos (${logs.filter(l => l.estado === 'Fallido').length})`} />
</Tabs>
</Box>
{loading ? <CircularProgress /> : error ? <Alert severity="error">{error}</Alert> :
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Destinatario</TableCell>
<TableCell>Asunto</TableCell>
<TableCell>Estado</TableCell>
<TableCell>Detalle del Error</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredLogs.map((log, index) => (
<TableRow key={index}>
<TableCell>{log.destinatarioEmail}</TableCell>
<TableCell>{log.asunto}</TableCell>
<TableCell><Chip label={log.estado} color={log.estado === 'Enviado' ? 'success' : 'error'} size="small" /></TableCell>
<TableCell sx={{ color: 'error.main' }}>{log.error || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
}
</DialogContent>
</Dialog>
);
};
export default ResultadoEnvioModal;

View File

@@ -41,7 +41,6 @@ interface SuscripcionFormModalProps {
clearErrorMessage: () => void;
}
// Usamos una interfaz local que contenga todos los campos posibles del formulario
interface FormState {
idPublicacion?: number | '';
fechaInicio?: string;
@@ -92,6 +91,13 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
const errors: { [key: string]: string | null } = {};
if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación.";
if (!formData.fechaInicio?.trim()) errors.fechaInicio = 'La Fecha de Inicio es obligatoria.';
// --- INICIO DE LA MODIFICACIÓN ---
if (formData.estado !== 'Activa' && !formData.fechaFin) {
errors.fechaFin = 'La Fecha de Fin es obligatoria si el estado es Pausada o Cancelada.';
}
// --- FIN DE LA MODIFICACIÓN ---
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.';
}
@@ -123,6 +129,27 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
if (errorMessage) clearErrorMessage();
};
// --- INICIO DE LA MODIFICACIÓN ---
const handleEstadoChange = (e: SelectChangeEvent<'Activa' | 'Pausada' | 'Cancelada'>) => {
const nuevoEstado = e.target.value as 'Activa' | 'Pausada' | 'Cancelada';
const hoy = new Date().toISOString().split('T')[0];
setFormData(prev => {
const newState = { ...prev, estado: nuevoEstado };
if ((nuevoEstado === 'Cancelada' || nuevoEstado === 'Pausada') && !prev.fechaFin) {
newState.fechaFin = hoy;
} else if (nuevoEstado === 'Activa') {
newState.fechaFin = null; // Limpiar la fecha de fin si se reactiva
}
return newState;
});
// Limpiar errores al cambiar
if (localErrors.fechaFin) setLocalErrors(prev => ({ ...prev, fechaFin: null }));
if (errorMessage) clearErrorMessage();
};
// --- FIN DE LA MODIFICACIÓN ---
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
clearErrorMessage();
@@ -133,7 +160,7 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
try {
const dataToSubmit = {
...formData,
fechaFin: formData.fechaFin || null,
fechaFin: formData.estado === 'Activa' ? null : formData.fechaFin, // Asegurarse de que fechaFin es null si está activa
diasEntrega: Array.from(selectedDays),
};
@@ -173,11 +200,25 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
<TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} />
<TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} />
<TextField
name="fechaFin"
label={formData.estado !== 'Activa' ? "Fecha Fin (Requerida)" : "Fecha Fin (Automática)"}
type="date"
value={formData.fechaFin || ''}
onChange={handleInputChange}
fullWidth
margin="dense"
InputLabelProps={{ shrink: true }}
error={!!localErrors.fechaFin}
helperText={localErrors.fechaFin}
disabled={loading || formData.estado === 'Activa'} // Deshabilitado si está activa
required={formData.estado !== 'Activa'} // Requerido si no está activa
/>
</Box>
<FormControl fullWidth margin="dense">
<InputLabel id="estado-label">Estado</InputLabel>
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleSelectChange} label="Estado" disabled={loading}>
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleEstadoChange} label="Estado" disabled={loading}>
<MenuItem value="Activa">Activa</MenuItem>
<MenuItem value="Pausada">Pausada</MenuItem>
<MenuItem value="Cancelada">Cancelada</MenuItem>

View File

@@ -0,0 +1,23 @@
import type { EmailLogDto } from "./EmailLogDto";
// Representa el resumen inmediato que se muestra tras el cierre
export interface LoteDeEnvioResumenDto {
idLoteDeEnvio: number;
periodo: string;
totalCorreos: number;
totalEnviados: number;
totalFallidos: number;
erroresDetallados: EmailLogDto[]; // Lista de errores inmediatos
}
// Representa una fila en la tabla de historial
export interface LoteDeEnvioHistorialDto {
idLoteDeEnvio: number;
fechaInicio: string;
periodo: string;
estado: string;
totalCorreos: number;
totalEnviados: number;
totalFallidos: number;
nombreUsuarioDisparo: string;
}

View File

@@ -0,0 +1,299 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Typography, Paper, Alert, Button, CircularProgress, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Chip, Select, MenuItem, FormControl, InputLabel, Tabs, Tab } from '@mui/material';
import facturacionService from '../../services/Suscripciones/facturacionService';
import type { LoteDeEnvioHistorialDto, LoteDeEnvioResumenDto } from '../../models/dtos/Comunicaciones/LoteDeEnvioDto';
import ResultadoEnvioModal from '../../components/Modals/Suscripciones/ResultadoEnvioModal';
import { usePermissions } from '../../hooks/usePermissions';
import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto';
const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
const meses = [
{ value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' },
{ value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' },
{ value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' },
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
];
// --- Sub-componente para renderizar la Pestaña de Historial ---
const TabHistorial: React.FC<{
puedeConsultar: boolean;
onVerDetalles: (lote: LoteDeEnvioHistorialDto) => void;
formatDate: (date: string) => string;
}> = ({ puedeConsultar, onVerDetalles, formatDate }) => {
const [historial, setHistorial] = useState<LoteDeEnvioHistorialDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filtroAnio, setFiltroAnio] = useState<number>(new Date().getFullYear());
const [filtroMes, setFiltroMes] = useState<number>(new Date().getMonth() + 1);
const cargarHistorial = useCallback(async () => {
if (!puedeConsultar) { setLoading(false); return; }
setLoading(true); setError(null);
try {
const data = await facturacionService.getHistorialLotesEnvio(filtroAnio, filtroMes);
setHistorial(data);
} catch (error) {
setError("No se pudo cargar el historial de cierres.");
} finally {
setLoading(false);
}
}, [puedeConsultar, filtroAnio, filtroMes]);
useEffect(() => {
cargarHistorial();
}, [cargarHistorial]);
return (
<Box>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle1" gutterBottom>Filtrar Historial</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<FormControl sx={{ minWidth: 150 }} size="small"><InputLabel>Mes</InputLabel><Select value={filtroMes} label="Mes" onChange={(e) => setFiltroMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select></FormControl>
<FormControl sx={{ minWidth: 120 }} size="small"><InputLabel>Año</InputLabel><Select value={filtroAnio} label="Año" onChange={(e) => setFiltroAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select></FormControl>
</Box>
</Paper>
{error && <Alert severity="error">{error}</Alert>}
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Período</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Fecha Proceso</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Usuario</TableCell>
<TableCell sx={{ fontWeight: 'bold' }} align="center">Total Correos</TableCell>
<TableCell sx={{ fontWeight: 'bold' }} align="center">Enviados</TableCell>
<TableCell sx={{ fontWeight: 'bold' }} align="center">Fallidos</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={7} align="center"><CircularProgress /></TableCell></TableRow>
) : historial.length === 0 ? (
<TableRow><TableCell colSpan={7} align="center">No hay registros para el período seleccionado.</TableCell></TableRow>
) : (
historial.map(lote => (
<TableRow key={lote.idLoteDeEnvio} hover>
<TableCell sx={{ fontWeight: 'bold' }}>{lote.periodo}</TableCell>
<TableCell>{formatDate(lote.fechaInicio)}</TableCell>
<TableCell>{lote.nombreUsuarioDisparo}</TableCell>
<TableCell align="center">{lote.totalCorreos}</TableCell>
<TableCell align="center"><Chip label={lote.totalEnviados} color="success" size="small" variant="outlined" /></TableCell>
<TableCell align="center"><Chip label={lote.totalFallidos} color={lote.totalFallidos > 0 ? 'error' : 'default'} size="small" variant="outlined" /></TableCell>
<TableCell>
<Button size="small" onClick={() => onVerDetalles(lote)}>Detalles</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
const CierreYProcesosPage: React.FC = () => {
const [activeTab, setActiveTab] = useState(0);
const [selectedAnio, setSelectedAnio] = useState<number>(new Date().getFullYear());
const [selectedMes, setSelectedMes] = useState<number>(new Date().getMonth() + 1);
const [loadingCierre, setLoadingCierre] = useState(false);
const [apiMessage, setApiMessage] = useState<string | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [loadingArchivoDebito, setLoadingArchivoDebito] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [loadingRespuesta, setLoadingRespuesta] = useState(false);
const [respuestaProceso, setRespuestaProceso] = useState<ProcesamientoLoteResponseDto | null>(null);
const [ultimoResultadoEnvio, setUltimoResultadoEnvio] = useState<LoteDeEnvioResumenDto | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [selectedLoteId, setSelectedLoteId] = useState<number | null>(null);
const [selectedLotePeriodo, setSelectedLotePeriodo] = useState("");
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGestionarCierre = isSuperAdmin || tienePermiso("SU006");
const puedeGestionarDebitos = isSuperAdmin || tienePermiso("SU007");
const handleGenerarCierre = async () => {
if (!window.confirm(`¿Está seguro de que desea generar el cierre y la facturación para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Esta acción no se puede deshacer y enviará avisos por email.`)) return;
setLoadingCierre(true);
setApiMessage(null);
setApiError(null);
setUltimoResultadoEnvio(null);
try {
const { message, resultadoEnvio } = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes);
setApiMessage(message);
if (resultadoEnvio) {
setUltimoResultadoEnvio(resultadoEnvio);
}
} catch (err: any) {
setApiError(err.response?.data?.message || 'Error al generar el cierre del período.');
} finally {
setLoadingCierre(false);
}
};
const handleGenerarArchivoDebito = async () => {
setLoadingArchivoDebito(true);
setApiError(null);
try {
const { fileContent, fileName } = await facturacionService.generarArchivoDebito(selectedAnio, selectedMes);
const url = window.URL.createObjectURL(new Blob([fileContent]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err: any) {
setApiError(err.response?.data?.message || 'Error al generar el archivo de débito.');
} finally {
setLoadingArchivoDebito(false);
}
};
const handleProcesarRespuesta = async () => {
if (!selectedFile) {
setApiError("Por favor, seleccione un archivo de respuesta.");
return;
}
setLoadingRespuesta(true);
setApiError(null);
setRespuestaProceso(null);
try {
const data = await facturacionService.procesarArchivoRespuesta(selectedFile);
setRespuestaProceso(data);
setApiMessage("Archivo de respuesta procesado exitosamente.");
} catch (err: any) {
const message = err.response?.data?.message || 'Error al procesar el archivo.';
setApiError(message);
if (err.response?.data) {
setRespuestaProceso(err.response.data);
}
} finally {
setLoadingRespuesta(false);
}
};
const handleOpenModal = (lote: LoteDeEnvioHistorialDto) => {
setSelectedLoteId(lote.idLoteDeEnvio);
setSelectedLotePeriodo(lote.periodo);
setModalOpen(true);
};
const formatDisplayDateTime = (dateString: string): string => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
if (!puedeGestionarCierre && !puedeGestionarDebitos) {
return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
}
return (
<Box sx={{ p: { xs: 1, sm: 2 } }}>
<Typography variant="h5" gutterBottom>Cierre de Período y Procesos Mensuales</Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={activeTab} onChange={(_event, newValue) => setActiveTab(newValue)}>
<Tab label="Procesos Mensuales" />
<Tab label="Historial de Cierres" />
</Tabs>
</Box>
{activeTab === 0 && (
<Box>
{apiError && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setApiError(null)}>{apiError}</Alert>}
{apiMessage && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setApiMessage(null)}>{apiMessage}</Alert>}
{puedeGestionarCierre && (
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6">1. Generación de Cierre Mensual</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Este proceso calcula las deudas, aplica ajustes y promociones, y envía los avisos de vencimiento por email.
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<FormControl sx={{ minWidth: 150 }} size="small"><InputLabel>Mes</InputLabel><Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select></FormControl>
<FormControl sx={{ minWidth: 120 }} size="small"><InputLabel>Año</InputLabel><Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select></FormControl>
<Button onClick={handleGenerarCierre} variant="contained" disabled={loadingCierre}>{loadingCierre ? <CircularProgress size={24} /> : 'Generar Cierre del Período'}</Button>
</Box>
{ultimoResultadoEnvio && (
<Alert severity={ultimoResultadoEnvio.totalFallidos > 0 ? "warning" : "success"} sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="subtitle1" component="div" sx={{ fontWeight: 'bold' }}>
Resultado del envío de correos para {ultimoResultadoEnvio.periodo}
</Typography>
<Typography variant="body2">
{ultimoResultadoEnvio.totalEnviados} enviados exitosamente, {ultimoResultadoEnvio.totalFallidos} fallidos.
</Typography>
</Box>
<Button size="small" variant="outlined" color="inherit" onClick={() => {
setSelectedLoteId(ultimoResultadoEnvio.idLoteDeEnvio);
setSelectedLotePeriodo(ultimoResultadoEnvio.periodo);
setModalOpen(true);
}}>Ver Detalles</Button>
</Box>
</Alert>
)}
</Paper>
)}
{puedeGestionarDebitos && (
<>
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6">2. Generación de Archivo para Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Genere el archivo de texto para procesar los débitos automáticos del período seleccionado.
</Typography>
<Button onClick={handleGenerarArchivoDebito} variant="contained" color="secondary" disabled={loadingArchivoDebito}>{loadingArchivoDebito ? <CircularProgress size={24} /> : 'Generar Archivo de Débito'}</Button>
</Paper>
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6">3. Procesar Respuesta del Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Suba el archivo de respuesta del banco para actualizar automáticamente el estado de los pagos.
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button variant="outlined" component="label">
Seleccionar Archivo
<input type="file" hidden onChange={(e) => setSelectedFile(e.target.files ? e.target.files[0] : null)} />
</Button>
{selectedFile && <Typography variant="body2">{selectedFile.name}</Typography>}
<Button onClick={handleProcesarRespuesta} variant="contained" color="secondary" disabled={loadingRespuesta || !selectedFile}>{loadingRespuesta ? <CircularProgress size={24} /> : 'Procesar Archivo'}</Button>
</Box>
{respuestaProceso && (
<Alert severity={respuestaProceso.errores.length > 0 ? "warning" : "info"} sx={{ mt: 2 }}>
{respuestaProceso.mensajeResumen}
{respuestaProceso.errores.length > 0 && (
<ul>{respuestaProceso.errores.map((e: string, i: number) => <li key={i}>{e}</li>)}</ul>
)}
</Alert>
)}
</Paper>
</>
)}
</Box>
)}
{activeTab === 1 && (
<TabHistorial
puedeConsultar={puedeGestionarCierre}
onVerDetalles={handleOpenModal}
formatDate={formatDisplayDateTime}
/>
)}
<ResultadoEnvioModal
open={modalOpen}
onClose={() => setModalOpen(false)}
loteId={selectedLoteId}
periodo={selectedLotePeriodo}
/>
</Box>
);
};
export default CierreYProcesosPage;

View File

@@ -1,201 +0,0 @@
import React, { useState } from 'react';
import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import DownloadIcon from '@mui/icons-material/Download';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { styled } from '@mui/material/styles';
import facturacionService from '../../services/Suscripciones/facturacionService';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
const meses = [
{ value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' },
{ value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' },
{ value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' },
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
];
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)', clipPath: 'inset(50%)', height: 1, overflow: 'hidden',
position: 'absolute', bottom: 0, left: 0, whiteSpace: 'nowrap', width: 1,
});
const FacturacionPage: React.FC = () => {
const [selectedAnio, setSelectedAnio] = useState<number>(new Date().getFullYear());
const [selectedMes, setSelectedMes] = useState<number>(new Date().getMonth() + 1);
const [loading, setLoading] = useState(false);
const [loadingArchivo, setLoadingArchivo] = useState(false);
const [loadingProceso, setLoadingProceso] = useState(false);
const [apiMessage, setApiMessage] = useState<string | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006");
const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007");
const handleGenerarFacturacion = async () => {
if (!window.confirm(`¿Está seguro de generar el cierre para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Se aplicarán los ajustes pendientes del mes anterior y se generarán los nuevos importes a cobrar.`)) {
return;
}
setLoading(true);
setApiMessage(null);
setApiError(null);
try {
const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes);
setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`);
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Ocurrió un error al generar la facturación.';
setApiError(message);
} finally {
setLoading(false);
}
};
const handleGenerarArchivo = async () => {
if (!window.confirm(`Se generará el archivo de débito para las facturas del período ${meses.find(m => m.value === selectedMes)?.label}/${selectedAnio} que estén en estado 'Pendiente de Cobro'. ¿Continuar?`)) {
return;
}
setLoadingArchivo(true);
setApiMessage(null);
setApiError(null);
try {
const { fileContent, fileName } = await facturacionService.generarArchivoDebito(selectedAnio, selectedMes);
const url = window.URL.createObjectURL(new Blob([fileContent]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
window.URL.revokeObjectURL(url);
setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`);
} catch (err: any) {
let message = 'Ocurrió un error al generar el archivo.';
if (axios.isAxiosError(err) && err.response) {
const errorText = await err.response.data.text();
try {
const errorJson = JSON.parse(errorText);
message = errorJson.message || message;
} catch { message = errorText || message; }
}
setApiError(message);
} finally {
setLoadingArchivo(false);
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setArchivoSeleccionado(event.target.files[0]);
setApiMessage(null);
setApiError(null);
}
};
const handleProcesarArchivo = async () => {
if (!archivoSeleccionado) {
setApiError("Por favor, seleccione un archivo de respuesta para procesar.");
return;
}
setLoadingProceso(true);
setApiMessage(null);
setApiError(null);
try {
const response = await facturacionService.procesarArchivoRespuesta(archivoSeleccionado);
setApiMessage(response.mensajeResumen);
if (response.errores?.length > 0) {
setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`);
}
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen
? err.response.data.mensajeResumen
: 'Ocurrió un error crítico al procesar el archivo.';
setApiError(message);
} finally {
setLoadingProceso(false);
setArchivoSeleccionado(null);
}
};
if (!puedeGenerarFacturacion) {
return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
}
if (!puedeGenerarFacturacion) {
return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Procesos Mensuales de Suscripciones</Typography>
<Paper sx={{ p: 1, mb: 1 }}>
<Typography variant="h6">1. Generación de Cierre Mensual</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Este proceso calcula los importes a cobrar y envía automáticamente una notificación de "Aviso de Vencimiento" a cada suscriptor.
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 1, alignItems: 'center' }}>
<FormControl sx={{ minWidth: 120 }} size="small">
<InputLabel>Mes</InputLabel>
<Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select>
</FormControl>
<FormControl sx={{ minWidth: 120 }} size="small">
<InputLabel>Año</InputLabel>
<Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select>
</FormControl>
</Box>
<Button variant="contained" color="primary" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} onClick={handleGenerarFacturacion} disabled={loading || loadingArchivo || loadingProceso}>
Generar Cierre del Período
</Button>
</Paper>
<Paper sx={{ p: 1, mb: 1 }}>
<Typography variant="h6">2. Generación de Archivo para Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro.</Typography>
<Button variant="contained" color="secondary" startIcon={loadingArchivo ? <CircularProgress size={20} color="inherit" /> : <DownloadIcon />} onClick={handleGenerarArchivo} disabled={loading || loadingArchivo || !puedeGenerarArchivo}>Generar Archivo de Débito</Button>
</Paper>
<Paper sx={{ p: 1, mb: 1 }}>
<Typography variant="h6">3. Procesar Respuesta del Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada".
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button
component="label"
role={undefined}
variant="outlined"
tabIndex={-1}
startIcon={<UploadFileIcon />}
disabled={loadingProceso}
>
Seleccionar Archivo
<VisuallyHiddenInput type="file" onChange={handleFileChange} accept=".txt, text/plain" />
</Button>
{archivoSeleccionado && <Typography variant="body2">{archivoSeleccionado.name}</Typography>}
</Box>
{archivoSeleccionado && (
<Button
variant="contained"
color="success"
sx={{ mt: 2 }}
onClick={handleProcesarArchivo}
disabled={loadingProceso}
startIcon={loadingProceso ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />}
>
Procesar Archivo de Respuesta
</Button>
)}
</Paper>
{apiError && <Alert severity="error" sx={{ my: 1 }}>{apiError}</Alert>}
{apiMessage && <Alert severity="success" sx={{ my: 1 }}>{apiMessage}</Alert>}
</Box>
);
};
export default FacturacionPage;

View File

@@ -83,7 +83,7 @@ import SuscripcionesIndexPage from '../pages/Suscripciones/SuscripcionesIndexPag
import GestionarSuscriptoresPage from '../pages/Suscripciones/GestionarSuscriptoresPage';
import GestionarPromocionesPage from '../pages/Suscripciones/GestionarPromocionesPage';
import ConsultaFacturasPage from '../pages/Suscripciones/ConsultaFacturasPage';
import FacturacionPage from '../pages/Suscripciones/FacturacionPage';
import CierreYProcesosPage from '../pages/Suscripciones/CierreYProcesosPage';
import GestionarSuscripcionesDeClientePage from '../pages/Suscripciones/GestionarSuscripcionesDeClientePage';
import CuentaCorrienteSuscriptorPage from '../pages/Suscripciones/CuentaCorrienteSuscriptorPage';
@@ -206,7 +206,7 @@ const AppRoutes = () => {
<Route index element={<Navigate to="suscriptores" replace />} />
<Route path="suscriptores" element={<GestionarSuscriptoresPage />} />
<Route path="consulta-facturas" element={<ConsultaFacturasPage />} />
<Route path="procesos" element={<FacturacionPage />} />
<Route path="procesos" element={<CierreYProcesosPage />} />
<Route path="promociones" element={<GestionarPromocionesPage />} />
</Route>

View File

@@ -1,9 +1,9 @@
import apiClient from '../apiClient';
import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto';
import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto';
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto';
import type { ResumenCuentaSuscriptorDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
import type { LoteDeEnvioHistorialDto, LoteDeEnvioResumenDto } from '../../models/dtos/Comunicaciones/LoteDeEnvioDto';
import type { EmailLogDto } from '../../models/dtos/Comunicaciones/EmailLogDto';
const API_URL = '/facturacion';
@@ -32,8 +32,8 @@ const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreS
return response.data;
};
const generarFacturacionMensual = async (anio: number, mes: number): Promise<GenerarFacturacionResponseDto> => {
const response = await apiClient.post<GenerarFacturacionResponseDto>(`${API_URL}/${anio}/${mes}`);
const generarFacturacionMensual = async (anio: number, mes: number): Promise<{ message: string, resultadoEnvio: LoteDeEnvioResumenDto }> => {
const response = await apiClient.post<{ message: string, resultadoEnvio: LoteDeEnvioResumenDto }>(`${API_URL}/${anio}/${mes}`);
return response.data;
};
@@ -77,6 +77,23 @@ const getHistorialEnvios = async (idFactura: number): Promise<EmailLogDto[]> =>
return response.data;
};
const getHistorialLotesEnvio = async (anio?: number, mes?: number): Promise<LoteDeEnvioHistorialDto[]> => {
const params = new URLSearchParams();
if (anio) params.append('anio', String(anio));
if (mes) params.append('mes', String(mes));
const queryString = params.toString();
const url = `${API_URL}/historial-lotes-envio${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<LoteDeEnvioHistorialDto[]>(url);
return response.data;
};
const getDetallesLoteEnvio = async (idLote: number): Promise<EmailLogDto[]> => {
const response = await apiClient.get<EmailLogDto[]>(`/lotes-envio/${idLote}/detalles`);
return response.data;
};
export default {
procesarArchivoRespuesta,
getResumenesDeCuentaPorPeriodo,
@@ -87,4 +104,6 @@ export default {
enviarAvisoCuentaPorEmail,
enviarFacturaPdfPorEmail,
getHistorialEnvios,
getHistorialLotesEnvio,
getDetallesLoteEnvio,
};