From 899e0a173f65c1f0b6614044f4ed6a3432270493 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 8 Aug 2025 09:48:15 -0300 Subject: [PATCH] =?UTF-8?q?Refactor:=20Mejora=20la=20l=C3=B3gica=20de=20fa?= =?UTF-8?q?cturaci=C3=B3n=20y=20la=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Este commit introduce una refactorización significativa en el módulo de suscripciones para alinear el sistema con reglas de negocio clave: facturación consolidada por empresa, cobro a mes adelantado con imputación de ajustes diferida, y una interfaz de usuario más clara. Backend: - **Facturación por Empresa:** Se modifica `FacturacionService` para agrupar las suscripciones por cliente y empresa, generando una factura consolidada para cada combinación. Esto asegura la correcta separación fiscal. - **Imputación de Ajustes:** Se ajusta la lógica para que la facturación de un período (ej. Septiembre) aplique únicamente los ajustes pendientes cuya fecha corresponde al período anterior (Agosto). - **Cierre Secuencial:** Se implementa una validación en `GenerarFacturacionMensual` que impide generar la facturación de un período si el anterior no ha sido cerrado, garantizando el orden cronológico. - **Emails Consolidados:** El proceso de notificación automática al generar el cierre ahora envía un único email consolidado por suscriptor, detallando los cargos de todas sus facturas/empresas. - **Envío de PDF Individual:** Se refactoriza el endpoint de envío manual para que opere sobre una `idFactura` individual y adjunte el PDF correspondiente si existe. - **Repositorios Mejorados:** Se optimizan y añaden métodos en `FacturaRepository` y `AjusteRepository` para soportar los nuevos requisitos de filtrado y consulta de datos consolidados. Frontend: - **Separación de Vistas:** La página de "Facturación" se divide en dos: - `ProcesosPage`: Para acciones masivas (generar cierre, archivo de débito, procesar respuesta). - `ConsultaFacturasPage`: Una nueva página dedicada a buscar, filtrar y gestionar facturas individuales con una interfaz de doble acordeón (Suscriptor -> Empresa). - **Filtros Avanzados:** La página `ConsultaFacturasPage` ahora incluye filtros por nombre de suscriptor, estado de pago y estado de facturación. - **Filtros de Fecha por Defecto:** La página de "Cuenta Corriente" ahora filtra por el mes actual por defecto para mejorar el rendimiento y la usabilidad. - **Validación de Fechas:** Se añade lógica en los filtros de fecha para impedir la selección de rangos inválidos. - **Validación de Monto de Pago:** El modal de pago manual ahora impide registrar un monto superior al saldo pendiente de la factura. --- .../DistribucionCanillasDocument.cs | 3 - .../FacturasPublicidadDocument.cs | 121 ++++ .../Reportes/ReportesController.cs | 57 +- .../Suscripciones/AjustesController.cs | 20 +- .../Suscripciones/FacturacionController.cs | 108 ++-- .../Suscripciones/SuscripcionesController.cs | 8 +- .../Reportes/IReportesRepository.cs | 1 + .../Reportes/ReportesRepository.cs | 45 ++ .../Suscripciones/AjusteRepository.cs | 64 +- .../Suscripciones/FacturaDetalleRepository.cs | 58 ++ .../Suscripciones/FacturaRepository.cs | 155 +++-- .../Suscripciones/IAjusteRepository.cs | 15 +- .../IFacturaDetalleRepository.cs | 22 + .../Suscripciones/IFacturaRepository.cs | 11 +- .../Suscripciones/IPagoRepository.cs | 1 + .../Suscripciones/IPromocionRepository.cs | 1 + .../Suscripciones/ISuscripcionRepository.cs | 8 +- .../Suscripciones/PagoRepository.cs | 12 +- .../Suscripciones/PromocionRepository.cs | 45 +- .../Suscripciones/SuscripcionRepository.cs | 40 +- .../Dtos/Reportes/FacturasParaReporteDto.cs | 14 + .../ViewModels/FacturasPublicidadViewModel.cs | 18 + .../Models/Dtos/Suscripciones/AjusteDto.cs | 1 + .../Dtos/Suscripciones/AsignarPromocionDto.cs | 13 + .../Dtos/Suscripciones/CreateAjusteDto.cs | 3 + .../Dtos/Suscripciones/CreatePromocionDto.cs | 23 +- .../Suscripciones/FacturaConsolidadaDto.cs | 13 + .../Models/Dtos/Suscripciones/FacturaDto.cs | 27 +- .../Suscripciones/PromocionAsignadaDto.cs | 8 + .../Models/Dtos/Suscripciones/PromocionDto.cs | 8 +- .../ResumenCuentaSuscriptorDto.cs | 11 + .../Dtos/Suscripciones/UpdateAjusteDto.cs | 19 + .../Models/Suscripciones/Ajuste.cs | 1 + .../Models/Suscripciones/Factura.cs | 5 +- .../Models/Suscripciones/FacturaDetalle.cs | 9 + .../Models/Suscripciones/Promocion.cs | 6 +- .../Suscripciones/SuscripcionPromocion.cs | 2 + Backend/GestionIntegral.Api/Program.cs | 1 + .../Services/Comunicaciones/EmailService.cs | 45 +- .../Services/Comunicaciones/IEmailService.cs | 3 +- .../Services/Reportes/IReportesService.cs | 1 + .../Services/Reportes/ReportesService.cs | 42 +- .../Services/Suscripciones/AjusteService.cs | 39 +- .../Suscripciones/DebitoAutomaticoService.cs | 63 +- .../Suscripciones/FacturacionService.cs | 549 +++++++++++++----- .../Services/Suscripciones/IAjusteService.cs | 3 +- .../Suscripciones/IFacturacionService.cs | 8 +- .../Suscripciones/ISuscripcionService.cs | 6 +- .../Services/Suscripciones/PagoService.cs | 46 +- .../Suscripciones/PromocionService.cs | 19 +- .../Suscripciones/SuscripcionService.cs | 55 +- .../Suscripciones/SuscriptorService.cs | 7 - Backend/GestionIntegral.Api/appsettings.json | 13 +- .../Modals/Suscripciones/AjusteFormModal.tsx | 69 ++- .../GestionarPromocionesSuscripcionModal.tsx | 102 +++- .../Modals/Suscripciones/PagoManualModal.tsx | 20 +- .../Suscripciones/PromocionFormModal.tsx | 121 ++-- .../Suscripciones/SuscripcionFormModal.tsx | 8 +- .../Suscripciones/SuscriptorFormModal.tsx | 87 ++- .../models/dtos/Suscripciones/AjusteDto.ts | 1 + .../dtos/Suscripciones/AsignarPromocionDto.ts | 5 + .../dtos/Suscripciones/CreateAjusteDto.ts | 1 + .../dtos/Suscripciones/CreatePromocionDto.ts | 6 +- .../Suscripciones/CreateSuscripcionDto.ts | 2 +- .../models/dtos/Suscripciones/FacturaDto.ts | 22 +- .../Suscripciones/PromocionAsignadaDto.ts | 6 + .../models/dtos/Suscripciones/PromocionDto.ts | 6 +- .../ResumenCuentaSuscriptorDto.ts | 27 + .../dtos/Suscripciones/SuscripcionDto.ts | 2 +- .../dtos/Suscripciones/UpdateAjusteDto.ts | 6 + .../ReporteFacturasPublicidadPage.tsx | 70 +++ .../src/pages/Reportes/ReportesIndexPage.tsx | 2 + .../SeleccionaReporteFacturasPublicidad.tsx | 81 +++ .../Suscripciones/ConsultaFacturasPage.tsx | 280 +++++++++ .../CuentaCorrienteSuscriptorPage.tsx | 241 ++++++++ .../CuentaCorrienteSuscriptorTab.tsx | 125 ---- .../pages/Suscripciones/FacturacionPage.tsx | 172 +----- .../GestionarPromocionesPage.tsx | 29 +- ...> GestionarSuscripcionesDeClientePage.tsx} | 52 +- .../GestionarSuscripcionesSuscriptorPage.tsx | 83 --- .../GestionarSuscriptoresPage.tsx | 370 ++++++------ .../Suscripciones/SuscripcionesIndexPage.tsx | 126 ++-- Frontend/src/routes/AppRoutes.tsx | 68 ++- .../src/services/Reportes/reportesService.ts | 22 +- .../services/Suscripciones/ajusteService.ts | 25 +- .../Suscripciones/facturacionService.ts | 48 +- .../Suscripciones/suscripcionService.ts | 17 +- 87 files changed, 2947 insertions(+), 1231 deletions(-) create mode 100644 Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/FacturasPublicidadDocument.cs create mode 100644 Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaDetalleRepository.cs create mode 100644 Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaDetalleRepository.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Reportes/FacturasParaReporteDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/FacturasPublicidadViewModel.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AsignarPromocionDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaConsolidadaDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionAsignadaDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/ResumenCuentaSuscriptorDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/UpdateAjusteDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Suscripciones/FacturaDetalle.cs create mode 100644 Frontend/src/models/dtos/Suscripciones/AsignarPromocionDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/PromocionAsignadaDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/ResumenCuentaSuscriptorDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/UpdateAjusteDto.ts create mode 100644 Frontend/src/pages/Reportes/ReporteFacturasPublicidadPage.tsx create mode 100644 Frontend/src/pages/Reportes/SeleccionaReporteFacturasPublicidad.tsx create mode 100644 Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx create mode 100644 Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorPage.tsx delete mode 100644 Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorTab.tsx rename Frontend/src/pages/Suscripciones/{SuscripcionesTab.tsx => GestionarSuscripcionesDeClientePage.tsx} (78%) delete mode 100644 Frontend/src/pages/Suscripciones/GestionarSuscripcionesSuscriptorPage.tsx diff --git a/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/DistribucionCanillasDocument.cs b/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/DistribucionCanillasDocument.cs index 18eca68..af68c66 100644 --- a/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/DistribucionCanillasDocument.cs +++ b/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/DistribucionCanillasDocument.cs @@ -1,12 +1,9 @@ -// --- REEMPLAZAR ARCHIVO: Controllers/Reportes/PdfTemplates/DistribucionCanillasDocument.cs --- using GestionIntegral.Api.Dtos.Reportes; using GestionIntegral.Api.Dtos.Reportes.ViewModels; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; -using System.Collections.Generic; using System.Globalization; -using System.Linq; namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates { diff --git a/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/FacturasPublicidadDocument.cs b/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/FacturasPublicidadDocument.cs new file mode 100644 index 0000000..54d4378 --- /dev/null +++ b/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/FacturasPublicidadDocument.cs @@ -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(); + }); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs b/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs index d099960..4ff304f 100644 --- a/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs @@ -1,15 +1,8 @@ using GestionIntegral.Api.Services.Reportes; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Reporting.NETCore; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; using GestionIntegral.Api.Dtos.Reportes; using GestionIntegral.Api.Data.Repositories.Impresion; -using System.IO; -using System.Linq; using GestionIntegral.Api.Data.Repositories.Distribucion; using GestionIntegral.Api.Services.Distribucion; using GestionIntegral.Api.Services.Pdf; @@ -45,6 +38,7 @@ namespace GestionIntegral.Api.Controllers private const string PermisoVerReporteConsumoBobinas = "RR007"; private const string PermisoVerReporteNovedadesCanillas = "RR004"; private const string PermisoVerReporteListadoDistMensual = "RR009"; + private const string PermisoVerReporteFacturasPublicidad = "RR010"; public ReportesController( IReportesService reportesService, @@ -1676,5 +1670,54 @@ namespace GestionIntegral.Api.Controllers return StatusCode(500, "Error interno al generar el PDF del reporte."); } } + + [HttpGet("suscripciones/facturas-para-publicidad/pdf")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task 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."); + } + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/AjustesController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/AjustesController.cs index 2ce443e..c173e20 100644 --- a/Backend/GestionIntegral.Api/Controllers/Suscripciones/AjustesController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/AjustesController.cs @@ -34,10 +34,10 @@ namespace GestionIntegral.Api.Controllers.Suscripciones // GET: api/suscriptores/{idSuscriptor}/ajustes [HttpGet("~/api/suscriptores/{idSuscriptor:int}/ajustes")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public async Task GetAjustesPorSuscriptor(int idSuscriptor) + public async Task GetAjustesPorSuscriptor(int idSuscriptor, [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta) { if (!TienePermiso(PermisoGestionarAjustes)) return Forbid(); - var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor); + var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor, fechaDesde, fechaHasta); return Ok(ajustes); } @@ -74,5 +74,21 @@ namespace GestionIntegral.Api.Controllers.Suscripciones return Ok(new { message = "Ajuste anulado correctamente." }); } + + // PUT: api/ajustes/{id} + [HttpPut("{id:int}")] + public async Task 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(); + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs index e7e084e..c4df979 100644 --- a/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs @@ -13,9 +13,8 @@ namespace GestionIntegral.Api.Controllers.Suscripciones { private readonly IFacturacionService _facturacionService; private readonly ILogger _logger; - - // Permiso para generar facturación (a crear en la BD) - private const string PermisoGenerarFacturacion = "SU006"; + private const string PermisoGestionarFacturacion = "SU006"; + private const string PermisoEnviarEmail = "SU009"; public FacturacionController(IFacturacionService facturacionService, ILogger logger) { @@ -28,67 +27,94 @@ namespace GestionIntegral.Api.Controllers.Suscripciones private int? GetCurrentUserId() { if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; + _logger.LogWarning("No se pudo obtener el UserId del token JWT en FacturacionController."); return null; } - // POST: api/facturacion/{anio}/{mes} - [HttpPost("{anio:int}/{mes:int}")] - public async Task GenerarFacturacion(int anio, int mes) + [HttpPut("{idFactura:int}/numero-factura")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task UpdateNumeroFactura(int idFactura, [FromBody] string numeroFactura) { - if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid(); + 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, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value); + var (exito, error) = await _facturacionService.ActualizarNumeroFactura(idFactura, numeroFactura, userId.Value); 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 Ok(new { message = mensaje, facturasGeneradas }); + return NoContent(); } - // GET: api/facturacion/{anio}/{mes} - [HttpGet("{anio:int}/{mes:int}")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task GetFacturas(int anio, int mes) + // POST: api/facturacion/{idFactura}/enviar-factura-pdf + [HttpPost("{idFactura:int}/enviar-factura-pdf")] + public async Task EnviarFacturaPdf(int idFactura) { - // Usamos el permiso de generar facturación también para verlas. - if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid(); - - if (anio < 2020 || mes < 1 || mes > 12) - { - 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 EnviarEmail(int idFactura) - { - // Usaremos un nuevo permiso para esta acción if (!TienePermiso("SU009")) return Forbid(); - var (exito, error) = await _facturacionService.EnviarFacturaPorEmail(idFactura); + var (exito, error) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura); if (!exito) { return BadRequest(new { message = error }); } + return Ok(new { message = "Email con factura PDF enviado a la cola de procesamiento." }); + } - return Ok(new { message = "Email enviado a la cola de procesamiento." }); + // POST: api/facturacion/{anio}/{mes}/suscriptor/{idSuscriptor}/enviar-aviso + [HttpPost("{anio:int}/{mes:int}/suscriptor/{idSuscriptor:int}/enviar-aviso")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task EnviarAvisoPorEmail(int anio, int mes, int idSuscriptor) + { + // Usamos el permiso de enviar email + if (!TienePermiso("SU009")) return Forbid(); + + var (exito, error) = await _facturacionService.EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor); + + if (!exito) + { + if (error != null && (error.Contains("no encontrada") || error.Contains("no es válido"))) + { + return NotFound(new { message = error }); + } + return BadRequest(new { message = error }); + } + + return Ok(new { message = "Email consolidado para el suscriptor ha sido enviado a la cola de procesamiento." }); + } + + // GET: api/facturacion/{anio}/{mes} + [HttpGet("{anio:int}/{mes:int}")] + public async Task 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 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, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value); + if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje }); + return Ok(new { message = mensaje, facturasGeneradas }); } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs index 8745cb6..2f30b84 100644 --- a/Backend/GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs @@ -112,15 +112,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones return Ok(promos); } - // POST: api/suscripciones/{idSuscripcion}/promociones/{idPromocion} - [HttpPost("{idSuscripcion:int}/promociones/{idPromocion:int}")] - public async Task AsignarPromocion(int idSuscripcion, int idPromocion) + // POST: api/suscripciones/{idSuscripcion}/promociones + [HttpPost("{idSuscripcion:int}/promociones")] + public async Task AsignarPromocion(int idSuscripcion, [FromBody] AsignarPromocionDto dto) { if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); var userId = GetCurrentUserId(); if (userId == null) return Unauthorized(); - var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, idPromocion, userId.Value); + var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, dto, userId.Value); if (!exito) return BadRequest(new { message = error }); return Ok(); } diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Reportes/IReportesRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Reportes/IReportesRepository.cs index c7c67bc..055ed86 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Reportes/IReportesRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Reportes/IReportesRepository.cs @@ -45,5 +45,6 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes Task> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla); Task> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); Task> GetReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); + Task> GetDatosReportePublicidadAsync(string periodo); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Reportes/ReportesRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Reportes/ReportesRepository.cs index 6faf180..0ef3e9c 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Reportes/ReportesRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Reportes/ReportesRepository.cs @@ -547,5 +547,50 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes commandType: CommandType.StoredProcedure, commandTimeout: 120 ); } + + public async Task> 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(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(); + } + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/AjusteRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/AjusteRepository.cs index e0f572e..5431e16 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/AjusteRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/AjusteRepository.cs @@ -1,6 +1,7 @@ using Dapper; using GestionIntegral.Api.Models.Suscripciones; using System.Data; +using System.Text; namespace GestionIntegral.Api.Data.Repositories.Suscripciones { @@ -15,36 +16,72 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones _logger = logger; } + public async Task UpdateAsync(Ajuste ajuste, IDbTransaction transaction) + { + const string sql = @" + UPDATE dbo.susc_Ajustes SET + FechaAjuste = @FechaAjuste, + TipoAjuste = @TipoAjuste, + Monto = @Monto, + Motivo = @Motivo + WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';"; // Solo se pueden editar los pendientes + 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; + } + + // Actualizar también el CreateAsync public async Task CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction) { const string sql = @" - INSERT INTO dbo.susc_Ajustes (IdSuscriptor, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta) + INSERT INTO dbo.susc_Ajustes (IdSuscriptor, FechaAjuste, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta) OUTPUT INSERTED.* - VALUES (@IdSuscriptor, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());"; - + VALUES (@IdSuscriptor, @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(sql, nuevoAjuste, transaction); } - public async Task> GetAjustesPorSuscriptorAsync(int idSuscriptor) + public async Task> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta) { - const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor ORDER BY FechaAlta DESC;"; + 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(sql, new { IdSuscriptor = idSuscriptor }); + return await connection.QueryAsync(sqlBuilder.ToString(), parameters); } - public async Task> GetAjustesPendientesPorSuscriptorAsync(int idSuscriptor, IDbTransaction transaction) + public async Task> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, DateTime fechaHasta, IDbTransaction transaction) { - const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor AND Estado = 'Pendiente';"; + const string sql = @" + SELECT * FROM dbo.susc_Ajustes + WHERE IdSuscriptor = @IdSuscriptor + AND Estado = 'Pendiente' + AND FechaAjuste <= @FechaHasta;"; // La condición clave es que la fecha del ajuste sea HASTA la fecha límite 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(sql, new { IdSuscriptor = idSuscriptor }, transaction); + return await transaction.Connection.QueryAsync(sql, new { idSuscriptor, FechaHasta = fechaHasta }, transaction); } public async Task MarcarAjustesComoAplicadosAsync(IEnumerable idsAjustes, int idFactura, IDbTransaction transaction) @@ -89,5 +126,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones var rows = await transaction.Connection.ExecuteAsync(sql, new { IdAjuste = idAjuste, IdUsuario = idUsuario }, transaction); return rows == 1; } + + public async Task> GetAjustesPorIdFacturaAsync(int idFactura) + { + const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdFacturaAplicado = @IdFactura;"; + using var connection = _connectionFactory.CreateConnection(); + return await connection.QueryAsync(sql, new { IdFactura = idFactura }); + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaDetalleRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaDetalleRepository.cs new file mode 100644 index 0000000..4beda98 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaDetalleRepository.cs @@ -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 _logger; + + public FacturaDetalleRepository(DbConnectionFactory connectionFactory, ILogger logger) + { + _connectionFactory = connectionFactory; + _logger = logger; + } + + public async Task 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(sqlInsert, nuevoDetalle, transaction); + } + + public async Task> GetDetallesPorFacturaIdAsync(int idFactura) + { + const string sql = "SELECT * FROM dbo.susc_FacturaDetalles WHERE IdFactura = @IdFactura;"; + using var connection = _connectionFactory.CreateConnection(); + return await connection.QueryAsync(sql, new { IdFactura = idFactura }); + } + + public async Task> 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(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(); + } + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs index ff65cd3..4347903 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs @@ -1,8 +1,13 @@ -// Archivo: GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs - using Dapper; +using GestionIntegral.Api.Data; using GestionIntegral.Api.Models.Suscripciones; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; namespace GestionIntegral.Api.Data.Repositories.Suscripciones { @@ -19,7 +24,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones public async Task 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(); return await connection.QuerySingleOrDefaultAsync(sql, new { idFactura }); } @@ -31,14 +36,21 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones return await connection.QueryAsync(sql, new { Periodo = periodo }); } - public async Task GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction) + public async Task 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) { throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); } - return await transaction.Connection.QuerySingleOrDefaultAsync(sql, new { IdSuscripcion = idSuscripcion, Periodo = periodo }, transaction); + return await transaction.Connection.QuerySingleOrDefaultAsync(sql, new { idSuscriptor, Periodo = periodo }, transaction); + } + + public async Task> 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(sql, new { idSuscriptor, Periodo = periodo }); } public async Task 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."); } const string sqlInsert = @" - INSERT INTO dbo.susc_Facturas - (IdSuscripcion, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, - DescuentoAplicado, ImporteFinal, Estado) + INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion) OUTPUT INSERTED.* - VALUES - (@IdSuscripcion, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, - @DescuentoAplicado, @ImporteFinal, @Estado);"; + VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);"; return await transaction.Connection.QuerySingleAsync(sqlInsert, nuevaFactura, transaction); } - public async Task UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction) + public async Task UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction) { if (transaction == null || transaction.Connection == null) { throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); } - const string sql = "UPDATE dbo.susc_Facturas SET Estado = @NuevoEstado WHERE IdFactura = @IdFactura;"; - var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstado = nuevoEstado, IdFactura = idFactura }, transaction); + const string sql = "UPDATE dbo.susc_Facturas SET EstadoPago = @NuevoEstadoPago WHERE IdFactura = @IdFactura;"; + var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, idFactura }, transaction); return rowsAffected == 1; } @@ -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."); } - const string sql = "UPDATE dbo.susc_Facturas SET NumeroFactura = @NumeroFactura, Estado = 'Pendiente de Cobro' WHERE IdFactura = @IdFactura;"; - var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, IdFactura = idFactura }, transaction); + const string sql = @" + UPDATE dbo.susc_Facturas SET + NumeroFactura = @NumeroFactura, + EstadoFacturacion = 'Facturado' + WHERE IdFactura = @IdFactura;"; + var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, idFactura }, transaction); return rowsAffected == 1; } @@ -87,59 +99,116 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones { 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); return rowsAffected == idsFacturas.Count(); } - public async Task> GetByPeriodoEnrichedAsync(string periodo) + public async Task> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) { - const string sql = @" - SELECT f.*, s.NombreCompleto AS NombreSuscriptor, p.Nombre AS NombrePublicacion - 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 - ORDER BY s.NombreCompleto; - "; + var sqlBuilder = new StringBuilder(@" + WITH FacturaConEmpresa AS ( + -- Esta subconsulta obtiene el IdEmpresa para cada factura basándose en la primera suscripción que encuentra en sus detalles. + -- Esto es seguro porque nuestra lógica de negocio asegura que todos los detalles de una factura pertenecen a la misma empresa. + SELECT + f.IdFactura, + (SELECT TOP 1 p.Id_Empresa + FROM dbo.susc_FacturaDetalles fd + JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion + JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion + WHERE fd.IdFactura = f.IdFactura) AS IdEmpresa + FROM dbo.susc_Facturas f + WHERE f.Periodo = @Periodo + ) + SELECT + f.*, + s.NombreCompleto AS NombreSuscriptor, + fce.IdEmpresa, + (SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos pg WHERE pg.IdFactura = f.IdFactura AND pg.Estado = 'Aprobado') AS TotalPagado + FROM dbo.susc_Facturas f + JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor + JOIN FacturaConEmpresa fce ON f.IdFactura = fce.IdFactura + WHERE f.Periodo = @Periodo"); + + var parameters = new DynamicParameters(); + parameters.Add("Periodo", periodo); + + if (!string.IsNullOrWhiteSpace(nombreSuscriptor)) + { + sqlBuilder.Append(" AND s.NombreCompleto LIKE @NombreSuscriptor"); + parameters.Add("NombreSuscriptor", $"%{nombreSuscriptor}%"); + } + if (!string.IsNullOrWhiteSpace(estadoPago)) + { + sqlBuilder.Append(" AND f.EstadoPago = @EstadoPago"); + parameters.Add("EstadoPago", estadoPago); + } + if (!string.IsNullOrWhiteSpace(estadoFacturacion)) + { + sqlBuilder.Append(" AND f.EstadoFacturacion = @EstadoFacturacion"); + parameters.Add("EstadoFacturacion", estadoFacturacion); + } + + sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;"); + try { using var connection = _connectionFactory.CreateConnection(); - var result = await connection.QueryAsync( - sql, - (factura, suscriptor, publicacion) => (factura, suscriptor, publicacion), - new { Periodo = periodo }, - splitOn: "NombreSuscriptor,NombrePublicacion" + var result = await connection.QueryAsync( + sqlBuilder.ToString(), + (factura, suscriptor, idEmpresa, totalPagado) => (factura, suscriptor, idEmpresa, totalPagado), + parameters, + splitOn: "NombreSuscriptor,IdEmpresa,TotalPagado" ); return result; } catch (Exception ex) { _logger.LogError(ex, "Error al obtener facturas enriquecidas para el período {Periodo}", periodo); - return Enumerable.Empty<(Factura, string, string)>(); + return Enumerable.Empty<(Factura, string, int, decimal)>(); } } - public async Task UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction) + public async Task UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction) { if (transaction == null || transaction.Connection == null) { throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); } - const string sql = @" UPDATE dbo.susc_Facturas SET - Estado = @NuevoEstado, + EstadoPago = @NuevoEstadoPago, MotivoRechazo = @MotivoRechazo WHERE IdFactura = @IdFactura;"; - - var rowsAffected = await transaction.Connection.ExecuteAsync( - sql, - new { NuevoEstado = nuevoEstado, MotivoRechazo = motivoRechazo, IdFactura = idFactura }, - transaction - ); + var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, MotivoRechazo = motivoRechazo, idFactura }, transaction); return rowsAffected == 1; } + + public async Task 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(sql); + } + + public async Task> 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(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(); + } + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs index 93b83a3..60af0fe 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs @@ -1,15 +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 CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction); - Task> GetAjustesPorSuscriptorAsync(int idSuscriptor); - Task> GetAjustesPendientesPorSuscriptorAsync(int idSuscriptor, IDbTransaction transaction); Task GetByIdAsync(int idAjuste); - Task MarcarAjustesComoAplicadosAsync(IEnumerable idsAjustes, int idFactura, IDbTransaction transaction); + Task CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction); + Task UpdateAsync(Ajuste ajuste, IDbTransaction transaction); Task AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction); + Task> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta); + Task> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, DateTime fechaHasta, IDbTransaction transaction); + Task MarcarAjustesComoAplicadosAsync(IEnumerable idsAjustes, int idFactura, IDbTransaction transaction); + Task> GetAjustesPorIdFacturaAsync(int idFactura); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaDetalleRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaDetalleRepository.cs new file mode 100644 index 0000000..d3759e4 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaDetalleRepository.cs @@ -0,0 +1,22 @@ +using System.Data; + +namespace GestionIntegral.Api.Data.Repositories.Suscripciones +{ + public interface IFacturaDetalleRepository + { + /// + /// Crea un nuevo registro de detalle de factura. + /// + Task CreateAsync(FacturaDetalle nuevoDetalle, IDbTransaction transaction); + + /// + /// Obtiene todos los detalles de una factura específica. + /// + Task> GetDetallesPorFacturaIdAsync(int idFactura); + + /// + /// Obtiene de forma eficiente todos los detalles de todas las facturas de un período específico. + /// + Task> GetDetallesPorPeriodoAsync(string periodo); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs index b8d331a..13d76c1 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs @@ -7,12 +7,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones { Task GetByIdAsync(int idFactura); Task> GetByPeriodoAsync(string periodo); - Task GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction); + Task GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction); + Task> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo); Task CreateAsync(Factura nuevaFactura, IDbTransaction transaction); - Task UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction); + Task UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); Task UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); Task UpdateLoteDebitoAsync(IEnumerable idsFacturas, int idLoteDebito, IDbTransaction transaction); - Task> GetByPeriodoEnrichedAsync(string periodo); - Task UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction); + Task> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); + Task UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction); + Task GetUltimoPeriodoFacturadoAsync(); + Task> GetFacturasPagadasPendientesDeFacturar(string periodo); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPagoRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPagoRepository.cs index c6ee740..c4f593f 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPagoRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPagoRepository.cs @@ -7,5 +7,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones { Task> GetByFacturaIdAsync(int idFactura); Task CreateAsync(Pago nuevoPago, IDbTransaction transaction); + Task GetTotalPagadoAprobadoAsync(int idFactura, IDbTransaction transaction); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPromocionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPromocionRepository.cs index 74b800b..8a44609 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPromocionRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPromocionRepository.cs @@ -10,5 +10,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones Task CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction); Task UpdateAsync(Promocion promocion, IDbTransaction transaction); Task> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction); + Task> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/ISuscripcionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/ISuscripcionRepository.cs index 0c007b2..252729e 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/ISuscripcionRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/ISuscripcionRepository.cs @@ -5,13 +5,13 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones { public interface ISuscripcionRepository { - Task> GetBySuscriptorIdAsync(int idSuscriptor); Task GetByIdAsync(int idSuscripcion); + Task> GetBySuscriptorIdAsync(int idSuscriptor); + Task> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction); Task CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction); Task UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction); - Task> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction); - Task> GetPromocionesBySuscripcionIdAsync(int idSuscripcion); - Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction); + Task> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion); + Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction); Task QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PagoRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PagoRepository.cs index e2f849b..d649897 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PagoRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PagoRepository.cs @@ -43,7 +43,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones OUTPUT INSERTED.* VALUES (@IdFactura, @FechaPago, @IdFormaPago, @Monto, @Estado, @Referencia, @Observaciones, @IdUsuarioRegistro);"; - + try { return await transaction.Connection.QuerySingleAsync(sqlInsert, nuevoPago, transaction); @@ -54,5 +54,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones return null; } } + + public async Task 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(sql, new { idFactura }, transaction); + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PromocionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PromocionRepository.cs index de9d644..7ecfaf8 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PromocionRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PromocionRepository.cs @@ -19,7 +19,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones public async Task> GetAllAsync(bool soloActivas) { var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones"); - if(soloActivas) + if (soloActivas) { sql.Append(" WHERE Activa = 1"); } @@ -39,10 +39,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones public async Task CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction) { 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.* - VALUES (@Descripcion, @TipoPromocion, @Valor, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());"; - + VALUES (@Descripcion, @TipoEfecto, @ValorEfecto, @TipoCondicion, + @ValorCondicion, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());"; if (transaction?.Connection == null) { throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); @@ -74,20 +76,43 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones public async Task> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction) { + // Esta consulta ahora es más compleja para respetar ambas vigencias. const string sql = @" - SELECT p.* FROM dbo.susc_Promociones p + 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 - AND p.FechaInicio <= @FechaPeriodo - AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo);"; - + AND p.Activa = 1 + -- 1. La promoción general debe estar activa en el período + AND p.FechaInicio <= @FechaPeriodo + AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo) + -- 2. La asignación específica al cliente debe estar activa en el período + AND sp.VigenciaDesde <= @FechaPeriodo + AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);"; if (transaction?.Connection == null) { throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); } - return await transaction.Connection.QueryAsync(sql, new { IdSuscripcion = idSuscripcion, FechaPeriodo = fechaPeriodo }, transaction); } + + // Versión SIN transacción, para solo lectura + public async Task> 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(sql, new { idSuscripcion, FechaPeriodo = fechaPeriodo }); + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/SuscripcionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/SuscripcionRepository.cs index e27ebba..cdb5308 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/SuscripcionRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/SuscripcionRepository.cs @@ -44,10 +44,9 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones return Enumerable.Empty(); } } - + public async Task> 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 month = int.Parse(periodo.Split('-')[1]); var primerDiaMes = new DateTime(year, month, 1); @@ -61,7 +60,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones AND su.Activo = 1 AND s.FechaInicio <= @UltimoDiaMes AND (s.FechaFin IS NULL OR s.FechaFin >= @PrimerDiaMes);"; - + if (transaction == null || transaction.Connection == null) { throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); @@ -85,7 +84,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones VALUES (@IdSuscriptor, @IdPublicacion, @FechaInicio, @FechaFin, @Estado, @DiasEntrega, @Observaciones, @IdUsuarioAlta, GETDATE());"; - + return await transaction.Connection.QuerySingleAsync(sqlInsert, nuevaSuscripcion, transaction); } @@ -112,30 +111,35 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones return rowsAffected == 1; } - public async Task> GetPromocionesBySuscripcionIdAsync(int idSuscripcion) + public async Task> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion) { const string sql = @" - SELECT p.* FROM dbo.susc_Promociones p - JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion - WHERE sp.IdSuscripcion = @IdSuscripcion;"; - + SELECT sp.*, p.* + FROM dbo.susc_SuscripcionPromociones sp + JOIN dbo.susc_Promociones p ON sp.IdPromocion = p.IdPromocion + WHERE sp.IdSuscripcion = @IdSuscripcion;"; + using var connection = _connectionFactory.CreateConnection(); - return await connection.QueryAsync(sql, new { IdSuscripcion = idSuscripcion }); + var result = await connection.QueryAsync( + 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) { throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); } const string sql = @" - INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno) - VALUES (@IdSuscripcion, @IdPromocion, @IdUsuario);"; - - await transaction.Connection.ExecuteAsync(sql, - new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion, IdUsuario = idUsuario }, - transaction); + INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno, VigenciaDesde, VigenciaHasta, FechaAsignacion) + VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());"; + + await transaction.Connection.ExecuteAsync(sql, asignacion, transaction); } public async Task 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."); } 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; } } diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/FacturasParaReporteDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/FacturasParaReporteDto.cs new file mode 100644 index 0000000..ae052ac --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/FacturasParaReporteDto.cs @@ -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; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/FacturasPublicidadViewModel.cs b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/FacturasPublicidadViewModel.cs new file mode 100644 index 0000000..b1d815a --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/FacturasPublicidadViewModel.cs @@ -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 Facturas { get; set; } = new List(); + public decimal TotalEmpresa => Facturas.Sum(f => f.ImporteFinal); + } + + public class FacturasPublicidadViewModel + { + public IEnumerable DatosPorEmpresa { get; set; } = new List(); + public string Periodo { get; set; } = string.Empty; + public string FechaGeneracion { get; set; } = string.Empty; + public decimal TotalGeneral => DatosPorEmpresa.Sum(e => e.TotalEmpresa); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs index a020b32..4032f5b 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs @@ -4,6 +4,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones { public int IdAjuste { get; set; } public int IdSuscriptor { 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; diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AsignarPromocionDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AsignarPromocionDto.cs new file mode 100644 index 0000000..15e486a --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AsignarPromocionDto.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreateAjusteDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreateAjusteDto.cs index 1c972d7..8267545 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreateAjusteDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreateAjusteDto.cs @@ -7,6 +7,9 @@ namespace GestionIntegral.Api.Dtos.Suscripciones [Required] public int IdSuscriptor { 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; diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePromocionDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePromocionDto.cs index 323f168..ce581e3 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePromocionDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePromocionDto.cs @@ -7,22 +7,25 @@ namespace GestionIntegral.Api.Dtos.Suscripciones { public class CreatePromocionDto { - [Required(ErrorMessage = "La descripción es obligatoria.")] + [Required] [StringLength(200)] public string Descripcion { get; set; } = string.Empty; - [Required(ErrorMessage = "El tipo de promoción es obligatorio.")] - public string TipoPromocion { get; set; } = string.Empty; + [Required] + public string TipoEfecto { get; set; } = string.Empty; // Corregido - [Required(ErrorMessage = "El valor es obligatorio.")] - [Range(0.01, 99999999.99, ErrorMessage = "El valor debe ser positivo.")] - public decimal Valor { get; set; } + [Required] + [Range(0, 99999999.99)] // Se permite 0 para bonificaciones + public decimal ValorEfecto { get; set; } // Corregido - [Required(ErrorMessage = "La fecha de inicio es obligatoria.")] - public DateTime FechaInicio { get; set; } - - public DateTime? FechaFin { get; set; } + [Required] + public string TipoCondicion { get; set; } = string.Empty; + public int? ValorCondicion { get; set; } + + [Required] + public DateTime FechaInicio { get; set; } + public DateTime? FechaFin { get; set; } public bool Activa { get; set; } = true; } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaConsolidadaDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaConsolidadaDto.cs new file mode 100644 index 0000000..a016451 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaConsolidadaDto.cs @@ -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 Detalles { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaDto.cs index dd0296f..d51de5c 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaDto.cs @@ -1,22 +1,25 @@ namespace GestionIntegral.Api.Dtos.Suscripciones { - /// - /// 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 class FacturaDetalleDto + { + public string Descripcion { get; set; } = string.Empty; + public decimal ImporteNeto { get; set; } + } + public class FacturaDto { public int IdFactura { get; set; } - public int IdSuscripcion { get; set; } - public string Periodo { get; set; } = string.Empty; // Formato "YYYY-MM" - public string FechaEmision { get; set; } = string.Empty; // Formato "yyyy-MM-dd" - public string FechaVencimiento { get; set; } = string.Empty; // Formato "yyyy-MM-dd" + public int IdSuscriptor { get; set; } + public string Periodo { get; set; } = string.Empty; + public string FechaEmision { get; set; } = string.Empty; + public string FechaVencimiento { get; set; } = string.Empty; public decimal ImporteFinal { get; set; } - public 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; } - - // Datos enriquecidos para la UI, poblados por el servicio public string NombreSuscriptor { get; set; } = string.Empty; - public string NombrePublicacion { get; set; } = string.Empty; + public List Detalles { get; set; } = new List(); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionAsignadaDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionAsignadaDto.cs new file mode 100644 index 0000000..056e8d1 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionAsignadaDto.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionDto.cs index 0e1ed5a..1c9c49e 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionDto.cs @@ -4,9 +4,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones { public int IdPromocion { get; set; } public string Descripcion { get; set; } = string.Empty; - public string TipoPromocion { get; set; } = string.Empty; - public decimal Valor { get; set; } - public string FechaInicio { get; set; } = string.Empty; // yyyy-MM-dd + public string TipoEfecto { get; set; } = string.Empty; + public decimal ValorEfecto { get; set; } + public string TipoCondicion { get; set; } = string.Empty; + public int? ValorCondicion { get; set; } + public string FechaInicio { get; set; } = string.Empty; public string? FechaFin { get; set; } public bool Activa { get; set; } } diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/ResumenCuentaSuscriptorDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/ResumenCuentaSuscriptorDto.cs new file mode 100644 index 0000000..89f3139 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/ResumenCuentaSuscriptorDto.cs @@ -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 Facturas { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/UpdateAjusteDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/UpdateAjusteDto.cs new file mode 100644 index 0000000..5a2a1eb --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/UpdateAjusteDto.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace GestionIntegral.Api.Dtos.Suscripciones; +public class UpdateAjusteDto +{ + [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; +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs b/Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs index 573f76a..aec573a 100644 --- a/Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs +++ b/Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs @@ -4,6 +4,7 @@ namespace GestionIntegral.Api.Models.Suscripciones { public int IdAjuste { get; set; } public int IdSuscriptor { 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; diff --git a/Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs b/Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs index 9307e23..73940fe 100644 --- a/Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs +++ b/Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs @@ -3,14 +3,15 @@ namespace GestionIntegral.Api.Models.Suscripciones public class Factura { public int IdFactura { get; set; } - public int IdSuscripcion { get; set; } + public int IdSuscriptor { get; set; } public string Periodo { get; set; } = string.Empty; public DateTime FechaEmision { get; set; } public DateTime FechaVencimiento { get; set; } public decimal ImporteBruto { get; set; } public decimal DescuentoAplicado { get; set; } public decimal ImporteFinal { get; set; } - public string 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 int? IdLoteDebito { get; set; } public string? MotivoRechazo { get; set; } diff --git a/Backend/GestionIntegral.Api/Models/Suscripciones/FacturaDetalle.cs b/Backend/GestionIntegral.Api/Models/Suscripciones/FacturaDetalle.cs new file mode 100644 index 0000000..97a9713 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Suscripciones/FacturaDetalle.cs @@ -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; } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Suscripciones/Promocion.cs b/Backend/GestionIntegral.Api/Models/Suscripciones/Promocion.cs index 7251c16..7ce7242 100644 --- a/Backend/GestionIntegral.Api/Models/Suscripciones/Promocion.cs +++ b/Backend/GestionIntegral.Api/Models/Suscripciones/Promocion.cs @@ -4,8 +4,10 @@ namespace GestionIntegral.Api.Models.Suscripciones { public int IdPromocion { get; set; } public string Descripcion { get; set; } = string.Empty; - public string TipoPromocion { get; set; } = string.Empty; - public decimal Valor { get; set; } + public string TipoEfecto { get; set; } = string.Empty; // Nuevo nombre + public decimal ValorEfecto { get; set; } // Nuevo nombre + public string TipoCondicion { get; set; } = string.Empty; // Nueva propiedad + public int? ValorCondicion { get; set; } // Nueva propiedad (nullable) public DateTime FechaInicio { get; set; } public DateTime? FechaFin { get; set; } public bool Activa { get; set; } diff --git a/Backend/GestionIntegral.Api/Models/Suscripciones/SuscripcionPromocion.cs b/Backend/GestionIntegral.Api/Models/Suscripciones/SuscripcionPromocion.cs index 0a25357..45c6221 100644 --- a/Backend/GestionIntegral.Api/Models/Suscripciones/SuscripcionPromocion.cs +++ b/Backend/GestionIntegral.Api/Models/Suscripciones/SuscripcionPromocion.cs @@ -6,5 +6,7 @@ namespace GestionIntegral.Api.Models.Suscripciones public int IdPromocion { get; set; } public DateTime FechaAsignacion { get; set; } public int IdUsuarioAsigno { get; set; } + public DateTime VigenciaDesde { get; set; } + public DateTime? VigenciaHasta { get; set; } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Program.cs b/Backend/GestionIntegral.Api/Program.cs index f55f883..bbbd06b 100644 --- a/Backend/GestionIntegral.Api/Program.cs +++ b/Backend/GestionIntegral.Api/Program.cs @@ -113,6 +113,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs index 7010442..47be05b 100644 --- a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs @@ -17,7 +17,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones _logger = logger; } - 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) { var email = new MimeMessage(); email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail); @@ -26,6 +26,10 @@ namespace GestionIntegral.Api.Services.Comunicaciones email.Subject = asunto; var builder = new BodyBuilder { HtmlBody = cuerpoHtml }; + if (attachment != null && !string.IsNullOrEmpty(attachmentName)) + { + builder.Attachments.Add(attachmentName, attachment, ContentType.Parse("application/pdf")); + } email.Body = builder.ToMessageBody(); using var smtp = new SmtpClient(); @@ -46,5 +50,44 @@ namespace GestionIntegral.Api.Services.Comunicaciones await smtp.DisconnectAsync(true); } } + + public async Task EnviarEmailConsolidadoAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, List<(byte[] content, string name)> adjuntos) + { + 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(); + + using var smtp = new SmtpClient(); + try + { + await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); + await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); + await smtp.SendAsync(email); + _logger.LogInformation("Email consolidado enviado exitosamente a {Destinatario}", destinatarioEmail); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al enviar email consolidado a {Destinatario}", destinatarioEmail); + throw; + } + finally + { + await smtp.DisconnectAsync(true); + } + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs index 502ce01..2c43061 100644 --- a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs @@ -2,6 +2,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones { public interface IEmailService { - Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml); + Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, byte[]? attachment = null, string? attachmentName = null); + Task EnviarEmailConsolidadoAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, List<(byte[] content, string name)> adjuntos); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs b/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs index 3cb1e44..893be31 100644 --- a/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs +++ b/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs @@ -72,5 +72,6 @@ namespace GestionIntegral.Api.Services.Reportes Task<(IEnumerable Data, string? Error)> ObtenerReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); Task<(IEnumerable Data, string? Error)> ObtenerReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); + Task<(IEnumerable Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs b/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs index c585b7c..6676322 100644 --- a/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs +++ b/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs @@ -1,21 +1,31 @@ +using GestionIntegral.Api.Data.Repositories.Distribucion; using GestionIntegral.Api.Data.Repositories.Reportes; +using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Dtos.Reportes; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; namespace GestionIntegral.Api.Services.Reportes { public class ReportesService : IReportesService { private readonly IReportesRepository _reportesRepository; + private readonly IFacturaRepository _facturaRepository; + private readonly IFacturaDetalleRepository _facturaDetalleRepository; + private readonly IPublicacionRepository _publicacionRepository; + private readonly IEmpresaRepository _empresaRepository; + private readonly ISuscriptorRepository _suscriptorRepository; + private readonly ISuscripcionRepository _suscripcionRepository; private readonly ILogger _logger; - public ReportesService(IReportesRepository reportesRepository, ILogger logger) + public ReportesService(IReportesRepository reportesRepository, IFacturaRepository facturaRepository, IFacturaDetalleRepository facturaDetalleRepository, IPublicacionRepository publicacionRepository, IEmpresaRepository empresaRepository + , ISuscriptorRepository suscriptorRepository, ISuscripcionRepository suscripcionRepository, ILogger logger) { _reportesRepository = reportesRepository; + _facturaRepository = facturaRepository; + _facturaDetalleRepository = facturaDetalleRepository; + _publicacionRepository = publicacionRepository; + _empresaRepository = empresaRepository; + _suscriptorRepository = suscriptorRepository; + _suscripcionRepository = suscripcionRepository; _logger = logger; } @@ -520,5 +530,25 @@ namespace GestionIntegral.Api.Services.Reportes return (Enumerable.Empty(), "Error al obtener datos del reporte (por publicación)."); } } + + public async Task<(IEnumerable Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes) + { + if (anio < 2020 || mes < 1 || mes > 12) + { + return (Enumerable.Empty(), "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(), "Error interno al generar el reporte."); + } + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs index ba9951c..64fb374 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs @@ -37,6 +37,7 @@ namespace GestionIntegral.Api.Services.Suscripciones { IdAjuste = ajuste.IdAjuste, IdSuscriptor = ajuste.IdSuscriptor, + FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"), TipoAjuste = ajuste.TipoAjuste, Monto = ajuste.Monto, Motivo = ajuste.Motivo, @@ -47,9 +48,9 @@ namespace GestionIntegral.Api.Services.Suscripciones }; } - public async Task> ObtenerAjustesPorSuscriptor(int idSuscriptor) + public async Task> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta) { - var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor); + var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor, fechaDesde, fechaHasta); var dtosTasks = ajustes.Select(a => MapToDto(a)); var dtos = await Task.WhenAll(dtosTasks); return dtos.Where(dto => dto != null)!; @@ -62,10 +63,11 @@ namespace GestionIntegral.Api.Services.Suscripciones { return (null, "El suscriptor especificado no existe."); } - + var nuevoAjuste = new Ajuste { IdSuscriptor = createDto.IdSuscriptor, + FechaAjuste = createDto.FechaAjuste.Date, TipoAjuste = createDto.TipoAjuste, Monto = createDto.Monto, Motivo = createDto.Motivo, @@ -119,5 +121,36 @@ namespace GestionIntegral.Api.Services.Suscripciones 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}'."); + + 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."); + } + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs index e8b28b1..ea225dc 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs @@ -1,17 +1,16 @@ -// Archivo: GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs - using GestionIntegral.Api.Data; using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Models.Suscripciones; +using System.Data; +using System.Text; +using GestionIntegral.Api.Dtos.Suscripciones; using Microsoft.Extensions.Logging; using System; -using System.Data; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Collections.Generic; -using GestionIntegral.Api.Dtos.Suscripciones; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using System.IO; namespace GestionIntegral.Api.Services.Suscripciones { @@ -19,21 +18,18 @@ namespace GestionIntegral.Api.Services.Suscripciones { private readonly IFacturaRepository _facturaRepository; private readonly ISuscriptorRepository _suscriptorRepository; - private readonly ISuscripcionRepository _suscripcionRepository; private readonly ILoteDebitoRepository _loteDebitoRepository; private readonly IFormaPagoRepository _formaPagoRepository; private readonly IPagoRepository _pagoRepository; private readonly DbConnectionFactory _connectionFactory; private readonly ILogger _logger; - // --- CONSTANTES DEL BANCO (Mover a appsettings.json si es necesario) --- private const string NRO_PRESTACION = "123456"; // Nro. de prestación asignado por el banco private const string ORIGEN_EMPRESA = "ELDIA"; // Nombre de la empresa (7 chars) public DebitoAutomaticoService( IFacturaRepository facturaRepository, ISuscriptorRepository suscriptorRepository, - ISuscripcionRepository suscripcionRepository, ILoteDebitoRepository loteDebitoRepository, IFormaPagoRepository formaPagoRepository, IPagoRepository pagoRepository, @@ -42,7 +38,6 @@ namespace GestionIntegral.Api.Services.Suscripciones { _facturaRepository = facturaRepository; _suscriptorRepository = suscriptorRepository; - _suscripcionRepository = suscripcionRepository; _loteDebitoRepository = loteDebitoRepository; _formaPagoRepository = formaPagoRepository; _pagoRepository = pagoRepository; @@ -61,9 +56,7 @@ namespace GestionIntegral.Api.Services.Suscripciones try { - // Buscamos facturas que están listas para ser enviadas al cobro. var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction); - if (!facturasParaDebito.Any()) { return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado."); @@ -73,7 +66,6 @@ namespace GestionIntegral.Api.Services.Suscripciones var cantidadRegistros = facturasParaDebito.Count(); var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt"; - // 1. Crear el Lote de Débito var nuevoLote = new LoteDebito { Periodo = periodo, @@ -85,18 +77,14 @@ namespace GestionIntegral.Api.Services.Suscripciones var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction); 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(); sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros)); - foreach (var item in facturasParaDebito) { sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); } - sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros)); - // 3. Actualizar las facturas con el ID del lote var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction); if (!actualizadas) throw new DataException("No se pudieron actualizar las facturas con la información del lote."); @@ -115,17 +103,12 @@ namespace GestionIntegral.Api.Services.Suscripciones private async Task> 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 facturasDelPeriodo = await _facturaRepository.GetByPeriodoAsync(periodo); var resultado = new List<(Factura, Suscriptor)>(); - - foreach (var f in facturas.Where(fa => fa.Estado == "Pendiente de Cobro")) + + foreach (var f in facturasDelPeriodo.Where(fa => fa.EstadoPago == "Pendiente")) { - var suscripcion = await _suscripcionRepository.GetByIdAsync(f.IdSuscripcion); - if (suscripcion == null) continue; - - var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor); + var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor); if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue; var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); @@ -231,28 +214,17 @@ namespace GestionIntegral.Api.Services.Suscripciones string? linea; while ((linea = await reader.ReadLineAsync()) != null) { - // Ignorar header/trailer si los hubiera (basado en el formato real) if (linea.Length < 20) continue; - 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 estadoProceso = linea.Substring(15, 2).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)) { respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'."); continue; } - // ================================================================= - // === FIN DE LA LÓGICA DE PARSEO === - // ================================================================= var factura = await _facturaRepository.GetByIdAsync(idFactura); if (factura == null) @@ -264,27 +236,24 @@ namespace GestionIntegral.Api.Services.Suscripciones var nuevoPago = new Pago { IdFactura = idFactura, - FechaPago = DateTime.Now.Date, // O la fecha que venga en el archivo - IdFormaPago = 1, // 1 = Débito Automático + FechaPago = DateTime.Now.Date, + IdFormaPago = 1, Monto = factura.ImporteFinal, IdUsuarioRegistro = idUsuario, Referencia = $"Lote {factura.IdLoteDebito} - Banco" }; - if (estadoProceso == "AP") // "AP" = Aprobado (Asunción) + if (estadoProceso == "AP") { nuevoPago.Estado = "Aprobado"; await _pagoRepository.CreateAsync(nuevoPago, transaction); - await _facturaRepository.UpdateEstadoAsync(idFactura, "Pagada", transaction); + await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction); respuesta.PagosAprobados++; } - else // Asumimos que cualquier otra cosa es Rechazado + else { nuevoPago.Estado = "Rechazado"; 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); respuesta.PagosRechazados++; } diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs index 0dcc5ef..267dde7 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs @@ -6,6 +6,8 @@ using GestionIntegral.Api.Models.Distribucion; using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Services.Comunicaciones; using System.Data; +using System.Globalization; +using System.Text; namespace GestionIntegral.Api.Services.Suscripciones { @@ -13,40 +15,393 @@ namespace GestionIntegral.Api.Services.Suscripciones { private readonly ISuscripcionRepository _suscripcionRepository; private readonly IFacturaRepository _facturaRepository; + private readonly IEmpresaRepository _empresaRepository; + private readonly IFacturaDetalleRepository _facturaDetalleRepository; private readonly IPrecioRepository _precioRepository; private readonly IPromocionRepository _promocionRepository; - private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación - private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor - private readonly DbConnectionFactory _connectionFactory; + private readonly ISuscriptorRepository _suscriptorRepository; + private readonly IAjusteRepository _ajusteRepository; private readonly IEmailService _emailService; + private readonly IPublicacionRepository _publicacionRepository; + private readonly DbConnectionFactory _connectionFactory; private readonly ILogger _logger; + private readonly string _facturasPdfPath; public FacturacionService( ISuscripcionRepository suscripcionRepository, IFacturaRepository facturaRepository, + IEmpresaRepository empresaRepository, + IFacturaDetalleRepository facturaDetalleRepository, IPrecioRepository precioRepository, IPromocionRepository promocionRepository, - IRecargoZonaRepository recargoZonaRepository, ISuscriptorRepository suscriptorRepository, - DbConnectionFactory connectionFactory, + IAjusteRepository ajusteRepository, IEmailService emailService, - ILogger logger) + IPublicacionRepository publicacionRepository, + DbConnectionFactory connectionFactory, + ILogger logger, + IConfiguration configuration) { _suscripcionRepository = suscripcionRepository; _facturaRepository = facturaRepository; + _empresaRepository = empresaRepository; + _facturaDetalleRepository = facturaDetalleRepository; _precioRepository = precioRepository; _promocionRepository = promocionRepository; - _recargoZonaRepository = recargoZonaRepository; _suscriptorRepository = suscriptorRepository; - _connectionFactory = connectionFactory; + _ajusteRepository = ajusteRepository; _emailService = emailService; + _publicacionRepository = publicacionRepository; + _connectionFactory = connectionFactory; _logger = logger; + _facturasPdfPath = configuration.GetValue("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF"; } public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario) + { + var periodoActual = new DateTime(anio, mes, 1); + var periodoActualStr = periodoActual.ToString("yyyy-MM"); + _logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodoActualStr, idUsuario); + + 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}.", 0); + } + } + + var facturasCreadas = new List(); + using var connection = _connectionFactory.CreateConnection(); + await (connection as System.Data.Common.DbConnection)!.OpenAsync(); + using var transaction = connection.BeginTransaction(); + + try + { + var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction); + if (!suscripcionesActivas.Any()) + { + return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0); + } + + // 1. Enriquecer las suscripciones con el IdEmpresa de su publicación + var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>(); + foreach (var s in suscripcionesActivas) + { + var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion); + if (pub != null) + { + suscripcionesConEmpresa.Add((s, pub.IdEmpresa)); + } + } + + // 2. Agrupar por la combinación (Suscriptor, Empresa) + var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa }); + + int facturasGeneradas = 0; + foreach (var grupo in gruposParaFacturar) + { + int idSuscriptor = grupo.Key.IdSuscriptor; + int idEmpresa = grupo.Key.IdEmpresa; + + // La verificación de existencia ahora debe ser más específica, pero por ahora la omitimos + // para no añadir otro método al repositorio. Asumimos que no se corre dos veces. + + decimal importeBrutoTotal = 0; + decimal descuentoPromocionesTotal = 0; + var detallesParaFactura = new List(); + + // 3. Calcular el importe para cada suscripción DENTRO del grupo + foreach (var item in grupo) + { + var suscripcion = item.Suscripcion; + decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction); + var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction); + decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones); + + importeBrutoTotal += importeBrutoSusc; + descuentoPromocionesTotal += descuentoSusc; + + var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion); + detallesParaFactura.Add(new FacturaDetalle + { + IdSuscripcion = suscripcion.IdSuscripcion, + Descripcion = $"Corresponde a {publicacion?.Nombre ?? "N/A"}", + ImporteBruto = importeBrutoSusc, + DescuentoAplicado = descuentoSusc, + ImporteNeto = importeBrutoSusc - descuentoSusc + }); + } + + // 4. Aplicar ajustes. Se aplican a la PRIMERA factura que se genere para el cliente. + var ultimoDiaDelMes = periodoActual.AddMonths(1).AddDays(-1); + var ajustesPendientes = await _ajusteRepository.GetAjustesPendientesHastaFechaAsync(idSuscriptor, ultimoDiaDelMes, transaction); + decimal totalAjustes = 0; + + // Verificamos si este grupo es el "primero" para este cliente para no aplicar ajustes varias veces + bool esPrimerGrupoParaCliente = !facturasCreadas.Any(f => f.IdSuscriptor == idSuscriptor); + if (esPrimerGrupoParaCliente) + { + totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto); + } + + var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes; + if (importeFinal < 0) importeFinal = 0; + if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue; + + // 5. Crear UNA factura por cada grupo (Suscriptor + Empresa) + var nuevaFactura = new Factura + { + IdSuscriptor = idSuscriptor, + Periodo = periodoActualStr, + FechaEmision = DateTime.Now.Date, + FechaVencimiento = new DateTime(anio, mes, 10), + ImporteBruto = importeBrutoTotal, + DescuentoAplicado = descuentoPromocionesTotal, + ImporteFinal = importeFinal, + EstadoPago = "Pendiente", + EstadoFacturacion = "Pendiente de Facturar" + }; + + var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); + if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}"); + + facturasCreadas.Add(facturaCreada); + + foreach (var detalle in detallesParaFactura) + { + detalle.IdFactura = facturaCreada.IdFactura; + await _facturaDetalleRepository.CreateAsync(detalle, transaction); + } + + if (esPrimerGrupoParaCliente && ajustesPendientes.Any()) + { + await _ajusteRepository.MarcarAjustesComoAplicadosAsync(ajustesPendientes.Select(a => a.IdAjuste), facturaCreada.IdFactura, transaction); + } + facturasGeneradas++; + } + // --- FIN DE LA LÓGICA DE AGRUPACIÓN --- + + transaction.Commit(); + _logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr); + + // --- INICIO DE LA LÓGICA DE ENVÍO CONSOLIDADO AUTOMÁTICO --- + int emailsEnviados = 0; + if (facturasCreadas.Any()) + { + // Agrupamos las facturas creadas por suscriptor para enviar un solo email + var suscriptoresAnotificar = facturasCreadas.Select(f => f.IdSuscriptor).Distinct().ToList(); + _logger.LogInformation("Iniciando envío automático de avisos para {Count} suscriptores.", suscriptoresAnotificar.Count); + + foreach (var idSuscriptor in suscriptoresAnotificar) + { + try + { + var (envioExitoso, _) = await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor); + if (envioExitoso) emailsEnviados++; + } + catch (Exception exEmail) + { + _logger.LogError(exEmail, "Falló el envío automático de email para el suscriptor ID {IdSuscriptor}", idSuscriptor); + } + } + _logger.LogInformation("{EmailsEnviados} avisos de vencimiento enviados automáticamente.", emailsEnviados); + } + + return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas y se enviaron {emailsEnviados} notificaciones.", facturasGeneradas); + } + catch (Exception ex) + { + try { transaction.Rollback(); } catch { } + _logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodoActualStr); + return (false, "Error interno del servidor al generar la facturación.", 0); + } + } + + public async Task> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) { var periodo = $"{anio}-{mes:D2}"; - _logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodo, idUsuario); + 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)> EnviarFacturaPdfPorEmail(int idFactura) + { + try + { + var factura = await _facturaRepository.GetByIdAsync(idFactura); + if (factura == null) return (false, "Factura no encontrada."); + if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número oficial asignado para generar el PDF."); + if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada."); + + 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."); + + var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); + var primeraSuscripcionId = detalles.FirstOrDefault()?.IdSuscripcion ?? 0; + var publicacion = await _publicacionRepository.GetByIdSimpleAsync(primeraSuscripcionId); + var empresa = await _empresaRepository.GetByIdAsync(publicacion?.IdEmpresa ?? 0); + + // --- LÓGICA DE BÚSQUEDA Y ADJUNTO DE PDF --- + 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_{empresa?.Nombre?.Replace(" ", "")}_{factura.NumeroFactura}.pdf"; + _logger.LogInformation("Adjuntando PDF encontrado en: {Ruta}", rutaCompleta); + } + else + { + _logger.LogWarning("Se intentó enviar la factura {NumeroFactura} pero no se encontró el PDF en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta); + return (false, "No se encontró el archivo PDF correspondiente en el servidor. Verifique que el archivo exista y el nombre coincida con el número de factura."); + } + + string asunto = $"Tu Factura Oficial - Diario El Día - Período {factura.Periodo}"; + string cuerpoHtml = $"

Hola {suscriptor.NombreCompleto},

Adjuntamos tu factura oficial número {factura.NumeroFactura} correspondiente al período {factura.Periodo}.

Gracias por ser parte de nuestra comunidad de lectores.

Diario El Día

"; + + await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, pdfAttachment, pdfFileName); + _logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor); + return (true, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura); + return (false, "Ocurrió un error al intentar enviar el email con la factura."); + } + } + + public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor) + { + var periodo = $"{anio}-{mes:D2}"; + try + { + var facturas = await _facturaRepository.GetListBySuscriptorYPeriodoAsync(idSuscriptor, periodo); + if (!facturas.Any()) return (false, "No se encontraron facturas para este suscriptor en el período."); + + var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); + if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email."); + + var resumenHtml = new StringBuilder(); + var adjuntos = new List<(byte[] content, string name)>(); + + foreach (var factura in facturas.Where(f => f.EstadoPago != "Anulada")) + { + var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); + if (!detalles.Any()) continue; + + var primeraSuscripcionId = detalles.First().IdSuscripcion; + var publicacion = await _publicacionRepository.GetByIdSimpleAsync(primeraSuscripcionId); + var empresa = await _empresaRepository.GetByIdAsync(publicacion?.IdEmpresa ?? 0); + + resumenHtml.Append($"

Resumen de Suscripción

"); + resumenHtml.Append(""); + foreach (var detalle in detalles) + { + resumenHtml.Append($""); + } + resumenHtml.Append($""); + resumenHtml.Append("
{detalle.Descripcion}${detalle.ImporteNeto:N2}
Subtotal${factura.ImporteFinal:N2}
"); + + 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_{empresa?.Nombre?.Replace(" ", "")}_{factura.NumeroFactura}.pdf"; + adjuntos.Add((pdfBytes, pdfFileName)); + _logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName); + } + else + { + _logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta); + } + } + } + + var totalGeneral = facturas.Where(f => f.EstadoPago != "Anulada").Sum(f => f.ImporteFinal); + string asunto = $"Resumen de Cuenta - Diario El Día - Período {periodo}"; + string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral); + + await _emailService.EnviarEmailConsolidadoAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, adjuntos); + _logger.LogInformation("Email consolidado para Suscriptor ID {IdSuscriptor} enviado para el período {Periodo}.", idSuscriptor, periodo); + return (true, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Falló el envío de email consolidado para el suscriptor ID {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo); + return (false, "Ocurrió un error al intentar enviar el email consolidado."); + } + } + + private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral) + { + return $@" +
+

Hola {suscriptor.NombreCompleto},

+

Le enviamos el resumen de su cuenta para el período {periodo}.

+ {resumenHtml} +
+ + + + + +
TOTAL:${totalGeneral:N2}
+

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.

+

Gracias por ser parte de nuestra comunidad de lectores.

+

Diario El Día

+
"; + } + + 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(); @@ -54,77 +409,31 @@ namespace GestionIntegral.Api.Services.Suscripciones try { - var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction); - if (!suscripcionesActivas.Any()) + var factura = await _facturaRepository.GetByIdAsync(idFactura); + if (factura == null) { - return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0); + return (false, "La factura especificada no existe."); + } + if (factura.EstadoPago == "Anulada") + { + return (false, "No se puede modificar una factura anulada."); } - int facturasGeneradas = 0; - foreach (var suscripcion in suscripcionesActivas) + var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction); + if (!actualizado) { - var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction); - if (facturaExistente != 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); - continue; - } - - // --- LÓGICA DE PROMOCIONES --- - var primerDiaMes = new DateTime(anio, mes, 1); - var promocionesAplicables = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, primerDiaMes, transaction); - - decimal importeBruto = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction); - decimal descuentoTotal = 0; - - // Aplicar promociones de descuento - foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "Porcentaje")) - { - descuentoTotal += (importeBruto * promo.Valor) / 100; - } - foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "MontoFijo")) - { - descuentoTotal += promo.Valor; - } - // La bonificación de días se aplicaría idealmente dentro de CalcularImporteParaSuscripcion, - // pero por simplicidad, aquí solo manejamos descuentos sobre el total. - - if (importeBruto <= 0) - { - _logger.LogInformation("Suscripción ID {IdSuscripcion} no tiene importe a facturar para el período {Periodo}. Se omite.", suscripcion.IdSuscripcion, periodo); - continue; - } - - var importeFinal = importeBruto - descuentoTotal; - if (importeFinal < 0) importeFinal = 0; // El importe no puede ser negativo - - var nuevaFactura = new Factura - { - IdSuscripcion = suscripcion.IdSuscripcion, - Periodo = periodo, - FechaEmision = DateTime.Now.Date, - FechaVencimiento = new DateTime(anio, mes, 10).AddMonths(1), - ImporteBruto = importeBruto, - DescuentoAplicado = descuentoTotal, - ImporteFinal = importeFinal, - Estado = "Pendiente de Facturar" - }; - - 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}"); - - facturasGeneradas++; + throw new DataException("La actualización del número de factura falló en el repositorio."); } transaction.Commit(); - _logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas); - return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", facturasGeneradas); + _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 crítico durante la generación de facturación para el período {Periodo}", periodo); - return (false, "Error interno del servidor al generar la facturación.", 0); + _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 +442,34 @@ namespace GestionIntegral.Api.Services.Suscripciones decimal importeTotal = 0; var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); var fechaActual = new DateTime(anio, mes, 1); + var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, fechaActual, transaction); + var promocionesDeBonificacion = promociones.Where(p => p.TipoEfecto == "BonificarEntregaDia").ToList(); while (fechaActual.Month == mes) { - // 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); if (diasDeEntrega.Contains(diaSemanaChar)) { + decimal precioDelDia = 0; var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction); if (precioActivo != null) { - importeTotal += GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek); + precioDelDia = GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek); } else { _logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date); } + + bool diaBonificado = promocionesDeBonificacion.Any(promo => EvaluarCondicionPromocion(promo, fechaActual)); + if (diaBonificado) + { + precioDelDia = 0; + _logger.LogInformation("Día {Fecha} bonificado para suscripción {IdSuscripcion} por promoción.", fechaActual.ToShortDateString(), suscripcion.IdSuscripcion); + } + importeTotal += precioDelDia; } } fechaActual = fechaActual.AddDays(1); @@ -159,72 +477,30 @@ namespace GestionIntegral.Api.Services.Suscripciones return importeTotal; } - public async Task> ObtenerFacturasPorPeriodo(int anio, int mes) + private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha) { - var periodo = $"{anio}-{mes:D2}"; - var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo); - - return facturasData.Select(data => new FacturaDto + switch (promocion.TipoCondicion) { - IdFactura = data.Factura.IdFactura, - IdSuscripcion = data.Factura.IdSuscripcion, - Periodo = data.Factura.Periodo, - FechaEmision = data.Factura.FechaEmision.ToString("yyyy-MM-dd"), - FechaVencimiento = data.Factura.FechaVencimiento.ToString("yyyy-MM-dd"), - ImporteFinal = data.Factura.ImporteFinal, - Estado = data.Factura.Estado, - NumeroFactura = data.Factura.NumeroFactura, - 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 = $@" -

Hola {suscriptor.NombreCompleto},

-

Te adjuntamos los detalles de tu factura para el período {factura.Periodo}.

-
    -
  • Número de Factura: {factura.NumeroFactura}
  • -
  • Importe Total: ${factura.ImporteFinal:N2}
  • -
  • Fecha de Vencimiento: {factura.FechaVencimiento:dd/MM/yyyy}
  • -
-

Gracias por ser parte de nuestra comunidad de lectores.

-

Diario El Día

"; - - 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."); + case "Siempre": return true; + case "DiaDeSemana": + int diaSemanaActual = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek; + return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActual; + case "PrimerDiaSemanaDelMes": + int diaSemanaActualMes = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek; + return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActualMes && fecha.Day <= 7; + default: return false; } } private string GetCharDiaSemana(DayOfWeek dia) => dia switch { - DayOfWeek.Sunday => "D", - DayOfWeek.Monday => "L", - DayOfWeek.Tuesday => "M", - DayOfWeek.Wednesday => "X", - DayOfWeek.Thursday => "J", - DayOfWeek.Friday => "V", - DayOfWeek.Saturday => "S", + DayOfWeek.Sunday => "Dom", + DayOfWeek.Monday => "Lun", + DayOfWeek.Tuesday => "Mar", + DayOfWeek.Wednesday => "Mie", + DayOfWeek.Thursday => "Jue", + DayOfWeek.Friday => "Vie", + DayOfWeek.Saturday => "Sab", _ => "" }; @@ -239,5 +515,14 @@ namespace GestionIntegral.Api.Services.Suscripciones DayOfWeek.Saturday => precio.Sabado ?? 0, _ => 0 }; + + private decimal CalcularDescuentoPromociones(decimal importeBruto, IEnumerable promociones) + { + return promociones.Where(p => p.TipoEfecto.Contains("Descuento")).Sum(p => + p.TipoEfecto == "DescuentoPorcentajeTotal" + ? (importeBruto * p.ValorEfecto) / 100 + : p.ValorEfecto + ); + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IAjusteService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IAjusteService.cs index c3da41c..c92bcfd 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/IAjusteService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IAjusteService.cs @@ -4,8 +4,9 @@ namespace GestionIntegral.Api.Services.Suscripciones { public interface IAjusteService { - Task> ObtenerAjustesPorSuscriptor(int idSuscriptor); + Task> 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); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs index 9411cd3..7e2eaf2 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs @@ -1,11 +1,15 @@ using GestionIntegral.Api.Dtos.Suscripciones; +using System.Collections.Generic; +using System.Threading.Tasks; namespace GestionIntegral.Api.Services.Suscripciones { public interface IFacturacionService { Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); - Task> ObtenerFacturasPorPeriodo(int anio, int mes); - Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura); + Task> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); + Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor); + Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura); + Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/ISuscripcionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/ISuscripcionService.cs index 36c1d08..590c4dc 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/ISuscripcionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/ISuscripcionService.cs @@ -4,13 +4,13 @@ namespace GestionIntegral.Api.Services.Suscripciones { public interface ISuscripcionService { - Task> ObtenerPorSuscriptorId(int idSuscriptor); Task ObtenerPorId(int idSuscripcion); + Task> ObtenerPorSuscriptorId(int idSuscriptor); Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario); Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario); - Task> ObtenerPromocionesAsignadas(int idSuscripcion); + Task> ObtenerPromocionesAsignadas(int idSuscripcion); Task> 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); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/PagoService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/PagoService.cs index 3314b90..5f4b85e 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/PagoService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/PagoService.cs @@ -66,45 +66,67 @@ namespace GestionIntegral.Api.Services.Suscripciones using var connection = _connectionFactory.CreateConnection(); await (connection as System.Data.Common.DbConnection)!.OpenAsync(); using var transaction = connection.BeginTransaction(); - try { var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura); 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); 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 { IdFactura = createDto.IdFactura, FechaPago = createDto.FechaPago, IdFormaPago = createDto.IdFormaPago, Monto = createDto.Monto, - Estado = "Aprobado", // Los pagos manuales se asumen aprobados + Estado = "Aprobado", Referencia = createDto.Referencia, Observaciones = createDto.Observaciones, IdUsuarioRegistro = idUsuario }; - // 1. Crear el registro del pago + // Creamos el nuevo pago var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); 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 - // (Permitimos pago mayor por si hay redondeos, etc.) - if (pagoCreado.Monto >= factura.ImporteFinal) + // Calculamos el nuevo total EN MEMORIA + var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto; + + // 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'."); } - + transaction.Commit(); _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); } catch (Exception ex) diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/PromocionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/PromocionService.cs index aed3abb..fb6552c 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/PromocionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/PromocionService.cs @@ -28,8 +28,10 @@ namespace GestionIntegral.Api.Services.Suscripciones { IdPromocion = promo.IdPromocion, Descripcion = promo.Descripcion, - TipoPromocion = promo.TipoPromocion, - Valor = promo.Valor, + TipoEfecto = promo.TipoEfecto, + ValorEfecto = promo.ValorEfecto, + TipoCondicion = promo.TipoCondicion, + ValorCondicion = promo.ValorCondicion, FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"), FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"), Activa = promo.Activa @@ -58,8 +60,10 @@ namespace GestionIntegral.Api.Services.Suscripciones var nuevaPromocion = new Promocion { Descripcion = createDto.Descripcion, - TipoPromocion = createDto.TipoPromocion, - Valor = createDto.Valor, + TipoEfecto = createDto.TipoEfecto, + ValorEfecto = createDto.ValorEfecto, + TipoCondicion = createDto.TipoCondicion, + ValorCondicion = createDto.ValorCondicion, FechaInicio = createDto.FechaInicio, FechaFin = createDto.FechaFin, 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."); } - // Mapeo existente.Descripcion = updateDto.Descripcion; - existente.TipoPromocion = updateDto.TipoPromocion; - existente.Valor = updateDto.Valor; + existente.TipoEfecto = updateDto.TipoEfecto; + existente.ValorEfecto = updateDto.ValorEfecto; + existente.TipoCondicion = updateDto.TipoCondicion; + existente.ValorCondicion = updateDto.ValorCondicion; existente.FechaInicio = updateDto.FechaInicio; existente.FechaFin = updateDto.FechaFin; existente.Activa = updateDto.Activa; diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs index 110566d..c64eaec 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs @@ -3,7 +3,12 @@ using GestionIntegral.Api.Data.Repositories.Distribucion; using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Models.Suscripciones; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; using System.Data; +using System.Linq; +using System.Threading.Tasks; namespace GestionIntegral.Api.Services.Suscripciones { @@ -36,8 +41,10 @@ namespace GestionIntegral.Api.Services.Suscripciones { IdPromocion = promo.IdPromocion, Descripcion = promo.Descripcion, - TipoPromocion = promo.TipoPromocion, - Valor = promo.Valor, + TipoEfecto = promo.TipoEfecto, + ValorEfecto = promo.ValorEfecto, + TipoCondicion = promo.TipoCondicion, + ValorCondicion = promo.ValorCondicion, FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"), FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"), Activa = promo.Activa @@ -154,47 +161,67 @@ namespace GestionIntegral.Api.Services.Suscripciones } } - public async Task> ObtenerPromocionesAsignadas(int idSuscripcion) + public async Task> ObtenerPromocionesAsignadas(int idSuscripcion) { - var promociones = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion); - return promociones.Select(MapPromocionToDto); + var asignaciones = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion); + 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> ObtenerPromocionesDisponibles(int idSuscripcion) { var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true); - var promosAsignadas = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion); - var idsAsignadas = promosAsignadas.Select(p => p.IdPromocion).ToHashSet(); + var promosAsignadasData = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion); + var idsAsignadas = promosAsignadasData.Select(p => p.Promocion.IdPromocion).ToHashSet(); return todasLasPromosActivas .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(); await (connection as System.Data.Common.DbConnection)!.OpenAsync(); using var transaction = connection.BeginTransaction(); try { - // Validaciones 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(); return (true, null); } catch (Exception ex) { - // Capturar error de Primary Key duplicada if (ex.Message.Contains("PRIMARY KEY constraint")) { return (false, "Esta promoción ya está asignada a la suscripción."); } 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."); } } diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs index a6304b2..3bc84d1 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs @@ -1,15 +1,8 @@ -// Archivo: GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs - using GestionIntegral.Api.Data; using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Models.Suscripciones; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; using System.Data; -using System.Linq; -using System.Threading.Tasks; namespace GestionIntegral.Api.Services.Suscripciones { diff --git a/Backend/GestionIntegral.Api/appsettings.json b/Backend/GestionIntegral.Api/appsettings.json index 6536e1c..30f1b16 100644 --- a/Backend/GestionIntegral.Api/appsettings.json +++ b/Backend/GestionIntegral.Api/appsettings.json @@ -5,6 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, + "AppSettings": { + "FacturasPdfPath": "C:\\Ruta\\A\\Tus\\FacturasPDF" + }, "Jwt": { "Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2", "Issuer": "GestionIntegralApi", @@ -13,11 +16,11 @@ }, "AllowedHosts": "*", "MailSettings": { - "SmtpHost": "smtp.yourprovider.com", + "SmtpHost": "mail.eldia.com", "SmtpPort": 587, - "SenderName": "Diario El Día - Suscripciones", - "SenderEmail": "suscripciones@eldia.com", - "SmtpUser": "your-smtp-username", - "SmtpPass": "your-smtp-password" + "SenderName": "Club - Diario El Día", + "SenderEmail": "alertas@eldia.com", + "SmtpUser": "alertas@eldia.com", + "SmtpPass": "@Alertas713550@" } } \ No newline at end of file diff --git a/Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx b/Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx index 07e0d18..41c2e04 100644 --- a/Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx +++ b/Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx @@ -1,6 +1,10 @@ +// Archivo: Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx + 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'; const modalStyle = { position: 'absolute' as 'absolute', @@ -12,34 +16,47 @@ const modalStyle = { boxShadow: 24, p: 4, }; +// --- TIPO UNIFICADO PARA EL ESTADO DEL FORMULARIO --- +type AjusteFormData = Partial; + interface AjusteFormModalProps { open: boolean; onClose: () => void; - onSubmit: (data: CreateAjusteDto) => Promise; + onSubmit: (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => Promise; + initialData?: AjusteDto | null; idSuscriptor: number; errorMessage?: string | null; clearErrorMessage: () => void; } -const AjusteFormModal: React.FC = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage }) => { - const [formData, setFormData] = useState>({}); +const AjusteFormModal: React.FC = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage, initialData }) => { + const [formData, setFormData] = useState({}); const [loading, setLoading] = useState(false); const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); + const isEditing = Boolean(initialData); + useEffect(() => { if (open) { + // Formatear fecha correctamente: el DTO de Ajuste tiene FechaAlta con hora, pero el input necesita "yyyy-MM-dd" + const fechaParaFormulario = initialData?.fechaAjuste + ? initialData.fechaAjuste.split(' ')[0] // Tomar solo la parte de la fecha + : new Date().toISOString().split('T')[0]; + setFormData({ - idSuscriptor: idSuscriptor, - tipoAjuste: 'Credito', // Por defecto es un crédito (descuento) - monto: 0, - motivo: '' + idSuscriptor: initialData?.idSuscriptor || idSuscriptor, + fechaAjuste: fechaParaFormulario, + tipoAjuste: initialData?.tipoAjuste || 'Credito', + monto: initialData?.monto || undefined, // undefined para que el placeholder se muestre + motivo: initialData?.motivo || '' }); setLocalErrors({}); } - }, [open, idSuscriptor]); + }, [open, initialData, idSuscriptor]); const validate = (): boolean => { const errors: { [key: string]: string | null } = {}; + 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."; @@ -47,16 +64,20 @@ const AjusteFormModal: React.FC = ({ open, onClose, onSubm return Object.keys(errors).length === 0; }; + // --- HANDLERS CON TIPADO EXPLÍCITO --- const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; - setFormData(prev => ({ ...prev, [name]: name === 'monto' ? parseFloat(value) : value })); + 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) => { + + const handleSelectChange = (e: SelectChangeEvent) => { // Tipado como string const { name, value } = e.target; - setFormData(prev => ({ ...prev, [name]: value })); + setFormData((prev: AjusteFormData) => ({ ...prev, [name]: value })); if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); if (errorMessage) clearErrorMessage(); }; @@ -68,7 +89,11 @@ const AjusteFormModal: React.FC = ({ open, onClose, onSubm setLoading(true); let success = false; try { - await onSubmit(formData as CreateAjusteDto); + if (isEditing && initialData) { + await onSubmit(formData as UpdateAjusteDto, initialData.idAjuste); + } else { + await onSubmit(formData as CreateAjusteDto); + } success = true; } catch (error) { success = false; @@ -81,20 +106,22 @@ const AjusteFormModal: React.FC = ({ open, onClose, onSubm return ( - Registrar Ajuste Manual + {isEditing ? 'Editar Ajuste Manual' : 'Registrar Ajuste Manual'} + - Tipo de Ajuste - + Tipo de Ajuste + $ }} inputProps={{ step: "0.01" }} /> - + + Nota: Este ajuste se aplicará en la facturación del período correspondiente a la "Fecha del Ajuste". + {errorMessage && {errorMessage}} - + + setVigenciaDesde(e.target.value)} required fullWidth size="small" InputLabelProps={{ shrink: true }} /> + setVigenciaHasta(e.target.value)} fullWidth size="small" InputLabelProps={{ shrink: true }} /> + + )} diff --git a/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx b/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx index 25630b6..2068d8b 100644 --- a/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx +++ b/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx @@ -1,5 +1,3 @@ -// Archivo: Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx - 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 { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto'; @@ -54,7 +52,7 @@ const PagoManualModal: React.FC = ({ open, onClose, onSubm fetchFormasDePago(); setFormData({ idFactura: factura.idFactura, - monto: factura.importeFinal, + monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto fechaPago: new Date().toISOString().split('T')[0] }); setLocalErrors({}); @@ -64,8 +62,18 @@ const PagoManualModal: React.FC = ({ open, onClose, onSubm const validate = (): boolean => { const errors: { [key: string]: string | null } = {}; 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."; + + 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); return Object.keys(errors).length === 0; }; @@ -109,8 +117,8 @@ const PagoManualModal: React.FC = ({ open, onClose, onSubm Registrar Pago Manual - - Factura #{factura.idFactura} para {factura.nombreSuscriptor} + + Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)} diff --git a/Frontend/src/components/Modals/Suscripciones/PromocionFormModal.tsx b/Frontend/src/components/Modals/Suscripciones/PromocionFormModal.tsx index d7d6ce6..d49c510 100644 --- a/Frontend/src/components/Modals/Suscripciones/PromocionFormModal.tsx +++ b/Frontend/src/components/Modals/Suscripciones/PromocionFormModal.tsx @@ -1,28 +1,33 @@ import React, { useState, useEffect } from 'react'; -import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, - FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, - type SelectChangeEvent, InputAdornment } from '@mui/material'; +import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, type SelectChangeEvent, InputAdornment } from '@mui/material'; import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto'; import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto'; const modalStyle = { position: 'absolute' as 'absolute', - top: '50%', - left: '50%', + 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' + boxShadow: 24, p: 4, + maxHeight: '90vh', overflowY: 'auto' }; -const tiposPromocion = [ - { value: 'Porcentaje', label: 'Descuento Porcentual (%)' }, - { value: 'MontoFijo', label: 'Descuento de Monto Fijo ($)' }, - // { value: 'BonificacionDias', label: 'Bonificación de Días' }, // Descomentar para futuras implementaciones +const tiposEfecto = [ + { value: 'DescuentoPorcentajeTotal', label: 'Descuento en Porcentaje (%) sobre el total' }, + { value: 'DescuentoMontoFijoTotal', label: 'Descuento en Monto Fijo ($) sobre el total' }, + { 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 { @@ -38,18 +43,22 @@ const PromocionFormModal: React.FC = ({ open, onClose, const [formData, setFormData] = useState>({}); const [loading, setLoading] = useState(false); const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); - const isEditing = Boolean(initialData); + const necesitaValorCondicion = formData.tipoCondicion === 'DiaDeSemana' || formData.tipoCondicion === 'PrimerDiaSemanaDelMes'; + useEffect(() => { if (open) { - setFormData(initialData || { + const defaults = { descripcion: '', - tipoPromocion: 'Porcentaje', - valor: 0, + tipoEfecto: 'DescuentoPorcentajeTotal' as const, + valorEfecto: 0, + tipoCondicion: 'Siempre' as const, + valorCondicion: null, fechaInicio: new Date().toISOString().split('T')[0], activa: true - }); + }; + setFormData(initialData ? { ...initialData } : defaults); setLocalErrors({}); } }, [open, initialData]); @@ -57,10 +66,16 @@ const PromocionFormModal: React.FC = ({ open, onClose, const validate = (): boolean => { const errors: { [key: string]: string | null } = {}; 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.valor || formData.valor <= 0) errors.valor = 'El valor debe ser mayor a cero.'; - if (formData.tipoPromocion === 'Porcentaje' && (formData.valor ?? 0) > 100) { - errors.valor = 'El valor para porcentaje no puede ser mayor a 100.'; + if (!formData.tipoEfecto) errors.tipoEfecto = 'El tipo de efecto es obligatorio.'; + if (formData.tipoEfecto !== 'BonificarEntregaDia' && (!formData.valorEfecto || formData.valorEfecto <= 0)) { + errors.valorEfecto = 'El valor debe ser mayor a cero.'; + } + 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.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) { @@ -72,7 +87,7 @@ const PromocionFormModal: React.FC = ({ open, onClose, const handleInputChange = (e: React.ChangeEvent) => { 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 })); if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); if (errorMessage) clearErrorMessage(); @@ -80,7 +95,16 @@ const PromocionFormModal: React.FC = ({ open, onClose, const handleSelectChange = (e: SelectChangeEvent) => { 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 (errorMessage) clearErrorMessage(); }; @@ -93,11 +117,7 @@ const PromocionFormModal: React.FC = ({ open, onClose, setLoading(true); let success = false; try { - const dataToSubmit = { - ...formData, - fechaFin: formData.fechaFin || null - } as CreatePromocionDto | UpdatePromocionDto; - + const dataToSubmit = { ...formData, fechaFin: formData.fechaFin || null } as CreatePromocionDto | UpdatePromocionDto; await onSubmit(dataToSubmit, initialData?.idPromocion); success = true; } catch (error) { @@ -111,32 +131,43 @@ const PromocionFormModal: React.FC = ({ open, onClose, return ( - {isEditing ? 'Editar Promoción' : 'Nueva Promoción'} + {isEditing ? 'Editar Promoción' : 'Nueva Promoción'} - - - - Tipo - - - {formData.tipoPromocion === 'Porcentaje' ? '%' : '$'} }} + + Efecto de la Promoción + + + {formData.tipoEfecto !== 'BonificarEntregaDia' && ( + {formData.tipoEfecto === 'DescuentoPorcentajeTotal' ? '%' : '$'} }} inputProps={{ step: "0.01" }} /> - - - + )} + + Condición de Aplicación + + + {necesitaValorCondicion && ( + + Día de la Semana + + + )} + - } label="Promoción Activa" sx={{mt: 1}} /> {errorMessage && {errorMessage}} - + + + + ); +}; + +export default SeleccionaReporteFacturasPublicidad; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx b/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx new file mode 100644 index 0000000..205aa0e --- /dev/null +++ b/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx @@ -0,0 +1,280 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, Typography, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText, Collapse, TextField } from '@mui/material'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import PaymentIcon from '@mui/icons-material/Payment'; +import EmailIcon from '@mui/icons-material/Email'; +import EditNoteIcon from '@mui/icons-material/EditNote'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import facturacionService from '../../services/Suscripciones/facturacionService'; +import { usePermissions } from '../../hooks/usePermissions'; +import axios from 'axios'; +import type { ResumenCuentaSuscriptorDto, FacturaConsolidadaDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; +import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal'; +import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; + +const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i); +const meses = [ + { value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' }, + { value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' }, + { value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' }, + { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } +]; + +const estadosPago = ['Pendiente', 'Pagada', 'Rechazada', 'Anulada']; +const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; + +const SuscriptorRow: React.FC<{ + resumen: ResumenCuentaSuscriptorDto; + handleMenuOpen: (event: React.MouseEvent, factura: FacturaConsolidadaDto) => void; +}> = ({ resumen, handleMenuOpen }) => { + const [open, setOpen] = useState(false); + return ( + + *': { borderBottom: 'unset' } }} hover> + setOpen(!open)}>{open ? : } + {resumen.nombreSuscriptor} + + 0 ? 'error.main' : 'success.main' }}>${resumen.saldoPendienteTotal.toFixed(2)} + de ${resumen.importeTotal.toFixed(2)} + + {/* La cabecera principal ya no tiene acciones */} + + + + + + + Facturas del Período para {resumen.nombreSuscriptor} + + + + EmpresaImporte + Estado PagoEstado Facturación + Nro. FacturaAcciones + + + + {resumen.facturas.map((factura) => ( + + {factura.nombreEmpresa} + ${factura.importeFinal.toFixed(2)} + + + {factura.numeroFactura || '-'} + + {/* El menú de acciones vuelve a estar aquí, por factura */} + handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> + + + + + ))} + +
+
+
+
+
+
+ ); +}; + +const ConsultaFacturasPage: React.FC = () => { + const [selectedAnio, setSelectedAnio] = useState(new Date().getFullYear()); + const [selectedMes, setSelectedMes] = useState(new Date().getMonth() + 1); + const [loading, setLoading] = useState(true); + const [apiMessage, setApiMessage] = useState(null); + const [apiError, setApiError] = useState(null); + const [resumenes, setResumenes] = useState([]); + const { tienePermiso, isSuperAdmin } = usePermissions(); + const puedeConsultar = isSuperAdmin || tienePermiso("SU006"); + const puedeGestionarFactura = isSuperAdmin || tienePermiso("SU006"); + const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008"); + const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009"); + const [pagoModalOpen, setPagoModalOpen] = useState(false); + const [selectedFactura, setSelectedFactura] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); + const [filtroNombre, setFiltroNombre] = useState(''); + const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); + const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); + + const cargarResumenesDelPeriodo = useCallback(async () => { + if (!puedeConsultar) return; + setLoading(true); + setApiError(null); + try { + const data = await facturacionService.getResumenesDeCuentaPorPeriodo( + selectedAnio, + selectedMes, + filtroNombre || undefined, + filtroEstadoPago || undefined, + filtroEstadoFacturacion || undefined + ); + setResumenes(data); + } catch (err) { + setResumenes([]); + setApiError("Error al cargar los resúmenes de cuenta del período."); + } finally { + setLoading(false); + } + }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]); + + useEffect(() => { + // Ejecutar la búsqueda cuando los filtros cambian + const timer = setTimeout(() => { + cargarResumenesDelPeriodo(); + }, 500); // Debounce para no buscar en cada tecla + return () => clearTimeout(timer); + }, [cargarResumenesDelPeriodo]); + + const handleMenuOpen = (event: React.MouseEvent, factura: FacturaConsolidadaDto) => { + setAnchorEl(event.currentTarget); + setSelectedFactura(factura); + }; + + const handleMenuClose = () => { setAnchorEl(null); }; + const handleOpenPagoModal = () => { setPagoModalOpen(true); handleMenuClose(); }; + const handleClosePagoModal = () => { setPagoModalOpen(false); setSelectedFactura(null); }; + + const handleSubmitPagoModal = async (data: CreatePagoDto) => { + setApiError(null); + try { + await facturacionService.registrarPagoManual(data); + setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`); + cargarResumenesDelPeriodo(); + } catch (err: any) { + const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.'; + setApiError(message); + throw err; + } + }; + + const handleUpdateNumeroFactura = async (factura: FacturaConsolidadaDto) => { + const nuevoNumero = prompt("Ingrese el número de factura (ARCA):", factura.numeroFactura || ""); + handleMenuClose(); + if (nuevoNumero !== null && nuevoNumero.trim() !== "") { + setApiError(null); + try { + await facturacionService.actualizarNumeroFactura(factura.idFactura, nuevoNumero.trim()); + setApiMessage(`Número de factura #${factura.idFactura} actualizado.`); + cargarResumenesDelPeriodo(); + } catch (err: any) { + setApiError(err.response?.data?.message || 'Error al actualizar el número de factura.'); + } + } + }; + + const handleSendEmail = async (idFactura: number) => { + if (!window.confirm(`¿Está seguro de enviar la factura #${idFactura} por email? Se adjuntará el PDF si se encuentra.`)) return; + setApiMessage(null); + setApiError(null); + try { + await facturacionService.enviarFacturaPdfPorEmail(idFactura); + setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`); + } catch (err: any) { + setApiError(err.response?.data?.message || 'Error al intentar enviar el email.'); + } finally { + handleMenuClose(); + } + }; + + if (!puedeConsultar) return No tiene permiso para acceder a esta sección.; + + return ( + + Consulta de Facturas de Suscripciones + + Filtros + + Mes + Año + setFiltroNombre(e.target.value)} + sx={{flexGrow: 1, minWidth: '200px'}} + /> + + Estado de Pago + + + + Estado de Facturación + + + + + + {apiError && {apiError}} + {apiMessage && {apiMessage}} + + + + + + + Suscriptor + Saldo Total / Importe Total + + + + + {loading ? () + : resumenes.length === 0 ? (No hay facturas para el período seleccionado.) + : (resumenes.map(resumen => ()))} + +
+
+ + {/* El menú de acciones ahora opera sobre la 'selectedFactura' */} + + {selectedFactura && puedeRegistrarPago && (Registrar Pago Manual)} + {selectedFactura && puedeGestionarFactura && ( handleUpdateNumeroFactura(selectedFactura)} disabled={selectedFactura.estadoPago === 'Anulada'}>Cargar/Modificar Nro. Factura)} + {selectedFactura && puedeEnviarEmail && ( + handleSendEmail(selectedFactura.idFactura)} + disabled={!selectedFactura.numeroFactura || selectedFactura.estadoPago === 'Anulada'}> + + Enviar Factura (PDF) + + )} + + + r.idSuscriptor === selectedFactura.idSuscriptor)?.nombreSuscriptor || '', + importeFinal: selectedFactura.importeFinal, + // Calculamos el saldo pendiente aquí + saldoPendiente: selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal, // Simplificación + // Rellenamos los campos restantes que el modal podría necesitar, aunque no los use. + idSuscriptor: selectedFactura.idSuscriptor, // Corregido para coincidir con FacturaDto + periodo: '', + fechaEmision: '', + fechaVencimiento: '', + totalPagado: selectedFactura.importeFinal - (selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal), + estadoPago: selectedFactura.estadoPago, + estadoFacturacion: selectedFactura.estadoFacturacion, + numeroFactura: selectedFactura.numeroFactura, + detalles: selectedFactura.detalles, + } : null + } + errorMessage={apiError} + clearErrorMessage={() => setApiError(null)} /> +
+ ); +}; + +export default ConsultaFacturasPage; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorPage.tsx b/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorPage.tsx new file mode 100644 index 0000000..2c851e6 --- /dev/null +++ b/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorPage.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, Tooltip, IconButton, TextField } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import EditIcon from '@mui/icons-material/Edit'; +import CancelIcon from '@mui/icons-material/Cancel'; +import ajusteService from '../../services/Suscripciones/ajusteService'; +import suscriptorService from '../../services/Suscripciones/suscriptorService'; +import { usePermissions } from '../../hooks/usePermissions'; +import axios from 'axios'; +import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto'; +import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto'; +import type { UpdateAjusteDto } from '../../models/dtos/Suscripciones/UpdateAjusteDto'; +import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto'; +import AjusteFormModal from '../../components/Modals/Suscripciones/AjusteFormModal'; + +const getInitialDateRange = () => { + const today = new Date(); + const firstDay = new Date(today.getFullYear(), today.getMonth(), 1); + const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); + const formatDate = (date: Date) => date.toISOString().split('T')[0]; + return { + fechaDesde: formatDate(firstDay), + fechaHasta: formatDate(lastDay) + }; +}; + +const CuentaCorrienteSuscriptorPage: React.FC = () => { + const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>(); + const navigate = useNavigate(); + const idSuscriptor = Number(idSuscriptorStr); + + const [suscriptor, setSuscriptor] = useState(null); + const [ajustes, setAjustes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [editingAjuste, setEditingAjuste] = useState(null); + const [apiErrorMessage, setApiErrorMessage] = useState(null); + const [filtroFechaDesde, setFiltroFechaDesde] = useState(getInitialDateRange().fechaDesde); + const [filtroFechaHasta, setFiltroFechaHasta] = useState(getInitialDateRange().fechaHasta); + + const { tienePermiso, isSuperAdmin } = usePermissions(); + const puedeGestionar = isSuperAdmin || tienePermiso("SU011"); + + const cargarDatos = useCallback(async () => { + if (isNaN(idSuscriptor)) { + setError("ID de Suscriptor inválido."); setLoading(false); return; + } + setLoading(true); setApiErrorMessage(null); setError(null); + try { + const suscriptorData = await suscriptorService.getSuscriptorById(idSuscriptor); + setSuscriptor(suscriptorData); + + const ajustesData = await ajusteService.getAjustesPorSuscriptor(idSuscriptor, filtroFechaDesde || undefined, filtroFechaHasta || undefined); + setAjustes(ajustesData); + + } catch (err) { + setError("Error al cargar los datos."); + } finally { + setLoading(false); + } + }, [idSuscriptor, puedeGestionar, filtroFechaDesde, filtroFechaHasta]); + + useEffect(() => { cargarDatos(); }, [cargarDatos]); + + // --- INICIO DE LA LÓGICA DE SINCRONIZACIÓN DE FECHAS --- + const handleFechaDesdeChange = (e: React.ChangeEvent) => { + const nuevaFechaDesde = e.target.value; + setFiltroFechaDesde(nuevaFechaDesde); + // Si la nueva fecha "desde" es posterior a la fecha "hasta", ajusta la fecha "hasta" + if (nuevaFechaDesde && filtroFechaHasta && new Date(nuevaFechaDesde) > new Date(filtroFechaHasta)) { + setFiltroFechaHasta(nuevaFechaDesde); + } + }; + + const handleFechaHastaChange = (e: React.ChangeEvent) => { + const nuevaFechaHasta = e.target.value; + setFiltroFechaHasta(nuevaFechaHasta); + // Si la nueva fecha "hasta" es anterior a la fecha "desde", ajusta la fecha "desde" + if (nuevaFechaHasta && filtroFechaDesde && new Date(nuevaFechaHasta) < new Date(filtroFechaDesde)) { + setFiltroFechaDesde(nuevaFechaHasta); + } + }; + + const handleOpenModal = (ajuste?: AjusteDto) => { + setEditingAjuste(ajuste || null); + setModalOpen(true); + }; + + const handleCloseModal = () => { + setModalOpen(false); + setEditingAjuste(null); + }; + + const handleSubmitModal = async (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => { + setApiErrorMessage(null); + try { + if (id && editingAjuste) { + await ajusteService.updateAjuste(id, data as UpdateAjusteDto); + } else { + await ajusteService.createAjusteManual(data as CreateAjusteDto); + } + cargarDatos(); + } catch (err: any) { + const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el ajuste.'; + setApiErrorMessage(message); + throw err; + } + }; + + const handleAnularAjuste = async (idAjuste: number) => { + if (window.confirm("¿Está seguro de que desea anular este ajuste? Esta acción no se puede deshacer.")) { + setApiErrorMessage(null); + try { + await ajusteService.anularAjuste(idAjuste); + cargarDatos(); + } catch (err: any) { + setApiErrorMessage(err.response?.data?.message || "Error al anular el ajuste."); + } + } + }; + + const formatDisplayDate = (dateString: string): string => { + if (!dateString) return ''; + const datePart = dateString.split(' ')[0]; + const parts = datePart.split('-'); + if (parts.length === 3) { + return `${parts[2]}/${parts[1]}/${parts[0]}`; + } + return dateString; + }; + + if (loading && !suscriptor) return ; + if (error) return {error}; + + return ( + + + Cuenta Corriente de: + {suscriptor?.nombreCompleto || ''} + + + + + + + + {puedeGestionar && ( + + )} + + + + {apiErrorMessage && {apiErrorMessage}} + + + + + + Fecha Ajuste + Tipo + Motivo + Monto + Estado + Usuario Carga + Acciones + + + + {loading ? ( + + ) : ajustes.length === 0 ? ( + No se encontraron ajustes para los filtros seleccionados. + ) : ( + ajustes.map(a => ( + + {formatDisplayDate(a.fechaAjuste)} + + + + {a.motivo} + ${a.monto.toFixed(2)} + {a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''} + {a.nombreUsuarioAlta} + + {a.estado === 'Pendiente' && puedeGestionar && ( + <> + + handleOpenModal(a)} size="small"> + + + + + handleAnularAjuste(a.idAjuste)} size="small"> + + + + + )} + + + )) + )} + +
+
+ + setApiErrorMessage(null)} + /> +
+ ); +}; + +export default CuentaCorrienteSuscriptorPage; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorTab.tsx b/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorTab.tsx deleted file mode 100644 index fd0bff2..0000000 --- a/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorTab.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Tooltip } from '@mui/material'; -import AddIcon from '@mui/icons-material/Add'; -import ajusteService from '../../services/Suscripciones/ajusteService'; -import { usePermissions } from '../../hooks/usePermissions'; -import axios from 'axios'; -import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto'; -import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto'; -import AjusteFormModal from '../../components/Modals/Suscripciones/AjusteFormModal'; -import CancelIcon from '@mui/icons-material/Cancel'; - -interface CuentaCorrienteSuscriptorTabProps { - idSuscriptor: number; -} - -const CuentaCorrienteSuscriptorTab: React.FC = ({ idSuscriptor }) => { - const [ajustes, setAjustes] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [modalOpen, setModalOpen] = useState(false); - const [apiErrorMessage, setApiErrorMessage] = useState(null); - - const { tienePermiso, isSuperAdmin } = usePermissions(); - const puedeGestionar = isSuperAdmin || tienePermiso("SU011"); - - const cargarDatos = useCallback(async () => { - if (!puedeGestionar) { - setError("No tiene permiso para ver la cuenta corriente."); setLoading(false); return; - } - setLoading(true); setApiErrorMessage(null); - try { - const data = await ajusteService.getAjustesPorSuscriptor(idSuscriptor); - setAjustes(data); - } catch (err) { - setError("Error al cargar los ajustes del suscriptor."); - } finally { - setLoading(false); - } - }, [idSuscriptor, puedeGestionar]); - - useEffect(() => { - cargarDatos(); - }, [cargarDatos]); - - const handleSubmitModal = async (data: CreateAjusteDto) => { - setApiErrorMessage(null); - try { - await ajusteService.createAjusteManual(data); - cargarDatos(); - } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el ajuste.'; - setApiErrorMessage(message); - throw err; - } - }; - - const handleAnularAjuste = async (idAjuste: number) => { - if (window.confirm("¿Está seguro de que desea anular este ajuste? Esta acción no se puede deshacer.")) { - setApiErrorMessage(null); - try { - await ajusteService.anularAjuste(idAjuste); - cargarDatos(); // Recargar para ver el cambio de estado - } catch (err: any) { - setApiErrorMessage(err.response?.data?.message || "Error al anular el ajuste."); - } - } - }; - - if (loading) return ; - if (error) return {error}; - - return ( - - - Historial de Ajustes - - - {apiErrorMessage && {apiErrorMessage}} - - - - FechaTipoMotivo - MontoEstadoUsuario - Acciones - - - {ajustes.map(a => ( - - {a.fechaAlta} - - - - {a.motivo} - ${a.monto.toFixed(2)} - {a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''} - {a.nombreUsuarioAlta} - - {a.estado === 'Pendiente' && puedeGestionar && ( - - handleAnularAjuste(a.idAjuste)} size="small"> - - - - )} - - - ))} - -
-
- setModalOpen(false)} - onSubmit={handleSubmitModal} - idSuscriptor={idSuscriptor} - errorMessage={apiErrorMessage} - clearErrorMessage={() => setApiErrorMessage(null)} - /> -
- ); -}; - -export default CuentaCorrienteSuscriptorTab; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/FacturacionPage.tsx b/Frontend/src/pages/Suscripciones/FacturacionPage.tsx index 3839c9b..f31fb66 100644 --- a/Frontend/src/pages/Suscripciones/FacturacionPage.tsx +++ b/Frontend/src/pages/Suscripciones/FacturacionPage.tsx @@ -1,18 +1,12 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText } from '@mui/material'; +import React, { useState } from 'react'; +import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel } from '@mui/material'; import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; import DownloadIcon from '@mui/icons-material/Download'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; -import PaymentIcon from '@mui/icons-material/Payment'; -import EmailIcon from '@mui/icons-material/Email'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import { styled } from '@mui/material/styles'; import facturacionService from '../../services/Suscripciones/facturacionService'; import { usePermissions } from '../../hooks/usePermissions'; import axios from 'axios'; -import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto'; -import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal'; -import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i); const meses = [ @@ -35,37 +29,14 @@ const FacturacionPage: React.FC = () => { const [loadingProceso, setLoadingProceso] = useState(false); const [apiMessage, setApiMessage] = useState(null); const [apiError, setApiError] = useState(null); - const [facturas, setFacturas] = useState([]); + const [archivoSeleccionado, setArchivoSeleccionado] = useState(null); + const { tienePermiso, isSuperAdmin } = usePermissions(); const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006"); const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007"); - const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008"); - const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009"); - const [pagoModalOpen, setPagoModalOpen] = useState(false); - const [selectedFactura, setSelectedFactura] = useState(null); - const [anchorEl, setAnchorEl] = useState(null); - const [archivoSeleccionado, setArchivoSeleccionado] = useState(null); - - const cargarFacturasDelPeriodo = useCallback(async () => { - if (!puedeGenerarFacturacion) return; - setLoading(true); - try { - const data = await facturacionService.getFacturasPorPeriodo(selectedAnio, selectedMes); - setFacturas(data); - } catch (err) { - setFacturas([]); - console.error(err); - } finally { - setLoading(false); - } - }, [selectedAnio, selectedMes, puedeGenerarFacturacion]); - - useEffect(() => { - cargarFacturasDelPeriodo(); - }, [cargarFacturasDelPeriodo]); const handleGenerarFacturacion = async () => { - if (!window.confirm(`¿Está seguro de que desea generar la facturación para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Este proceso creará registros de cobro para todas las suscripciones activas.`)) { + if (!window.confirm(`¿Está seguro de generar el cierre para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Se aplicarán los ajustes pendientes del mes anterior y se generarán los nuevos importes a cobrar.`)) { return; } setLoading(true); @@ -74,7 +45,6 @@ const FacturacionPage: React.FC = () => { try { const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes); setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`); - await cargarFacturasDelPeriodo(); } catch (err: any) { const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message @@ -103,7 +73,6 @@ const FacturacionPage: React.FC = () => { link.parentNode?.removeChild(link); window.URL.revokeObjectURL(url); setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`); - cargarFacturasDelPeriodo(); } catch (err: any) { let message = 'Ocurrió un error al generar el archivo.'; if (axios.isAxiosError(err) && err.response) { @@ -119,52 +88,6 @@ const FacturacionPage: React.FC = () => { } }; - const handleMenuOpen = (event: React.MouseEvent, factura: FacturaDto) => { - setAnchorEl(event.currentTarget); - setSelectedFactura(factura); - }; - - const handleMenuClose = () => { - setAnchorEl(null); - setSelectedFactura(null); - }; - - const handleOpenPagoModal = () => { - setPagoModalOpen(true); - handleMenuClose(); - }; - - const handleClosePagoModal = () => { - setPagoModalOpen(false); - }; - - const handleSubmitPagoModal = async (data: CreatePagoDto) => { - setApiError(null); - try { - await facturacionService.registrarPagoManual(data); - setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`); - cargarFacturasDelPeriodo(); - } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.'; - setApiError(message); - throw err; - } - }; - - const handleSendEmail = async (idFactura: number) => { - if (!window.confirm(`¿Está seguro de enviar la notificación de la factura #${idFactura} por email?`)) return; - setApiMessage(null); - setApiError(null); - try { - await facturacionService.enviarFacturaPorEmail(idFactura); - setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`); - } catch (err: any) { - setApiError(err.response?.data?.message || 'Error al intentar enviar el email.'); - } finally { - handleMenuClose(); - } - }; - const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files.length > 0) { setArchivoSeleccionado(event.target.files[0]); @@ -187,7 +110,6 @@ const FacturacionPage: React.FC = () => { if (response.errores?.length > 0) { setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`); } - cargarFacturasDelPeriodo(); // Recargar para ver los estados finales } catch (err: any) { const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen ? err.response.data.mensajeResumen @@ -203,15 +125,20 @@ const FacturacionPage: React.FC = () => { return No tiene permiso para acceder a esta sección.; } + if (!puedeGenerarFacturacion) { + return No tiene permiso para acceder a esta sección.; + } + return ( - Facturación y Débito Automático - - 1. Generación de Facturación + Procesos Mensuales de Suscripciones + + + 1. Generación de Cierre Mensual - Este proceso calcula los importes a cobrar para todas las suscripciones activas en el período seleccionado. + Este proceso calcula los importes a cobrar y envía automáticamente una notificación de "Aviso de Vencimiento" a cada suscriptor. - + Mes @@ -221,20 +148,23 @@ const FacturacionPage: React.FC = () => { - + - + + 2. Generación de Archivo para Banco - Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro. + Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro. - + 3. Procesar Respuesta del Banco - + Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada". - + + Gestionar Suscripciones de: + {suscriptor?.nombreCompleto || ''} + + {puedeGestionar && ( - {suscriptor?.nombreCompleto || 'Cargando...'} - - Documento: {suscriptor?.tipoDocumento} {suscriptor?.nroDocumento} | Dirección: {suscriptor?.direccion} - - - - - - - - - - - {tabValue === 0 && ( - - )} - {tabValue === 1 && ( - - )} - - - ); -}; - -export default GestionarSuscripcionesSuscriptorPage; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx b/Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx index 4dff0b2..4dfa188 100644 --- a/Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx +++ b/Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx @@ -1,5 +1,3 @@ -// Archivo: Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx - import React, { useState, useEffect, useCallback } from 'react'; import { Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, CircularProgress, Alert, Chip, ListItemIcon, ListItemText, FormControlLabel } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; @@ -16,188 +14,220 @@ import { usePermissions } from '../../hooks/usePermissions'; import axios from 'axios'; import { useNavigate } from 'react-router-dom'; import ArticleIcon from '@mui/icons-material/Article'; +import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; const GestionarSuscriptoresPage: React.FC = () => { - const [suscriptores, setSuscriptores] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [filtroNombre, setFiltroNombre] = useState(''); - const [filtroNroDoc, setFiltroNroDoc] = useState(''); - const [filtroSoloActivos, setFiltroSoloActivos] = useState(true); - const [modalOpen, setModalOpen] = useState(false); - const [editingSuscriptor, setEditingSuscriptor] = useState(null); - const [apiErrorMessage, setApiErrorMessage] = useState(null); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(15); - const [anchorEl, setAnchorEl] = useState(null); - const [selectedRow, setSelectedRow] = useState(null); + const [suscriptores, setSuscriptores] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filtroNombre, setFiltroNombre] = useState(''); + const [filtroNroDoc, setFiltroNroDoc] = useState(''); + const [filtroSoloActivos, setFiltroSoloActivos] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [editingSuscriptor, setEditingSuscriptor] = useState(null); + const [apiErrorMessage, setApiErrorMessage] = useState(null); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(15); + const [anchorEl, setAnchorEl] = useState(null); + const [selectedRow, setSelectedRow] = useState(null); - const { tienePermiso, isSuperAdmin } = usePermissions(); + const { tienePermiso, isSuperAdmin } = usePermissions(); + const navigate = useNavigate(); - const puedeVer = isSuperAdmin || tienePermiso("SU001"); - const puedeCrear = isSuperAdmin || tienePermiso("SU002"); - const puedeModificar = isSuperAdmin || tienePermiso("SU003"); - const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004"); + const puedeVer = isSuperAdmin || tienePermiso("SU001"); + const puedeCrear = isSuperAdmin || tienePermiso("SU002"); + const puedeModificar = isSuperAdmin || tienePermiso("SU003"); + const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004"); + const puedeVerSuscripciones = isSuperAdmin || tienePermiso("SU005"); + const puedeVerCuentaCorriente = isSuperAdmin || tienePermiso("SU011"); - const navigate = useNavigate(); - - const cargarSuscriptores = useCallback(async () => { - if (!puedeVer) { - setError("No tiene permiso para ver esta sección."); - setLoading(false); - return; - } - setLoading(true); - setError(null); - setApiErrorMessage(null); - try { - const data = await suscriptorService.getAllSuscriptores(filtroNombre, filtroNroDoc, filtroSoloActivos); - setSuscriptores(data); - } catch (err) { - console.error(err); - setError('Error al cargar los suscriptores.'); - } finally { - setLoading(false); - } - }, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]); - - useEffect(() => { - cargarSuscriptores(); - }, [cargarSuscriptores]); - - const handleOpenModal = (suscriptor?: SuscriptorDto) => { - setEditingSuscriptor(suscriptor || null); - setApiErrorMessage(null); - setModalOpen(true); - }; - - const handleCloseModal = () => { - setModalOpen(false); - setEditingSuscriptor(null); - }; - - const handleSubmitModal = async (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => { - setApiErrorMessage(null); - try { - if (id && editingSuscriptor) { - await suscriptorService.updateSuscriptor(id, data as UpdateSuscriptorDto); - } else { - await suscriptorService.createSuscriptor(data as CreateSuscriptorDto); - } - cargarSuscriptores(); - } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.message - ? err.response.data.message - : 'Error al guardar el suscriptor.'; - setApiErrorMessage(message); - throw err; // Re-lanzar para que el modal sepa que falló - } - }; - - const handleToggleActivo = async (suscriptor: SuscriptorDto) => { - const action = suscriptor.activo ? 'desactivar' : 'activar'; - if (window.confirm(`¿Está seguro de que desea ${action} a ${suscriptor.nombreCompleto}?`)) { - setApiErrorMessage(null); - try { - if (suscriptor.activo) { - await suscriptorService.deactivateSuscriptor(suscriptor.idSuscriptor); - } else { - await suscriptorService.activateSuscriptor(suscriptor.idSuscriptor); + const cargarSuscriptores = useCallback(async () => { + if (!puedeVer) { + setError("No tiene permiso para ver esta sección."); + setLoading(false); + return; } + setLoading(true); + setError(null); + setApiErrorMessage(null); + try { + const data = await suscriptorService.getAllSuscriptores(filtroNombre, filtroNroDoc, filtroSoloActivos); + setSuscriptores(data); + } catch (err) { + console.error(err); + setError('Error al cargar los suscriptores.'); + } finally { + setLoading(false); + } + }, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]); + + useEffect(() => { cargarSuscriptores(); - } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`; - setApiErrorMessage(message); - } + }, [cargarSuscriptores]); + + const handleOpenModal = (suscriptor?: SuscriptorDto) => { + setEditingSuscriptor(suscriptor || null); + setApiErrorMessage(null); + setModalOpen(true); + }; + + const handleCloseModal = () => { + setModalOpen(false); + setEditingSuscriptor(null); + }; + + const handleSubmitModal = async (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => { + setApiErrorMessage(null); + try { + if (id && editingSuscriptor) { + await suscriptorService.updateSuscriptor(id, data as UpdateSuscriptorDto); + } else { + await suscriptorService.createSuscriptor(data as CreateSuscriptorDto); + } + cargarSuscriptores(); + } catch (err: any) { + let message = 'Error al guardar el suscriptor.'; + if (axios.isAxiosError(err) && err.response?.data?.errors) { + const validationErrors = err.response.data.errors; + const errorMessages = Object.values(validationErrors).flat(); + message = errorMessages.join(' '); + } else if (axios.isAxiosError(err) && err.response?.data?.message) { + message = err.response.data.message; + } + setApiErrorMessage(message); + throw err; + } + }; + + const handleToggleActivo = async (suscriptor: SuscriptorDto) => { + const action = suscriptor.activo ? 'desactivar' : 'activar'; + if (window.confirm(`¿Está seguro de que desea ${action} a ${suscriptor.nombreCompleto}?`)) { + setApiErrorMessage(null); + try { + if (suscriptor.activo) { + await suscriptorService.deactivateSuscriptor(suscriptor.idSuscriptor); + } else { + await suscriptorService.activateSuscriptor(suscriptor.idSuscriptor); + } + cargarSuscriptores(); + } catch (err: any) { + const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`; + setApiErrorMessage(message); + } + } + handleMenuClose(); + }; + + const handleMenuOpen = (event: React.MouseEvent, suscriptor: SuscriptorDto) => { + setAnchorEl(event.currentTarget); + setSelectedRow(suscriptor); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + setSelectedRow(null); + }; + + // --- INICIO DE LA CORRECCIÓN CLAVE --- + const handleNavigateToSuscripciones = (idSuscriptor: number) => { + // La ruta debe ser la ruta completa y final que renderiza el componente + navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`); + handleMenuClose(); + }; + // --- FIN DE LA CORRECCIÓN CLAVE --- + + const handleNavigateToCuentaCorriente = (idSuscriptor: number) => { + navigate(`/suscripciones/suscriptor/${idSuscriptor}/cuenta-corriente`); + handleMenuClose(); + }; + + const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const displayData = suscriptores.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); + + if (!puedeVer) { + return {error || "No tiene permiso para ver esta sección."}; } - handleMenuClose(); - }; - const handleMenuOpen = (event: React.MouseEvent, suscriptor: SuscriptorDto) => { - setAnchorEl(event.currentTarget); - setSelectedRow(suscriptor); - }; + return ( + + Gestionar Suscriptores + + + setFiltroNombre(e.target.value)} sx={{ flexGrow: 1 }} /> + setFiltroNroDoc(e.target.value)} sx={{ flexGrow: 1 }} /> + setFiltroSoloActivos(e.target.checked)} />} label="Solo Activos" /> + + {puedeCrear && } + - const handleMenuClose = () => { - setAnchorEl(null); - setSelectedRow(null); - }; + {loading && } + {error && !loading && {error}} + {apiErrorMessage && {apiErrorMessage}} - const handleNavigateToSuscripciones = (idSuscriptor: number) => { - navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`); - handleMenuClose(); - }; + {!loading && !error && ( + <> + + + + NombreDocumentoDirección + Forma de PagoMailEstadoAcciones + + + {displayData.map((s) => ( + + {s.nombreCompleto} + {s.tipoDocumento} {s.nroDocumento} + {s.direccion} + {s.nombreFormaPagoPreferida} + {s.email} + + + handleMenuOpen(e, s)}> + + + ))} + +
+
+ + + )} - const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); - const handleChangeRowsPerPage = (event: React.ChangeEvent) => { - setRowsPerPage(parseInt(event.target.value, 10)); - setPage(0); - }; + + {selectedRow && puedeModificar && ( + { handleOpenModal(selectedRow); handleMenuClose(); }}> + + Editar Datos + + )} + {selectedRow && puedeVerSuscripciones && ( + handleNavigateToSuscripciones(selectedRow.idSuscriptor)}> + + Ver Suscripciones + + )} + {selectedRow && puedeVerCuentaCorriente && ( + handleNavigateToCuentaCorriente(selectedRow.idSuscriptor)}> + + Ver Cuenta Corriente + + )} + {selectedRow && puedeActivarDesactivar && ( + handleToggleActivo(selectedRow)}> + {selectedRow.activo ? : } + {selectedRow.activo ? 'Desactivar' : 'Activar'} + + )} + - const displayData = suscriptores.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); - - if (!puedeVer) { - return {error || "No tiene permiso para ver esta sección."}; - } - - return ( - - Gestionar Suscriptores - - - setFiltroNombre(e.target.value)} sx={{ flexGrow: 1 }} /> - setFiltroNroDoc(e.target.value)} sx={{ flexGrow: 1 }} /> - setFiltroSoloActivos(e.target.checked)} />} label="Solo Activos" /> + setApiErrorMessage(null)} /> - {puedeCrear && } - - - {loading && } - {error && !loading && {error}} - {apiErrorMessage && {apiErrorMessage}} - - {!loading && !error && ( - <> - - - - NombreDocumentoDirección - Forma de PagoEstadoAcciones - - - {displayData.map((s) => ( - - {s.nombreCompleto} - {s.tipoDocumento} {s.nroDocumento} - {s.direccion} - {s.nombreFormaPagoPreferida} - - - handleMenuOpen(e, s)}> - - - ))} - -
-
- - - )} - - - {selectedRow && puedeModificar && { handleOpenModal(selectedRow); handleMenuClose(); }}>Editar} - {selectedRow && handleNavigateToSuscripciones(selectedRow.idSuscriptor)}>Ver Suscripciones} - {selectedRow && puedeActivarDesactivar && ( - handleToggleActivo(selectedRow)}> - {selectedRow.activo ? : } - {selectedRow.activo ? 'Desactivar' : 'Activar'} - - )} - - - setApiErrorMessage(null)} /> -
- ); + ); }; export default GestionarSuscriptoresPage; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx b/Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx index a200b51..b42c963 100644 --- a/Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx +++ b/Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx @@ -3,80 +3,76 @@ import { Box, Tabs, Tab, Paper, Typography } from '@mui/material'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { usePermissions } from '../../hooks/usePermissions'; -// Define las pestañas del módulo. Ajusta los permisos según sea necesario. const suscripcionesSubModules = [ { label: 'Suscriptores', path: 'suscriptores', requiredPermission: 'SU001' }, - { label: 'Facturación', path: 'facturacion', requiredPermission: 'SU006' }, + { label: 'Consulta Pagos y Facturas', path: 'consulta-facturas', requiredPermission: 'SU006' }, + { label: 'Cierre y Procesos', path: 'procesos', requiredPermission: 'SU006' }, { label: 'Promociones', path: 'promociones', requiredPermission: 'SU010' }, ]; const SuscripcionesIndexPage: React.FC = () => { - const navigate = useNavigate(); - const location = useLocation(); - const { tienePermiso, isSuperAdmin } = usePermissions(); - const [selectedSubTab, setSelectedSubTab] = useState(false); - - // Filtra los sub-módulos a los que el usuario tiene acceso - const accessibleSubModules = suscripcionesSubModules.filter( - (subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission) - ); - - useEffect(() => { - if (accessibleSubModules.length === 0) { - // Si no tiene acceso a ningún submódulo, no hacemos nada. - // El enrutador principal debería manejar esto. - return; - } - - const currentBasePath = '/suscripciones'; - const subPath = location.pathname.startsWith(`${currentBasePath}/`) - ? location.pathname.substring(currentBasePath.length + 1) - : (location.pathname === currentBasePath ? accessibleSubModules[0]?.path : undefined); - - const activeTabIndex = accessibleSubModules.findIndex( - (subModule) => subModule.path === subPath - ); - - if (activeTabIndex !== -1) { - setSelectedSubTab(activeTabIndex); - } else if (location.pathname === currentBasePath) { - navigate(accessibleSubModules[0].path, { replace: true }); - } else { - setSelectedSubTab(false); - } - }, [location.pathname, navigate, accessibleSubModules]); + const navigate = useNavigate(); + const location = useLocation(); + const { tienePermiso, isSuperAdmin } = usePermissions(); + const [selectedSubTab, setSelectedSubTab] = useState(false); - const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => { - navigate(accessibleSubModules[newValue].path); - }; + const accessibleSubModules = suscripcionesSubModules.filter( + (subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission) + ); + + useEffect(() => { + if (accessibleSubModules.length === 0) return; + + const currentPath = location.pathname; + const basePath = '/suscripciones'; + + // Encuentra la pestaña que mejor coincide con la ruta actual + const activeTabIndex = accessibleSubModules.findIndex(subModule => + currentPath.startsWith(`${basePath}/${subModule.path}`) + ); - if (accessibleSubModules.length === 0) { - return No tiene permisos para acceder a este módulo.; - } - - return ( - - Módulo de Suscripciones - - - {accessibleSubModules.map((subModule) => ( - - ))} - - - - + if (activeTabIndex !== -1) { + setSelectedSubTab(activeTabIndex); + } else if (currentPath === basePath && accessibleSubModules.length > 0) { + // Si estamos en la raíz del módulo, redirigir a la primera pestaña accesible + navigate(accessibleSubModules[0].path, { replace: true }); + } else { + setSelectedSubTab(false); // Ninguna pestaña activa + } + }, [location.pathname, navigate, accessibleSubModules]); + + const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => { + navigate(accessibleSubModules[newValue].path); + }; + + if (accessibleSubModules.length === 0) { + return No tiene permisos para acceder a este módulo.; + } + + return ( + + Módulo de Suscripciones + + + {accessibleSubModules.map((subModule) => ( + + ))} + + + + {/* Aquí se renderizará el componente de la sub-ruta activa */} + + - - ); + ); }; export default SuscripcionesIndexPage; \ No newline at end of file diff --git a/Frontend/src/routes/AppRoutes.tsx b/Frontend/src/routes/AppRoutes.tsx index c696f70..c69ed9e 100644 --- a/Frontend/src/routes/AppRoutes.tsx +++ b/Frontend/src/routes/AppRoutes.tsx @@ -75,13 +75,16 @@ import ReporteControlDevolucionesPage from '../pages/Reportes/ReporteControlDevo import GestionarNovedadesCanillaPage from '../pages/Distribucion/GestionarNovedadesCanillaPage'; import ReporteNovedadesCanillasPage from '../pages/Reportes/ReporteNovedadesCanillasPage'; import ReporteListadoDistMensualPage from '../pages/Reportes/ReporteListadoDistMensualPage'; +import ReporteFacturasPublicidadPage from '../pages/Reportes/ReporteFacturasPublicidadPage'; // Suscripciones import SuscripcionesIndexPage from '../pages/Suscripciones/SuscripcionesIndexPage'; import GestionarSuscriptoresPage from '../pages/Suscripciones/GestionarSuscriptoresPage'; -import GestionarSuscripcionesSuscriptorPage from '../pages/Suscripciones/GestionarSuscripcionesSuscriptorPage'; -import FacturacionPage from '../pages/Suscripciones/FacturacionPage'; import GestionarPromocionesPage from '../pages/Suscripciones/GestionarPromocionesPage'; +import ConsultaFacturasPage from '../pages/Suscripciones/ConsultaFacturasPage'; +import FacturacionPage from '../pages/Suscripciones/FacturacionPage'; +import GestionarSuscripcionesDeClientePage from '../pages/Suscripciones/GestionarSuscripcionesDeClientePage'; +import CuentaCorrienteSuscriptorPage from '../pages/Suscripciones/CuentaCorrienteSuscriptorPage'; // Anonalías import AlertasPage from '../pages/Anomalia/AlertasPage'; @@ -185,36 +188,44 @@ const AppRoutes = () => { - {/* --- Módulo de Suscripciones --- */} + {/* Módulo de Suscripciones */} - + {/* Este Outlet es para las sub-rutas anidadas */} + } > - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> + {/* 1. Ruta para el layout con pestañas */} + } + > + } /> + } /> + } /> + } /> + } /> + + + {/* 2. Rutas de detalle que NO usan el layout de pestañas */} + + + + } + /> + + + + } + /> {/* Módulo Contable (anidado) */} @@ -273,6 +284,11 @@ const AppRoutes = () => { } /> } /> } /> + + + + }/> {/* Módulo de Radios (anidado) */} diff --git a/Frontend/src/services/Reportes/reportesService.ts b/Frontend/src/services/Reportes/reportesService.ts index 84318dd..126089e 100644 --- a/Frontend/src/services/Reportes/reportesService.ts +++ b/Frontend/src/services/Reportes/reportesService.ts @@ -445,6 +445,25 @@ const getListadoDistMensualPorPublicacionPdf = async (params: GetListadoDistMens return response.data; }; +const getReporteFacturasPublicidadPdf = async (anio: number, mes: number): Promise<{ fileContent: Blob, fileName: string }> => { + const params = new URLSearchParams({ anio: String(anio), mes: String(mes) }); + const url = `/reportes/suscripciones/facturas-para-publicidad/pdf?${params.toString()}`; + const response = await apiClient.get(url, { + responseType: 'blob', + }); + + const contentDisposition = response.headers['content-disposition']; + let fileName = `ReportePublicidad_Suscripciones_${anio}-${String(mes).padStart(2, '0')}.pdf`; // Fallback + if (contentDisposition) { + const fileNameMatch = contentDisposition.match(/filename="(.+)"/); + if (fileNameMatch && fileNameMatch.length > 1) { + fileName = fileNameMatch[1]; + } + } + + return { fileContent: response.data, fileName: fileName }; +}; + const reportesService = { getExistenciaPapel, getExistenciaPapelPdf, @@ -487,7 +506,8 @@ const reportesService = { getListadoDistMensualDiarios, getListadoDistMensualDiariosPdf, getListadoDistMensualPorPublicacion, - getListadoDistMensualPorPublicacionPdf, + getListadoDistMensualPorPublicacionPdf, + getReporteFacturasPublicidadPdf, }; export default reportesService; \ No newline at end of file diff --git a/Frontend/src/services/Suscripciones/ajusteService.ts b/Frontend/src/services/Suscripciones/ajusteService.ts index 06d8f00..86f0ad6 100644 --- a/Frontend/src/services/Suscripciones/ajusteService.ts +++ b/Frontend/src/services/Suscripciones/ajusteService.ts @@ -1,12 +1,26 @@ import apiClient from '../apiClient'; import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto'; import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto'; +import type { UpdateAjusteDto } from '../../models/dtos/Suscripciones/UpdateAjusteDto'; const API_URL_BY_SUSCRIPTOR = '/suscriptores'; const API_URL_BASE = '/ajustes'; -const getAjustesPorSuscriptor = async (idSuscriptor: number): Promise => { - const response = await apiClient.get(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes`); +const getAjustesPorSuscriptor = async (idSuscriptor: number, fechaDesde?: string, fechaHasta?: string): Promise => { + // URLSearchParams nos ayuda a construir la query string de forma segura y limpia + const params = new URLSearchParams(); + if (fechaDesde) { + params.append('fechaDesde', fechaDesde); + } + if (fechaHasta) { + params.append('fechaHasta', fechaHasta); + } + + // Si hay parámetros, los añadimos a la URL. Si no, la URL queda limpia. + const queryString = params.toString(); + const url = `${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes${queryString ? `?${queryString}` : ''}`; + + const response = await apiClient.get(url); return response.data; }; @@ -19,8 +33,13 @@ const anularAjuste = async (idAjuste: number): Promise => { await apiClient.post(`${API_URL_BASE}/${idAjuste}/anular`); }; +const updateAjuste = async (idAjuste: number, data: UpdateAjusteDto): Promise => { + await apiClient.put(`${API_URL_BASE}/${idAjuste}`, data); +}; + export default { getAjustesPorSuscriptor, createAjusteManual, - anularAjuste + anularAjuste, + updateAjuste, }; \ No newline at end of file diff --git a/Frontend/src/services/Suscripciones/facturacionService.ts b/Frontend/src/services/Suscripciones/facturacionService.ts index 578da0a..cb42973 100644 --- a/Frontend/src/services/Suscripciones/facturacionService.ts +++ b/Frontend/src/services/Suscripciones/facturacionService.ts @@ -1,29 +1,33 @@ import apiClient from '../apiClient'; -import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto'; import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto'; import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto'; import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto'; +import type { ResumenCuentaSuscriptorDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; const API_URL = '/facturacion'; const DEBITOS_URL = '/debitos'; const PAGOS_URL = '/pagos'; -const FACTURAS_URL = '/facturas'; const procesarArchivoRespuesta = async (archivo: File): Promise => { const formData = new FormData(); formData.append('archivo', archivo); - const response = await apiClient.post(`${DEBITOS_URL}/procesar-respuesta`, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, + headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data; }; -const getFacturasPorPeriodo = async (anio: number, mes: number): Promise => { - const response = await apiClient.get(`${API_URL}/${anio}/${mes}`); +const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise => { + const params = new URLSearchParams(); + if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor); + if (estadoPago) params.append('estadoPago', estadoPago); + if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion); + + const queryString = params.toString(); + const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`; + + const response = await apiClient.get(url); return response.data; }; @@ -36,7 +40,6 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo const response = await apiClient.post(`${DEBITOS_URL}/${anio}/${mes}/generar-archivo`, {}, { responseType: 'blob', }); - const contentDisposition = response.headers['content-disposition']; let fileName = `debito_${anio}_${mes}.txt`; if (contentDisposition) { @@ -45,30 +48,35 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo fileName = fileNameMatch[1]; } } - return { fileContent: response.data, fileName: fileName }; }; -const getPagosPorFactura = async (idFactura: number): Promise => { - const response = await apiClient.get(`${FACTURAS_URL}/${idFactura}/pagos`); - return response.data; -}; - const registrarPagoManual = async (data: CreatePagoDto): Promise => { const response = await apiClient.post(PAGOS_URL, data); return response.data; }; -const enviarFacturaPorEmail = async (idFactura: number): Promise => { - await apiClient.post(`${API_URL}/${idFactura}/enviar-email`); +const actualizarNumeroFactura = async (idFactura: number, numeroFactura: string): Promise => { + await apiClient.put(`${API_URL}/${idFactura}/numero-factura`, `"${numeroFactura}"`, { + headers: { 'Content-Type': 'application/json' } + }); +}; + +const enviarAvisoCuentaPorEmail = async (anio: number, mes: number, idSuscriptor: number): Promise => { + await apiClient.post(`${API_URL}/${anio}/${mes}/suscriptor/${idSuscriptor}/enviar-aviso`); +}; + +const enviarFacturaPdfPorEmail = async (idFactura: number): Promise => { + await apiClient.post(`${API_URL}/${idFactura}/enviar-factura-pdf`); }; export default { procesarArchivoRespuesta, - getFacturasPorPeriodo, + getResumenesDeCuentaPorPeriodo, generarFacturacionMensual, generarArchivoDebito, - getPagosPorFactura, registrarPagoManual, - enviarFacturaPorEmail, + actualizarNumeroFactura, + enviarAvisoCuentaPorEmail, + enviarFacturaPdfPorEmail, }; \ No newline at end of file diff --git a/Frontend/src/services/Suscripciones/suscripcionService.ts b/Frontend/src/services/Suscripciones/suscripcionService.ts index 71c6f77..588291b 100644 --- a/Frontend/src/services/Suscripciones/suscripcionService.ts +++ b/Frontend/src/services/Suscripciones/suscripcionService.ts @@ -3,12 +3,15 @@ import type { SuscripcionDto } from '../../models/dtos/Suscripciones/Suscripcion import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto'; import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto'; import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto'; +import type { PromocionAsignadaDto } from '../../models/dtos/Suscripciones/PromocionAsignadaDto'; +import type { AsignarPromocionDto } from '../../models/dtos/Suscripciones/AsignarPromocionDto'; const API_URL_BASE = '/suscripciones'; -const API_URL_BY_SUSCRIPTOR = '/suscriptores'; // Para la ruta anidada +const API_URL_SUSCRIPTORES = '/suscriptores'; const getSuscripcionesPorSuscriptor = async (idSuscriptor: number): Promise => { - const response = await apiClient.get(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/suscripciones`); + // La URL correcta es /suscriptores/{id}/suscripciones, no /suscripciones/suscriptor/... + const response = await apiClient.get(`${API_URL_SUSCRIPTORES}/${idSuscriptor}/suscripciones`); return response.data; }; @@ -26,8 +29,8 @@ const updateSuscripcion = async (id: number, data: UpdateSuscripcionDto): Promis await apiClient.put(`${API_URL_BASE}/${id}`, data); }; -const getPromocionesAsignadas = async (idSuscripcion: number): Promise => { - const response = await apiClient.get(`${API_URL_BASE}/${idSuscripcion}/promociones`); +const getPromocionesAsignadas = async (idSuscripcion: number): Promise => { + const response = await apiClient.get(`${API_URL_BASE}/${idSuscripcion}/promociones`); return response.data; }; @@ -36,8 +39,8 @@ const getPromocionesDisponibles = async (idSuscripcion: number): Promise => { - await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`); +const asignarPromocion = async (idSuscripcion: number, data: AsignarPromocionDto): Promise => { + await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones`, data); }; const quitarPromocion = async (idSuscripcion: number, idPromocion: number): Promise => { @@ -52,5 +55,5 @@ export default { getPromocionesAsignadas, getPromocionesDisponibles, asignarPromocion, - quitarPromocion + quitarPromocion, }; \ No newline at end of file