Compare commits
	
		
			7 Commits
		
	
	
		
			9e248efc84
			...
			Suscripcio
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8c194b8441 | |||
| 1a288fcfa5 | |||
| 7dc0940001 | |||
| 5a806eda38 | |||
| 21c5c1d7d9 | |||
| 899e0a173f | |||
| 9cfe9d012e | 
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -19,9 +19,6 @@ lerna-debug.log* | |||||||
|  |  | ||||||
| # Variables de entorno | # 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.local | ||||||
| .env.development.local | .env.development.local | ||||||
| .env.test.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; | ||||||
| using GestionIntegral.Api.Dtos.Reportes.ViewModels; | using GestionIntegral.Api.Dtos.Reportes.ViewModels; | ||||||
| using QuestPDF.Fluent; | using QuestPDF.Fluent; | ||||||
| using QuestPDF.Helpers; | using QuestPDF.Helpers; | ||||||
| using QuestPDF.Infrastructure; | using QuestPDF.Infrastructure; | ||||||
| using System.Collections.Generic; |  | ||||||
| using System.Globalization; | using System.Globalization; | ||||||
| using System.Linq; |  | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates | 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 GestionIntegral.Api.Services.Reportes; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | 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.Dtos.Reportes; | ||||||
| using GestionIntegral.Api.Data.Repositories.Impresion; | using GestionIntegral.Api.Data.Repositories.Impresion; | ||||||
| using System.IO; |  | ||||||
| using System.Linq; |  | ||||||
| using GestionIntegral.Api.Data.Repositories.Distribucion; | using GestionIntegral.Api.Data.Repositories.Distribucion; | ||||||
| using GestionIntegral.Api.Services.Distribucion; | using GestionIntegral.Api.Services.Distribucion; | ||||||
| using GestionIntegral.Api.Services.Pdf; | using GestionIntegral.Api.Services.Pdf; | ||||||
| @@ -45,6 +38,8 @@ namespace GestionIntegral.Api.Controllers | |||||||
|         private const string PermisoVerReporteConsumoBobinas = "RR007"; |         private const string PermisoVerReporteConsumoBobinas = "RR007"; | ||||||
|         private const string PermisoVerReporteNovedadesCanillas = "RR004"; |         private const string PermisoVerReporteNovedadesCanillas = "RR004"; | ||||||
|         private const string PermisoVerReporteListadoDistMensual = "RR009"; |         private const string PermisoVerReporteListadoDistMensual = "RR009"; | ||||||
|  |         private const string PermisoVerReporteFacturasPublicidad = "RR010"; | ||||||
|  |         private const string PermisoVerReporteDistSuscripciones = "RR011"; | ||||||
|  |  | ||||||
|         public ReportesController( |         public ReportesController( | ||||||
|             IReportesService reportesService, |             IReportesService reportesService, | ||||||
| @@ -1676,5 +1671,88 @@ namespace GestionIntegral.Api.Controllers | |||||||
|                 return StatusCode(500, "Error interno al generar el PDF del reporte."); |                 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(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,4 +1,5 @@ | |||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Comunicaciones; | ||||||
|  | using GestionIntegral.Api.Services.Comunicaciones; | ||||||
| using GestionIntegral.Api.Services.Suscripciones; | using GestionIntegral.Api.Services.Suscripciones; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| @@ -13,14 +14,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones | |||||||
|     { |     { | ||||||
|         private readonly IFacturacionService _facturacionService; |         private readonly IFacturacionService _facturacionService; | ||||||
|         private readonly ILogger<FacturacionController> _logger; |         private readonly ILogger<FacturacionController> _logger; | ||||||
|  |         private readonly IEmailLogService _emailLogService; | ||||||
|  |         private const string PermisoGestionarFacturacion = "SU006"; | ||||||
|  |         private const string PermisoEnviarEmail = "SU009"; | ||||||
|  |  | ||||||
|         // Permiso para generar facturación (a crear en la BD) |         public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger, IEmailLogService emailLogService) | ||||||
|         private const string PermisoGenerarFacturacion = "SU006"; |  | ||||||
|  |  | ||||||
|         public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger) |  | ||||||
|         { |         { | ||||||
|             _facturacionService = facturacionService; |             _facturacionService = facturacionService; | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|  |             _emailLogService = emailLogService; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); |         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||||
| @@ -28,67 +30,96 @@ namespace GestionIntegral.Api.Controllers.Suscripciones | |||||||
|         private int? GetCurrentUserId() |         private int? GetCurrentUserId() | ||||||
|         { |         { | ||||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; |             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; |             return null; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // POST: api/facturacion/{anio}/{mes} |         [HttpPut("{idFactura:int}/numero-factura")] | ||||||
|         [HttpPost("{anio:int}/{mes:int}")] |         [ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|         public async Task<IActionResult> GenerarFacturacion(int anio, int mes) |         public async Task<IActionResult> UpdateNumeroFactura(int idFactura, [FromBody] string numeroFactura) | ||||||
|         { |         { | ||||||
|             if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid(); |             if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); | ||||||
|  |  | ||||||
|             var userId = GetCurrentUserId(); |             var userId = GetCurrentUserId(); | ||||||
|             if (userId == null) return Unauthorized(); |             if (userId == null) return Unauthorized(); | ||||||
|  |  | ||||||
|             if (anio < 2020 || mes < 1 || mes > 12) |             var (exito, error) = await _facturacionService.ActualizarNumeroFactura(idFactura, numeroFactura, userId.Value); | ||||||
|             { |  | ||||||
|                 return BadRequest(new { message = "El año y el mes proporcionados no son válidos." }); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             var (exito, mensaje, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value); |  | ||||||
|  |  | ||||||
|             if (!exito) |             if (!exito) | ||||||
|             { |             { | ||||||
|                 return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje }); |                 if (error != null && error.Contains("no existe")) return NotFound(new { message = error }); | ||||||
|  |                 return BadRequest(new { message = error }); | ||||||
|  |             } | ||||||
|  |             return NoContent(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|             return Ok(new { message = mensaje, facturasGeneradas }); |         [HttpPost("{idFactura:int}/enviar-factura-pdf")] | ||||||
|         } |         public async Task<IActionResult> EnviarFacturaPdf(int idFactura) | ||||||
|  |  | ||||||
|         // GET: api/facturacion/{anio}/{mes} |  | ||||||
|         [HttpGet("{anio:int}/{mes:int}")] |  | ||||||
|         [ProducesResponseType(typeof(IEnumerable<FacturaDto>), StatusCodes.Status200OK)] |  | ||||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] |  | ||||||
|         public async Task<IActionResult> GetFacturas(int anio, int mes) |  | ||||||
|         { |         { | ||||||
|             // Usamos el permiso de generar facturación también para verlas. |             if (!TienePermiso(PermisoEnviarEmail)) return Forbid(); | ||||||
|             if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid(); |             var userId = GetCurrentUserId(); | ||||||
|  |             if (userId == null) return Unauthorized(); | ||||||
|             if (anio < 2020 || mes < 1 || mes > 12) |             var (exito, error, emailDestino) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura, userId.Value); | ||||||
|             { |  | ||||||
|                 return BadRequest(new { message = "El período no es válido." }); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             var facturas = await _facturacionService.ObtenerFacturasPorPeriodo(anio, mes); |  | ||||||
|             return Ok(facturas); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // POST: api/facturacion/{idFactura}/enviar-email |  | ||||||
|         [HttpPost("{idFactura:int}/enviar-email")] |  | ||||||
|         [ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
|         public async Task<IActionResult> EnviarEmail(int idFactura) |  | ||||||
|         { |  | ||||||
|             // Usaremos un nuevo permiso para esta acción |  | ||||||
|             if (!TienePermiso("SU009")) return Forbid(); |  | ||||||
|  |  | ||||||
|             var (exito, error) = await _facturacionService.EnviarFacturaPorEmail(idFactura); |  | ||||||
|  |  | ||||||
|             if (!exito) |             if (!exito) | ||||||
|             { |             { | ||||||
|                 return BadRequest(new { message = error }); |                 return BadRequest(new { message = error }); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             return Ok(new { message = "Email enviado a la cola de procesamiento." }); |             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); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -112,15 +112,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones | |||||||
|             return Ok(promos); |             return Ok(promos); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // POST: api/suscripciones/{idSuscripcion}/promociones/{idPromocion} |         // POST: api/suscripciones/{idSuscripcion}/promociones | ||||||
|         [HttpPost("{idSuscripcion:int}/promociones/{idPromocion:int}")] |         [HttpPost("{idSuscripcion:int}/promociones")] | ||||||
|         public async Task<IActionResult> AsignarPromocion(int idSuscripcion, int idPromocion) |         public async Task<IActionResult> AsignarPromocion(int idSuscripcion, [FromBody] AsignarPromocionDto dto) | ||||||
|         { |         { | ||||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); |             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||||
|             var userId = GetCurrentUserId(); |             var userId = GetCurrentUserId(); | ||||||
|             if (userId == null) return Unauthorized(); |             if (userId == null) return Unauthorized(); | ||||||
|  |  | ||||||
|             var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, idPromocion, userId.Value); |             var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, dto, userId.Value); | ||||||
|             if (!exito) return BadRequest(new { message = error }); |             if (!exito) return BadRequest(new { message = error }); | ||||||
|             return Ok(); |             return Ok(); | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -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<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla); | ||||||
|         Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); |         Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); | ||||||
|         Task<IEnumerable<ListadoDistCanMensualPubDto>> GetReporteMensualPorPublicacionAsync(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 |                 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>(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,8 +1,13 @@ | |||||||
| // Archivo: GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs |  | ||||||
|  |  | ||||||
| using Dapper; | using Dapper; | ||||||
|  | using GestionIntegral.Api.Data; | ||||||
| using GestionIntegral.Api.Models.Suscripciones; | using GestionIntegral.Api.Models.Suscripciones; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
| using System.Data; | using System.Data; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Text; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||||
| { | { | ||||||
| @@ -19,7 +24,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|  |  | ||||||
|         public async Task<Factura?> GetByIdAsync(int idFactura) |         public async Task<Factura?> GetByIdAsync(int idFactura) | ||||||
|         { |         { | ||||||
|             const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura = @IdFactura;"; |             const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura = @idFactura;"; | ||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             return await connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idFactura }); |             return await connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idFactura }); | ||||||
|         } |         } | ||||||
| @@ -31,14 +36,21 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|             return await connection.QueryAsync<Factura>(sql, new { Periodo = periodo }); |             return await connection.QueryAsync<Factura>(sql, new { Periodo = periodo }); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<Factura?> GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction) |         public async Task<Factura?> GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction) | ||||||
|         { |         { | ||||||
|             const string sql = "SELECT TOP 1 * FROM dbo.susc_Facturas WHERE IdSuscripcion = @IdSuscripcion AND Periodo = @Periodo;"; |             const string sql = "SELECT TOP 1 * FROM dbo.susc_Facturas WHERE IdSuscriptor = @IdSuscriptor AND Periodo = @Periodo;"; | ||||||
|             if (transaction == null || transaction.Connection == null) |             if (transaction == null || transaction.Connection == null) | ||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||||
|             } |             } | ||||||
|             return await transaction.Connection.QuerySingleOrDefaultAsync<Factura>(sql, new { IdSuscripcion = idSuscripcion, Periodo = periodo }, transaction); |             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) |         public async Task<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction) | ||||||
| @@ -48,25 +60,21 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||||
|             } |             } | ||||||
|             const string sqlInsert = @" |             const string sqlInsert = @" | ||||||
|                 INSERT INTO dbo.susc_Facturas |                 INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion) | ||||||
|                     (IdSuscripcion, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,  |  | ||||||
|                      DescuentoAplicado, ImporteFinal, Estado) |  | ||||||
|                 OUTPUT INSERTED.* |                 OUTPUT INSERTED.* | ||||||
|                 VALUES |                 VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);"; | ||||||
|                     (@IdSuscripcion, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,  |  | ||||||
|                      @DescuentoAplicado, @ImporteFinal, @Estado);"; |  | ||||||
|  |  | ||||||
|             return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); |             return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<bool> UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction) |         public async Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction) | ||||||
|         { |         { | ||||||
|             if (transaction == null || transaction.Connection == null) |             if (transaction == null || transaction.Connection == null) | ||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||||
|             } |             } | ||||||
|             const string sql = "UPDATE dbo.susc_Facturas SET Estado = @NuevoEstado WHERE IdFactura = @IdFactura;"; |             const string sql = "UPDATE dbo.susc_Facturas SET EstadoPago = @NuevoEstadoPago WHERE IdFactura = @IdFactura;"; | ||||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstado = nuevoEstado, IdFactura = idFactura }, transaction); |             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, idFactura }, transaction); | ||||||
|             return rowsAffected == 1; |             return rowsAffected == 1; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -76,8 +84,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 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, Estado = 'Pendiente de Cobro' WHERE IdFactura = @IdFactura;"; |             const string sql = @" | ||||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, IdFactura = idFactura }, transaction); |                 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; |             return rowsAffected == 1; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -87,59 +99,161 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 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, Estado = 'Enviada a Débito' WHERE IdFactura IN @IdsFacturas;"; |             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); |             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdLoteDebito = idLoteDebito, IdsFacturas = idsFacturas }, transaction); | ||||||
|             return rowsAffected == idsFacturas.Count(); |             return rowsAffected == idsFacturas.Count(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo) |         public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) | ||||||
|         { |         { | ||||||
|             const string sql = @" |             var sqlBuilder = new StringBuilder(@" | ||||||
|                         SELECT  f.*, s.NombreCompleto AS NombreSuscriptor, p.Nombre AS NombrePublicacion |                 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 |                     FROM dbo.susc_Facturas f | ||||||
|                         JOIN dbo.susc_Suscripciones sc ON f.IdSuscripcion = sc.IdSuscripcion |  | ||||||
|                         JOIN dbo.susc_Suscriptores s ON sc.IdSuscriptor = s.IdSuscriptor |  | ||||||
|                         JOIN dbo.dist_dtPublicaciones p ON sc.IdPublicacion = p.Id_Publicacion |  | ||||||
|                     WHERE f.Periodo = @Periodo |                     WHERE f.Periodo = @Periodo | ||||||
|                         ORDER BY s.NombreCompleto; |                 ) | ||||||
|                     "; |                 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 |             try | ||||||
|             { |             { | ||||||
|                 using var connection = _connectionFactory.CreateConnection(); |                 using var connection = _connectionFactory.CreateConnection(); | ||||||
|                 var result = await connection.QueryAsync<Factura, string, string, (Factura, string, string)>( |                 var result = await connection.QueryAsync<Factura, string, int, decimal, (Factura, string, int, decimal)>( | ||||||
|                     sql, |                     sqlBuilder.ToString(), | ||||||
|                     (factura, suscriptor, publicacion) => (factura, suscriptor, publicacion), |                     (factura, suscriptor, idEmpresa, totalPagado) => (factura, suscriptor, idEmpresa, totalPagado), | ||||||
|                     new { Periodo = periodo }, |                     parameters, | ||||||
|                     splitOn: "NombreSuscriptor,NombrePublicacion" |                     splitOn: "NombreSuscriptor,IdEmpresa,TotalPagado" | ||||||
|                 ); |                 ); | ||||||
|                 return result; |                 return result; | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 _logger.LogError(ex, "Error al obtener facturas enriquecidas para el período {Periodo}", periodo); |                 _logger.LogError(ex, "Error al obtener facturas enriquecidas para el período {Periodo}", periodo); | ||||||
|                 return Enumerable.Empty<(Factura, string, string)>(); |                 return Enumerable.Empty<(Factura, string, int, decimal)>(); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction) |         public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction) | ||||||
|         { |         { | ||||||
|             if (transaction == null || transaction.Connection == null) |             if (transaction == null || transaction.Connection == null) | ||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             const string sql = @" |             const string sql = @" | ||||||
|                 UPDATE dbo.susc_Facturas SET |                 UPDATE dbo.susc_Facturas SET | ||||||
|                     Estado = @NuevoEstado, |                     EstadoPago = @NuevoEstadoPago, | ||||||
|                     MotivoRechazo = @MotivoRechazo |                     MotivoRechazo = @MotivoRechazo | ||||||
|                 WHERE IdFactura = @IdFactura;"; |                 WHERE IdFactura = @IdFactura;"; | ||||||
|  |             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, MotivoRechazo = motivoRechazo, idFactura }, transaction); | ||||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync( |  | ||||||
|                 sql, |  | ||||||
|                 new { NuevoEstado = nuevoEstado, MotivoRechazo = motivoRechazo, IdFactura = idFactura }, |  | ||||||
|                 transaction |  | ||||||
|             ); |  | ||||||
|             return rowsAffected == 1; |             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,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); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -6,13 +6,18 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|     public interface IFacturaRepository |     public interface IFacturaRepository | ||||||
|     { |     { | ||||||
|         Task<Factura?> GetByIdAsync(int idFactura); |         Task<Factura?> GetByIdAsync(int idFactura); | ||||||
|  |         Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids); | ||||||
|         Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo); |         Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo); | ||||||
|         Task<Factura?> GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction); |         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<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction); |         Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); |         Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); |         Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); | ||||||
|         Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo); |         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 nuevoEstado, string? motivoRechazo, IDbTransaction transaction); |         Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);    | ||||||
|  |         Task<string?> GetUltimoPeriodoFacturadoAsync(); | ||||||
|  |         Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -7,5 +7,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|     { |     { | ||||||
|         Task<IEnumerable<Pago>> GetByFacturaIdAsync(int idFactura); |         Task<IEnumerable<Pago>> GetByFacturaIdAsync(int idFactura); | ||||||
|         Task<Pago?> CreateAsync(Pago nuevoPago, IDbTransaction transaction); |         Task<Pago?> CreateAsync(Pago nuevoPago, IDbTransaction transaction); | ||||||
|  |         Task<decimal> GetTotalPagadoAprobadoAsync(int idFactura, IDbTransaction transaction); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -10,5 +10,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|         Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction); |         Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateAsync(Promocion promocion, 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, IDbTransaction transaction); | ||||||
|  |         Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -5,13 +5,13 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
| { | { | ||||||
|     public interface ISuscripcionRepository |     public interface ISuscripcionRepository | ||||||
|     { |     { | ||||||
|         Task<IEnumerable<Suscripcion>> GetBySuscriptorIdAsync(int idSuscriptor); |  | ||||||
|         Task<Suscripcion?> GetByIdAsync(int idSuscripcion); |         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<Suscripcion?> CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction); |         Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction); | ||||||
|         Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction); |         Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion); | ||||||
|         Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion); |         Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction); | ||||||
|         Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction); |  | ||||||
|         Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction); |         Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -54,5 +54,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|                 return null; |                 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); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -39,10 +39,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|         public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction) |         public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction) | ||||||
|         { |         { | ||||||
|             const string sql = @" |             const string sql = @" | ||||||
|                 INSERT INTO dbo.susc_Promociones (Descripcion, TipoPromocion, Valor, FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta) |                 INSERT INTO dbo.susc_Promociones | ||||||
|  |                     (Descripcion, TipoEfecto, ValorEfecto, TipoCondicion, ValorCondicion, | ||||||
|  |                     FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta) | ||||||
|                 OUTPUT INSERTED.* |                 OUTPUT INSERTED.* | ||||||
|                 VALUES (@Descripcion, @TipoPromocion, @Valor, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());"; |                 VALUES (@Descripcion, @TipoEfecto, @ValorEfecto, @TipoCondicion, | ||||||
|  |                         @ValorCondicion, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());"; | ||||||
|             if (transaction?.Connection == null) |             if (transaction?.Connection == null) | ||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); |                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||||
| @@ -74,20 +76,43 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|  |  | ||||||
|         public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction) |         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 = @" |             const string sql = @" | ||||||
|                 SELECT p.* FROM dbo.susc_Promociones p |                 SELECT p.*  | ||||||
|  |                 FROM dbo.susc_Promociones p | ||||||
|                 JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion |                 JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion | ||||||
|                 WHERE sp.IdSuscripcion = @IdSuscripcion |                 WHERE sp.IdSuscripcion = @IdSuscripcion | ||||||
|                 AND p.Activa = 1 |                 AND p.Activa = 1 | ||||||
|  |                 -- 1. La promoción general debe estar activa en el período | ||||||
|                 AND p.FechaInicio <= @FechaPeriodo |                 AND p.FechaInicio <= @FechaPeriodo | ||||||
|                   AND (p.FechaFin IS NULL OR p.FechaFin >= @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) |             if (transaction?.Connection == null) | ||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); |                 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); |             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 }); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -47,7 +47,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|          |          | ||||||
|         public async Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction) |         public async Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction) | ||||||
|         { |         { | ||||||
|             // Lógica para determinar el rango del período (ej. '2023-11') |  | ||||||
|             var year = int.Parse(periodo.Split('-')[0]); |             var year = int.Parse(periodo.Split('-')[0]); | ||||||
|             var month = int.Parse(periodo.Split('-')[1]); |             var month = int.Parse(periodo.Split('-')[1]); | ||||||
|             var primerDiaMes = new DateTime(year, month, 1); |             var primerDiaMes = new DateTime(year, month, 1); | ||||||
| @@ -112,30 +111,35 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|             return rowsAffected == 1; |             return rowsAffected == 1; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion) |         public async Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion) | ||||||
|         { |         { | ||||||
|             const string sql = @" |             const string sql = @" | ||||||
|         SELECT p.* FROM dbo.susc_Promociones p |                 SELECT sp.*, p.*  | ||||||
|         JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion |                 FROM dbo.susc_SuscripcionPromociones sp | ||||||
|  |                 JOIN dbo.susc_Promociones p ON sp.IdPromocion = p.IdPromocion | ||||||
|                 WHERE sp.IdSuscripcion = @IdSuscripcion;"; |                 WHERE sp.IdSuscripcion = @IdSuscripcion;"; | ||||||
|              |              | ||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             return await connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion }); |             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(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction) |         public async Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction) | ||||||
|         { |         { | ||||||
|             if (transaction == null || transaction.Connection == null) |             if (transaction == null || transaction.Connection == null) | ||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||||
|             } |             } | ||||||
|             const string sql = @" |             const string sql = @" | ||||||
|         INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno) |                 INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno, VigenciaDesde, VigenciaHasta, FechaAsignacion) | ||||||
|         VALUES (@IdSuscripcion, @IdPromocion, @IdUsuario);"; |                 VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());"; | ||||||
|              |              | ||||||
|             await transaction.Connection.ExecuteAsync(sql, |             await transaction.Connection.ExecuteAsync(sql, asignacion, transaction); | ||||||
|                 new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion, IdUsuario = idUsuario }, |  | ||||||
|                 transaction); |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction) |         public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction) | ||||||
| @@ -145,7 +149,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 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;"; |             const string sql = "DELETE FROM dbo.susc_SuscripcionPromociones WHERE IdSuscripcion = @IdSuscripcion AND IdPromocion = @IdPromocion;"; | ||||||
|             var rows = await transaction.Connection.ExecuteAsync(sql, new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion }, transaction); |             var rows = await transaction.Connection.ExecuteAsync(sql, new { idSuscripcion, idPromocion }, transaction); | ||||||
|             return rows == 1; |             return rows == 1; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | // Archivo: GestionIntegral.Api/Data/Repositories/Usuarios/IUsuarioRepository.cs | ||||||
|  |  | ||||||
| using GestionIntegral.Api.Models.Usuarios; // Para Usuario | using GestionIntegral.Api.Models.Usuarios; // Para Usuario | ||||||
| using GestionIntegral.Api.Dtos.Usuarios.Auditoria; | using GestionIntegral.Api.Dtos.Usuarios.Auditoria; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| @@ -10,6 +12,7 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios | |||||||
|     { |     { | ||||||
|         Task<IEnumerable<Usuario>> GetAllAsync(string? userFilter, string? nombreFilter); |         Task<IEnumerable<Usuario>> GetAllAsync(string? userFilter, string? nombreFilter); | ||||||
|         Task<Usuario?> GetByIdAsync(int id); |         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?> 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<Usuario?> CreateAsync(Usuario nuevoUsuario, int idUsuarioCreador, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateAsync(Usuario usuarioAActualizar, int idUsuarioModificador, 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> DeleteAsync(int id, int idUsuarioModificador, IDbTransaction transaction); | ||||||
|         Task<bool> SetPasswordAsync(int userId, string newHash, string newSalt, bool debeCambiarClave, 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); |         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<IEnumerable<(Usuario Usuario, string NombrePerfil)>> GetAllWithProfileNameAsync(string? userFilter, string? nombreFilter); | ||||||
|         Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id); |         Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id); | ||||||
|         Task<IEnumerable<UsuarioHistorialDto>> GetHistorialByUsuarioIdAsync(int idUsuarioAfectado, DateTime? fechaDesde, DateTime? fechaHasta); |         Task<IEnumerable<UsuarioHistorialDto>> GetHistorialByUsuarioIdAsync(int idUsuarioAfectado, DateTime? fechaDesde, DateTime? fechaHasta); | ||||||
|   | |||||||
| @@ -1,12 +1,8 @@ | |||||||
| using Dapper; | using Dapper; | ||||||
| using GestionIntegral.Api.Models.Usuarios; | using GestionIntegral.Api.Models.Usuarios; | ||||||
| using GestionIntegral.Api.Dtos.Usuarios.Auditoria; | using GestionIntegral.Api.Dtos.Usuarios.Auditoria; | ||||||
| using Microsoft.Extensions.Logging; |  | ||||||
| using System.Collections.Generic; |  | ||||||
| using System.Data; | using System.Data; | ||||||
| using System.Linq; |  | ||||||
| using System.Text; | using System.Text; | ||||||
| using System.Threading.Tasks; |  | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Data.Repositories.Usuarios | namespace GestionIntegral.Api.Data.Repositories.Usuarios | ||||||
| { | { | ||||||
| @@ -88,7 +84,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|         public async Task<Usuario?> GetByIdAsync(int id) |         public async Task<Usuario?> GetByIdAsync(int id) | ||||||
|         { |         { | ||||||
|             const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id = @Id"; |             const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id = @Id"; | ||||||
| @@ -103,6 +98,33 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios | |||||||
|                 return null; |                 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) |         public async Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id) | ||||||
|         { |         { | ||||||
|             const string sql = @" |             const string sql = @" | ||||||
| @@ -128,7 +150,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|         public async Task<Usuario?> GetByUsernameAsync(string username) |         public async Task<Usuario?> GetByUsernameAsync(string username) | ||||||
|         { |         { | ||||||
|             // Esta es la misma que en AuthRepository, si se unifican, se puede eliminar una. |             // Esta es la misma que en AuthRepository, si se unifican, se puede eliminar una. | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ | |||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> |     <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> | ||||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> |     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||||
|  |     <PackageReference Include="DotNetEnv" Version="3.1.1" /> | ||||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> |     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> |     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" /> |     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" /> | ||||||
|   | |||||||
| @@ -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,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; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -7,22 +7,25 @@ namespace GestionIntegral.Api.Dtos.Suscripciones | |||||||
| { | { | ||||||
|     public class CreatePromocionDto |     public class CreatePromocionDto | ||||||
|     { |     { | ||||||
|         [Required(ErrorMessage = "La descripción es obligatoria.")] |         [Required] | ||||||
|         [StringLength(200)] |         [StringLength(200)] | ||||||
|         public string Descripcion { get; set; } = string.Empty; |         public string Descripcion { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         [Required(ErrorMessage = "El tipo de promoción es obligatorio.")] |         [Required] | ||||||
|         public string TipoPromocion { get; set; } = string.Empty; |         public string TipoEfecto { get; set; } = string.Empty; // Corregido | ||||||
|  |  | ||||||
|         [Required(ErrorMessage = "El valor es obligatorio.")] |         [Required] | ||||||
|         [Range(0.01, 99999999.99, ErrorMessage = "El valor debe ser positivo.")] |         [Range(0, 99999999.99)] // Se permite 0 para bonificaciones | ||||||
|         public decimal Valor { get; set; } |         public decimal ValorEfecto { get; set; } // Corregido | ||||||
|  |  | ||||||
|         [Required(ErrorMessage = "La fecha de inicio es obligatoria.")] |         [Required] | ||||||
|  |         public string TipoCondicion { get; set; } = string.Empty; | ||||||
|  |          | ||||||
|  |         public int? ValorCondicion { get; set; } | ||||||
|  |  | ||||||
|  |         [Required] | ||||||
|         public DateTime FechaInicio { get; set; } |         public DateTime FechaInicio { get; set; } | ||||||
|  |  | ||||||
|         public DateTime? FechaFin { get; set; } |         public DateTime? FechaFin { get; set; } | ||||||
|          |  | ||||||
|         public bool Activa { get; set; } = true; |         public bool Activa { get; set; } = true; | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | // Archivo: GestionIntegral.Api/Dtos/Suscripciones/CreateSuscriptorDto.cs | ||||||
|  |  | ||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | namespace GestionIntegral.Api.Dtos.Suscripciones | ||||||
| @@ -13,6 +15,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones | |||||||
|         public string? Email { get; set; } |         public string? Email { get; set; } | ||||||
|  |  | ||||||
|         [StringLength(50)] |         [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; } |         public string? Telefono { get; set; } | ||||||
|  |  | ||||||
|         [Required(ErrorMessage = "La dirección es obligatoria.")] |         [Required(ErrorMessage = "La dirección es obligatoria.")] | ||||||
| @@ -25,9 +28,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones | |||||||
|  |  | ||||||
|         [Required(ErrorMessage = "El número de documento es obligatorio.")] |         [Required(ErrorMessage = "El número de documento es obligatorio.")] | ||||||
|         [StringLength(11)] |         [StringLength(11)] | ||||||
|  |         [RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")] | ||||||
|         public string NroDocumento { get; set; } = string.Empty; |         public string NroDocumento { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         [StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")] |         [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; } |         public string? CBU { get; set; } | ||||||
|  |  | ||||||
|         [Required(ErrorMessage = "La forma de pago es obligatoria.")] |         [Required(ErrorMessage = "La forma de pago es obligatoria.")] | ||||||
|   | |||||||
| @@ -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>(); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,22 +1,25 @@ | |||||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | namespace GestionIntegral.Api.Dtos.Suscripciones | ||||||
| { | { | ||||||
|     /// <summary> |     public class FacturaDetalleDto | ||||||
|     /// DTO para enviar la información de una factura generada al frontend. |     { | ||||||
|     /// Incluye datos enriquecidos como nombres para facilitar su visualización en la UI. |         public string Descripcion { get; set; } = string.Empty; | ||||||
|     /// </summary> |         public decimal ImporteNeto { get; set; } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public class FacturaDto |     public class FacturaDto | ||||||
|     { |     { | ||||||
|         public int IdFactura { get; set; } |         public int IdFactura { get; set; } | ||||||
|         public int IdSuscripcion { get; set; } |         public int IdSuscriptor { get; set; } | ||||||
|         public string Periodo { get; set; } = string.Empty; // Formato "YYYY-MM" |         public string Periodo { get; set; } = string.Empty; | ||||||
|         public string FechaEmision { get; set; } = string.Empty; // Formato "yyyy-MM-dd" |         public string FechaEmision { get; set; } = string.Empty; | ||||||
|         public string FechaVencimiento { get; set; } = string.Empty; // Formato "yyyy-MM-dd" |         public string FechaVencimiento { get; set; } = string.Empty; | ||||||
|         public decimal ImporteFinal { get; set; } |         public decimal ImporteFinal { get; set; } | ||||||
|         public string Estado { get; set; } = string.Empty; |         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? NumeroFactura { get; set; } | ||||||
|  |  | ||||||
|         // Datos enriquecidos para la UI, poblados por el servicio |  | ||||||
|         public string NombreSuscriptor { get; set; } = string.Empty; |         public string NombreSuscriptor { get; set; } = string.Empty; | ||||||
|         public string NombrePublicacion { get; set; } = string.Empty; |         public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -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; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -4,9 +4,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones | |||||||
|     { |     { | ||||||
|         public int IdPromocion { get; set; } |         public int IdPromocion { get; set; } | ||||||
|         public string Descripcion { get; set; } = string.Empty; |         public string Descripcion { get; set; } = string.Empty; | ||||||
|         public string TipoPromocion { get; set; } = string.Empty; |         public string TipoEfecto { get; set; } = string.Empty; | ||||||
|         public decimal Valor { get; set; } |         public decimal ValorEfecto { get; set; } | ||||||
|         public string FechaInicio { get; set; } = string.Empty; // yyyy-MM-dd |         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 string? FechaFin { get; set; } | ||||||
|         public bool Activa { 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,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; | ||||||
|  | } | ||||||
| @@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations; | |||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | namespace GestionIntegral.Api.Dtos.Suscripciones | ||||||
| { | { | ||||||
|     // Es idéntico al CreateDto, pero se mantiene separado por si las reglas de validación cambian. |  | ||||||
|     public class UpdateSuscriptorDto |     public class UpdateSuscriptorDto | ||||||
|     { |     { | ||||||
|         [Required(ErrorMessage = "El nombre completo es obligatorio.")] |         [Required(ErrorMessage = "El nombre completo es obligatorio.")] | ||||||
| @@ -14,6 +13,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones | |||||||
|         public string? Email { get; set; } |         public string? Email { get; set; } | ||||||
|  |  | ||||||
|         [StringLength(50)] |         [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; } |         public string? Telefono { get; set; } | ||||||
|  |  | ||||||
|         [Required(ErrorMessage = "La dirección es obligatoria.")] |         [Required(ErrorMessage = "La dirección es obligatoria.")] | ||||||
| @@ -26,9 +26,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones | |||||||
|  |  | ||||||
|         [Required(ErrorMessage = "El número de documento es obligatorio.")] |         [Required(ErrorMessage = "El número de documento es obligatorio.")] | ||||||
|         [StringLength(11)] |         [StringLength(11)] | ||||||
|  |         [RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")] | ||||||
|         public string NroDocumento { get; set; } = string.Empty; |         public string NroDocumento { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         [StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")] |         [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; } |         public string? CBU { get; set; } | ||||||
|  |  | ||||||
|         [Required(ErrorMessage = "La forma de pago es obligatoria.")] |         [Required(ErrorMessage = "La forma de pago es obligatoria.")] | ||||||
|   | |||||||
							
								
								
									
										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; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -3,14 +3,15 @@ namespace GestionIntegral.Api.Models.Suscripciones | |||||||
|     public class Factura |     public class Factura | ||||||
|     { |     { | ||||||
|         public int IdFactura { get; set; } |         public int IdFactura { get; set; } | ||||||
|         public int IdSuscripcion { get; set; } |         public int IdSuscriptor { get; set; } | ||||||
|         public string Periodo { get; set; } = string.Empty; |         public string Periodo { get; set; } = string.Empty; | ||||||
|         public DateTime FechaEmision { get; set; } |         public DateTime FechaEmision { get; set; } | ||||||
|         public DateTime FechaVencimiento { get; set; } |         public DateTime FechaVencimiento { get; set; } | ||||||
|         public decimal ImporteBruto { get; set; } |         public decimal ImporteBruto { get; set; } | ||||||
|         public decimal DescuentoAplicado { get; set; } |         public decimal DescuentoAplicado { get; set; } | ||||||
|         public decimal ImporteFinal { get; set; } |         public decimal ImporteFinal { get; set; } | ||||||
|         public string Estado { get; set; } = string.Empty; |         public string EstadoPago { get; set; } = string.Empty; | ||||||
|  |         public string EstadoFacturacion { get; set; } = string.Empty; | ||||||
|         public string? NumeroFactura { get; set; } |         public string? NumeroFactura { get; set; } | ||||||
|         public int? IdLoteDebito { get; set; } |         public int? IdLoteDebito { get; set; } | ||||||
|         public string? MotivoRechazo { 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; } | ||||||
|  | } | ||||||
| @@ -4,8 +4,10 @@ namespace GestionIntegral.Api.Models.Suscripciones | |||||||
|     { |     { | ||||||
|         public int IdPromocion { get; set; } |         public int IdPromocion { get; set; } | ||||||
|         public string Descripcion { get; set; } = string.Empty; |         public string Descripcion { get; set; } = string.Empty; | ||||||
|         public string TipoPromocion { get; set; } = string.Empty; |         public string TipoEfecto { get; set; } = string.Empty; // Nuevo nombre | ||||||
|         public decimal Valor { get; set; } |         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 FechaInicio { get; set; } | ||||||
|         public DateTime? FechaFin { get; set; } |         public DateTime? FechaFin { get; set; } | ||||||
|         public bool Activa { get; set; } |         public bool Activa { get; set; } | ||||||
|   | |||||||
| @@ -6,5 +6,7 @@ namespace GestionIntegral.Api.Models.Suscripciones | |||||||
|         public int IdPromocion { get; set; } |         public int IdPromocion { get; set; } | ||||||
|         public DateTime FechaAsignacion { get; set; } |         public DateTime FechaAsignacion { get; set; } | ||||||
|         public int IdUsuarioAsigno { get; set; } |         public int IdUsuarioAsigno { get; set; } | ||||||
|  |         public DateTime VigenciaDesde { get; set; } | ||||||
|  |         public DateTime? VigenciaHasta { get; set; } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -22,6 +22,11 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones; | |||||||
| using GestionIntegral.Api.Services.Suscripciones; | using GestionIntegral.Api.Services.Suscripciones; | ||||||
| using GestionIntegral.Api.Models.Comunicaciones; | using GestionIntegral.Api.Models.Comunicaciones; | ||||||
| using GestionIntegral.Api.Services.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); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
| @@ -112,6 +117,8 @@ builder.Services.AddScoped<IFacturaRepository, FacturaRepository>(); | |||||||
| builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>(); | builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>(); | ||||||
| builder.Services.AddScoped<IPagoRepository, PagoRepository>(); | builder.Services.AddScoped<IPagoRepository, PagoRepository>(); | ||||||
| builder.Services.AddScoped<IPromocionRepository, PromocionRepository>(); | builder.Services.AddScoped<IPromocionRepository, PromocionRepository>(); | ||||||
|  | builder.Services.AddScoped<IAjusteRepository, AjusteRepository>(); | ||||||
|  | builder.Services.AddScoped<IFacturaDetalleRepository, FacturaDetalleRepository>(); | ||||||
|  |  | ||||||
| builder.Services.AddScoped<IFormaPagoService, FormaPagoService>(); | builder.Services.AddScoped<IFormaPagoService, FormaPagoService>(); | ||||||
| builder.Services.AddScoped<ISuscriptorService, SuscriptorService>(); | builder.Services.AddScoped<ISuscriptorService, SuscriptorService>(); | ||||||
| @@ -120,10 +127,14 @@ builder.Services.AddScoped<IFacturacionService, FacturacionService>(); | |||||||
| builder.Services.AddScoped<IDebitoAutomaticoService, DebitoAutomaticoService>(); | builder.Services.AddScoped<IDebitoAutomaticoService, DebitoAutomaticoService>(); | ||||||
| builder.Services.AddScoped<IPagoService, PagoService>(); | builder.Services.AddScoped<IPagoService, PagoService>(); | ||||||
| builder.Services.AddScoped<IPromocionService, PromocionService>(); | builder.Services.AddScoped<IPromocionService, PromocionService>(); | ||||||
|  | builder.Services.AddScoped<IAjusteService, AjusteService>(); | ||||||
|  |  | ||||||
| // --- Comunicaciones --- | // --- Comunicaciones --- | ||||||
| builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings")); | builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings")); | ||||||
| builder.Services.AddTransient<IEmailService, EmailService>(); | builder.Services.AddTransient<IEmailService, EmailService>(); | ||||||
|  | builder.Services.AddScoped<IEmailLogRepository, EmailLogRepository>(); | ||||||
|  | builder.Services.AddScoped<IEmailLogService, EmailLogService>(); | ||||||
|  | builder.Services.AddScoped<ILoteDeEnvioRepository, LoteDeEnvioRepository>(); | ||||||
|  |  | ||||||
| // --- SERVICIO DE HEALTH CHECKS --- | // --- SERVICIO DE HEALTH CHECKS --- | ||||||
| // Añadimos una comprobación específica para SQL Server. | // Añadimos una comprobación específica para SQL Server. | ||||||
|   | |||||||
| @@ -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" | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using GestionIntegral.Api.Data.Repositories.Comunicaciones; | ||||||
| using GestionIntegral.Api.Models.Comunicaciones; | using GestionIntegral.Api.Models.Comunicaciones; | ||||||
| using MailKit.Net.Smtp; | using MailKit.Net.Smtp; | ||||||
| using MailKit.Security; | using MailKit.Security; | ||||||
| @@ -10,14 +11,23 @@ namespace GestionIntegral.Api.Services.Comunicaciones | |||||||
|     { |     { | ||||||
|         private readonly MailSettings _mailSettings; |         private readonly MailSettings _mailSettings; | ||||||
|         private readonly ILogger<EmailService> _logger; |         private readonly ILogger<EmailService> _logger; | ||||||
|  |         private readonly IEmailLogRepository _emailLogRepository; | ||||||
|  |  | ||||||
|         public EmailService(IOptions<MailSettings> mailSettings, ILogger<EmailService> logger) |         public EmailService( | ||||||
|  |             IOptions<MailSettings> mailSettings,  | ||||||
|  |             ILogger<EmailService> logger, | ||||||
|  |             IEmailLogRepository emailLogRepository) | ||||||
|         { |         { | ||||||
|             _mailSettings = mailSettings.Value; |             _mailSettings = mailSettings.Value; | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|  |             _emailLogRepository = emailLogRepository; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml) |         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(); |             var email = new MimeMessage(); | ||||||
|             email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail); |             email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail); | ||||||
| @@ -26,25 +36,102 @@ namespace GestionIntegral.Api.Services.Comunicaciones | |||||||
|             email.Subject = asunto; |             email.Subject = asunto; | ||||||
|  |  | ||||||
|             var builder = new BodyBuilder { HtmlBody = cuerpoHtml }; |             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(); |             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(); |             using var smtp = new SmtpClient(); | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); |                 await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); | ||||||
|                 await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); |                 await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); | ||||||
|                 await smtp.SendAsync(email); |                 await smtp.SendAsync(emailMessage); | ||||||
|                 _logger.LogInformation("Email enviado exitosamente a {Destinatario}", destinatarioEmail); |                  | ||||||
|  |                 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) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 _logger.LogError(ex, "Error al enviar email a {Destinatario}", destinatarioEmail); |                 _logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); | ||||||
|                 throw; // Relanzar para que el servicio que lo llamó sepa que falló |                 log.Estado = "Fallido"; | ||||||
|  |                 log.Error = ex.Message; | ||||||
|  |                 throw; | ||||||
|             } |             } | ||||||
|             finally |             finally | ||||||
|  |             { | ||||||
|  |                 if (smtp.IsConnected) | ||||||
|                 { |                 { | ||||||
|                     await smtp.DisconnectAsync(true); |                     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); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -2,6 +2,54 @@ namespace GestionIntegral.Api.Services.Comunicaciones | |||||||
| { | { | ||||||
|     public interface IEmailService |     public interface IEmailService | ||||||
|     { |     { | ||||||
|         Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml); |         /// <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, |             IEnumerable<SaldoDto> Saldos, | ||||||
|             string? Error |             string? Error | ||||||
|         )> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta); |         )> 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<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<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<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.Reportes; | ||||||
|  | using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||||
| using GestionIntegral.Api.Dtos.Reportes; | 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 | namespace GestionIntegral.Api.Services.Reportes | ||||||
| { | { | ||||||
|     public class ReportesService : IReportesService |     public class ReportesService : IReportesService | ||||||
|     { |     { | ||||||
|         private readonly IReportesRepository _reportesRepository; |         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; |         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; |             _reportesRepository = reportesRepository; | ||||||
|  |             _facturaRepository = facturaRepository; | ||||||
|  |             _facturaDetalleRepository = facturaDetalleRepository; | ||||||
|  |             _publicacionRepository = publicacionRepository; | ||||||
|  |             _empresaRepository = empresaRepository; | ||||||
|  |             _suscriptorRepository = suscriptorRepository; | ||||||
|  |             _suscripcionRepository = suscripcionRepository; | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -520,5 +530,49 @@ namespace GestionIntegral.Api.Services.Reportes | |||||||
|                 return (Enumerable.Empty<ListadoDistCanMensualPubDto>(), "Error al obtener datos del reporte (por publicación)."); |                 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."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,16 +1,8 @@ | |||||||
| // Archivo: GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs |  | ||||||
|  |  | ||||||
| using GestionIntegral.Api.Data; | using GestionIntegral.Api.Data; | ||||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||||
| using GestionIntegral.Api.Models.Suscripciones; | using GestionIntegral.Api.Models.Suscripciones; | ||||||
| using Microsoft.Extensions.Logging; |  | ||||||
| using System; |  | ||||||
| using System.Data; | using System.Data; | ||||||
| using System.Globalization; |  | ||||||
| using System.Linq; |  | ||||||
| using System.Text; | using System.Text; | ||||||
| using System.Threading.Tasks; |  | ||||||
| using System.Collections.Generic; |  | ||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Suscripciones; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Suscripciones | namespace GestionIntegral.Api.Services.Suscripciones | ||||||
| @@ -19,21 +11,18 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|     { |     { | ||||||
|         private readonly IFacturaRepository _facturaRepository; |         private readonly IFacturaRepository _facturaRepository; | ||||||
|         private readonly ISuscriptorRepository _suscriptorRepository; |         private readonly ISuscriptorRepository _suscriptorRepository; | ||||||
|         private readonly ISuscripcionRepository _suscripcionRepository; |  | ||||||
|         private readonly ILoteDebitoRepository _loteDebitoRepository; |         private readonly ILoteDebitoRepository _loteDebitoRepository; | ||||||
|         private readonly IFormaPagoRepository _formaPagoRepository; |         private readonly IFormaPagoRepository _formaPagoRepository; | ||||||
|         private readonly IPagoRepository _pagoRepository; |         private readonly IPagoRepository _pagoRepository; | ||||||
|         private readonly DbConnectionFactory _connectionFactory; |         private readonly DbConnectionFactory _connectionFactory; | ||||||
|         private readonly ILogger<DebitoAutomaticoService> _logger; |         private readonly ILogger<DebitoAutomaticoService> _logger; | ||||||
|  |  | ||||||
|         // --- CONSTANTES DEL BANCO (Mover a appsettings.json si es necesario) --- |         private const string NRO_PRESTACION = "123456"; | ||||||
|         private const string NRO_PRESTACION = "123456"; // Nro. de prestación asignado por el banco |         private const string ORIGEN_EMPRESA = "ELDIA"; | ||||||
|         private const string ORIGEN_EMPRESA = "ELDIA";   // Nombre de la empresa (7 chars) |  | ||||||
|  |  | ||||||
|         public DebitoAutomaticoService( |         public DebitoAutomaticoService( | ||||||
|             IFacturaRepository facturaRepository, |             IFacturaRepository facturaRepository, | ||||||
|             ISuscriptorRepository suscriptorRepository, |             ISuscriptorRepository suscriptorRepository, | ||||||
|             ISuscripcionRepository suscripcionRepository, |  | ||||||
|             ILoteDebitoRepository loteDebitoRepository, |             ILoteDebitoRepository loteDebitoRepository, | ||||||
|             IFormaPagoRepository formaPagoRepository, |             IFormaPagoRepository formaPagoRepository, | ||||||
|             IPagoRepository pagoRepository, |             IPagoRepository pagoRepository, | ||||||
| @@ -42,7 +31,6 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|         { |         { | ||||||
|             _facturaRepository = facturaRepository; |             _facturaRepository = facturaRepository; | ||||||
|             _suscriptorRepository = suscriptorRepository; |             _suscriptorRepository = suscriptorRepository; | ||||||
|             _suscripcionRepository = suscripcionRepository; |  | ||||||
|             _loteDebitoRepository = loteDebitoRepository; |             _loteDebitoRepository = loteDebitoRepository; | ||||||
|             _formaPagoRepository = formaPagoRepository; |             _formaPagoRepository = formaPagoRepository; | ||||||
|             _pagoRepository = pagoRepository; |             _pagoRepository = pagoRepository; | ||||||
| @@ -52,6 +40,11 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|         public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) |         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 periodo = $"{anio}-{mes:D2}"; | ||||||
|             var fechaGeneracion = DateTime.Now; |             var fechaGeneracion = DateTime.Now; | ||||||
|  |  | ||||||
| @@ -61,9 +54,7 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 // Buscamos facturas que están listas para ser enviadas al cobro. |  | ||||||
|                 var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction); |                 var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction); | ||||||
|  |  | ||||||
|                 if (!facturasParaDebito.Any()) |                 if (!facturasParaDebito.Any()) | ||||||
|                 { |                 { | ||||||
|                     return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado."); |                     return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado."); | ||||||
| @@ -71,9 +62,10 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); |                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); | ||||||
|                 var cantidadRegistros = facturasParaDebito.Count(); |                 var cantidadRegistros = facturasParaDebito.Count(); | ||||||
|                 var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt"; |  | ||||||
|                  |                  | ||||||
|                 // 1. Crear el Lote de Débito |                 // Se utiliza la variable 'identificacionArchivo' para nombrar el archivo. | ||||||
|  |                 var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt"; | ||||||
|  |  | ||||||
|                 var nuevoLote = new LoteDebito |                 var nuevoLote = new LoteDebito | ||||||
|                 { |                 { | ||||||
|                     Periodo = periodo, |                     Periodo = periodo, | ||||||
| @@ -85,18 +77,16 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                 var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction); |                 var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction); | ||||||
|                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); |                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); | ||||||
|  |  | ||||||
|                 // 2. Generar el contenido del archivo |  | ||||||
|                 var sb = new StringBuilder(); |                 var sb = new StringBuilder(); | ||||||
|                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros)); |                 // Se pasa la 'identificacionArchivo' al método que crea el Header. | ||||||
|  |                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||||
|                 foreach (var item in facturasParaDebito) |                 foreach (var item in facturasParaDebito) | ||||||
|                 { |                 { | ||||||
|                     sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); |                     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)); | ||||||
|  |  | ||||||
|                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros)); |  | ||||||
|  |  | ||||||
|                 // 3. Actualizar las facturas con el ID del lote |  | ||||||
|                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); |                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); | ||||||
|                 bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction); |                 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."); |                 if (!actualizadas) throw new DataException("No se pudieron actualizar las facturas con la información del lote."); | ||||||
| @@ -115,18 +105,19 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|         private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction) |         private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction) | ||||||
|         { |         { | ||||||
|             // Idealmente, esto debería estar en el repositorio para optimizar la consulta. |  | ||||||
|             // Por simplicidad del ejemplo, lo hacemos aquí. |  | ||||||
|             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); |             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); | ||||||
|             var resultado = new List<(Factura, Suscriptor)>(); |             var resultado = new List<(Factura, Suscriptor)>(); | ||||||
|  |  | ||||||
|             foreach (var f in facturas.Where(fa => fa.Estado == "Pendiente de Cobro")) |             foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente")) | ||||||
|             { |             { | ||||||
|                 var suscripcion = await _suscripcionRepository.GetByIdAsync(f.IdSuscripcion); |                 var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor); | ||||||
|                 if (suscripcion == null) continue; |  | ||||||
|  |  | ||||||
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor); |                 // Se valida que el CBU de Banelco (22 caracteres) exista antes de intentar la conversión. | ||||||
|                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue; |                 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); |                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); | ||||||
|                 if (formaPago != null && formaPago.RequiereCBU) |                 if (formaPago != null && formaPago.RequiereCBU) | ||||||
| @@ -137,83 +128,119 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             return resultado; |             return resultado; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // --- Métodos de Formateo de Campos --- |         // Lógica de conversión de CBU. | ||||||
|         private string FormatString(string? value, int length) => (value ?? "").PadRight(length).Substring(0, length); |         private string ConvertirCbuBanelcoASnp(string cbu22) | ||||||
|         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); |         { | ||||||
|  |             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);  | ||||||
|  |             } | ||||||
|  |  | ||||||
|         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros) |             // 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(); |             var sb = new StringBuilder(); | ||||||
|             sb.Append("00"); // Tipo de Registro |             sb.Append("00"); // Tipo de Registro Header | ||||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); |             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||||
|             sb.Append("C"); // Servicio |             sb.Append("C"); // Servicio: Sistema Nacional de Pagos | ||||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); |             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||||
|             sb.Append("1"); // Identificación de Archivo (ej. '1' para el primer envío del día) |             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo | ||||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); |             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); // 12 enteros + 2 decimales |             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); |             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||||
|             sb.Append(FormatString("", 304)); // Libre |             sb.Append(FormatString("", 304)); | ||||||
|             sb.Append("\r\n"); |             sb.Append("\r\n"); | ||||||
|             return sb.ToString(); |             return sb.ToString(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) |         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(); |             var sb = new StringBuilder(); | ||||||
|             sb.Append("0101"); // Tipo de Registro |             sb.Append("0370"); // Tipo de Registro Detalle (Orden de Débito) | ||||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación Cliente |             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación de Cliente | ||||||
|             sb.Append(FormatString(suscriptor.CBU, 26)); // CBU |             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. | ||||||
|             // Referencia Unívoca: Usaremos ID Factura para asegurar unicidad |             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); | ||||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); |             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); | ||||||
|  |             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento | ||||||
|             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); // Fecha 1er Vto |             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento | ||||||
|             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); // Importe 1er Vto |             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento | ||||||
|  |             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento | ||||||
|             // Campos opcionales o con valores fijos |  | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vto |  | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vto |  | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vto |  | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vto |  | ||||||
|             sb.Append("0"); // Moneda (0 = Pesos) |             sb.Append("0"); // Moneda (0 = Pesos) | ||||||
|             sb.Append(FormatString("", 3)); // Motivo Rechazo |             sb.Append(FormatString("", 3)); // Motivo Rechazo (vacío en el envío) | ||||||
|             sb.Append(FormatString(suscriptor.TipoDocumento, 4)); |             sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4)); | ||||||
|             sb.Append(FormatString(suscriptor.NroDocumento, 11)); |             sb.Append(FormatString(suscriptor.NroDocumento, 11)); | ||||||
|  |  | ||||||
|             // El resto son campos opcionales que rellenamos con espacios/ceros |  | ||||||
|             sb.Append(FormatString("", 22)); // Nueva ID Cliente |             sb.Append(FormatString("", 22)); // Nueva ID Cliente | ||||||
|             sb.Append(FormatNumeric(0, 26)); // Nuevo CBU |             sb.Append(FormatString("", 26)); // Nueva CBU | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo |             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vto |             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vencimiento | ||||||
|             sb.Append(FormatString("", 22)); // ID Cuenta Anterior |             sb.Append(FormatString("", 22)); // Identificación Cuenta Anterior | ||||||
|             sb.Append(FormatString("", 40)); // Mensaje ATM |             sb.Append(FormatString("", 40)); // Mensaje ATM | ||||||
|             sb.Append(FormatString($"Suscripcion {factura.Periodo}", 10)); // Concepto Factura |             sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); // Concepto Factura | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro |             sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Cobrado |             sb.Append(FormatNumeric(0, 14)); // Importe Cobrado | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Acreditación |             sb.Append(FormatNumeric(0, 8)); // Fecha de Acreditamiento | ||||||
|             sb.Append(FormatString("", 26)); // Libre |             sb.Append(FormatString("", 26)); // Libre | ||||||
|             sb.Append("\r\n"); |             sb.Append("\r\n"); | ||||||
|             return sb.ToString(); |             return sb.ToString(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros) |         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||||
|         { |         { | ||||||
|             var sb = new StringBuilder(); |             var sb = new StringBuilder(); | ||||||
|             sb.Append("99"); // Tipo de Registro |             sb.Append("99"); // Tipo de Registro Trailer | ||||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); |             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||||
|             sb.Append("C"); // Servicio |             sb.Append("C"); // Servicio: Sistema Nacional de Pagos | ||||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); |             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||||
|             sb.Append("1"); // Identificación de Archivo |             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo | ||||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); |             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); |             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); |             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||||
|             sb.Append(FormatString("", 304)); // Libre |             sb.Append(FormatString("", 304)); | ||||||
|             // No se añade \r\n al final del último registro |             // La última línea del archivo no lleva salto de línea (\r\n). | ||||||
|             return sb.ToString(); |             return sb.ToString(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario) |         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(); |             var respuesta = new ProcesamientoLoteResponseDto(); | ||||||
|             if (archivo == null || archivo.Length == 0) |             if (archivo == null || archivo.Length == 0) | ||||||
|             { |             { | ||||||
| @@ -231,28 +258,17 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                 string? linea; |                 string? linea; | ||||||
|                 while ((linea = await reader.ReadLineAsync()) != null) |                 while ((linea = await reader.ReadLineAsync()) != null) | ||||||
|                 { |                 { | ||||||
|                     // Ignorar header/trailer si los hubiera (basado en el formato real) |  | ||||||
|                     if (linea.Length < 20) continue; |                     if (linea.Length < 20) continue; | ||||||
|  |  | ||||||
|                     respuesta.TotalRegistrosLeidos++; |                     respuesta.TotalRegistrosLeidos++; | ||||||
|  |  | ||||||
|                     // ================================================================= |  | ||||||
|                     // === ESTA ES LA LÓGICA DE PARSEO QUE SE DEBE AJUSTAR === |  | ||||||
|                     // === CON EL FORMATO REAL DEL ARCHIVO DE RESPUESTA    === |  | ||||||
|                     // ================================================================= |  | ||||||
|                     // Asunción: Pos 1-15: Referencia, Pos 16-17: Estado, Pos 18-20: Rechazo |  | ||||||
|                     var referencia = linea.Substring(0, 15).Trim(); |                     var referencia = linea.Substring(0, 15).Trim(); | ||||||
|                     var estadoProceso = linea.Substring(15, 2).Trim(); |                     var estadoProceso = linea.Substring(15, 2).Trim(); | ||||||
|                     var motivoRechazo = linea.Substring(17, 3).Trim(); |                     var motivoRechazo = linea.Substring(17, 3).Trim(); | ||||||
|                     // Asumimos que podemos extraer el IdFactura de la referencia |  | ||||||
|                     if (!int.TryParse(referencia.Replace("SUSC-", ""), out int idFactura)) |                     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}'."); |                         respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'."); | ||||||
|                         continue; |                         continue; | ||||||
|                     } |                     } | ||||||
|                     // ================================================================= |  | ||||||
|                     // === FIN DE LA LÓGICA DE PARSEO                         === |  | ||||||
|                     // ================================================================= |  | ||||||
|  |  | ||||||
|                     var factura = await _facturaRepository.GetByIdAsync(idFactura); |                     var factura = await _facturaRepository.GetByIdAsync(idFactura); | ||||||
|                     if (factura == null) |                     if (factura == null) | ||||||
| @@ -264,27 +280,24 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                     var nuevoPago = new Pago |                     var nuevoPago = new Pago | ||||||
|                     { |                     { | ||||||
|                         IdFactura = idFactura, |                         IdFactura = idFactura, | ||||||
|                         FechaPago = DateTime.Now.Date, // O la fecha que venga en el archivo |                         FechaPago = DateTime.Now.Date, | ||||||
|                         IdFormaPago = 1, // 1 = Débito Automático |                         IdFormaPago = 1, // Se asume una forma de pago para el débito. | ||||||
|                         Monto = factura.ImporteFinal, |                         Monto = factura.ImporteFinal, | ||||||
|                         IdUsuarioRegistro = idUsuario, |                         IdUsuarioRegistro = idUsuario, | ||||||
|                         Referencia = $"Lote {factura.IdLoteDebito} - Banco" |                         Referencia = $"Lote {factura.IdLoteDebito} - Banco" | ||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
|                     if (estadoProceso == "AP") // "AP" = Aprobado (Asunción) |                     if (estadoProceso == "AP") | ||||||
|                     { |                     { | ||||||
|                         nuevoPago.Estado = "Aprobado"; |                         nuevoPago.Estado = "Aprobado"; | ||||||
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction); |                         await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||||
|                         await _facturaRepository.UpdateEstadoAsync(idFactura, "Pagada", transaction); |                         await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction); | ||||||
|                         respuesta.PagosAprobados++; |                         respuesta.PagosAprobados++; | ||||||
|                     } |                     } | ||||||
|                     else // Asumimos que cualquier otra cosa es Rechazado |                     else | ||||||
|                     { |                     { | ||||||
|                         nuevoPago.Estado = "Rechazado"; |                         nuevoPago.Estado = "Rechazado"; | ||||||
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction); |                         await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||||
|                         factura.Estado = "Rechazada"; |  | ||||||
|                         factura.MotivoRechazo = motivoRechazo; |  | ||||||
|                         // Necesitamos un método en el repo para actualizar estado y motivo |  | ||||||
|                         await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction); |                         await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction); | ||||||
|                         respuesta.PagosRechazados++; |                         respuesta.PagosRechazados++; | ||||||
|                     } |                     } | ||||||
|   | |||||||
| @@ -4,49 +4,103 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones; | |||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Suscripciones; | ||||||
| using GestionIntegral.Api.Models.Distribucion; | using GestionIntegral.Api.Models.Distribucion; | ||||||
| using GestionIntegral.Api.Models.Suscripciones; | using GestionIntegral.Api.Models.Suscripciones; | ||||||
| using GestionIntegral.Api.Services.Comunicaciones; |  | ||||||
| using System.Data; | 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 | namespace GestionIntegral.Api.Services.Suscripciones | ||||||
| { | { | ||||||
|     public class FacturacionService : IFacturacionService |     public class FacturacionService : IFacturacionService | ||||||
|     { |     { | ||||||
|  |         private readonly ILoteDeEnvioRepository _loteDeEnvioRepository; | ||||||
|  |         private readonly IUsuarioRepository _usuarioRepository; | ||||||
|         private readonly ISuscripcionRepository _suscripcionRepository; |         private readonly ISuscripcionRepository _suscripcionRepository; | ||||||
|         private readonly IFacturaRepository _facturaRepository; |         private readonly IFacturaRepository _facturaRepository; | ||||||
|  |         private readonly IEmpresaRepository _empresaRepository; | ||||||
|  |         private readonly IFacturaDetalleRepository _facturaDetalleRepository; | ||||||
|         private readonly IPrecioRepository _precioRepository; |         private readonly IPrecioRepository _precioRepository; | ||||||
|         private readonly IPromocionRepository _promocionRepository; |         private readonly IPromocionRepository _promocionRepository; | ||||||
|         private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación |         private readonly ISuscriptorRepository _suscriptorRepository; | ||||||
|         private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor |         private readonly IAjusteRepository _ajusteRepository; | ||||||
|         private readonly DbConnectionFactory _connectionFactory; |  | ||||||
|         private readonly IEmailService _emailService; |         private readonly IEmailService _emailService; | ||||||
|  |         private readonly IPublicacionRepository _publicacionRepository; | ||||||
|  |         private readonly DbConnectionFactory _connectionFactory; | ||||||
|         private readonly ILogger<FacturacionService> _logger; |         private readonly ILogger<FacturacionService> _logger; | ||||||
|  |         private readonly string _facturasPdfPath; | ||||||
|  |         private const string LogoUrl = "https://www.eldia.com/img/header/eldia.png"; | ||||||
|  |  | ||||||
|         public FacturacionService( |         public FacturacionService( | ||||||
|             ISuscripcionRepository suscripcionRepository, |             ISuscripcionRepository suscripcionRepository, | ||||||
|             IFacturaRepository facturaRepository, |             IFacturaRepository facturaRepository, | ||||||
|  |             IEmpresaRepository empresaRepository, | ||||||
|  |             IFacturaDetalleRepository facturaDetalleRepository, | ||||||
|             IPrecioRepository precioRepository, |             IPrecioRepository precioRepository, | ||||||
|             IPromocionRepository promocionRepository, |             IPromocionRepository promocionRepository, | ||||||
|             IRecargoZonaRepository recargoZonaRepository, |  | ||||||
|             ISuscriptorRepository suscriptorRepository, |             ISuscriptorRepository suscriptorRepository, | ||||||
|             DbConnectionFactory connectionFactory, |             IAjusteRepository ajusteRepository, | ||||||
|             IEmailService emailService, |             IEmailService emailService, | ||||||
|             ILogger<FacturacionService> logger) |             IPublicacionRepository publicacionRepository, | ||||||
|  |             DbConnectionFactory connectionFactory, | ||||||
|  |             ILogger<FacturacionService> logger, | ||||||
|  |             IConfiguration configuration, | ||||||
|  |             ILoteDeEnvioRepository loteDeEnvioRepository, | ||||||
|  |              IUsuarioRepository usuarioRepository) | ||||||
|         { |         { | ||||||
|  |             _loteDeEnvioRepository = loteDeEnvioRepository; | ||||||
|  |             _usuarioRepository = usuarioRepository; | ||||||
|             _suscripcionRepository = suscripcionRepository; |             _suscripcionRepository = suscripcionRepository; | ||||||
|             _facturaRepository = facturaRepository; |             _facturaRepository = facturaRepository; | ||||||
|  |             _empresaRepository = empresaRepository; | ||||||
|  |             _facturaDetalleRepository = facturaDetalleRepository; | ||||||
|             _precioRepository = precioRepository; |             _precioRepository = precioRepository; | ||||||
|             _promocionRepository = promocionRepository; |             _promocionRepository = promocionRepository; | ||||||
|             _recargoZonaRepository = recargoZonaRepository; |  | ||||||
|             _suscriptorRepository = suscriptorRepository; |             _suscriptorRepository = suscriptorRepository; | ||||||
|             _connectionFactory = connectionFactory; |             _ajusteRepository = ajusteRepository; | ||||||
|             _emailService = emailService; |             _emailService = emailService; | ||||||
|  |             _publicacionRepository = publicacionRepository; | ||||||
|  |             _connectionFactory = connectionFactory; | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|  |             _facturasPdfPath = configuration.GetValue<string>("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF"; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario) |         public async Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario) | ||||||
|         { |         { | ||||||
|             var periodo = $"{anio}-{mes:D2}"; |             var periodoActual = new DateTime(anio, mes, 1); | ||||||
|             _logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodo, idUsuario); |             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(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); |             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||||
| @@ -54,77 +108,473 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction); |                 var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction); | ||||||
|                 if (!suscripcionesActivas.Any()) |                 if (!suscripcionesActivas.Any()) | ||||||
|                 { |                 { | ||||||
|                     return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0); |                     // Si no hay nada que facturar, consideramos el proceso exitoso pero sin resultados. | ||||||
|  |                     lote.Estado = "Completado"; | ||||||
|  |                     lote.FechaFin = DateTime.Now; | ||||||
|  |                     await _loteDeEnvioRepository.UpdateAsync(lote); | ||||||
|  |                     return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", null); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 int facturasGeneradas = 0; |                 var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>(); | ||||||
|                 foreach (var suscripcion in suscripcionesActivas) |                 foreach (var s in suscripcionesActivas) | ||||||
|                 { |                 { | ||||||
|                     var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction); |                     var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion); | ||||||
|                     if (facturaExistente != null) |                     if (pub != null) | ||||||
|                     { |                     { | ||||||
|                         _logger.LogWarning("Ya existe una factura (ID: {IdFactura}) para la suscripción ID {IdSuscripcion} en el período {Periodo}. Se omite.", facturaExistente.IdFactura, suscripcion.IdSuscripcion, periodo); |                         suscripcionesConEmpresa.Add((s, pub.IdEmpresa)); | ||||||
|                         continue; |                     } | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                     // --- LÓGICA DE PROMOCIONES --- |                 var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa }); | ||||||
|                     var primerDiaMes = new DateTime(anio, mes, 1); |  | ||||||
|                     var promocionesAplicables = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, primerDiaMes, transaction); |  | ||||||
|  |  | ||||||
|                     decimal importeBruto = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction); |                 foreach (var grupo in gruposParaFacturar) | ||||||
|                     decimal descuentoTotal = 0; |  | ||||||
|  |  | ||||||
|                     // Aplicar promociones de descuento |  | ||||||
|                     foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "Porcentaje")) |  | ||||||
|                 { |                 { | ||||||
|                         descuentoTotal += (importeBruto * promo.Valor) / 100; |                     int idSuscriptor = grupo.Key.IdSuscriptor; | ||||||
|                     } |                     int idEmpresa = grupo.Key.IdEmpresa; | ||||||
|                     foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "MontoFijo")) |                     decimal importeBrutoTotal = 0; | ||||||
|  |                     decimal descuentoPromocionesTotal = 0; | ||||||
|  |                     var detallesParaFactura = new List<FacturaDetalle>(); | ||||||
|  |                     foreach (var item in grupo) | ||||||
|                     { |                     { | ||||||
|                         descuentoTotal += promo.Valor; |                         var suscripcion = item.Suscripcion; | ||||||
|                     } |                         decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction); | ||||||
|                     // La bonificación de días se aplicaría idealmente dentro de CalcularImporteParaSuscripcion, |                         var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction); | ||||||
|                     // pero por simplicidad, aquí solo manejamos descuentos sobre el total. |                         decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones); | ||||||
|  |                         importeBrutoTotal += importeBrutoSusc; | ||||||
|                     if (importeBruto <= 0) |                         descuentoPromocionesTotal += descuentoSusc; | ||||||
|                     { |                         var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion); | ||||||
|                         _logger.LogInformation("Suscripción ID {IdSuscripcion} no tiene importe a facturar para el período {Periodo}. Se omite.", suscripcion.IdSuscripcion, periodo); |                         detallesParaFactura.Add(new FacturaDetalle | ||||||
|                         continue; |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     var importeFinal = importeBruto - descuentoTotal; |  | ||||||
|                     if (importeFinal < 0) importeFinal = 0; // El importe no puede ser negativo |  | ||||||
|  |  | ||||||
|                     var nuevaFactura = new Factura |  | ||||||
|                         { |                         { | ||||||
|                             IdSuscripcion = suscripcion.IdSuscripcion, |                             IdSuscripcion = suscripcion.IdSuscripcion, | ||||||
|                         Periodo = periodo, |                             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, |                         FechaEmision = DateTime.Now.Date, | ||||||
|                         FechaVencimiento = new DateTime(anio, mes, 10).AddMonths(1), |                         FechaVencimiento = new DateTime(anio, mes, 10), | ||||||
|                         ImporteBruto = importeBruto, |                         ImporteBruto = importeBrutoTotal, | ||||||
|                         DescuentoAplicado = descuentoTotal, |                         DescuentoAplicado = descuentoPromocionesTotal, | ||||||
|                         ImporteFinal = importeFinal, |                         ImporteFinal = importeFinal, | ||||||
|                         Estado = "Pendiente de Facturar" |                         EstadoPago = "Pendiente", | ||||||
|  |                         EstadoFacturacion = "Pendiente de Facturar" | ||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
|                     var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); |                     var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); | ||||||
|                     if (facturaCreada == null) throw new DataException($"No se pudo crear el registro de factura para la suscripción ID {suscripcion.IdSuscripcion}"); |                     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++; |                     facturasGeneradas++; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 transaction.Commit(); |                 transaction.Commit(); | ||||||
|                 _logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas); |                 _logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr); | ||||||
|                 return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", facturasGeneradas); |  | ||||||
|  |                 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) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction.Rollback(); } catch { } | ||||||
|                 _logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodo); |                 lote.Estado = "Fallido"; | ||||||
|                 return (false, "Error interno del servidor al generar la facturación.", 0); |                 _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."); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -133,25 +583,34 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             decimal importeTotal = 0; |             decimal importeTotal = 0; | ||||||
|             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); |             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); | ||||||
|             var fechaActual = new DateTime(anio, mes, 1); |             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) |             while (fechaActual.Month == mes) | ||||||
|             { |             { | ||||||
|                 // La suscripción debe estar activa en este día |                 if (fechaActual.Date >= suscripcion.FechaInicio.Date && (suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date)) | ||||||
|                 if (fechaActual.Date >= suscripcion.FechaInicio.Date && |  | ||||||
|                     (suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date)) |  | ||||||
|                 { |                 { | ||||||
|                     var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek); |                     var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek); | ||||||
|                     if (diasDeEntrega.Contains(diaSemanaChar)) |                     if (diasDeEntrega.Contains(diaSemanaChar)) | ||||||
|                     { |                     { | ||||||
|  |                         decimal precioDelDia = 0; | ||||||
|                         var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction); |                         var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction); | ||||||
|                         if (precioActivo != null) |                         if (precioActivo != null) | ||||||
|                         { |                         { | ||||||
|                             importeTotal += GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek); |                             precioDelDia = GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek); | ||||||
|                         } |                         } | ||||||
|                         else |                         else | ||||||
|                         { |                         { | ||||||
|                             _logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date); |                             _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); |                 fechaActual = fechaActual.AddDays(1); | ||||||
| @@ -159,72 +618,30 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             return importeTotal; |             return importeTotal; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes) |         private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha) | ||||||
|         { |         { | ||||||
|             var periodo = $"{anio}-{mes:D2}"; |             switch (promocion.TipoCondicion) | ||||||
|             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo); |  | ||||||
|  |  | ||||||
|             return facturasData.Select(data => new FacturaDto |  | ||||||
|             { |             { | ||||||
|                 IdFactura = data.Factura.IdFactura, |                 case "Siempre": return true; | ||||||
|                 IdSuscripcion = data.Factura.IdSuscripcion, |                 case "DiaDeSemana": | ||||||
|                 Periodo = data.Factura.Periodo, |                     int diaSemanaActual = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek; | ||||||
|                 FechaEmision = data.Factura.FechaEmision.ToString("yyyy-MM-dd"), |                     return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActual; | ||||||
|                 FechaVencimiento = data.Factura.FechaVencimiento.ToString("yyyy-MM-dd"), |                 case "PrimerDiaSemanaDelMes": | ||||||
|                 ImporteFinal = data.Factura.ImporteFinal, |                     int diaSemanaActualMes = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek; | ||||||
|                 Estado = data.Factura.Estado, |                     return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActualMes && fecha.Day <= 7; | ||||||
|                 NumeroFactura = data.Factura.NumeroFactura, |                 default: return false; | ||||||
|                 NombreSuscriptor = data.NombreSuscriptor, |  | ||||||
|                 NombrePublicacion = data.NombrePublicacion |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         public async Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura) |  | ||||||
|         { |  | ||||||
|             var factura = await _facturaRepository.GetByIdAsync(idFactura); |  | ||||||
|             if (factura == null) return (false, "Factura no encontrada."); |  | ||||||
|             if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura aún no tiene un número asignado por ARCA."); |  | ||||||
|  |  | ||||||
|             var suscripcion = await _suscripcionRepository.GetByIdAsync(factura.IdSuscripcion); |  | ||||||
|             if (suscripcion == null) return (false, "Suscripción asociada no encontrada."); |  | ||||||
|  |  | ||||||
|             var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor); |  | ||||||
|             if (suscriptor == null) return (false, "Suscriptor asociado no encontrado."); |  | ||||||
|             if (string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no tiene una dirección de email configurada."); |  | ||||||
|  |  | ||||||
|             try |  | ||||||
|             { |  | ||||||
|                 var asunto = $"Tu factura del Diario El Día - Período {factura.Periodo}"; |  | ||||||
|                 var cuerpo = $@" |  | ||||||
|             <h1>Hola {suscriptor.NombreCompleto},</h1> |  | ||||||
|             <p>Te adjuntamos los detalles de tu factura para el período {factura.Periodo}.</p> |  | ||||||
|             <ul> |  | ||||||
|                 <li><strong>Número de Factura:</strong> {factura.NumeroFactura}</li> |  | ||||||
|                 <li><strong>Importe Total:</strong> ${factura.ImporteFinal:N2}</li> |  | ||||||
|                 <li><strong>Fecha de Vencimiento:</strong> {factura.FechaVencimiento:dd/MM/yyyy}</li> |  | ||||||
|             </ul> |  | ||||||
|             <p>Gracias por ser parte de nuestra comunidad de lectores.</p> |  | ||||||
|             <p><em>Diario El Día</em></p>"; |  | ||||||
|  |  | ||||||
|                 await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpo); |  | ||||||
|                 return (true, null); |  | ||||||
|             } |  | ||||||
|             catch (Exception ex) |  | ||||||
|             { |  | ||||||
|                 _logger.LogError(ex, "Falló el envío de email para la factura ID {IdFactura}", idFactura); |  | ||||||
|                 return (false, "Ocurrió un error al intentar enviar el email."); |  | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         private string GetCharDiaSemana(DayOfWeek dia) => dia switch |         private string GetCharDiaSemana(DayOfWeek dia) => dia switch | ||||||
|         { |         { | ||||||
|             DayOfWeek.Sunday => "D", |             DayOfWeek.Sunday => "Dom", | ||||||
|             DayOfWeek.Monday => "L", |             DayOfWeek.Monday => "Lun", | ||||||
|             DayOfWeek.Tuesday => "M", |             DayOfWeek.Tuesday => "Mar", | ||||||
|             DayOfWeek.Wednesday => "X", |             DayOfWeek.Wednesday => "Mie", | ||||||
|             DayOfWeek.Thursday => "J", |             DayOfWeek.Thursday => "Jue", | ||||||
|             DayOfWeek.Friday => "V", |             DayOfWeek.Friday => "Vie", | ||||||
|             DayOfWeek.Saturday => "S", |             DayOfWeek.Saturday => "Sab", | ||||||
|             _ => "" |             _ => "" | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
| @@ -239,5 +656,14 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             DayOfWeek.Saturday => precio.Sabado ?? 0, |             DayOfWeek.Saturday => precio.Sabado ?? 0, | ||||||
|             _ => 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,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); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,11 +1,14 @@ | |||||||
|  | using GestionIntegral.Api.Dtos.Comunicaciones; | ||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Suscripciones; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Suscripciones | namespace GestionIntegral.Api.Services.Suscripciones | ||||||
| { | { | ||||||
|     public interface IFacturacionService |     public interface IFacturacionService | ||||||
|     { |     { | ||||||
|         Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); |         Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); | ||||||
|         Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes); |         Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes); | ||||||
|         Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura); |         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); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -4,13 +4,13 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
| { | { | ||||||
|     public interface ISuscripcionService |     public interface ISuscripcionService | ||||||
|     { |     { | ||||||
|         Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor); |  | ||||||
|         Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion); |         Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion); | ||||||
|  |         Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor); | ||||||
|         Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario); |         Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario); | ||||||
|         Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario); |         Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario); | ||||||
|         Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion); |         Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion); | ||||||
|         Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion); |         Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion); | ||||||
|         Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario); |         Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, AsignarPromocionDto dto, int idUsuario); | ||||||
|         Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion); |         Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -66,45 +66,67 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); |             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||||
|             using var transaction = connection.BeginTransaction(); |             using var transaction = connection.BeginTransaction(); | ||||||
|  |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura); |                 var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura); | ||||||
|                 if (factura == null) return (null, "La factura especificada no existe."); |                 if (factura == null) return (null, "La factura especificada no existe."); | ||||||
|                 if (factura.Estado == "Pagada") return (null, "La factura ya se encuentra pagada."); |  | ||||||
|                 if (factura.Estado == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada."); |                 // Usar EstadoPago para la validación | ||||||
|  |                 if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada."); | ||||||
|  |  | ||||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago); |                 var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago); | ||||||
|                 if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida."); |                 if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida."); | ||||||
|  |  | ||||||
|  |                 // Obtenemos la suma de pagos ANTERIORES | ||||||
|  |                 var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction); | ||||||
|  |  | ||||||
|                 var nuevoPago = new Pago |                 var nuevoPago = new Pago | ||||||
|                 { |                 { | ||||||
|                     IdFactura = createDto.IdFactura, |                     IdFactura = createDto.IdFactura, | ||||||
|                     FechaPago = createDto.FechaPago, |                     FechaPago = createDto.FechaPago, | ||||||
|                     IdFormaPago = createDto.IdFormaPago, |                     IdFormaPago = createDto.IdFormaPago, | ||||||
|                     Monto = createDto.Monto, |                     Monto = createDto.Monto, | ||||||
|                     Estado = "Aprobado", // Los pagos manuales se asumen aprobados |                     Estado = "Aprobado", | ||||||
|                     Referencia = createDto.Referencia, |                     Referencia = createDto.Referencia, | ||||||
|                     Observaciones = createDto.Observaciones, |                     Observaciones = createDto.Observaciones, | ||||||
|                     IdUsuarioRegistro = idUsuario |                     IdUsuarioRegistro = idUsuario | ||||||
|                 }; |                 }; | ||||||
|  |  | ||||||
|                 // 1. Crear el registro del pago |                 // Creamos el nuevo pago | ||||||
|                 var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); |                 var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||||
|                 if (pagoCreado == null) throw new DataException("No se pudo registrar el pago."); |                 if (pagoCreado == null) throw new DataException("No se pudo registrar el pago."); | ||||||
|  |  | ||||||
|                 // 2. Si el monto pagado es igual o mayor al importe de la factura, actualizar la factura |                 // Calculamos el nuevo total EN MEMORIA | ||||||
|                 // (Permitimos pago mayor por si hay redondeos, etc.) |                 var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto; | ||||||
|                 if (pagoCreado.Monto >= factura.ImporteFinal) |  | ||||||
|  |                 // Comparamos y actualizamos el estado si es necesario | ||||||
|  |                 // CORRECCIÓN: Usar EstadoPago y el método correcto del repositorio | ||||||
|  |                 if (factura.EstadoPago != "Pagada" && nuevoTotalPagado >= factura.ImporteFinal) | ||||||
|                 { |                 { | ||||||
|                     bool actualizado = await _facturaRepository.UpdateEstadoAsync(factura.IdFactura, "Pagada", transaction); |                     bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, "Pagada", transaction); | ||||||
|                     if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'."); |                     if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'."); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 transaction.Commit(); |                 transaction.Commit(); | ||||||
|                 _logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario); |                 _logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario); | ||||||
|  |  | ||||||
|                 var dto = await MapToDto(pagoCreado); |                 // Construimos el DTO de respuesta SIN volver a consultar la base de datos | ||||||
|  |                 var usuario = await _usuarioRepository.GetByIdAsync(idUsuario); | ||||||
|  |                 var dto = new PagoDto | ||||||
|  |                 { | ||||||
|  |                     IdPago = pagoCreado.IdPago, | ||||||
|  |                     IdFactura = pagoCreado.IdFactura, | ||||||
|  |                     FechaPago = pagoCreado.FechaPago.ToString("yyyy-MM-dd"), | ||||||
|  |                     IdFormaPago = pagoCreado.IdFormaPago, | ||||||
|  |                     NombreFormaPago = formaPago.Nombre, | ||||||
|  |                     Monto = pagoCreado.Monto, | ||||||
|  |                     Estado = pagoCreado.Estado, | ||||||
|  |                     Referencia = pagoCreado.Referencia, | ||||||
|  |                     Observaciones = pagoCreado.Observaciones, | ||||||
|  |                     IdUsuarioRegistro = pagoCreado.IdUsuarioRegistro, | ||||||
|  |                     NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A" | ||||||
|  |                 }; | ||||||
|  |  | ||||||
|                 return (dto, null); |                 return (dto, null); | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|   | |||||||
| @@ -28,8 +28,10 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             { |             { | ||||||
|                 IdPromocion = promo.IdPromocion, |                 IdPromocion = promo.IdPromocion, | ||||||
|                 Descripcion = promo.Descripcion, |                 Descripcion = promo.Descripcion, | ||||||
|                 TipoPromocion = promo.TipoPromocion, |                 TipoEfecto = promo.TipoEfecto, | ||||||
|                 Valor = promo.Valor, |                 ValorEfecto = promo.ValorEfecto, | ||||||
|  |                 TipoCondicion = promo.TipoCondicion, | ||||||
|  |                 ValorCondicion = promo.ValorCondicion, | ||||||
|                 FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"), |                 FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"), | ||||||
|                 FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"), |                 FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"), | ||||||
|                 Activa = promo.Activa |                 Activa = promo.Activa | ||||||
| @@ -58,8 +60,10 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             var nuevaPromocion = new Promocion |             var nuevaPromocion = new Promocion | ||||||
|             { |             { | ||||||
|                 Descripcion = createDto.Descripcion, |                 Descripcion = createDto.Descripcion, | ||||||
|                 TipoPromocion = createDto.TipoPromocion, |                 TipoEfecto = createDto.TipoEfecto, | ||||||
|                 Valor = createDto.Valor, |                 ValorEfecto = createDto.ValorEfecto, | ||||||
|  |                 TipoCondicion = createDto.TipoCondicion, | ||||||
|  |                 ValorCondicion = createDto.ValorCondicion, | ||||||
|                 FechaInicio = createDto.FechaInicio, |                 FechaInicio = createDto.FechaInicio, | ||||||
|                 FechaFin = createDto.FechaFin, |                 FechaFin = createDto.FechaFin, | ||||||
|                 Activa = createDto.Activa, |                 Activa = createDto.Activa, | ||||||
| @@ -96,10 +100,11 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                 return (false, "La fecha de fin no puede ser anterior a la fecha de inicio."); |                 return (false, "La fecha de fin no puede ser anterior a la fecha de inicio."); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Mapeo |  | ||||||
|             existente.Descripcion = updateDto.Descripcion; |             existente.Descripcion = updateDto.Descripcion; | ||||||
|             existente.TipoPromocion = updateDto.TipoPromocion; |             existente.TipoEfecto = updateDto.TipoEfecto; | ||||||
|             existente.Valor = updateDto.Valor; |             existente.ValorEfecto = updateDto.ValorEfecto; | ||||||
|  |             existente.TipoCondicion = updateDto.TipoCondicion; | ||||||
|  |             existente.ValorCondicion = updateDto.ValorCondicion; | ||||||
|             existente.FechaInicio = updateDto.FechaInicio; |             existente.FechaInicio = updateDto.FechaInicio; | ||||||
|             existente.FechaFin = updateDto.FechaFin; |             existente.FechaFin = updateDto.FechaFin; | ||||||
|             existente.Activa = updateDto.Activa; |             existente.Activa = updateDto.Activa; | ||||||
|   | |||||||
| @@ -3,7 +3,12 @@ using GestionIntegral.Api.Data.Repositories.Distribucion; | |||||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Suscripciones; | ||||||
| using GestionIntegral.Api.Models.Suscripciones; | using GestionIntegral.Api.Models.Suscripciones; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
| using System.Data; | using System.Data; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Suscripciones | namespace GestionIntegral.Api.Services.Suscripciones | ||||||
| { | { | ||||||
| @@ -36,8 +41,10 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|         { |         { | ||||||
|             IdPromocion = promo.IdPromocion, |             IdPromocion = promo.IdPromocion, | ||||||
|             Descripcion = promo.Descripcion, |             Descripcion = promo.Descripcion, | ||||||
|             TipoPromocion = promo.TipoPromocion, |             TipoEfecto = promo.TipoEfecto, | ||||||
|             Valor = promo.Valor, |             ValorEfecto = promo.ValorEfecto, | ||||||
|  |             TipoCondicion = promo.TipoCondicion, | ||||||
|  |             ValorCondicion = promo.ValorCondicion, | ||||||
|             FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"), |             FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"), | ||||||
|             FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"), |             FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"), | ||||||
|             Activa = promo.Activa |             Activa = promo.Activa | ||||||
| @@ -85,6 +92,15 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                 return (null, "La publicación no existe."); |                 return (null, "La publicación no existe."); | ||||||
|             if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio) |             if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio) | ||||||
|                 return (null, "La fecha de fin no puede ser anterior a la fecha de inicio."); |                 return (null, "La fecha de fin no puede ser anterior a la fecha de inicio."); | ||||||
|  |             if ((createDto.Estado == "Cancelada" || createDto.Estado == "Pausada") && !createDto.FechaFin.HasValue) | ||||||
|  |             { | ||||||
|  |                 return (null, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (createDto.Estado == "Activa") | ||||||
|  |             { | ||||||
|  |                 createDto.FechaFin = null; | ||||||
|  |             } | ||||||
|  |  | ||||||
|             var nuevaSuscripcion = new Suscripcion |             var nuevaSuscripcion = new Suscripcion | ||||||
|             { |             { | ||||||
| @@ -123,6 +139,18 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion); |             var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion); | ||||||
|             if (existente == null) return (false, "Suscripción no encontrada."); |             if (existente == null) return (false, "Suscripción no encontrada."); | ||||||
|  |  | ||||||
|  |             // Validación de lógica de negocio en el backend | ||||||
|  |             if ((updateDto.Estado == "Cancelada" || updateDto.Estado == "Pausada") && !updateDto.FechaFin.HasValue) | ||||||
|  |             { | ||||||
|  |                 return (false, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Si el estado es 'Activa', nos aseguramos de que la FechaFin sea nula. | ||||||
|  |             if (updateDto.Estado == "Activa") | ||||||
|  |             { | ||||||
|  |                 updateDto.FechaFin = null; | ||||||
|  |             } | ||||||
|  |  | ||||||
|             if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio) |             if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio) | ||||||
|                 return (false, "La fecha de fin no puede ser anterior a la fecha de inicio."); |                 return (false, "La fecha de fin no puede ser anterior a la fecha de inicio."); | ||||||
|  |  | ||||||
| @@ -154,47 +182,67 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion) |         public async Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion) | ||||||
|         { |         { | ||||||
|             var promociones = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion); |             var asignaciones = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion); | ||||||
|             return promociones.Select(MapPromocionToDto); |             return asignaciones.Select(a => new PromocionAsignadaDto | ||||||
|  |             { | ||||||
|  |                 IdPromocion = a.Promocion.IdPromocion, | ||||||
|  |                 Descripcion = a.Promocion.Descripcion, | ||||||
|  |                 TipoEfecto = a.Promocion.TipoEfecto, | ||||||
|  |                 ValorEfecto = a.Promocion.ValorEfecto, | ||||||
|  |                 TipoCondicion = a.Promocion.TipoCondicion, | ||||||
|  |                 ValorCondicion = a.Promocion.ValorCondicion, | ||||||
|  |                 FechaInicio = a.Promocion.FechaInicio.ToString("yyyy-MM-dd"), | ||||||
|  |                 FechaFin = a.Promocion.FechaFin?.ToString("yyyy-MM-dd"), | ||||||
|  |                 Activa = a.Promocion.Activa, | ||||||
|  |                 VigenciaDesdeAsignacion = a.Asignacion.VigenciaDesde.ToString("yyyy-MM-dd"), | ||||||
|  |                 VigenciaHastaAsignacion = a.Asignacion.VigenciaHasta?.ToString("yyyy-MM-dd") | ||||||
|  |             }); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion) |         public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion) | ||||||
|         { |         { | ||||||
|             var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true); |             var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true); | ||||||
|             var promosAsignadas = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion); |             var promosAsignadasData = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion); | ||||||
|             var idsAsignadas = promosAsignadas.Select(p => p.IdPromocion).ToHashSet(); |             var idsAsignadas = promosAsignadasData.Select(p => p.Promocion.IdPromocion).ToHashSet(); | ||||||
|  |  | ||||||
|             return todasLasPromosActivas |             return todasLasPromosActivas | ||||||
|                 .Where(p => !idsAsignadas.Contains(p.IdPromocion)) |                 .Where(p => !idsAsignadas.Contains(p.IdPromocion)) | ||||||
|                 .Select(MapPromocionToDto); |                 .Select(MapPromocionToDto); // Usa el helper que ya creamos | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario) |         public async Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, AsignarPromocionDto dto, int idUsuario) | ||||||
|         { |         { | ||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); |             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||||
|             using var transaction = connection.BeginTransaction(); |             using var transaction = connection.BeginTransaction(); | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 // Validaciones |  | ||||||
|                 if (await _suscripcionRepository.GetByIdAsync(idSuscripcion) == null) return (false, "Suscripción no encontrada."); |                 if (await _suscripcionRepository.GetByIdAsync(idSuscripcion) == null) return (false, "Suscripción no encontrada."); | ||||||
|                 if (await _promocionRepository.GetByIdAsync(idPromocion) == null) return (false, "Promoción no encontrada."); |                 if (await _promocionRepository.GetByIdAsync(dto.IdPromocion) == null) return (false, "Promoción no encontrada."); | ||||||
|  |  | ||||||
|                 await _suscripcionRepository.AsignarPromocionAsync(idSuscripcion, idPromocion, idUsuario, transaction); |                 var nuevaAsignacion = new SuscripcionPromocion | ||||||
|  |                 { | ||||||
|  |                     IdSuscripcion = idSuscripcion, | ||||||
|  |                     IdPromocion = dto.IdPromocion, | ||||||
|  |                     IdUsuarioAsigno = idUsuario, | ||||||
|  |                     VigenciaDesde = dto.VigenciaDesde, | ||||||
|  |                     VigenciaHasta = dto.VigenciaHasta | ||||||
|  |                 }; | ||||||
|  |  | ||||||
|  |                 await _suscripcionRepository.AsignarPromocionAsync(nuevaAsignacion, transaction); | ||||||
|                 transaction.Commit(); |                 transaction.Commit(); | ||||||
|                 return (true, null); |                 return (true, null); | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 // Capturar error de Primary Key duplicada |  | ||||||
|                 if (ex.Message.Contains("PRIMARY KEY constraint")) |                 if (ex.Message.Contains("PRIMARY KEY constraint")) | ||||||
|                 { |                 { | ||||||
|                     return (false, "Esta promoción ya está asignada a la suscripción."); |                     return (false, "Esta promoción ya está asignada a la suscripción."); | ||||||
|                 } |                 } | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction.Rollback(); } catch { } | ||||||
|                 _logger.LogError(ex, "Error al asignar promoción {IdPromocion} a suscripción {IdSuscripcion}", idPromocion, idSuscripcion); |                 _logger.LogError(ex, "Error al asignar promoción {IdPromocion} a suscripción {IdSuscripcion}", dto.IdPromocion, idSuscripcion); | ||||||
|                 return (false, "Error interno al asignar la promoción."); |                 return (false, "Error interno al asignar la promoción."); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -1,15 +1,8 @@ | |||||||
| // Archivo: GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs |  | ||||||
|  |  | ||||||
| using GestionIntegral.Api.Data; | using GestionIntegral.Api.Data; | ||||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Suscripciones; | ||||||
| using GestionIntegral.Api.Models.Suscripciones; | using GestionIntegral.Api.Models.Suscripciones; | ||||||
| using Microsoft.Extensions.Logging; |  | ||||||
| using System; |  | ||||||
| using System.Collections.Generic; |  | ||||||
| using System.Data; | using System.Data; | ||||||
| using System.Linq; |  | ||||||
| using System.Threading.Tasks; |  | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Suscripciones | namespace GestionIntegral.Api.Services.Suscripciones | ||||||
| { | { | ||||||
| @@ -58,10 +51,42 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|         public async Task<IEnumerable<SuscriptorDto>> ObtenerTodos(string? nombreFilter, string? nroDocFilter, bool soloActivos) |         public async Task<IEnumerable<SuscriptorDto>> ObtenerTodos(string? nombreFilter, string? nroDocFilter, bool soloActivos) | ||||||
|         { |         { | ||||||
|  |             // 1. Obtener todos los suscriptores en una sola consulta | ||||||
|             var suscriptores = await _suscriptorRepository.GetAllAsync(nombreFilter, nroDocFilter, soloActivos); |             var suscriptores = await _suscriptorRepository.GetAllAsync(nombreFilter, nroDocFilter, soloActivos); | ||||||
|             var dtosTasks = suscriptores.Select(s => MapToDto(s)); |             if (!suscriptores.Any()) | ||||||
|             var dtos = await Task.WhenAll(dtosTasks); |             { | ||||||
|             return dtos.Where(dto => dto != null).Select(dto => dto!); |                 return Enumerable.Empty<SuscriptorDto>(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // 2. Obtener todas las formas de pago en una sola consulta | ||||||
|  |             // y convertirlas a un diccionario para una búsqueda rápida (O(1) en lugar de O(n)). | ||||||
|  |             var formasDePago = (await _formaPagoRepository.GetAllAsync()) | ||||||
|  |                                 .ToDictionary(fp => fp.IdFormaPago); | ||||||
|  |  | ||||||
|  |             // 3. Mapear en memoria, evitando múltiples llamadas a la base de datos. | ||||||
|  |             var dtos = suscriptores.Select(s => | ||||||
|  |             { | ||||||
|  |                 // Busca la forma de pago en el diccionario en memoria. | ||||||
|  |                 formasDePago.TryGetValue(s.IdFormaPagoPreferida, out var formaPago); | ||||||
|  |  | ||||||
|  |                 return new SuscriptorDto | ||||||
|  |                 { | ||||||
|  |                     IdSuscriptor = s.IdSuscriptor, | ||||||
|  |                     NombreCompleto = s.NombreCompleto, | ||||||
|  |                     Email = s.Email, | ||||||
|  |                     Telefono = s.Telefono, | ||||||
|  |                     Direccion = s.Direccion, | ||||||
|  |                     TipoDocumento = s.TipoDocumento, | ||||||
|  |                     NroDocumento = s.NroDocumento, | ||||||
|  |                     CBU = s.CBU, | ||||||
|  |                     IdFormaPagoPreferida = s.IdFormaPagoPreferida, | ||||||
|  |                     NombreFormaPagoPreferida = formaPago?.Nombre ?? "Desconocida", // Asigna el nombre | ||||||
|  |                     Observaciones = s.Observaciones, | ||||||
|  |                     Activo = s.Activo | ||||||
|  |                 }; | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             return dtos; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<SuscriptorDto?> ObtenerPorId(int id) |         public async Task<SuscriptorDto?> ObtenerPorId(int id) | ||||||
|   | |||||||
| @@ -5,6 +5,9 @@ | |||||||
|       "Microsoft.AspNetCore": "Warning" |       "Microsoft.AspNetCore": "Warning" | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |   "AppSettings": { | ||||||
|  |     "FacturasPdfPath": "E:\\Facturas" | ||||||
|  |   }, | ||||||
|   "Jwt": { |   "Jwt": { | ||||||
|     "Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2", |     "Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2", | ||||||
|     "Issuer": "GestionIntegralApi", |     "Issuer": "GestionIntegralApi", | ||||||
| @@ -13,11 +16,11 @@ | |||||||
|   }, |   }, | ||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "MailSettings": { |   "MailSettings": { | ||||||
|   "SmtpHost": "smtp.yourprovider.com", |     "SmtpHost": "", | ||||||
|   "SmtpPort": 587, |     "SmtpPort": 0, | ||||||
|   "SenderName": "Diario El Día - Suscripciones", |     "SenderName": "", | ||||||
|   "SenderEmail": "suscripciones@eldia.com", |     "SenderEmail": "", | ||||||
|   "SmtpUser": "your-smtp-username", |     "SmtpUser": "", | ||||||
|   "SmtpPass": "your-smtp-password" |     "SmtpPass": "" | ||||||
|   } |   } | ||||||
| } | } | ||||||
							
								
								
									
										159
									
								
								Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | |||||||
|  | import React, { useState, useEffect } from 'react'; | ||||||
|  | import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material'; | ||||||
|  | import type { CreateAjusteDto } from '../../../models/dtos/Suscripciones/CreateAjusteDto'; | ||||||
|  | import type { UpdateAjusteDto } from '../../../models/dtos/Suscripciones/UpdateAjusteDto'; | ||||||
|  | import type { AjusteDto } from '../../../models/dtos/Suscripciones/AjusteDto'; | ||||||
|  | import type { EmpresaDropdownDto } from '../../../models/dtos/Distribucion/EmpresaDropdownDto'; | ||||||
|  |  | ||||||
|  | const modalStyle = { | ||||||
|  |   position: 'absolute' as 'absolute', | ||||||
|  |   top: '50%', left: '50%', | ||||||
|  |   transform: 'translate(-50%, -50%)', | ||||||
|  |   width: { xs: '95%', sm: '80%', md: '500px' }, | ||||||
|  |   bgcolor: 'background.paper', | ||||||
|  |   border: '2px solid #000', | ||||||
|  |   boxShadow: 24, p: 4, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // --- TIPO UNIFICADO PARA EL ESTADO DEL FORMULARIO --- | ||||||
|  | type AjusteFormData = Partial<CreateAjusteDto & UpdateAjusteDto>; | ||||||
|  |  | ||||||
|  | interface AjusteFormModalProps { | ||||||
|  |   open: boolean; | ||||||
|  |   onClose: () => void; | ||||||
|  |   onSubmit: (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => Promise<void>; | ||||||
|  |   initialData?: AjusteDto | null; | ||||||
|  |   idSuscriptor: number; | ||||||
|  |   errorMessage?: string | null; | ||||||
|  |   clearErrorMessage: () => void; | ||||||
|  |   empresas: EmpresaDropdownDto[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage, initialData, empresas }) => { | ||||||
|  |   const [formData, setFormData] = useState<AjusteFormData>({}); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|  |  | ||||||
|  |   const isEditing = Boolean(initialData); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (open) { | ||||||
|  |       const fechaParaFormulario = initialData?.fechaAjuste | ||||||
|  |         ? initialData.fechaAjuste.split(' ')[0] | ||||||
|  |         : new Date().toISOString().split('T')[0]; | ||||||
|  |  | ||||||
|  |       setFormData({ | ||||||
|  |         idSuscriptor: initialData?.idSuscriptor || idSuscriptor, | ||||||
|  |         idEmpresa: initialData?.idEmpresa || undefined, // undefined para que el placeholder se muestre | ||||||
|  |         fechaAjuste: fechaParaFormulario, | ||||||
|  |         tipoAjuste: initialData?.tipoAjuste || 'Credito', | ||||||
|  |         monto: initialData?.monto || undefined, | ||||||
|  |         motivo: initialData?.motivo || '' | ||||||
|  |       }); | ||||||
|  |       setLocalErrors({}); | ||||||
|  |     } | ||||||
|  |   }, [open, initialData, idSuscriptor]); | ||||||
|  |  | ||||||
|  |   const validate = (): boolean => { | ||||||
|  |     const errors: { [key: string]: string | null } = {}; | ||||||
|  |     if (!formData.idEmpresa) errors.idEmpresa = "Debe seleccionar una empresa."; | ||||||
|  |     if (!formData.fechaAjuste) errors.fechaAjuste = "La fecha es obligatoria."; | ||||||
|  |     if (!formData.tipoAjuste) errors.tipoAjuste = "Seleccione un tipo."; | ||||||
|  |     if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero."; | ||||||
|  |     if (!formData.motivo?.trim()) errors.motivo = "El motivo es obligatorio."; | ||||||
|  |     setLocalErrors(errors); | ||||||
|  |     return Object.keys(errors).length === 0; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|  |     const { name, value } = e.target; | ||||||
|  |     setFormData((prev: AjusteFormData) => ({ | ||||||
|  |       ...prev, | ||||||
|  |       [name]: name === 'monto' && value !== '' ? parseFloat(value) : value | ||||||
|  |     })); | ||||||
|  |     if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||||
|  |     if (errorMessage) clearErrorMessage(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSelectChange = (e: SelectChangeEvent<string | number>) => { // Acepta string o number | ||||||
|  |     const { name, value } = e.target; | ||||||
|  |     setFormData((prev: AjusteFormData) => ({ ...prev, [name]: value })); | ||||||
|  |     if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||||
|  |     if (errorMessage) clearErrorMessage(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     clearErrorMessage(); | ||||||
|  |     if (!validate()) return; | ||||||
|  |     setLoading(true); | ||||||
|  |     let success = false; | ||||||
|  |     try { | ||||||
|  |       if (isEditing && initialData) { | ||||||
|  |         await onSubmit(formData as UpdateAjusteDto, initialData.idAjuste); | ||||||
|  |       } else { | ||||||
|  |         await onSubmit(formData as CreateAjusteDto); | ||||||
|  |       } | ||||||
|  |       success = true; | ||||||
|  |     } catch (error) { | ||||||
|  |       success = false; | ||||||
|  |     } finally { | ||||||
|  |       setLoading(false); | ||||||
|  |       if (success) onClose(); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Modal open={open} onClose={onClose}> | ||||||
|  |       <Box sx={modalStyle}> | ||||||
|  |         <Typography variant="h6">{isEditing ? 'Editar Ajuste Manual' : 'Registrar Ajuste Manual'}</Typography> | ||||||
|  |         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||||
|  |  | ||||||
|  |           <TextField name="fechaAjuste" label="Fecha del Ajuste" type="date" value={formData.fechaAjuste || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaAjuste} helperText={localErrors.fechaAjuste} /> | ||||||
|  |           <FormControl fullWidth margin="dense" required error={!!localErrors.idEmpresa}> | ||||||
|  |             <InputLabel id="empresa-label">Empresa</InputLabel> | ||||||
|  |             <Select | ||||||
|  |               name="idEmpresa" | ||||||
|  |               labelId="empresa-label" | ||||||
|  |               value={formData.idEmpresa || ''} | ||||||
|  |               onChange={handleSelectChange} | ||||||
|  |               label="Empresa" | ||||||
|  |             > | ||||||
|  |               {empresas.map((empresa) => ( | ||||||
|  |                 <MenuItem key={empresa.idEmpresa} value={empresa.idEmpresa}> | ||||||
|  |                   {empresa.nombre} | ||||||
|  |                 </MenuItem> | ||||||
|  |               ))} | ||||||
|  |             </Select> | ||||||
|  |             {localErrors.idEmpresa && <Typography color="error" variant="caption">{localErrors.idEmpresa}</Typography>} | ||||||
|  |           </FormControl> | ||||||
|  |           <FormControl fullWidth margin="dense" error={!!localErrors.tipoAjuste}> | ||||||
|  |             <InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel> | ||||||
|  |             <Select name="tipoAjuste" labelId="tipo-ajuste-label" value={formData.tipoAjuste || ''} onChange={handleSelectChange} label="Tipo de Ajuste"> | ||||||
|  |               <MenuItem value="Credito">Crédito (Descuento a favor del cliente)</MenuItem> | ||||||
|  |               <MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem> | ||||||
|  |             </Select> | ||||||
|  |           </FormControl> | ||||||
|  |  | ||||||
|  |           <TextField name="monto" label="Monto" type="number" value={formData.monto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.monto} helperText={localErrors.monto} InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} inputProps={{ step: "0.01" }} /> | ||||||
|  |           <TextField name="motivo" label="Motivo" value={formData.motivo || ''} onChange={handleInputChange} required fullWidth margin="dense" multiline rows={3} error={!!localErrors.motivo} helperText={localErrors.motivo} /> | ||||||
|  |  | ||||||
|  |           <Alert severity="info" sx={{ mt: 2 }}> | ||||||
|  |             Nota: Este ajuste se aplicará a la factura de la <strong>empresa seleccionada</strong> en el período correspondiente a la "Fecha del Ajuste". | ||||||
|  |           </Alert> | ||||||
|  |  | ||||||
|  |           {errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>} | ||||||
|  |  | ||||||
|  |           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||||
|  |             <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> | ||||||
|  |             <Button type="submit" variant="contained" disabled={loading}> | ||||||
|  |               {loading ? <CircularProgress size={24} /> : 'Guardar Ajuste'} | ||||||
|  |             </Button> | ||||||
|  |           </Box> | ||||||
|  |         </Box> | ||||||
|  |       </Box> | ||||||
|  |     </Modal> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default AjusteFormModal; | ||||||
| @@ -1,12 +1,26 @@ | |||||||
| import React, { useState, useEffect, useCallback } from 'react'; | import React, { useState, useEffect, useCallback } from 'react'; | ||||||
| import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material'; | import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider, type SelectChangeEvent, TextField } from '@mui/material'; | ||||||
| import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; | import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; | ||||||
| import DeleteIcon from '@mui/icons-material/Delete'; | import DeleteIcon from '@mui/icons-material/Delete'; | ||||||
| import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto'; | import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto'; | ||||||
| import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto'; | import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto'; | ||||||
|  | import type { PromocionAsignadaDto } from '../../../models/dtos/Suscripciones/PromocionAsignadaDto'; | ||||||
|  | import type { AsignarPromocionDto } from '../../../models/dtos/Suscripciones/AsignarPromocionDto'; | ||||||
| import suscripcionService from '../../../services/Suscripciones/suscripcionService'; | import suscripcionService from '../../../services/Suscripciones/suscripcionService'; | ||||||
|  |  | ||||||
| const modalStyle = { /* ... */ }; | const modalStyle = { | ||||||
|  |     position: 'absolute' as 'absolute', | ||||||
|  |     top: '50%', | ||||||
|  |     left: '50%', | ||||||
|  |     transform: 'translate(-50%, -50%)', | ||||||
|  |     width: { xs: '95%', sm: '80%', md: '600px' }, | ||||||
|  |     bgcolor: 'background.paper', | ||||||
|  |     border: '2px solid #000', | ||||||
|  |     boxShadow: 24, | ||||||
|  |     p: 4, | ||||||
|  |     maxHeight: '90vh', | ||||||
|  |     overflowY: 'auto' | ||||||
|  | }; | ||||||
|  |  | ||||||
| interface GestionarPromocionesSuscripcionModalProps { | interface GestionarPromocionesSuscripcionModalProps { | ||||||
|     open: boolean; |     open: boolean; | ||||||
| @@ -15,12 +29,15 @@ interface GestionarPromocionesSuscripcionModalProps { | |||||||
| } | } | ||||||
|  |  | ||||||
| const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => { | const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => { | ||||||
|     const [asignadas, setAsignadas] = useState<PromocionDto[]>([]); |     const [asignadas, setAsignadas] = useState<PromocionAsignadaDto[]>([]); | ||||||
|     const [disponibles, setDisponibles] = useState<PromocionDto[]>([]); |     const [disponibles, setDisponibles] = useState<PromocionDto[]>([]); | ||||||
|     const [selectedPromo, setSelectedPromo] = useState<number | string>(''); |  | ||||||
|     const [loading, setLoading] = useState(false); |     const [loading, setLoading] = useState(false); | ||||||
|     const [error, setError] = useState<string | null>(null); |     const [error, setError] = useState<string | null>(null); | ||||||
|  |  | ||||||
|  |     const [selectedPromo, setSelectedPromo] = useState<number | string>(''); | ||||||
|  |     const [vigenciaDesde, setVigenciaDesde] = useState(''); | ||||||
|  |     const [vigenciaHasta, setVigenciaHasta] = useState(''); | ||||||
|  |  | ||||||
|     const cargarDatos = useCallback(async () => { |     const cargarDatos = useCallback(async () => { | ||||||
|         if (!suscripcion) return; |         if (!suscripcion) return; | ||||||
|         setLoading(true); |         setLoading(true); | ||||||
| @@ -40,16 +57,30 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip | |||||||
|     }, [suscripcion]); |     }, [suscripcion]); | ||||||
|  |  | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         if (open) { |         if (open && suscripcion) { | ||||||
|             cargarDatos(); |             cargarDatos(); | ||||||
|  |             setSelectedPromo(''); | ||||||
|  |             setVigenciaDesde(suscripcion.fechaInicio); | ||||||
|  |             setVigenciaHasta(''); | ||||||
|         } |         } | ||||||
|     }, [open, cargarDatos]); |     }, [open, suscripcion]); | ||||||
|  |  | ||||||
|     const handleAsignar = async () => { |     const handleAsignar = async () => { | ||||||
|         if (!suscripcion || !selectedPromo) return; |         if (!suscripcion || !selectedPromo || !vigenciaDesde) { | ||||||
|  |             setError("Debe seleccionar una promoción y una fecha de inicio."); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         setError(null); | ||||||
|         try { |         try { | ||||||
|             await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, Number(selectedPromo)); |             const dto: AsignarPromocionDto = { | ||||||
|  |                 idPromocion: Number(selectedPromo), | ||||||
|  |                 vigenciaDesde: vigenciaDesde, | ||||||
|  |                 vigenciaHasta: vigenciaHasta || null | ||||||
|  |             }; | ||||||
|  |             await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, dto); | ||||||
|             setSelectedPromo(''); |             setSelectedPromo(''); | ||||||
|  |             setVigenciaDesde(suscripcion.fechaInicio); | ||||||
|  |             setVigenciaHasta(''); | ||||||
|             cargarDatos(); |             cargarDatos(); | ||||||
|         } catch (err: any) { |         } catch (err: any) { | ||||||
|             setError(err.response?.data?.message || "Error al asignar la promoción."); |             setError(err.response?.data?.message || "Error al asignar la promoción."); | ||||||
| @@ -58,12 +89,32 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip | |||||||
|  |  | ||||||
|     const handleQuitar = async (idPromocion: number) => { |     const handleQuitar = async (idPromocion: number) => { | ||||||
|         if (!suscripcion) return; |         if (!suscripcion) return; | ||||||
|  |         setError(null); | ||||||
|  |         if (window.confirm("¿Está seguro de que desea quitar esta promoción de la suscripción?")) { | ||||||
|             try { |             try { | ||||||
|                 await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion); |                 await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion); | ||||||
|                 cargarDatos(); |                 cargarDatos(); | ||||||
|             } catch (err: any) { |             } catch (err: any) { | ||||||
|                 setError(err.response?.data?.message || "Error al quitar la promoción."); |                 setError(err.response?.data?.message || "Error al quitar la promoción."); | ||||||
|             } |             } | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const formatDate = (dateString?: string | null) => { | ||||||
|  |         if (!dateString) return 'Indefinida'; | ||||||
|  |         const parts = dateString.split('-'); | ||||||
|  |         return `${parts[2]}/${parts[1]}/${parts[0]}`; | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const formatSecondaryText = (promo: PromocionAsignadaDto): string => { | ||||||
|  |         let text = ''; | ||||||
|  |         switch (promo.tipoEfecto) { | ||||||
|  |             case 'DescuentoPorcentajeTotal': text = `Descuento Total: ${promo.valorEfecto}%`; break; | ||||||
|  |             case 'DescuentoMontoFijoTotal': text = `Descuento Total: $${promo.valorEfecto.toFixed(2)}`; break; | ||||||
|  |             case 'BonificarEntregaDia': text = 'Bonificación de Día'; break; | ||||||
|  |             default: text = 'Tipo desconocido'; | ||||||
|  |         } | ||||||
|  |         return text; | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (!suscripcion) return null; |     if (!suscripcion) return null; | ||||||
| @@ -73,30 +124,39 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip | |||||||
|             <Box sx={modalStyle}> |             <Box sx={modalStyle}> | ||||||
|                 <Typography variant="h6">Gestionar Promociones</Typography> |                 <Typography variant="h6">Gestionar Promociones</Typography> | ||||||
|                 <Typography variant="body2" color="text.secondary" gutterBottom> |                 <Typography variant="body2" color="text.secondary" gutterBottom> | ||||||
|                     Suscripción a: {suscripcion.nombrePublicacion} |                     Suscripción a: <strong>{suscripcion.nombrePublicacion}</strong> | ||||||
|                 </Typography> |                 </Typography> | ||||||
|                 {error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} |                 {error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} | ||||||
|                 {loading ? <CircularProgress /> : ( |                 {loading ? <CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> : ( | ||||||
|                     <> |                     <> | ||||||
|                         <Typography sx={{ mt: 2 }}>Promociones Asignadas</Typography> |                         <Typography sx={{ mt: 2, fontWeight: 'medium' }}>Promociones Asignadas</Typography> | ||||||
|                         <List dense> |                         <List dense> | ||||||
|                             {asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>} |                             {asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>} | ||||||
|                             {asignadas.map(p => ( |                             {asignadas.map(p => ( | ||||||
|                                 <ListItem key={p.idPromocion} secondaryAction={<IconButton edge="end" onClick={() => handleQuitar(p.idPromocion)}><DeleteIcon /></IconButton>}> |                                 <ListItem key={p.idPromocion} secondaryAction={<IconButton edge="end" onClick={() => handleQuitar(p.idPromocion)}><DeleteIcon /></IconButton>}> | ||||||
|                                     <ListItemText primary={p.descripcion} secondary={`Tipo: ${p.tipoPromocion}, Valor: ${p.valor}`} /> |                                     <ListItemText | ||||||
|  |                                         primary={p.descripcion} | ||||||
|  |                                         secondary={`Vigente del ${formatDate(p.vigenciaDesdeAsignacion)} al ${formatDate(p.vigenciaHastaAsignacion)} - ${formatSecondaryText(p)}`} | ||||||
|  |                                     /> | ||||||
|                                 </ListItem> |                                 </ListItem> | ||||||
|                             ))} |                             ))} | ||||||
|                         </List> |                         </List> | ||||||
|                         <Divider sx={{ my: 2 }} /> |                         <Divider sx={{ my: 2 }} /> | ||||||
|                         <Typography>Asignar Nueva Promoción</Typography> |                         <Typography>Asignar Nueva Promoción</Typography> | ||||||
|                         <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}> |                         <Box sx={{ mt: 1 }}> | ||||||
|                             <FormControl fullWidth size="small"> |                             <FormControl fullWidth size="small" sx={{ mb: 2 }}> | ||||||
|                                 <InputLabel>Promociones Disponibles</InputLabel> |                                 <InputLabel>Promociones Disponibles</InputLabel> | ||||||
|                                 <Select value={selectedPromo} label="Promociones Disponibles" onChange={(e) => setSelectedPromo(e.target.value)}> |                                 <Select value={selectedPromo} label="Promociones Disponibles" onChange={(e: SelectChangeEvent<number | string>) => setSelectedPromo(e.target.value)}> | ||||||
|                                     {disponibles.map(p => <MenuItem key={p.idPromocion} value={p.idPromocion}>{p.descripcion}</MenuItem>)} |                                     {disponibles.map(p => <MenuItem key={p.idPromocion} value={p.idPromocion}>{p.descripcion}</MenuItem>)} | ||||||
|                                 </Select> |                                 </Select> | ||||||
|                             </FormControl> |                             </FormControl> | ||||||
|                             <Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo}><AddCircleOutlineIcon /></Button> |                             <Box sx={{ display: 'flex', gap: 2 }}> | ||||||
|  |                                 <TextField label="Vigencia Desde" type="date" value={vigenciaDesde} onChange={(e) => setVigenciaDesde(e.target.value)} required fullWidth size="small" InputLabelProps={{ shrink: true }} /> | ||||||
|  |                                 <TextField label="Vigencia Hasta (Opcional)" type="date" value={vigenciaHasta} onChange={(e) => setVigenciaHasta(e.target.value)} fullWidth size="small" InputLabelProps={{ shrink: true }} /> | ||||||
|  |                             </Box> | ||||||
|  |                             <Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo} sx={{ mt: 2 }} startIcon={<AddCircleOutlineIcon />}> | ||||||
|  |                                 Asignar | ||||||
|  |                             </Button> | ||||||
|                         </Box> |                         </Box> | ||||||
|                     </> |                     </> | ||||||
|                 )} |                 )} | ||||||
|   | |||||||
| @@ -0,0 +1,89 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { Modal, Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, Tooltip, IconButton, CircularProgress, Alert } from '@mui/material'; | ||||||
|  | import CloseIcon from '@mui/icons-material/Close'; | ||||||
|  | import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto'; | ||||||
|  |  | ||||||
|  | interface HistorialEnviosModalProps { | ||||||
|  |   open: boolean; | ||||||
|  |   onClose: () => void; | ||||||
|  |   logs: EmailLogDto[]; | ||||||
|  |   isLoading: boolean; | ||||||
|  |   error: string | null; | ||||||
|  |   titulo: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const modalStyle = { | ||||||
|  |   position: 'absolute' as 'absolute', | ||||||
|  |   top: '50%', left: '50%', | ||||||
|  |   transform: 'translate(-50%, -50%)', | ||||||
|  |   width: { xs: '95%', sm: '80%', md: '700px' }, | ||||||
|  |   bgcolor: 'background.paper', | ||||||
|  |   boxShadow: 24, p: 4, | ||||||
|  |   borderRadius: 2, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const HistorialEnviosModal: React.FC<HistorialEnviosModalProps> = ({ open, onClose, logs, isLoading, error, titulo }) => { | ||||||
|  |   const formatDisplayDateTime = (dateString: string): string => { | ||||||
|  |     if (!dateString) return ''; | ||||||
|  |     const date = new Date(dateString); | ||||||
|  |     return date.toLocaleString('es-AR', { | ||||||
|  |       day: '2-digit', month: '2-digit', year: 'numeric', | ||||||
|  |       hour: '2-digit', minute: '2-digit' | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Modal open={open} onClose={onClose}> | ||||||
|  |       <Box sx={modalStyle}> | ||||||
|  |         <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | ||||||
|  |           <Typography variant="h6" component="h2">{titulo}</Typography> | ||||||
|  |           <IconButton onClick={onClose}><CloseIcon /></IconButton> | ||||||
|  |         </Box> | ||||||
|  |         {isLoading ? ( | ||||||
|  |           <Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}><CircularProgress /></Box> | ||||||
|  |         ) : error ? ( | ||||||
|  |           <Alert severity="error">{error}</Alert> | ||||||
|  |         ) : ( | ||||||
|  |           <TableContainer component={Paper}> | ||||||
|  |             <Table size="small"> | ||||||
|  |               <TableHead> | ||||||
|  |                 <TableRow> | ||||||
|  |                   <TableCell sx={{ fontWeight: 'bold' }}>Fecha de Envío</TableCell> | ||||||
|  |                   <TableCell sx={{ fontWeight: 'bold' }}>Estado</TableCell> | ||||||
|  |                   <TableCell sx={{ fontWeight: 'bold' }}>Destinatario</TableCell> | ||||||
|  |                   <TableCell sx={{ fontWeight: 'bold' }}>Asunto</TableCell> | ||||||
|  |                 </TableRow> | ||||||
|  |               </TableHead> | ||||||
|  |               <TableBody> | ||||||
|  |                 {logs.length === 0 ? ( | ||||||
|  |                   <TableRow> | ||||||
|  |                     <TableCell colSpan={4} align="center">No se han registrado envíos.</TableCell> | ||||||
|  |                   </TableRow> | ||||||
|  |                 ) : ( | ||||||
|  |                   logs.map((log, index) => ( | ||||||
|  |                     <TableRow key={index}> | ||||||
|  |                       <TableCell>{formatDisplayDateTime(log.fechaEnvio)}</TableCell> | ||||||
|  |                       <TableCell> | ||||||
|  |                         <Tooltip title={log.estado === 'Fallido' ? (log.error || 'Error desconocido') : ''} arrow> | ||||||
|  |                           <Chip  | ||||||
|  |                             label={log.estado}  | ||||||
|  |                             color={log.estado === 'Enviado' ? 'success' : 'error'}  | ||||||
|  |                             size="small"  | ||||||
|  |                           /> | ||||||
|  |                         </Tooltip> | ||||||
|  |                       </TableCell> | ||||||
|  |                       <TableCell>{log.destinatarioEmail}</TableCell> | ||||||
|  |                       <TableCell>{log.asunto}</TableCell> | ||||||
|  |                     </TableRow> | ||||||
|  |                   )) | ||||||
|  |                 )} | ||||||
|  |               </TableBody> | ||||||
|  |             </Table> | ||||||
|  |           </TableContainer> | ||||||
|  |         )} | ||||||
|  |       </Box> | ||||||
|  |     </Modal> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default HistorialEnviosModal; | ||||||
| @@ -1,5 +1,3 @@ | |||||||
| // Archivo: Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx |  | ||||||
|  |  | ||||||
| import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material'; | import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material'; | ||||||
| import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto'; | import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto'; | ||||||
| @@ -54,7 +52,7 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | |||||||
|       fetchFormasDePago(); |       fetchFormasDePago(); | ||||||
|       setFormData({ |       setFormData({ | ||||||
|         idFactura: factura.idFactura, |         idFactura: factura.idFactura, | ||||||
|         monto: factura.importeFinal, |         monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto | ||||||
|         fechaPago: new Date().toISOString().split('T')[0] |         fechaPago: new Date().toISOString().split('T')[0] | ||||||
|       }); |       }); | ||||||
|       setLocalErrors({}); |       setLocalErrors({}); | ||||||
| @@ -64,8 +62,18 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | |||||||
|   const validate = (): boolean => { |   const validate = (): boolean => { | ||||||
|     const errors: { [key: string]: string | null } = {}; |     const errors: { [key: string]: string | null } = {}; | ||||||
|     if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago."; |     if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago."; | ||||||
|     if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero."; |  | ||||||
|     if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria."; |     if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria."; | ||||||
|  |      | ||||||
|  |     const monto = formData.monto ?? 0; | ||||||
|  |     const saldo = factura?.saldoPendiente ?? 0; | ||||||
|  |  | ||||||
|  |     if (monto <= 0) { | ||||||
|  |         errors.monto = "El monto debe ser mayor a cero."; | ||||||
|  |     } else if (monto > saldo) { | ||||||
|  |         // Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje | ||||||
|  |         errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     setLocalErrors(errors); |     setLocalErrors(errors); | ||||||
|     return Object.keys(errors).length === 0; |     return Object.keys(errors).length === 0; | ||||||
|   }; |   }; | ||||||
| @@ -109,8 +117,8 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | |||||||
|     <Modal open={open} onClose={onClose}> |     <Modal open={open} onClose={onClose}> | ||||||
|       <Box sx={modalStyle}> |       <Box sx={modalStyle}> | ||||||
|         <Typography variant="h6">Registrar Pago Manual</Typography> |         <Typography variant="h6">Registrar Pago Manual</Typography> | ||||||
|         <Typography variant="body2" color="text.secondary" gutterBottom> |         <Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}> | ||||||
|           Factura #{factura.idFactura} para {factura.nombreSuscriptor} |           Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)} | ||||||
|         </Typography> |         </Typography> | ||||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> |         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||||
|             <TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} /> |             <TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} /> | ||||||
|   | |||||||
| @@ -1,28 +1,33 @@ | |||||||
| import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, type SelectChangeEvent, InputAdornment } from '@mui/material'; | ||||||
|         FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, |  | ||||||
|         type SelectChangeEvent, InputAdornment } from '@mui/material'; |  | ||||||
| import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto'; | import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto'; | ||||||
| import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto'; | import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto'; | ||||||
|  |  | ||||||
| const modalStyle = { | const modalStyle = { | ||||||
|   position: 'absolute' as 'absolute', |   position: 'absolute' as 'absolute', | ||||||
|   top: '50%', |   top: '50%', left: '50%', | ||||||
|   left: '50%', |  | ||||||
|   transform: 'translate(-50%, -50%)', |   transform: 'translate(-50%, -50%)', | ||||||
|   width: { xs: '95%', sm: '80%', md: '600px' }, |   width: { xs: '95%', sm: '80%', md: '600px' }, | ||||||
|   bgcolor: 'background.paper', |   bgcolor: 'background.paper', | ||||||
|   border: '2px solid #000', |   border: '2px solid #000', | ||||||
|   boxShadow: 24, |   boxShadow: 24, p: 4, | ||||||
|   p: 4, |   maxHeight: '90vh', overflowY: 'auto' | ||||||
|   maxHeight: '90vh', |  | ||||||
|   overflowY: 'auto' |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const tiposPromocion = [ | const tiposEfecto = [ | ||||||
|     { value: 'Porcentaje', label: 'Descuento Porcentual (%)' }, |     { value: 'DescuentoPorcentajeTotal', label: 'Descuento en Porcentaje (%) sobre el total' }, | ||||||
|     { value: 'MontoFijo', label: 'Descuento de Monto Fijo ($)' }, |     { value: 'DescuentoMontoFijoTotal', label: 'Descuento en Monto Fijo ($) sobre el total' }, | ||||||
|     // { value: 'BonificacionDias', label: 'Bonificación de Días' }, // Descomentar para futuras implementaciones |     { value: 'BonificarEntregaDia', label: 'Bonificar / Día Gratis (Precio del día = $0)' }, | ||||||
|  | ]; | ||||||
|  | const tiposCondicion = [ | ||||||
|  |     { value: 'Siempre', label: 'Siempre (en todos los días de entrega)' }, | ||||||
|  |     { value: 'DiaDeSemana', label: 'Un día de la semana específico' }, | ||||||
|  |     { value: 'PrimerDiaSemanaDelMes', label: 'El primer día de la semana del mes' }, | ||||||
|  | ]; | ||||||
|  | const diasSemana = [ | ||||||
|  |     { value: 1, label: 'Lunes' }, { value: 2, label: 'Martes' }, { value: 3, label: 'Miércoles' }, | ||||||
|  |     { value: 4, label: 'Jueves' }, { value: 5, label: 'Viernes' }, { value: 6, label: 'Sábado' }, | ||||||
|  |     { value: 7, label: 'Domingo' } | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| interface PromocionFormModalProps { | interface PromocionFormModalProps { | ||||||
| @@ -38,18 +43,22 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose, | |||||||
|     const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({}); |     const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({}); | ||||||
|     const [loading, setLoading] = useState(false); |     const [loading, setLoading] = useState(false); | ||||||
|     const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); |     const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|      |  | ||||||
|     const isEditing = Boolean(initialData); |     const isEditing = Boolean(initialData); | ||||||
|  |  | ||||||
|  |     const necesitaValorCondicion = formData.tipoCondicion === 'DiaDeSemana' || formData.tipoCondicion === 'PrimerDiaSemanaDelMes'; | ||||||
|  |  | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         if (open) { |         if (open) { | ||||||
|             setFormData(initialData || { |             const defaults = { | ||||||
|                 descripcion: '', |                 descripcion: '', | ||||||
|                 tipoPromocion: 'Porcentaje', |                 tipoEfecto: 'DescuentoPorcentajeTotal' as const, | ||||||
|                 valor: 0, |                 valorEfecto: 0, | ||||||
|  |                 tipoCondicion: 'Siempre' as const, | ||||||
|  |                 valorCondicion: null, | ||||||
|                 fechaInicio: new Date().toISOString().split('T')[0], |                 fechaInicio: new Date().toISOString().split('T')[0], | ||||||
|                 activa: true |                 activa: true | ||||||
|             }); |             }; | ||||||
|  |             setFormData(initialData ? { ...initialData } : defaults); | ||||||
|             setLocalErrors({}); |             setLocalErrors({}); | ||||||
|         } |         } | ||||||
|     }, [open, initialData]); |     }, [open, initialData]); | ||||||
| @@ -57,10 +66,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose, | |||||||
|     const validate = (): boolean => { |     const validate = (): boolean => { | ||||||
|         const errors: { [key: string]: string | null } = {}; |         const errors: { [key: string]: string | null } = {}; | ||||||
|         if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.'; |         if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.'; | ||||||
|         if (!formData.tipoPromocion) errors.tipoPromocion = 'El tipo de promoción es obligatorio.'; |         if (!formData.tipoEfecto) errors.tipoEfecto = 'El tipo de efecto es obligatorio.'; | ||||||
|         if (!formData.valor || formData.valor <= 0) errors.valor = 'El valor debe ser mayor a cero.'; |         if (formData.tipoEfecto !== 'BonificarEntregaDia' && (!formData.valorEfecto || formData.valorEfecto <= 0)) { | ||||||
|         if (formData.tipoPromocion === 'Porcentaje' && (formData.valor ?? 0) > 100) { |             errors.valorEfecto = 'El valor debe ser mayor a cero.'; | ||||||
|             errors.valor = 'El valor para porcentaje no puede ser mayor a 100.'; |         } | ||||||
|  |         if (formData.tipoEfecto === 'DescuentoPorcentajeTotal' && formData.valorEfecto && formData.valorEfecto > 100) { | ||||||
|  |             errors.valorEfecto = 'El valor para porcentaje no puede ser mayor a 100.'; | ||||||
|  |         } | ||||||
|  |         if (!formData.tipoCondicion) errors.tipoCondicion = 'La condición es obligatoria.'; | ||||||
|  |         if (necesitaValorCondicion && !formData.valorCondicion) { | ||||||
|  |             errors.valorCondicion = "Debe seleccionar un día para esta condición."; | ||||||
|         } |         } | ||||||
|         if (!formData.fechaInicio) errors.fechaInicio = 'La fecha de inicio es obligatoria.'; |         if (!formData.fechaInicio) errors.fechaInicio = 'La fecha de inicio es obligatoria.'; | ||||||
|         if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) { |         if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) { | ||||||
| @@ -72,7 +87,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose, | |||||||
|  |  | ||||||
|     const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |     const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|         const { name, value, type, checked } = e.target; |         const { name, value, type, checked } = e.target; | ||||||
|         const finalValue = type === 'checkbox' ? checked : (type === 'number' ? parseFloat(value) : value); |         const finalValue = type === 'checkbox' ? checked : (name === 'valorEfecto' && value !== '' ? parseFloat(value) : value); | ||||||
|         setFormData(prev => ({ ...prev, [name]: finalValue })); |         setFormData(prev => ({ ...prev, [name]: finalValue })); | ||||||
|         if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); |         if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||||
|         if (errorMessage) clearErrorMessage(); |         if (errorMessage) clearErrorMessage(); | ||||||
| @@ -80,7 +95,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose, | |||||||
|      |      | ||||||
|     const handleSelectChange = (e: SelectChangeEvent<any>) => { |     const handleSelectChange = (e: SelectChangeEvent<any>) => { | ||||||
|         const { name, value } = e.target; |         const { name, value } = e.target; | ||||||
|         setFormData(prev => ({ ...prev, [name]: value })); |         const newFormData = { ...formData, [name]: value }; | ||||||
|  |  | ||||||
|  |         if (name === 'tipoCondicion' && value === 'Siempre') { | ||||||
|  |             newFormData.valorCondicion = null; | ||||||
|  |         } | ||||||
|  |         if (name === 'tipoEfecto' && value === 'BonificarEntregaDia') { | ||||||
|  |             newFormData.valorEfecto = 0; // Bonificar no necesita valor | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         setFormData(newFormData); | ||||||
|         if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); |         if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||||
|         if (errorMessage) clearErrorMessage(); |         if (errorMessage) clearErrorMessage(); | ||||||
|     }; |     }; | ||||||
| @@ -93,11 +117,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose, | |||||||
|         setLoading(true); |         setLoading(true); | ||||||
|         let success = false; |         let success = false; | ||||||
|         try { |         try { | ||||||
|             const dataToSubmit = { |             const dataToSubmit = { ...formData, fechaFin: formData.fechaFin || null } as CreatePromocionDto | UpdatePromocionDto; | ||||||
|                 ...formData, |  | ||||||
|                 fechaFin: formData.fechaFin || null |  | ||||||
|             } as CreatePromocionDto | UpdatePromocionDto; |  | ||||||
|              |  | ||||||
|             await onSubmit(dataToSubmit, initialData?.idPromocion); |             await onSubmit(dataToSubmit, initialData?.idPromocion); | ||||||
|             success = true; |             success = true; | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
| @@ -111,28 +131,39 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose, | |||||||
|     return ( |     return ( | ||||||
|         <Modal open={open} onClose={onClose}> |         <Modal open={open} onClose={onClose}> | ||||||
|             <Box sx={modalStyle}> |             <Box sx={modalStyle}> | ||||||
|                 <Typography variant="h6" component="h2">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography> |                 <Typography variant="h6">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography> | ||||||
|                 <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> |                 <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||||
|                     <TextField name="descripcion" label="Descripción" value={formData.descripcion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.descripcion} helperText={localErrors.descripcion} disabled={loading} autoFocus /> |                     <TextField name="descripcion" label="Descripción" value={formData.descripcion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.descripcion} helperText={localErrors.descripcion} disabled={loading} autoFocus /> | ||||||
|                      |                     <FormControl fullWidth margin="dense" error={!!localErrors.tipoEfecto}> | ||||||
|                     <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> |                         <InputLabel>Efecto de la Promoción</InputLabel> | ||||||
|                         <FormControl fullWidth margin="dense" sx={{flex: 2}} error={!!localErrors.tipoPromocion}> |                         <Select name="tipoEfecto" value={formData.tipoEfecto || ''} onChange={handleSelectChange} label="Efecto de la Promoción" disabled={loading}> | ||||||
|                             <InputLabel id="tipo-promo-label" required>Tipo</InputLabel> |                             {tiposEfecto.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)} | ||||||
|                             <Select name="tipoPromocion" labelId="tipo-promo-label" value={formData.tipoPromocion || ''} onChange={handleSelectChange} label="Tipo" disabled={loading}> |  | ||||||
|                                 {tiposPromocion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)} |  | ||||||
|                         </Select> |                         </Select> | ||||||
|                     </FormControl> |                     </FormControl> | ||||||
|                         <TextField name="valor" label="Valor" type="number" value={formData.valor || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{flex: 1}} error={!!localErrors.valor} helperText={localErrors.valor} disabled={loading}  |                     {formData.tipoEfecto !== 'BonificarEntregaDia' && ( | ||||||
|                             InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoPromocion === 'Porcentaje' ? '%' : '$'}</InputAdornment> }}  |                          <TextField name="valorEfecto" label="Valor" type="number" value={formData.valorEfecto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.valorEfecto} helperText={localErrors.valorEfecto} disabled={loading} | ||||||
|  |                             InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoEfecto === 'DescuentoPorcentajeTotal' ? '%' : '$'}</InputAdornment> }}  | ||||||
|                             inputProps={{ step: "0.01" }} |                             inputProps={{ step: "0.01" }} | ||||||
|                         /> |                         /> | ||||||
|                     </Box> |                     )} | ||||||
|                      |                     <FormControl fullWidth margin="dense" error={!!localErrors.tipoCondicion}> | ||||||
|  |                         <InputLabel>Condición de Aplicación</InputLabel> | ||||||
|  |                         <Select name="tipoCondicion" value={formData.tipoCondicion || ''} onChange={handleSelectChange} label="Condición de Aplicación" disabled={loading}> | ||||||
|  |                              {tiposCondicion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)} | ||||||
|  |                         </Select> | ||||||
|  |                     </FormControl> | ||||||
|  |                     {necesitaValorCondicion && ( | ||||||
|  |                         <FormControl fullWidth margin="dense" error={!!localErrors.valorCondicion}> | ||||||
|  |                             <InputLabel>Día de la Semana</InputLabel> | ||||||
|  |                             <Select name="valorCondicion" value={formData.valorCondicion || ''} onChange={handleSelectChange} label="Día de la Semana" disabled={loading}> | ||||||
|  |                                 {diasSemana.map(d => <MenuItem key={d.value} value={d.value}>{d.label}</MenuItem>)} | ||||||
|  |                             </Select> | ||||||
|  |                         </FormControl> | ||||||
|  |                     )} | ||||||
|                      <Box sx={{ display: 'flex', gap: 2, mt: 1 }}> |                      <Box sx={{ display: 'flex', gap: 2, mt: 1 }}> | ||||||
|                         <TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} /> |                         <TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} /> | ||||||
|                         <TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} /> |                         <TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} /> | ||||||
|                     </Box> |                     </Box> | ||||||
|  |  | ||||||
|                     <FormControlLabel control={<Checkbox name="activa" checked={formData.activa ?? true} onChange={handleInputChange} disabled={loading}/>} label="Promoción Activa" sx={{mt: 1}} /> |                     <FormControlLabel control={<Checkbox name="activa" checked={formData.activa ?? true} onChange={handleInputChange} disabled={loading}/>} label="Promoción Activa" sx={{mt: 1}} /> | ||||||
|  |  | ||||||
|                     {errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>} |                     {errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>} | ||||||
|   | |||||||
| @@ -0,0 +1,88 @@ | |||||||
|  | import React, { useState, useEffect, useMemo } from 'react'; | ||||||
|  | import { Box, Dialog, DialogTitle, DialogContent, IconButton, Tabs, Tab, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Paper, Chip, CircularProgress, Alert } from '@mui/material'; | ||||||
|  | import CloseIcon from '@mui/icons-material/Close'; | ||||||
|  | import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto'; | ||||||
|  | import facturacionService from '../../../services/Suscripciones/facturacionService'; | ||||||
|  |  | ||||||
|  | interface ResultadoEnvioModalProps { | ||||||
|  |   open: boolean; | ||||||
|  |   onClose: () => void; | ||||||
|  |   loteId: number | null; | ||||||
|  |   periodo: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const ResultadoEnvioModal: React.FC<ResultadoEnvioModalProps> = ({ open, onClose, loteId, periodo }) => { | ||||||
|  |   const [activeTab, setActiveTab] = useState(0); | ||||||
|  |   const [logs, setLogs] = useState<EmailLogDto[]>([]); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [error, setError] = useState<string | null>(null); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (open && loteId) { | ||||||
|  |       const fetchDetails = async () => { | ||||||
|  |         setLoading(true); | ||||||
|  |         setError(null); | ||||||
|  |         try { | ||||||
|  |           const data = await facturacionService.getDetallesLoteEnvio(loteId); | ||||||
|  |           setLogs(data); | ||||||
|  |         } catch (err) { | ||||||
|  |           setError('No se pudieron cargar los detalles del envío.'); | ||||||
|  |         } finally { | ||||||
|  |           setLoading(false); | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  |       fetchDetails(); | ||||||
|  |     } | ||||||
|  |   }, [open, loteId]); | ||||||
|  |  | ||||||
|  |   const filteredLogs = useMemo(() => { | ||||||
|  |     if (activeTab === 1) return logs.filter(log => log.estado === 'Enviado'); | ||||||
|  |     if (activeTab === 2) return logs.filter(log => log.estado === 'Fallido'); | ||||||
|  |     return logs; // Tab 0 es 'Todos' | ||||||
|  |   }, [logs, activeTab]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth> | ||||||
|  |       <DialogTitle> | ||||||
|  |         Detalle del Lote de Envío - Período {periodo} | ||||||
|  |         <IconButton onClick={onClose} sx={{ position: 'absolute', right: 8, top: 8 }}><CloseIcon /></IconButton> | ||||||
|  |       </DialogTitle> | ||||||
|  |       <DialogContent dividers> | ||||||
|  |         <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}> | ||||||
|  |           <Tabs value={activeTab} onChange={(_e, newValue) => setActiveTab(newValue)}> | ||||||
|  |             <Tab label={`Todos (${logs.length})`} /> | ||||||
|  |             <Tab label={`Enviados (${logs.filter(l => l.estado === 'Enviado').length})`} /> | ||||||
|  |             <Tab label={`Fallidos (${logs.filter(l => l.estado === 'Fallido').length})`} /> | ||||||
|  |           </Tabs> | ||||||
|  |         </Box> | ||||||
|  |  | ||||||
|  |         {loading ? <CircularProgress /> : error ? <Alert severity="error">{error}</Alert> : | ||||||
|  |         <TableContainer component={Paper}> | ||||||
|  |           <Table size="small"> | ||||||
|  |             <TableHead> | ||||||
|  |               <TableRow> | ||||||
|  |                 <TableCell>Destinatario</TableCell> | ||||||
|  |                 <TableCell>Asunto</TableCell> | ||||||
|  |                 <TableCell>Estado</TableCell> | ||||||
|  |                 <TableCell>Detalle del Error</TableCell> | ||||||
|  |               </TableRow> | ||||||
|  |             </TableHead> | ||||||
|  |             <TableBody> | ||||||
|  |               {filteredLogs.map((log, index) => ( | ||||||
|  |                 <TableRow key={index}> | ||||||
|  |                   <TableCell>{log.destinatarioEmail}</TableCell> | ||||||
|  |                   <TableCell>{log.asunto}</TableCell> | ||||||
|  |                   <TableCell><Chip label={log.estado} color={log.estado === 'Enviado' ? 'success' : 'error'} size="small" /></TableCell> | ||||||
|  |                   <TableCell sx={{ color: 'error.main' }}>{log.error || '-'}</TableCell> | ||||||
|  |                 </TableRow> | ||||||
|  |               ))} | ||||||
|  |             </TableBody> | ||||||
|  |           </Table> | ||||||
|  |         </TableContainer> | ||||||
|  |         } | ||||||
|  |       </DialogContent> | ||||||
|  |     </Dialog> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default ResultadoEnvioModal; | ||||||
| @@ -25,10 +25,10 @@ const modalStyle = { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| const dias = [ | const dias = [ | ||||||
|     { label: 'Lunes', value: 'L' }, { label: 'Martes', value: 'M' },  |   { label: 'Lunes', value: 'Lun' }, { label: 'Martes', value: 'Mar' }, | ||||||
|     { label: 'Miércoles', value: 'X' }, { label: 'Jueves', value: 'J' },  |   { label: 'Miércoles', value: 'Mie' }, { label: 'Jueves', value: 'Jue' }, | ||||||
|     { label: 'Viernes', value: 'V' }, { label: 'Sábado', value: 'S' },  |   { label: 'Viernes', value: 'Vie' }, { label: 'Sábado', value: 'Sab' }, | ||||||
|     { label: 'Domingo', value: 'D' } |   { label: 'Domingo', value: 'Dom' } | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| interface SuscripcionFormModalProps { | interface SuscripcionFormModalProps { | ||||||
| @@ -41,7 +41,6 @@ interface SuscripcionFormModalProps { | |||||||
|   clearErrorMessage: () => void; |   clearErrorMessage: () => void; | ||||||
| } | } | ||||||
|  |  | ||||||
| // Usamos una interfaz local que contenga todos los campos posibles del formulario |  | ||||||
| interface FormState { | interface FormState { | ||||||
|   idPublicacion?: number | ''; |   idPublicacion?: number | ''; | ||||||
|   fechaInicio?: string; |   fechaInicio?: string; | ||||||
| @@ -92,6 +91,13 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo | |||||||
|     const errors: { [key: string]: string | null } = {}; |     const errors: { [key: string]: string | null } = {}; | ||||||
|     if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación."; |     if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación."; | ||||||
|     if (!formData.fechaInicio?.trim()) errors.fechaInicio = 'La Fecha de Inicio es obligatoria.'; |     if (!formData.fechaInicio?.trim()) errors.fechaInicio = 'La Fecha de Inicio es obligatoria.'; | ||||||
|  |  | ||||||
|  |     // --- INICIO DE LA MODIFICACIÓN --- | ||||||
|  |     if (formData.estado !== 'Activa' && !formData.fechaFin) { | ||||||
|  |       errors.fechaFin = 'La Fecha de Fin es obligatoria si el estado es Pausada o Cancelada.'; | ||||||
|  |     } | ||||||
|  |     // --- FIN DE LA MODIFICACIÓN --- | ||||||
|  |  | ||||||
|     if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) { |     if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) { | ||||||
|       errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.'; |       errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.'; | ||||||
|     } |     } | ||||||
| @@ -123,6 +129,27 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo | |||||||
|     if (errorMessage) clearErrorMessage(); |     if (errorMessage) clearErrorMessage(); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   // --- INICIO DE LA MODIFICACIÓN --- | ||||||
|  |   const handleEstadoChange = (e: SelectChangeEvent<'Activa' | 'Pausada' | 'Cancelada'>) => { | ||||||
|  |     const nuevoEstado = e.target.value as 'Activa' | 'Pausada' | 'Cancelada'; | ||||||
|  |     const hoy = new Date().toISOString().split('T')[0]; | ||||||
|  |  | ||||||
|  |     setFormData(prev => { | ||||||
|  |       const newState = { ...prev, estado: nuevoEstado }; | ||||||
|  |  | ||||||
|  |       if ((nuevoEstado === 'Cancelada' || nuevoEstado === 'Pausada') && !prev.fechaFin) { | ||||||
|  |         newState.fechaFin = hoy; | ||||||
|  |       } else if (nuevoEstado === 'Activa') { | ||||||
|  |         newState.fechaFin = null; // Limpiar la fecha de fin si se reactiva | ||||||
|  |       } | ||||||
|  |       return newState; | ||||||
|  |     }); | ||||||
|  |     // Limpiar errores al cambiar | ||||||
|  |     if (localErrors.fechaFin) setLocalErrors(prev => ({ ...prev, fechaFin: null })); | ||||||
|  |     if (errorMessage) clearErrorMessage(); | ||||||
|  |   }; | ||||||
|  |   // --- FIN DE LA MODIFICACIÓN --- | ||||||
|  |  | ||||||
|   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { |   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     clearErrorMessage(); |     clearErrorMessage(); | ||||||
| @@ -133,7 +160,7 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo | |||||||
|     try { |     try { | ||||||
|       const dataToSubmit = { |       const dataToSubmit = { | ||||||
|         ...formData, |         ...formData, | ||||||
|         fechaFin: formData.fechaFin || null, |         fechaFin: formData.estado === 'Activa' ? null : formData.fechaFin, // Asegurarse de que fechaFin es null si está activa | ||||||
|         diasEntrega: Array.from(selectedDays), |         diasEntrega: Array.from(selectedDays), | ||||||
|       }; |       }; | ||||||
|  |  | ||||||
| @@ -173,11 +200,25 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo | |||||||
|  |  | ||||||
|           <Box sx={{ display: 'flex', gap: 2, mt: 1 }}> |           <Box sx={{ display: 'flex', gap: 2, mt: 1 }}> | ||||||
|             <TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} /> |             <TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} /> | ||||||
|                 <TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} /> |             <TextField | ||||||
|  |               name="fechaFin" | ||||||
|  |               label={formData.estado !== 'Activa' ? "Fecha Fin (Requerida)" : "Fecha Fin (Automática)"} | ||||||
|  |               type="date" | ||||||
|  |               value={formData.fechaFin || ''} | ||||||
|  |               onChange={handleInputChange} | ||||||
|  |               fullWidth | ||||||
|  |               margin="dense" | ||||||
|  |               InputLabelProps={{ shrink: true }} | ||||||
|  |               error={!!localErrors.fechaFin} | ||||||
|  |               helperText={localErrors.fechaFin} | ||||||
|  |               disabled={loading || formData.estado === 'Activa'} // Deshabilitado si está activa | ||||||
|  |               required={formData.estado !== 'Activa'} // Requerido si no está activa | ||||||
|  |             /> | ||||||
|           </Box> |           </Box> | ||||||
|  |  | ||||||
|           <FormControl fullWidth margin="dense"> |           <FormControl fullWidth margin="dense"> | ||||||
|             <InputLabel id="estado-label">Estado</InputLabel> |             <InputLabel id="estado-label">Estado</InputLabel> | ||||||
|                 <Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleSelectChange} label="Estado" disabled={loading}> |             <Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleEstadoChange} label="Estado" disabled={loading}> | ||||||
|               <MenuItem value="Activa">Activa</MenuItem> |               <MenuItem value="Activa">Activa</MenuItem> | ||||||
|               <MenuItem value="Pausada">Pausada</MenuItem> |               <MenuItem value="Pausada">Pausada</MenuItem> | ||||||
|               <MenuItem value="Cancelada">Cancelada</MenuItem> |               <MenuItem value="Cancelada">Cancelada</MenuItem> | ||||||
|   | |||||||
| @@ -1,7 +1,5 @@ | |||||||
| // Archivo: Frontend/src/components/Modals/Suscripciones/SuscriptorFormModal.tsx |  | ||||||
|  |  | ||||||
| import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material'; // 1. Importar SelectChangeEvent | import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material'; | ||||||
| import type { SuscriptorDto } from '../../../models/dtos/Suscripciones/SuscriptorDto'; | import type { SuscriptorDto } from '../../../models/dtos/Suscripciones/SuscriptorDto'; | ||||||
| import type { CreateSuscriptorDto } from '../../../models/dtos/Suscripciones/CreateSuscriptorDto'; | import type { CreateSuscriptorDto } from '../../../models/dtos/Suscripciones/CreateSuscriptorDto'; | ||||||
| import type { UpdateSuscriptorDto } from '../../../models/dtos/Suscripciones/UpdateSuscriptorDto'; | import type { UpdateSuscriptorDto } from '../../../models/dtos/Suscripciones/UpdateSuscriptorDto'; | ||||||
| @@ -31,9 +29,7 @@ interface SuscriptorFormModalProps { | |||||||
|   clearErrorMessage: () => void; |   clearErrorMessage: () => void; | ||||||
| } | } | ||||||
|  |  | ||||||
| const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ | const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage }) => { | ||||||
|   open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage |  | ||||||
| }) => { |  | ||||||
|   const [formData, setFormData] = useState<Partial<CreateSuscriptorDto>>({}); |   const [formData, setFormData] = useState<Partial<CreateSuscriptorDto>>({}); | ||||||
|   const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]); |   const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]); | ||||||
|   const [loading, setLoading] = useState(false); |   const [loading, setLoading] = useState(false); | ||||||
| @@ -59,9 +55,18 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ | |||||||
|  |  | ||||||
|     if (open) { |     if (open) { | ||||||
|       fetchFormasDePago(); |       fetchFormasDePago(); | ||||||
|       setFormData(initialData || { |       const dataParaFormulario: Partial<CreateSuscriptorDto> = { | ||||||
|         nombreCompleto: '', tipoDocumento: 'DNI', nroDocumento: '', cbu: '' |         nombreCompleto: initialData?.nombreCompleto || '', | ||||||
|       }); |         email: initialData?.email || '', | ||||||
|  |         telefono: initialData?.telefono || '', | ||||||
|  |         direccion: initialData?.direccion || '', | ||||||
|  |         tipoDocumento: initialData?.tipoDocumento || 'DNI', | ||||||
|  |         nroDocumento: initialData?.nroDocumento || '', | ||||||
|  |         cbu: initialData?.cbu || '', | ||||||
|  |         idFormaPagoPreferida: initialData?.idFormaPagoPreferida, | ||||||
|  |         observaciones: initialData?.observaciones || '' | ||||||
|  |       }; | ||||||
|  |       setFormData(dataParaFormulario); | ||||||
|       setLocalErrors({}); |       setLocalErrors({}); | ||||||
|     } |     } | ||||||
|   }, [open, initialData]); |   }, [open, initialData]); | ||||||
| @@ -73,36 +78,61 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ | |||||||
|     if (!formData.tipoDocumento) errors.tipoDocumento = 'El tipo de documento es obligatorio.'; |     if (!formData.tipoDocumento) errors.tipoDocumento = 'El tipo de documento es obligatorio.'; | ||||||
|     if (!formData.nroDocumento?.trim()) errors.nroDocumento = 'El número de documento es obligatorio.'; |     if (!formData.nroDocumento?.trim()) errors.nroDocumento = 'El número de documento es obligatorio.'; | ||||||
|     if (!formData.idFormaPagoPreferida) errors.idFormaPagoPreferida = 'La forma de pago es obligatoria.'; |     if (!formData.idFormaPagoPreferida) errors.idFormaPagoPreferida = 'La forma de pago es obligatoria.'; | ||||||
|     if (CBURequerido && (!formData.cbu || formData.cbu.trim().length !== 22)) { |  | ||||||
|       errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.'; |     // Validar formato de Nro de Documento (solo números) | ||||||
|  |     if (formData.nroDocumento && !/^[0-9]+$/.test(formData.nroDocumento)) { | ||||||
|  |       errors.nroDocumento = 'El documento solo debe contener números.'; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Validar formato de Email | ||||||
|     if (formData.email && !/^\S+@\S+\.\S+$/.test(formData.email)) { |     if (formData.email && !/^\S+@\S+\.\S+$/.test(formData.email)) { | ||||||
|       errors.email = 'El formato del email no es válido.'; |       errors.email = 'El formato del email no es válido.'; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Validar formato y longitud de CBU | ||||||
|  |     if (CBURequerido) { | ||||||
|  |       if (!formData.cbu || !/^[0-9]+$/.test(formData.cbu) || formData.cbu.length !== 22) { | ||||||
|  |         errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos numéricos.'; | ||||||
|  |       } | ||||||
|  |     } else if (formData.cbu && formData.cbu.trim().length > 0 && (!/^[0-9]+$/.test(formData.cbu) || formData.cbu.length !== 26)) { | ||||||
|  |       errors.cbu = 'El CBU debe tener 22 dígitos numéricos o estar vacío.'; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     setLocalErrors(errors); |     setLocalErrors(errors); | ||||||
|     return Object.keys(errors).length === 0; |     return Object.keys(errors).length === 0; | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   // --- HANDLER DE INPUT MEJORADO --- | ||||||
|   const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |   const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|     const { name, value } = e.target; |     const { name, value } = e.target; | ||||||
|  |  | ||||||
|  |     // Prevenir entrada de caracteres no numéricos para CBU y NroDocumento | ||||||
|  |     if (name === 'cbu' || name === 'nroDocumento') { | ||||||
|  |       const numericValue = value.replace(/[^0-9]/g, ''); | ||||||
|  |       setFormData(prev => ({ ...prev, [name]: numericValue })); | ||||||
|  |     } else { | ||||||
|       setFormData(prev => ({ ...prev, [name]: value })); |       setFormData(prev => ({ ...prev, [name]: value })); | ||||||
|     if (localErrors[name]) { |  | ||||||
|       setLocalErrors(prev => ({ ...prev, [name]: null })); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||||
|     if (errorMessage) clearErrorMessage(); |     if (errorMessage) clearErrorMessage(); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   // 2. Crear un handler específico para los Select |  | ||||||
|   const handleSelectChange = (e: SelectChangeEvent<any>) => { |   const handleSelectChange = (e: SelectChangeEvent<any>) => { | ||||||
|     const { name, value } = e.target; |     const { name, value } = e.target; | ||||||
|     setFormData(prev => ({ ...prev, [name]: value })); |     const newFormData = { ...formData, [name]: value }; | ||||||
|     if (localErrors[name]) { |  | ||||||
|       setLocalErrors(prev => ({ ...prev, [name]: null })); |     if (name === 'idFormaPagoPreferida') { | ||||||
|  |       const formaDePagoSeleccionada = formasDePago.find(fp => fp.idFormaPago === value); | ||||||
|  |       if (formaDePagoSeleccionada && !formaDePagoSeleccionada.requiereCBU) { | ||||||
|  |         newFormData.cbu = ''; | ||||||
|       } |       } | ||||||
|  |     } | ||||||
|  |     setFormData(newFormData); | ||||||
|  |     if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||||
|     if (errorMessage) clearErrorMessage(); |     if (errorMessage) clearErrorMessage(); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |  | ||||||
|   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { |   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
|     clearErrorMessage(); |     clearErrorMessage(); | ||||||
| @@ -111,7 +141,12 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ | |||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     let success = false; |     let success = false; | ||||||
|     try { |     try { | ||||||
|       const dataToSubmit = formData as CreateSuscriptorDto | UpdateSuscriptorDto; |       const dataToSubmit = { | ||||||
|  |         ...formData, | ||||||
|  |         idFormaPagoPreferida: Number(formData.idFormaPagoPreferida), | ||||||
|  |         cbu: formData.cbu?.trim() || null | ||||||
|  |       } as CreateSuscriptorDto | UpdateSuscriptorDto; | ||||||
|  |  | ||||||
|       await onSubmit(dataToSubmit, initialData?.idSuscriptor); |       await onSubmit(dataToSubmit, initialData?.idSuscriptor); | ||||||
|       success = true; |       success = true; | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
| @@ -140,26 +175,47 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ | |||||||
|           <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> |           <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> | ||||||
|             <FormControl margin="dense" sx={{ minWidth: 120 }}> |             <FormControl margin="dense" sx={{ minWidth: 120 }}> | ||||||
|               <InputLabel id="tipo-doc-label">Tipo</InputLabel> |               <InputLabel id="tipo-doc-label">Tipo</InputLabel> | ||||||
|               {/* 3. Aplicar el nuevo handler a los Selects */} |  | ||||||
|               <Select labelId="tipo-doc-label" name="tipoDocumento" value={formData.tipoDocumento || 'DNI'} onChange={handleSelectChange} label="Tipo" disabled={loading}> |               <Select labelId="tipo-doc-label" name="tipoDocumento" value={formData.tipoDocumento || 'DNI'} onChange={handleSelectChange} label="Tipo" disabled={loading}> | ||||||
|                 <MenuItem value="DNI">DNI</MenuItem> |                 <MenuItem value="DNI">DNI</MenuItem> | ||||||
|                 <MenuItem value="CUIT">CUIT</MenuItem> |                 <MenuItem value="CUIT">CUIT</MenuItem> | ||||||
|                 <MenuItem value="CUIL">CUIL</MenuItem> |                 <MenuItem value="CUIL">CUIL</MenuItem> | ||||||
|               </Select> |               </Select> | ||||||
|             </FormControl> |             </FormControl> | ||||||
|             <TextField name="nroDocumento" label="Nro Documento" value={formData.nroDocumento || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{ flex: 2 }} error={!!localErrors.nroDocumento} helperText={localErrors.nroDocumento} disabled={loading} /> |             <TextField name="nroDocumento" label="Nro Documento" value={formData.nroDocumento || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{ flex: 2 }} error={!!localErrors.nroDocumento} helperText={localErrors.nroDocumento} disabled={loading} inputProps={{ maxLength: 11 }} /> | ||||||
|           </Box> |           </Box> | ||||||
|           <FormControl fullWidth margin="dense" error={!!localErrors.idFormaPagoPreferida}> |           <FormControl fullWidth margin="dense" error={!!localErrors.idFormaPagoPreferida}> | ||||||
|             <InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel> |             <InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel> | ||||||
|             {/* 3. Aplicar el nuevo handler a los Selects */} |             <Select | ||||||
|             <Select labelId="forma-pago-label" name="idFormaPagoPreferida" value={formData.idFormaPagoPreferida || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loading || loadingFormasPago}> |               labelId="forma-pago-label" | ||||||
|  |               name="idFormaPagoPreferida" | ||||||
|  |               value={loadingFormasPago ? '' : formData.idFormaPagoPreferida || ''} | ||||||
|  |               onChange={handleSelectChange} | ||||||
|  |               label="Forma de Pago" | ||||||
|  |               disabled={loading || loadingFormasPago} | ||||||
|  |             > | ||||||
|  |               {loadingFormasPago && <MenuItem value=""><em>Cargando...</em></MenuItem>} | ||||||
|  |  | ||||||
|               {formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)} |               {formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)} | ||||||
|             </Select> |             </Select> | ||||||
|             {localErrors.idFormaPagoPreferida && <Typography color="error" variant="caption">{localErrors.idFormaPagoPreferida}</Typography>} |             {localErrors.idFormaPagoPreferida && <Typography color="error" variant="caption">{localErrors.idFormaPagoPreferida}</Typography>} | ||||||
|           </FormControl> |           </FormControl> | ||||||
|  |  | ||||||
|           {CBURequerido && ( |           {CBURequerido && ( | ||||||
|             <TextField name="cbu" label="CBU" value={formData.cbu || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.cbu} helperText={localErrors.cbu} disabled={loading} inputProps={{ maxLength: 22 }} /> |             <TextField | ||||||
|  |               name="cbu" | ||||||
|  |               label="CBU" | ||||||
|  |               value={formData.cbu || ''} | ||||||
|  |               onChange={handleInputChange} | ||||||
|  |               required | ||||||
|  |               fullWidth | ||||||
|  |               margin="dense" | ||||||
|  |               error={!!localErrors.cbu} | ||||||
|  |               helperText={localErrors.cbu} | ||||||
|  |               disabled={loading} | ||||||
|  |               inputProps={{ maxLength: 22 }} | ||||||
|  |             /> | ||||||
|           )} |           )} | ||||||
|  |  | ||||||
|           <TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} /> |           <TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} /> | ||||||
|  |  | ||||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} |           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								Frontend/src/models/dtos/Comunicaciones/EmailLogDto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Frontend/src/models/dtos/Comunicaciones/EmailLogDto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | export interface EmailLogDto { | ||||||
|  |   fechaEnvio: string; // Formato ISO de fecha y hora | ||||||
|  |   estado: 'Enviado' | 'Fallido'; | ||||||
|  |   asunto: string; | ||||||
|  |   destinatarioEmail: string; | ||||||
|  |   error?: string | null; | ||||||
|  |   nombreUsuarioDisparo?: string | null; | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								Frontend/src/models/dtos/Comunicaciones/LoteDeEnvioDto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								Frontend/src/models/dtos/Comunicaciones/LoteDeEnvioDto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | import type { EmailLogDto } from "./EmailLogDto"; | ||||||
|  |  | ||||||
|  | // Representa el resumen inmediato que se muestra tras el cierre | ||||||
|  | export interface LoteDeEnvioResumenDto { | ||||||
|  |   idLoteDeEnvio: number; | ||||||
|  |   periodo: string; | ||||||
|  |   totalCorreos: number; | ||||||
|  |   totalEnviados: number; | ||||||
|  |   totalFallidos: number; | ||||||
|  |   erroresDetallados: EmailLogDto[]; // Lista de errores inmediatos | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Representa una fila en la tabla de historial | ||||||
|  | export interface LoteDeEnvioHistorialDto { | ||||||
|  |   idLoteDeEnvio: number; | ||||||
|  |   fechaInicio: string; | ||||||
|  |   periodo: string; | ||||||
|  |   estado: string; | ||||||
|  |   totalCorreos: number; | ||||||
|  |   totalEnviados: number; | ||||||
|  |   totalFallidos: number; | ||||||
|  |   nombreUsuarioDisparo: string; | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								Frontend/src/models/dtos/Suscripciones/AjusteDto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								Frontend/src/models/dtos/Suscripciones/AjusteDto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | export interface AjusteDto { | ||||||
|  |   idAjuste: number; | ||||||
|  |   fechaAjuste: string; | ||||||
|  |   idSuscriptor: number; | ||||||
|  |   idEmpresa: number; | ||||||
|  |   nombreEmpresa?: string; | ||||||
|  |   tipoAjuste: 'Credito' | 'Debito'; | ||||||
|  |   monto: number; | ||||||
|  |   motivo: string; | ||||||
|  |   estado: 'Pendiente' | 'Aplicado' | 'Anulado'; | ||||||
|  |   idFacturaAplicado?: number | null; | ||||||
|  |   numeroFacturaAplicado?: string | null; | ||||||
|  |   fechaAlta: string; // "yyyy-MM-dd HH:mm" | ||||||
|  |   nombreUsuarioAlta: string; | ||||||
|  | } | ||||||
| @@ -0,0 +1,5 @@ | |||||||
|  | export interface AsignarPromocionDto { | ||||||
|  |     idPromocion: number; | ||||||
|  |     vigenciaDesde: string; // "yyyy-MM-dd" | ||||||
|  |     vigenciaHasta?: string | null; | ||||||
|  | } | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | export interface CreateAjusteDto { | ||||||
|  |   idEmpresa: number; | ||||||
|  |   fechaAjuste: string; | ||||||
|  |   idSuscriptor: number; | ||||||
|  |   tipoAjuste: 'Credito' | 'Debito'; | ||||||
|  |   monto: number; | ||||||
|  |   motivo: string; | ||||||
|  | } | ||||||
| @@ -1,7 +1,9 @@ | |||||||
| export interface CreatePromocionDto { | export interface CreatePromocionDto { | ||||||
|   descripcion: string; |   descripcion: string; | ||||||
|   tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias'; |   tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia'; | ||||||
|   valor: number; |   valorEfecto: number; | ||||||
|  |   tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes'; | ||||||
|  |   valorCondicion?: number | null; | ||||||
|   fechaInicio: string; // "yyyy-MM-dd" |   fechaInicio: string; // "yyyy-MM-dd" | ||||||
|   fechaFin?: string | null; |   fechaFin?: string | null; | ||||||
|   activa: boolean; |   activa: boolean; | ||||||
|   | |||||||
| @@ -4,6 +4,6 @@ export interface CreateSuscripcionDto { | |||||||
|   fechaInicio: string; // "yyyy-MM-dd" |   fechaInicio: string; // "yyyy-MM-dd" | ||||||
|   fechaFin?: string | null; |   fechaFin?: string | null; | ||||||
|   estado: 'Activa' | 'Pausada' | 'Cancelada'; |   estado: 'Activa' | 'Pausada' | 'Cancelada'; | ||||||
|   diasEntrega: string[]; // ["L", "M", "X"] |   diasEntrega: string[]; // ["Lun", "Mar", "Mie"] | ||||||
|   observaciones?: string | null; |   observaciones?: string | null; | ||||||
| } | } | ||||||
| @@ -1,14 +1,20 @@ | |||||||
|  | export interface FacturaDetalleDto { | ||||||
|  |     descripcion: string; | ||||||
|  |     importeNeto: number; | ||||||
|  | } | ||||||
|  |  | ||||||
| export interface FacturaDto { | export interface FacturaDto { | ||||||
|   idFactura: number; |   idFactura: number; | ||||||
|   idSuscripcion: number; |   idSuscriptor: number; | ||||||
|   periodo: string; // "YYYY-MM" |   periodo: string; | ||||||
|   fechaEmision: string; // "yyyy-MM-dd" |   fechaEmision: string; | ||||||
|   fechaVencimiento: string; // "yyyy-MM-dd" |   fechaVencimiento: string; | ||||||
|   importeFinal: number; |   importeFinal: number; | ||||||
|   estado: string; |   totalPagado: number; | ||||||
|  |   saldoPendiente: number; | ||||||
|  |   estadoPago: string; | ||||||
|  |   estadoFacturacion: string; | ||||||
|   numeroFactura?: string | null; |   numeroFactura?: string | null; | ||||||
|    |  | ||||||
|   // Datos enriquecidos para la UI |  | ||||||
|   nombreSuscriptor: string; |   nombreSuscriptor: string; | ||||||
|   nombrePublicacion: string; |   detalles: FacturaDetalleDto[]; // <-- AÑADIR ESTA LÍNEA | ||||||
| } | } | ||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | import { type PromocionDto } from "./PromocionDto"; | ||||||
|  |  | ||||||
|  | export interface PromocionAsignadaDto extends PromocionDto { | ||||||
|  |     vigenciaDesdeAsignacion: string; // "yyyy-MM-dd" | ||||||
|  |     vigenciaHastaAsignacion?: string | null; | ||||||
|  | } | ||||||
| @@ -1,8 +1,10 @@ | |||||||
| export interface PromocionDto { | export interface PromocionDto { | ||||||
|   idPromocion: number; |   idPromocion: number; | ||||||
|   descripcion: string; |   descripcion: string; | ||||||
|   tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias'; |   tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia'; | ||||||
|   valor: number; |   valorEfecto: number; | ||||||
|  |   tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes'; | ||||||
|  |   valorCondicion?: number | null; | ||||||
|   fechaInicio: string; // "yyyy-MM-dd" |   fechaInicio: string; // "yyyy-MM-dd" | ||||||
|   fechaFin?: string | null; |   fechaFin?: string | null; | ||||||
|   activa: boolean; |   activa: boolean; | ||||||
|   | |||||||
| @@ -0,0 +1,27 @@ | |||||||
|  | // DTO para el detalle de cada línea dentro de una factura (cada suscripción) | ||||||
|  | export interface FacturaDetalleDto { | ||||||
|  |     descripcion: string; | ||||||
|  |     importeNeto: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // DTO para cada factura individual (por empresa) dentro del resumen consolidado | ||||||
|  | export interface FacturaConsolidadaDto { | ||||||
|  |     idFactura: number; | ||||||
|  |     nombreEmpresa: string; | ||||||
|  |     importeFinal: number; | ||||||
|  |     estadoPago: string; | ||||||
|  |     estadoFacturacion: string; | ||||||
|  |     numeroFactura?: string | null; | ||||||
|  |     detalles: FacturaDetalleDto[]; | ||||||
|  |     // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers | ||||||
|  |     idSuscriptor: number;  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // DTO principal que agrupa todo por suscriptor para la vista de consulta | ||||||
|  | export interface ResumenCuentaSuscriptorDto { | ||||||
|  |     idSuscriptor: number; | ||||||
|  |     nombreSuscriptor: string; | ||||||
|  |     saldoPendienteTotal: number; | ||||||
|  |     importeTotal: number; | ||||||
|  |     facturas: FacturaConsolidadaDto[]; | ||||||
|  | } | ||||||
| @@ -6,6 +6,6 @@ export interface SuscripcionDto { | |||||||
|   fechaInicio: string; // "yyyy-MM-dd" |   fechaInicio: string; // "yyyy-MM-dd" | ||||||
|   fechaFin?: string | null; |   fechaFin?: string | null; | ||||||
|   estado: 'Activa' | 'Pausada' | 'Cancelada'; |   estado: 'Activa' | 'Pausada' | 'Cancelada'; | ||||||
|   diasEntrega: string; // "L,M,X,J,V,S,D" |   diasEntrega: string; // "Lun,Mar,Mie,Jue,Vie,Sab,Dom" | ||||||
|   observaciones?: string | null; |   observaciones?: string | null; | ||||||
| } | } | ||||||
| @@ -0,0 +1,7 @@ | |||||||
|  | export interface UpdateAjusteDto { | ||||||
|  |   idEmpresa: number; | ||||||
|  |   fechaAjuste: string; // "yyyy-MM-dd" | ||||||
|  |   tipoAjuste: 'Credito' | 'Debito'; | ||||||
|  |   monto: number; | ||||||
|  |   motivo: string; | ||||||
|  | } | ||||||
| @@ -0,0 +1,68 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { Box, Alert, Paper } from '@mui/material'; | ||||||
|  | import reporteService from '../../services/Reportes/reportesService'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import SeleccionaReporteDistribucionSuscripciones from './SeleccionaReporteDistribucionSuscripciones'; | ||||||
|  |  | ||||||
|  | const ReporteDistribucionSuscripcionesPage: React.FC = () => { | ||||||
|  |     const [loading, setLoading] = useState(false); | ||||||
|  |     const [apiError, setApiError] = useState<string | null>(null); | ||||||
|  |      | ||||||
|  |     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |     const puedeVerReporte = isSuperAdmin || tienePermiso("RR011"); | ||||||
|  |  | ||||||
|  |     const handleGenerateReport = async (params: { fechaDesde: string; fechaHasta: string; }) => { | ||||||
|  |         setLoading(true); | ||||||
|  |         setApiError(null); | ||||||
|  |         try { | ||||||
|  |             const { fileContent, fileName } = await reporteService.getReporteDistribucionSuscripcionesPdf(params.fechaDesde, params.fechaHasta); | ||||||
|  |              | ||||||
|  |             const url = window.URL.createObjectURL(new Blob([fileContent], { type: 'application/pdf' })); | ||||||
|  |             const link = document.createElement('a'); | ||||||
|  |             link.href = url; | ||||||
|  |             link.setAttribute('download', fileName); | ||||||
|  |             document.body.appendChild(link); | ||||||
|  |             link.click(); | ||||||
|  |             link.parentNode?.removeChild(link); | ||||||
|  |             window.URL.revokeObjectURL(url); | ||||||
|  |  | ||||||
|  |         } catch (err: any) { | ||||||
|  |             let message = 'Ocurrió un error al generar el reporte.'; | ||||||
|  |             if (axios.isAxiosError(err) && err.response) { | ||||||
|  |                 if (err.response.status === 404) { | ||||||
|  |                     message = "No se encontraron datos para los parámetros seleccionados."; | ||||||
|  |                 } else if (err.response.data instanceof Blob && err.response.data.type === "application/json") { | ||||||
|  |                     const errorText = await err.response.data.text(); | ||||||
|  |                     try { | ||||||
|  |                         const errorJson = JSON.parse(errorText); | ||||||
|  |                         message = errorJson.message || message; | ||||||
|  |                     } catch {  | ||||||
|  |                         message = errorText || message;  | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             setApiError(message); | ||||||
|  |         } finally { | ||||||
|  |             setLoading(false); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     if (!puedeVerReporte) { | ||||||
|  |         return <Alert severity="error">No tiene permiso para ver este reporte.</Alert>; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', pt: 4 }}> | ||||||
|  |             <Paper elevation={3} sx={{ borderRadius: '8px' }}> | ||||||
|  |                  <SeleccionaReporteDistribucionSuscripciones | ||||||
|  |                     onGenerarReporte={handleGenerateReport} | ||||||
|  |                     isLoading={loading} | ||||||
|  |                     apiErrorMessage={apiError} | ||||||
|  |                 /> | ||||||
|  |             </Paper> | ||||||
|  |         </Box> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default ReporteDistribucionSuscripcionesPage; | ||||||
| @@ -0,0 +1,70 @@ | |||||||
|  | // Archivo: Frontend/src/pages/Reportes/ReporteFacturasPublicidadPage.tsx | ||||||
|  |  | ||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { Box, Alert, Paper } from '@mui/material'; | ||||||
|  | import reporteService from '../../services/Reportes/reportesService'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import SeleccionaReporteFacturasPublicidad from './SeleccionaReporteFacturasPublicidad'; | ||||||
|  |  | ||||||
|  | const ReporteFacturasPublicidadPage: React.FC = () => { | ||||||
|  |     const [loading, setLoading] = useState(false); | ||||||
|  |     const [apiError, setApiError] = useState<string | null>(null); | ||||||
|  |      | ||||||
|  |     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |     const puedeVerReporte = isSuperAdmin || tienePermiso("RR010"); | ||||||
|  |  | ||||||
|  |     const handleGenerateReport = async (params: { anio: number; mes: number; }) => { | ||||||
|  |         setLoading(true); | ||||||
|  |         setApiError(null); | ||||||
|  |         try { | ||||||
|  |             const { fileContent, fileName } = await reporteService.getReporteFacturasPublicidadPdf(params.anio, params.mes); | ||||||
|  |              | ||||||
|  |             const url = window.URL.createObjectURL(new Blob([fileContent], { type: 'application/pdf' })); | ||||||
|  |             const link = document.createElement('a'); | ||||||
|  |             link.href = url; | ||||||
|  |             link.setAttribute('download', fileName); | ||||||
|  |             document.body.appendChild(link); | ||||||
|  |             link.click(); | ||||||
|  |             link.parentNode?.removeChild(link); | ||||||
|  |             window.URL.revokeObjectURL(url); | ||||||
|  |  | ||||||
|  |         } catch (err: any) { | ||||||
|  |             let message = 'Ocurrió un error al generar el reporte.'; | ||||||
|  |             if (axios.isAxiosError(err) && err.response) { | ||||||
|  |                 if (err.response.status === 404) { | ||||||
|  |                     message = "No se encontraron datos para los parámetros seleccionados."; | ||||||
|  |                 } else if (err.response.data instanceof Blob && err.response.data.type === "application/json") { | ||||||
|  |                     const errorText = await err.response.data.text(); | ||||||
|  |                     try { | ||||||
|  |                         const errorJson = JSON.parse(errorText); | ||||||
|  |                         message = errorJson.message || message; | ||||||
|  |                     } catch {  | ||||||
|  |                         message = errorText || message;  | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             setApiError(message); | ||||||
|  |         } finally { | ||||||
|  |             setLoading(false); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     if (!puedeVerReporte) { | ||||||
|  |         return <Alert severity="error">No tiene permiso para ver este reporte.</Alert>; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', pt: 4 }}> | ||||||
|  |             <Paper elevation={3} sx={{ borderRadius: '8px' }}> | ||||||
|  |                  <SeleccionaReporteFacturasPublicidad | ||||||
|  |                     onGenerarReporte={handleGenerateReport} | ||||||
|  |                     isLoading={loading} | ||||||
|  |                     apiErrorMessage={apiError} | ||||||
|  |                 /> | ||||||
|  |             </Paper> | ||||||
|  |         </Box> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default ReporteFacturasPublicidadPage; | ||||||
| @@ -23,6 +23,8 @@ const allReportModules: { category: string; label: string; path: string }[] = [ | |||||||
|   { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' }, |   { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' }, | ||||||
|   { category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' }, |   { category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' }, | ||||||
|   { category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' }, |   { category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' }, | ||||||
|  |   { category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' }, | ||||||
|  |   { category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion' }, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| const predefinedCategoryOrder = [ | const predefinedCategoryOrder = [ | ||||||
| @@ -30,6 +32,7 @@ const predefinedCategoryOrder = [ | |||||||
|   'Listados Distribución', |   'Listados Distribución', | ||||||
|   'Ctrl. Devoluciones', |   'Ctrl. Devoluciones', | ||||||
|   'Novedades de Canillitas', |   'Novedades de Canillitas', | ||||||
|  |   'Suscripciones', | ||||||
|   'Existencia Papel', |   'Existencia Papel', | ||||||
|   'Movimientos Bobinas', |   'Movimientos Bobinas', | ||||||
|   'Consumos Bobinas', |   'Consumos Bobinas', | ||||||
|   | |||||||
| @@ -0,0 +1,63 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { Box, Typography, Button, CircularProgress, Alert, TextField } from '@mui/material'; | ||||||
|  |  | ||||||
|  | interface SeleccionaReporteProps { | ||||||
|  |   onGenerarReporte: (params: { fechaDesde: string, fechaHasta: string }) => Promise<void>; | ||||||
|  |   isLoading?: boolean; | ||||||
|  |   apiErrorMessage?: string | null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const SeleccionaReporteDistribucionSuscripciones: React.FC<SeleccionaReporteProps> = ({ | ||||||
|  |   onGenerarReporte, | ||||||
|  |   isLoading, | ||||||
|  |   apiErrorMessage | ||||||
|  | }) => { | ||||||
|  |   const [fechaDesde, setFechaDesde] = useState(new Date().toISOString().split('T')[0]); | ||||||
|  |   const [fechaHasta, setFechaHasta] = useState(new Date().toISOString().split('T')[0]); | ||||||
|  |  | ||||||
|  |   const handleGenerar = () => { | ||||||
|  |     onGenerarReporte({ fechaDesde, fechaHasta }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 400 }}> | ||||||
|  |       <Typography variant="h6" gutterBottom> | ||||||
|  |         Reporte de Distribución de Suscripciones | ||||||
|  |       </Typography> | ||||||
|  |       <Typography variant="body2" color="text.secondary" sx={{mb: 2}}> | ||||||
|  |         Seleccione un rango de fechas para generar el listado de suscriptores activos y sus detalles de entrega. | ||||||
|  |       </Typography> | ||||||
|  |  | ||||||
|  |       <Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}> | ||||||
|  |         <TextField | ||||||
|  |             label="Fecha Desde" | ||||||
|  |             type="date" | ||||||
|  |             fullWidth | ||||||
|  |             value={fechaDesde} | ||||||
|  |             onChange={(e) => setFechaDesde(e.target.value)} | ||||||
|  |             InputLabelProps={{ shrink: true }} | ||||||
|  |             disabled={isLoading} | ||||||
|  |         /> | ||||||
|  |         <TextField | ||||||
|  |             label="Fecha Hasta" | ||||||
|  |             type="date" | ||||||
|  |             fullWidth | ||||||
|  |             value={fechaHasta} | ||||||
|  |             onChange={(e) => setFechaHasta(e.target.value)} | ||||||
|  |             InputLabelProps={{ shrink: true }} | ||||||
|  |             disabled={isLoading} | ||||||
|  |         /> | ||||||
|  |       </Box> | ||||||
|  |  | ||||||
|  |       {apiErrorMessage && <Alert severity="error" sx={{ mt: 2 }}>{apiErrorMessage}</Alert>} | ||||||
|  |  | ||||||
|  |       <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||||
|  |         <Button onClick={handleGenerar} variant="contained" disabled={isLoading}> | ||||||
|  |           {isLoading ? <CircularProgress size={24} /> : 'Generar Reporte'} | ||||||
|  |         </Button> | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default SeleccionaReporteDistribucionSuscripciones; | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user