Compare commits
	
		
			8 Commits
		
	
	
		
			9f8d577265
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| c27dc2a0ba | |||
| 24b1c07342 | |||
| cb64bbc1f5 | |||
| 057310ca47 | |||
| e95c851e5b | |||
| 038faefd35 | |||
| da50c052f1 | |||
| 5781713b13 | 
| @@ -26,12 +26,6 @@ jobs: | |||||||
|             set -e |             set -e | ||||||
|             echo "--- INICIO DEL DESPLIEGUE OPTIMIZADO ---" |             echo "--- INICIO DEL DESPLIEGUE OPTIMIZADO ---" | ||||||
|              |              | ||||||
|             # --- Asegurar que el Stack de la Base de Datos esté corriendo --- |  | ||||||
|             echo "Asegurando que el stack de la base de datos esté activo..." |  | ||||||
|             cd /opt/shared-services/database |  | ||||||
|             # El comando 'up -d' es idempotente. Si ya está corriendo, no hace nada. |  | ||||||
|             docker compose up -d |  | ||||||
|              |  | ||||||
|             # 1. Preparar entorno |             # 1. Preparar entorno | ||||||
|             TEMP_DIR=$(mktemp -d) |             TEMP_DIR=$(mktemp -d) | ||||||
|             REPO_OWNER="dmolinari" |             REPO_OWNER="dmolinari" | ||||||
|   | |||||||
| @@ -1,12 +0,0 @@ | |||||||
| # ================================================ |  | ||||||
| # VARIABLES DE ENTORNO PARA LA CONFIGURACIÓN DE CORREO |  | ||||||
| # ================================================ |  | ||||||
| # El separador de doble guion bajo (__) se usa para mapear la jerarquía del JSON. |  | ||||||
| # MailSettings:SmtpHost se convierte en MailSettings__SmtpHost |  | ||||||
|  |  | ||||||
| MailSettings__SmtpHost="192.168.5.201" |  | ||||||
| MailSettings__SmtpPort=587 |  | ||||||
| MailSettings__SenderName="Club - Diario El Día" |  | ||||||
| MailSettings__SenderEmail="alertas@eldia.com" |  | ||||||
| MailSettings__SmtpUser="alertas@eldia.com" |  | ||||||
| MailSettings__SmtpPass="@Alertas713550@" |  | ||||||
| @@ -75,12 +75,13 @@ namespace GestionIntegral.Api.Controllers.Suscripciones | |||||||
|         int anio, int mes, |         int anio, int mes, | ||||||
|         [FromQuery] string? nombreSuscriptor, |         [FromQuery] string? nombreSuscriptor, | ||||||
|         [FromQuery] string? estadoPago, |         [FromQuery] string? estadoPago, | ||||||
|             [FromQuery] string? estadoFacturacion) |         [FromQuery] string? estadoFacturacion, | ||||||
|  |         [FromQuery] string? tipoFactura) | ||||||
|         { |         { | ||||||
|             if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); |             if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); | ||||||
|             if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." }); |             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); |             var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura); | ||||||
|             return Ok(resumenes); |             return Ok(resumenes); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -59,10 +59,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             const string sqlInsert = @" |             const string sqlInsert = @" | ||||||
|                 INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion) |                 INSERT INTO dbo.susc_Facturas  | ||||||
|  |                     (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,  | ||||||
|  |                      DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion, TipoFactura) | ||||||
|                 OUTPUT INSERTED.* |                 OUTPUT INSERTED.* | ||||||
|                 VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);"; |                 VALUES  | ||||||
|  |                     (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,  | ||||||
|  |                      @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion, @TipoFactura);"; | ||||||
|  |  | ||||||
|             return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); |             return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); | ||||||
|         } |         } | ||||||
| @@ -104,7 +109,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|             return rowsAffected == idsFacturas.Count(); |             return rowsAffected == idsFacturas.Count(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) |         public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync( | ||||||
|  |         string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura) | ||||||
|         { |         { | ||||||
|             var sqlBuilder = new StringBuilder(@" |             var sqlBuilder = new StringBuilder(@" | ||||||
|                 WITH FacturaConEmpresa AS ( |                 WITH FacturaConEmpresa AS ( | ||||||
| @@ -149,6 +155,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|                 parameters.Add("EstadoFacturacion", estadoFacturacion); |                 parameters.Add("EstadoFacturacion", estadoFacturacion); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(tipoFactura)) | ||||||
|  |             { | ||||||
|  |                 sqlBuilder.Append(" AND f.TipoFactura = @TipoFactura"); | ||||||
|  |                 parameters.Add("TipoFactura", tipoFactura); | ||||||
|  |             } | ||||||
|  |  | ||||||
|             sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;"); |             sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;"); | ||||||
|  |  | ||||||
|             try |             try | ||||||
|   | |||||||
| @@ -15,7 +15,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|         Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); |         Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); |         Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); |         Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); | ||||||
|         Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); |         Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync( | ||||||
|  |         string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura); | ||||||
|         Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);    |         Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);    | ||||||
|         Task<string?> GetUltimoPeriodoFacturadoAsync(); |         Task<string?> GetUltimoPeriodoFacturadoAsync(); | ||||||
|         Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo); |         Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo); | ||||||
|   | |||||||
| @@ -9,7 +9,6 @@ | |||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> |     <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> | ||||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> |     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> |  | ||||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> |     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> |     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" /> |     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" /> | ||||||
|   | |||||||
| @@ -9,6 +9,8 @@ namespace GestionIntegral.Api.Dtos.Suscripciones | |||||||
|         public string EstadoFacturacion { get; set; } = string.Empty; |         public string EstadoFacturacion { get; set; } = string.Empty; | ||||||
|         public string? NumeroFactura { get; set; } |         public string? NumeroFactura { get; set; } | ||||||
|         public decimal TotalPagado { get; set; } |         public decimal TotalPagado { get; set; } | ||||||
|  |         public string TipoFactura { get; set; } = string.Empty; | ||||||
|  |         public int IdSuscriptor { get; set; } | ||||||
|         public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); |         public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -15,5 +15,6 @@ namespace GestionIntegral.Api.Models.Suscripciones | |||||||
|         public string? NumeroFactura { get; set; } |         public string? NumeroFactura { get; set; } | ||||||
|         public int? IdLoteDebito { get; set; } |         public int? IdLoteDebito { get; set; } | ||||||
|         public string? MotivoRechazo { get; set; } |         public string? MotivoRechazo { get; set; } | ||||||
|  |         public string TipoFactura { get; set; } = string.Empty; | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -24,10 +24,6 @@ using GestionIntegral.Api.Models.Comunicaciones; | |||||||
| using GestionIntegral.Api.Services.Comunicaciones; | using GestionIntegral.Api.Services.Comunicaciones; | ||||||
| using GestionIntegral.Api.Data.Repositories.Comunicaciones; | using GestionIntegral.Api.Data.Repositories.Comunicaciones; | ||||||
|  |  | ||||||
| // Carga las variables de entorno desde el archivo .env al inicio de la aplicación. |  | ||||||
| // Debe ser la primera línea para que la configuración esté disponible para el 'builder'. |  | ||||||
| DotNetEnv.Env.Load(); |  | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
| // --- Registros de Servicios --- | // --- Registros de Servicios --- | ||||||
|   | |||||||
| @@ -4,6 +4,8 @@ using MailKit.Net.Smtp; | |||||||
| using MailKit.Security; | using MailKit.Security; | ||||||
| using Microsoft.Extensions.Options; | using Microsoft.Extensions.Options; | ||||||
| using MimeKit; | using MimeKit; | ||||||
|  | using System.Net.Security; | ||||||
|  | using System.Security.Cryptography.X509Certificates; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Comunicaciones | namespace GestionIntegral.Api.Services.Comunicaciones | ||||||
| { | { | ||||||
| @@ -88,6 +90,30 @@ namespace GestionIntegral.Api.Services.Comunicaciones | |||||||
|             using var smtp = new SmtpClient(); |             using var smtp = new SmtpClient(); | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|  |                 // Se añade una política de validación de certificado personalizada. | ||||||
|  |                 // Esto es necesario para entornos de desarrollo o redes internas donde | ||||||
|  |                 // el nombre del host al que nos conectamos (ej. una IP) no coincide | ||||||
|  |                 // con el nombre en el certificado SSL (ej. mail.eldia.com). | ||||||
|  |                 smtp.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => | ||||||
|  |                 { | ||||||
|  |                     // Si no hay errores, el certificado es válido. | ||||||
|  |                     if (sslPolicyErrors == SslPolicyErrors.None) | ||||||
|  |                         return true; | ||||||
|  |  | ||||||
|  |                     // Si el único error es que el nombre no coincide (RemoteCertificateNameMismatch) | ||||||
|  |                     // Y el certificado es el que esperamos (emitido para "mail.eldia.com"), | ||||||
|  |                     // entonces lo aceptamos como válido. | ||||||
|  |                     if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch) && certificate != null && certificate.Subject.Contains("CN=mail.eldia.com")) | ||||||
|  |                     { | ||||||
|  |                         _logger.LogWarning("Se aceptó un certificado SSL con 'Name Mismatch' para el host de confianza 'mail.eldia.com'."); | ||||||
|  |                         return true; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     // Para cualquier otro error, rechazamos el certificado. | ||||||
|  |                     _logger.LogError("Error de validación de certificado SSL: {Errors}", sslPolicyErrors); | ||||||
|  |                     return false; | ||||||
|  |                 }; | ||||||
|  |  | ||||||
|                 await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); |                 await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); | ||||||
|                 await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); |                 await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); | ||||||
|                 await smtp.SendAsync(emailMessage); |                 await smtp.SendAsync(emailMessage); | ||||||
| @@ -95,20 +121,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones | |||||||
|                 log.Estado = "Enviado"; |                 log.Estado = "Enviado"; | ||||||
|                 _logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); |                 _logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); | ||||||
|             } |             } | ||||||
|             catch (SmtpCommandException scEx) |  | ||||||
|             { |  | ||||||
|                 _logger.LogError(scEx, "Error de comando SMTP al enviar a {Destinatario}. StatusCode: {StatusCode}", destinatario, scEx.StatusCode); |  | ||||||
|                 log.Estado = "Fallido"; |  | ||||||
|                 log.Error = $"Error del servidor: ({scEx.StatusCode}) {scEx.Message}"; |  | ||||||
|                 throw; |  | ||||||
|             } |  | ||||||
|             catch (AuthenticationException authEx) |  | ||||||
|             { |  | ||||||
|                 _logger.LogError(authEx, "Error de autenticación con el servidor SMTP."); |  | ||||||
|                 log.Estado = "Fallido"; |  | ||||||
|                 log.Error = "Error de autenticación. Revise las credenciales de correo."; |  | ||||||
|                 throw; |  | ||||||
|             } |  | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 _logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); |                 _logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); | ||||||
|   | |||||||
| @@ -17,8 +17,8 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|         private readonly DbConnectionFactory _connectionFactory; |         private readonly DbConnectionFactory _connectionFactory; | ||||||
|         private readonly ILogger<DebitoAutomaticoService> _logger; |         private readonly ILogger<DebitoAutomaticoService> _logger; | ||||||
|  |  | ||||||
|         private const string NRO_PRESTACION = "123456"; |         private const string NRO_PRESTACION = "26435"; // Reemplazar por el número real | ||||||
|         private const string ORIGEN_EMPRESA = "ELDIA"; |         private const string ORIGEN_EMPRESA = "EMPRESA"; | ||||||
|  |  | ||||||
|         public DebitoAutomaticoService( |         public DebitoAutomaticoService( | ||||||
|             IFacturaRepository facturaRepository, |             IFacturaRepository facturaRepository, | ||||||
| @@ -40,9 +40,7 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|         public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) |         public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) | ||||||
|         { |         { | ||||||
|             // Se define la identificación del archivo. |             // Este número debe ser gestionado para no repetirse. Por ahora, lo mantenemos como 1. | ||||||
|             // Este número debe ser gestionado para no repetirse en archivos generados |  | ||||||
|             // para la misma prestación y fecha. |  | ||||||
|             const int identificacionArchivo = 1; |             const int identificacionArchivo = 1; | ||||||
|  |  | ||||||
|             var periodo = $"{anio}-{mes:D2}"; |             var periodo = $"{anio}-{mes:D2}"; | ||||||
| @@ -62,8 +60,6 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); |                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); | ||||||
|                 var cantidadRegistros = facturasParaDebito.Count(); |                 var cantidadRegistros = facturasParaDebito.Count(); | ||||||
|                  |  | ||||||
|                 // Se utiliza la variable 'identificacionArchivo' para nombrar el archivo. |  | ||||||
|                 var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt"; |                 var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt"; | ||||||
|  |  | ||||||
|                 var nuevoLote = new LoteDebito |                 var nuevoLote = new LoteDebito | ||||||
| @@ -78,13 +74,11 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); |                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); | ||||||
|  |  | ||||||
|                 var sb = new StringBuilder(); |                 var sb = new StringBuilder(); | ||||||
|                 // Se pasa la 'identificacionArchivo' al método que crea el Header. |  | ||||||
|                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); |                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||||
|                 foreach (var item in facturasParaDebito) |                 foreach (var item in facturasParaDebito) | ||||||
|                 { |                 { | ||||||
|                     sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); |                     sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); | ||||||
|                 } |                 } | ||||||
|                 // Se pasa la 'identificacionArchivo' al método que crea el Trailer. |  | ||||||
|                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); |                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||||
|  |  | ||||||
|                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); |                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); | ||||||
| @@ -108,17 +102,18 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); |             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); | ||||||
|             var resultado = new List<(Factura, Suscriptor)>(); |             var resultado = new List<(Factura, Suscriptor)>(); | ||||||
|  |  | ||||||
|             foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente")) |             // Filtramos por estado Y POR TIPO DE FACTURA | ||||||
|  |             foreach (var f in facturas.Where(fa => | ||||||
|  |                 (fa.EstadoPago == "Pendiente" || fa.EstadoPago == "Pagada Parcialmente" || fa.EstadoPago == "Rechazada") && | ||||||
|  |                 fa.TipoFactura == "Mensual" | ||||||
|  |             )) | ||||||
|             { |             { | ||||||
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor); |                 var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor); | ||||||
|  |  | ||||||
|                 // Se valida que el CBU de Banelco (22 caracteres) exista antes de intentar la conversión. |  | ||||||
|                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22) |                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22) | ||||||
|                 { |                 { | ||||||
|                     _logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", suscriptor?.IdSuscriptor); |                     _logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", f.IdSuscriptor); | ||||||
|                     continue; |                     continue; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); |                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); | ||||||
|                 if (formaPago != null && formaPago.RequiereCBU) |                 if (formaPago != null && formaPago.RequiereCBU) | ||||||
|                 { |                 { | ||||||
| @@ -128,26 +123,13 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             return resultado; |             return resultado; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Lógica de conversión de CBU. |  | ||||||
|         private string ConvertirCbuBanelcoASnp(string cbu22) |         private string ConvertirCbuBanelcoASnp(string cbu22) | ||||||
|         { |         { | ||||||
|             if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) |             if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) return "".PadRight(26); | ||||||
|             { |  | ||||||
|                 _logger.LogError("Se intentó convertir un CBU inválido de {Length} caracteres. Se devolverá un campo vacío.", cbu22?.Length ?? 0); |  | ||||||
|                 // Devolver un string de 26 espacios/ceros según la preferencia del banco para campos erróneos. |  | ||||||
|                 return "".PadRight(26);  |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // El formato SNP de 26 se obtiene insertando un "0" al inicio y "000" después del 8vo caracter del CBU de 22. |  | ||||||
|             // Formato Banelco (22): [BBBSSSSX] [T....Y] |  | ||||||
|             // Posiciones:            (0-7)      (8-21) |  | ||||||
|             // Formato SNP (26):      0[BBBSSSSX]000[T....Y] |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 string bloque1 = cbu22.Substring(0, 8); // Contiene código de banco, sucursal y DV del bloque 1. |                 string bloque1 = cbu22.Substring(0, 8); | ||||||
|                 string bloque2 = cbu22.Substring(8);    // Contiene el resto de la cadena. |                 string bloque2 = cbu22.Substring(8); | ||||||
|  |  | ||||||
|                 // Reconstruir en formato SNP de 26 dígitos según el instructivo. |  | ||||||
|                 return $"0{bloque1}000{bloque2}"; |                 return $"0{bloque1}000{bloque2}"; | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
| @@ -157,9 +139,10 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // --- Métodos de Formateo y Mapeo --- |         // --- Helpers de Formateo --- | ||||||
|         private string FormatString(string? value, int length) => (value ?? "").PadRight(length); |         private string FormatString(string? value, int length) => (value ?? "").PadRight(length); | ||||||
|         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); |         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); | ||||||
|  |         private string FormatNumericString(string? value, int length) => (value ?? "").PadLeft(length, '0'); | ||||||
|         private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch |         private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch | ||||||
|         { |         { | ||||||
|             "DNI" => "0096", |             "DNI" => "0096", | ||||||
| @@ -167,17 +150,17 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             "CUIL" => "0086", |             "CUIL" => "0086", | ||||||
|             "LE" => "0089", |             "LE" => "0089", | ||||||
|             "LC" => "0090", |             "LC" => "0090", | ||||||
|             _ => "0000" // Tipo no especificado o C.I. Policía Federal según anexo. |             _ => "0000" | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) |         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||||
|         { |         { | ||||||
|             var sb = new StringBuilder(); |             var sb = new StringBuilder(); | ||||||
|             sb.Append("00"); // Tipo de Registro Header |             sb.Append("00"); | ||||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); |             sb.Append(FormatNumericString(NRO_PRESTACION, 6)); | ||||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos |             sb.Append("C"); | ||||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); |             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||||
|             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo |             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); | ||||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); |             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); |             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); |             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||||
| @@ -188,35 +171,33 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) |         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) | ||||||
|         { |         { | ||||||
|             // Convertimos el CBU de 22 (Banelco) a 26 (SNP) antes de usarlo. |  | ||||||
|             string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!); |             string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!); | ||||||
|  |  | ||||||
|             var sb = new StringBuilder(); |             var sb = new StringBuilder(); | ||||||
|             sb.Append("0370"); // Tipo de Registro Detalle (Orden de Débito) |             sb.Append("0370"); | ||||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación de Cliente |             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); | ||||||
|             sb.Append(FormatString(cbu26, 26)); // CBU en formato SNP de 26 caracteres. |             sb.Append(cbu26); | ||||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); // Referencia Unívoca de la factura. |             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); | ||||||
|             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); |             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); | ||||||
|             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); |             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento |             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento |             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento |             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento |             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento | ||||||
|             sb.Append("0"); // Moneda (0 = Pesos) |             sb.Append("0"); | ||||||
|             sb.Append(FormatString("", 3)); // Motivo Rechazo (vacío en el envío) |             sb.Append(FormatString("", 3)); | ||||||
|             sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4)); |             sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4)); | ||||||
|             sb.Append(FormatString(suscriptor.NroDocumento, 11)); |             sb.Append(FormatNumericString(suscriptor.NroDocumento, 11)); | ||||||
|             sb.Append(FormatString("", 22)); // Nueva ID Cliente |             sb.Append(FormatString("", 22)); | ||||||
|             sb.Append(FormatString("", 26)); // Nueva CBU |             sb.Append(FormatString("", 26)); | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo |             sb.Append(FormatNumeric(0, 14)); | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vencimiento |             sb.Append(FormatNumeric(0, 8)); | ||||||
|             sb.Append(FormatString("", 22)); // Identificación Cuenta Anterior |             sb.Append(FormatString("", 22)); | ||||||
|             sb.Append(FormatString("", 40)); // Mensaje ATM |             sb.Append(FormatString("", 40)); | ||||||
|             sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); // Concepto Factura |             sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro |             sb.Append(FormatNumeric(0, 8)); | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Cobrado |             sb.Append(FormatNumeric(0, 14)); | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Acreditamiento |             sb.Append(FormatNumeric(0, 8)); | ||||||
|             sb.Append(FormatString("", 26)); // Libre |             sb.Append(FormatString("", 26)); | ||||||
|             sb.Append("\r\n"); |             sb.Append("\r\n"); | ||||||
|             return sb.ToString(); |             return sb.ToString(); | ||||||
|         } |         } | ||||||
| @@ -224,16 +205,16 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) |         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||||
|         { |         { | ||||||
|             var sb = new StringBuilder(); |             var sb = new StringBuilder(); | ||||||
|             sb.Append("99"); // Tipo de Registro Trailer |             sb.Append("99"); | ||||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); |             sb.Append(FormatNumericString(NRO_PRESTACION, 6)); | ||||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos |             sb.Append("C"); | ||||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); |             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||||
|             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo |             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); | ||||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); |             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); |             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); |             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||||
|             sb.Append(FormatString("", 304)); |             sb.Append(FormatString("", 304)); | ||||||
|             // La última línea del archivo no lleva salto de línea (\r\n). |             sb.Append("\r\n"); | ||||||
|             return sb.ToString(); |             return sb.ToString(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -171,10 +171,13 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                         DescuentoAplicado = descuentoPromocionesTotal, |                         DescuentoAplicado = descuentoPromocionesTotal, | ||||||
|                         ImporteFinal = importeFinal, |                         ImporteFinal = importeFinal, | ||||||
|                         EstadoPago = "Pendiente", |                         EstadoPago = "Pendiente", | ||||||
|                         EstadoFacturacion = "Pendiente de Facturar" |                         EstadoFacturacion = "Pendiente de Facturar", | ||||||
|  |                         TipoFactura = "Mensual" | ||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
|                     var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); |                     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}"); |                     if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}"); | ||||||
|  |  | ||||||
|                     facturasCreadas.Add(facturaCreada); |                     facturasCreadas.Add(facturaCreada); | ||||||
|                     foreach (var detalle in detallesParaFactura) |                     foreach (var detalle in detallesParaFactura) | ||||||
|                     { |                     { | ||||||
| @@ -278,10 +281,11 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) |         public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo( | ||||||
|  |         int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura) | ||||||
|         { |         { | ||||||
|             var periodo = $"{anio}-{mes:D2}"; |             var periodo = $"{anio}-{mes:D2}"; | ||||||
|             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion); |             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura); | ||||||
|             var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); |             var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); | ||||||
|             var empresas = await _empresaRepository.GetAllAsync(null, null); |             var empresas = await _empresaRepository.GetAllAsync(null, null); | ||||||
|  |  | ||||||
| @@ -302,10 +306,17 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                             EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, |                             EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, | ||||||
|                             NumeroFactura = itemFactura.Factura.NumeroFactura, |                             NumeroFactura = itemFactura.Factura.NumeroFactura, | ||||||
|                             TotalPagado = itemFactura.TotalPagado, |                             TotalPagado = itemFactura.TotalPagado, | ||||||
|  |  | ||||||
|  |                             // Faltaba esta línea para pasar el tipo de factura al frontend. | ||||||
|  |                             TipoFactura = itemFactura.Factura.TipoFactura, | ||||||
|  |  | ||||||
|                             Detalles = detallesData |                             Detalles = detallesData | ||||||
|                                 .Where(d => d.IdFactura == itemFactura.Factura.IdFactura) |                                 .Where(d => d.IdFactura == itemFactura.Factura.IdFactura) | ||||||
|                                 .Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto }) |                                 .Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto }) | ||||||
|                                 .ToList() |                                 .ToList(), | ||||||
|  |  | ||||||
|  |                             // Pasamos el id del suscriptor para facilitar las cosas en el frontend | ||||||
|  |                             IdSuscriptor = itemFactura.Factura.IdSuscriptor | ||||||
|                         }; |                         }; | ||||||
|                     }).ToList(); |                     }).ToList(); | ||||||
|  |  | ||||||
| @@ -579,7 +590,7 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         private async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction) |         public async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction) | ||||||
|         { |         { | ||||||
|             decimal importeTotal = 0; |             decimal importeTotal = 0; | ||||||
|             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); |             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); | ||||||
|   | |||||||
| @@ -1,5 +1,7 @@ | |||||||
|  | using System.Data; | ||||||
| using GestionIntegral.Api.Dtos.Comunicaciones; | using GestionIntegral.Api.Dtos.Comunicaciones; | ||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Suscripciones; | ||||||
|  | using GestionIntegral.Api.Models.Suscripciones; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Suscripciones | namespace GestionIntegral.Api.Services.Suscripciones | ||||||
| { | { | ||||||
| @@ -7,8 +9,10 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|     { |     { | ||||||
|         Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); |         Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); | ||||||
|         Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes); |         Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes); | ||||||
|         Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); |         Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo( | ||||||
|  |         int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura); | ||||||
|         Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario); |         Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario); | ||||||
|         Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); |         Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); | ||||||
|  |         Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -3,12 +3,8 @@ using GestionIntegral.Api.Data.Repositories.Distribucion; | |||||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Suscripciones; | ||||||
| using GestionIntegral.Api.Models.Suscripciones; | using GestionIntegral.Api.Models.Suscripciones; | ||||||
| using Microsoft.Extensions.Logging; |  | ||||||
| using System; |  | ||||||
| using System.Collections.Generic; |  | ||||||
| using System.Data; | using System.Data; | ||||||
| using System.Linq; | using System.Globalization; | ||||||
| using System.Threading.Tasks; |  | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Suscripciones | namespace GestionIntegral.Api.Services.Suscripciones | ||||||
| { | { | ||||||
| @@ -18,23 +14,32 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|         private readonly ISuscriptorRepository _suscriptorRepository; |         private readonly ISuscriptorRepository _suscriptorRepository; | ||||||
|         private readonly IPublicacionRepository _publicacionRepository; |         private readonly IPublicacionRepository _publicacionRepository; | ||||||
|         private readonly IPromocionRepository _promocionRepository; |         private readonly IPromocionRepository _promocionRepository; | ||||||
|         private readonly DbConnectionFactory _connectionFactory; |         private readonly IFacturaRepository _facturaRepository; | ||||||
|  |         private readonly IFacturaDetalleRepository _facturaDetalleRepository; | ||||||
|  |         private readonly IFacturacionService _facturacionService; | ||||||
|         private readonly ILogger<SuscripcionService> _logger; |         private readonly ILogger<SuscripcionService> _logger; | ||||||
|  |         private readonly DbConnectionFactory _connectionFactory; | ||||||
|  |  | ||||||
|         public SuscripcionService( |         public SuscripcionService( | ||||||
|             ISuscripcionRepository suscripcionRepository, |             ISuscripcionRepository suscripcionRepository, | ||||||
|         ISuscriptorRepository suscriptorRepository, |         ISuscriptorRepository suscriptorRepository, | ||||||
|         IPublicacionRepository publicacionRepository, |         IPublicacionRepository publicacionRepository, | ||||||
|         IPromocionRepository promocionRepository, |         IPromocionRepository promocionRepository, | ||||||
|             DbConnectionFactory connectionFactory, |         IFacturaRepository facturaRepository, | ||||||
|             ILogger<SuscripcionService> logger) |         IFacturaDetalleRepository facturaDetalleRepository, | ||||||
|  |         IFacturacionService facturacionService, | ||||||
|  |         ILogger<SuscripcionService> logger, | ||||||
|  |         DbConnectionFactory connectionFactory) | ||||||
|         { |         { | ||||||
|             _suscripcionRepository = suscripcionRepository; |             _suscripcionRepository = suscripcionRepository; | ||||||
|             _suscriptorRepository = suscriptorRepository; |             _suscriptorRepository = suscriptorRepository; | ||||||
|             _publicacionRepository = publicacionRepository; |             _publicacionRepository = publicacionRepository; | ||||||
|             _promocionRepository = promocionRepository; |             _promocionRepository = promocionRepository; | ||||||
|             _connectionFactory = connectionFactory; |             _facturaRepository = facturaRepository; | ||||||
|  |             _facturaDetalleRepository = facturaDetalleRepository; | ||||||
|  |             _facturacionService = facturacionService; | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|  |             _connectionFactory = connectionFactory; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto |         private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto | ||||||
| @@ -122,6 +127,53 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                 var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction); |                 var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction); | ||||||
|                 if (creada == null) throw new DataException("Error al crear la suscripción."); |                 if (creada == null) throw new DataException("Error al crear la suscripción."); | ||||||
|  |  | ||||||
|  |                 var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync(); | ||||||
|  |                 if (ultimoPeriodoFacturadoStr != null) | ||||||
|  |                 { | ||||||
|  |                     var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture); | ||||||
|  |                     var periodoSuscripcion = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1); | ||||||
|  |  | ||||||
|  |                     if (periodoSuscripcion <= ultimoPeriodo) | ||||||
|  |                     { | ||||||
|  |                         _logger.LogInformation("Suscripción en período ya cerrado detectada. Generando factura de alta pro-rata."); | ||||||
|  |  | ||||||
|  |                         decimal importeProporcional = await _facturacionService.CalcularImporteParaSuscripcion(creada, creada.FechaInicio.Year, creada.FechaInicio.Month, transaction); | ||||||
|  |  | ||||||
|  |                         if (importeProporcional > 0) | ||||||
|  |                         { | ||||||
|  |                             var facturaDeAlta = new Factura | ||||||
|  |                             { | ||||||
|  |                                 IdSuscriptor = creada.IdSuscriptor, | ||||||
|  |                                 Periodo = creada.FechaInicio.ToString("yyyy-MM"), | ||||||
|  |                                 FechaEmision = DateTime.Now.Date, | ||||||
|  |                                 FechaVencimiento = DateTime.Now.AddDays(10).Date, | ||||||
|  |                                 ImporteBruto = importeProporcional, | ||||||
|  |                                 ImporteFinal = importeProporcional, | ||||||
|  |                                 EstadoPago = "Pendiente", | ||||||
|  |                                 EstadoFacturacion = "Pendiente de Facturar", | ||||||
|  |                                 TipoFactura = "Alta" | ||||||
|  |                             }; | ||||||
|  |  | ||||||
|  |                             var facturaCreada = await _facturaRepository.CreateAsync(facturaDeAlta, transaction); | ||||||
|  |                             if (facturaCreada == null) throw new DataException("No se pudo crear la factura de alta."); | ||||||
|  |  | ||||||
|  |                             var publicacion = await _publicacionRepository.GetByIdSimpleAsync(creada.IdPublicacion); | ||||||
|  |                             var finDeMes = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1).AddMonths(1).AddDays(-1); | ||||||
|  |  | ||||||
|  |                             await _facturaDetalleRepository.CreateAsync(new FacturaDetalle | ||||||
|  |                             { | ||||||
|  |                                 IdFactura = facturaCreada.IdFactura, | ||||||
|  |                                 IdSuscripcion = creada.IdSuscripcion, | ||||||
|  |                                 Descripcion = $"Suscripción proporcional {publicacion?.Nombre} ({creada.FechaInicio:dd/MM} al {finDeMes:dd/MM})", | ||||||
|  |                                 ImporteBruto = importeProporcional, | ||||||
|  |                                 ImporteNeto = importeProporcional, | ||||||
|  |                                 DescuentoAplicado = 0 | ||||||
|  |                             }, transaction); | ||||||
|  |  | ||||||
|  |                             _logger.LogInformation("Factura de alta #{IdFactura} por ${Importe} generada para la nueva suscripción #{IdSuscripcion}.", facturaCreada.IdFactura, importeProporcional, creada.IdSuscripcion); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|                 transaction.Commit(); |                 transaction.Commit(); | ||||||
|                 _logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario); |                 _logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario); | ||||||
|                 return (await MapToDto(creada), null); |                 return (await MapToDto(creada), null); | ||||||
|   | |||||||
| @@ -16,11 +16,11 @@ | |||||||
|   }, |   }, | ||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "MailSettings": { |   "MailSettings": { | ||||||
|     "SmtpHost": "", |     "SmtpHost": "192.168.5.201", | ||||||
|     "SmtpPort": 0, |     "SmtpPort": 587, | ||||||
|     "SenderName": "", |     "SenderName": "Club - Diario El Día", | ||||||
|     "SenderEmail": "", |     "SenderEmail": "alertas@eldia.com", | ||||||
|     "SmtpUser": "", |     "SmtpUser": "alertas@eldia.com", | ||||||
|     "SmtpPass": "" |     "SmtpPass": "@Alertas713550@" | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material'; | import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material'; | ||||||
| import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto'; | import type { FacturaConsolidadaDto } from '../../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; | ||||||
| import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto'; | import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto'; | ||||||
| import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto'; | import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto'; | ||||||
| import formaPagoService from '../../../services/Suscripciones/formaPagoService'; | import formaPagoService from '../../../services/Suscripciones/formaPagoService'; | ||||||
| @@ -23,17 +23,19 @@ interface PagoManualModalProps { | |||||||
|   open: boolean; |   open: boolean; | ||||||
|   onClose: () => void; |   onClose: () => void; | ||||||
|   onSubmit: (data: CreatePagoDto) => Promise<void>; |   onSubmit: (data: CreatePagoDto) => Promise<void>; | ||||||
|   factura: FacturaDto | null; |   factura: FacturaConsolidadaDto | null; | ||||||
|  |   nombreSuscriptor: string; // Se pasa el nombre del suscriptor como prop- | ||||||
|   errorMessage?: string | null; |   errorMessage?: string | null; | ||||||
|   clearErrorMessage: () => void; |   clearErrorMessage: () => void; | ||||||
| } | } | ||||||
|  |  | ||||||
| const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, errorMessage, clearErrorMessage }) => { | const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, nombreSuscriptor, errorMessage, clearErrorMessage }) => { | ||||||
|   const [formData, setFormData] = useState<Partial<CreatePagoDto>>({}); |   const [formData, setFormData] = useState<Partial<CreatePagoDto>>({}); | ||||||
|   const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]); |   const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]); | ||||||
|   const [loading, setLoading] = useState(false); |   const [loading, setLoading] = useState(false); | ||||||
|   const [loadingFormasPago, setLoadingFormasPago] = useState(false); |   const [loadingFormasPago, setLoadingFormasPago] = useState(false); | ||||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|  |   const saldoPendiente = factura ? factura.importeFinal - factura.totalPagado : 0; | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const fetchFormasDePago = async () => { |     const fetchFormasDePago = async () => { | ||||||
| @@ -52,12 +54,12 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | |||||||
|       fetchFormasDePago(); |       fetchFormasDePago(); | ||||||
|       setFormData({ |       setFormData({ | ||||||
|         idFactura: factura.idFactura, |         idFactura: factura.idFactura, | ||||||
|         monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto |         monto: saldoPendiente, | ||||||
|         fechaPago: new Date().toISOString().split('T')[0] |         fechaPago: new Date().toISOString().split('T')[0] | ||||||
|       }); |       }); | ||||||
|       setLocalErrors({}); |       setLocalErrors({}); | ||||||
|     } |     } | ||||||
|   }, [open, factura]); |   }, [open, factura, saldoPendiente]); | ||||||
|  |  | ||||||
|   const validate = (): boolean => { |   const validate = (): boolean => { | ||||||
|     const errors: { [key: string]: string | null } = {}; |     const errors: { [key: string]: string | null } = {}; | ||||||
| @@ -65,13 +67,11 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | |||||||
|     if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria."; |     if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria."; | ||||||
|  |  | ||||||
|     const monto = formData.monto ?? 0; |     const monto = formData.monto ?? 0; | ||||||
|     const saldo = factura?.saldoPendiente ?? 0; |  | ||||||
|  |  | ||||||
|     if (monto <= 0) { |     if (monto <= 0) { | ||||||
|       errors.monto = "El monto debe ser mayor a cero."; |       errors.monto = "El monto debe ser mayor a cero."; | ||||||
|     } else if (monto > saldo) { |     } else if (monto > saldoPendiente) { | ||||||
|         // Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje |       errors.monto = `El monto no puede superar el saldo pendiente de $${saldoPendiente.toFixed(2)}.`; | ||||||
|         errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     setLocalErrors(errors); |     setLocalErrors(errors); | ||||||
| @@ -117,8 +117,11 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | |||||||
|     <Modal open={open} onClose={onClose}> |     <Modal open={open} onClose={onClose}> | ||||||
|       <Box sx={modalStyle}> |       <Box sx={modalStyle}> | ||||||
|         <Typography variant="h6">Registrar Pago Manual</Typography> |         <Typography variant="h6">Registrar Pago Manual</Typography> | ||||||
|         <Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}> |         <Typography variant="body1" color="text.secondary" gutterBottom> | ||||||
|           Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)} |           Para: {nombreSuscriptor} | ||||||
|  |         </Typography> | ||||||
|  |         <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}> | ||||||
|  |           Saldo Pendiente: ${saldoPendiente.toFixed(2)} | ||||||
|         </Typography> |         </Typography> | ||||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> |         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||||
|           <TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} /> |           <TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} /> | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import { | import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, |  | ||||||
|     FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox |     FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto'; | import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto'; | ||||||
|   | |||||||
| @@ -1,22 +1,22 @@ | |||||||
| // src/hooks/usePermissions.ts |  | ||||||
| import { useAuth } from '../contexts/AuthContext'; | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { useCallback } from 'react'; | ||||||
|  |  | ||||||
| export const usePermissions = () => { | export const usePermissions = () => { | ||||||
|   const { user } = useAuth(); // user aquí es de tipo UserContextData | null |   const { user } = useAuth(); | ||||||
|  |  | ||||||
|   const tienePermiso = (codigoPermisoRequerido: string): boolean => { |   // Envolvemos la función en useCallback. | ||||||
|     if (!user) { // Si no hay usuario logueado |   // Su dependencia es [user], por lo que la función solo se | ||||||
|  |   // volverá a crear si el objeto 'user' cambia (ej. al iniciar/cerrar sesión). | ||||||
|  |   const tienePermiso = useCallback((codigoPermisoRequerido: string): boolean => { | ||||||
|  |     if (!user) { | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|     if (user.esSuperAdmin) { // SuperAdmin tiene todos los permisos |     if (user.esSuperAdmin) { | ||||||
|       return true; |       return true; | ||||||
|     } |     } | ||||||
|     // Verificar si la lista de permisos del usuario incluye el código requerido |  | ||||||
|     return user.permissions?.includes(codigoPermisoRequerido) ?? false; |     return user.permissions?.includes(codigoPermisoRequerido) ?? false; | ||||||
|   }; |   }, [user]); | ||||||
|  |  | ||||||
|   // También puede exportar el objeto user completo si se necesita en otros lugares |  | ||||||
|   // o propiedades específicas como idPerfil, esSuperAdmin. |  | ||||||
|   return { |   return { | ||||||
|     tienePermiso, |     tienePermiso, | ||||||
|     isSuperAdmin: user?.esSuperAdmin ?? false, |     isSuperAdmin: user?.esSuperAdmin ?? false, | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ export interface FacturaConsolidadaDto { | |||||||
|     estadoFacturacion: string; |     estadoFacturacion: string; | ||||||
|     numeroFactura?: string | null; |     numeroFactura?: string | null; | ||||||
|     totalPagado: number; |     totalPagado: number; | ||||||
|  |     tipoFactura: 'Mensual' | 'Alta'; | ||||||
|     detalles: FacturaDetalleDto[]; |     detalles: FacturaDetalleDto[]; | ||||||
|     // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers |     // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers | ||||||
|     idSuscriptor: number;  |     idSuscriptor: number;  | ||||||
|   | |||||||
| @@ -40,14 +40,12 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|   const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true); |   const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true); | ||||||
|   const { tienePermiso, isSuperAdmin } = usePermissions(); |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |  | ||||||
|   // 1. Creamos una lista memoizada de reportes a los que el usuario SÍ tiene acceso. |  | ||||||
|   const accessibleReportModules = useMemo(() => { |   const accessibleReportModules = useMemo(() => { | ||||||
|     return allReportModules.filter(module => |     return allReportModules.filter(module => | ||||||
|       isSuperAdmin || tienePermiso(module.requiredPermission) |       isSuperAdmin || tienePermiso(module.requiredPermission) | ||||||
|     ); |     ); | ||||||
|   }, [isSuperAdmin, tienePermiso]); |   }, [isSuperAdmin, tienePermiso]); | ||||||
|    |    | ||||||
|   // 2. Creamos una lista de categorías que SÍ tienen al menos un reporte accesible. |  | ||||||
|   const accessibleCategories = useMemo(() => { |   const accessibleCategories = useMemo(() => { | ||||||
|     const categoriesWithAccess = new Set(accessibleReportModules.map(r => r.category)); |     const categoriesWithAccess = new Set(accessibleReportModules.map(r => r.category)); | ||||||
|     return predefinedCategoryOrder.filter(category => categoriesWithAccess.has(category)); |     return predefinedCategoryOrder.filter(category => categoriesWithAccess.has(category)); | ||||||
| @@ -62,18 +60,23 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|       const activeReport = accessibleReportModules.find(module => module.path === subPathSegment); |       const activeReport = accessibleReportModules.find(module => module.path === subPathSegment); | ||||||
|       if (activeReport) { |       if (activeReport) { | ||||||
|         setExpandedCategory(activeReport.category); |         setExpandedCategory(activeReport.category); | ||||||
|  |       } else { | ||||||
|  |         // Si la URL apunta a un reporte que no es accesible, no expandimos nada | ||||||
|  |         setExpandedCategory(false); | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       // Si estamos en la página base (/reportes), expandimos la primera categoría disponible. | ||||||
|  |       if (accessibleCategories.length > 0) { | ||||||
|  |         setExpandedCategory(accessibleCategories[0]); | ||||||
|  |       } else { | ||||||
|  |         setExpandedCategory(false); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     // 4. Navegamos al PRIMER REPORTE ACCESIBLE si estamos en la ruta base. |     // No hay navegación automática, solo manejamos el estado de carga. | ||||||
|     if (location.pathname === currentBasePath && accessibleReportModules.length > 0 && isLoadingInitialNavigation) { |  | ||||||
|         const firstReportToNavigate = accessibleReportModules[0]; |  | ||||||
|         navigate(firstReportToNavigate.path, { replace: true }); |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     setIsLoadingInitialNavigation(false); |     setIsLoadingInitialNavigation(false); | ||||||
|  |  | ||||||
|   }, [location.pathname, navigate, accessibleReportModules, isLoadingInitialNavigation]); |   }, [location.pathname, accessibleReportModules, accessibleCategories]); | ||||||
|  |  | ||||||
|   const handleCategoryClick = (categoryName: string) => { |   const handleCategoryClick = (categoryName: string) => { | ||||||
|     setExpandedCategory(prev => (prev === categoryName ? false : categoryName)); |     setExpandedCategory(prev => (prev === categoryName ? false : categoryName)); | ||||||
| @@ -84,12 +87,10 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const isReportActive = (reportPath: string) => { |   const isReportActive = (reportPath: string) => { | ||||||
|     return location.pathname === `/reportes/${reportPath}` || location.pathname.startsWith(`/reportes/${reportPath}/`); |     return location.pathname.startsWith(`/reportes/${reportPath}`); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   // Si isLoadingInitialNavigation es true Y estamos en /reportes, mostrar loader |   if (isLoadingInitialNavigation) { | ||||||
|   // Esto evita mostrar el loader si se navega directamente a un sub-reporte. |  | ||||||
|   if (isLoadingInitialNavigation && (location.pathname === '/reportes' || location.pathname === '/reportes/')) { |  | ||||||
|     return ( |     return ( | ||||||
|         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}> |         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}> | ||||||
|             <CircularProgress /> |             <CircularProgress /> | ||||||
| @@ -98,48 +99,25 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     // Contenedor principal que se adaptará a su padre |  | ||||||
|     // Eliminamos 'height: calc(100vh - 64px)' y cualquier margen/padding que controle el espacio exterior |  | ||||||
|     <Box sx={{ display: 'flex', width: '100%', height: '100%' }}> |     <Box sx={{ display: 'flex', width: '100%', height: '100%' }}> | ||||||
|       {/* Panel Lateral para Navegación */} |       <Paper elevation={0} square sx={{ | ||||||
|       <Paper |           width: { xs: 220, sm: 250, md: 280 }, | ||||||
|         elevation={0} // Sin elevación para que se sienta más integrado si el fondo es el mismo |  | ||||||
|         square // Bordes rectos |  | ||||||
|         sx={{ |  | ||||||
|           width: { xs: 220, sm: 250, md: 280 }, // Ancho responsivo del panel lateral |  | ||||||
|           minWidth: { xs: 200, sm: 220 }, |           minWidth: { xs: 200, sm: 220 }, | ||||||
|           height: '100%', // Ocupa toda la altura del Box padre |           height: '100%', | ||||||
|           borderRight: (theme) => `1px solid ${theme.palette.divider}`, |           borderRight: (theme) => `1px solid ${theme.palette.divider}`, | ||||||
|           overflowY: 'auto', |           overflowY: 'auto', | ||||||
|           bgcolor: 'background.paper', // O el color que desees para el menú |           bgcolor: 'background.paper', | ||||||
|           // display: 'flex', flexDirection: 'column' // Para que el título y la lista usen el espacio vertical |         }}> | ||||||
|         }} |         <Box sx={{ p: 1.5 }}> | ||||||
|       > |             <Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 }}> | ||||||
|         {/* Título del Menú Lateral */} |  | ||||||
|         <Box |  | ||||||
|           sx={{ |  | ||||||
|             p: 1.5, // Padding interno para el título |  | ||||||
|             // borderBottom: (theme) => `1px solid ${theme.palette.divider}`, // Opcional: separador |  | ||||||
|             // position: 'sticky', // Si quieres que el título quede fijo al hacer scroll en la lista |  | ||||||
|             // top: 0, |  | ||||||
|             // zIndex: 1, |  | ||||||
|             // bgcolor: 'background.paper' // Necesario si es sticky y tiene scroll la lista |  | ||||||
|           }} |  | ||||||
|         > |  | ||||||
|             <Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 /* Pequeño margen para alinear con items */ }}> |  | ||||||
|             Reportes |             Reportes | ||||||
|             </Typography> |             </Typography> | ||||||
|         </Box> |         </Box> | ||||||
|          |          | ||||||
|         {/* 5. Renderizamos el menú usando la lista de categorías ACCESIBLES. */} |  | ||||||
|         {accessibleCategories.length > 0 ? ( |         {accessibleCategories.length > 0 ? ( | ||||||
|         <List component="nav" dense sx={{ pt: 0 }}> |         <List component="nav" dense sx={{ pt: 0 }}> | ||||||
|           {accessibleCategories.map((category) => { |           {accessibleCategories.map((category) => { | ||||||
|             // 6. Obtenemos los reportes de esta categoría de la lista ACCESIBLE. |  | ||||||
|             const reportsInCategory = accessibleReportModules.filter(r => r.category === category); |             const reportsInCategory = accessibleReportModules.filter(r => r.category === category); | ||||||
|              |  | ||||||
|             // Ya no es necesario el `if (reportsInCategory.length === 0) return null;` porque `accessibleCategories` ya está filtrado. |  | ||||||
|              |  | ||||||
|             const isExpanded = expandedCategory === category; |             const isExpanded = expandedCategory === category; | ||||||
|  |  | ||||||
|             return ( |             return ( | ||||||
| @@ -147,15 +125,10 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|                 <ListItemButton |                 <ListItemButton | ||||||
|                   onClick={() => handleCategoryClick(category)} |                   onClick={() => handleCategoryClick(category)} | ||||||
|                   sx={{ |                   sx={{ | ||||||
|                     // py: 1.2, // Ajustar padding vertical de items de categoría |  | ||||||
|                     // backgroundColor: isExpanded ? 'action.selected' : 'transparent', |  | ||||||
|                     borderLeft: isExpanded ? (theme) => `4px solid ${theme.palette.primary.main}` : '4px solid transparent', |                     borderLeft: isExpanded ? (theme) => `4px solid ${theme.palette.primary.main}` : '4px solid transparent', | ||||||
|                     pr: 1, // Menos padding a la derecha para dar espacio al ícono expander |                     pr: 1, | ||||||
|                     '&:hover': { |                     '&:hover': { backgroundColor: 'action.hover' } | ||||||
|                         backgroundColor: 'action.hover' |                   }}> | ||||||
|                     } |  | ||||||
|                   }} |  | ||||||
|                 > |  | ||||||
|                   <ListItemText primary={category} primaryTypographyProps={{ fontWeight: isExpanded ? 'bold' : 'normal' }}/> |                   <ListItemText primary={category} primaryTypographyProps={{ fontWeight: isExpanded ? 'bold' : 'normal' }}/> | ||||||
|                   {isExpanded ? <ExpandLess /> : <ExpandMore />} |                   {isExpanded ? <ExpandLess /> : <ExpandMore />} | ||||||
|                 </ListItemButton> |                 </ListItemButton> | ||||||
| @@ -167,21 +140,14 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|                           selected={isReportActive(report.path)} |                           selected={isReportActive(report.path)} | ||||||
|                           onClick={() => handleReportClick(report.path)} |                           onClick={() => handleReportClick(report.path)} | ||||||
|                           sx={{  |                           sx={{  | ||||||
|                             pl: 3.5, // Indentación para los reportes (ajustar si se cambió el padding del título) |                             pl: 3.5, py: 0.8, | ||||||
|                             py: 0.8, // Padding vertical de items de reporte |  | ||||||
|                             ...(isReportActive(report.path) && { |                             ...(isReportActive(report.path) && { | ||||||
|                                 backgroundColor: (theme) => theme.palette.action.selected, // Un color de fondo sutil |                                 backgroundColor: (theme) => theme.palette.action.selected, | ||||||
|                                 borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`, // Un borde para el activo |                                 borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`, | ||||||
|                                 '& .MuiListItemText-primary': { |                                 '& .MuiListItemText-primary': { fontWeight: 'medium' }, | ||||||
|                                     fontWeight: 'medium', // O 'bold' |  | ||||||
|                                     // color: 'primary.main' |  | ||||||
|                                 }, |  | ||||||
|                             }), |                             }), | ||||||
|                             '&:hover': { |                             '&:hover': { backgroundColor: (theme) => theme.palette.action.hover } | ||||||
|                                 backgroundColor: (theme) => theme.palette.action.hover |                           }}> | ||||||
|                             } |  | ||||||
|                           }} |  | ||||||
|                         > |  | ||||||
|                           <ListItemText primary={report.label} primaryTypographyProps={{ variant: 'body2' }}/> |                           <ListItemText primary={report.label} primaryTypographyProps={{ variant: 'body2' }}/> | ||||||
|                         </ListItemButton> |                         </ListItemButton> | ||||||
|                       ))} |                       ))} | ||||||
| @@ -196,26 +162,17 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|         )} |         )} | ||||||
|       </Paper> |       </Paper> | ||||||
|  |  | ||||||
|       {/* Área Principal para el Contenido del Reporte */} |       <Box component="main" sx={{ | ||||||
|       <Box |           flexGrow: 1, p: { xs: 1, sm: 2, md: 3 }, | ||||||
|         component="main" |           overflowY: 'auto', height: '100%', bgcolor: 'grey.100' | ||||||
|         sx={{ |         }}> | ||||||
|           flexGrow: 1, // Ocupa el espacio restante |         {/* Lógica para mostrar el mensaje de bienvenida */} | ||||||
|           p: { xs: 1, sm: 2, md: 3 }, // Padding interno para el contenido, responsivo |         {location.pathname === '/reportes' && !isLoadingInitialNavigation && ( | ||||||
|           overflowY: 'auto', |  | ||||||
|           height: '100%', // Ocupa toda la altura del Box padre |  | ||||||
|           bgcolor: 'grey.100' // Un color de fondo diferente para distinguir el área de contenido |  | ||||||
|         }} |  | ||||||
|       > |  | ||||||
|         {/* El Outlet renderiza el componente del reporte específico */} |  | ||||||
|         {(!location.pathname.startsWith('/reportes/') || !allReportModules.some(r => isReportActive(r.path))) && location.pathname !== '/reportes/' && location.pathname !== '/reportes' && !isLoadingInitialNavigation && ( |  | ||||||
|              <Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}> |              <Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}> | ||||||
|                 El reporte solicitado no existe o la ruta no es válida. |                 {accessibleReportModules.length > 0  | ||||||
|             </Typography> |                     ? "Seleccione una categoría y un reporte del menú lateral."  | ||||||
|         )} |                     : "No tiene acceso a ningún reporte." | ||||||
|         {(location.pathname === '/reportes/' || location.pathname === '/reportes') && !allReportModules.some(r => isReportActive(r.path)) && !isLoadingInitialNavigation && ( |                 } | ||||||
|              <Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}> |  | ||||||
|                 {allReportModules.length > 0 ? "Seleccione una categoría y un reporte del menú lateral." : "No hay reportes configurados."} |  | ||||||
|             </Typography> |             </Typography> | ||||||
|         )} |         )} | ||||||
|         <Outlet /> |         <Outlet /> | ||||||
|   | |||||||
| @@ -26,6 +26,7 @@ const meses = [ | |||||||
|  |  | ||||||
| const estadosPago = ['Pendiente', 'Pagada', 'Pagada Parcialmente', 'Rechazada', 'Anulada']; | const estadosPago = ['Pendiente', 'Pagada', 'Pagada Parcialmente', 'Rechazada', 'Anulada']; | ||||||
| const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; | const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; | ||||||
|  | const tiposFactura = ['Mensual', 'Alta']; | ||||||
|  |  | ||||||
| const SuscriptorRow: React.FC<{ | const SuscriptorRow: React.FC<{ | ||||||
|     resumen: ResumenCuentaSuscriptorDto; |     resumen: ResumenCuentaSuscriptorDto; | ||||||
| @@ -33,8 +34,6 @@ const SuscriptorRow: React.FC<{ | |||||||
|     handleOpenHistorial: (factura: FacturaConsolidadaDto) => void; |     handleOpenHistorial: (factura: FacturaConsolidadaDto) => void; | ||||||
| }> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => { | }> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => { | ||||||
|     const [open, setOpen] = useState(false); |     const [open, setOpen] = useState(false); | ||||||
|  |  | ||||||
|     // Función para formatear moneda |  | ||||||
|     const formatCurrency = (value: number) => `$${value.toFixed(2)}`; |     const formatCurrency = (value: number) => `$${value.toFixed(2)}`; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
| @@ -43,20 +42,16 @@ const SuscriptorRow: React.FC<{ | |||||||
|                 <TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell> |                 <TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell> | ||||||
|                 <TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell> |                 <TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell> | ||||||
|                 <TableCell align="right"> |                 <TableCell align="right"> | ||||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}> |                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>{formatCurrency(resumen.saldoPendienteTotal)}</Typography> | ||||||
|                         {formatCurrency(resumen.saldoPendienteTotal)} |  | ||||||
|                     </Typography> |  | ||||||
|                     <Typography variant="caption" color="text.secondary">de {formatCurrency(resumen.importeTotal)}</Typography> |                     <Typography variant="caption" color="text.secondary">de {formatCurrency(resumen.importeTotal)}</Typography> | ||||||
|                 </TableCell> |                 </TableCell> | ||||||
|                 <TableCell colSpan={7}></TableCell> |                 <TableCell colSpan={6}></TableCell> | ||||||
|             </TableRow> |             </TableRow> | ||||||
|             <TableRow> |             <TableRow> | ||||||
|                 <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={10}> |                 <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={9}> {/* <-- Ajustado para la nueva columna */} | ||||||
|                     <Collapse in={open} timeout="auto" unmountOnExit> |                     <Collapse in={open} timeout="auto" unmountOnExit> | ||||||
|                         <Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> |                         <Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> | ||||||
|                             <Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}> |                             <Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography> | ||||||
|                                 Facturas del Período para {resumen.nombreSuscriptor} |  | ||||||
|                             </Typography> |  | ||||||
|                             <Table size="small"> |                             <Table size="small"> | ||||||
|                                 <TableHead> |                                 <TableHead> | ||||||
|                                     <TableRow> |                                     <TableRow> | ||||||
| @@ -64,6 +59,7 @@ const SuscriptorRow: React.FC<{ | |||||||
|                                         <TableCell align="right">Importe Total</TableCell> |                                         <TableCell align="right">Importe Total</TableCell> | ||||||
|                                         <TableCell align="right">Pagado</TableCell> |                                         <TableCell align="right">Pagado</TableCell> | ||||||
|                                         <TableCell align="right">Saldo</TableCell> |                                         <TableCell align="right">Saldo</TableCell> | ||||||
|  |                                         <TableCell>Tipo Factura</TableCell> | ||||||
|                                         <TableCell>Estado Pago</TableCell> |                                         <TableCell>Estado Pago</TableCell> | ||||||
|                                         <TableCell>Estado Facturación</TableCell> |                                         <TableCell>Estado Facturación</TableCell> | ||||||
|                                         <TableCell>Nro. Factura</TableCell> |                                         <TableCell>Nro. Factura</TableCell> | ||||||
| @@ -77,35 +73,17 @@ const SuscriptorRow: React.FC<{ | |||||||
|                                             <TableRow key={factura.idFactura}> |                                             <TableRow key={factura.idFactura}> | ||||||
|                                                 <TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell> |                                                 <TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell> | ||||||
|                                                 <TableCell align="right">{formatCurrency(factura.importeFinal)}</TableCell> |                                                 <TableCell align="right">{formatCurrency(factura.importeFinal)}</TableCell> | ||||||
|                                                 <TableCell align="right" sx={{ color: 'success.dark' }}> |                                                 <TableCell align="right" sx={{ color: 'success.dark' }}>{formatCurrency(factura.totalPagado)}</TableCell> | ||||||
|                                                     {formatCurrency(factura.totalPagado)} |                                                 <TableCell align="right" sx={{ fontWeight: 'bold', color: saldo > 0 ? 'error.main' : 'inherit' }}>{formatCurrency(saldo)}</TableCell> | ||||||
|                                                 </TableCell> |  | ||||||
|                                                 <TableCell align="right" sx={{ fontWeight: 'bold', color: saldo > 0 ? 'error.main' : 'inherit' }}> |  | ||||||
|                                                     {formatCurrency(saldo)} |  | ||||||
|                                                 </TableCell> |  | ||||||
|                                                 <TableCell> |                                                 <TableCell> | ||||||
|                                                     <Chip |                                                     <Chip label={factura.tipoFactura} size="small" color={factura.tipoFactura === 'Alta' ? 'secondary' : 'default'} /> | ||||||
|                                                         label={factura.estadoPago} |  | ||||||
|                                                         size="small" |  | ||||||
|                                                         color={ |  | ||||||
|                                                             factura.estadoPago === 'Pagada' ? 'success' : |  | ||||||
|                                                                 factura.estadoPago === 'Pagada Parcialmente' ? 'primary' : |  | ||||||
|                                                                     factura.estadoPago === 'Rechazada' ? 'error' : |  | ||||||
|                                                                         'default' |  | ||||||
|                                                         } |  | ||||||
|                                                     /> |  | ||||||
|                                                 </TableCell> |                                                 </TableCell> | ||||||
|  |                                                 <TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Pagada Parcialmente' ? 'primary' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default'))} /></TableCell> | ||||||
|                                                 <TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell> |                                                 <TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell> | ||||||
|                                                 <TableCell>{factura.numeroFactura || '-'}</TableCell> |                                                 <TableCell>{factura.numeroFactura || '-'}</TableCell> | ||||||
|                                                 <TableCell align="right"> |                                                 <TableCell align="right"> | ||||||
|                                                     <IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> |                                                     <IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}><MoreVertIcon /></IconButton> | ||||||
|                                                         <MoreVertIcon /> |                                                     <Tooltip title="Ver Historial de Envíos"><IconButton onClick={() => handleOpenHistorial(factura)}><MailOutlineIcon /></IconButton></Tooltip> | ||||||
|                                                     </IconButton> |  | ||||||
|                                                     <Tooltip title="Ver Historial de Envíos"> |  | ||||||
|                                                         <IconButton onClick={() => handleOpenHistorial(factura)}> |  | ||||||
|                                                             <MailOutlineIcon /> |  | ||||||
|                                                         </IconButton> |  | ||||||
|                                                     </Tooltip> |  | ||||||
|                                                 </TableCell> |                                                 </TableCell> | ||||||
|                                             </TableRow> |                                             </TableRow> | ||||||
|                                         ); |                                         ); | ||||||
| @@ -135,6 +113,7 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|     const [filtroNombre, setFiltroNombre] = useState(''); |     const [filtroNombre, setFiltroNombre] = useState(''); | ||||||
|     const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); |     const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); | ||||||
|     const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); |     const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); | ||||||
|  |     const [filtroTipoFactura, setFiltroTipoFactura] = useState(''); | ||||||
|  |  | ||||||
|     const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null); |     const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null); | ||||||
|     const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); |     const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||||
| @@ -154,7 +133,8 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|                 selectedMes, |                 selectedMes, | ||||||
|                 filtroNombre || undefined, |                 filtroNombre || undefined, | ||||||
|                 filtroEstadoPago || undefined, |                 filtroEstadoPago || undefined, | ||||||
|                 filtroEstadoFacturacion || undefined |                 filtroEstadoFacturacion || undefined, | ||||||
|  |                 filtroTipoFactura || undefined | ||||||
|             ); |             ); | ||||||
|             setResumenes(data); |             setResumenes(data); | ||||||
|         } catch (err) { |         } catch (err) { | ||||||
| @@ -163,7 +143,7 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|         } finally { |         } finally { | ||||||
|             setLoading(false); |             setLoading(false); | ||||||
|         } |         } | ||||||
|     }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]); |     }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion, filtroTipoFactura]); | ||||||
|  |  | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         const timer = setTimeout(() => { |         const timer = setTimeout(() => { | ||||||
| @@ -251,6 +231,17 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|                     <TextField label="Buscar por Suscriptor" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1, minWidth: '200px' }} /> |                     <TextField label="Buscar por Suscriptor" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1, minWidth: '200px' }} /> | ||||||
|                     <FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Pago</InputLabel><Select value={filtroEstadoPago} label="Estado de Pago" onChange={(e) => setFiltroEstadoPago(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosPago.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl> |                     <FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Pago</InputLabel><Select value={filtroEstadoPago} label="Estado de Pago" onChange={(e) => setFiltroEstadoPago(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosPago.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl> | ||||||
|                     <FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Facturación</InputLabel><Select value={filtroEstadoFacturacion} label="Estado de Facturación" onChange={(e) => setFiltroEstadoFacturacion(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosFacturacion.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl> |                     <FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Facturación</InputLabel><Select value={filtroEstadoFacturacion} label="Estado de Facturación" onChange={(e) => setFiltroEstadoFacturacion(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosFacturacion.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl> | ||||||
|  |                     <FormControl sx={{ minWidth: 200 }} size="small"> | ||||||
|  |                         <InputLabel>Tipo de Factura</InputLabel> | ||||||
|  |                         <Select | ||||||
|  |                             value={filtroTipoFactura} | ||||||
|  |                             label="Tipo de Factura" | ||||||
|  |                             onChange={(e) => setFiltroTipoFactura(e.target.value)} | ||||||
|  |                         > | ||||||
|  |                             <MenuItem value=""><em>Todos</em></MenuItem> | ||||||
|  |                             {tiposFactura.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)} | ||||||
|  |                         </Select> | ||||||
|  |                     </FormControl> | ||||||
|                 </Box> |                 </Box> | ||||||
|             </Paper> |             </Paper> | ||||||
|  |  | ||||||
| @@ -259,7 +250,7 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|  |  | ||||||
|             <TableContainer component={Paper}> |             <TableContainer component={Paper}> | ||||||
|                 <Table aria-label="collapsible table"> |                 <Table aria-label="collapsible table"> | ||||||
|                     <TableHead><TableRow><TableCell /><TableCell>Suscriptor</TableCell><TableCell align="right">Saldo Total / Importe Total</TableCell><TableCell colSpan={5}></TableCell></TableRow></TableHead> |                     <TableHead><TableRow><TableCell /><TableCell>Suscriptor</TableCell><TableCell align="right">Saldo Total / Importe Total</TableCell><TableCell colSpan={6}></TableCell></TableRow></TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
|                         {loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>) |                         {loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>) | ||||||
|                             : resumenes.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>) |                             : resumenes.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>) | ||||||
| @@ -285,22 +276,9 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|                 open={pagoModalOpen} |                 open={pagoModalOpen} | ||||||
|                 onClose={handleClosePagoModal} |                 onClose={handleClosePagoModal} | ||||||
|                 onSubmit={handleSubmitPagoModal} |                 onSubmit={handleSubmitPagoModal} | ||||||
|                 factura={ |                 factura={selectedFactura} | ||||||
|                     selectedFactura ? { |                 nombreSuscriptor={ | ||||||
|                         idFactura: selectedFactura.idFactura, |                     resumenes.find(r => r.idSuscriptor === selectedFactura?.idSuscriptor)?.nombreSuscriptor || '' | ||||||
|                         nombreSuscriptor: resumenes.find(r => r.idSuscriptor === resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor)?.nombreSuscriptor || '', |  | ||||||
|                         importeFinal: selectedFactura.importeFinal, |  | ||||||
|                         saldoPendiente: selectedFactura.importeFinal - selectedFactura.totalPagado, |  | ||||||
|                         totalPagado: selectedFactura.totalPagado, |  | ||||||
|                         idSuscriptor: resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor || 0, |  | ||||||
|                         periodo: '', |  | ||||||
|                         fechaEmision: '', |  | ||||||
|                         fechaVencimiento: '', |  | ||||||
|                         estadoPago: selectedFactura.estadoPago, |  | ||||||
|                         estadoFacturacion: selectedFactura.estadoFacturacion, |  | ||||||
|                         numeroFactura: selectedFactura.numeroFactura, |  | ||||||
|                         detalles: selectedFactura.detalles, |  | ||||||
|                     } : null |  | ||||||
|                 } |                 } | ||||||
|                 errorMessage={apiError} |                 errorMessage={apiError} | ||||||
|                 clearErrorMessage={() => setApiError(null)} |                 clearErrorMessage={() => setApiError(null)} | ||||||
|   | |||||||
| @@ -1,11 +1,9 @@ | |||||||
| // src/routes/AppRoutes.tsx |  | ||||||
| import React, { type JSX } from 'react'; | import React, { type JSX } from 'react'; | ||||||
| import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; | import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; | ||||||
| import LoginPage from '../pages/LoginPage'; | import LoginPage from '../pages/LoginPage'; | ||||||
| import HomePage from '../pages/HomePage'; | import HomePage from '../pages/HomePage'; | ||||||
| import { useAuth } from '../contexts/AuthContext'; | import { useAuth } from '../contexts/AuthContext'; | ||||||
| import MainLayout from '../layouts/MainLayout'; | import MainLayout from '../layouts/MainLayout'; | ||||||
| import { Typography } from '@mui/material'; |  | ||||||
| import SectionProtectedRoute from './SectionProtectedRoute'; | import SectionProtectedRoute from './SectionProtectedRoute'; | ||||||
|  |  | ||||||
| // Distribución | // Distribución | ||||||
| @@ -267,7 +265,6 @@ const AppRoutes = () => { | |||||||
|                 <ReportesIndexPage /> |                 <ReportesIndexPage /> | ||||||
|               </SectionProtectedRoute>} |               </SectionProtectedRoute>} | ||||||
|           > |           > | ||||||
|             <Route index element={<Typography sx={{ p: 2 }}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */} |  | ||||||
|             <Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} /> |             <Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} /> | ||||||
|             <Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} /> |             <Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} /> | ||||||
|             <Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} /> |             <Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} /> | ||||||
|   | |||||||
| @@ -19,11 +19,16 @@ const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLot | |||||||
|     return response.data; |     return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise<ResumenCuentaSuscriptorDto[]> => { | const getResumenesDeCuentaPorPeriodo = async ( | ||||||
|  |     anio: number, mes: number, | ||||||
|  |     nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string, | ||||||
|  |     tipoFactura?: string | ||||||
|  | ): Promise<ResumenCuentaSuscriptorDto[]> => { | ||||||
|     const params = new URLSearchParams(); |     const params = new URLSearchParams(); | ||||||
|     if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor); |     if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor); | ||||||
|     if (estadoPago) params.append('estadoPago', estadoPago); |     if (estadoPago) params.append('estadoPago', estadoPago); | ||||||
|     if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion); |     if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion); | ||||||
|  |     if (tipoFactura) params.append('tipoFactura', tipoFactura); | ||||||
|  |  | ||||||
|     const queryString = params.toString(); |     const queryString = params.toString(); | ||||||
|     const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`; |     const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`; | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								README.md
									
									
									
									
									
								
							| @@ -60,6 +60,22 @@ El sistema está organizado en varios módulos clave para cubrir todas las área | |||||||
| - **Autenticación Segura:** Mediante JSON Web Tokens (JWT). | - **Autenticación Segura:** Mediante JSON Web Tokens (JWT). | ||||||
| - **Auditoría:** Todas las modificaciones a los datos maestros y transacciones importantes se registran en tablas de historial (`_H`). | - **Auditoría:** Todas las modificaciones a los datos maestros y transacciones importantes se registran en tablas de historial (`_H`). | ||||||
|  |  | ||||||
|  | ### 📨 Suscripciones | ||||||
|  | - **Gestión de Suscriptores:** ABM completo de clientes, incluyendo datos de contacto, dirección de entrega y forma de pago preferida. | ||||||
|  | - **Ciclo de Vida de la Suscripción:** Creación y administración de suscripciones por cliente y publicación, con fechas de inicio, fin, días de entrega y estados (`Activa`, `Pausada`, `Cancelada`). | ||||||
|  | - **Facturación Proporcional (Pro-rata):** El sistema genera automáticamente una "Factura de Alta" por el monto proporcional cuando un cliente se suscribe en un período ya cerrado, evitando cobros excesivos en la primera factura. | ||||||
|  | - **Gestión de Promociones:** ABM de promociones (ej. descuentos porcentuales, bonificación de días) y asignación a suscripciones específicas con vigencia definida. | ||||||
|  | - **Cuenta Corriente del Suscriptor:** Administración de ajustes manuales (`Crédito`/`Débito`) para manejar situaciones excepcionales como notas de crédito, devoluciones o cargos especiales. | ||||||
|  | - **Procesos de Cierre Mensual:** | ||||||
|  |   - **Generación de Cierre:** Proceso masivo que calcula y genera todas las facturas del período, aplicando promociones y ajustes. | ||||||
|  |   - **Notificaciones Automáticas:** Envío automático de resúmenes de cuenta por email a cada suscriptor al generar el cierre. | ||||||
|  | - **Gestión de Débito Automático:** | ||||||
|  |   - **Generación de Archivo:** Creación del archivo de texto plano en formato "Pago Directo" para el Banco Galicia. Las "Facturas de Alta" se excluyen automáticamente de este proceso. | ||||||
|  |   - **Procesamiento de Respuesta:** Herramienta para procesar el archivo de respuesta del banco, actualizando los estados de pago (`Pagada`/`Rechazada`) de forma masiva. | ||||||
|  | - **Auditoría de Comunicaciones:** | ||||||
|  |   - **Log de Envíos:** Registro detallado de cada correo electrónico enviado (individual o masivo), incluyendo estado (`Enviado`/`Fallido`) y mensajes de error. | ||||||
|  |   - **Historial de Envíos:** Interfaz para consultar el historial de notificaciones enviadas por cada factura o por cada lote de cierre mensual. | ||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
| ## 🛠️ Stack Tecnológico | ## 🛠️ Stack Tecnológico | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user