Compare commits
	
		
			13 Commits
		
	
	
		
			7e4d3282fb
			...
			8c194b8441
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8c194b8441 | |||
| 1a288fcfa5 | |||
| 7dc0940001 | |||
| 5a806eda38 | |||
| 21c5c1d7d9 | |||
| 899e0a173f | |||
| 9cfe9d012e | |||
| 9e248efc84 | |||
| 84187a66df | |||
| b14c5de1b4 | |||
| d62ca7feb3 | |||
| f09c795fb0 | |||
| 19e7192a16 | 
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -19,9 +19,6 @@ lerna-debug.log* | ||||
|  | ||||
| # Variables de entorno | ||||
| # ------------------------------- | ||||
| # Nunca subas tus claves de API, contraseñas de BD, etc. | ||||
| # Crea un archivo .env.example con las variables vacías para guiar a otros desarrolladores. | ||||
| .env | ||||
| .env.local | ||||
| .env.development.local | ||||
| .env.test.local | ||||
|   | ||||
							
								
								
									
										12
									
								
								Backend/GestionIntegral.Api/.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								Backend/GestionIntegral.Api/.env
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| # ================================================ | ||||
| # VARIABLES DE ENTORNO PARA LA CONFIGURACIÓN DE CORREO | ||||
| # ================================================ | ||||
| # El separador de doble guion bajo (__) se usa para mapear la jerarquía del JSON. | ||||
| # MailSettings:SmtpHost se convierte en MailSettings__SmtpHost | ||||
|  | ||||
| MailSettings__SmtpHost="mail.eldia.com" | ||||
| MailSettings__SmtpPort=587 | ||||
| MailSettings__SenderName="Club - Diario El Día" | ||||
| MailSettings__SenderEmail="alertas@eldia.com" | ||||
| MailSettings__SmtpUser="alertas@eldia.com" | ||||
| MailSettings__SmtpPass="@Alertas713550@" | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,12 +1,9 @@ | ||||
| // --- REEMPLAZAR ARCHIVO: Controllers/Reportes/PdfTemplates/DistribucionCanillasDocument.cs --- | ||||
| using GestionIntegral.Api.Dtos.Reportes; | ||||
| using GestionIntegral.Api.Dtos.Reportes.ViewModels; | ||||
| using QuestPDF.Fluent; | ||||
| using QuestPDF.Helpers; | ||||
| using QuestPDF.Infrastructure; | ||||
| using System.Collections.Generic; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates | ||||
| { | ||||
|   | ||||
| @@ -0,0 +1,152 @@ | ||||
| using GestionIntegral.Api.Dtos.Reportes.ViewModels; | ||||
| using QuestPDF.Fluent; | ||||
| using QuestPDF.Helpers; | ||||
| using QuestPDF.Infrastructure; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates | ||||
| { | ||||
|     public class DistribucionSuscripcionesDocument : IDocument | ||||
|     { | ||||
|         public DistribucionSuscripcionesViewModel Model { get; } | ||||
|  | ||||
|         public DistribucionSuscripcionesDocument(DistribucionSuscripcionesViewModel model) | ||||
|         { | ||||
|             Model = model; | ||||
|         } | ||||
|  | ||||
|         public DocumentMetadata GetMetadata() => DocumentMetadata.Default; | ||||
|  | ||||
|         public void Compose(IDocumentContainer container) | ||||
|         { | ||||
|             container.Page(page => | ||||
|             { | ||||
|                 page.Margin(1, Unit.Centimetre); | ||||
|                 page.DefaultTextStyle(x => x.FontFamily("Arial").FontSize(9)); | ||||
|                 page.Header().Element(ComposeHeader); | ||||
|                 page.Content().Element(ComposeContent); | ||||
|                 page.Footer().AlignCenter().Text(x => { x.Span("Página "); x.CurrentPageNumber(); }); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         void ComposeHeader(IContainer container) | ||||
|         { | ||||
|             container.Column(column => | ||||
|             { | ||||
|                 column.Item().Row(row => | ||||
|                 { | ||||
|                     row.RelativeItem().Column(col => | ||||
|                     { | ||||
|                         col.Item().Text("Reporte de Distribución de Suscripciones").SemiBold().FontSize(14); | ||||
|                         col.Item().Text($"Período: {Model.FechaDesde} al {Model.FechaHasta}").FontSize(11); | ||||
|                     }); | ||||
|                     row.ConstantItem(150).AlignRight().Text($"Generado: {Model.FechaGeneracion}"); | ||||
|                 }); | ||||
|                 column.Item().PaddingTop(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten2); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         void ComposeContent(IContainer container) | ||||
|         { | ||||
|             container.PaddingTop(10).Column(column => | ||||
|             { | ||||
|                 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 => | ||||
|                 { | ||||
|                     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 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); | ||||
|                  | ||||
|                 // Contenedor para las tablas de las publicaciones de esta empresa | ||||
|                 column.Item().PaddingTop(5).Column(colPub => | ||||
|                 { | ||||
|                     colPub.Spacing(10); // Espacio entre cada tabla de publicación | ||||
|                     foreach (var publicacion in empresa.Publicaciones) | ||||
|                     { | ||||
|                         colPub.Item().Element(c => ComposeTablaPublicacion(c, publicacion, esBaja)); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         void ComposeTablaPublicacion(IContainer container, GrupoPublicacion publicacion, bool esBaja) | ||||
|         { | ||||
|             // 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().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 ?? "-"); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,121 @@ | ||||
| using GestionIntegral.Api.Dtos.Reportes.ViewModels; | ||||
| using QuestPDF.Fluent; | ||||
| using QuestPDF.Helpers; | ||||
| using QuestPDF.Infrastructure; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates | ||||
| { | ||||
|     public class FacturasPublicidadDocument : IDocument | ||||
|     { | ||||
|         public FacturasPublicidadViewModel Model { get; } | ||||
|  | ||||
|         public FacturasPublicidadDocument(FacturasPublicidadViewModel model) | ||||
|         { | ||||
|             Model = model; | ||||
|         } | ||||
|  | ||||
|         public DocumentMetadata GetMetadata() => DocumentMetadata.Default; | ||||
|  | ||||
|         public void Compose(IDocumentContainer container) | ||||
|         { | ||||
|             container.Page(page => | ||||
|             { | ||||
|                 page.Margin(1, Unit.Centimetre); | ||||
|                 page.DefaultTextStyle(x => x.FontFamily("Arial").FontSize(9)); | ||||
|                  | ||||
|                 page.Header().Element(ComposeHeader); | ||||
|                 page.Content().Element(ComposeContent); | ||||
|                 page.Footer().AlignCenter().Text(x => { x.Span("Página "); x.CurrentPageNumber(); }); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         void ComposeHeader(IContainer container) | ||||
|         { | ||||
|             // Se envuelve todo el contenido del header en una única Columna. | ||||
|             container.Column(column => | ||||
|             { | ||||
|                 // El primer item de la columna es la fila con los títulos. | ||||
|                 column.Item().Row(row => | ||||
|                 { | ||||
|                     row.RelativeItem().Column(col => | ||||
|                     { | ||||
|                         col.Item().Text($"Reporte de Suscripciones a Facturar").SemiBold().FontSize(14); | ||||
|                         col.Item().Text($"Período: {Model.Periodo}").FontSize(11); | ||||
|                     }); | ||||
|  | ||||
|                     row.ConstantItem(150).AlignRight().Column(col => { | ||||
|                         col.Item().AlignRight().Text($"Fecha de Generación:"); | ||||
|                         col.Item().AlignRight().Text(Model.FechaGeneracion); | ||||
|                     }); | ||||
|                 }); | ||||
|  | ||||
|                 // El segundo item de la columna es el separador. | ||||
|                 column.Item().PaddingTop(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten2); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         void ComposeContent(IContainer container) | ||||
|         { | ||||
|             container.PaddingTop(10).Column(column => | ||||
|             { | ||||
|                 column.Spacing(20); | ||||
|  | ||||
|                 foreach (var empresaData in Model.DatosPorEmpresa) | ||||
|                 { | ||||
|                     column.Item().Element(c => ComposeTablaPorEmpresa(c, empresaData)); | ||||
|                 } | ||||
|                  | ||||
|                 column.Item().AlignRight().PaddingTop(15).Text($"Total General a Facturar: {Model.TotalGeneral.ToString("C", new CultureInfo("es-AR"))}").Bold().FontSize(12); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         void ComposeTablaPorEmpresa(IContainer container, DatosEmpresaViewModel empresaData) | ||||
|         { | ||||
|             container.Table(table => | ||||
|             { | ||||
|                 table.ColumnsDefinition(columns => | ||||
|                 { | ||||
|                     columns.RelativeColumn(3); // Nombre Suscriptor | ||||
|                     columns.ConstantColumn(100); // Documento | ||||
|                     columns.ConstantColumn(100, Unit.Point); // Importe | ||||
|                 }); | ||||
|  | ||||
|                 table.Header(header => | ||||
|                 { | ||||
|                     header.Cell().ColumnSpan(3).Background(Colors.Grey.Lighten2) | ||||
|                         .Padding(5).Text(empresaData.NombreEmpresa).Bold().FontSize(12); | ||||
|                      | ||||
|                     header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Padding(2).Text("Suscriptor").SemiBold(); | ||||
|                     header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Padding(2).Text("Documento").SemiBold(); | ||||
|                     header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Padding(2).AlignRight().Text("Importe a Facturar").SemiBold(); | ||||
|                 }); | ||||
|                  | ||||
|                 var facturasPorSuscriptor = empresaData.Facturas.GroupBy(f => f.NombreSuscriptor); | ||||
|  | ||||
|                 foreach (var grupoSuscriptor in facturasPorSuscriptor.OrderBy(g => g.Key)) | ||||
|                 { | ||||
|                     foreach(var item in grupoSuscriptor) | ||||
|                     { | ||||
|                         table.Cell().Padding(2).Text(item.NombreSuscriptor); | ||||
|                         table.Cell().Padding(2).Text($"{item.TipoDocumento} {item.NroDocumento}"); | ||||
|                         table.Cell().Padding(2).AlignRight().Text(item.ImporteFinal.ToString("C", new CultureInfo("es-AR"))); | ||||
|                     } | ||||
|  | ||||
|                     if(grupoSuscriptor.Count() > 1) | ||||
|                     { | ||||
|                         var subtotal = grupoSuscriptor.Sum(i => i.ImporteFinal); | ||||
|                         table.Cell().ColumnSpan(2).AlignRight().Padding(2).Text($"Subtotal {grupoSuscriptor.Key}:").Italic(); | ||||
|                         table.Cell().AlignRight().Padding(2).Text(subtotal.ToString("C", new CultureInfo("es-AR"))).Italic().SemiBold(); | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 table.Cell().ColumnSpan(2).BorderTop(1).BorderColor(Colors.Grey.Darken1).AlignRight() | ||||
|                     .PaddingTop(5).Text("Total Empresa:").Bold(); | ||||
|                 table.Cell().BorderTop(1).BorderColor(Colors.Grey.Darken1).AlignRight() | ||||
|                     .PaddingTop(5).Text(empresaData.TotalEmpresa.ToString("C", new CultureInfo("es-AR"))).Bold(); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,15 +1,8 @@ | ||||
| using GestionIntegral.Api.Services.Reportes; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Reporting.NETCore; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading.Tasks; | ||||
| using GestionIntegral.Api.Dtos.Reportes; | ||||
| using GestionIntegral.Api.Data.Repositories.Impresion; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using GestionIntegral.Api.Data.Repositories.Distribucion; | ||||
| using GestionIntegral.Api.Services.Distribucion; | ||||
| using GestionIntegral.Api.Services.Pdf; | ||||
| @@ -45,6 +38,8 @@ namespace GestionIntegral.Api.Controllers | ||||
|         private const string PermisoVerReporteConsumoBobinas = "RR007"; | ||||
|         private const string PermisoVerReporteNovedadesCanillas = "RR004"; | ||||
|         private const string PermisoVerReporteListadoDistMensual = "RR009"; | ||||
|         private const string PermisoVerReporteFacturasPublicidad = "RR010"; | ||||
|         private const string PermisoVerReporteDistSuscripciones = "RR011"; | ||||
|  | ||||
|         public ReportesController( | ||||
|             IReportesService reportesService, | ||||
| @@ -1676,5 +1671,88 @@ namespace GestionIntegral.Api.Controllers | ||||
|                 return StatusCode(500, "Error interno al generar el PDF del reporte."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         [HttpGet("suscripciones/facturas-para-publicidad/pdf")] | ||||
|         [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||
|         public async Task<IActionResult> GetReporteFacturasPublicidadPdf([FromQuery] int anio, [FromQuery] int mes) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoVerReporteFacturasPublicidad)) return Forbid(); | ||||
|  | ||||
|             var (data, error) = await _reportesService.ObtenerFacturasParaReportePublicidad(anio, mes); | ||||
|             if (error != null) return BadRequest(new { message = error }); | ||||
|             if (data == null || !data.Any()) | ||||
|             { | ||||
|                 return NotFound(new { message = "No hay facturas pagadas y pendientes de facturar para el período seleccionado." }); | ||||
|             } | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 // --- INICIO DE LA LÓGICA DE AGRUPACIÓN --- | ||||
|                 var datosAgrupados = data | ||||
|                     .GroupBy(f => f.IdEmpresa) | ||||
|                     .Select(g => new DatosEmpresaViewModel | ||||
|                     { | ||||
|                         NombreEmpresa = g.First().NombreEmpresa, | ||||
|                         Facturas = g.ToList() | ||||
|                     }) | ||||
|                     .OrderBy(e => e.NombreEmpresa); | ||||
|  | ||||
|                 var viewModel = new FacturasPublicidadViewModel | ||||
|                 { | ||||
|                     DatosPorEmpresa = datosAgrupados, | ||||
|                     Periodo = new DateTime(anio, mes, 1).ToString("MMMM yyyy", new CultureInfo("es-ES")), | ||||
|                     FechaGeneracion = DateTime.Now.ToString("dd/MM/yyyy HH:mm") | ||||
|                 }; | ||||
|                 // --- FIN DE LA LÓGICA DE AGRUPACIÓN --- | ||||
|  | ||||
|                 var document = new FacturasPublicidadDocument(viewModel); | ||||
|                 byte[] pdfBytes = await _pdfGenerator.GeneratePdfAsync(document); | ||||
|                 string fileName = $"ReportePublicidad_Suscripciones_{anio}-{mes:D2}.pdf"; | ||||
|                 return File(pdfBytes, "application/pdf", fileName); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al generar PDF para Reporte de Facturas a Publicidad."); | ||||
|                 return StatusCode(500, "Error interno al generar el PDF del reporte."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         [HttpGet("suscripciones/distribucion/pdf")] | ||||
|         [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] | ||||
|         public async Task<IActionResult> GetReporteDistribucionSuscripcionesPdf([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoVerReporteDistSuscripciones)) return Forbid(); | ||||
|  | ||||
|             var (altas, bajas, error) = await _reportesService.ObtenerReporteDistribucionSuscripcionesAsync(fechaDesde, fechaHasta); | ||||
|             if (error != null) return BadRequest(new { message = error }); | ||||
|             if ((altas == null || !altas.Any()) && (bajas == null || !bajas.Any())) | ||||
|             { | ||||
|                 return NotFound(new { message = "No se encontraron suscripciones activas ni bajas para el período seleccionado." }); | ||||
|             } | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var viewModel = new DistribucionSuscripcionesViewModel(altas ?? Enumerable.Empty<DistribucionSuscripcionDto>(), bajas ?? Enumerable.Empty<DistribucionSuscripcionDto>()) | ||||
|                 { | ||||
|                     FechaDesde = fechaDesde.ToString("dd/MM/yyyy"), | ||||
|                     FechaHasta = fechaHasta.ToString("dd/MM/yyyy"), | ||||
|                     FechaGeneracion = DateTime.Now.ToString("dd/MM/yyyy HH:mm") | ||||
|                 }; | ||||
|  | ||||
|                 var document = new DistribucionSuscripcionesDocument(viewModel); | ||||
|                 byte[] pdfBytes = await _pdfGenerator.GeneratePdfAsync(document); | ||||
|                 string fileName = $"ReporteDistribucionSuscripciones_{fechaDesde:yyyyMMdd}_al_{fechaHasta:yyyyMMdd}.pdf"; | ||||
|                 return File(pdfBytes, "application/pdf", fileName); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al generar PDF para Reporte de Distribución de Suscripciones."); | ||||
|                 return StatusCode(500, "Error interno al generar el PDF del reporte."); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,94 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/ajustes")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class AjustesController : ControllerBase | ||||
|     { | ||||
|         private readonly IAjusteService _ajusteService; | ||||
|         private readonly ILogger<AjustesController> _logger; | ||||
|  | ||||
|         // Permiso a crear en BD | ||||
|         private const string PermisoGestionarAjustes = "SU011"; | ||||
|  | ||||
|         public AjustesController(IAjusteService ajusteService, ILogger<AjustesController> logger) | ||||
|         { | ||||
|             _ajusteService = ajusteService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|  | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // GET: api/suscriptores/{idSuscriptor}/ajustes | ||||
|         [HttpGet("~/api/suscriptores/{idSuscriptor:int}/ajustes")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<AjusteDto>), StatusCodes.Status200OK)] | ||||
|         public async Task<IActionResult> GetAjustesPorSuscriptor(int idSuscriptor, [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarAjustes)) return Forbid(); | ||||
|             var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor, fechaDesde, fechaHasta); | ||||
|             return Ok(ajustes); | ||||
|         } | ||||
|  | ||||
|         // POST: api/ajustes | ||||
|         [HttpPost] | ||||
|         [ProducesResponseType(typeof(AjusteDto), StatusCodes.Status201Created)] | ||||
|         public async Task<IActionResult> CreateAjuste([FromBody] CreateAjusteDto createDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarAjustes)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (dto, error) = await _ajusteService.CrearAjusteManual(createDto, userId.Value); | ||||
|  | ||||
|             if (error != null) return BadRequest(new { message = error }); | ||||
|             if (dto == null) return StatusCode(500, "Error al crear el ajuste."); | ||||
|  | ||||
|             // Devolvemos el objeto creado con un 201 | ||||
|             return StatusCode(201, dto); | ||||
|         } | ||||
|  | ||||
|         // POST: api/ajustes/{id}/anular | ||||
|         [HttpPost("{id:int}/anular")] | ||||
|         public async Task<IActionResult> Anular(int id) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarAjustes)) return Forbid(); | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _ajusteService.AnularAjuste(id, userId.Value); | ||||
|             if (!exito) return BadRequest(new { message = error }); | ||||
|  | ||||
|             return Ok(new { message = "Ajuste anulado correctamente." }); | ||||
|         } | ||||
|  | ||||
|         // PUT: api/ajustes/{id} | ||||
|         [HttpPut("{id:int}")] | ||||
|         public async Task<IActionResult> UpdateAjuste(int id, [FromBody] UpdateAjusteDto updateDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarAjustes)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|  | ||||
|             var (exito, error) = await _ajusteService.ActualizarAjuste(id, updateDto); | ||||
|             if (!exito) | ||||
|             { | ||||
|                 if (error != null && error.Contains("no encontrado")) return NotFound(new { message = error }); | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|             return NoContent(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,93 @@ | ||||
| // Archivo: GestionIntegral.Api/Controllers/Suscripciones/DebitosController.cs | ||||
|  | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
| using System.Text; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/debitos")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class DebitosController : ControllerBase | ||||
|     { | ||||
|         private readonly IDebitoAutomaticoService _debitoService; | ||||
|         private readonly ILogger<DebitosController> _logger; | ||||
|  | ||||
|         // Permiso para generar archivos de débito (a crear en BD) | ||||
|         private const string PermisoGenerarDebitos = "SU007"; | ||||
|  | ||||
|         public DebitosController(IDebitoAutomaticoService debitoService, ILogger<DebitosController> logger) | ||||
|         { | ||||
|             _debitoService = debitoService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // POST: api/debitos/{anio}/{mes}/generar-archivo | ||||
|         [HttpPost("{anio:int}/{mes:int}/generar-archivo")] | ||||
|         [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||
|         public async Task<IActionResult> GenerarArchivo(int anio, int mes) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGenerarDebitos)) return Forbid(); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (contenido, nombreArchivo, error) = await _debitoService.GenerarArchivoPagoDirecto(anio, mes, userId.Value); | ||||
|  | ||||
|             if (error != null) | ||||
|             { | ||||
|                 // Si el error es "No se encontraron facturas", es un 404. Otros son 400. | ||||
|                 if (error.Contains("No se encontraron")) | ||||
|                 { | ||||
|                     return NotFound(new { message = error }); | ||||
|                 } | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|  | ||||
|             if (string.IsNullOrEmpty(contenido) || string.IsNullOrEmpty(nombreArchivo)) | ||||
|             { | ||||
|                 return StatusCode(500, new { message = "El servicio no pudo generar el contenido del archivo correctamente." }); | ||||
|             } | ||||
|  | ||||
|             // Devolver el archivo para descarga | ||||
|             var fileBytes = Encoding.UTF8.GetBytes(contenido); | ||||
|             return File(fileBytes, "text/plain", nombreArchivo); | ||||
|         } | ||||
|  | ||||
|         // POST: api/debitos/procesar-respuesta | ||||
|         [HttpPost("procesar-respuesta")] | ||||
|         [ProducesResponseType(typeof(ProcesamientoLoteResponseDto), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         public async Task<IActionResult> ProcesarArchivoRespuesta(IFormFile archivo) | ||||
|         { | ||||
|             // Usamos el mismo permiso de generar débitos para procesar la respuesta. | ||||
|             if (!TienePermiso(PermisoGenerarDebitos)) return Forbid(); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var resultado = await _debitoService.ProcesarArchivoRespuesta(archivo, userId.Value); | ||||
|  | ||||
|             if (resultado.Errores.Any() && resultado.PagosAprobados == 0 && resultado.PagosRechazados == 0) | ||||
|             { | ||||
|                 return BadRequest(resultado); | ||||
|             } | ||||
|  | ||||
|             return Ok(resultado); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,125 @@ | ||||
| using GestionIntegral.Api.Dtos.Comunicaciones; | ||||
| using GestionIntegral.Api.Services.Comunicaciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/facturacion")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class FacturacionController : ControllerBase | ||||
|     { | ||||
|         private readonly IFacturacionService _facturacionService; | ||||
|         private readonly ILogger<FacturacionController> _logger; | ||||
|         private readonly IEmailLogService _emailLogService; | ||||
|         private const string PermisoGestionarFacturacion = "SU006"; | ||||
|         private const string PermisoEnviarEmail = "SU009"; | ||||
|  | ||||
|         public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger, IEmailLogService emailLogService) | ||||
|         { | ||||
|             _facturacionService = facturacionService; | ||||
|             _logger = logger; | ||||
|             _emailLogService = emailLogService; | ||||
|         } | ||||
|  | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|  | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             _logger.LogWarning("No se pudo obtener el UserId del token JWT en FacturacionController."); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         [HttpPut("{idFactura:int}/numero-factura")] | ||||
|         [ProducesResponseType(StatusCodes.Status204NoContent)] | ||||
|         public async Task<IActionResult> UpdateNumeroFactura(int idFactura, [FromBody] string numeroFactura) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _facturacionService.ActualizarNumeroFactura(idFactura, numeroFactura, userId.Value); | ||||
|  | ||||
|             if (!exito) | ||||
|             { | ||||
|                 if (error != null && error.Contains("no existe")) return NotFound(new { message = error }); | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|             return NoContent(); | ||||
|         } | ||||
|  | ||||
|         [HttpPost("{idFactura:int}/enviar-factura-pdf")] | ||||
|         public async Task<IActionResult> EnviarFacturaPdf(int idFactura) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoEnviarEmail)) return Forbid(); | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|             var (exito, error, emailDestino) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura, userId.Value); | ||||
|  | ||||
|             if (!exito) | ||||
|             { | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|  | ||||
|             var mensajeExito = $"El email con la factura PDF se ha enviado correctamente a {emailDestino}."; | ||||
|             return Ok(new { message = mensajeExito }); | ||||
|         } | ||||
|  | ||||
|         [HttpGet("{anio:int}/{mes:int}")] | ||||
|         public async Task<IActionResult> GetFacturas( | ||||
|             int anio, int mes, | ||||
|             [FromQuery] string? nombreSuscriptor, | ||||
|             [FromQuery] string? estadoPago, | ||||
|             [FromQuery] string? estadoFacturacion) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); | ||||
|             if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." }); | ||||
|  | ||||
|             var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion); | ||||
|             return Ok(resumenes); | ||||
|         } | ||||
|  | ||||
|         [HttpPost("{anio:int}/{mes:int}")] | ||||
|         public async Task<IActionResult> GenerarFacturacion(int anio, int mes) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); | ||||
|             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, resultadoEnvio) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value); | ||||
|  | ||||
|             if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje }); | ||||
|  | ||||
|             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) | ||||
|         { | ||||
|             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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,27 @@ | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/formaspago")] | ||||
|     [ApiController] | ||||
|     [Authorize] // Solo usuarios logueados pueden ver esto | ||||
|     public class FormasDePagoController : ControllerBase | ||||
|     { | ||||
|         private readonly IFormaPagoService _formaPagoService; | ||||
|  | ||||
|         public FormasDePagoController(IFormaPagoService formaPagoService) | ||||
|         { | ||||
|             _formaPagoService = formaPagoService; | ||||
|         } | ||||
|  | ||||
|         // GET: api/formaspago | ||||
|         [HttpGet] | ||||
|         public async Task<IActionResult> GetAll() | ||||
|         { | ||||
|             var formasDePago = await _formaPagoService.ObtenerTodos(); | ||||
|             return Ok(formasDePago); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/pagos")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class PagosController : ControllerBase | ||||
|     { | ||||
|         private readonly IPagoService _pagoService; | ||||
|         private readonly ILogger<PagosController> _logger; | ||||
|  | ||||
|         // Permiso para registrar pagos manuales (a crear en BD) | ||||
|         private const string PermisoRegistrarPago = "SU008"; | ||||
|  | ||||
|         public PagosController(IPagoService pagoService, ILogger<PagosController> logger) | ||||
|         { | ||||
|             _pagoService = pagoService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|          | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // GET: api/facturas/{idFactura}/pagos | ||||
|         [HttpGet("~/api/facturas/{idFactura:int}/pagos")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<PagoDto>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         public async Task<IActionResult> GetPagosPorFactura(int idFactura) | ||||
|         { | ||||
|             // Se podría usar un permiso de "Ver Facturación" | ||||
|             if (!TienePermiso("SU006")) return Forbid(); | ||||
|  | ||||
|             var pagos = await _pagoService.ObtenerPagosPorFacturaId(idFactura); | ||||
|             return Ok(pagos); | ||||
|         } | ||||
|  | ||||
|         // POST: api/pagos | ||||
|         [HttpPost] | ||||
|         [ProducesResponseType(typeof(PagoDto), StatusCodes.Status201Created)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         public async Task<IActionResult> RegistrarPago([FromBody] CreatePagoDto createDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoRegistrarPago)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (dto, error) = await _pagoService.RegistrarPagoManual(createDto, userId.Value); | ||||
|  | ||||
|             if (error != null) return BadRequest(new { message = error }); | ||||
|             if (dto == null) return StatusCode(StatusCodes.Status500InternalServerError, "Error al registrar el pago."); | ||||
|  | ||||
|             // No tenemos un "GetById" para pagos, así que devolvemos el objeto con un 201. | ||||
|             return StatusCode(201, dto); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,90 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/promociones")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class PromocionesController : ControllerBase | ||||
|     { | ||||
|         private readonly IPromocionService _promocionService; | ||||
|         private readonly ILogger<PromocionesController> _logger; | ||||
|  | ||||
|         // Permiso a crear en BD | ||||
|         private const string PermisoGestionarPromociones = "SU010"; | ||||
|  | ||||
|         public PromocionesController(IPromocionService promocionService, ILogger<PromocionesController> logger) | ||||
|         { | ||||
|             _promocionService = promocionService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|          | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // GET: api/promociones | ||||
|         [HttpGet] | ||||
|         public async Task<IActionResult> GetAll([FromQuery] bool soloActivas = true) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarPromociones)) return Forbid(); | ||||
|             var promociones = await _promocionService.ObtenerTodas(soloActivas); | ||||
|             return Ok(promociones); | ||||
|         } | ||||
|  | ||||
|         // GET: api/promociones/{id} | ||||
|         [HttpGet("{id:int}", Name = "GetPromocionById")] | ||||
|         public async Task<IActionResult> GetById(int id) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarPromociones)) return Forbid(); | ||||
|             var promocion = await _promocionService.ObtenerPorId(id); | ||||
|             if (promocion == null) return NotFound(); | ||||
|             return Ok(promocion); | ||||
|         } | ||||
|  | ||||
|         // POST: api/promociones | ||||
|         [HttpPost] | ||||
|         public async Task<IActionResult> Create([FromBody] CreatePromocionDto createDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarPromociones)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|              | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (dto, error) = await _promocionService.Crear(createDto, userId.Value); | ||||
|              | ||||
|             if (error != null) return BadRequest(new { message = error }); | ||||
|             if (dto == null) return StatusCode(500, "Error al crear la promoción."); | ||||
|              | ||||
|             return CreatedAtRoute("GetPromocionById", new { id = dto.IdPromocion }, dto); | ||||
|         } | ||||
|  | ||||
|         // PUT: api/promociones/{id} | ||||
|         [HttpPut("{id:int}")] | ||||
|         public async Task<IActionResult> Update(int id, [FromBody] UpdatePromocionDto updateDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarPromociones)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _promocionService.Actualizar(id, updateDto, userId.Value); | ||||
|              | ||||
|             if (!exito) | ||||
|             { | ||||
|                 if (error != null && error.Contains("no encontrada")) return NotFound(new { message = error }); | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|             return NoContent(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,138 @@ | ||||
| // Archivo: GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs | ||||
|  | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/suscripciones")] // Ruta base para acciones sobre una suscripción específica | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class SuscripcionesController : ControllerBase | ||||
|     { | ||||
|         private readonly ISuscripcionService _suscripcionService; | ||||
|         private readonly ILogger<SuscripcionesController> _logger; | ||||
|  | ||||
|         // Permisos (nuevos, a crear en la BD) | ||||
|         private const string PermisoGestionarSuscripciones = "SU005"; | ||||
|  | ||||
|         public SuscripcionesController(ISuscripcionService suscripcionService, ILogger<SuscripcionesController> logger) | ||||
|         { | ||||
|             _suscripcionService = suscripcionService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|  | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             _logger.LogWarning("No se pudo obtener el UserId del token JWT en SuscripcionesController."); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // Endpoint anidado para obtener las suscripciones de un suscriptor | ||||
|         // GET: api/suscriptores/{idSuscriptor}/suscripciones | ||||
|         [HttpGet("~/api/suscriptores/{idSuscriptor:int}/suscripciones")] | ||||
|         public async Task<IActionResult> GetBySuscriptor(int idSuscriptor) | ||||
|         { | ||||
|             // Se podría usar el permiso de ver suscriptores (SU001) o el de gestionar suscripciones (SU005) | ||||
|             if (!TienePermiso("SU001")) return Forbid(); | ||||
|  | ||||
|             var suscripciones = await _suscripcionService.ObtenerPorSuscriptorId(idSuscriptor); | ||||
|             return Ok(suscripciones); | ||||
|         } | ||||
|  | ||||
|         // GET: api/suscripciones/{id} | ||||
|         [HttpGet("{id:int}", Name = "GetSuscripcionById")] | ||||
|         public async Task<IActionResult> GetById(int id) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             var suscripcion = await _suscripcionService.ObtenerPorId(id); | ||||
|             if (suscripcion == null) return NotFound(); | ||||
|             return Ok(suscripcion); | ||||
|         } | ||||
|  | ||||
|         // POST: api/suscripciones | ||||
|         [HttpPost] | ||||
|         public async Task<IActionResult> Create([FromBody] CreateSuscripcionDto createDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (dto, error) = await _suscripcionService.Crear(createDto, userId.Value); | ||||
|  | ||||
|             if (error != null) return BadRequest(new { message = error }); | ||||
|             if (dto == null) return StatusCode(StatusCodes.Status500InternalServerError, "Error al crear la suscripción."); | ||||
|  | ||||
|             return CreatedAtRoute("GetSuscripcionById", new { id = dto.IdSuscripcion }, dto); | ||||
|         } | ||||
|  | ||||
|         // PUT: api/suscripciones/{id} | ||||
|         [HttpPut("{id:int}")] | ||||
|         public async Task<IActionResult> Update(int id, [FromBody] UpdateSuscripcionDto updateDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _suscripcionService.Actualizar(id, updateDto, userId.Value); | ||||
|  | ||||
|             if (!exito) | ||||
|             { | ||||
|                 if (error != null && error.Contains("no encontrada")) return NotFound(new { message = error }); | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|             return NoContent(); | ||||
|         } | ||||
|  | ||||
|         // GET: api/suscripciones/{idSuscripcion}/promociones | ||||
|         [HttpGet("{idSuscripcion:int}/promociones")] | ||||
|         public async Task<IActionResult> GetPromocionesAsignadas(int idSuscripcion) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             var promos = await _suscripcionService.ObtenerPromocionesAsignadas(idSuscripcion); | ||||
|             return Ok(promos); | ||||
|         } | ||||
|  | ||||
|         // GET: api/suscripciones/{idSuscripcion}/promociones-disponibles | ||||
|         [HttpGet("{idSuscripcion:int}/promociones-disponibles")] | ||||
|         public async Task<IActionResult> GetPromocionesDisponibles(int idSuscripcion) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             var promos = await _suscripcionService.ObtenerPromocionesDisponibles(idSuscripcion); | ||||
|             return Ok(promos); | ||||
|         } | ||||
|  | ||||
|         // POST: api/suscripciones/{idSuscripcion}/promociones | ||||
|         [HttpPost("{idSuscripcion:int}/promociones")] | ||||
|         public async Task<IActionResult> AsignarPromocion(int idSuscripcion, [FromBody] AsignarPromocionDto dto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, dto, userId.Value); | ||||
|             if (!exito) return BadRequest(new { message = error }); | ||||
|             return Ok(); | ||||
|         } | ||||
|  | ||||
|         // DELETE: api/suscripciones/{idSuscripcion}/promociones/{idPromocion} | ||||
|         [HttpDelete("{idSuscripcion:int}/promociones/{idPromocion:int}")] | ||||
|         public async Task<IActionResult> QuitarPromocion(int idSuscripcion, int idPromocion) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             var (exito, error) = await _suscripcionService.QuitarPromocion(idSuscripcion, idPromocion); | ||||
|             if (!exito) return BadRequest(new { message = error }); | ||||
|             return NoContent(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,153 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/suscriptores")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class SuscriptoresController : ControllerBase | ||||
|     { | ||||
|         private readonly ISuscriptorService _suscriptorService; | ||||
|         private readonly ILogger<SuscriptoresController> _logger; | ||||
|  | ||||
|         // Permisos para Suscriptores | ||||
|         private const string PermisoVer = "SU001"; | ||||
|         private const string PermisoCrear = "SU002"; | ||||
|         private const string PermisoModificar = "SU003"; | ||||
|         private const string PermisoActivarDesactivar = "SU004"; | ||||
|  | ||||
|         public SuscriptoresController(ISuscriptorService suscriptorService, ILogger<SuscriptoresController> logger) | ||||
|         { | ||||
|             _suscriptorService = suscriptorService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|          | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             _logger.LogWarning("No se pudo obtener el UserId del token JWT en SuscriptoresController."); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // GET: api/suscriptores | ||||
|         [HttpGet] | ||||
|         [ProducesResponseType(typeof(IEnumerable<SuscriptorDto>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         public async Task<IActionResult> GetAll([FromQuery] string? nombre, [FromQuery] string? nroDoc, [FromQuery] bool soloActivos = true) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoVer)) return Forbid(); | ||||
|             var suscriptores = await _suscriptorService.ObtenerTodos(nombre, nroDoc, soloActivos); | ||||
|             return Ok(suscriptores); | ||||
|         } | ||||
|  | ||||
|         // GET: api/suscriptores/{id} | ||||
|         [HttpGet("{id:int}", Name = "GetSuscriptorById")] | ||||
|         [ProducesResponseType(typeof(SuscriptorDto), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||
|         public async Task<IActionResult> GetById(int id) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoVer)) return Forbid(); | ||||
|             var suscriptor = await _suscriptorService.ObtenerPorId(id); | ||||
|             if (suscriptor == null) return NotFound(); | ||||
|             return Ok(suscriptor); | ||||
|         } | ||||
|  | ||||
|         // POST: api/suscriptores | ||||
|         [HttpPost] | ||||
|         [ProducesResponseType(typeof(SuscriptorDto), StatusCodes.Status201Created)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         public async Task<IActionResult> Create([FromBody] CreateSuscriptorDto createDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoCrear)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|              | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (dto, error) = await _suscriptorService.Crear(createDto, userId.Value); | ||||
|              | ||||
|             if (error != null) return BadRequest(new { message = error }); | ||||
|             if (dto == null) return StatusCode(StatusCodes.Status500InternalServerError, "Error al crear el suscriptor."); | ||||
|              | ||||
|             return CreatedAtRoute("GetSuscriptorById", new { id = dto.IdSuscriptor }, dto); | ||||
|         } | ||||
|  | ||||
|         // PUT: api/suscriptores/{id} | ||||
|         [HttpPut("{id:int}")] | ||||
|         [ProducesResponseType(StatusCodes.Status204NoContent)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||
|         public async Task<IActionResult> Update(int id, [FromBody] UpdateSuscriptorDto updateDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoModificar)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _suscriptorService.Actualizar(id, updateDto, userId.Value); | ||||
|              | ||||
|             if (!exito) | ||||
|             { | ||||
|                 if (error != null && error.Contains("no encontrado")) return NotFound(new { message = error }); | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|             return NoContent(); | ||||
|         } | ||||
|  | ||||
|         // DELETE: api/suscriptores/{id} (Desactivar) | ||||
|         [HttpDelete("{id:int}")] | ||||
|         [ProducesResponseType(StatusCodes.Status204NoContent)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||
|         public async Task<IActionResult> Deactivate(int id) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoActivarDesactivar)) return Forbid(); | ||||
|              | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _suscriptorService.Desactivar(id, userId.Value); | ||||
|              | ||||
|             if (!exito) | ||||
|             { | ||||
|                 if (error != null && error.Contains("no encontrado")) return NotFound(new { message = error }); | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|             return NoContent(); | ||||
|         } | ||||
|  | ||||
|         // POST: api/suscriptores/{id}/activar | ||||
|         [HttpPost("{id:int}/activar")] | ||||
|         [ProducesResponseType(StatusCodes.Status204NoContent)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||
|         public async Task<IActionResult> Activate(int id) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoActivarDesactivar)) return Forbid(); | ||||
|              | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _suscriptorService.Activar(id, userId.Value); | ||||
|              | ||||
|             if (!exito) | ||||
|             { | ||||
|                 if (error != null && error.Contains("no encontrado")) return NotFound(new { message = error }); | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|             return NoContent(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,65 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Comunicaciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Comunicaciones | ||||
| { | ||||
|   public class EmailLogRepository : IEmailLogRepository | ||||
|   { | ||||
|     private readonly DbConnectionFactory _connectionFactory; | ||||
|     private readonly ILogger<EmailLogRepository> _logger; | ||||
|     public EmailLogRepository(DbConnectionFactory connectionFactory, ILogger<EmailLogRepository> logger) | ||||
|     { | ||||
|       _connectionFactory = connectionFactory; | ||||
|       _logger = logger; | ||||
|     } | ||||
|  | ||||
|     public async Task CreateAsync(EmailLog log) | ||||
|     { | ||||
|       const string sql = @" | ||||
|         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); | ||||
|     } | ||||
|  | ||||
|     public async Task<IEnumerable<EmailLog>> GetByReferenceAsync(string referenciaId) | ||||
|     { | ||||
|       const string sql = @" | ||||
|             SELECT * FROM dbo.com_EmailLogs | ||||
|             WHERE ReferenciaId = @ReferenciaId | ||||
|             ORDER BY FechaEnvio DESC;"; | ||||
|       try | ||||
|       { | ||||
|         using var connection = _connectionFactory.CreateConnection(); | ||||
|         return await connection.QueryAsync<EmailLog>(sql, new { ReferenciaId = referenciaId }); | ||||
|       } | ||||
|       catch (System.Exception ex) | ||||
|       { | ||||
|         _logger.LogError(ex, "Error al obtener logs de email por ReferenciaId: {ReferenciaId}", referenciaId); | ||||
|         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>(); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| using GestionIntegral.Api.Models.Comunicaciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Comunicaciones | ||||
| { | ||||
|     public interface IEmailLogRepository | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Guarda un nuevo registro de log de email en la base de datos. | ||||
|         /// </summary> | ||||
|         Task CreateAsync(EmailLog log); | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Obtiene todos los registros de log de email que coinciden con una referencia específica. | ||||
|         /// </summary> | ||||
|         /// <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 }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -45,5 +45,8 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes | ||||
|         Task<IEnumerable<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla); | ||||
|         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>> GetDistribucionSuscripcionesActivasAsync(DateTime fechaDesde, DateTime fechaHasta); | ||||
|         Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesBajasAsync(DateTime fechaDesde, DateTime fechaHasta); | ||||
|     } | ||||
| } | ||||
| @@ -547,5 +547,111 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes | ||||
|                 commandType: CommandType.StoredProcedure, commandTimeout: 120 | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<FacturasParaReporteDto>> GetDatosReportePublicidadAsync(string periodo) | ||||
|         { | ||||
|             // Esta consulta une todas las tablas necesarias para obtener los datos del reporte | ||||
|             const string sql = @" | ||||
|               SELECT  | ||||
|                   f.IdFactura, | ||||
|                   f.Periodo, | ||||
|                   s.NombreCompleto AS NombreSuscriptor, | ||||
|                   s.TipoDocumento, | ||||
|                   s.NroDocumento, | ||||
|                   f.ImporteFinal, | ||||
|                   e.Id_Empresa AS IdEmpresa, | ||||
|                   e.Nombre AS NombreEmpresa | ||||
|               FROM dbo.susc_Facturas f | ||||
|               JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor | ||||
|               -- Usamos una subconsulta para obtener la empresa de forma segura | ||||
|               JOIN ( | ||||
|                   SELECT DISTINCT  | ||||
|                       fd.IdFactura,  | ||||
|                       p.Id_Empresa | ||||
|                   FROM dbo.susc_FacturaDetalles fd | ||||
|                   JOIN dbo.susc_Suscripciones sub ON fd.IdSuscripcion = sub.IdSuscripcion | ||||
|                   JOIN dbo.dist_dtPublicaciones p ON sub.IdPublicacion = p.Id_Publicacion | ||||
|               ) AS FacturaEmpresa ON f.IdFactura = FacturaEmpresa.IdFactura | ||||
|               JOIN dbo.dist_dtEmpresas e ON FacturaEmpresa.Id_Empresa = e.Id_Empresa | ||||
|               WHERE  | ||||
|                   f.Periodo = @Periodo | ||||
|                   AND f.EstadoPago = 'Pagada' | ||||
|                   AND f.EstadoFacturacion = 'Pendiente de Facturar' | ||||
|               ORDER BY  | ||||
|                   e.Nombre, s.NombreCompleto; | ||||
|           "; | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _dbConnectionFactory.CreateConnection(); | ||||
|                 return await connection.QueryAsync<FacturasParaReporteDto>(sql, new { Periodo = periodo }); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al ejecutar la consulta para el Reporte de Publicidad para el período {Periodo}", periodo); | ||||
|                 return Enumerable.Empty<FacturasParaReporteDto>(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         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 | ||||
|                 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  | ||||
|                     -- --- 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 | ||||
|                     AND (s.FechaFin IS NULL OR s.FechaFin >= @FechaDesde) | ||||
|                 ORDER BY e.Nombre, p.Nombre, 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 (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>(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,139 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
| using System.Text; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class AjusteRepository : IAjusteRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<AjusteRepository> _logger; | ||||
|  | ||||
|         public AjusteRepository(DbConnectionFactory factory, ILogger<AjusteRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = factory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateAsync(Ajuste ajuste, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 UPDATE dbo.susc_Ajustes SET | ||||
|                     IdEmpresa = @IdEmpresa, | ||||
|                     FechaAjuste = @FechaAjuste, | ||||
|                     TipoAjuste = @TipoAjuste, | ||||
|                     Monto = @Monto, | ||||
|                     Motivo = @Motivo | ||||
|                 WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';"; | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|             var rows = await transaction.Connection.ExecuteAsync(sql, ajuste, transaction); | ||||
|             return rows == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 INSERT INTO dbo.susc_Ajustes (IdSuscriptor, IdEmpresa, FechaAjuste, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta) | ||||
|                 OUTPUT INSERTED.* | ||||
|                 VALUES (@IdSuscriptor, @IdEmpresa, @FechaAjuste, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());"; | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|             return await transaction.Connection.QuerySingleOrDefaultAsync<Ajuste>(sql, nuevoAjuste, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta) | ||||
|         { | ||||
|             var sqlBuilder = new StringBuilder("SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor"); | ||||
|             var parameters = new DynamicParameters(); | ||||
|             parameters.Add("IdSuscriptor", idSuscriptor); | ||||
|  | ||||
|             if (fechaDesde.HasValue) | ||||
|             { | ||||
|                 sqlBuilder.Append(" AND FechaAjuste >= @FechaDesde"); | ||||
|                 parameters.Add("FechaDesde", fechaDesde.Value.Date); | ||||
|             } | ||||
|             if (fechaHasta.HasValue) | ||||
|             { | ||||
|                 sqlBuilder.Append(" AND FechaAjuste <= @FechaHasta"); | ||||
|                 parameters.Add("FechaHasta", fechaHasta.Value.Date); | ||||
|             } | ||||
|  | ||||
|             sqlBuilder.Append(" ORDER BY FechaAlta DESC;"); | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<Ajuste>(sqlBuilder.ToString(), parameters); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Ajuste>> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, int idEmpresa, DateTime fechaHasta, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 SELECT * FROM dbo.susc_Ajustes  | ||||
|                 WHERE IdSuscriptor = @IdSuscriptor  | ||||
|                 AND IdEmpresa = @IdEmpresa | ||||
|                 AND Estado = 'Pendiente' | ||||
|                 AND FechaAjuste <= @FechaHasta;"; | ||||
|              | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|             return await transaction.Connection.QueryAsync<Ajuste>(sql, new { idSuscriptor, idEmpresa, FechaHasta = fechaHasta }, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction) | ||||
|         { | ||||
|             if (!idsAjustes.Any()) return true; | ||||
|  | ||||
|             const string sql = @" | ||||
|                 UPDATE dbo.susc_Ajustes SET | ||||
|                     Estado = 'Aplicado', | ||||
|                     IdFacturaAplicado = @IdFactura | ||||
|                 WHERE IdAjuste IN @IdsAjustes;"; | ||||
|  | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdsAjustes = idsAjustes, IdFactura = idFactura }, transaction); | ||||
|             return rowsAffected == idsAjustes.Count(); | ||||
|         } | ||||
|  | ||||
|         public async Task<Ajuste?> GetByIdAsync(int idAjuste) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdAjuste = @IdAjuste;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QuerySingleOrDefaultAsync<Ajuste>(sql, new { idAjuste }); | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 UPDATE dbo.susc_Ajustes SET | ||||
|                     Estado = 'Anulado', | ||||
|                     IdUsuarioAnulo = @IdUsuario, | ||||
|                     FechaAnulacion = GETDATE() | ||||
|                 WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';"; | ||||
|  | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|  | ||||
|             var rows = await transaction.Connection.ExecuteAsync(sql, new { IdAjuste = idAjuste, IdUsuario = idUsuario }, transaction); | ||||
|             return rows == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Ajuste>> GetAjustesPorIdFacturaAsync(int idFactura) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdFacturaAplicado = @IdFactura;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<Ajuste>(sql, new { IdFactura = idFactura }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,58 @@ | ||||
| using Dapper; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class FacturaDetalleRepository : IFacturaDetalleRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<FacturaDetalleRepository> _logger; | ||||
|  | ||||
|         public FacturaDetalleRepository(DbConnectionFactory connectionFactory, ILogger<FacturaDetalleRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<FacturaDetalle?> CreateAsync(FacturaDetalle nuevoDetalle, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sqlInsert = @" | ||||
|                 INSERT INTO dbo.susc_FacturaDetalles (IdFactura, IdSuscripcion, Descripcion, ImporteBruto, DescuentoAplicado, ImporteNeto) | ||||
|                 OUTPUT INSERTED.* | ||||
|                 VALUES (@IdFactura, @IdSuscripcion, @Descripcion, @ImporteBruto, @DescuentoAplicado, @ImporteNeto);"; | ||||
|              | ||||
|             return await transaction.Connection.QuerySingleOrDefaultAsync<FacturaDetalle>(sqlInsert, nuevoDetalle, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<FacturaDetalle>> GetDetallesPorFacturaIdAsync(int idFactura) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_FacturaDetalles WHERE IdFactura = @IdFactura;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<FacturaDetalle>(sql, new { IdFactura = idFactura }); | ||||
|         } | ||||
|          | ||||
|         public async Task<IEnumerable<FacturaDetalle>> GetDetallesPorPeriodoAsync(string periodo) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 SELECT fd.*  | ||||
|                 FROM dbo.susc_FacturaDetalles fd | ||||
|                 JOIN dbo.susc_Facturas f ON fd.IdFactura = f.IdFactura | ||||
|                 WHERE f.Periodo = @Periodo;"; | ||||
|              | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.QueryAsync<FacturaDetalle>(sql, new { Periodo = periodo }); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener los detalles de factura para el período {Periodo}", periodo); | ||||
|                 return Enumerable.Empty<FacturaDetalle>(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,259 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Data; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Data; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class FacturaRepository : IFacturaRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<FacturaRepository> _logger; | ||||
|  | ||||
|         public FacturaRepository(DbConnectionFactory connectionFactory, ILogger<FacturaRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<Factura?> GetByIdAsync(int idFactura) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura = @idFactura;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idFactura }); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Facturas WHERE Periodo = @Periodo ORDER BY IdFactura;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<Factura>(sql, new { Periodo = periodo }); | ||||
|         } | ||||
|  | ||||
|         public async Task<Factura?> GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = "SELECT TOP 1 * FROM dbo.susc_Facturas WHERE IdSuscriptor = @IdSuscriptor AND Periodo = @Periodo;"; | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             return await transaction.Connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idSuscriptor, Periodo = periodo }, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Factura>> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdSuscriptor = @IdSuscriptor AND Periodo = @Periodo;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<Factura>(sql, new { idSuscriptor, Periodo = periodo }); | ||||
|         } | ||||
|  | ||||
|         public async Task<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sqlInsert = @" | ||||
|                 INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion) | ||||
|                 OUTPUT INSERTED.* | ||||
|                 VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);"; | ||||
|  | ||||
|             return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sql = "UPDATE dbo.susc_Facturas SET EstadoPago = @NuevoEstadoPago WHERE IdFactura = @IdFactura;"; | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, idFactura }, transaction); | ||||
|             return rowsAffected == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sql = @" | ||||
|                 UPDATE dbo.susc_Facturas SET  | ||||
|                     NumeroFactura = @NumeroFactura,  | ||||
|                     EstadoFacturacion = 'Facturado'  | ||||
|                 WHERE IdFactura = @IdFactura;"; | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, idFactura }, transaction); | ||||
|             return rowsAffected == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sql = "UPDATE dbo.susc_Facturas SET IdLoteDebito = @IdLoteDebito WHERE IdFactura IN @IdsFacturas;"; | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdLoteDebito = idLoteDebito, IdsFacturas = idsFacturas }, transaction); | ||||
|             return rowsAffected == idsFacturas.Count(); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) | ||||
|         { | ||||
|             var sqlBuilder = new StringBuilder(@" | ||||
|                 WITH FacturaConEmpresa AS ( | ||||
|                     -- Esta subconsulta obtiene el IdEmpresa para cada factura basándose en la primera suscripción que encuentra en sus detalles. | ||||
|                     -- Esto es seguro porque nuestra lógica de negocio asegura que todos los detalles de una factura pertenecen a la misma empresa. | ||||
|                     SELECT  | ||||
|                         f.IdFactura, | ||||
|                         (SELECT TOP 1 p.Id_Empresa  | ||||
|                          FROM dbo.susc_FacturaDetalles fd | ||||
|                          JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion | ||||
|                          JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion | ||||
|                          WHERE fd.IdFactura = f.IdFactura) AS IdEmpresa | ||||
|                     FROM dbo.susc_Facturas f | ||||
|                     WHERE f.Periodo = @Periodo | ||||
|                 ) | ||||
|                 SELECT  | ||||
|                     f.*,  | ||||
|                     s.NombreCompleto AS NombreSuscriptor,  | ||||
|                     fce.IdEmpresa, | ||||
|                     (SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos pg WHERE pg.IdFactura = f.IdFactura AND pg.Estado = 'Aprobado') AS TotalPagado | ||||
|                 FROM dbo.susc_Facturas f | ||||
|                 JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor | ||||
|                 JOIN FacturaConEmpresa fce ON f.IdFactura = fce.IdFactura | ||||
|                 WHERE f.Periodo = @Periodo"); | ||||
|  | ||||
|             var parameters = new DynamicParameters(); | ||||
|             parameters.Add("Periodo", periodo); | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(nombreSuscriptor)) | ||||
|             { | ||||
|                 sqlBuilder.Append(" AND s.NombreCompleto LIKE @NombreSuscriptor"); | ||||
|                 parameters.Add("NombreSuscriptor", $"%{nombreSuscriptor}%"); | ||||
|             } | ||||
|             if (!string.IsNullOrWhiteSpace(estadoPago)) | ||||
|             { | ||||
|                 sqlBuilder.Append(" AND f.EstadoPago = @EstadoPago"); | ||||
|                 parameters.Add("EstadoPago", estadoPago); | ||||
|             } | ||||
|             if (!string.IsNullOrWhiteSpace(estadoFacturacion)) | ||||
|             { | ||||
|                 sqlBuilder.Append(" AND f.EstadoFacturacion = @EstadoFacturacion"); | ||||
|                 parameters.Add("EstadoFacturacion", estadoFacturacion); | ||||
|             } | ||||
|  | ||||
|             sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;"); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 var result = await connection.QueryAsync<Factura, string, int, decimal, (Factura, string, int, decimal)>( | ||||
|                     sqlBuilder.ToString(), | ||||
|                     (factura, suscriptor, idEmpresa, totalPagado) => (factura, suscriptor, idEmpresa, totalPagado), | ||||
|                     parameters, | ||||
|                     splitOn: "NombreSuscriptor,IdEmpresa,TotalPagado" | ||||
|                 ); | ||||
|                 return result; | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener facturas enriquecidas para el período {Periodo}", periodo); | ||||
|                 return Enumerable.Empty<(Factura, string, int, decimal)>(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sql = @" | ||||
|                 UPDATE dbo.susc_Facturas SET | ||||
|                     EstadoPago = @NuevoEstadoPago, | ||||
|                     MotivoRechazo = @MotivoRechazo | ||||
|                 WHERE IdFactura = @IdFactura;"; | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, MotivoRechazo = motivoRechazo, idFactura }, transaction); | ||||
|             return rowsAffected == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<string?> GetUltimoPeriodoFacturadoAsync() | ||||
|         { | ||||
|             const string sql = "SELECT TOP 1 Periodo FROM dbo.susc_Facturas ORDER BY Periodo DESC;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QuerySingleOrDefaultAsync<string>(sql); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<(Factura Factura, string NombreEmpresa)>> GetFacturasConEmpresaAsync(int idSuscriptor, string periodo) | ||||
|         { | ||||
|             // Esta consulta es más robusta y eficiente. Obtiene la factura y el nombre de la empresa en una sola llamada. | ||||
|             const string sql = @" | ||||
|             SELECT f.*, e.Nombre AS NombreEmpresa | ||||
|             FROM dbo.susc_Facturas f | ||||
|             OUTER APPLY ( | ||||
|                 SELECT TOP 1 emp.Nombre | ||||
|                 FROM dbo.susc_FacturaDetalles fd | ||||
|                 JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion | ||||
|                 JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion | ||||
|                 JOIN dbo.dist_dtEmpresas emp ON p.Id_Empresa = emp.Id_Empresa | ||||
|                 WHERE fd.IdFactura = f.IdFactura | ||||
|             ) e | ||||
|             WHERE f.IdSuscriptor = @IdSuscriptor AND f.Periodo = @Periodo;"; | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 var result = await connection.QueryAsync<Factura, string, (Factura, string)>( | ||||
|                     sql, | ||||
|                     (factura, nombreEmpresa) => (factura, nombreEmpresa ?? "N/A"), // Asignamos "N/A" si no encuentra empresa | ||||
|                     new { IdSuscriptor = idSuscriptor, Periodo = periodo }, | ||||
|                     splitOn: "NombreEmpresa" | ||||
|                 ); | ||||
|                 return result; | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener facturas con empresa para suscriptor {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo); | ||||
|                 return Enumerable.Empty<(Factura, string)>(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo) | ||||
|         { | ||||
|             // Consulta simplificada pero robusta. | ||||
|             const string sql = @" | ||||
|               SELECT * FROM dbo.susc_Facturas  | ||||
|               WHERE Periodo = @Periodo  | ||||
|               AND EstadoPago = 'Pagada'  | ||||
|               AND EstadoFacturacion = 'Pendiente de Facturar';"; | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.QueryAsync<Factura>(sql, new { Periodo = periodo }); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener facturas pagadas pendientes de facturar para el período {Periodo}", periodo); | ||||
|                 return Enumerable.Empty<Factura>(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids) | ||||
|         { | ||||
|             if (ids == null || !ids.Any()) | ||||
|             { | ||||
|                 return Enumerable.Empty<Factura>(); | ||||
|             } | ||||
|             const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura IN @Ids;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<Factura>(sql, new { Ids = ids }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,54 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class FormaPagoRepository : IFormaPagoRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<FormaPagoRepository> _logger; | ||||
|  | ||||
|         public FormaPagoRepository(DbConnectionFactory connectionFactory, ILogger<FormaPagoRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<FormaPago>> GetAllAsync() | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 SELECT IdFormaPago, Nombre, RequiereCBU, Activo  | ||||
|                 FROM dbo.susc_FormasDePago  | ||||
|                 WHERE Activo = 1  | ||||
|                 ORDER BY Nombre;"; | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.QueryAsync<FormaPago>(sql); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener todas las Formas de Pago activas."); | ||||
|                 return Enumerable.Empty<FormaPago>(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<FormaPago?> GetByIdAsync(int id) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 SELECT IdFormaPago, Nombre, RequiereCBU, Activo  | ||||
|                 FROM dbo.susc_FormasDePago  | ||||
|                 WHERE IdFormaPago = @Id;"; | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.QuerySingleOrDefaultAsync<FormaPago>(sql, new { Id = id }); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener Forma de Pago por ID: {IdFormaPago}", id); | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| // Archivo: GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs | ||||
|  | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Data; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public interface IAjusteRepository | ||||
|     { | ||||
|         Task<Ajuste?> GetByIdAsync(int idAjuste); | ||||
|         Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction); | ||||
|         Task<bool> UpdateAsync(Ajuste ajuste, IDbTransaction transaction); | ||||
|         Task<bool> AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction); | ||||
|         Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta); | ||||
|         Task<IEnumerable<Ajuste>> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, int idEmpresa, DateTime fechaHasta, IDbTransaction transaction); | ||||
|         Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction); | ||||
|         Task<IEnumerable<Ajuste>> GetAjustesPorIdFacturaAsync(int idFactura); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public interface IFacturaDetalleRepository | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Crea un nuevo registro de detalle de factura. | ||||
|         /// </summary> | ||||
|         Task<FacturaDetalle?> CreateAsync(FacturaDetalle nuevoDetalle, IDbTransaction transaction); | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Obtiene todos los detalles de una factura específica. | ||||
|         /// </summary> | ||||
|         Task<IEnumerable<FacturaDetalle>> GetDetallesPorFacturaIdAsync(int idFactura); | ||||
|          | ||||
|         /// <summary> | ||||
|         /// Obtiene de forma eficiente todos los detalles de todas las facturas de un período específico. | ||||
|         /// </summary> | ||||
|         Task<IEnumerable<FacturaDetalle>> GetDetallesPorPeriodoAsync(string periodo); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public interface IFacturaRepository | ||||
|     { | ||||
|         Task<Factura?> GetByIdAsync(int idFactura); | ||||
|         Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids); | ||||
|         Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo); | ||||
|         Task<Factura?> GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction); | ||||
|         Task<IEnumerable<Factura>> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo); | ||||
|         Task<IEnumerable<(Factura Factura, string NombreEmpresa)>> GetFacturasConEmpresaAsync(int idSuscriptor, string periodo); | ||||
|         Task<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction); | ||||
|         Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); | ||||
|         Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); | ||||
|         Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); | ||||
|         Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); | ||||
|         Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);    | ||||
|         Task<string?> GetUltimoPeriodoFacturadoAsync(); | ||||
|         Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public interface IFormaPagoRepository | ||||
|     { | ||||
|         Task<IEnumerable<FormaPago>> GetAllAsync(); | ||||
|         Task<FormaPago?> GetByIdAsync(int id); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|   public interface ILoteDebitoRepository | ||||
|   { | ||||
|     Task<LoteDebito?> CreateAsync(LoteDebito nuevoLote, IDbTransaction transaction); | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public interface IPagoRepository | ||||
|     { | ||||
|         Task<IEnumerable<Pago>> GetByFacturaIdAsync(int idFactura); | ||||
|         Task<Pago?> CreateAsync(Pago nuevoPago, IDbTransaction transaction); | ||||
|         Task<decimal> GetTotalPagadoAprobadoAsync(int idFactura, IDbTransaction transaction); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public interface IPromocionRepository | ||||
|     { | ||||
|         Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas); | ||||
|         Task<Promocion?> GetByIdAsync(int id); | ||||
|         Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction); | ||||
|         Task<bool> UpdateAsync(Promocion promocion, IDbTransaction transaction); | ||||
|         Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction); | ||||
|         Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public interface ISuscripcionRepository | ||||
|     { | ||||
|         Task<Suscripcion?> GetByIdAsync(int idSuscripcion); | ||||
|         Task<IEnumerable<Suscripcion>> GetBySuscriptorIdAsync(int idSuscriptor); | ||||
|         Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction); | ||||
|         Task<Suscripcion?> CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction); | ||||
|         Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction); | ||||
|         Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion); | ||||
|         Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction); | ||||
|         Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public interface ISuscriptorRepository | ||||
|     { | ||||
|         Task<IEnumerable<Suscriptor>> GetAllAsync(string? nombreFilter, string? nroDocFilter, bool soloActivos); | ||||
|         Task<Suscriptor?> GetByIdAsync(int id); | ||||
|         Task<Suscriptor?> CreateAsync(Suscriptor nuevoSuscriptor, IDbTransaction transaction); | ||||
|         Task<bool> UpdateAsync(Suscriptor suscriptorAActualizar, IDbTransaction transaction); | ||||
|         Task<bool> ToggleActivoAsync(int id, bool activar, int idUsuario, IDbTransaction transaction); | ||||
|         Task<bool> ExistsByDocumentoAsync(string tipoDocumento, string nroDocumento, int? excludeId = null); | ||||
|         Task<bool> IsInUseAsync(int id); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,43 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class LoteDebitoRepository : ILoteDebitoRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<LoteDebitoRepository> _logger; | ||||
|  | ||||
|         public LoteDebitoRepository(DbConnectionFactory connectionFactory, ILogger<LoteDebitoRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<LoteDebito?> CreateAsync(LoteDebito nuevoLote, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|  | ||||
|             const string sqlInsert = @" | ||||
|                 INSERT INTO dbo.susc_LotesDebito | ||||
|                     (FechaGeneracion, Periodo, NombreArchivo, ImporteTotal, CantidadRegistros, IdUsuarioGeneracion) | ||||
|                 OUTPUT INSERTED.* | ||||
|                 VALUES | ||||
|                     (GETDATE(), @Periodo, @NombreArchivo, @ImporteTotal, @CantidadRegistros, @IdUsuarioGeneracion);"; | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 return await transaction.Connection.QuerySingleAsync<LoteDebito>(sqlInsert, nuevoLote, transaction); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al crear el registro de LoteDebito para el período {Periodo}", nuevoLote.Periodo); | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,68 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class PagoRepository : IPagoRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<PagoRepository> _logger; | ||||
|  | ||||
|         public PagoRepository(DbConnectionFactory connectionFactory, ILogger<PagoRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Pago>> GetByFacturaIdAsync(int idFactura) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Pagos WHERE IdFactura = @IdFactura ORDER BY FechaPago DESC;"; | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.QueryAsync<Pago>(sql, new { idFactura }); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener pagos para la factura ID: {IdFactura}", idFactura); | ||||
|                 return Enumerable.Empty<Pago>(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<Pago?> CreateAsync(Pago nuevoPago, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|  | ||||
|             const string sqlInsert = @" | ||||
|                 INSERT INTO dbo.susc_Pagos | ||||
|                     (IdFactura, FechaPago, IdFormaPago, Monto, Estado, Referencia, Observaciones, IdUsuarioRegistro) | ||||
|                 OUTPUT INSERTED.* | ||||
|                 VALUES | ||||
|                     (@IdFactura, @FechaPago, @IdFormaPago, @Monto, @Estado, @Referencia, @Observaciones, @IdUsuarioRegistro);"; | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 return await transaction.Connection.QuerySingleAsync<Pago>(sqlInsert, nuevoPago, transaction); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al registrar un nuevo Pago para la Factura ID: {IdFactura}", nuevoPago.IdFactura); | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<decimal> GetTotalPagadoAprobadoAsync(int idFactura, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sql = "SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos WHERE IdFactura = @IdFactura AND Estado = 'Aprobado';"; | ||||
|             return await transaction.Connection.ExecuteScalarAsync<decimal>(sql, new { idFactura }, transaction); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,118 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
| using System.Text; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class PromocionRepository : IPromocionRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<PromocionRepository> _logger; | ||||
|  | ||||
|         public PromocionRepository(DbConnectionFactory factory, ILogger<PromocionRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = factory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas) | ||||
|         { | ||||
|             var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones"); | ||||
|             if (soloActivas) | ||||
|             { | ||||
|                 sql.Append(" WHERE Activa = 1"); | ||||
|             } | ||||
|             sql.Append(" ORDER BY FechaInicio DESC;"); | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<Promocion>(sql.ToString()); | ||||
|         } | ||||
|  | ||||
|         public async Task<Promocion?> GetByIdAsync(int id) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Promociones WHERE IdPromocion = @Id;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QuerySingleOrDefaultAsync<Promocion>(sql, new { Id = id }); | ||||
|         } | ||||
|  | ||||
|         public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 INSERT INTO dbo.susc_Promociones | ||||
|                     (Descripcion, TipoEfecto, ValorEfecto, TipoCondicion, ValorCondicion, | ||||
|                     FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta) | ||||
|                 OUTPUT INSERTED.* | ||||
|                 VALUES (@Descripcion, @TipoEfecto, @ValorEfecto, @TipoCondicion, | ||||
|                         @ValorCondicion, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());"; | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|  | ||||
|             return await transaction.Connection.QuerySingleAsync<Promocion>(sql, nuevaPromocion, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateAsync(Promocion promocion, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 UPDATE dbo.susc_Promociones SET | ||||
|                     Descripcion = @Descripcion, | ||||
|                     TipoPromocion = @TipoPromocion, | ||||
|                     Valor = @Valor, | ||||
|                     FechaInicio = @FechaInicio, | ||||
|                     FechaFin = @FechaFin, | ||||
|                     Activa = @Activa | ||||
|                 WHERE IdPromocion = @IdPromocion;"; | ||||
|  | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|  | ||||
|             var rows = await transaction.Connection.ExecuteAsync(sql, promocion, transaction); | ||||
|             return rows == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction) | ||||
|         { | ||||
|             // Esta consulta ahora es más compleja para respetar ambas vigencias. | ||||
|             const string sql = @" | ||||
|                 SELECT p.*  | ||||
|                 FROM dbo.susc_Promociones p | ||||
|                 JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion | ||||
|                 WHERE sp.IdSuscripcion = @IdSuscripcion | ||||
|                 AND p.Activa = 1 | ||||
|                 -- 1. La promoción general debe estar activa en el período | ||||
|                 AND p.FechaInicio <= @FechaPeriodo | ||||
|                 AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo) | ||||
|                 -- 2. La asignación específica al cliente debe estar activa en el período | ||||
|                 AND sp.VigenciaDesde <= @FechaPeriodo | ||||
|                 AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);"; | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|             return await transaction.Connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion, FechaPeriodo = fechaPeriodo }, transaction); | ||||
|         } | ||||
|  | ||||
|         // Versión SIN transacción, para solo lectura | ||||
|         public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 SELECT p.*  | ||||
|                 FROM dbo.susc_Promociones p | ||||
|                 JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion | ||||
|                 WHERE sp.IdSuscripcion = @IdSuscripcion | ||||
|                 AND p.Activa = 1 | ||||
|                 -- 1. La promoción general debe estar activa en el período | ||||
|                 AND p.FechaInicio <= @FechaPeriodo | ||||
|                 AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo) | ||||
|                 -- 2. La asignación específica al cliente debe estar activa en el período | ||||
|                 AND sp.VigenciaDesde <= @FechaPeriodo | ||||
|                 AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<Promocion>(sql, new { idSuscripcion, FechaPeriodo = fechaPeriodo }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,156 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class SuscripcionRepository : ISuscripcionRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<SuscripcionRepository> _logger; | ||||
|  | ||||
|         public SuscripcionRepository(DbConnectionFactory connectionFactory, ILogger<SuscripcionRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<Suscripcion?> GetByIdAsync(int idSuscripcion) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Suscripciones WHERE IdSuscripcion = @IdSuscripcion;"; | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.QuerySingleOrDefaultAsync<Suscripcion>(sql, new { IdSuscripcion = idSuscripcion }); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener Suscripción por ID: {IdSuscripcion}", idSuscripcion); | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Suscripcion>> GetBySuscriptorIdAsync(int idSuscriptor) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Suscripciones WHERE IdSuscriptor = @IdSuscriptor ORDER BY FechaInicio DESC;"; | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.QueryAsync<Suscripcion>(sql, new { IdSuscriptor = idSuscriptor }); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener suscripciones para el suscriptor ID: {IdSuscriptor}", idSuscriptor); | ||||
|                 return Enumerable.Empty<Suscripcion>(); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         public async Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction) | ||||
|         { | ||||
|             var year = int.Parse(periodo.Split('-')[0]); | ||||
|             var month = int.Parse(periodo.Split('-')[1]); | ||||
|             var primerDiaMes = new DateTime(year, month, 1); | ||||
|             var ultimoDiaMes = primerDiaMes.AddMonths(1).AddDays(-1); | ||||
|  | ||||
|             const string sql = @" | ||||
|                 SELECT s.* | ||||
|                 FROM dbo.susc_Suscripciones s | ||||
|                 JOIN dbo.susc_Suscriptores su ON s.IdSuscriptor = su.IdSuscriptor | ||||
|                 WHERE s.Estado = 'Activa' | ||||
|                   AND su.Activo = 1 | ||||
|                   AND s.FechaInicio <= @UltimoDiaMes | ||||
|                   AND (s.FechaFin IS NULL OR s.FechaFin >= @PrimerDiaMes);"; | ||||
|              | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|  | ||||
|             return await transaction.Connection.QueryAsync<Suscripcion>(sql, new { PrimerDiaMes = primerDiaMes, UltimoDiaMes = ultimoDiaMes }, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<Suscripcion?> CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|  | ||||
|             const string sqlInsert = @" | ||||
|                 INSERT INTO dbo.susc_Suscripciones | ||||
|                     (IdSuscriptor, IdPublicacion, FechaInicio, FechaFin, Estado, DiasEntrega,  | ||||
|                      Observaciones, IdUsuarioAlta, FechaAlta) | ||||
|                 OUTPUT INSERTED.* | ||||
|                 VALUES | ||||
|                     (@IdSuscriptor, @IdPublicacion, @FechaInicio, @FechaFin, @Estado, @DiasEntrega,  | ||||
|                      @Observaciones, @IdUsuarioAlta, GETDATE());"; | ||||
|              | ||||
|             return await transaction.Connection.QuerySingleAsync<Suscripcion>(sqlInsert, nuevaSuscripcion, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateAsync(Suscripcion suscripcion, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|  | ||||
|             const string sqlUpdate = @" | ||||
|                 UPDATE dbo.susc_Suscripciones SET | ||||
|                     IdPublicacion = @IdPublicacion, | ||||
|                     FechaInicio = @FechaInicio, | ||||
|                     FechaFin = @FechaFin, | ||||
|                     Estado = @Estado, | ||||
|                     DiasEntrega = @DiasEntrega, | ||||
|                     Observaciones = @Observaciones, | ||||
|                     IdUsuarioMod = @IdUsuarioMod, | ||||
|                     FechaMod = @FechaMod | ||||
|                 WHERE IdSuscripcion = @IdSuscripcion;"; | ||||
|  | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sqlUpdate, suscripcion, transaction); | ||||
|             return rowsAffected == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 SELECT sp.*, p.*  | ||||
|                 FROM dbo.susc_SuscripcionPromociones sp | ||||
|                 JOIN dbo.susc_Promociones p ON sp.IdPromocion = p.IdPromocion | ||||
|                 WHERE sp.IdSuscripcion = @IdSuscripcion;"; | ||||
|              | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             var result = await connection.QueryAsync<SuscripcionPromocion, Promocion, (SuscripcionPromocion, Promocion)>( | ||||
|                 sql,  | ||||
|                 (asignacion, promocion) => (asignacion, promocion), | ||||
|                 new { IdSuscripcion = idSuscripcion }, | ||||
|                 splitOn: "IdPromocion" | ||||
|             ); | ||||
|             return result; | ||||
|         } | ||||
|  | ||||
|         public async Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sql = @" | ||||
|                 INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno, VigenciaDesde, VigenciaHasta, FechaAsignacion) | ||||
|                 VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());"; | ||||
|              | ||||
|             await transaction.Connection.ExecuteAsync(sql, asignacion, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sql = "DELETE FROM dbo.susc_SuscripcionPromociones WHERE IdSuscripcion = @IdSuscripcion AND IdPromocion = @IdPromocion;"; | ||||
|             var rows = await transaction.Connection.ExecuteAsync(sql, new { idSuscripcion, idPromocion }, transaction); | ||||
|             return rows == 1; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,195 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
| using System.Text; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class SuscriptorRepository : ISuscriptorRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<SuscriptorRepository> _logger; | ||||
|  | ||||
|         public SuscriptorRepository(DbConnectionFactory connectionFactory, ILogger<SuscriptorRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Suscriptor>> GetAllAsync(string? nombreFilter, string? nroDocFilter, bool soloActivos) | ||||
|         { | ||||
|             var sqlBuilder = new StringBuilder(@" | ||||
|                 SELECT IdSuscriptor, NombreCompleto, Email, Telefono, Direccion, TipoDocumento,  | ||||
|                        NroDocumento, CBU, IdFormaPagoPreferida, Observaciones, Activo,  | ||||
|                        IdUsuarioAlta, FechaAlta, IdUsuarioMod, FechaMod | ||||
|                 FROM dbo.susc_Suscriptores WHERE 1=1"); | ||||
|              | ||||
|             var parameters = new DynamicParameters(); | ||||
|  | ||||
|             if (soloActivos) | ||||
|             { | ||||
|                 sqlBuilder.Append(" AND Activo = 1"); | ||||
|             } | ||||
|             if (!string.IsNullOrWhiteSpace(nombreFilter)) | ||||
|             { | ||||
|                 sqlBuilder.Append(" AND NombreCompleto LIKE @NombreFilter"); | ||||
|                 parameters.Add("NombreFilter", $"%{nombreFilter}%"); | ||||
|             } | ||||
|             if (!string.IsNullOrWhiteSpace(nroDocFilter)) | ||||
|             { | ||||
|                 sqlBuilder.Append(" AND NroDocumento LIKE @NroDocFilter"); | ||||
|                 parameters.Add("NroDocFilter", $"%{nroDocFilter}%"); | ||||
|             } | ||||
|             sqlBuilder.Append(" ORDER BY NombreCompleto;"); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.QueryAsync<Suscriptor>(sqlBuilder.ToString(), parameters); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener todos los Suscriptores."); | ||||
|                 return Enumerable.Empty<Suscriptor>(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<Suscriptor?> GetByIdAsync(int id) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 SELECT IdSuscriptor, NombreCompleto, Email, Telefono, Direccion, TipoDocumento,  | ||||
|                        NroDocumento, CBU, IdFormaPagoPreferida, Observaciones, Activo,  | ||||
|                        IdUsuarioAlta, FechaAlta, IdUsuarioMod, FechaMod | ||||
|                 FROM dbo.susc_Suscriptores WHERE IdSuscriptor = @Id;"; | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.QuerySingleOrDefaultAsync<Suscriptor>(sql, new { Id = id }); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener Suscriptor por ID: {IdSuscriptor}", id); | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         public async Task<bool> ExistsByDocumentoAsync(string tipoDocumento, string nroDocumento, int? excludeId = null) | ||||
|         { | ||||
|             var sqlBuilder = new StringBuilder("SELECT COUNT(1) FROM dbo.susc_Suscriptores WHERE TipoDocumento = @TipoDocumento AND NroDocumento = @NroDocumento"); | ||||
|             var parameters = new DynamicParameters(); | ||||
|             parameters.Add("TipoDocumento", tipoDocumento); | ||||
|             parameters.Add("NroDocumento", nroDocumento); | ||||
|  | ||||
|             if (excludeId.HasValue) | ||||
|             { | ||||
|                 sqlBuilder.Append(" AND IdSuscriptor != @ExcludeId"); | ||||
|                 parameters.Add("ExcludeId", excludeId.Value); | ||||
|             } | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 return await connection.ExecuteScalarAsync<bool>(sqlBuilder.ToString(), parameters); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error en ExistsByDocumentoAsync para Suscriptor."); | ||||
|                 return true; // Asumir que existe si hay error para prevenir duplicados. | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> IsInUseAsync(int id) | ||||
|         { | ||||
|             // Un suscriptor está en uso si tiene suscripciones activas. | ||||
|             const string sql = "SELECT TOP 1 1 FROM dbo.susc_Suscripciones WHERE IdSuscriptor = @Id AND Estado = 'Activa'"; | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 var inUse = await connection.ExecuteScalarAsync<int?>(sql, new { Id = id }); | ||||
|                 return inUse.HasValue && inUse.Value == 1; | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error en IsInUseAsync para Suscriptor ID: {IdSuscriptor}", id); | ||||
|                 return true; // Asumir en uso si hay error. | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<Suscriptor?> CreateAsync(Suscriptor nuevoSuscriptor, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|  | ||||
|             const string sqlInsert = @" | ||||
|                 INSERT INTO dbo.susc_Suscriptores  | ||||
|                     (NombreCompleto, Email, Telefono, Direccion, TipoDocumento, NroDocumento,  | ||||
|                      CBU, IdFormaPagoPreferida, Observaciones, Activo, IdUsuarioAlta, FechaAlta) | ||||
|                 OUTPUT INSERTED.* | ||||
|                 VALUES  | ||||
|                     (@NombreCompleto, @Email, @Telefono, @Direccion, @TipoDocumento, @NroDocumento,  | ||||
|                      @CBU, @IdFormaPagoPreferida, @Observaciones, 1, @IdUsuarioAlta, GETDATE());"; | ||||
|  | ||||
|             var suscriptorCreado = await transaction.Connection.QuerySingleAsync<Suscriptor>( | ||||
|                 sqlInsert, | ||||
|                 nuevoSuscriptor, | ||||
|                 transaction: transaction | ||||
|             ); | ||||
|  | ||||
|             // No se necesita historial para la creación, ya que la propia tabla tiene campos de auditoría. | ||||
|             // Si se necesitara una tabla _H, aquí iría el INSERT a esa tabla. | ||||
|  | ||||
|             return suscriptorCreado; | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateAsync(Suscriptor suscriptor, IDbTransaction transaction) | ||||
|         { | ||||
|             // El servicio ya ha poblado IdUsuarioMod y FechaMod en la entidad. | ||||
|             const string sqlUpdate = @" | ||||
|                 UPDATE dbo.susc_Suscriptores SET | ||||
|                     NombreCompleto = @NombreCompleto, | ||||
|                     Email = @Email, | ||||
|                     Telefono = @Telefono, | ||||
|                     Direccion = @Direccion, | ||||
|                     TipoDocumento = @TipoDocumento, | ||||
|                     NroDocumento = @NroDocumento, | ||||
|                     CBU = @CBU, | ||||
|                     IdFormaPagoPreferida = @IdFormaPagoPreferida, | ||||
|                     Observaciones = @Observaciones, | ||||
|                     IdUsuarioMod = @IdUsuarioMod, | ||||
|                     FechaMod = @FechaMod | ||||
|                 WHERE IdSuscriptor = @IdSuscriptor;"; | ||||
|  | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|  | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sqlUpdate, suscriptor, transaction: transaction); | ||||
|             return rowsAffected == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> ToggleActivoAsync(int id, bool activar, int idUsuario, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sqlToggle = @" | ||||
|                 UPDATE dbo.susc_Suscriptores SET | ||||
|                     Activo = @Activar, | ||||
|                     IdUsuarioMod = @IdUsuario, | ||||
|                     FechaMod = GETDATE() | ||||
|                 WHERE IdSuscriptor = @Id;"; | ||||
|  | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|  | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync( | ||||
|                 sqlToggle,  | ||||
|                 new { Activar = activar, IdUsuario = idUsuario, Id = id },  | ||||
|                 transaction: transaction | ||||
|             ); | ||||
|             return rowsAffected == 1; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,3 +1,5 @@ | ||||
| // Archivo: GestionIntegral.Api/Data/Repositories/Usuarios/IUsuarioRepository.cs | ||||
|  | ||||
| using GestionIntegral.Api.Models.Usuarios; // Para Usuario | ||||
| using GestionIntegral.Api.Dtos.Usuarios.Auditoria; | ||||
| using System.Collections.Generic; | ||||
| @@ -10,6 +12,7 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios | ||||
|     { | ||||
|         Task<IEnumerable<Usuario>> GetAllAsync(string? userFilter, string? nombreFilter); | ||||
|         Task<Usuario?> GetByIdAsync(int id); | ||||
|         Task<IEnumerable<Usuario>> GetByIdsAsync(IEnumerable<int> ids); | ||||
|         Task<Usuario?> GetByUsernameAsync(string username); // Ya existe en IAuthRepository, pero lo duplicamos para cohesión del CRUD | ||||
|         Task<Usuario?> CreateAsync(Usuario nuevoUsuario, int idUsuarioCreador, IDbTransaction transaction); | ||||
|         Task<bool> UpdateAsync(Usuario usuarioAActualizar, int idUsuarioModificador, IDbTransaction transaction); | ||||
| @@ -17,7 +20,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios | ||||
|         // Task<bool> DeleteAsync(int id, int idUsuarioModificador, IDbTransaction transaction); | ||||
|         Task<bool> SetPasswordAsync(int userId, string newHash, string newSalt, bool debeCambiarClave, int idUsuarioModificador, IDbTransaction transaction); | ||||
|         Task<bool> UserExistsAsync(string username, int? excludeId = null); | ||||
|         // Para el DTO de listado | ||||
|         Task<IEnumerable<(Usuario Usuario, string NombrePerfil)>> GetAllWithProfileNameAsync(string? userFilter, string? nombreFilter); | ||||
|         Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id); | ||||
|         Task<IEnumerable<UsuarioHistorialDto>> GetHistorialByUsuarioIdAsync(int idUsuarioAfectado, DateTime? fechaDesde, DateTime? fechaHasta); | ||||
|   | ||||
| @@ -1,12 +1,8 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Usuarios; | ||||
| using GestionIntegral.Api.Dtos.Usuarios.Auditoria; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System.Collections.Generic; | ||||
| using System.Data; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Usuarios | ||||
| { | ||||
| @@ -88,7 +84,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios | ||||
|             } | ||||
|         } | ||||
|  | ||||
|  | ||||
|         public async Task<Usuario?> GetByIdAsync(int id) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id = @Id"; | ||||
| @@ -103,6 +98,33 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Usuario>> GetByIdsAsync(IEnumerable<int> ids) | ||||
|         { | ||||
|             // 1. Validar si la lista de IDs está vacía para evitar una consulta innecesaria a la BD. | ||||
|             if (ids == null || !ids.Any()) | ||||
|             { | ||||
|                 return Enumerable.Empty<Usuario>(); | ||||
|             } | ||||
|  | ||||
|             // 2. Definir la consulta. Dapper manejará la expansión de la cláusula IN de forma segura. | ||||
|             const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id IN @Ids"; | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 // 3. Crear conexión y ejecutar la consulta. | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 // 4. Pasar la colección de IDs como parámetro. El nombre 'Ids' debe coincidir con el placeholder '@Ids'. | ||||
|                 return await connection.QueryAsync<Usuario>(sql, new { Ids = ids }); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 // 5. Registrar el error y devolver una lista vacía en caso de fallo para no romper la aplicación. | ||||
|                 _logger.LogError(ex, "Error al obtener Usuarios por lista de IDs."); | ||||
|                 return Enumerable.Empty<Usuario>(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id) | ||||
|         { | ||||
|             const string sql = @" | ||||
| @@ -128,7 +150,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios | ||||
|             } | ||||
|         } | ||||
|  | ||||
|  | ||||
|         public async Task<Usuario?> GetByUsernameAsync(string username) | ||||
|         { | ||||
|             // Esta es la misma que en AuthRepository, si se unifican, se puede eliminar una. | ||||
|   | ||||
| @@ -9,6 +9,8 @@ | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> | ||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> | ||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" /> | ||||
|     <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" /> | ||||
|   | ||||
| @@ -0,0 +1,16 @@ | ||||
| namespace GestionIntegral.Api.Models.Comunicaciones | ||||
| { | ||||
|     public class EmailLog | ||||
|     { | ||||
|         public int IdEmailLog { get; set; } | ||||
|         public DateTime FechaEnvio { get; set; } | ||||
|         public string DestinatarioEmail { get; set; } = string.Empty; | ||||
|         public string Asunto { get; set; } = string.Empty; | ||||
|         public string Estado { get; set; } = string.Empty; | ||||
|         public string? Error { get; set; } | ||||
|         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,12 @@ | ||||
| namespace GestionIntegral.Api.Models.Comunicaciones | ||||
| { | ||||
|     public class MailSettings | ||||
|     { | ||||
|         public string SmtpHost { get; set; } = string.Empty; | ||||
|         public int SmtpPort { get; set; } | ||||
|         public string SenderName { get; set; } = string.Empty; | ||||
|         public string SenderEmail { get; set; } = string.Empty; | ||||
|         public string SmtpUser { get; set; } = string.Empty; | ||||
|         public string SmtpPass { get; set; } = string.Empty; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,20 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Comunicaciones | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Representa un registro de historial de envío de correo para ser mostrado en la interfaz de usuario. | ||||
|     /// </summary> | ||||
|     public class EmailLogDto | ||||
|     { | ||||
|         public DateTime FechaEnvio { get; set; } | ||||
|         public string Estado { get; set; } = string.Empty; | ||||
|         public string Asunto { get; set; } = string.Empty; | ||||
|         public string DestinatarioEmail { get; set; } = string.Empty; | ||||
|         public string? Error { get; set; } | ||||
|          | ||||
|         /// <summary> | ||||
|         /// Nombre del usuario que inició la acción de envío (ej. "Juan Pérez"). | ||||
|         /// Puede ser "Sistema" si el envío fue automático (ej. Cierre Mensual). | ||||
|         /// </summary> | ||||
|         public string? NombreUsuarioDisparo { 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(); | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Reportes | ||||
| { | ||||
|     public class DistribucionSuscripcionDto | ||||
|     { | ||||
|         public string NombreEmpresa { get; set; } = string.Empty; | ||||
|         public string NombrePublicacion { get; set; } = string.Empty; | ||||
|         public string NombreSuscriptor { get; set; } = string.Empty; | ||||
|         public string Direccion { get; set; } = string.Empty; | ||||
|         public string? Telefono { get; set; } | ||||
|         public DateTime FechaInicio { get; set; } | ||||
|         public DateTime? FechaFin { get; set; } | ||||
|         public string DiasEntrega { get; set; } = string.Empty; | ||||
|         public string? Observaciones { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Reportes | ||||
| { | ||||
|     public class FacturasParaReporteDto | ||||
|     { | ||||
|         public int IdFactura { get; set; } | ||||
|         public string Periodo { get; set; } = string.Empty; | ||||
|         public string NombreSuscriptor { get; set; } = string.Empty; | ||||
|         public string TipoDocumento { get; set; } = string.Empty; | ||||
|         public string NroDocumento { get; set; } = string.Empty; | ||||
|         public decimal ImporteFinal { get; set; } | ||||
|         public int IdEmpresa { get; set; } | ||||
|         public string NombreEmpresa { get; set; } = string.Empty; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,55 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Reportes.ViewModels | ||||
| { | ||||
|     /// <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; | ||||
|         public IEnumerable<GrupoPublicacion> Publicaciones { get; set; } = Enumerable.Empty<GrupoPublicacion>(); | ||||
|     } | ||||
|  | ||||
|     public class DistribucionSuscripcionesViewModel | ||||
|     { | ||||
|         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> altas, IEnumerable<DistribucionSuscripcionDto> bajas) | ||||
|         { | ||||
|             // 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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Reportes.ViewModels | ||||
| { | ||||
|     // Esta clase anidada representará los datos de una empresa | ||||
|     public class DatosEmpresaViewModel | ||||
|     { | ||||
|         public string NombreEmpresa { get; set; } = string.Empty; | ||||
|         public IEnumerable<FacturasParaReporteDto> Facturas { get; set; } = new List<FacturasParaReporteDto>(); | ||||
|         public decimal TotalEmpresa => Facturas.Sum(f => f.ImporteFinal); | ||||
|     } | ||||
|  | ||||
|     public class FacturasPublicidadViewModel | ||||
|     { | ||||
|         public IEnumerable<DatosEmpresaViewModel> DatosPorEmpresa { get; set; } = new List<DatosEmpresaViewModel>(); | ||||
|         public string Periodo { get; set; } = string.Empty; | ||||
|         public string FechaGeneracion { get; set; } = string.Empty; | ||||
|         public decimal TotalGeneral => DatosPorEmpresa.Sum(e => e.TotalEmpresa); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,19 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class AjusteDto | ||||
|     { | ||||
|         public int IdAjuste { get; set; } | ||||
|         public int IdSuscriptor { get; set; } | ||||
|         public int IdEmpresa { get; set; } | ||||
|         public string? NombreEmpresa { get; set; } | ||||
|         public string FechaAjuste { get; set; } = string.Empty; | ||||
|         public string TipoAjuste { get; set; } = string.Empty; | ||||
|         public decimal Monto { get; set; } | ||||
|         public string Motivo { get; set; } = string.Empty; | ||||
|         public string Estado { get; set; } = string.Empty; | ||||
|         public int? IdFacturaAplicado { get; set; } | ||||
|         public string? NumeroFacturaAplicado { get; set; } | ||||
|         public string FechaAlta { get; set; } = string.Empty; | ||||
|         public string NombreUsuarioAlta { get; set; } = string.Empty; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class AsignarPromocionDto | ||||
|     { | ||||
|         [Required] | ||||
|         public int IdPromocion { get; set; } | ||||
|         [Required] | ||||
|         public DateTime VigenciaDesde { get; set; } | ||||
|         public DateTime? VigenciaHasta { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,28 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class CreateAjusteDto | ||||
|     { | ||||
|         [Required] | ||||
|         public int IdSuscriptor { get; set; } | ||||
|          | ||||
|         [Required] | ||||
|         public int IdEmpresa { get; set; } | ||||
|  | ||||
|         [Required] | ||||
|         public DateTime FechaAjuste { get; set; } | ||||
|          | ||||
|         [Required] | ||||
|         [RegularExpression("^(Credito|Debito)$", ErrorMessage = "El tipo de ajuste debe ser 'Credito' o 'Debito'.")] | ||||
|         public string TipoAjuste { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required] | ||||
|         [Range(0.01, 999999.99, ErrorMessage = "El monto debe ser un valor positivo.")] | ||||
|         public decimal Monto { get; set; } | ||||
|  | ||||
|         [Required(ErrorMessage = "El motivo es obligatorio.")] | ||||
|         [StringLength(250)] | ||||
|         public string Motivo { get; set; } = string.Empty; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| // Archivo: GestionIntegral.Api/Dtos/Suscripciones/CreatePagoDto.cs | ||||
|  | ||||
| using System; | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class CreatePagoDto | ||||
|     { | ||||
|         [Required] | ||||
|         public int IdFactura { get; set; } | ||||
|  | ||||
|         [Required] | ||||
|         public DateTime FechaPago { get; set; } | ||||
|  | ||||
|         [Required(ErrorMessage = "Debe seleccionar una forma de pago.")] | ||||
|         public int IdFormaPago { get; set; } | ||||
|  | ||||
|         [Required(ErrorMessage = "El monto es obligatorio.")] | ||||
|         [Range(0.01, 99999999.99, ErrorMessage = "El monto debe ser un valor positivo.")] | ||||
|         public decimal Monto { get; set; } | ||||
|  | ||||
|         [StringLength(100)] | ||||
|         public string? Referencia { get; set; } // Nro. de comprobante, etc. | ||||
|  | ||||
|         [StringLength(250)] | ||||
|         public string? Observaciones { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,31 @@ | ||||
| // Archivo: GestionIntegral.Api/Dtos/Suscripciones/CreatePromocionDto.cs | ||||
|  | ||||
| using System; | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class CreatePromocionDto | ||||
|     { | ||||
|         [Required] | ||||
|         [StringLength(200)] | ||||
|         public string Descripcion { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required] | ||||
|         public string TipoEfecto { get; set; } = string.Empty; // Corregido | ||||
|  | ||||
|         [Required] | ||||
|         [Range(0, 99999999.99)] // Se permite 0 para bonificaciones | ||||
|         public decimal ValorEfecto { get; set; } // Corregido | ||||
|  | ||||
|         [Required] | ||||
|         public string TipoCondicion { get; set; } = string.Empty; | ||||
|          | ||||
|         public int? ValorCondicion { get; set; } | ||||
|  | ||||
|         [Required] | ||||
|         public DateTime FechaInicio { get; set; } | ||||
|         public DateTime? FechaFin { get; set; } | ||||
|         public bool Activa { get; set; } = true; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,30 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class CreateSuscripcionDto | ||||
|     { | ||||
|         [Required] | ||||
|         public int IdSuscriptor { get; set; } | ||||
|          | ||||
|         [Required] | ||||
|         public int IdPublicacion { get; set; } | ||||
|  | ||||
|         [Required] | ||||
|         public DateTime FechaInicio { get; set; } | ||||
|          | ||||
|         public DateTime? FechaFin { get; set; } | ||||
|  | ||||
|         [Required] | ||||
|         public string Estado { get; set; } = "Activa"; | ||||
|  | ||||
|         [Required(ErrorMessage = "Debe especificar los días de entrega.")] | ||||
|         public List<string> DiasEntrega { get; set; } = new List<string>(); // "L", "M", "X"... | ||||
|  | ||||
|         [StringLength(250)] | ||||
|         public string? Observaciones { get; set; } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Nota: Por ahora, el DTO de actualización puede ser similar al de creación. | ||||
| // Si se necesita una lógica diferente, se crearía un UpdateSuscripcionDto. | ||||
| @@ -0,0 +1,44 @@ | ||||
| // Archivo: GestionIntegral.Api/Dtos/Suscripciones/CreateSuscriptorDto.cs | ||||
|  | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class CreateSuscriptorDto | ||||
|     { | ||||
|         [Required(ErrorMessage = "El nombre completo es obligatorio.")] | ||||
|         [StringLength(150)] | ||||
|         public string NombreCompleto { get; set; } = string.Empty; | ||||
|  | ||||
|         [EmailAddress(ErrorMessage = "El formato del email no es válido.")] | ||||
|         [StringLength(100)] | ||||
|         public string? Email { get; set; } | ||||
|  | ||||
|         [StringLength(50)] | ||||
|         [RegularExpression(@"^[0-9\s\+\-\(\)]*$", ErrorMessage = "El teléfono solo puede contener números y los símbolos +, -, () y espacios.")] | ||||
|         public string? Telefono { get; set; } | ||||
|  | ||||
|         [Required(ErrorMessage = "La dirección es obligatoria.")] | ||||
|         [StringLength(200)] | ||||
|         public string Direccion { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required(ErrorMessage = "El tipo de documento es obligatorio.")] | ||||
|         [StringLength(4)] | ||||
|         public string TipoDocumento { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required(ErrorMessage = "El número de documento es obligatorio.")] | ||||
|         [StringLength(11)] | ||||
|         [RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")] | ||||
|         public string NroDocumento { get; set; } = string.Empty; | ||||
|  | ||||
|         [StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")] | ||||
|         [RegularExpression("^[0-9]*$", ErrorMessage = "El CBU solo puede contener números.")] | ||||
|         public string? CBU { get; set; } | ||||
|  | ||||
|         [Required(ErrorMessage = "La forma de pago es obligatoria.")] | ||||
|         public int IdFormaPagoPreferida { get; set; } | ||||
|  | ||||
|         [StringLength(250)] | ||||
|         public string? Observaciones { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class FacturaConsolidadaDto | ||||
|     { | ||||
|         public int IdFactura { get; set; } | ||||
|         public string NombreEmpresa { get; set; } = string.Empty; | ||||
|         public decimal ImporteFinal { get; set; } | ||||
|         public string EstadoPago { get; set; } = string.Empty; | ||||
|         public string EstadoFacturacion { get; set; } = string.Empty; | ||||
|         public string? NumeroFactura { get; set; } | ||||
|         public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,25 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class FacturaDetalleDto | ||||
|     { | ||||
|         public string Descripcion { get; set; } = string.Empty; | ||||
|         public decimal ImporteNeto { get; set; } | ||||
|     } | ||||
|  | ||||
|     public class FacturaDto | ||||
|     { | ||||
|         public int IdFactura { get; set; } | ||||
|         public int IdSuscriptor { get; set; } | ||||
|         public string Periodo { get; set; } = string.Empty; | ||||
|         public string FechaEmision { get; set; } = string.Empty; | ||||
|         public string FechaVencimiento { get; set; } = string.Empty; | ||||
|         public decimal ImporteFinal { get; set; } | ||||
|         public decimal TotalPagado { get; set; } | ||||
|         public decimal SaldoPendiente { get; set; } | ||||
|         public string EstadoPago { get; set; } = string.Empty; | ||||
|         public string EstadoFacturacion { get; set; } = string.Empty; | ||||
|         public string? NumeroFactura { get; set; } | ||||
|         public string NombreSuscriptor { get; set; } = string.Empty; | ||||
|         public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class FormaPagoDto | ||||
|     { | ||||
|         public int IdFormaPago { get; set; } | ||||
|         public string Nombre { get; set; } = string.Empty; | ||||
|         public bool RequiereCBU { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class PagoDto | ||||
|     { | ||||
|         public int IdPago { get; set; } | ||||
|         public int IdFactura { get; set; } | ||||
|         public string FechaPago { get; set; } = string.Empty; // "yyyy-MM-dd" | ||||
|         public int IdFormaPago { get; set; } | ||||
|         public string NombreFormaPago { get; set; } = string.Empty; // Enriquecido | ||||
|         public decimal Monto { get; set; } | ||||
|         public string Estado { get; set; } = string.Empty; // "Aprobado", "Rechazado" | ||||
|         public string? Referencia { get; set; } | ||||
|         public string? Observaciones { get; set; } | ||||
|         public int IdUsuarioRegistro { get; set; } | ||||
|         public string NombreUsuarioRegistro { get; set; } = string.Empty; // Enriquecido | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class ProcesamientoLoteResponseDto | ||||
|     { | ||||
|         public int TotalRegistrosLeidos { get; set; } | ||||
|         public int PagosAprobados { get; set; } | ||||
|         public int PagosRechazados { get; set; } | ||||
|         public List<string> Errores { get; set; } = new List<string>(); | ||||
|         public string MensajeResumen { get; set; } = string.Empty; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class PromocionAsignadaDto : PromocionDto | ||||
|     { | ||||
|         public string VigenciaDesdeAsignacion { get; set; } = string.Empty; | ||||
|         public string? VigenciaHastaAsignacion { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class PromocionDto | ||||
|     { | ||||
|         public int IdPromocion { get; set; } | ||||
|         public string Descripcion { get; set; } = string.Empty; | ||||
|         public string TipoEfecto { get; set; } = string.Empty; | ||||
|         public decimal ValorEfecto { get; set; } | ||||
|         public string TipoCondicion { get; set; } = string.Empty; | ||||
|         public int? ValorCondicion { get; set; } | ||||
|         public string FechaInicio { get; set; } = string.Empty; | ||||
|         public string? FechaFin { get; set; } | ||||
|         public bool Activa { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class ResumenCuentaSuscriptorDto | ||||
|     { | ||||
|         public int IdSuscriptor { get; set; } | ||||
|         public string NombreSuscriptor { get; set; } = string.Empty; | ||||
|         public decimal SaldoPendienteTotal { get; set; } | ||||
|         public decimal ImporteTotal { get; set; } | ||||
|         public List<FacturaConsolidadaDto> Facturas { get; set; } = new List<FacturaConsolidadaDto>(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class SuscripcionDto | ||||
|     { | ||||
|         public int IdSuscripcion { get; set; } | ||||
|         public int IdSuscriptor { get; set; } | ||||
|         public int IdPublicacion { get; set; } | ||||
|         public string NombrePublicacion { get; set; } = string.Empty; // Para UI | ||||
|         public string FechaInicio { get; set; } = string.Empty; // Formato "yyyy-MM-dd" | ||||
|         public string? FechaFin { get; set; } | ||||
|         public string Estado { get; set; } = string.Empty; | ||||
|         public string DiasEntrega { get; set; } = string.Empty; | ||||
|         public string? Observaciones { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,19 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     // DTO para mostrar la información de un suscriptor | ||||
|     public class SuscriptorDto | ||||
|     { | ||||
|         public int IdSuscriptor { get; set; } | ||||
|         public string NombreCompleto { get; set; } = string.Empty; | ||||
|         public string? Email { get; set; } | ||||
|         public string? Telefono { get; set; } | ||||
|         public string Direccion { get; set; } = string.Empty; | ||||
|         public string TipoDocumento { get; set; } = string.Empty; | ||||
|         public string NroDocumento { get; set; } = string.Empty; | ||||
|         public string? CBU { get; set; } | ||||
|         public int IdFormaPagoPreferida { get; set; } | ||||
|         public string NombreFormaPagoPreferida { get; set; } = string.Empty; // Para UI | ||||
|         public string? Observaciones { get; set; } | ||||
|         public bool Activo { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones; | ||||
| public class UpdateAjusteDto | ||||
| { | ||||
|   [Required] | ||||
|   public int IdEmpresa { get; set; } | ||||
|  | ||||
|   [Required] | ||||
|   public DateTime FechaAjuste { get; set; } | ||||
|  | ||||
|   [Required] | ||||
|   [RegularExpression("^(Credito|Debito)$")] | ||||
|   public string TipoAjuste { get; set; } = string.Empty; | ||||
|  | ||||
|   [Required] | ||||
|   [Range(0.01, 999999.99)] | ||||
|   public decimal Monto { get; set; } | ||||
|  | ||||
|   [Required] | ||||
|   [StringLength(250)] | ||||
|   public string Motivo { get; set; } = string.Empty; | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| // Archivo: GestionIntegral.Api/Dtos/Suscripciones/UpdatePromocionDto.cs | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     /// <summary> | ||||
|     /// DTO para actualizar una promoción. Hereda todas las propiedades y validaciones | ||||
|     /// de CreatePromocionDto, ya que por ahora son idénticas. | ||||
|     /// </summary> | ||||
|     public class UpdatePromocionDto : CreatePromocionDto | ||||
|     { | ||||
|         // No se necesitan propiedades adicionales por el momento. | ||||
|         // Si en el futuro se necesitara una validación diferente para la actualización, | ||||
|         // se podrían añadir o sobrescribir propiedades aquí. | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,24 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class UpdateSuscripcionDto | ||||
|     { | ||||
|         // No permitimos cambiar el suscriptor o la publicación una vez creada. | ||||
|         // Se debe cancelar y crear una nueva. | ||||
|  | ||||
|         [Required] | ||||
|         public DateTime FechaInicio { get; set; } | ||||
|          | ||||
|         public DateTime? FechaFin { get; set; } | ||||
|  | ||||
|         [Required] | ||||
|         public string Estado { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required(ErrorMessage = "Debe especificar los días de entrega.")] | ||||
|         public List<string> DiasEntrega { get; set; } = new List<string>(); | ||||
|  | ||||
|         [StringLength(250)] | ||||
|         public string? Observaciones { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,42 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class UpdateSuscriptorDto | ||||
|     { | ||||
|         [Required(ErrorMessage = "El nombre completo es obligatorio.")] | ||||
|         [StringLength(150)] | ||||
|         public string NombreCompleto { get; set; } = string.Empty; | ||||
|  | ||||
|         [EmailAddress(ErrorMessage = "El formato del email no es válido.")] | ||||
|         [StringLength(100)] | ||||
|         public string? Email { get; set; } | ||||
|  | ||||
|         [StringLength(50)] | ||||
|         [RegularExpression(@"^[0-9\s\+\-\(\)]*$", ErrorMessage = "El teléfono solo puede contener números y los símbolos +, -, () y espacios.")] | ||||
|         public string? Telefono { get; set; } | ||||
|  | ||||
|         [Required(ErrorMessage = "La dirección es obligatoria.")] | ||||
|         [StringLength(200)] | ||||
|         public string Direccion { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required(ErrorMessage = "El tipo de documento es obligatorio.")] | ||||
|         [StringLength(4)] | ||||
|         public string TipoDocumento { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required(ErrorMessage = "El número de documento es obligatorio.")] | ||||
|         [StringLength(11)] | ||||
|         [RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")] | ||||
|         public string NroDocumento { get; set; } = string.Empty; | ||||
|  | ||||
|         [StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")] | ||||
|         [RegularExpression("^[0-9]*$", ErrorMessage = "El CBU solo puede contener números.")] | ||||
|         public string? CBU { get; set; } | ||||
|  | ||||
|         [Required(ErrorMessage = "La forma de pago es obligatoria.")] | ||||
|         public int IdFormaPagoPreferida { get; set; } | ||||
|  | ||||
|         [StringLength(250)] | ||||
|         public string? Observaciones { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class Ajuste | ||||
|     { | ||||
|         public int IdAjuste { get; set; } | ||||
|         public int IdSuscriptor { get; set; } | ||||
|         public int IdEmpresa { get; set; } | ||||
|         public DateTime FechaAjuste { get; set; } | ||||
|         public string TipoAjuste { get; set; } = string.Empty; | ||||
|         public decimal Monto { get; set; } | ||||
|         public string Motivo { get; set; } = string.Empty; | ||||
|         public string Estado { get; set; } = string.Empty; | ||||
|         public int? IdFacturaAplicado { get; set; } | ||||
|         public int IdUsuarioAlta { get; set; } | ||||
|         public DateTime FechaAlta { get; set; } | ||||
|         public int? IdUsuarioAnulo { get; set; } | ||||
|         public DateTime? FechaAnulacion { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class Factura | ||||
|     { | ||||
|         public int IdFactura { get; set; } | ||||
|         public int IdSuscriptor { get; set; } | ||||
|         public string Periodo { get; set; } = string.Empty; | ||||
|         public DateTime FechaEmision { get; set; } | ||||
|         public DateTime FechaVencimiento { get; set; } | ||||
|         public decimal ImporteBruto { get; set; } | ||||
|         public decimal DescuentoAplicado { get; set; } | ||||
|         public decimal ImporteFinal { get; set; } | ||||
|         public string EstadoPago { get; set; } = string.Empty; | ||||
|         public string EstadoFacturacion { get; set; } = string.Empty; | ||||
|         public string? NumeroFactura { get; set; } | ||||
|         public int? IdLoteDebito { get; set; } | ||||
|         public string? MotivoRechazo { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| public class FacturaDetalle { | ||||
|     public int IdFacturaDetalle { get; set; } | ||||
|     public int IdFactura { get; set; } | ||||
|     public int IdSuscripcion { get; set; } | ||||
|     public string Descripcion { get; set; } = string.Empty; | ||||
|     public decimal ImporteBruto { get; set; } | ||||
|     public decimal DescuentoAplicado { get; set; } | ||||
|     public decimal ImporteNeto { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class FormaPago | ||||
|     { | ||||
|         public int IdFormaPago { get; set; } | ||||
|         public string Nombre { get; set; } = string.Empty; | ||||
|         public bool RequiereCBU { get; set; } | ||||
|         public bool Activo { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class LoteDebito | ||||
|     { | ||||
|         public int IdLoteDebito { get; set; } | ||||
|         public DateTime FechaGeneracion { get; set; } | ||||
|         public string Periodo { get; set; } = string.Empty; | ||||
|         public string NombreArchivo { get; set; } = string.Empty; | ||||
|         public decimal ImporteTotal { get; set; } | ||||
|         public int CantidadRegistros { get; set; } | ||||
|         public int IdUsuarioGeneracion { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										15
									
								
								Backend/GestionIntegral.Api/Models/Suscripciones/Pago.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								Backend/GestionIntegral.Api/Models/Suscripciones/Pago.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class Pago | ||||
|     { | ||||
|         public int IdPago { get; set; } | ||||
|         public int IdFactura { get; set; } | ||||
|         public DateTime FechaPago { get; set; } | ||||
|         public int IdFormaPago { get; set; } | ||||
|         public decimal Monto { get; set; } | ||||
|         public string Estado { get; set; } = string.Empty; | ||||
|         public string? Referencia { get; set; } | ||||
|         public string? Observaciones { get; set; } | ||||
|         public int IdUsuarioRegistro { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class Promocion | ||||
|     { | ||||
|         public int IdPromocion { get; set; } | ||||
|         public string Descripcion { get; set; } = string.Empty; | ||||
|         public string TipoEfecto { get; set; } = string.Empty; // Nuevo nombre | ||||
|         public decimal ValorEfecto { get; set; } // Nuevo nombre | ||||
|         public string TipoCondicion { get; set; } = string.Empty; // Nueva propiedad | ||||
|         public int? ValorCondicion { get; set; } // Nueva propiedad (nullable) | ||||
|         public DateTime FechaInicio { get; set; } | ||||
|         public DateTime? FechaFin { get; set; } | ||||
|         public bool Activa { get; set; } | ||||
|         public int IdUsuarioAlta { get; set; } | ||||
|         public DateTime FechaAlta { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class Suscripcion | ||||
|     { | ||||
|         public int IdSuscripcion { get; set; } | ||||
|         public int IdSuscriptor { get; set; } | ||||
|         public int IdPublicacion { get; set; } | ||||
|         public DateTime FechaInicio { get; set; } | ||||
|         public DateTime? FechaFin { get; set; } | ||||
|         public string Estado { get; set; } = string.Empty; | ||||
|         public string DiasEntrega { get; set; } = string.Empty; | ||||
|         public string? Observaciones { get; set; } | ||||
|         public int IdUsuarioAlta { get; set; } | ||||
|         public DateTime FechaAlta { get; set; } | ||||
|         public int? IdUsuarioMod { get; set; } | ||||
|         public DateTime? FechaMod { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class SuscripcionPromocion | ||||
|     { | ||||
|         public int IdSuscripcion { get; set; } | ||||
|         public int IdPromocion { get; set; } | ||||
|         public DateTime FechaAsignacion { get; set; } | ||||
|         public int IdUsuarioAsigno { get; set; } | ||||
|         public DateTime VigenciaDesde { get; set; } | ||||
|         public DateTime? VigenciaHasta { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,21 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class Suscriptor | ||||
|     { | ||||
|         public int IdSuscriptor { get; set; } | ||||
|         public string NombreCompleto { get; set; } = string.Empty; | ||||
|         public string? Email { get; set; } | ||||
|         public string? Telefono { get; set; } | ||||
|         public string Direccion { get; set; } = string.Empty; | ||||
|         public string TipoDocumento { get; set; } = string.Empty; | ||||
|         public string NroDocumento { get; set; } = string.Empty; | ||||
|         public string? CBU { get; set; } | ||||
|         public int IdFormaPagoPreferida { get; set; } | ||||
|         public string? Observaciones { get; set; } | ||||
|         public bool Activo { get; set; } | ||||
|         public int IdUsuarioAlta { get; set; } | ||||
|         public DateTime FechaAlta { get; set; } | ||||
|         public int? IdUsuarioMod { get; set; } | ||||
|         public DateTime? FechaMod { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -18,6 +18,15 @@ using GestionIntegral.Api.Services.Reportes; | ||||
| using GestionIntegral.Api.Services.Pdf; | ||||
| using Microsoft.Extensions.Diagnostics.HealthChecks; | ||||
| using GestionIntegral.Api.Services.Anomalia; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Comunicaciones; | ||||
| using GestionIntegral.Api.Services.Comunicaciones; | ||||
| using GestionIntegral.Api.Data.Repositories.Comunicaciones; | ||||
|  | ||||
| // Carga las variables de entorno desde el archivo .env al inicio de la aplicación. | ||||
| // Debe ser la primera línea para que la configuración esté disponible para el 'builder'. | ||||
| DotNetEnv.Env.Load(); | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| @@ -100,6 +109,33 @@ builder.Services.AddScoped<IQuestPdfGenerator, QuestPdfGenerator>(); | ||||
| // Servicio de Alertas | ||||
| builder.Services.AddScoped<IAlertaService, AlertaService>(); | ||||
|  | ||||
| // --- Suscripciones --- | ||||
| builder.Services.AddScoped<IFormaPagoRepository, FormaPagoRepository>(); | ||||
| builder.Services.AddScoped<ISuscriptorRepository, SuscriptorRepository>(); | ||||
| builder.Services.AddScoped<ISuscripcionRepository, SuscripcionRepository>(); | ||||
| builder.Services.AddScoped<IFacturaRepository, FacturaRepository>(); | ||||
| builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>(); | ||||
| builder.Services.AddScoped<IPagoRepository, PagoRepository>(); | ||||
| builder.Services.AddScoped<IPromocionRepository, PromocionRepository>(); | ||||
| builder.Services.AddScoped<IAjusteRepository, AjusteRepository>(); | ||||
| builder.Services.AddScoped<IFacturaDetalleRepository, FacturaDetalleRepository>(); | ||||
|  | ||||
| builder.Services.AddScoped<IFormaPagoService, FormaPagoService>(); | ||||
| builder.Services.AddScoped<ISuscriptorService, SuscriptorService>(); | ||||
| builder.Services.AddScoped<ISuscripcionService, SuscripcionService>(); | ||||
| builder.Services.AddScoped<IFacturacionService, FacturacionService>(); | ||||
| builder.Services.AddScoped<IDebitoAutomaticoService, DebitoAutomaticoService>(); | ||||
| builder.Services.AddScoped<IPagoService, PagoService>(); | ||||
| builder.Services.AddScoped<IPromocionService, PromocionService>(); | ||||
| builder.Services.AddScoped<IAjusteService, AjusteService>(); | ||||
|  | ||||
| // --- Comunicaciones --- | ||||
| builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings")); | ||||
| 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. | ||||
| // El sistema usará la cadena de conexión configurada en appsettings.json o variables de entorno. | ||||
|   | ||||
| @@ -0,0 +1,87 @@ | ||||
| using GestionIntegral.Api.Data.Repositories.Comunicaciones; | ||||
| using GestionIntegral.Api.Data.Repositories.Usuarios; | ||||
| using GestionIntegral.Api.Dtos.Comunicaciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Comunicaciones | ||||
| { | ||||
|   public class EmailLogService : IEmailLogService | ||||
|   { | ||||
|     private readonly IEmailLogRepository _emailLogRepository; | ||||
|     private readonly IUsuarioRepository _usuarioRepository; | ||||
|  | ||||
|     public EmailLogService(IEmailLogRepository emailLogRepository, IUsuarioRepository usuarioRepository) | ||||
|     { | ||||
|       _emailLogRepository = emailLogRepository; | ||||
|       _usuarioRepository = usuarioRepository; | ||||
|     } | ||||
|  | ||||
|     public async Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId) | ||||
|     { | ||||
|       var logs = await _emailLogRepository.GetByReferenceAsync(referenciaId); | ||||
|       if (!logs.Any()) | ||||
|       { | ||||
|         return Enumerable.Empty<EmailLogDto>(); | ||||
|       } | ||||
|  | ||||
|       // Optimización N+1: Obtener todos los usuarios necesarios en una sola consulta | ||||
|       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}"); | ||||
|       } | ||||
|  | ||||
|       // Mapear a DTO | ||||
|       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" | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     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" | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,137 @@ | ||||
| using GestionIntegral.Api.Data.Repositories.Comunicaciones; | ||||
| using GestionIntegral.Api.Models.Comunicaciones; | ||||
| using MailKit.Net.Smtp; | ||||
| using MailKit.Security; | ||||
| using Microsoft.Extensions.Options; | ||||
| using MimeKit; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Comunicaciones | ||||
| { | ||||
|     public class EmailService : IEmailService | ||||
|     { | ||||
|         private readonly MailSettings _mailSettings; | ||||
|         private readonly ILogger<EmailService> _logger; | ||||
|         private readonly IEmailLogRepository _emailLogRepository; | ||||
|  | ||||
|         public EmailService( | ||||
|             IOptions<MailSettings> mailSettings,  | ||||
|             ILogger<EmailService> logger, | ||||
|             IEmailLogRepository emailLogRepository) | ||||
|         { | ||||
|             _mailSettings = mailSettings.Value; | ||||
|             _logger = logger; | ||||
|             _emailLogRepository = emailLogRepository; | ||||
|         } | ||||
|  | ||||
|         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, | ||||
|             int? idLoteDeEnvio = null) | ||||
|         { | ||||
|             var email = new MimeMessage(); | ||||
|             email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail); | ||||
|             email.From.Add(email.Sender); | ||||
|             email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail)); | ||||
|             email.Subject = asunto; | ||||
|  | ||||
|             var builder = new BodyBuilder { HtmlBody = cuerpoHtml }; | ||||
|             if (attachment != null && !string.IsNullOrEmpty(attachmentName)) | ||||
|             { | ||||
|                 builder.Attachments.Add(attachmentName, attachment, ContentType.Parse("application/pdf")); | ||||
|             } | ||||
|             email.Body = builder.ToMessageBody(); | ||||
|  | ||||
|             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, | ||||
|             int? idLoteDeEnvio = null) | ||||
|         { | ||||
|             var email = new MimeMessage(); | ||||
|             email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail); | ||||
|             email.From.Add(email.Sender); | ||||
|             email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail)); | ||||
|             email.Subject = asunto; | ||||
|  | ||||
|             var builder = new BodyBuilder { HtmlBody = cuerpoHtml }; | ||||
|             if (adjuntos != null) | ||||
|             { | ||||
|                 foreach (var adjunto in adjuntos) | ||||
|                 { | ||||
|                     builder.Attachments.Add(adjunto.name, adjunto.content, ContentType.Parse("application/pdf")); | ||||
|                 } | ||||
|             } | ||||
|             email.Body = builder.ToMessageBody(); | ||||
|  | ||||
|             await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio); | ||||
|         } | ||||
|  | ||||
|         private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo, int? idLoteDeEnvio) | ||||
|         { | ||||
|             var destinatario = emailMessage.To.Mailboxes.FirstOrDefault()?.Address ?? "desconocido"; | ||||
|  | ||||
|             var log = new EmailLog | ||||
|             { | ||||
|                 FechaEnvio = DateTime.Now, | ||||
|                 DestinatarioEmail = destinatario, | ||||
|                 Asunto = emailMessage.Subject, | ||||
|                 Origen = origen, | ||||
|                 ReferenciaId = referenciaId, | ||||
|                 IdUsuarioDisparo = idUsuarioDisparo, | ||||
|                 IdLoteDeEnvio = idLoteDeEnvio | ||||
|             }; | ||||
|  | ||||
|             using var smtp = new SmtpClient(); | ||||
|             try | ||||
|             { | ||||
|                 await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); | ||||
|                 await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); | ||||
|                 await smtp.SendAsync(emailMessage); | ||||
|                  | ||||
|                 log.Estado = "Enviado"; | ||||
|                 _logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); | ||||
|             } | ||||
|             catch (SmtpCommandException scEx) | ||||
|             { | ||||
|                 _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}"; | ||||
|                 throw; | ||||
|             } | ||||
|             catch (AuthenticationException authEx) | ||||
|             { | ||||
|                 _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) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); | ||||
|                 log.Estado = "Fallido"; | ||||
|                 log.Error = ex.Message; | ||||
|                 throw; | ||||
|             } | ||||
|             finally | ||||
|             { | ||||
|                 if (smtp.IsConnected) | ||||
|                 { | ||||
|                     await smtp.DisconnectAsync(true); | ||||
|                 } | ||||
|  | ||||
|                 try | ||||
|                 { | ||||
|                     await _emailLogRepository.CreateAsync(log); | ||||
|                 } | ||||
|                 catch (Exception logEx) | ||||
|                 { | ||||
|                     _logger.LogError(logEx, "FALLO CRÍTICO: No se pudo guardar el log del email para {Destinatario}", destinatario); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| using GestionIntegral.Api.Dtos.Comunicaciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Comunicaciones | ||||
| { | ||||
|     public interface IEmailLogService | ||||
|     { | ||||
|         Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId); | ||||
|         Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,55 @@ | ||||
| namespace GestionIntegral.Api.Services.Comunicaciones | ||||
| { | ||||
|     public interface IEmailService | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar un archivo. | ||||
|         /// Este método también registra automáticamente el resultado del envío en la base de datos. | ||||
|         /// </summary> | ||||
|         /// <param name="destinatarioEmail">La dirección de correo del destinatario.</param> | ||||
|         /// <param name="destinatarioNombre">El nombre del destinatario.</param> | ||||
|         /// <param name="asunto">El asunto del correo.</param> | ||||
|         /// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param> | ||||
|         /// <param name="attachment">Los bytes del archivo a adjuntar (opcional).</param> | ||||
|         /// <param name="attachmentName">El nombre del archivo adjunto (requerido si se provee attachment).</param> | ||||
|         /// <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,  | ||||
|             string asunto,  | ||||
|             string cuerpoHtml,  | ||||
|             byte[]? attachment = null,  | ||||
|             string? attachmentName = null, | ||||
|             string? origen = null,  | ||||
|             string? referenciaId = 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. | ||||
|         /// Este método también registra automáticamente el resultado del envío en la base de datos. | ||||
|         /// </summary> | ||||
|         /// <param name="destinatarioEmail">La dirección de correo del destinatario.</param> | ||||
|         /// <param name="destinatarioNombre">El nombre del destinatario.</param> | ||||
|         /// <param name="asunto">El asunto del correo.</param> | ||||
|         /// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param> | ||||
|         /// <param name="adjuntos">Una lista de tuplas que contienen los bytes y el nombre de cada archivo a adjuntar.</param> | ||||
|         /// <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,  | ||||
|             string asunto,  | ||||
|             string cuerpoHtml,  | ||||
|             List<(byte[] content, string name)> adjuntos, | ||||
|             string? origen = null,  | ||||
|             string? referenciaId = null,  | ||||
|             int? idUsuarioDisparo = null, | ||||
|             int? idLoteDeEnvio = null); | ||||
|     } | ||||
| } | ||||
| @@ -61,16 +61,11 @@ namespace GestionIntegral.Api.Services.Reportes | ||||
|             IEnumerable<SaldoDto> Saldos, | ||||
|             string? Error | ||||
|         )> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta); | ||||
|  | ||||
|         Task<(IEnumerable<ListadoDistribucionDistSimpleDto> Simple, IEnumerable<ListadoDistribucionDistPromedioDiaDto> Promedios, string? Error)> ObtenerListadoDistribucionDistribuidoresAsync(int idDistribuidor, int idPublicacion, DateTime fechaDesde, DateTime fechaHasta); | ||||
|  | ||||
|         Task<( | ||||
|             IEnumerable<LiquidacionCanillaDetalleDto> Detalles, | ||||
|             IEnumerable<LiquidacionCanillaGananciaDto> Ganancias, | ||||
|             string? Error | ||||
|         )> ObtenerDatosTicketLiquidacionAsync(DateTime fecha, int idCanilla); | ||||
|  | ||||
|         Task<(IEnumerable<LiquidacionCanillaDetalleDto> Detalles, IEnumerable<LiquidacionCanillaGananciaDto> Ganancias, string? Error)> ObtenerDatosTicketLiquidacionAsync(DateTime fecha, int idCanilla); | ||||
|         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> Altas, IEnumerable<DistribucionSuscripcionDto> Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta); | ||||
|     } | ||||
| } | ||||
| @@ -1,21 +1,31 @@ | ||||
| using GestionIntegral.Api.Data.Repositories.Distribucion; | ||||
| using GestionIntegral.Api.Data.Repositories.Reportes; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Dtos.Reportes; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Reportes | ||||
| { | ||||
|     public class ReportesService : IReportesService | ||||
|     { | ||||
|         private readonly IReportesRepository _reportesRepository; | ||||
|         private readonly IFacturaRepository _facturaRepository; | ||||
|         private readonly IFacturaDetalleRepository _facturaDetalleRepository; | ||||
|         private readonly IPublicacionRepository _publicacionRepository; | ||||
|         private readonly IEmpresaRepository _empresaRepository; | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; | ||||
|         private readonly ISuscripcionRepository _suscripcionRepository; | ||||
|         private readonly ILogger<ReportesService> _logger; | ||||
|  | ||||
|         public ReportesService(IReportesRepository reportesRepository, ILogger<ReportesService> logger) | ||||
|         public ReportesService(IReportesRepository reportesRepository, IFacturaRepository facturaRepository, IFacturaDetalleRepository facturaDetalleRepository, IPublicacionRepository publicacionRepository, IEmpresaRepository empresaRepository | ||||
|             , ISuscriptorRepository suscriptorRepository, ISuscripcionRepository suscripcionRepository, ILogger<ReportesService> logger) | ||||
|         { | ||||
|             _reportesRepository = reportesRepository; | ||||
|             _facturaRepository = facturaRepository; | ||||
|             _facturaDetalleRepository = facturaDetalleRepository; | ||||
|             _publicacionRepository = publicacionRepository; | ||||
|             _empresaRepository = empresaRepository; | ||||
|             _suscriptorRepository = suscriptorRepository; | ||||
|             _suscripcionRepository = suscripcionRepository; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
| @@ -520,5 +530,49 @@ namespace GestionIntegral.Api.Services.Reportes | ||||
|                 return (Enumerable.Empty<ListadoDistCanMensualPubDto>(), "Error al obtener datos del reporte (por publicación)."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<(IEnumerable<FacturasParaReporteDto> Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes) | ||||
|         { | ||||
|             if (anio < 2020 || mes < 1 || mes > 12) | ||||
|             { | ||||
|                 return (Enumerable.Empty<FacturasParaReporteDto>(), "Período no válido."); | ||||
|             } | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
|             try | ||||
|             { | ||||
|                 // Llamada directa al nuevo método del repositorio | ||||
|                 var data = await _reportesRepository.GetDatosReportePublicidadAsync(periodo); | ||||
|                 return (data, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error en servicio al obtener datos para reporte de publicidad para el período {Periodo}", periodo); | ||||
|                 return (new List<FacturasParaReporteDto>(), "Error interno al generar el reporte."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<(IEnumerable<DistribucionSuscripcionDto> Altas, IEnumerable<DistribucionSuscripcionDto> Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta) | ||||
|         { | ||||
|             if (fechaDesde > fechaHasta) | ||||
|             { | ||||
|                 return (Enumerable.Empty<DistribucionSuscripcionDto>(), Enumerable.Empty<DistribucionSuscripcionDto>(), "La fecha 'Desde' no puede ser mayor que la fecha 'Hasta'."); | ||||
|             } | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 // 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 (Enumerable.Empty<DistribucionSuscripcionDto>(), Enumerable.Empty<DistribucionSuscripcionDto>(), "Error interno al generar el reporte."); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,225 @@ | ||||
| using GestionIntegral.Api.Data; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Data.Repositories.Usuarios; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
| using GestionIntegral.Api.Data.Repositories.Distribucion; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public class AjusteService : IAjusteService | ||||
|     { | ||||
|         private readonly IAjusteRepository _ajusteRepository; | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; | ||||
|         private readonly IUsuarioRepository _usuarioRepository; | ||||
|         private readonly IEmpresaRepository _empresaRepository; | ||||
|         private readonly IFacturaRepository _facturaRepository; | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<AjusteService> _logger; | ||||
|  | ||||
|         public AjusteService( | ||||
|             IAjusteRepository ajusteRepository, | ||||
|             ISuscriptorRepository suscriptorRepository, | ||||
|             IUsuarioRepository usuarioRepository, | ||||
|             IEmpresaRepository empresaRepository, | ||||
|             IFacturaRepository facturaRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<AjusteService> logger) | ||||
|         { | ||||
|             _ajusteRepository = ajusteRepository; | ||||
|             _suscriptorRepository = suscriptorRepository; | ||||
|             _usuarioRepository = usuarioRepository; | ||||
|             _empresaRepository = empresaRepository; | ||||
|             _facturaRepository = facturaRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private async Task<AjusteDto?> MapToDto(Ajuste ajuste) | ||||
|         { | ||||
|             if (ajuste == null) return null; | ||||
|             var usuario = await _usuarioRepository.GetByIdAsync(ajuste.IdUsuarioAlta); | ||||
|             var empresa = await _empresaRepository.GetByIdAsync(ajuste.IdEmpresa); | ||||
|             return new AjusteDto | ||||
|             { | ||||
|                 IdAjuste = ajuste.IdAjuste, | ||||
|                 IdSuscriptor = ajuste.IdSuscriptor, | ||||
|                 IdEmpresa = ajuste.IdEmpresa, | ||||
|                 NombreEmpresa = empresa?.Nombre ?? "N/A", | ||||
|                 FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"), | ||||
|                 TipoAjuste = ajuste.TipoAjuste, | ||||
|                 Monto = ajuste.Monto, | ||||
|                 Motivo = ajuste.Motivo, | ||||
|                 Estado = ajuste.Estado, | ||||
|                 IdFacturaAplicado = ajuste.IdFacturaAplicado, | ||||
|                 FechaAlta = ajuste.FechaAlta.ToString("yyyy-MM-dd HH:mm"), | ||||
|                 NombreUsuarioAlta = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A" | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta) | ||||
|         { | ||||
|             var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor, fechaDesde, fechaHasta); | ||||
|             if (!ajustes.Any()) | ||||
|             { | ||||
|                 return Enumerable.Empty<AjusteDto>(); | ||||
|             } | ||||
|  | ||||
|             // 1. Recolectar IDs de usuarios, empresas Y FACTURAS | ||||
|             var idsUsuarios = ajustes.Select(a => a.IdUsuarioAlta).Distinct().ToList(); | ||||
|             var idsEmpresas = ajustes.Select(a => a.IdEmpresa).Distinct().ToList(); | ||||
|             var idsFacturas = ajustes.Where(a => a.IdFacturaAplicado.HasValue) | ||||
|                                      .Select(a => a.IdFacturaAplicado!.Value) | ||||
|                                      .Distinct().ToList(); | ||||
|  | ||||
|             // 2. Obtener todos los datos necesarios en consultas masivas | ||||
|             var usuariosTask = _usuarioRepository.GetByIdsAsync(idsUsuarios); | ||||
|             var empresasTask = _empresaRepository.GetAllAsync(null, null); | ||||
|             var facturasTask = _facturaRepository.GetByIdsAsync(idsFacturas); | ||||
|  | ||||
|             await Task.WhenAll(usuariosTask, empresasTask, facturasTask); | ||||
|  | ||||
|             // 3. Convertir a diccionarios para búsqueda rápida | ||||
|             var usuariosDict = (await usuariosTask).ToDictionary(u => u.Id); | ||||
|             var empresasDict = (await empresasTask).ToDictionary(e => e.IdEmpresa); | ||||
|             var facturasDict = (await facturasTask).ToDictionary(f => f.IdFactura); | ||||
|  | ||||
|             // 4. Mapear en memoria, ahora con la información de la factura disponible | ||||
|             var dtos = ajustes.Select(ajuste => | ||||
|             { | ||||
|                 usuariosDict.TryGetValue(ajuste.IdUsuarioAlta, out var usuario); | ||||
|                 empresasDict.TryGetValue(ajuste.IdEmpresa, out var empresa); | ||||
|  | ||||
|                 // Buscar la factura en el diccionario si el ajuste está aplicado | ||||
|                 facturasDict.TryGetValue(ajuste.IdFacturaAplicado ?? 0, out var factura); | ||||
|  | ||||
|                 return new AjusteDto | ||||
|                 { | ||||
|                     IdAjuste = ajuste.IdAjuste, | ||||
|                     IdSuscriptor = ajuste.IdSuscriptor, | ||||
|                     IdEmpresa = ajuste.IdEmpresa, | ||||
|                     NombreEmpresa = empresa?.Nombre ?? "N/A", | ||||
|                     FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"), | ||||
|                     TipoAjuste = ajuste.TipoAjuste, | ||||
|                     Monto = ajuste.Monto, | ||||
|                     Motivo = ajuste.Motivo, | ||||
|                     Estado = ajuste.Estado, | ||||
|                     IdFacturaAplicado = ajuste.IdFacturaAplicado, | ||||
|                     NumeroFacturaAplicado = factura?.NumeroFactura, | ||||
|                     FechaAlta = ajuste.FechaAlta.ToString("yyyy-MM-dd HH:mm"), | ||||
|                     NombreUsuarioAlta = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A" | ||||
|                 }; | ||||
|             }); | ||||
|  | ||||
|             return dtos; | ||||
|         } | ||||
|  | ||||
|         public async Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario) | ||||
|         { | ||||
|             var suscriptor = await _suscriptorRepository.GetByIdAsync(createDto.IdSuscriptor); | ||||
|             if (suscriptor == null) | ||||
|             { | ||||
|                 return (null, "El suscriptor especificado no existe."); | ||||
|             } | ||||
|             var empresa = await _empresaRepository.GetByIdAsync(createDto.IdEmpresa); | ||||
|             if (empresa == null) | ||||
|             { | ||||
|                 return (null, "La empresa especificada no existe."); | ||||
|             } | ||||
|  | ||||
|             var nuevoAjuste = new Ajuste | ||||
|             { | ||||
|                 IdSuscriptor = createDto.IdSuscriptor, | ||||
|                 IdEmpresa = createDto.IdEmpresa, | ||||
|                 FechaAjuste = createDto.FechaAjuste.Date, | ||||
|                 TipoAjuste = createDto.TipoAjuste, | ||||
|                 Monto = createDto.Monto, | ||||
|                 Motivo = createDto.Motivo, | ||||
|                 IdUsuarioAlta = idUsuario | ||||
|             }; | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|             try | ||||
|             { | ||||
|                 var ajusteCreado = await _ajusteRepository.CreateAsync(nuevoAjuste, transaction); | ||||
|                 if (ajusteCreado == null) throw new DataException("Error al crear el registro de ajuste."); | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Ajuste manual ID {IdAjuste} creado para Suscriptor ID {IdSuscriptor} por Usuario ID {IdUsuario}", ajusteCreado.IdAjuste, ajusteCreado.IdSuscriptor, idUsuario); | ||||
|  | ||||
|                 var dto = await MapToDto(ajusteCreado); | ||||
|                 return (dto, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error al crear ajuste manual para Suscriptor ID {IdSuscriptor}", createDto.IdSuscriptor); | ||||
|                 return (null, "Error interno al registrar el ajuste."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario) | ||||
|         { | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|             try | ||||
|             { | ||||
|                 var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste); | ||||
|                 if (ajuste == null) return (false, "Ajuste no encontrado."); | ||||
|                 if (ajuste.Estado != "Pendiente") return (false, $"No se puede anular un ajuste en estado '{ajuste.Estado}'."); | ||||
|  | ||||
|                 var exito = await _ajusteRepository.AnularAjusteAsync(idAjuste, idUsuario, transaction); | ||||
|                 if (!exito) throw new DataException("No se pudo anular el ajuste."); | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Ajuste ID {IdAjuste} anulado por Usuario ID {IdUsuario}", idAjuste, idUsuario); | ||||
|                 return (true, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error al anular ajuste ID {IdAjuste}", idAjuste); | ||||
|                 return (false, "Error interno al anular el ajuste."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error)> ActualizarAjuste(int idAjuste, UpdateAjusteDto updateDto) | ||||
|         { | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|             try | ||||
|             { | ||||
|                 var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste); | ||||
|                 if (ajuste == null) return (false, "Ajuste no encontrado."); | ||||
|                 if (ajuste.Estado != "Pendiente") return (false, $"No se puede modificar un ajuste en estado '{ajuste.Estado}'."); | ||||
|  | ||||
|                 var empresa = await _empresaRepository.GetByIdAsync(updateDto.IdEmpresa); | ||||
|                 if (empresa == null) return (false, "La empresa especificada no existe."); | ||||
|  | ||||
|                 ajuste.IdEmpresa = updateDto.IdEmpresa; | ||||
|                 ajuste.FechaAjuste = updateDto.FechaAjuste; | ||||
|                 ajuste.TipoAjuste = updateDto.TipoAjuste; | ||||
|                 ajuste.Monto = updateDto.Monto; | ||||
|                 ajuste.Motivo = updateDto.Motivo; | ||||
|  | ||||
|                 var actualizado = await _ajusteRepository.UpdateAsync(ajuste, transaction); | ||||
|                 if (!actualizado) throw new DataException("La actualización falló o el ajuste ya no estaba pendiente."); | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Ajuste ID {IdAjuste} actualizado.", idAjuste); | ||||
|                 return (true, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error al actualizar ajuste ID {IdAjuste}", idAjuste); | ||||
|                 return (false, "Error interno al actualizar el ajuste."); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,320 @@ | ||||
| using GestionIntegral.Api.Data; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
| using System.Text; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public class DebitoAutomaticoService : IDebitoAutomaticoService | ||||
|     { | ||||
|         private readonly IFacturaRepository _facturaRepository; | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; | ||||
|         private readonly ILoteDebitoRepository _loteDebitoRepository; | ||||
|         private readonly IFormaPagoRepository _formaPagoRepository; | ||||
|         private readonly IPagoRepository _pagoRepository; | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<DebitoAutomaticoService> _logger; | ||||
|  | ||||
|         private const string NRO_PRESTACION = "123456"; | ||||
|         private const string ORIGEN_EMPRESA = "ELDIA"; | ||||
|  | ||||
|         public DebitoAutomaticoService( | ||||
|             IFacturaRepository facturaRepository, | ||||
|             ISuscriptorRepository suscriptorRepository, | ||||
|             ILoteDebitoRepository loteDebitoRepository, | ||||
|             IFormaPagoRepository formaPagoRepository, | ||||
|             IPagoRepository pagoRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<DebitoAutomaticoService> logger) | ||||
|         { | ||||
|             _facturaRepository = facturaRepository; | ||||
|             _suscriptorRepository = suscriptorRepository; | ||||
|             _loteDebitoRepository = loteDebitoRepository; | ||||
|             _formaPagoRepository = formaPagoRepository; | ||||
|             _pagoRepository = pagoRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) | ||||
|         { | ||||
|             // Se define la identificación del archivo. | ||||
|             // Este número debe ser gestionado para no repetirse en archivos generados | ||||
|             // para la misma prestación y fecha. | ||||
|             const int identificacionArchivo = 1; | ||||
|              | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
|             var fechaGeneracion = DateTime.Now; | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction); | ||||
|                 if (!facturasParaDebito.Any()) | ||||
|                 { | ||||
|                     return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado."); | ||||
|                 } | ||||
|  | ||||
|                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); | ||||
|                 var cantidadRegistros = facturasParaDebito.Count(); | ||||
|                  | ||||
|                 // Se utiliza la variable 'identificacionArchivo' para nombrar el archivo. | ||||
|                 var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt"; | ||||
|  | ||||
|                 var nuevoLote = new LoteDebito | ||||
|                 { | ||||
|                     Periodo = periodo, | ||||
|                     NombreArchivo = nombreArchivo, | ||||
|                     ImporteTotal = importeTotal, | ||||
|                     CantidadRegistros = cantidadRegistros, | ||||
|                     IdUsuarioGeneracion = idUsuario | ||||
|                 }; | ||||
|                 var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction); | ||||
|                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); | ||||
|  | ||||
|                 var sb = new StringBuilder(); | ||||
|                 // Se pasa la 'identificacionArchivo' al método que crea el Header. | ||||
|                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||
|                 foreach (var item in facturasParaDebito) | ||||
|                 { | ||||
|                     sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); | ||||
|                 } | ||||
|                 // Se pasa la 'identificacionArchivo' al método que crea el Trailer. | ||||
|                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||
|  | ||||
|                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); | ||||
|                 bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction); | ||||
|                 if (!actualizadas) throw new DataException("No se pudieron actualizar las facturas con la información del lote."); | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Archivo de débito {NombreArchivo} generado exitosamente para el período {Periodo}.", nombreArchivo, periodo); | ||||
|                 return (sb.ToString(), nombreArchivo, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error crítico al generar el archivo de débito para el período {Periodo}", periodo); | ||||
|                 return (null, null, $"Error interno: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction) | ||||
|         { | ||||
|             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); | ||||
|             var resultado = new List<(Factura, Suscriptor)>(); | ||||
|  | ||||
|             foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente")) | ||||
|             { | ||||
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor); | ||||
|  | ||||
|                 // Se valida que el CBU de Banelco (22 caracteres) exista antes de intentar la conversión. | ||||
|                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22) | ||||
|                 { | ||||
|                     _logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", suscriptor?.IdSuscriptor); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); | ||||
|                 if (formaPago != null && formaPago.RequiereCBU) | ||||
|                 { | ||||
|                     resultado.Add((f, suscriptor)); | ||||
|                 } | ||||
|             } | ||||
|             return resultado; | ||||
|         } | ||||
|  | ||||
|         // Lógica de conversión de CBU. | ||||
|         private string ConvertirCbuBanelcoASnp(string cbu22) | ||||
|         { | ||||
|             if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) | ||||
|             { | ||||
|                 _logger.LogError("Se intentó convertir un CBU inválido de {Length} caracteres. Se devolverá un campo vacío.", cbu22?.Length ?? 0); | ||||
|                 // Devolver un string de 26 espacios/ceros según la preferencia del banco para campos erróneos. | ||||
|                 return "".PadRight(26);  | ||||
|             } | ||||
|  | ||||
|             // El formato SNP de 26 se obtiene insertando un "0" al inicio y "000" después del 8vo caracter del CBU de 22. | ||||
|             // Formato Banelco (22): [BBBSSSSX] [T....Y] | ||||
|             // Posiciones:            (0-7)      (8-21) | ||||
|             // Formato SNP (26):      0[BBBSSSSX]000[T....Y] | ||||
|             try | ||||
|             { | ||||
|                 string bloque1 = cbu22.Substring(0, 8); // Contiene código de banco, sucursal y DV del bloque 1. | ||||
|                 string bloque2 = cbu22.Substring(8);    // Contiene el resto de la cadena. | ||||
|  | ||||
|                 // Reconstruir en formato SNP de 26 dígitos según el instructivo. | ||||
|                 return $"0{bloque1}000{bloque2}"; | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al parsear y convertir CBU de 22 dígitos: {CBU}", cbu22); | ||||
|                 return "".PadRight(26); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // --- Métodos de Formateo y Mapeo --- | ||||
|         private string FormatString(string? value, int length) => (value ?? "").PadRight(length); | ||||
|         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); | ||||
|         private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch | ||||
|         { | ||||
|             "DNI" => "0096", | ||||
|             "CUIT" => "0080", | ||||
|             "CUIL" => "0086", | ||||
|             "LE" => "0089", | ||||
|             "LC" => "0090", | ||||
|             _ => "0000" // Tipo no especificado o C.I. Policía Federal según anexo. | ||||
|         }; | ||||
|  | ||||
|         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("00"); // Tipo de Registro Header | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos | ||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||
|             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo | ||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
|             sb.Append(FormatString("", 304)); | ||||
|             sb.Append("\r\n"); | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) | ||||
|         { | ||||
|             // Convertimos el CBU de 22 (Banelco) a 26 (SNP) antes de usarlo. | ||||
|             string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!); | ||||
|  | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("0370"); // Tipo de Registro Detalle (Orden de Débito) | ||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación de Cliente | ||||
|             sb.Append(FormatString(cbu26, 26)); // CBU en formato SNP de 26 caracteres. | ||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); // Referencia Unívoca de la factura. | ||||
|             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); | ||||
|             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento | ||||
|             sb.Append("0"); // Moneda (0 = Pesos) | ||||
|             sb.Append(FormatString("", 3)); // Motivo Rechazo (vacío en el envío) | ||||
|             sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4)); | ||||
|             sb.Append(FormatString(suscriptor.NroDocumento, 11)); | ||||
|             sb.Append(FormatString("", 22)); // Nueva ID Cliente | ||||
|             sb.Append(FormatString("", 26)); // Nueva CBU | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vencimiento | ||||
|             sb.Append(FormatString("", 22)); // Identificación Cuenta Anterior | ||||
|             sb.Append(FormatString("", 40)); // Mensaje ATM | ||||
|             sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); // Concepto Factura | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Cobrado | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Acreditamiento | ||||
|             sb.Append(FormatString("", 26)); // Libre | ||||
|             sb.Append("\r\n"); | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("99"); // Tipo de Registro Trailer | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos | ||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||
|             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo | ||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
|             sb.Append(FormatString("", 304)); | ||||
|             // La última línea del archivo no lleva salto de línea (\r\n). | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         public async Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario) | ||||
|         { | ||||
|             // Se mantiene la lógica original para procesar el archivo de respuesta del banco. | ||||
|              | ||||
|             var respuesta = new ProcesamientoLoteResponseDto(); | ||||
|             if (archivo == null || archivo.Length == 0) | ||||
|             { | ||||
|                 respuesta.Errores.Add("No se proporcionó ningún archivo o el archivo está vacío."); | ||||
|                 return respuesta; | ||||
|             } | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 using var reader = new StreamReader(archivo.OpenReadStream()); | ||||
|                 string? linea; | ||||
|                 while ((linea = await reader.ReadLineAsync()) != null) | ||||
|                 { | ||||
|                     if (linea.Length < 20) continue; | ||||
|                     respuesta.TotalRegistrosLeidos++; | ||||
|  | ||||
|                     var referencia = linea.Substring(0, 15).Trim(); | ||||
|                     var estadoProceso = linea.Substring(15, 2).Trim(); | ||||
|                     var motivoRechazo = linea.Substring(17, 3).Trim(); | ||||
|                     if (!int.TryParse(referencia.Replace("SUSC-", ""), out int idFactura)) | ||||
|                     { | ||||
|                         respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'."); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var factura = await _facturaRepository.GetByIdAsync(idFactura); | ||||
|                     if (factura == null) | ||||
|                     { | ||||
|                         respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: La factura con ID {idFactura} no fue encontrada en el sistema."); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var nuevoPago = new Pago | ||||
|                     { | ||||
|                         IdFactura = idFactura, | ||||
|                         FechaPago = DateTime.Now.Date, | ||||
|                         IdFormaPago = 1, // Se asume una forma de pago para el débito. | ||||
|                         Monto = factura.ImporteFinal, | ||||
|                         IdUsuarioRegistro = idUsuario, | ||||
|                         Referencia = $"Lote {factura.IdLoteDebito} - Banco" | ||||
|                     }; | ||||
|  | ||||
|                     if (estadoProceso == "AP") | ||||
|                     { | ||||
|                         nuevoPago.Estado = "Aprobado"; | ||||
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||
|                         await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction); | ||||
|                         respuesta.PagosAprobados++; | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         nuevoPago.Estado = "Rechazado"; | ||||
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||
|                         await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction); | ||||
|                         respuesta.PagosRechazados++; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 respuesta.MensajeResumen = $"Archivo procesado. Leídos: {respuesta.TotalRegistrosLeidos}, Aprobados: {respuesta.PagosAprobados}, Rechazados: {respuesta.PagosRechazados}."; | ||||
|                 _logger.LogInformation(respuesta.MensajeResumen); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error crítico al procesar archivo de respuesta de débito."); | ||||
|                 respuesta.Errores.Add($"Error fatal en el procesamiento: {ex.Message}"); | ||||
|             } | ||||
|  | ||||
|             return respuesta; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,669 @@ | ||||
| using GestionIntegral.Api.Data; | ||||
| using GestionIntegral.Api.Data.Repositories.Distribucion; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Distribucion; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| 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; | ||||
|         private readonly IFacturaDetalleRepository _facturaDetalleRepository; | ||||
|         private readonly IPrecioRepository _precioRepository; | ||||
|         private readonly IPromocionRepository _promocionRepository; | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; | ||||
|         private readonly IAjusteRepository _ajusteRepository; | ||||
|         private readonly IEmailService _emailService; | ||||
|         private readonly IPublicacionRepository _publicacionRepository; | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<FacturacionService> _logger; | ||||
|         private readonly string _facturasPdfPath; | ||||
|         private const string LogoUrl = "https://www.eldia.com/img/header/eldia.png"; | ||||
|  | ||||
|         public FacturacionService( | ||||
|             ISuscripcionRepository suscripcionRepository, | ||||
|             IFacturaRepository facturaRepository, | ||||
|             IEmpresaRepository empresaRepository, | ||||
|             IFacturaDetalleRepository facturaDetalleRepository, | ||||
|             IPrecioRepository precioRepository, | ||||
|             IPromocionRepository promocionRepository, | ||||
|             ISuscriptorRepository suscriptorRepository, | ||||
|             IAjusteRepository ajusteRepository, | ||||
|             IEmailService emailService, | ||||
|             IPublicacionRepository publicacionRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<FacturacionService> logger, | ||||
|             IConfiguration configuration, | ||||
|             ILoteDeEnvioRepository loteDeEnvioRepository, | ||||
|              IUsuarioRepository usuarioRepository) | ||||
|         { | ||||
|             _loteDeEnvioRepository = loteDeEnvioRepository; | ||||
|             _usuarioRepository = usuarioRepository; | ||||
|             _suscripcionRepository = suscripcionRepository; | ||||
|             _facturaRepository = facturaRepository; | ||||
|             _empresaRepository = empresaRepository; | ||||
|             _facturaDetalleRepository = facturaDetalleRepository; | ||||
|             _precioRepository = precioRepository; | ||||
|             _promocionRepository = promocionRepository; | ||||
|             _suscriptorRepository = suscriptorRepository; | ||||
|             _ajusteRepository = ajusteRepository; | ||||
|             _emailService = emailService; | ||||
|             _publicacionRepository = publicacionRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|             _facturasPdfPath = configuration.GetValue<string>("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF"; | ||||
|         } | ||||
|  | ||||
|         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) | ||||
|             { | ||||
|                 var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture); | ||||
|                 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}.", 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(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction); | ||||
|                 if (!suscripcionesActivas.Any()) | ||||
|                 { | ||||
|                     // 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); | ||||
|                 } | ||||
|  | ||||
|                 var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>(); | ||||
|                 foreach (var s in suscripcionesActivas) | ||||
|                 { | ||||
|                     var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion); | ||||
|                     if (pub != null) | ||||
|                     { | ||||
|                         suscripcionesConEmpresa.Add((s, pub.IdEmpresa)); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa }); | ||||
|  | ||||
|                 foreach (var grupo in gruposParaFacturar) | ||||
|                 { | ||||
|                     int idSuscriptor = grupo.Key.IdSuscriptor; | ||||
|                     int idEmpresa = grupo.Key.IdEmpresa; | ||||
|                     decimal importeBrutoTotal = 0; | ||||
|                     decimal descuentoPromocionesTotal = 0; | ||||
|                     var detallesParaFactura = new List<FacturaDetalle>(); | ||||
|                     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 | ||||
|                         { | ||||
|                             IdSuscripcion = suscripcion.IdSuscripcion, | ||||
|                             Descripcion = $"Corresponde a {publicacion?.Nombre ?? "N/A"}", | ||||
|                             ImporteBruto = importeBrutoSusc, | ||||
|                             DescuentoAplicado = descuentoSusc, | ||||
|                             ImporteNeto = importeBrutoSusc - descuentoSusc | ||||
|                         }); | ||||
|                     } | ||||
|                     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); | ||||
|                     var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes; | ||||
|                     if (importeFinal < 0) importeFinal = 0; | ||||
|                     if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue; | ||||
|                     var nuevaFactura = new Factura | ||||
|                     { | ||||
|                         IdSuscriptor = idSuscriptor, | ||||
|                         Periodo = periodoActualStr, | ||||
|                         FechaEmision = DateTime.Now.Date, | ||||
|                         FechaVencimiento = new DateTime(anio, mes, 10), | ||||
|                         ImporteBruto = importeBrutoTotal, | ||||
|                         DescuentoAplicado = descuentoPromocionesTotal, | ||||
|                         ImporteFinal = importeFinal, | ||||
|                         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++; | ||||
|                 } | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr); | ||||
|  | ||||
|                 if (facturasCreadas.Any()) | ||||
|                 { | ||||
|                     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 | ||||
|                         { | ||||
|                             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); | ||||
|                 } | ||||
|  | ||||
|                 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.", 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) | ||||
|         { | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
|             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion); | ||||
|             var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo | ||||
|             var empresas = await _empresaRepository.GetAllAsync(null, null); | ||||
|  | ||||
|             var resumenes = facturasData | ||||
|                 .GroupBy(data => data.Factura.IdSuscriptor) | ||||
|                 .Select(grupo => | ||||
|                 { | ||||
|                     var primerItem = grupo.First(); | ||||
|                     var facturasConsolidadas = grupo.Select(itemFactura => | ||||
|                     { | ||||
|                         var empresa = empresas.FirstOrDefault(e => e.IdEmpresa == itemFactura.IdEmpresa); | ||||
|                         return new FacturaConsolidadaDto | ||||
|                         { | ||||
|                             IdFactura = itemFactura.Factura.IdFactura, | ||||
|                             NombreEmpresa = empresa?.Nombre ?? "N/A", | ||||
|                             ImporteFinal = itemFactura.Factura.ImporteFinal, | ||||
|                             EstadoPago = itemFactura.Factura.EstadoPago, | ||||
|                             EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, | ||||
|                             NumeroFactura = itemFactura.Factura.NumeroFactura, | ||||
|                             Detalles = detallesData | ||||
|                                 .Where(d => d.IdFactura == itemFactura.Factura.IdFactura) | ||||
|                                 .Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto }) | ||||
|                                 .ToList() | ||||
|                         }; | ||||
|                     }).ToList(); | ||||
|  | ||||
|                     return new ResumenCuentaSuscriptorDto | ||||
|                     { | ||||
|                         IdSuscriptor = primerItem.Factura.IdSuscriptor, | ||||
|                         NombreSuscriptor = primerItem.NombreSuscriptor, | ||||
|                         Facturas = facturasConsolidadas, | ||||
|                         ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal), | ||||
|                         SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.EstadoPago == "Pagada" ? 0 : f.ImporteFinal) | ||||
|                     }; | ||||
|                 }); | ||||
|  | ||||
|             return resumenes.ToList(); | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var factura = await _facturaRepository.GetByIdAsync(idFactura); | ||||
|                 if (factura == null) return (false, "Factura no encontrada.", null); | ||||
|                 if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número asignado.", null); | ||||
|                 if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada.", null); | ||||
|  | ||||
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(factura.IdSuscriptor); | ||||
|                 if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.", null); | ||||
|  | ||||
|                 byte[]? pdfAttachment = null; | ||||
|                 string? pdfFileName = null; | ||||
|                 var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf"); | ||||
|  | ||||
|                 if (File.Exists(rutaCompleta)) | ||||
|                 { | ||||
|                     pdfAttachment = await File.ReadAllBytesAsync(rutaCompleta); | ||||
|                     pdfFileName = $"Factura_{factura.NumeroFactura}.pdf"; | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     _logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura}", factura.NumeroFactura); | ||||
|                     return (false, "No se encontró el archivo PDF correspondiente en el servidor.", null); | ||||
|                 } | ||||
|  | ||||
|                 string asunto = $"Factura Electrónica - Período {factura.Periodo}"; | ||||
|                 string cuerpoHtml = ConstruirCuerpoEmailFacturaPdf(suscriptor, factura); | ||||
|  | ||||
|                 // Pasamos los nuevos parámetros de contexto al EmailService. | ||||
|                 await _emailService.EnviarEmailAsync( | ||||
|                     destinatarioEmail: suscriptor.Email, | ||||
|                     destinatarioNombre: suscriptor.NombreCompleto, | ||||
|                     asunto: asunto, | ||||
|                     cuerpoHtml: cuerpoHtml, | ||||
|                     attachment: pdfAttachment, | ||||
|                     attachmentName: pdfFileName, | ||||
|                     origen: "EnvioManualPDF", | ||||
|                     referenciaId: $"Factura-{idFactura}", | ||||
|                     idUsuarioDisparo: idUsuario); | ||||
|  | ||||
|                 _logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor); | ||||
|  | ||||
|                 return (true, null, suscriptor.Email); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura); | ||||
|                 // El error ya será logueado por EmailService, pero lo relanzamos para que el controller lo maneje. | ||||
|                 // En este caso, simplemente devolvemos la tupla de error. | ||||
|                 return (false, "Ocurrió un error al intentar enviar el email con la factura.", null); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /// <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}"; | ||||
|  | ||||
|             // 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()) | ||||
|             { | ||||
|                 // 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); | ||||
|             // 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)>(); | ||||
|  | ||||
|             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) | ||||
|                 { | ||||
|                     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 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); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             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) | ||||
|         { | ||||
|             return $@" | ||||
|             <div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'> | ||||
|                 <div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'> | ||||
|                     <div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'> | ||||
|                         <img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'> | ||||
|                         <h2>Resumen de su Cuenta</h2> | ||||
|                     </div> | ||||
|                     <div style='padding: 20px; color: #333;'> | ||||
|                         <h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3> | ||||
|                         <p>Le enviamos el resumen de su cuenta para el período <strong>{periodo}</strong>.</p> | ||||
|                          | ||||
|                         <!-- Aquí se insertan las tablas de resumen generadas dinámicamente --> | ||||
|                         {resumenHtml} | ||||
|                          | ||||
|                         <hr style='border: none; border-top: 1px solid #eee; margin: 20px 0;'/> | ||||
|                         <table style='width: 100%;'> | ||||
|                             <tr> | ||||
|                                 <td style='font-size: 1.2em; font-weight: bold;'>TOTAL A ABONAR:</td> | ||||
|                                 <td style='font-size: 1.4em; font-weight: bold; text-align: right; color: #34515e;'>${totalGeneral:N2}</td> | ||||
|                             </tr> | ||||
|                         </table> | ||||
|                         <p style='margin-top: 25px;'>Si su pago es por débito automático, los importes se debitarán de su cuenta. Si utiliza otro medio de pago, por favor, regularice su situación.</p> | ||||
|                         <p>Gracias por ser parte de nuestra comunidad de lectores.</p> | ||||
|                     </div> | ||||
|                     <div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'> | ||||
|                         <p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p> | ||||
|                         <p>© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div>"; | ||||
|         } | ||||
|  | ||||
|         private string ConstruirCuerpoEmailFacturaPdf(Suscriptor suscriptor, Factura factura) | ||||
|         { | ||||
|             return $@" | ||||
|             <div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'> | ||||
|                 <div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'> | ||||
|                     <div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'> | ||||
|                         <img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'> | ||||
|                         <h2>Factura Electrónica Adjunta</h2> | ||||
|                     </div> | ||||
|                     <div style='padding: 20px; color: #333;'> | ||||
|                         <h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3> | ||||
|                         <p>Le enviamos adjunta su factura correspondiente al período <strong>{factura.Periodo}</strong>.</p> | ||||
|                         <h4 style='border-bottom: 2px solid #34515e; padding-bottom: 5px; margin-top: 30px;'>Resumen de la Factura</h4> | ||||
|                         <table style='width: 100%; border-collapse: collapse; margin-top: 15px;'> | ||||
|                             <tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Número de Factura:</td><td style='padding: 8px; text-align: right;'>{factura.NumeroFactura}</td></tr> | ||||
|                             <tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Período:</td><td style='padding: 8px; text-align: right;'>{factura.Periodo}</td></tr> | ||||
|                             <tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Fecha de Envío:</td><td style='padding: 8px; text-align: right;'>{factura.FechaEmision:dd/MM/yyyy}</td></tr> | ||||
|                             <tr style='background-color: #f2f2f2;'><td style='padding: 12px; font-weight: bold; font-size: 1.1em;'>IMPORTE TOTAL:</td><td style='padding: 12px; text-align: right; font-weight: bold; font-size: 1.2em; color: #34515e;'>${factura.ImporteFinal:N2}</td></tr> | ||||
|                         </table> | ||||
|                         <p style='margin-top: 30px;'>Puede descargar y guardar el archivo PDF adjunto para sus registros.</p> | ||||
|                         <p>Gracias por ser parte de nuestra comunidad de lectores.</p> | ||||
|                     </div> | ||||
|                     <div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'> | ||||
|                         <p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p> | ||||
|                         <p>© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div>"; | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(numeroFactura)) | ||||
|             { | ||||
|                 return (false, "El número de factura no puede estar vacío."); | ||||
|             } | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var factura = await _facturaRepository.GetByIdAsync(idFactura); | ||||
|                 if (factura == null) | ||||
|                 { | ||||
|                     return (false, "La factura especificada no existe."); | ||||
|                 } | ||||
|                 if (factura.EstadoPago == "Anulada") | ||||
|                 { | ||||
|                     return (false, "No se puede modificar una factura anulada."); | ||||
|                 } | ||||
|  | ||||
|                 var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction); | ||||
|                 if (!actualizado) | ||||
|                 { | ||||
|                     throw new DataException("La actualización del número de factura falló en el repositorio."); | ||||
|                 } | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Número de factura para Factura ID {IdFactura} actualizado a {NumeroFactura} por Usuario ID {IdUsuario}", idFactura, numeroFactura, idUsuario); | ||||
|                 return (true, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error al actualizar número de factura para Factura ID {IdFactura}", idFactura); | ||||
|                 return (false, "Error interno al actualizar el número de factura."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction) | ||||
|         { | ||||
|             decimal importeTotal = 0; | ||||
|             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); | ||||
|             var fechaActual = new DateTime(anio, mes, 1); | ||||
|             var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, fechaActual, transaction); | ||||
|             var promocionesDeBonificacion = promociones.Where(p => p.TipoEfecto == "BonificarEntregaDia").ToList(); | ||||
|  | ||||
|             while (fechaActual.Month == mes) | ||||
|             { | ||||
|                 if (fechaActual.Date >= suscripcion.FechaInicio.Date && (suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date)) | ||||
|                 { | ||||
|                     var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek); | ||||
|                     if (diasDeEntrega.Contains(diaSemanaChar)) | ||||
|                     { | ||||
|                         decimal precioDelDia = 0; | ||||
|                         var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction); | ||||
|                         if (precioActivo != null) | ||||
|                         { | ||||
|                             precioDelDia = GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek); | ||||
|                         } | ||||
|                         else | ||||
|                         { | ||||
|                             _logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date); | ||||
|                         } | ||||
|  | ||||
|                         bool diaBonificado = promocionesDeBonificacion.Any(promo => EvaluarCondicionPromocion(promo, fechaActual)); | ||||
|                         if (diaBonificado) | ||||
|                         { | ||||
|                             precioDelDia = 0; | ||||
|                             _logger.LogInformation("Día {Fecha} bonificado para suscripción {IdSuscripcion} por promoción.", fechaActual.ToShortDateString(), suscripcion.IdSuscripcion); | ||||
|                         } | ||||
|                         importeTotal += precioDelDia; | ||||
|                     } | ||||
|                 } | ||||
|                 fechaActual = fechaActual.AddDays(1); | ||||
|             } | ||||
|             return importeTotal; | ||||
|         } | ||||
|  | ||||
|         private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha) | ||||
|         { | ||||
|             switch (promocion.TipoCondicion) | ||||
|             { | ||||
|                 case "Siempre": return true; | ||||
|                 case "DiaDeSemana": | ||||
|                     int diaSemanaActual = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek; | ||||
|                     return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActual; | ||||
|                 case "PrimerDiaSemanaDelMes": | ||||
|                     int diaSemanaActualMes = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek; | ||||
|                     return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActualMes && fecha.Day <= 7; | ||||
|                 default: return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private string GetCharDiaSemana(DayOfWeek dia) => dia switch | ||||
|         { | ||||
|             DayOfWeek.Sunday => "Dom", | ||||
|             DayOfWeek.Monday => "Lun", | ||||
|             DayOfWeek.Tuesday => "Mar", | ||||
|             DayOfWeek.Wednesday => "Mie", | ||||
|             DayOfWeek.Thursday => "Jue", | ||||
|             DayOfWeek.Friday => "Vie", | ||||
|             DayOfWeek.Saturday => "Sab", | ||||
|             _ => "" | ||||
|         }; | ||||
|  | ||||
|         private decimal GetPrecioDelDia(Precio precio, DayOfWeek dia) => dia switch | ||||
|         { | ||||
|             DayOfWeek.Sunday => precio.Domingo ?? 0, | ||||
|             DayOfWeek.Monday => precio.Lunes ?? 0, | ||||
|             DayOfWeek.Tuesday => precio.Martes ?? 0, | ||||
|             DayOfWeek.Wednesday => precio.Miercoles ?? 0, | ||||
|             DayOfWeek.Thursday => precio.Jueves ?? 0, | ||||
|             DayOfWeek.Friday => precio.Viernes ?? 0, | ||||
|             DayOfWeek.Saturday => precio.Sabado ?? 0, | ||||
|             _ => 0 | ||||
|         }; | ||||
|  | ||||
|         private decimal CalcularDescuentoPromociones(decimal importeBruto, IEnumerable<Promocion> promociones) | ||||
|         { | ||||
|             return promociones.Where(p => p.TipoEfecto.Contains("Descuento")).Sum(p => | ||||
|                p.TipoEfecto == "DescuentoPorcentajeTotal" | ||||
|                ? (importeBruto * p.ValorEfecto) / 100 | ||||
|                : p.ValorEfecto | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public class FormaPagoService : IFormaPagoService | ||||
|     { | ||||
|         private readonly IFormaPagoRepository _formaPagoRepository; | ||||
|  | ||||
|         public FormaPagoService(IFormaPagoRepository formaPagoRepository) | ||||
|         { | ||||
|             _formaPagoRepository = formaPagoRepository; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<FormaPagoDto>> ObtenerTodos() | ||||
|         { | ||||
|             var formasDePago = await _formaPagoRepository.GetAllAsync(); | ||||
|             return formasDePago.Select(fp => new FormaPagoDto | ||||
|             { | ||||
|                 IdFormaPago = fp.IdFormaPago, | ||||
|                 Nombre = fp.Nombre, | ||||
|                 RequiereCBU = fp.RequiereCBU | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IAjusteService | ||||
|     { | ||||
|         Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta); | ||||
|         Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario); | ||||
|         Task<(bool Exito, string? Error)> ActualizarAjuste(int idAjuste, UpdateAjusteDto updateDto); | ||||
|         Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IDebitoAutomaticoService | ||||
|     { | ||||
|         Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario); | ||||
|         Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| using GestionIntegral.Api.Dtos.Comunicaciones; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IFacturacionService | ||||
|     { | ||||
|         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, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario); | ||||
|         Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IFormaPagoService | ||||
|     { | ||||
|         Task<IEnumerable<FormaPagoDto>> ObtenerTodos(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| // Archivo: GestionIntegral.Api/Services/Suscripciones/IPagoService.cs | ||||
|  | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IPagoService | ||||
|     { | ||||
|         Task<IEnumerable<PagoDto>> ObtenerPagosPorFacturaId(int idFactura); | ||||
|         Task<(PagoDto? Pago, string? Error)> RegistrarPagoManual(CreatePagoDto createDto, int idUsuario); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IPromocionService | ||||
|     { | ||||
|         Task<IEnumerable<PromocionDto>> ObtenerTodas(bool soloActivas); | ||||
|         Task<PromocionDto?> ObtenerPorId(int id); | ||||
|         Task<(PromocionDto? Promocion, string? Error)> Crear(CreatePromocionDto createDto, int idUsuario); | ||||
|         Task<(bool Exito, string? Error)> Actualizar(int id, UpdatePromocionDto updateDto, int idUsuario); | ||||
|     } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user