Compare commits
20 Commits
9e248efc84
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c27dc2a0ba | |||
| 24b1c07342 | |||
| cb64bbc1f5 | |||
| 057310ca47 | |||
| e95c851e5b | |||
| 038faefd35 | |||
| da50c052f1 | |||
| 5781713b13 | |||
| 9f8d577265 | |||
| b594a48fde | |||
| 2e7d1e36be | |||
| dd2277fce2 | |||
| 9412556fa8 | |||
| 8c194b8441 | |||
| 1a288fcfa5 | |||
| 7dc0940001 | |||
| 5a806eda38 | |||
| 21c5c1d7d9 | |||
| 899e0a173f | |||
| 9cfe9d012e |
@@ -26,12 +26,6 @@ jobs:
|
||||
set -e
|
||||
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
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
REPO_OWNER="dmolinari"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,9 +19,6 @@ lerna-debug.log*
|
||||
|
||||
# Variables de entorno
|
||||
# -------------------------------
|
||||
# Nunca subas tus claves de API, contraseñas de BD, etc.
|
||||
# Crea un archivo .env.example con las variables vacías para guiar a otros desarrolladores.
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace GestionIntegral.Api.Controllers.Comunicaciones
|
||||
{
|
||||
[Route("api/lotes-envio")]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class LotesEnvioController : ControllerBase
|
||||
{
|
||||
private readonly IEmailLogService _emailLogService;
|
||||
|
||||
public LotesEnvioController(IEmailLogService emailLogService)
|
||||
{
|
||||
_emailLogService = emailLogService;
|
||||
}
|
||||
|
||||
// GET: api/lotes-envio/123/detalles
|
||||
[HttpGet("{idLote:int}/detalles")]
|
||||
[ProducesResponseType(typeof(IEnumerable<EmailLogDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetDetallesLote(int idLote)
|
||||
{
|
||||
// Reutilizamos un permiso existente, ya que esta es una función de auditoría relacionada.
|
||||
var tienePermiso = User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == "SU006");
|
||||
if (!tienePermiso)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var detalles = await _emailLogService.ObtenerDetallesPorLoteId(idLote);
|
||||
|
||||
// Devolvemos OK con un array vacío si no hay resultados, el frontend lo manejará.
|
||||
return Ok(detalles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
// --- REEMPLAZAR ARCHIVO: Controllers/Reportes/PdfTemplates/DistribucionCanillasDocument.cs ---
|
||||
using GestionIntegral.Api.Dtos.Reportes;
|
||||
using GestionIntegral.Api.Dtos.Reportes.ViewModels;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
|
||||
{
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using GestionIntegral.Api.Dtos.Reportes.ViewModels;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
|
||||
{
|
||||
public class DistribucionSuscripcionesDocument : IDocument
|
||||
{
|
||||
public DistribucionSuscripcionesViewModel Model { get; }
|
||||
|
||||
public DistribucionSuscripcionesDocument(DistribucionSuscripcionesViewModel model)
|
||||
{
|
||||
Model = model;
|
||||
}
|
||||
|
||||
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
|
||||
|
||||
public void Compose(IDocumentContainer container)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Margin(1, Unit.Centimetre);
|
||||
page.DefaultTextStyle(x => x.FontFamily("Arial").FontSize(9));
|
||||
page.Header().Element(ComposeHeader);
|
||||
page.Content().Element(ComposeContent);
|
||||
page.Footer().AlignCenter().Text(x => { x.Span("Página "); x.CurrentPageNumber(); });
|
||||
});
|
||||
}
|
||||
|
||||
void ComposeHeader(IContainer container)
|
||||
{
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(col =>
|
||||
{
|
||||
col.Item().Text("Reporte de Distribución de Suscripciones").SemiBold().FontSize(14);
|
||||
col.Item().Text($"Período: {Model.FechaDesde} al {Model.FechaHasta}").FontSize(11);
|
||||
});
|
||||
row.ConstantItem(150).AlignRight().Text($"Generado: {Model.FechaGeneracion}");
|
||||
});
|
||||
column.Item().PaddingTop(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten2);
|
||||
});
|
||||
}
|
||||
|
||||
void ComposeContent(IContainer container)
|
||||
{
|
||||
container.PaddingTop(10).Column(column =>
|
||||
{
|
||||
column.Spacing(20); // Espacio entre elementos principales (sección de altas y sección de bajas)
|
||||
|
||||
// --- Sección 1: Altas y Activas ---
|
||||
column.Item().Column(colAltas =>
|
||||
{
|
||||
colAltas.Item().Text("Altas y Suscripciones Activas en el Período").Bold().FontSize(14).Underline();
|
||||
colAltas.Item().PaddingBottom(10).Text("Listado de suscriptores que deben recibir entregas en el período seleccionado.");
|
||||
|
||||
if (!Model.DatosAgrupadosAltas.Any())
|
||||
{
|
||||
colAltas.Item().PaddingTop(10).Text("No se encontraron suscripciones activas para este período.").Italic();
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var empresa in Model.DatosAgrupadosAltas)
|
||||
{
|
||||
colAltas.Item().Element(c => ComposeTablaEmpresa(c, empresa, esBaja: false));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Sección 2: Bajas ---
|
||||
if (Model.DatosAgrupadosBajas.Any())
|
||||
{
|
||||
column.Item().PageBreak(); // Salto de página para separar las secciones
|
||||
column.Item().Column(colBajas =>
|
||||
{
|
||||
colBajas.Item().Text("Bajas de Suscripciones en el Período").Bold().FontSize(14).Underline().FontColor(Colors.Red.Medium);
|
||||
colBajas.Item().PaddingBottom(10).Text("Listado de suscriptores cuya suscripción finalizó. NO se les debe entregar a partir de su 'Fecha de Baja'.");
|
||||
|
||||
foreach (var empresa in Model.DatosAgrupadosBajas)
|
||||
{
|
||||
colBajas.Item().Element(c => ComposeTablaEmpresa(c, empresa, esBaja: true));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ComposeTablaEmpresa(IContainer container, GrupoEmpresa empresa, bool esBaja)
|
||||
{
|
||||
container.Column(column =>
|
||||
{
|
||||
// Cabecera de la EMPRESA (ej. EL DIA)
|
||||
column.Item().Background(Colors.Grey.Lighten2).Padding(5).Text(empresa.NombreEmpresa).Bold().FontSize(12);
|
||||
|
||||
// Contenedor para las tablas de las publicaciones de esta empresa
|
||||
column.Item().PaddingTop(5).Column(colPub =>
|
||||
{
|
||||
colPub.Spacing(10); // Espacio entre cada tabla de publicación
|
||||
foreach (var publicacion in empresa.Publicaciones)
|
||||
{
|
||||
colPub.Item().Element(c => ComposeTablaPublicacion(c, publicacion, esBaja));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void ComposeTablaPublicacion(IContainer container, GrupoPublicacion publicacion, bool esBaja)
|
||||
{
|
||||
// Se envuelve la tabla en una columna para poder ponerle un título simple arriba.
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Item().PaddingLeft(2).PaddingBottom(2).Text(publicacion.NombrePublicacion).SemiBold().FontSize(10);
|
||||
column.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2.5f); // Nombre
|
||||
columns.RelativeColumn(3); // Dirección
|
||||
columns.RelativeColumn(1.5f); // Teléfono
|
||||
columns.ConstantColumn(65); // Fecha Inicio / Baja
|
||||
columns.RelativeColumn(1.5f); // Días
|
||||
columns.RelativeColumn(2.5f); // Observaciones
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Suscriptor").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Dirección").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Teléfono").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text(esBaja ? "Fecha de Baja" : "Fecha Inicio").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Días Entrega").SemiBold();
|
||||
header.Cell().BorderBottom(1).Padding(2).Text("Observaciones").SemiBold();
|
||||
});
|
||||
|
||||
foreach (var item in publicacion.Suscripciones)
|
||||
{
|
||||
table.Cell().Padding(2).Text(item.NombreSuscriptor);
|
||||
table.Cell().Padding(2).Text(item.Direccion);
|
||||
table.Cell().Padding(2).Text(item.Telefono ?? "-");
|
||||
var fecha = esBaja ? item.FechaFin : item.FechaInicio;
|
||||
table.Cell().Padding(2).Text(fecha?.ToString("dd/MM/yyyy") ?? "-");
|
||||
table.Cell().Padding(2).Text(item.DiasEntrega);
|
||||
table.Cell().Padding(2).Text(item.Observaciones ?? "-");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using GestionIntegral.Api.Dtos.Reportes.ViewModels;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
|
||||
{
|
||||
public class FacturasPublicidadDocument : IDocument
|
||||
{
|
||||
public FacturasPublicidadViewModel Model { get; }
|
||||
|
||||
public FacturasPublicidadDocument(FacturasPublicidadViewModel model)
|
||||
{
|
||||
Model = model;
|
||||
}
|
||||
|
||||
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
|
||||
|
||||
public void Compose(IDocumentContainer container)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Margin(1, Unit.Centimetre);
|
||||
page.DefaultTextStyle(x => x.FontFamily("Arial").FontSize(9));
|
||||
|
||||
page.Header().Element(ComposeHeader);
|
||||
page.Content().Element(ComposeContent);
|
||||
page.Footer().AlignCenter().Text(x => { x.Span("Página "); x.CurrentPageNumber(); });
|
||||
});
|
||||
}
|
||||
|
||||
void ComposeHeader(IContainer container)
|
||||
{
|
||||
// Se envuelve todo el contenido del header en una única Columna.
|
||||
container.Column(column =>
|
||||
{
|
||||
// El primer item de la columna es la fila con los títulos.
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(col =>
|
||||
{
|
||||
col.Item().Text($"Reporte de Suscripciones a Facturar").SemiBold().FontSize(14);
|
||||
col.Item().Text($"Período: {Model.Periodo}").FontSize(11);
|
||||
});
|
||||
|
||||
row.ConstantItem(150).AlignRight().Column(col => {
|
||||
col.Item().AlignRight().Text($"Fecha de Generación:");
|
||||
col.Item().AlignRight().Text(Model.FechaGeneracion);
|
||||
});
|
||||
});
|
||||
|
||||
// El segundo item de la columna es el separador.
|
||||
column.Item().PaddingTop(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten2);
|
||||
});
|
||||
}
|
||||
|
||||
void ComposeContent(IContainer container)
|
||||
{
|
||||
container.PaddingTop(10).Column(column =>
|
||||
{
|
||||
column.Spacing(20);
|
||||
|
||||
foreach (var empresaData in Model.DatosPorEmpresa)
|
||||
{
|
||||
column.Item().Element(c => ComposeTablaPorEmpresa(c, empresaData));
|
||||
}
|
||||
|
||||
column.Item().AlignRight().PaddingTop(15).Text($"Total General a Facturar: {Model.TotalGeneral.ToString("C", new CultureInfo("es-AR"))}").Bold().FontSize(12);
|
||||
});
|
||||
}
|
||||
|
||||
void ComposeTablaPorEmpresa(IContainer container, DatosEmpresaViewModel empresaData)
|
||||
{
|
||||
container.Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(3); // Nombre Suscriptor
|
||||
columns.ConstantColumn(100); // Documento
|
||||
columns.ConstantColumn(100, Unit.Point); // Importe
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().ColumnSpan(3).Background(Colors.Grey.Lighten2)
|
||||
.Padding(5).Text(empresaData.NombreEmpresa).Bold().FontSize(12);
|
||||
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Padding(2).Text("Suscriptor").SemiBold();
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Padding(2).Text("Documento").SemiBold();
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Padding(2).AlignRight().Text("Importe a Facturar").SemiBold();
|
||||
});
|
||||
|
||||
var facturasPorSuscriptor = empresaData.Facturas.GroupBy(f => f.NombreSuscriptor);
|
||||
|
||||
foreach (var grupoSuscriptor in facturasPorSuscriptor.OrderBy(g => g.Key))
|
||||
{
|
||||
foreach(var item in grupoSuscriptor)
|
||||
{
|
||||
table.Cell().Padding(2).Text(item.NombreSuscriptor);
|
||||
table.Cell().Padding(2).Text($"{item.TipoDocumento} {item.NroDocumento}");
|
||||
table.Cell().Padding(2).AlignRight().Text(item.ImporteFinal.ToString("C", new CultureInfo("es-AR")));
|
||||
}
|
||||
|
||||
if(grupoSuscriptor.Count() > 1)
|
||||
{
|
||||
var subtotal = grupoSuscriptor.Sum(i => i.ImporteFinal);
|
||||
table.Cell().ColumnSpan(2).AlignRight().Padding(2).Text($"Subtotal {grupoSuscriptor.Key}:").Italic();
|
||||
table.Cell().AlignRight().Padding(2).Text(subtotal.ToString("C", new CultureInfo("es-AR"))).Italic().SemiBold();
|
||||
}
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(2).BorderTop(1).BorderColor(Colors.Grey.Darken1).AlignRight()
|
||||
.PaddingTop(5).Text("Total Empresa:").Bold();
|
||||
table.Cell().BorderTop(1).BorderColor(Colors.Grey.Darken1).AlignRight()
|
||||
.PaddingTop(5).Text(empresaData.TotalEmpresa.ToString("C", new CultureInfo("es-AR"))).Bold();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
using GestionIntegral.Api.Services.Reportes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Reporting.NETCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GestionIntegral.Api.Dtos.Reportes;
|
||||
using GestionIntegral.Api.Data.Repositories.Impresion;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using GestionIntegral.Api.Data.Repositories.Distribucion;
|
||||
using GestionIntegral.Api.Services.Distribucion;
|
||||
using GestionIntegral.Api.Services.Pdf;
|
||||
@@ -45,6 +38,8 @@ namespace GestionIntegral.Api.Controllers
|
||||
private const string PermisoVerReporteConsumoBobinas = "RR007";
|
||||
private const string PermisoVerReporteNovedadesCanillas = "RR004";
|
||||
private const string PermisoVerReporteListadoDistMensual = "RR009";
|
||||
private const string PermisoVerReporteFacturasPublicidad = "RR010";
|
||||
private const string PermisoVerReporteDistSuscripciones = "RR011";
|
||||
|
||||
public ReportesController(
|
||||
IReportesService reportesService,
|
||||
@@ -1676,5 +1671,88 @@ namespace GestionIntegral.Api.Controllers
|
||||
return StatusCode(500, "Error interno al generar el PDF del reporte.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("suscripciones/facturas-para-publicidad/pdf")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> GetReporteFacturasPublicidadPdf([FromQuery] int anio, [FromQuery] int mes)
|
||||
{
|
||||
if (!TienePermiso(PermisoVerReporteFacturasPublicidad)) return Forbid();
|
||||
|
||||
var (data, error) = await _reportesService.ObtenerFacturasParaReportePublicidad(anio, mes);
|
||||
if (error != null) return BadRequest(new { message = error });
|
||||
if (data == null || !data.Any())
|
||||
{
|
||||
return NotFound(new { message = "No hay facturas pagadas y pendientes de facturar para el período seleccionado." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// --- INICIO DE LA LÓGICA DE AGRUPACIÓN ---
|
||||
var datosAgrupados = data
|
||||
.GroupBy(f => f.IdEmpresa)
|
||||
.Select(g => new DatosEmpresaViewModel
|
||||
{
|
||||
NombreEmpresa = g.First().NombreEmpresa,
|
||||
Facturas = g.ToList()
|
||||
})
|
||||
.OrderBy(e => e.NombreEmpresa);
|
||||
|
||||
var viewModel = new FacturasPublicidadViewModel
|
||||
{
|
||||
DatosPorEmpresa = datosAgrupados,
|
||||
Periodo = new DateTime(anio, mes, 1).ToString("MMMM yyyy", new CultureInfo("es-ES")),
|
||||
FechaGeneracion = DateTime.Now.ToString("dd/MM/yyyy HH:mm")
|
||||
};
|
||||
// --- FIN DE LA LÓGICA DE AGRUPACIÓN ---
|
||||
|
||||
var document = new FacturasPublicidadDocument(viewModel);
|
||||
byte[] pdfBytes = await _pdfGenerator.GeneratePdfAsync(document);
|
||||
string fileName = $"ReportePublicidad_Suscripciones_{anio}-{mes:D2}.pdf";
|
||||
return File(pdfBytes, "application/pdf", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al generar PDF para Reporte de Facturas a Publicidad.");
|
||||
return StatusCode(500, "Error interno al generar el PDF del reporte.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("suscripciones/distribucion/pdf")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetReporteDistribucionSuscripcionesPdf([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta)
|
||||
{
|
||||
if (!TienePermiso(PermisoVerReporteDistSuscripciones)) return Forbid();
|
||||
|
||||
var (altas, bajas, error) = await _reportesService.ObtenerReporteDistribucionSuscripcionesAsync(fechaDesde, fechaHasta);
|
||||
if (error != null) return BadRequest(new { message = error });
|
||||
if ((altas == null || !altas.Any()) && (bajas == null || !bajas.Any()))
|
||||
{
|
||||
return NotFound(new { message = "No se encontraron suscripciones activas ni bajas para el período seleccionado." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var viewModel = new DistribucionSuscripcionesViewModel(altas ?? Enumerable.Empty<DistribucionSuscripcionDto>(), bajas ?? Enumerable.Empty<DistribucionSuscripcionDto>())
|
||||
{
|
||||
FechaDesde = fechaDesde.ToString("dd/MM/yyyy"),
|
||||
FechaHasta = fechaHasta.ToString("dd/MM/yyyy"),
|
||||
FechaGeneracion = DateTime.Now.ToString("dd/MM/yyyy HH:mm")
|
||||
};
|
||||
|
||||
var document = new DistribucionSuscripcionesDocument(viewModel);
|
||||
byte[] pdfBytes = await _pdfGenerator.GeneratePdfAsync(document);
|
||||
string fileName = $"ReporteDistribucionSuscripciones_{fechaDesde:yyyyMMdd}_al_{fechaHasta:yyyyMMdd}.pdf";
|
||||
return File(pdfBytes, "application/pdf", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al generar PDF para Reporte de Distribución de Suscripciones.");
|
||||
return StatusCode(500, "Error interno al generar el PDF del reporte.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Services.Suscripciones;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
{
|
||||
[Route("api/ajustes")]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AjustesController : ControllerBase
|
||||
{
|
||||
private readonly IAjusteService _ajusteService;
|
||||
private readonly ILogger<AjustesController> _logger;
|
||||
|
||||
// Permiso a crear en BD
|
||||
private const string PermisoGestionarAjustes = "SU011";
|
||||
|
||||
public AjustesController(IAjusteService ajusteService, ILogger<AjustesController> logger)
|
||||
{
|
||||
_ajusteService = ajusteService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc);
|
||||
|
||||
private int? GetCurrentUserId()
|
||||
{
|
||||
if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId;
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET: api/suscriptores/{idSuscriptor}/ajustes
|
||||
[HttpGet("~/api/suscriptores/{idSuscriptor:int}/ajustes")]
|
||||
[ProducesResponseType(typeof(IEnumerable<AjusteDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetAjustesPorSuscriptor(int idSuscriptor, [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
|
||||
var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor, fechaDesde, fechaHasta);
|
||||
return Ok(ajustes);
|
||||
}
|
||||
|
||||
// POST: api/ajustes
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AjusteDto), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateAjuste([FromBody] CreateAjusteDto createDto)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
|
||||
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var (dto, error) = await _ajusteService.CrearAjusteManual(createDto, userId.Value);
|
||||
|
||||
if (error != null) return BadRequest(new { message = error });
|
||||
if (dto == null) return StatusCode(500, "Error al crear el ajuste.");
|
||||
|
||||
// Devolvemos el objeto creado con un 201
|
||||
return StatusCode(201, dto);
|
||||
}
|
||||
|
||||
// POST: api/ajustes/{id}/anular
|
||||
[HttpPost("{id:int}/anular")]
|
||||
public async Task<IActionResult> Anular(int id)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var (exito, error) = await _ajusteService.AnularAjuste(id, userId.Value);
|
||||
if (!exito) return BadRequest(new { message = error });
|
||||
|
||||
return Ok(new { message = "Ajuste anulado correctamente." });
|
||||
}
|
||||
|
||||
// PUT: api/ajustes/{id}
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> UpdateAjuste(int id, [FromBody] UpdateAjusteDto updateDto)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
|
||||
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||
|
||||
var (exito, error) = await _ajusteService.ActualizarAjuste(id, updateDto);
|
||||
if (!exito)
|
||||
{
|
||||
if (error != null && error.Contains("no encontrado")) return NotFound(new { message = error });
|
||||
return BadRequest(new { message = error });
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using GestionIntegral.Api.Services.Suscripciones;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -13,14 +14,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
{
|
||||
private readonly IFacturacionService _facturacionService;
|
||||
private readonly ILogger<FacturacionController> _logger;
|
||||
private readonly IEmailLogService _emailLogService;
|
||||
private const string PermisoGestionarFacturacion = "SU006";
|
||||
private const string PermisoEnviarEmail = "SU009";
|
||||
|
||||
// Permiso para generar facturación (a crear en la BD)
|
||||
private const string PermisoGenerarFacturacion = "SU006";
|
||||
|
||||
public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger)
|
||||
public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger, IEmailLogService emailLogService)
|
||||
{
|
||||
_facturacionService = facturacionService;
|
||||
_logger = logger;
|
||||
_emailLogService = emailLogService;
|
||||
}
|
||||
|
||||
private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc);
|
||||
@@ -28,67 +30,97 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
private int? GetCurrentUserId()
|
||||
{
|
||||
if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId;
|
||||
_logger.LogWarning("No se pudo obtener el UserId del token JWT en FacturacionController.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// POST: api/facturacion/{anio}/{mes}
|
||||
[HttpPost("{anio:int}/{mes:int}")]
|
||||
public async Task<IActionResult> GenerarFacturacion(int anio, int mes)
|
||||
[HttpPut("{idFactura:int}/numero-factura")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> UpdateNumeroFactura(int idFactura, [FromBody] string numeroFactura)
|
||||
{
|
||||
if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid();
|
||||
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
|
||||
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
if (anio < 2020 || mes < 1 || mes > 12)
|
||||
{
|
||||
return BadRequest(new { message = "El año y el mes proporcionados no son válidos." });
|
||||
}
|
||||
|
||||
var (exito, mensaje, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
|
||||
var (exito, error) = await _facturacionService.ActualizarNumeroFactura(idFactura, numeroFactura, userId.Value);
|
||||
|
||||
if (!exito)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje });
|
||||
if (error != null && error.Contains("no existe")) return NotFound(new { message = error });
|
||||
return BadRequest(new { message = error });
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
return Ok(new { message = mensaje, facturasGeneradas });
|
||||
}
|
||||
|
||||
// GET: api/facturacion/{anio}/{mes}
|
||||
[HttpGet("{anio:int}/{mes:int}")]
|
||||
[ProducesResponseType(typeof(IEnumerable<FacturaDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> GetFacturas(int anio, int mes)
|
||||
[HttpPost("{idFactura:int}/enviar-factura-pdf")]
|
||||
public async Task<IActionResult> EnviarFacturaPdf(int idFactura)
|
||||
{
|
||||
// Usamos el permiso de generar facturación también para verlas.
|
||||
if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid();
|
||||
|
||||
if (anio < 2020 || mes < 1 || mes > 12)
|
||||
{
|
||||
return BadRequest(new { message = "El período no es válido." });
|
||||
}
|
||||
|
||||
var facturas = await _facturacionService.ObtenerFacturasPorPeriodo(anio, mes);
|
||||
return Ok(facturas);
|
||||
}
|
||||
|
||||
// POST: api/facturacion/{idFactura}/enviar-email
|
||||
[HttpPost("{idFactura:int}/enviar-email")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> EnviarEmail(int idFactura)
|
||||
{
|
||||
// Usaremos un nuevo permiso para esta acción
|
||||
if (!TienePermiso("SU009")) return Forbid();
|
||||
|
||||
var (exito, error) = await _facturacionService.EnviarFacturaPorEmail(idFactura);
|
||||
if (!TienePermiso(PermisoEnviarEmail)) return Forbid();
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
var (exito, error, emailDestino) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura, userId.Value);
|
||||
|
||||
if (!exito)
|
||||
{
|
||||
return BadRequest(new { message = error });
|
||||
}
|
||||
|
||||
return Ok(new { message = "Email enviado a la cola de procesamiento." });
|
||||
var mensajeExito = $"El email con la factura PDF se ha enviado correctamente a {emailDestino}.";
|
||||
return Ok(new { message = mensajeExito });
|
||||
}
|
||||
|
||||
[HttpGet("{anio:int}/{mes:int}")]
|
||||
public async Task<IActionResult> GetFacturas(
|
||||
int anio, int mes,
|
||||
[FromQuery] string? nombreSuscriptor,
|
||||
[FromQuery] string? estadoPago,
|
||||
[FromQuery] string? estadoFacturacion,
|
||||
[FromQuery] string? tipoFactura)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
|
||||
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." });
|
||||
|
||||
var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura);
|
||||
return Ok(resumenes);
|
||||
}
|
||||
|
||||
[HttpPost("{anio:int}/{mes:int}")]
|
||||
public async Task<IActionResult> GenerarFacturacion(int anio, int mes)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El año y el mes proporcionados no son válidos." });
|
||||
|
||||
var (exito, mensaje, resultadoEnvio) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
|
||||
|
||||
if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje });
|
||||
|
||||
return Ok(new { message = mensaje, resultadoEnvio });
|
||||
}
|
||||
|
||||
[HttpGet("historial-lotes-envio")]
|
||||
[ProducesResponseType(typeof(IEnumerable<LoteDeEnvioHistorialDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetHistorialLotesEnvio([FromQuery] int? anio, [FromQuery] int? mes)
|
||||
{
|
||||
if (!TienePermiso("SU006")) return Forbid();
|
||||
var historial = await _facturacionService.ObtenerHistorialLotesEnvio(anio, mes);
|
||||
return Ok(historial);
|
||||
}
|
||||
|
||||
// Endpoint para el historial de envíos de una factura individual
|
||||
[HttpGet("{idFactura:int}/historial-envios")]
|
||||
[ProducesResponseType(typeof(IEnumerable<EmailLogDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetHistorialEnvios(int idFactura)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); // Reutilizamos el permiso
|
||||
|
||||
// Construimos la referencia que se guarda en el log
|
||||
string referencia = $"Factura-{idFactura}";
|
||||
var historial = await _emailLogService.ObtenerHistorialPorReferencia(referencia);
|
||||
|
||||
return Ok(historial);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,15 +112,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
return Ok(promos);
|
||||
}
|
||||
|
||||
// POST: api/suscripciones/{idSuscripcion}/promociones/{idPromocion}
|
||||
[HttpPost("{idSuscripcion:int}/promociones/{idPromocion:int}")]
|
||||
public async Task<IActionResult> AsignarPromocion(int idSuscripcion, int idPromocion)
|
||||
// POST: api/suscripciones/{idSuscripcion}/promociones
|
||||
[HttpPost("{idSuscripcion:int}/promociones")]
|
||||
public async Task<IActionResult> AsignarPromocion(int idSuscripcion, [FromBody] AsignarPromocionDto dto)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, idPromocion, userId.Value);
|
||||
var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, dto, userId.Value);
|
||||
if (!exito) return BadRequest(new { message = error });
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Dapper;
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
|
||||
{
|
||||
public class EmailLogRepository : IEmailLogRepository
|
||||
{
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<EmailLogRepository> _logger;
|
||||
public EmailLogRepository(DbConnectionFactory connectionFactory, ILogger<EmailLogRepository> logger)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task CreateAsync(EmailLog log)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT INTO dbo.com_EmailLogs
|
||||
(FechaEnvio, DestinatarioEmail, Asunto, Estado, Error, IdUsuarioDisparo, Origen, ReferenciaId, IdLoteDeEnvio)
|
||||
VALUES
|
||||
(@FechaEnvio, @DestinatarioEmail, @Asunto, @Estado, @Error, @IdUsuarioDisparo, @Origen, @ReferenciaId, @IdLoteDeEnvio);";
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(sql, log);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<EmailLog>> GetByReferenceAsync(string referenciaId)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT * FROM dbo.com_EmailLogs
|
||||
WHERE ReferenciaId = @ReferenciaId
|
||||
ORDER BY FechaEnvio DESC;";
|
||||
try
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<EmailLog>(sql, new { ReferenciaId = referenciaId });
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener logs de email por ReferenciaId: {ReferenciaId}", referenciaId);
|
||||
return Enumerable.Empty<EmailLog>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<EmailLog>> GetByLoteIdAsync(int idLoteDeEnvio)
|
||||
{
|
||||
// Ordenamos por Estado descendente para que los 'Fallidos' aparezcan primero
|
||||
const string sql = @"
|
||||
SELECT * FROM dbo.com_EmailLogs
|
||||
WHERE IdLoteDeEnvio = @IdLoteDeEnvio
|
||||
ORDER BY Estado DESC, FechaEnvio DESC;";
|
||||
try
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<EmailLog>(sql, new { IdLoteDeEnvio = idLoteDeEnvio });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener logs de email por IdLoteDeEnvio: {IdLoteDeEnvio}", idLoteDeEnvio);
|
||||
return Enumerable.Empty<EmailLog>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
|
||||
{
|
||||
public interface IEmailLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Guarda un nuevo registro de log de email en la base de datos.
|
||||
/// </summary>
|
||||
Task CreateAsync(EmailLog log);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene todos los registros de log de email que coinciden con una referencia específica.
|
||||
/// </summary>
|
||||
/// <param name="referenciaId">El identificador de la entidad (ej. "Factura-59").</param>
|
||||
/// <returns>Una colección de registros de log de email.</returns>
|
||||
Task<IEnumerable<EmailLog>> GetByReferenceAsync(string referenciaId);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene todos los registros de log de email que pertenecen a un lote de envío masivo.
|
||||
/// </summary>
|
||||
/// <param name="idLoteDeEnvio">El ID del lote de envío.</param>
|
||||
/// <returns>Una colección de registros de log de email.</returns>
|
||||
Task<IEnumerable<EmailLog>> GetByLoteIdAsync(int idLoteDeEnvio);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
|
||||
{
|
||||
public interface ILoteDeEnvioRepository
|
||||
{
|
||||
Task<LoteDeEnvio> CreateAsync(LoteDeEnvio lote);
|
||||
Task<bool> UpdateAsync(LoteDeEnvio lote);
|
||||
Task<IEnumerable<LoteDeEnvio>> GetAllAsync(int? anio, int? mes);
|
||||
Task<LoteDeEnvio?> GetByIdAsync(int id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
|
||||
{
|
||||
public class LoteDeEnvioRepository : ILoteDeEnvioRepository
|
||||
{
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
public LoteDeEnvioRepository(DbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<LoteDeEnvio> CreateAsync(LoteDeEnvio lote)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT INTO dbo.com_LotesDeEnvio (FechaInicio, Periodo, Origen, Estado, IdUsuarioDisparo)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES (@FechaInicio, @Periodo, @Origen, @Estado, @IdUsuarioDisparo);";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<LoteDeEnvio>(sql, lote);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(LoteDeEnvio lote)
|
||||
{
|
||||
const string sql = @"
|
||||
UPDATE dbo.com_LotesDeEnvio SET
|
||||
FechaFin = @FechaFin,
|
||||
Estado = @Estado,
|
||||
TotalCorreos = @TotalCorreos,
|
||||
TotalEnviados = @TotalEnviados,
|
||||
TotalFallidos = @TotalFallidos
|
||||
WHERE IdLoteDeEnvio = @IdLoteDeEnvio;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
var rows = await connection.ExecuteAsync(sql, lote);
|
||||
return rows == 1;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LoteDeEnvio>> GetAllAsync(int? anio, int? mes)
|
||||
{
|
||||
var sqlBuilder = new StringBuilder("SELECT * FROM dbo.com_LotesDeEnvio WHERE 1=1");
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
if (anio.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND YEAR(FechaInicio) = @Anio");
|
||||
parameters.Add("Anio", anio.Value);
|
||||
}
|
||||
if (mes.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND MONTH(FechaInicio) = @Mes");
|
||||
parameters.Add("Mes", mes.Value);
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY FechaInicio DESC;");
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<LoteDeEnvio>(sqlBuilder.ToString(), parameters);
|
||||
}
|
||||
|
||||
public async Task<LoteDeEnvio?> GetByIdAsync(int id)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.com_LotesDeEnvio WHERE IdLoteDeEnvio = @Id;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<LoteDeEnvio>(sql, new { Id = id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,5 +45,8 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
|
||||
Task<IEnumerable<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla);
|
||||
Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
|
||||
Task<IEnumerable<ListadoDistCanMensualPubDto>> GetReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
|
||||
Task<IEnumerable<FacturasParaReporteDto>> GetDatosReportePublicidadAsync(string periodo);
|
||||
Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesActivasAsync(DateTime fechaDesde, DateTime fechaHasta);
|
||||
Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesBajasAsync(DateTime fechaDesde, DateTime fechaHasta);
|
||||
}
|
||||
}
|
||||
@@ -547,5 +547,111 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
|
||||
commandType: CommandType.StoredProcedure, commandTimeout: 120
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<FacturasParaReporteDto>> GetDatosReportePublicidadAsync(string periodo)
|
||||
{
|
||||
// Esta consulta une todas las tablas necesarias para obtener los datos del reporte
|
||||
const string sql = @"
|
||||
SELECT
|
||||
f.IdFactura,
|
||||
f.Periodo,
|
||||
s.NombreCompleto AS NombreSuscriptor,
|
||||
s.TipoDocumento,
|
||||
s.NroDocumento,
|
||||
f.ImporteFinal,
|
||||
e.Id_Empresa AS IdEmpresa,
|
||||
e.Nombre AS NombreEmpresa
|
||||
FROM dbo.susc_Facturas f
|
||||
JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor
|
||||
-- Usamos una subconsulta para obtener la empresa de forma segura
|
||||
JOIN (
|
||||
SELECT DISTINCT
|
||||
fd.IdFactura,
|
||||
p.Id_Empresa
|
||||
FROM dbo.susc_FacturaDetalles fd
|
||||
JOIN dbo.susc_Suscripciones sub ON fd.IdSuscripcion = sub.IdSuscripcion
|
||||
JOIN dbo.dist_dtPublicaciones p ON sub.IdPublicacion = p.Id_Publicacion
|
||||
) AS FacturaEmpresa ON f.IdFactura = FacturaEmpresa.IdFactura
|
||||
JOIN dbo.dist_dtEmpresas e ON FacturaEmpresa.Id_Empresa = e.Id_Empresa
|
||||
WHERE
|
||||
f.Periodo = @Periodo
|
||||
AND f.EstadoPago = 'Pagada'
|
||||
AND f.EstadoFacturacion = 'Pendiente de Facturar'
|
||||
ORDER BY
|
||||
e.Nombre, s.NombreCompleto;
|
||||
";
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _dbConnectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<FacturasParaReporteDto>(sql, new { Periodo = periodo });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al ejecutar la consulta para el Reporte de Publicidad para el período {Periodo}", periodo);
|
||||
return Enumerable.Empty<FacturasParaReporteDto>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesActivasAsync(DateTime fechaDesde, DateTime fechaHasta)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT
|
||||
e.Nombre AS NombreEmpresa, p.Nombre AS NombrePublicacion,
|
||||
sus.NombreCompleto AS NombreSuscriptor, sus.Direccion, sus.Telefono,
|
||||
s.FechaInicio, s.FechaFin, s.DiasEntrega, s.Observaciones
|
||||
FROM dbo.susc_Suscripciones s
|
||||
JOIN dbo.susc_Suscriptores sus ON s.IdSuscriptor = sus.IdSuscriptor
|
||||
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
|
||||
JOIN dbo.dist_dtEmpresas e ON p.Id_Empresa = e.Id_Empresa
|
||||
WHERE
|
||||
-- --- INICIO DE LA CORRECCIÓN ---
|
||||
-- Se asegura de que SOLO se incluyan suscripciones y suscriptores ACTIVOS.
|
||||
s.Estado = 'Activa' AND sus.Activo = 1
|
||||
-- --- FIN DE LA CORRECCIÓN ---
|
||||
AND s.FechaInicio <= @FechaHasta
|
||||
AND (s.FechaFin IS NULL OR s.FechaFin >= @FechaDesde)
|
||||
ORDER BY e.Nombre, p.Nombre, sus.NombreCompleto;";
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _dbConnectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<DistribucionSuscripcionDto>(sql, new { FechaDesde = fechaDesde, FechaHasta = fechaHasta });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener datos para Reporte de Distribución (Activas).");
|
||||
return Enumerable.Empty<DistribucionSuscripcionDto>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesBajasAsync(DateTime fechaDesde, DateTime fechaHasta)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT
|
||||
e.Nombre AS NombreEmpresa, p.Nombre AS NombrePublicacion,
|
||||
sus.NombreCompleto AS NombreSuscriptor, sus.Direccion, sus.Telefono,
|
||||
s.FechaInicio, s.FechaFin, s.DiasEntrega, s.Observaciones
|
||||
FROM dbo.susc_Suscripciones s
|
||||
JOIN dbo.susc_Suscriptores sus ON s.IdSuscriptor = sus.IdSuscriptor
|
||||
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
|
||||
JOIN dbo.dist_dtEmpresas e ON p.Id_Empresa = e.Id_Empresa
|
||||
WHERE
|
||||
-- La lógica aquí es correcta: buscamos cualquier suscripción cuya fecha de fin
|
||||
-- caiga dentro del rango de fechas seleccionado.
|
||||
s.FechaFin BETWEEN @FechaDesde AND @FechaHasta
|
||||
ORDER BY e.Nombre, p.Nombre, s.FechaFin, sus.NombreCompleto;";
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _dbConnectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<DistribucionSuscripcionDto>(sql, new { FechaDesde = fechaDesde, FechaHasta = fechaHasta });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener datos para Reporte de Distribución (Bajas).");
|
||||
return Enumerable.Empty<DistribucionSuscripcionDto>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Dapper;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using System.Data;
|
||||
using System.Text;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
public class AjusteRepository : IAjusteRepository
|
||||
{
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<AjusteRepository> _logger;
|
||||
|
||||
public AjusteRepository(DbConnectionFactory factory, ILogger<AjusteRepository> logger)
|
||||
{
|
||||
_connectionFactory = factory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Ajuste ajuste, IDbTransaction transaction)
|
||||
{
|
||||
const string sql = @"
|
||||
UPDATE dbo.susc_Ajustes SET
|
||||
IdEmpresa = @IdEmpresa,
|
||||
FechaAjuste = @FechaAjuste,
|
||||
TipoAjuste = @TipoAjuste,
|
||||
Monto = @Monto,
|
||||
Motivo = @Motivo
|
||||
WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';";
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
var rows = await transaction.Connection.ExecuteAsync(sql, ajuste, transaction);
|
||||
return rows == 1;
|
||||
}
|
||||
|
||||
public async Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT INTO dbo.susc_Ajustes (IdSuscriptor, IdEmpresa, FechaAjuste, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES (@IdSuscriptor, @IdEmpresa, @FechaAjuste, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());";
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
return await transaction.Connection.QuerySingleOrDefaultAsync<Ajuste>(sql, nuevoAjuste, transaction);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta)
|
||||
{
|
||||
var sqlBuilder = new StringBuilder("SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor");
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("IdSuscriptor", idSuscriptor);
|
||||
|
||||
if (fechaDesde.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND FechaAjuste >= @FechaDesde");
|
||||
parameters.Add("FechaDesde", fechaDesde.Value.Date);
|
||||
}
|
||||
if (fechaHasta.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND FechaAjuste <= @FechaHasta");
|
||||
parameters.Add("FechaHasta", fechaHasta.Value.Date);
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY FechaAlta DESC;");
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<Ajuste>(sqlBuilder.ToString(), parameters);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Ajuste>> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, int idEmpresa, DateTime fechaHasta, IDbTransaction transaction)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT * FROM dbo.susc_Ajustes
|
||||
WHERE IdSuscriptor = @IdSuscriptor
|
||||
AND IdEmpresa = @IdEmpresa
|
||||
AND Estado = 'Pendiente'
|
||||
AND FechaAjuste <= @FechaHasta;";
|
||||
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
return await transaction.Connection.QueryAsync<Ajuste>(sql, new { idSuscriptor, idEmpresa, FechaHasta = fechaHasta }, transaction);
|
||||
}
|
||||
|
||||
public async Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction)
|
||||
{
|
||||
if (!idsAjustes.Any()) return true;
|
||||
|
||||
const string sql = @"
|
||||
UPDATE dbo.susc_Ajustes SET
|
||||
Estado = 'Aplicado',
|
||||
IdFacturaAplicado = @IdFactura
|
||||
WHERE IdAjuste IN @IdsAjustes;";
|
||||
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdsAjustes = idsAjustes, IdFactura = idFactura }, transaction);
|
||||
return rowsAffected == idsAjustes.Count();
|
||||
}
|
||||
|
||||
public async Task<Ajuste?> GetByIdAsync(int idAjuste)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdAjuste = @IdAjuste;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<Ajuste>(sql, new { idAjuste });
|
||||
}
|
||||
|
||||
public async Task<bool> AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction)
|
||||
{
|
||||
const string sql = @"
|
||||
UPDATE dbo.susc_Ajustes SET
|
||||
Estado = 'Anulado',
|
||||
IdUsuarioAnulo = @IdUsuario,
|
||||
FechaAnulacion = GETDATE()
|
||||
WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';";
|
||||
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
|
||||
var rows = await transaction.Connection.ExecuteAsync(sql, new { IdAjuste = idAjuste, IdUsuario = idUsuario }, transaction);
|
||||
return rows == 1;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Ajuste>> GetAjustesPorIdFacturaAsync(int idFactura)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdFacturaAplicado = @IdFactura;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<Ajuste>(sql, new { IdFactura = idFactura });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Dapper;
|
||||
using System.Data;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
public class FacturaDetalleRepository : IFacturaDetalleRepository
|
||||
{
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<FacturaDetalleRepository> _logger;
|
||||
|
||||
public FacturaDetalleRepository(DbConnectionFactory connectionFactory, ILogger<FacturaDetalleRepository> logger)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<FacturaDetalle?> CreateAsync(FacturaDetalle nuevoDetalle, IDbTransaction transaction)
|
||||
{
|
||||
if (transaction == null || transaction.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sqlInsert = @"
|
||||
INSERT INTO dbo.susc_FacturaDetalles (IdFactura, IdSuscripcion, Descripcion, ImporteBruto, DescuentoAplicado, ImporteNeto)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES (@IdFactura, @IdSuscripcion, @Descripcion, @ImporteBruto, @DescuentoAplicado, @ImporteNeto);";
|
||||
|
||||
return await transaction.Connection.QuerySingleOrDefaultAsync<FacturaDetalle>(sqlInsert, nuevoDetalle, transaction);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<FacturaDetalle>> GetDetallesPorFacturaIdAsync(int idFactura)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.susc_FacturaDetalles WHERE IdFactura = @IdFactura;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<FacturaDetalle>(sql, new { IdFactura = idFactura });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<FacturaDetalle>> GetDetallesPorPeriodoAsync(string periodo)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT fd.*
|
||||
FROM dbo.susc_FacturaDetalles fd
|
||||
JOIN dbo.susc_Facturas f ON fd.IdFactura = f.IdFactura
|
||||
WHERE f.Periodo = @Periodo;";
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<FacturaDetalle>(sql, new { Periodo = periodo });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener los detalles de factura para el período {Periodo}", periodo);
|
||||
return Enumerable.Empty<FacturaDetalle>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
// Archivo: GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs
|
||||
|
||||
using Dapper;
|
||||
using GestionIntegral.Api.Data;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
@@ -19,7 +24,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
|
||||
public async Task<Factura?> GetByIdAsync(int idFactura)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura = @IdFactura;";
|
||||
const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura = @idFactura;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idFactura });
|
||||
}
|
||||
@@ -31,14 +36,21 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
return await connection.QueryAsync<Factura>(sql, new { Periodo = periodo });
|
||||
}
|
||||
|
||||
public async Task<Factura?> GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction)
|
||||
public async Task<Factura?> GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction)
|
||||
{
|
||||
const string sql = "SELECT TOP 1 * FROM dbo.susc_Facturas WHERE IdSuscripcion = @IdSuscripcion AND Periodo = @Periodo;";
|
||||
const string sql = "SELECT TOP 1 * FROM dbo.susc_Facturas WHERE IdSuscriptor = @IdSuscriptor AND Periodo = @Periodo;";
|
||||
if (transaction == null || transaction.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
return await transaction.Connection.QuerySingleOrDefaultAsync<Factura>(sql, new { IdSuscripcion = idSuscripcion, Periodo = periodo }, transaction);
|
||||
return await transaction.Connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idSuscriptor, Periodo = periodo }, transaction);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Factura>> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdSuscriptor = @IdSuscriptor AND Periodo = @Periodo;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<Factura>(sql, new { idSuscriptor, Periodo = periodo });
|
||||
}
|
||||
|
||||
public async Task<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction)
|
||||
@@ -47,26 +59,27 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
|
||||
const string sqlInsert = @"
|
||||
INSERT INTO dbo.susc_Facturas
|
||||
(IdSuscripcion, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,
|
||||
DescuentoAplicado, ImporteFinal, Estado)
|
||||
(IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,
|
||||
DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion, TipoFactura)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES
|
||||
(@IdSuscripcion, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,
|
||||
@DescuentoAplicado, @ImporteFinal, @Estado);";
|
||||
(@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,
|
||||
@DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion, @TipoFactura);";
|
||||
|
||||
return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction)
|
||||
public async Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction)
|
||||
{
|
||||
if (transaction == null || transaction.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sql = "UPDATE dbo.susc_Facturas SET Estado = @NuevoEstado WHERE IdFactura = @IdFactura;";
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstado = nuevoEstado, IdFactura = idFactura }, transaction);
|
||||
const string sql = "UPDATE dbo.susc_Facturas SET EstadoPago = @NuevoEstadoPago WHERE IdFactura = @IdFactura;";
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, idFactura }, transaction);
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
@@ -76,8 +89,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sql = "UPDATE dbo.susc_Facturas SET NumeroFactura = @NumeroFactura, Estado = 'Pendiente de Cobro' WHERE IdFactura = @IdFactura;";
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, IdFactura = idFactura }, transaction);
|
||||
const string sql = @"
|
||||
UPDATE dbo.susc_Facturas SET
|
||||
NumeroFactura = @NumeroFactura,
|
||||
EstadoFacturacion = 'Facturado'
|
||||
WHERE IdFactura = @IdFactura;";
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, idFactura }, transaction);
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
@@ -87,59 +104,168 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sql = "UPDATE dbo.susc_Facturas SET IdLoteDebito = @IdLoteDebito, Estado = 'Enviada a Débito' WHERE IdFactura IN @IdsFacturas;";
|
||||
const string sql = "UPDATE dbo.susc_Facturas SET IdLoteDebito = @IdLoteDebito WHERE IdFactura IN @IdsFacturas;";
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdLoteDebito = idLoteDebito, IdsFacturas = idsFacturas }, transaction);
|
||||
return rowsAffected == idsFacturas.Count();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo)
|
||||
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(
|
||||
string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT f.*, s.NombreCompleto AS NombreSuscriptor, p.Nombre AS NombrePublicacion
|
||||
var sqlBuilder = new StringBuilder(@"
|
||||
WITH FacturaConEmpresa AS (
|
||||
-- Esta subconsulta obtiene el IdEmpresa para cada factura basándose en la primera suscripción que encuentra en sus detalles.
|
||||
-- Esto es seguro porque nuestra lógica de negocio asegura que todos los detalles de una factura pertenecen a la misma empresa.
|
||||
SELECT
|
||||
f.IdFactura,
|
||||
(SELECT TOP 1 p.Id_Empresa
|
||||
FROM dbo.susc_FacturaDetalles fd
|
||||
JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion
|
||||
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
|
||||
WHERE fd.IdFactura = f.IdFactura) AS IdEmpresa
|
||||
FROM dbo.susc_Facturas f
|
||||
JOIN dbo.susc_Suscripciones sc ON f.IdSuscripcion = sc.IdSuscripcion
|
||||
JOIN dbo.susc_Suscriptores s ON sc.IdSuscriptor = s.IdSuscriptor
|
||||
JOIN dbo.dist_dtPublicaciones p ON sc.IdPublicacion = p.Id_Publicacion
|
||||
WHERE f.Periodo = @Periodo
|
||||
ORDER BY s.NombreCompleto;
|
||||
";
|
||||
)
|
||||
SELECT
|
||||
f.*,
|
||||
s.NombreCompleto AS NombreSuscriptor,
|
||||
fce.IdEmpresa,
|
||||
(SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos pg WHERE pg.IdFactura = f.IdFactura AND pg.Estado = 'Aprobado') AS TotalPagado
|
||||
FROM dbo.susc_Facturas f
|
||||
JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor
|
||||
JOIN FacturaConEmpresa fce ON f.IdFactura = fce.IdFactura
|
||||
WHERE f.Periodo = @Periodo");
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("Periodo", periodo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(nombreSuscriptor))
|
||||
{
|
||||
sqlBuilder.Append(" AND s.NombreCompleto LIKE @NombreSuscriptor");
|
||||
parameters.Add("NombreSuscriptor", $"%{nombreSuscriptor}%");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(estadoPago))
|
||||
{
|
||||
sqlBuilder.Append(" AND f.EstadoPago = @EstadoPago");
|
||||
parameters.Add("EstadoPago", estadoPago);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(estadoFacturacion))
|
||||
{
|
||||
sqlBuilder.Append(" AND f.EstadoFacturacion = @EstadoFacturacion");
|
||||
parameters.Add("EstadoFacturacion", estadoFacturacion);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tipoFactura))
|
||||
{
|
||||
sqlBuilder.Append(" AND f.TipoFactura = @TipoFactura");
|
||||
parameters.Add("TipoFactura", tipoFactura);
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;");
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
var result = await connection.QueryAsync<Factura, string, string, (Factura, string, string)>(
|
||||
sql,
|
||||
(factura, suscriptor, publicacion) => (factura, suscriptor, publicacion),
|
||||
new { Periodo = periodo },
|
||||
splitOn: "NombreSuscriptor,NombrePublicacion"
|
||||
var result = await connection.QueryAsync<Factura, string, int, decimal, (Factura, string, int, decimal)>(
|
||||
sqlBuilder.ToString(),
|
||||
(factura, suscriptor, idEmpresa, totalPagado) => (factura, suscriptor, idEmpresa, totalPagado),
|
||||
parameters,
|
||||
splitOn: "NombreSuscriptor,IdEmpresa,TotalPagado"
|
||||
);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener facturas enriquecidas para el período {Periodo}", periodo);
|
||||
return Enumerable.Empty<(Factura, string, string)>();
|
||||
return Enumerable.Empty<(Factura, string, int, decimal)>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction)
|
||||
public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction)
|
||||
{
|
||||
if (transaction == null || transaction.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
|
||||
const string sql = @"
|
||||
UPDATE dbo.susc_Facturas SET
|
||||
Estado = @NuevoEstado,
|
||||
EstadoPago = @NuevoEstadoPago,
|
||||
MotivoRechazo = @MotivoRechazo
|
||||
WHERE IdFactura = @IdFactura;";
|
||||
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(
|
||||
sql,
|
||||
new { NuevoEstado = nuevoEstado, MotivoRechazo = motivoRechazo, IdFactura = idFactura },
|
||||
transaction
|
||||
);
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, MotivoRechazo = motivoRechazo, idFactura }, transaction);
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
public async Task<string?> GetUltimoPeriodoFacturadoAsync()
|
||||
{
|
||||
const string sql = "SELECT TOP 1 Periodo FROM dbo.susc_Facturas ORDER BY Periodo DESC;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<string>(sql);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<(Factura Factura, string NombreEmpresa)>> GetFacturasConEmpresaAsync(int idSuscriptor, string periodo)
|
||||
{
|
||||
// Esta consulta es más robusta y eficiente. Obtiene la factura y el nombre de la empresa en una sola llamada.
|
||||
const string sql = @"
|
||||
SELECT f.*, e.Nombre AS NombreEmpresa
|
||||
FROM dbo.susc_Facturas f
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 emp.Nombre
|
||||
FROM dbo.susc_FacturaDetalles fd
|
||||
JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion
|
||||
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
|
||||
JOIN dbo.dist_dtEmpresas emp ON p.Id_Empresa = emp.Id_Empresa
|
||||
WHERE fd.IdFactura = f.IdFactura
|
||||
) e
|
||||
WHERE f.IdSuscriptor = @IdSuscriptor AND f.Periodo = @Periodo;";
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
var result = await connection.QueryAsync<Factura, string, (Factura, string)>(
|
||||
sql,
|
||||
(factura, nombreEmpresa) => (factura, nombreEmpresa ?? "N/A"), // Asignamos "N/A" si no encuentra empresa
|
||||
new { IdSuscriptor = idSuscriptor, Periodo = periodo },
|
||||
splitOn: "NombreEmpresa"
|
||||
);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener facturas con empresa para suscriptor {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo);
|
||||
return Enumerable.Empty<(Factura, string)>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo)
|
||||
{
|
||||
// Consulta simplificada pero robusta.
|
||||
const string sql = @"
|
||||
SELECT * FROM dbo.susc_Facturas
|
||||
WHERE Periodo = @Periodo
|
||||
AND EstadoPago = 'Pagada'
|
||||
AND EstadoFacturacion = 'Pendiente de Facturar';";
|
||||
try
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<Factura>(sql, new { Periodo = periodo });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener facturas pagadas pendientes de facturar para el período {Periodo}", periodo);
|
||||
return Enumerable.Empty<Factura>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids)
|
||||
{
|
||||
if (ids == null || !ids.Any())
|
||||
{
|
||||
return Enumerable.Empty<Factura>();
|
||||
}
|
||||
const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura IN @Ids;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<Factura>(sql, new { Ids = ids });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Archivo: GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs
|
||||
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
public interface IAjusteRepository
|
||||
{
|
||||
Task<Ajuste?> GetByIdAsync(int idAjuste);
|
||||
Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction);
|
||||
Task<bool> UpdateAsync(Ajuste ajuste, IDbTransaction transaction);
|
||||
Task<bool> AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction);
|
||||
Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta);
|
||||
Task<IEnumerable<Ajuste>> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, int idEmpresa, DateTime fechaHasta, IDbTransaction transaction);
|
||||
Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction);
|
||||
Task<IEnumerable<Ajuste>> GetAjustesPorIdFacturaAsync(int idFactura);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Data;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
public interface IFacturaDetalleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Crea un nuevo registro de detalle de factura.
|
||||
/// </summary>
|
||||
Task<FacturaDetalle?> CreateAsync(FacturaDetalle nuevoDetalle, IDbTransaction transaction);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene todos los detalles de una factura específica.
|
||||
/// </summary>
|
||||
Task<IEnumerable<FacturaDetalle>> GetDetallesPorFacturaIdAsync(int idFactura);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene de forma eficiente todos los detalles de todas las facturas de un período específico.
|
||||
/// </summary>
|
||||
Task<IEnumerable<FacturaDetalle>> GetDetallesPorPeriodoAsync(string periodo);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,19 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
public interface IFacturaRepository
|
||||
{
|
||||
Task<Factura?> GetByIdAsync(int idFactura);
|
||||
Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids);
|
||||
Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo);
|
||||
Task<Factura?> GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction);
|
||||
Task<Factura?> GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction);
|
||||
Task<IEnumerable<Factura>> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo);
|
||||
Task<IEnumerable<(Factura Factura, string NombreEmpresa)>> GetFacturasConEmpresaAsync(int idSuscriptor, string periodo);
|
||||
Task<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction);
|
||||
Task<bool> UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction);
|
||||
Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction);
|
||||
Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction);
|
||||
Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction);
|
||||
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo);
|
||||
Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction);
|
||||
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<string?> GetUltimoPeriodoFacturadoAsync();
|
||||
Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo);
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
Task<IEnumerable<Pago>> GetByFacturaIdAsync(int idFactura);
|
||||
Task<Pago?> CreateAsync(Pago nuevoPago, IDbTransaction transaction);
|
||||
Task<decimal> GetTotalPagadoAprobadoAsync(int idFactura, IDbTransaction transaction);
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction);
|
||||
Task<bool> UpdateAsync(Promocion promocion, IDbTransaction transaction);
|
||||
Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction);
|
||||
Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo);
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,13 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
public interface ISuscripcionRepository
|
||||
{
|
||||
Task<IEnumerable<Suscripcion>> GetBySuscriptorIdAsync(int idSuscriptor);
|
||||
Task<Suscripcion?> GetByIdAsync(int idSuscripcion);
|
||||
Task<IEnumerable<Suscripcion>> GetBySuscriptorIdAsync(int idSuscriptor);
|
||||
Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction);
|
||||
Task<Suscripcion?> CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction);
|
||||
Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction);
|
||||
Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction);
|
||||
Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion);
|
||||
Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction);
|
||||
Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion);
|
||||
Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction);
|
||||
Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction);
|
||||
}
|
||||
}
|
||||
@@ -54,5 +54,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> GetTotalPagadoAprobadoAsync(int idFactura, IDbTransaction transaction)
|
||||
{
|
||||
if (transaction == null || transaction.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sql = "SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos WHERE IdFactura = @IdFactura AND Estado = 'Aprobado';";
|
||||
return await transaction.Connection.ExecuteScalarAsync<decimal>(sql, new { idFactura }, transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT INTO dbo.susc_Promociones (Descripcion, TipoPromocion, Valor, FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta)
|
||||
INSERT INTO dbo.susc_Promociones
|
||||
(Descripcion, TipoEfecto, ValorEfecto, TipoCondicion, ValorCondicion,
|
||||
FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES (@Descripcion, @TipoPromocion, @Valor, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());";
|
||||
|
||||
VALUES (@Descripcion, @TipoEfecto, @ValorEfecto, @TipoCondicion,
|
||||
@ValorCondicion, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());";
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
@@ -74,20 +76,43 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
|
||||
public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction)
|
||||
{
|
||||
// Esta consulta ahora es más compleja para respetar ambas vigencias.
|
||||
const string sql = @"
|
||||
SELECT p.* FROM dbo.susc_Promociones p
|
||||
SELECT p.*
|
||||
FROM dbo.susc_Promociones p
|
||||
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
|
||||
WHERE sp.IdSuscripcion = @IdSuscripcion
|
||||
AND p.Activa = 1
|
||||
-- 1. La promoción general debe estar activa en el período
|
||||
AND p.FechaInicio <= @FechaPeriodo
|
||||
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo);";
|
||||
|
||||
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo)
|
||||
-- 2. La asignación específica al cliente debe estar activa en el período
|
||||
AND sp.VigenciaDesde <= @FechaPeriodo
|
||||
AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);";
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
|
||||
return await transaction.Connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion, FechaPeriodo = fechaPeriodo }, transaction);
|
||||
}
|
||||
|
||||
// Versión SIN transacción, para solo lectura
|
||||
public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT p.*
|
||||
FROM dbo.susc_Promociones p
|
||||
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
|
||||
WHERE sp.IdSuscripcion = @IdSuscripcion
|
||||
AND p.Activa = 1
|
||||
-- 1. La promoción general debe estar activa en el período
|
||||
AND p.FechaInicio <= @FechaPeriodo
|
||||
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo)
|
||||
-- 2. La asignación específica al cliente debe estar activa en el período
|
||||
AND sp.VigenciaDesde <= @FechaPeriodo
|
||||
AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<Promocion>(sql, new { idSuscripcion, FechaPeriodo = fechaPeriodo });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
|
||||
public async Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction)
|
||||
{
|
||||
// Lógica para determinar el rango del período (ej. '2023-11')
|
||||
var year = int.Parse(periodo.Split('-')[0]);
|
||||
var month = int.Parse(periodo.Split('-')[1]);
|
||||
var primerDiaMes = new DateTime(year, month, 1);
|
||||
@@ -112,30 +111,35 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion)
|
||||
public async Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT p.* FROM dbo.susc_Promociones p
|
||||
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
|
||||
SELECT sp.*, p.*
|
||||
FROM dbo.susc_SuscripcionPromociones sp
|
||||
JOIN dbo.susc_Promociones p ON sp.IdPromocion = p.IdPromocion
|
||||
WHERE sp.IdSuscripcion = @IdSuscripcion;";
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion });
|
||||
var result = await connection.QueryAsync<SuscripcionPromocion, Promocion, (SuscripcionPromocion, Promocion)>(
|
||||
sql,
|
||||
(asignacion, promocion) => (asignacion, promocion),
|
||||
new { IdSuscripcion = idSuscripcion },
|
||||
splitOn: "IdPromocion"
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction)
|
||||
public async Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction)
|
||||
{
|
||||
if (transaction == null || transaction.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sql = @"
|
||||
INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno)
|
||||
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuario);";
|
||||
INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno, VigenciaDesde, VigenciaHasta, FechaAsignacion)
|
||||
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());";
|
||||
|
||||
await transaction.Connection.ExecuteAsync(sql,
|
||||
new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion, IdUsuario = idUsuario },
|
||||
transaction);
|
||||
await transaction.Connection.ExecuteAsync(sql, asignacion, transaction);
|
||||
}
|
||||
|
||||
public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction)
|
||||
@@ -145,7 +149,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sql = "DELETE FROM dbo.susc_SuscripcionPromociones WHERE IdSuscripcion = @IdSuscripcion AND IdPromocion = @IdPromocion;";
|
||||
var rows = await transaction.Connection.ExecuteAsync(sql, new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion }, transaction);
|
||||
var rows = await transaction.Connection.ExecuteAsync(sql, new { idSuscripcion, idPromocion }, transaction);
|
||||
return rows == 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Archivo: GestionIntegral.Api/Data/Repositories/Usuarios/IUsuarioRepository.cs
|
||||
|
||||
using GestionIntegral.Api.Models.Usuarios; // Para Usuario
|
||||
using GestionIntegral.Api.Dtos.Usuarios.Auditoria;
|
||||
using System.Collections.Generic;
|
||||
@@ -10,6 +12,7 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
|
||||
{
|
||||
Task<IEnumerable<Usuario>> GetAllAsync(string? userFilter, string? nombreFilter);
|
||||
Task<Usuario?> GetByIdAsync(int id);
|
||||
Task<IEnumerable<Usuario>> GetByIdsAsync(IEnumerable<int> ids);
|
||||
Task<Usuario?> GetByUsernameAsync(string username); // Ya existe en IAuthRepository, pero lo duplicamos para cohesión del CRUD
|
||||
Task<Usuario?> CreateAsync(Usuario nuevoUsuario, int idUsuarioCreador, IDbTransaction transaction);
|
||||
Task<bool> UpdateAsync(Usuario usuarioAActualizar, int idUsuarioModificador, IDbTransaction transaction);
|
||||
@@ -17,7 +20,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
|
||||
// Task<bool> DeleteAsync(int id, int idUsuarioModificador, IDbTransaction transaction);
|
||||
Task<bool> SetPasswordAsync(int userId, string newHash, string newSalt, bool debeCambiarClave, int idUsuarioModificador, IDbTransaction transaction);
|
||||
Task<bool> UserExistsAsync(string username, int? excludeId = null);
|
||||
// Para el DTO de listado
|
||||
Task<IEnumerable<(Usuario Usuario, string NombrePerfil)>> GetAllWithProfileNameAsync(string? userFilter, string? nombreFilter);
|
||||
Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id);
|
||||
Task<IEnumerable<UsuarioHistorialDto>> GetHistorialByUsuarioIdAsync(int idUsuarioAfectado, DateTime? fechaDesde, DateTime? fechaHasta);
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
using Dapper;
|
||||
using GestionIntegral.Api.Models.Usuarios;
|
||||
using GestionIntegral.Api.Dtos.Usuarios.Auditoria;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Usuarios
|
||||
{
|
||||
@@ -88,7 +84,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<Usuario?> GetByIdAsync(int id)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id = @Id";
|
||||
@@ -103,6 +98,33 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Usuario>> GetByIdsAsync(IEnumerable<int> ids)
|
||||
{
|
||||
// 1. Validar si la lista de IDs está vacía para evitar una consulta innecesaria a la BD.
|
||||
if (ids == null || !ids.Any())
|
||||
{
|
||||
return Enumerable.Empty<Usuario>();
|
||||
}
|
||||
|
||||
// 2. Definir la consulta. Dapper manejará la expansión de la cláusula IN de forma segura.
|
||||
const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id IN @Ids";
|
||||
|
||||
try
|
||||
{
|
||||
// 3. Crear conexión y ejecutar la consulta.
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
// 4. Pasar la colección de IDs como parámetro. El nombre 'Ids' debe coincidir con el placeholder '@Ids'.
|
||||
return await connection.QueryAsync<Usuario>(sql, new { Ids = ids });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 5. Registrar el error y devolver una lista vacía en caso de fallo para no romper la aplicación.
|
||||
_logger.LogError(ex, "Error al obtener Usuarios por lista de IDs.");
|
||||
return Enumerable.Empty<Usuario>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id)
|
||||
{
|
||||
const string sql = @"
|
||||
@@ -128,7 +150,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<Usuario?> GetByUsernameAsync(string username)
|
||||
{
|
||||
// Esta es la misma que en AuthRepository, si se unifican, se puede eliminar una.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace GestionIntegral.Api.Models.Comunicaciones
|
||||
{
|
||||
public class EmailLog
|
||||
{
|
||||
public int IdEmailLog { get; set; }
|
||||
public DateTime FechaEnvio { get; set; }
|
||||
public string DestinatarioEmail { get; set; } = string.Empty;
|
||||
public string Asunto { get; set; } = string.Empty;
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public string? Error { get; set; }
|
||||
public int? IdUsuarioDisparo { get; set; }
|
||||
public string? Origen { get; set; }
|
||||
public string? ReferenciaId { get; set; }
|
||||
public int? IdLoteDeEnvio { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace GestionIntegral.Api.Models.Comunicaciones
|
||||
{
|
||||
public class LoteDeEnvio
|
||||
{
|
||||
public int IdLoteDeEnvio { get; set; }
|
||||
public DateTime FechaInicio { get; set; }
|
||||
public DateTime? FechaFin { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public string Origen { get; set; } = string.Empty;
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public int TotalCorreos { get; set; }
|
||||
public int TotalEnviados { get; set; }
|
||||
public int TotalFallidos { get; set; }
|
||||
public int IdUsuarioDisparo { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace GestionIntegral.Api.Dtos.Comunicaciones
|
||||
{
|
||||
/// <summary>
|
||||
/// Representa un registro de historial de envío de correo para ser mostrado en la interfaz de usuario.
|
||||
/// </summary>
|
||||
public class EmailLogDto
|
||||
{
|
||||
public DateTime FechaEnvio { get; set; }
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public string Asunto { get; set; } = string.Empty;
|
||||
public string DestinatarioEmail { get; set; } = string.Empty;
|
||||
public string? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Nombre del usuario que inició la acción de envío (ej. "Juan Pérez").
|
||||
/// Puede ser "Sistema" si el envío fue automático (ej. Cierre Mensual).
|
||||
/// </summary>
|
||||
public string? NombreUsuarioDisparo { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace GestionIntegral.Api.Dtos.Comunicaciones
|
||||
{
|
||||
// DTO para el feedback inmediato
|
||||
public class LoteDeEnvioResumenDto
|
||||
{
|
||||
public int IdLoteDeEnvio { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public int TotalCorreos { get; set; }
|
||||
public int TotalEnviados { get; set; }
|
||||
public int TotalFallidos { get; set; }
|
||||
public List<EmailLogDto> ErroresDetallados { get; set; } = new();
|
||||
}
|
||||
|
||||
// DTO para la tabla de historial
|
||||
public class LoteDeEnvioHistorialDto
|
||||
{
|
||||
public int IdLoteDeEnvio { get; set; }
|
||||
public DateTime FechaInicio { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public int TotalCorreos { get; set; }
|
||||
public int TotalEnviados { get; set; }
|
||||
public int TotalFallidos { get; set; }
|
||||
public string NombreUsuarioDisparo { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
|
||||
public class LoteDeEnvioResumenDto
|
||||
{
|
||||
public int IdLoteDeEnvio { get; set; }
|
||||
public required string Periodo { get; set; }
|
||||
public int TotalCorreos { get; set; }
|
||||
public int TotalEnviados { get; set; }
|
||||
public int TotalFallidos { get; set; }
|
||||
public List<EmailLogDto> ErroresDetallados { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace GestionIntegral.Api.Dtos.Reportes
|
||||
{
|
||||
public class DistribucionSuscripcionDto
|
||||
{
|
||||
public string NombreEmpresa { get; set; } = string.Empty;
|
||||
public string NombrePublicacion { get; set; } = string.Empty;
|
||||
public string NombreSuscriptor { get; set; } = string.Empty;
|
||||
public string Direccion { get; set; } = string.Empty;
|
||||
public string? Telefono { get; set; }
|
||||
public DateTime FechaInicio { get; set; }
|
||||
public DateTime? FechaFin { get; set; }
|
||||
public string DiasEntrega { get; set; } = string.Empty;
|
||||
public string? Observaciones { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace GestionIntegral.Api.Dtos.Reportes
|
||||
{
|
||||
public class FacturasParaReporteDto
|
||||
{
|
||||
public int IdFactura { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public string NombreSuscriptor { get; set; } = string.Empty;
|
||||
public string TipoDocumento { get; set; } = string.Empty;
|
||||
public string NroDocumento { get; set; } = string.Empty;
|
||||
public decimal ImporteFinal { get; set; }
|
||||
public int IdEmpresa { get; set; }
|
||||
public string NombreEmpresa { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// Representa una agrupación de suscripciones por publicación para el reporte.
|
||||
/// </summary>
|
||||
public class GrupoPublicacion
|
||||
{
|
||||
public string NombrePublicacion { get; set; } = string.Empty;
|
||||
public IEnumerable<DistribucionSuscripcionDto> Suscripciones { get; set; } = Enumerable.Empty<DistribucionSuscripcionDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Representa una agrupación de publicaciones por empresa para el reporte.
|
||||
/// </summary>
|
||||
public class GrupoEmpresa
|
||||
{
|
||||
public string NombreEmpresa { get; set; } = string.Empty;
|
||||
public IEnumerable<GrupoPublicacion> Publicaciones { get; set; } = Enumerable.Empty<GrupoPublicacion>();
|
||||
}
|
||||
|
||||
public class DistribucionSuscripcionesViewModel
|
||||
{
|
||||
public IEnumerable<GrupoEmpresa> DatosAgrupadosAltas { get; }
|
||||
public IEnumerable<GrupoEmpresa> DatosAgrupadosBajas { get; }
|
||||
public string FechaDesde { get; set; } = string.Empty;
|
||||
public string FechaHasta { get; set; } = string.Empty;
|
||||
public string FechaGeneracion { get; set; } = string.Empty;
|
||||
|
||||
public DistribucionSuscripcionesViewModel(IEnumerable<DistribucionSuscripcionDto> altas, IEnumerable<DistribucionSuscripcionDto> bajas)
|
||||
{
|
||||
// Función local para evitar repetir el código de agrupación
|
||||
Func<IEnumerable<DistribucionSuscripcionDto>, IEnumerable<GrupoEmpresa>> agruparDatos = (suscripciones) =>
|
||||
{
|
||||
return suscripciones
|
||||
.GroupBy(s => s.NombreEmpresa)
|
||||
.Select(gEmpresa => new GrupoEmpresa
|
||||
{
|
||||
NombreEmpresa = gEmpresa.Key,
|
||||
Publicaciones = gEmpresa
|
||||
.GroupBy(s => s.NombrePublicacion)
|
||||
.Select(gPub => new GrupoPublicacion
|
||||
{
|
||||
NombrePublicacion = gPub.Key,
|
||||
Suscripciones = gPub.OrderBy(s => s.NombreSuscriptor).ToList()
|
||||
})
|
||||
.OrderBy(p => p.NombrePublicacion)
|
||||
})
|
||||
.OrderBy(e => e.NombreEmpresa);
|
||||
};
|
||||
|
||||
DatosAgrupadosAltas = agruparDatos(altas);
|
||||
DatosAgrupadosBajas = agruparDatos(bajas);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
|
||||
{
|
||||
// Esta clase anidada representará los datos de una empresa
|
||||
public class DatosEmpresaViewModel
|
||||
{
|
||||
public string NombreEmpresa { get; set; } = string.Empty;
|
||||
public IEnumerable<FacturasParaReporteDto> Facturas { get; set; } = new List<FacturasParaReporteDto>();
|
||||
public decimal TotalEmpresa => Facturas.Sum(f => f.ImporteFinal);
|
||||
}
|
||||
|
||||
public class FacturasPublicidadViewModel
|
||||
{
|
||||
public IEnumerable<DatosEmpresaViewModel> DatosPorEmpresa { get; set; } = new List<DatosEmpresaViewModel>();
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public string FechaGeneracion { get; set; } = string.Empty;
|
||||
public decimal TotalGeneral => DatosPorEmpresa.Sum(e => e.TotalEmpresa);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class AjusteDto
|
||||
{
|
||||
public int IdAjuste { get; set; }
|
||||
public int IdSuscriptor { get; set; }
|
||||
public int IdEmpresa { get; set; }
|
||||
public string? NombreEmpresa { get; set; }
|
||||
public string FechaAjuste { get; set; } = string.Empty;
|
||||
public string TipoAjuste { get; set; } = string.Empty;
|
||||
public decimal Monto { get; set; }
|
||||
public string Motivo { get; set; } = string.Empty;
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public int? IdFacturaAplicado { get; set; }
|
||||
public string? NumeroFacturaAplicado { get; set; }
|
||||
public string FechaAlta { get; set; } = string.Empty;
|
||||
public string NombreUsuarioAlta { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class AsignarPromocionDto
|
||||
{
|
||||
[Required]
|
||||
public int IdPromocion { get; set; }
|
||||
[Required]
|
||||
public DateTime VigenciaDesde { get; set; }
|
||||
public DateTime? VigenciaHasta { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class CreateAjusteDto
|
||||
{
|
||||
[Required]
|
||||
public int IdSuscriptor { get; set; }
|
||||
|
||||
[Required]
|
||||
public int IdEmpresa { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime FechaAjuste { get; set; }
|
||||
|
||||
[Required]
|
||||
[RegularExpression("^(Credito|Debito)$", ErrorMessage = "El tipo de ajuste debe ser 'Credito' o 'Debito'.")]
|
||||
public string TipoAjuste { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Range(0.01, 999999.99, ErrorMessage = "El monto debe ser un valor positivo.")]
|
||||
public decimal Monto { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "El motivo es obligatorio.")]
|
||||
[StringLength(250)]
|
||||
public string Motivo { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -7,22 +7,25 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class CreatePromocionDto
|
||||
{
|
||||
[Required(ErrorMessage = "La descripción es obligatoria.")]
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Descripcion { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "El tipo de promoción es obligatorio.")]
|
||||
public string TipoPromocion { get; set; } = string.Empty;
|
||||
[Required]
|
||||
public string TipoEfecto { get; set; } = string.Empty; // Corregido
|
||||
|
||||
[Required(ErrorMessage = "El valor es obligatorio.")]
|
||||
[Range(0.01, 99999999.99, ErrorMessage = "El valor debe ser positivo.")]
|
||||
public decimal Valor { get; set; }
|
||||
[Required]
|
||||
[Range(0, 99999999.99)] // Se permite 0 para bonificaciones
|
||||
public decimal ValorEfecto { get; set; } // Corregido
|
||||
|
||||
[Required(ErrorMessage = "La fecha de inicio es obligatoria.")]
|
||||
[Required]
|
||||
public string TipoCondicion { get; set; } = string.Empty;
|
||||
|
||||
public int? ValorCondicion { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime FechaInicio { get; set; }
|
||||
|
||||
public DateTime? FechaFin { get; set; }
|
||||
|
||||
public bool Activa { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Archivo: GestionIntegral.Api/Dtos/Suscripciones/CreateSuscriptorDto.cs
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
@@ -13,6 +15,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
public string? Email { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[RegularExpression(@"^[0-9\s\+\-\(\)]*$", ErrorMessage = "El teléfono solo puede contener números y los símbolos +, -, () y espacios.")]
|
||||
public string? Telefono { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "La dirección es obligatoria.")]
|
||||
@@ -25,9 +28,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
|
||||
[Required(ErrorMessage = "El número de documento es obligatorio.")]
|
||||
[StringLength(11)]
|
||||
[RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")]
|
||||
public string NroDocumento { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")]
|
||||
[RegularExpression("^[0-9]*$", ErrorMessage = "El CBU solo puede contener números.")]
|
||||
public string? CBU { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "La forma de pago es obligatoria.")]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class FacturaConsolidadaDto
|
||||
{
|
||||
public int IdFactura { get; set; }
|
||||
public string NombreEmpresa { get; set; } = string.Empty;
|
||||
public decimal ImporteFinal { get; set; }
|
||||
public string EstadoPago { get; set; } = string.Empty;
|
||||
public string EstadoFacturacion { get; set; } = string.Empty;
|
||||
public string? NumeroFactura { get; set; }
|
||||
public 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>();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO para enviar la información de una factura generada al frontend.
|
||||
/// Incluye datos enriquecidos como nombres para facilitar su visualización en la UI.
|
||||
/// </summary>
|
||||
public class FacturaDetalleDto
|
||||
{
|
||||
public string Descripcion { get; set; } = string.Empty;
|
||||
public decimal ImporteNeto { get; set; }
|
||||
}
|
||||
|
||||
public class FacturaDto
|
||||
{
|
||||
public int IdFactura { get; set; }
|
||||
public int IdSuscripcion { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty; // Formato "YYYY-MM"
|
||||
public string FechaEmision { get; set; } = string.Empty; // Formato "yyyy-MM-dd"
|
||||
public string FechaVencimiento { get; set; } = string.Empty; // Formato "yyyy-MM-dd"
|
||||
public int IdSuscriptor { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public string FechaEmision { get; set; } = string.Empty;
|
||||
public string FechaVencimiento { get; set; } = string.Empty;
|
||||
public decimal ImporteFinal { get; set; }
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public decimal TotalPagado { get; set; }
|
||||
public decimal SaldoPendiente { get; set; }
|
||||
public string EstadoPago { get; set; } = string.Empty;
|
||||
public string EstadoFacturacion { get; set; } = string.Empty;
|
||||
public string? NumeroFactura { get; set; }
|
||||
|
||||
// Datos enriquecidos para la UI, poblados por el servicio
|
||||
public string NombreSuscriptor { get; set; } = string.Empty;
|
||||
public string NombrePublicacion { get; set; } = string.Empty;
|
||||
public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class PromocionAsignadaDto : PromocionDto
|
||||
{
|
||||
public string VigenciaDesdeAsignacion { get; set; } = string.Empty;
|
||||
public string? VigenciaHastaAsignacion { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public int IdPromocion { get; set; }
|
||||
public string Descripcion { get; set; } = string.Empty;
|
||||
public string TipoPromocion { get; set; } = string.Empty;
|
||||
public decimal Valor { get; set; }
|
||||
public string FechaInicio { get; set; } = string.Empty; // yyyy-MM-dd
|
||||
public string TipoEfecto { get; set; } = string.Empty;
|
||||
public decimal ValorEfecto { get; set; }
|
||||
public string TipoCondicion { get; set; } = string.Empty;
|
||||
public int? ValorCondicion { get; set; }
|
||||
public string FechaInicio { get; set; } = string.Empty;
|
||||
public string? FechaFin { get; set; }
|
||||
public bool Activa { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class ResumenCuentaSuscriptorDto
|
||||
{
|
||||
public int IdSuscriptor { get; set; }
|
||||
public string NombreSuscriptor { get; set; } = string.Empty;
|
||||
public decimal SaldoPendienteTotal { get; set; }
|
||||
public decimal ImporteTotal { get; set; }
|
||||
public List<FacturaConsolidadaDto> Facturas { get; set; } = new List<FacturaConsolidadaDto>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones;
|
||||
public class UpdateAjusteDto
|
||||
{
|
||||
[Required]
|
||||
public int IdEmpresa { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime FechaAjuste { get; set; }
|
||||
|
||||
[Required]
|
||||
[RegularExpression("^(Credito|Debito)$")]
|
||||
public string TipoAjuste { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Range(0.01, 999999.99)]
|
||||
public decimal Monto { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(250)]
|
||||
public string Motivo { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
// Es idéntico al CreateDto, pero se mantiene separado por si las reglas de validación cambian.
|
||||
public class UpdateSuscriptorDto
|
||||
{
|
||||
[Required(ErrorMessage = "El nombre completo es obligatorio.")]
|
||||
@@ -14,6 +13,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
public string? Email { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[RegularExpression(@"^[0-9\s\+\-\(\)]*$", ErrorMessage = "El teléfono solo puede contener números y los símbolos +, -, () y espacios.")]
|
||||
public string? Telefono { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "La dirección es obligatoria.")]
|
||||
@@ -26,9 +26,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
|
||||
[Required(ErrorMessage = "El número de documento es obligatorio.")]
|
||||
[StringLength(11)]
|
||||
[RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")]
|
||||
public string NroDocumento { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")]
|
||||
[RegularExpression("^[0-9]*$", ErrorMessage = "El CBU solo puede contener números.")]
|
||||
public string? CBU { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "La forma de pago es obligatoria.")]
|
||||
|
||||
19
Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs
Normal file
19
Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace GestionIntegral.Api.Models.Suscripciones
|
||||
{
|
||||
public class Ajuste
|
||||
{
|
||||
public int IdAjuste { get; set; }
|
||||
public int IdSuscriptor { get; set; }
|
||||
public int IdEmpresa { get; set; }
|
||||
public DateTime FechaAjuste { get; set; }
|
||||
public string TipoAjuste { get; set; } = string.Empty;
|
||||
public decimal Monto { get; set; }
|
||||
public string Motivo { get; set; } = string.Empty;
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public int? IdFacturaAplicado { get; set; }
|
||||
public int IdUsuarioAlta { get; set; }
|
||||
public DateTime FechaAlta { get; set; }
|
||||
public int? IdUsuarioAnulo { get; set; }
|
||||
public DateTime? FechaAnulacion { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,18 @@ namespace GestionIntegral.Api.Models.Suscripciones
|
||||
public class Factura
|
||||
{
|
||||
public int IdFactura { get; set; }
|
||||
public int IdSuscripcion { get; set; }
|
||||
public int IdSuscriptor { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public DateTime FechaEmision { get; set; }
|
||||
public DateTime FechaVencimiento { get; set; }
|
||||
public decimal ImporteBruto { get; set; }
|
||||
public decimal DescuentoAplicado { get; set; }
|
||||
public decimal ImporteFinal { get; set; }
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public string EstadoPago { get; set; } = string.Empty;
|
||||
public string EstadoFacturacion { get; set; } = string.Empty;
|
||||
public string? NumeroFactura { get; set; }
|
||||
public int? IdLoteDebito { get; set; }
|
||||
public string? MotivoRechazo { get; set; }
|
||||
public string TipoFactura { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public class FacturaDetalle {
|
||||
public int IdFacturaDetalle { get; set; }
|
||||
public int IdFactura { get; set; }
|
||||
public int IdSuscripcion { get; set; }
|
||||
public string Descripcion { get; set; } = string.Empty;
|
||||
public decimal ImporteBruto { get; set; }
|
||||
public decimal DescuentoAplicado { get; set; }
|
||||
public decimal ImporteNeto { get; set; }
|
||||
}
|
||||
@@ -4,8 +4,10 @@ namespace GestionIntegral.Api.Models.Suscripciones
|
||||
{
|
||||
public int IdPromocion { get; set; }
|
||||
public string Descripcion { get; set; } = string.Empty;
|
||||
public string TipoPromocion { get; set; } = string.Empty;
|
||||
public decimal Valor { get; set; }
|
||||
public string TipoEfecto { get; set; } = string.Empty; // Nuevo nombre
|
||||
public decimal ValorEfecto { get; set; } // Nuevo nombre
|
||||
public string TipoCondicion { get; set; } = string.Empty; // Nueva propiedad
|
||||
public int? ValorCondicion { get; set; } // Nueva propiedad (nullable)
|
||||
public DateTime FechaInicio { get; set; }
|
||||
public DateTime? FechaFin { get; set; }
|
||||
public bool Activa { get; set; }
|
||||
|
||||
@@ -6,5 +6,7 @@ namespace GestionIntegral.Api.Models.Suscripciones
|
||||
public int IdPromocion { get; set; }
|
||||
public DateTime FechaAsignacion { get; set; }
|
||||
public int IdUsuarioAsigno { get; set; }
|
||||
public DateTime VigenciaDesde { get; set; }
|
||||
public DateTime? VigenciaHasta { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Services.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -112,6 +113,8 @@ builder.Services.AddScoped<IFacturaRepository, FacturaRepository>();
|
||||
builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>();
|
||||
builder.Services.AddScoped<IPagoRepository, PagoRepository>();
|
||||
builder.Services.AddScoped<IPromocionRepository, PromocionRepository>();
|
||||
builder.Services.AddScoped<IAjusteRepository, AjusteRepository>();
|
||||
builder.Services.AddScoped<IFacturaDetalleRepository, FacturaDetalleRepository>();
|
||||
|
||||
builder.Services.AddScoped<IFormaPagoService, FormaPagoService>();
|
||||
builder.Services.AddScoped<ISuscriptorService, SuscriptorService>();
|
||||
@@ -120,10 +123,14 @@ builder.Services.AddScoped<IFacturacionService, FacturacionService>();
|
||||
builder.Services.AddScoped<IDebitoAutomaticoService, DebitoAutomaticoService>();
|
||||
builder.Services.AddScoped<IPagoService, PagoService>();
|
||||
builder.Services.AddScoped<IPromocionService, PromocionService>();
|
||||
builder.Services.AddScoped<IAjusteService, AjusteService>();
|
||||
|
||||
// --- Comunicaciones ---
|
||||
builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings"));
|
||||
builder.Services.AddTransient<IEmailService, EmailService>();
|
||||
builder.Services.AddScoped<IEmailLogRepository, EmailLogRepository>();
|
||||
builder.Services.AddScoped<IEmailLogService, EmailLogService>();
|
||||
builder.Services.AddScoped<ILoteDeEnvioRepository, LoteDeEnvioRepository>();
|
||||
|
||||
// --- SERVICIO DE HEALTH CHECKS ---
|
||||
// Añadimos una comprobación específica para SQL Server.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
|
||||
using GestionIntegral.Api.Data.Repositories.Usuarios;
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
{
|
||||
public class EmailLogService : IEmailLogService
|
||||
{
|
||||
private readonly IEmailLogRepository _emailLogRepository;
|
||||
private readonly IUsuarioRepository _usuarioRepository;
|
||||
|
||||
public EmailLogService(IEmailLogRepository emailLogRepository, IUsuarioRepository usuarioRepository)
|
||||
{
|
||||
_emailLogRepository = emailLogRepository;
|
||||
_usuarioRepository = usuarioRepository;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId)
|
||||
{
|
||||
var logs = await _emailLogRepository.GetByReferenceAsync(referenciaId);
|
||||
if (!logs.Any())
|
||||
{
|
||||
return Enumerable.Empty<EmailLogDto>();
|
||||
}
|
||||
|
||||
// Optimización N+1: Obtener todos los usuarios necesarios en una sola consulta
|
||||
var idsUsuarios = logs
|
||||
.Where(l => l.IdUsuarioDisparo.HasValue)
|
||||
.Select(l => l.IdUsuarioDisparo!.Value)
|
||||
.Distinct();
|
||||
|
||||
var usuariosDict = new Dictionary<int, string>();
|
||||
if (idsUsuarios.Any())
|
||||
{
|
||||
var usuarios = await _usuarioRepository.GetByIdsAsync(idsUsuarios);
|
||||
usuariosDict = usuarios.ToDictionary(u => u.Id, u => $"{u.Nombre} {u.Apellido}");
|
||||
}
|
||||
|
||||
// Mapear a DTO
|
||||
return logs.Select(log => new EmailLogDto
|
||||
{
|
||||
FechaEnvio = log.FechaEnvio,
|
||||
Estado = log.Estado,
|
||||
Asunto = log.Asunto,
|
||||
DestinatarioEmail = log.DestinatarioEmail,
|
||||
Error = log.Error,
|
||||
NombreUsuarioDisparo = log.IdUsuarioDisparo.HasValue
|
||||
? usuariosDict.GetValueOrDefault(log.IdUsuarioDisparo.Value, "Usuario Desconocido")
|
||||
: "Sistema"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio)
|
||||
{
|
||||
var logs = await _emailLogRepository.GetByLoteIdAsync(idLoteDeEnvio);
|
||||
if (!logs.Any())
|
||||
{
|
||||
return Enumerable.Empty<EmailLogDto>();
|
||||
}
|
||||
|
||||
// Reutilizamos la misma lógica de optimización N+1 que ya teníamos
|
||||
var idsUsuarios = logs
|
||||
.Where(l => l.IdUsuarioDisparo.HasValue)
|
||||
.Select(l => l.IdUsuarioDisparo!.Value)
|
||||
.Distinct();
|
||||
|
||||
var usuariosDict = new Dictionary<int, string>();
|
||||
if (idsUsuarios.Any())
|
||||
{
|
||||
var usuarios = await _usuarioRepository.GetByIdsAsync(idsUsuarios);
|
||||
usuariosDict = usuarios.ToDictionary(u => u.Id, u => $"{u.Nombre} {u.Apellido}");
|
||||
}
|
||||
|
||||
return logs.Select(log => new EmailLogDto
|
||||
{
|
||||
FechaEnvio = log.FechaEnvio,
|
||||
Estado = log.Estado,
|
||||
Asunto = log.Asunto,
|
||||
DestinatarioEmail = log.DestinatarioEmail,
|
||||
Error = log.Error,
|
||||
NombreUsuarioDisparo = log.IdUsuarioDisparo.HasValue
|
||||
? usuariosDict.GetValueOrDefault(log.IdUsuarioDisparo.Value, "Usuario Desconocido")
|
||||
: "Sistema"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
{
|
||||
@@ -10,14 +13,23 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
{
|
||||
private readonly MailSettings _mailSettings;
|
||||
private readonly ILogger<EmailService> _logger;
|
||||
private readonly IEmailLogRepository _emailLogRepository;
|
||||
|
||||
public EmailService(IOptions<MailSettings> mailSettings, ILogger<EmailService> logger)
|
||||
public EmailService(
|
||||
IOptions<MailSettings> mailSettings,
|
||||
ILogger<EmailService> logger,
|
||||
IEmailLogRepository emailLogRepository)
|
||||
{
|
||||
_mailSettings = mailSettings.Value;
|
||||
_logger = logger;
|
||||
_emailLogRepository = emailLogRepository;
|
||||
}
|
||||
|
||||
public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml)
|
||||
public async Task EnviarEmailAsync(
|
||||
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
|
||||
byte[]? attachment = null, string? attachmentName = null,
|
||||
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null,
|
||||
int? idLoteDeEnvio = null)
|
||||
{
|
||||
var email = new MimeMessage();
|
||||
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
|
||||
@@ -26,25 +38,112 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
email.Subject = asunto;
|
||||
|
||||
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
|
||||
if (attachment != null && !string.IsNullOrEmpty(attachmentName))
|
||||
{
|
||||
builder.Attachments.Add(attachmentName, attachment, ContentType.Parse("application/pdf"));
|
||||
}
|
||||
email.Body = builder.ToMessageBody();
|
||||
|
||||
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
|
||||
}
|
||||
|
||||
public async Task EnviarEmailConsolidadoAsync(
|
||||
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
|
||||
List<(byte[] content, string name)> adjuntos,
|
||||
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null,
|
||||
int? idLoteDeEnvio = null)
|
||||
{
|
||||
var email = new MimeMessage();
|
||||
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
|
||||
email.From.Add(email.Sender);
|
||||
email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail));
|
||||
email.Subject = asunto;
|
||||
|
||||
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
|
||||
if (adjuntos != null)
|
||||
{
|
||||
foreach (var adjunto in adjuntos)
|
||||
{
|
||||
builder.Attachments.Add(adjunto.name, adjunto.content, ContentType.Parse("application/pdf"));
|
||||
}
|
||||
}
|
||||
email.Body = builder.ToMessageBody();
|
||||
|
||||
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
|
||||
}
|
||||
|
||||
private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo, int? idLoteDeEnvio)
|
||||
{
|
||||
var destinatario = emailMessage.To.Mailboxes.FirstOrDefault()?.Address ?? "desconocido";
|
||||
|
||||
var log = new EmailLog
|
||||
{
|
||||
FechaEnvio = DateTime.Now,
|
||||
DestinatarioEmail = destinatario,
|
||||
Asunto = emailMessage.Subject,
|
||||
Origen = origen,
|
||||
ReferenciaId = referenciaId,
|
||||
IdUsuarioDisparo = idUsuarioDisparo,
|
||||
IdLoteDeEnvio = idLoteDeEnvio
|
||||
};
|
||||
|
||||
using var smtp = new SmtpClient();
|
||||
try
|
||||
{
|
||||
// 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.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
|
||||
await smtp.SendAsync(email);
|
||||
_logger.LogInformation("Email enviado exitosamente a {Destinatario}", destinatarioEmail);
|
||||
await smtp.SendAsync(emailMessage);
|
||||
|
||||
log.Estado = "Enviado";
|
||||
_logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al enviar email a {Destinatario}", destinatarioEmail);
|
||||
throw; // Relanzar para que el servicio que lo llamó sepa que falló
|
||||
_logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
|
||||
log.Estado = "Fallido";
|
||||
log.Error = ex.Message;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (smtp.IsConnected)
|
||||
{
|
||||
await smtp.DisconnectAsync(true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _emailLogRepository.CreateAsync(log);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
_logger.LogError(logEx, "FALLO CRÍTICO: No se pudo guardar el log del email para {Destinatario}", destinatario);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
{
|
||||
public interface IEmailLogService
|
||||
{
|
||||
Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId);
|
||||
Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,54 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
{
|
||||
public interface IEmailService
|
||||
{
|
||||
Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml);
|
||||
/// <summary>
|
||||
/// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar un archivo.
|
||||
/// Este método también registra automáticamente el resultado del envío en la base de datos.
|
||||
/// </summary>
|
||||
/// <param name="destinatarioEmail">La dirección de correo del destinatario.</param>
|
||||
/// <param name="destinatarioNombre">El nombre del destinatario.</param>
|
||||
/// <param name="asunto">El asunto del correo.</param>
|
||||
/// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param>
|
||||
/// <param name="attachment">Los bytes del archivo a adjuntar (opcional).</param>
|
||||
/// <param name="attachmentName">El nombre del archivo adjunto (requerido si se provee attachment).</param>
|
||||
/// <param name="origen">Identificador del proceso que dispara el email (ej. "EnvioManualPDF"). Para logging.</param>
|
||||
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Factura-59"). Para logging.</param>
|
||||
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
|
||||
/// <param name="idLoteDeEnvio">ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging.</param>
|
||||
Task EnviarEmailAsync(
|
||||
string destinatarioEmail,
|
||||
string destinatarioNombre,
|
||||
string asunto,
|
||||
string cuerpoHtml,
|
||||
byte[]? attachment = null,
|
||||
string? attachmentName = null,
|
||||
string? origen = null,
|
||||
string? referenciaId = null,
|
||||
int? idUsuarioDisparo = null,
|
||||
int? idLoteDeEnvio = null);
|
||||
|
||||
/// <summary>
|
||||
/// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar múltiples archivos.
|
||||
/// Este método también registra automáticamente el resultado del envío en la base de datos.
|
||||
/// </summary>
|
||||
/// <param name="destinatarioEmail">La dirección de correo del destinatario.</param>
|
||||
/// <param name="destinatarioNombre">El nombre del destinatario.</param>
|
||||
/// <param name="asunto">El asunto del correo.</param>
|
||||
/// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param>
|
||||
/// <param name="adjuntos">Una lista de tuplas que contienen los bytes y el nombre de cada archivo a adjuntar.</param>
|
||||
/// <param name="origen">Identificador del proceso que dispara el email (ej. "FacturacionMensual"). Para logging.</param>
|
||||
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Suscriptor-3"). Para logging.</param>
|
||||
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
|
||||
/// <param name="idLoteDeEnvio">ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging.</param>
|
||||
Task EnviarEmailConsolidadoAsync(
|
||||
string destinatarioEmail,
|
||||
string destinatarioNombre,
|
||||
string asunto,
|
||||
string cuerpoHtml,
|
||||
List<(byte[] content, string name)> adjuntos,
|
||||
string? origen = null,
|
||||
string? referenciaId = null,
|
||||
int? idUsuarioDisparo = null,
|
||||
int? idLoteDeEnvio = null);
|
||||
}
|
||||
}
|
||||
@@ -61,16 +61,11 @@ namespace GestionIntegral.Api.Services.Reportes
|
||||
IEnumerable<SaldoDto> Saldos,
|
||||
string? Error
|
||||
)> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta);
|
||||
|
||||
Task<(IEnumerable<ListadoDistribucionDistSimpleDto> Simple, IEnumerable<ListadoDistribucionDistPromedioDiaDto> Promedios, string? Error)> ObtenerListadoDistribucionDistribuidoresAsync(int idDistribuidor, int idPublicacion, DateTime fechaDesde, DateTime fechaHasta);
|
||||
|
||||
Task<(
|
||||
IEnumerable<LiquidacionCanillaDetalleDto> Detalles,
|
||||
IEnumerable<LiquidacionCanillaGananciaDto> Ganancias,
|
||||
string? Error
|
||||
)> ObtenerDatosTicketLiquidacionAsync(DateTime fecha, int idCanilla);
|
||||
|
||||
Task<(IEnumerable<LiquidacionCanillaDetalleDto> Detalles, IEnumerable<LiquidacionCanillaGananciaDto> Ganancias, string? Error)> ObtenerDatosTicketLiquidacionAsync(DateTime fecha, int idCanilla);
|
||||
Task<(IEnumerable<ListadoDistCanMensualDiariosDto> Data, string? Error)> ObtenerReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
|
||||
Task<(IEnumerable<ListadoDistCanMensualPubDto> Data, string? Error)> ObtenerReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
|
||||
Task<(IEnumerable<FacturasParaReporteDto> Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes);
|
||||
Task<(IEnumerable<DistribucionSuscripcionDto> Altas, IEnumerable<DistribucionSuscripcionDto> Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,31 @@
|
||||
using GestionIntegral.Api.Data.Repositories.Distribucion;
|
||||
using GestionIntegral.Api.Data.Repositories.Reportes;
|
||||
using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Dtos.Reportes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Reportes
|
||||
{
|
||||
public class ReportesService : IReportesService
|
||||
{
|
||||
private readonly IReportesRepository _reportesRepository;
|
||||
private readonly IFacturaRepository _facturaRepository;
|
||||
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
|
||||
private readonly IPublicacionRepository _publicacionRepository;
|
||||
private readonly IEmpresaRepository _empresaRepository;
|
||||
private readonly ISuscriptorRepository _suscriptorRepository;
|
||||
private readonly ISuscripcionRepository _suscripcionRepository;
|
||||
private readonly ILogger<ReportesService> _logger;
|
||||
|
||||
public ReportesService(IReportesRepository reportesRepository, ILogger<ReportesService> logger)
|
||||
public ReportesService(IReportesRepository reportesRepository, IFacturaRepository facturaRepository, IFacturaDetalleRepository facturaDetalleRepository, IPublicacionRepository publicacionRepository, IEmpresaRepository empresaRepository
|
||||
, ISuscriptorRepository suscriptorRepository, ISuscripcionRepository suscripcionRepository, ILogger<ReportesService> logger)
|
||||
{
|
||||
_reportesRepository = reportesRepository;
|
||||
_facturaRepository = facturaRepository;
|
||||
_facturaDetalleRepository = facturaDetalleRepository;
|
||||
_publicacionRepository = publicacionRepository;
|
||||
_empresaRepository = empresaRepository;
|
||||
_suscriptorRepository = suscriptorRepository;
|
||||
_suscripcionRepository = suscripcionRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -520,5 +530,49 @@ namespace GestionIntegral.Api.Services.Reportes
|
||||
return (Enumerable.Empty<ListadoDistCanMensualPubDto>(), "Error al obtener datos del reporte (por publicación).");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<FacturasParaReporteDto> Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes)
|
||||
{
|
||||
if (anio < 2020 || mes < 1 || mes > 12)
|
||||
{
|
||||
return (Enumerable.Empty<FacturasParaReporteDto>(), "Período no válido.");
|
||||
}
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
try
|
||||
{
|
||||
// Llamada directa al nuevo método del repositorio
|
||||
var data = await _reportesRepository.GetDatosReportePublicidadAsync(periodo);
|
||||
return (data, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error en servicio al obtener datos para reporte de publicidad para el período {Periodo}", periodo);
|
||||
return (new List<FacturasParaReporteDto>(), "Error interno al generar el reporte.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<DistribucionSuscripcionDto> Altas, IEnumerable<DistribucionSuscripcionDto> Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta)
|
||||
{
|
||||
if (fechaDesde > fechaHasta)
|
||||
{
|
||||
return (Enumerable.Empty<DistribucionSuscripcionDto>(), Enumerable.Empty<DistribucionSuscripcionDto>(), "La fecha 'Desde' no puede ser mayor que la fecha 'Hasta'.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Ejecutamos ambas consultas en paralelo para mayor eficiencia
|
||||
var altasTask = _reportesRepository.GetDistribucionSuscripcionesActivasAsync(fechaDesde, fechaHasta);
|
||||
var bajasTask = _reportesRepository.GetDistribucionSuscripcionesBajasAsync(fechaDesde, fechaHasta);
|
||||
|
||||
await Task.WhenAll(altasTask, bajasTask);
|
||||
|
||||
return (await altasTask, await bajasTask, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error en servicio al obtener datos para reporte de distribución de suscripciones.");
|
||||
return (Enumerable.Empty<DistribucionSuscripcionDto>(), Enumerable.Empty<DistribucionSuscripcionDto>(), "Error interno al generar el reporte.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using GestionIntegral.Api.Data;
|
||||
using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Data.Repositories.Usuarios;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using System.Data;
|
||||
using GestionIntegral.Api.Data.Repositories.Distribucion;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public class AjusteService : IAjusteService
|
||||
{
|
||||
private readonly IAjusteRepository _ajusteRepository;
|
||||
private readonly ISuscriptorRepository _suscriptorRepository;
|
||||
private readonly IUsuarioRepository _usuarioRepository;
|
||||
private readonly IEmpresaRepository _empresaRepository;
|
||||
private readonly IFacturaRepository _facturaRepository;
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<AjusteService> _logger;
|
||||
|
||||
public AjusteService(
|
||||
IAjusteRepository ajusteRepository,
|
||||
ISuscriptorRepository suscriptorRepository,
|
||||
IUsuarioRepository usuarioRepository,
|
||||
IEmpresaRepository empresaRepository,
|
||||
IFacturaRepository facturaRepository,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<AjusteService> logger)
|
||||
{
|
||||
_ajusteRepository = ajusteRepository;
|
||||
_suscriptorRepository = suscriptorRepository;
|
||||
_usuarioRepository = usuarioRepository;
|
||||
_empresaRepository = empresaRepository;
|
||||
_facturaRepository = facturaRepository;
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private async Task<AjusteDto?> MapToDto(Ajuste ajuste)
|
||||
{
|
||||
if (ajuste == null) return null;
|
||||
var usuario = await _usuarioRepository.GetByIdAsync(ajuste.IdUsuarioAlta);
|
||||
var empresa = await _empresaRepository.GetByIdAsync(ajuste.IdEmpresa);
|
||||
return new AjusteDto
|
||||
{
|
||||
IdAjuste = ajuste.IdAjuste,
|
||||
IdSuscriptor = ajuste.IdSuscriptor,
|
||||
IdEmpresa = ajuste.IdEmpresa,
|
||||
NombreEmpresa = empresa?.Nombre ?? "N/A",
|
||||
FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"),
|
||||
TipoAjuste = ajuste.TipoAjuste,
|
||||
Monto = ajuste.Monto,
|
||||
Motivo = ajuste.Motivo,
|
||||
Estado = ajuste.Estado,
|
||||
IdFacturaAplicado = ajuste.IdFacturaAplicado,
|
||||
FechaAlta = ajuste.FechaAlta.ToString("yyyy-MM-dd HH:mm"),
|
||||
NombreUsuarioAlta = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta)
|
||||
{
|
||||
var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor, fechaDesde, fechaHasta);
|
||||
if (!ajustes.Any())
|
||||
{
|
||||
return Enumerable.Empty<AjusteDto>();
|
||||
}
|
||||
|
||||
// 1. Recolectar IDs de usuarios, empresas Y FACTURAS
|
||||
var idsUsuarios = ajustes.Select(a => a.IdUsuarioAlta).Distinct().ToList();
|
||||
var idsEmpresas = ajustes.Select(a => a.IdEmpresa).Distinct().ToList();
|
||||
var idsFacturas = ajustes.Where(a => a.IdFacturaAplicado.HasValue)
|
||||
.Select(a => a.IdFacturaAplicado!.Value)
|
||||
.Distinct().ToList();
|
||||
|
||||
// 2. Obtener todos los datos necesarios en consultas masivas
|
||||
var usuariosTask = _usuarioRepository.GetByIdsAsync(idsUsuarios);
|
||||
var empresasTask = _empresaRepository.GetAllAsync(null, null);
|
||||
var facturasTask = _facturaRepository.GetByIdsAsync(idsFacturas);
|
||||
|
||||
await Task.WhenAll(usuariosTask, empresasTask, facturasTask);
|
||||
|
||||
// 3. Convertir a diccionarios para búsqueda rápida
|
||||
var usuariosDict = (await usuariosTask).ToDictionary(u => u.Id);
|
||||
var empresasDict = (await empresasTask).ToDictionary(e => e.IdEmpresa);
|
||||
var facturasDict = (await facturasTask).ToDictionary(f => f.IdFactura);
|
||||
|
||||
// 4. Mapear en memoria, ahora con la información de la factura disponible
|
||||
var dtos = ajustes.Select(ajuste =>
|
||||
{
|
||||
usuariosDict.TryGetValue(ajuste.IdUsuarioAlta, out var usuario);
|
||||
empresasDict.TryGetValue(ajuste.IdEmpresa, out var empresa);
|
||||
|
||||
// Buscar la factura en el diccionario si el ajuste está aplicado
|
||||
facturasDict.TryGetValue(ajuste.IdFacturaAplicado ?? 0, out var factura);
|
||||
|
||||
return new AjusteDto
|
||||
{
|
||||
IdAjuste = ajuste.IdAjuste,
|
||||
IdSuscriptor = ajuste.IdSuscriptor,
|
||||
IdEmpresa = ajuste.IdEmpresa,
|
||||
NombreEmpresa = empresa?.Nombre ?? "N/A",
|
||||
FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"),
|
||||
TipoAjuste = ajuste.TipoAjuste,
|
||||
Monto = ajuste.Monto,
|
||||
Motivo = ajuste.Motivo,
|
||||
Estado = ajuste.Estado,
|
||||
IdFacturaAplicado = ajuste.IdFacturaAplicado,
|
||||
NumeroFacturaAplicado = factura?.NumeroFactura,
|
||||
FechaAlta = ajuste.FechaAlta.ToString("yyyy-MM-dd HH:mm"),
|
||||
NombreUsuarioAlta = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
|
||||
};
|
||||
});
|
||||
|
||||
return dtos;
|
||||
}
|
||||
|
||||
public async Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario)
|
||||
{
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(createDto.IdSuscriptor);
|
||||
if (suscriptor == null)
|
||||
{
|
||||
return (null, "El suscriptor especificado no existe.");
|
||||
}
|
||||
var empresa = await _empresaRepository.GetByIdAsync(createDto.IdEmpresa);
|
||||
if (empresa == null)
|
||||
{
|
||||
return (null, "La empresa especificada no existe.");
|
||||
}
|
||||
|
||||
var nuevoAjuste = new Ajuste
|
||||
{
|
||||
IdSuscriptor = createDto.IdSuscriptor,
|
||||
IdEmpresa = createDto.IdEmpresa,
|
||||
FechaAjuste = createDto.FechaAjuste.Date,
|
||||
TipoAjuste = createDto.TipoAjuste,
|
||||
Monto = createDto.Monto,
|
||||
Motivo = createDto.Motivo,
|
||||
IdUsuarioAlta = idUsuario
|
||||
};
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var ajusteCreado = await _ajusteRepository.CreateAsync(nuevoAjuste, transaction);
|
||||
if (ajusteCreado == null) throw new DataException("Error al crear el registro de ajuste.");
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Ajuste manual ID {IdAjuste} creado para Suscriptor ID {IdSuscriptor} por Usuario ID {IdUsuario}", ajusteCreado.IdAjuste, ajusteCreado.IdSuscriptor, idUsuario);
|
||||
|
||||
var dto = await MapToDto(ajusteCreado);
|
||||
return (dto, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error al crear ajuste manual para Suscriptor ID {IdSuscriptor}", createDto.IdSuscriptor);
|
||||
return (null, "Error interno al registrar el ajuste.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario)
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste);
|
||||
if (ajuste == null) return (false, "Ajuste no encontrado.");
|
||||
if (ajuste.Estado != "Pendiente") return (false, $"No se puede anular un ajuste en estado '{ajuste.Estado}'.");
|
||||
|
||||
var exito = await _ajusteRepository.AnularAjusteAsync(idAjuste, idUsuario, transaction);
|
||||
if (!exito) throw new DataException("No se pudo anular el ajuste.");
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Ajuste ID {IdAjuste} anulado por Usuario ID {IdUsuario}", idAjuste, idUsuario);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error al anular ajuste ID {IdAjuste}", idAjuste);
|
||||
return (false, "Error interno al anular el ajuste.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> ActualizarAjuste(int idAjuste, UpdateAjusteDto updateDto)
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste);
|
||||
if (ajuste == null) return (false, "Ajuste no encontrado.");
|
||||
if (ajuste.Estado != "Pendiente") return (false, $"No se puede modificar un ajuste en estado '{ajuste.Estado}'.");
|
||||
|
||||
var empresa = await _empresaRepository.GetByIdAsync(updateDto.IdEmpresa);
|
||||
if (empresa == null) return (false, "La empresa especificada no existe.");
|
||||
|
||||
ajuste.IdEmpresa = updateDto.IdEmpresa;
|
||||
ajuste.FechaAjuste = updateDto.FechaAjuste;
|
||||
ajuste.TipoAjuste = updateDto.TipoAjuste;
|
||||
ajuste.Monto = updateDto.Monto;
|
||||
ajuste.Motivo = updateDto.Motivo;
|
||||
|
||||
var actualizado = await _ajusteRepository.UpdateAsync(ajuste, transaction);
|
||||
if (!actualizado) throw new DataException("La actualización falló o el ajuste ya no estaba pendiente.");
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Ajuste ID {IdAjuste} actualizado.", idAjuste);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error al actualizar ajuste ID {IdAjuste}", idAjuste);
|
||||
return (false, "Error interno al actualizar el ajuste.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
// Archivo: GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs
|
||||
|
||||
using GestionIntegral.Api.Data;
|
||||
using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
@@ -19,21 +11,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
private readonly IFacturaRepository _facturaRepository;
|
||||
private readonly ISuscriptorRepository _suscriptorRepository;
|
||||
private readonly ISuscripcionRepository _suscripcionRepository;
|
||||
private readonly ILoteDebitoRepository _loteDebitoRepository;
|
||||
private readonly IFormaPagoRepository _formaPagoRepository;
|
||||
private readonly IPagoRepository _pagoRepository;
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<DebitoAutomaticoService> _logger;
|
||||
|
||||
// --- CONSTANTES DEL BANCO (Mover a appsettings.json si es necesario) ---
|
||||
private const string NRO_PRESTACION = "123456"; // Nro. de prestación asignado por el banco
|
||||
private const string ORIGEN_EMPRESA = "ELDIA"; // Nombre de la empresa (7 chars)
|
||||
private const string NRO_PRESTACION = "26435"; // Reemplazar por el número real
|
||||
private const string ORIGEN_EMPRESA = "EMPRESA";
|
||||
|
||||
public DebitoAutomaticoService(
|
||||
IFacturaRepository facturaRepository,
|
||||
ISuscriptorRepository suscriptorRepository,
|
||||
ISuscripcionRepository suscripcionRepository,
|
||||
ILoteDebitoRepository loteDebitoRepository,
|
||||
IFormaPagoRepository formaPagoRepository,
|
||||
IPagoRepository pagoRepository,
|
||||
@@ -42,7 +31,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
_facturaRepository = facturaRepository;
|
||||
_suscriptorRepository = suscriptorRepository;
|
||||
_suscripcionRepository = suscripcionRepository;
|
||||
_loteDebitoRepository = loteDebitoRepository;
|
||||
_formaPagoRepository = formaPagoRepository;
|
||||
_pagoRepository = pagoRepository;
|
||||
@@ -52,6 +40,9 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
|
||||
public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario)
|
||||
{
|
||||
// Este número debe ser gestionado para no repetirse. Por ahora, lo mantenemos como 1.
|
||||
const int identificacionArchivo = 1;
|
||||
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
var fechaGeneracion = DateTime.Now;
|
||||
|
||||
@@ -61,9 +52,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
|
||||
try
|
||||
{
|
||||
// Buscamos facturas que están listas para ser enviadas al cobro.
|
||||
var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction);
|
||||
|
||||
if (!facturasParaDebito.Any())
|
||||
{
|
||||
return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado.");
|
||||
@@ -71,9 +60,8 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
|
||||
var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal);
|
||||
var cantidadRegistros = facturasParaDebito.Count();
|
||||
var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt";
|
||||
var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt";
|
||||
|
||||
// 1. Crear el Lote de Débito
|
||||
var nuevoLote = new LoteDebito
|
||||
{
|
||||
Periodo = periodo,
|
||||
@@ -85,18 +73,14 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction);
|
||||
if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito.");
|
||||
|
||||
// 2. Generar el contenido del archivo
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros));
|
||||
|
||||
sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
|
||||
foreach (var item in facturasParaDebito)
|
||||
{
|
||||
sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor));
|
||||
}
|
||||
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
|
||||
|
||||
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros));
|
||||
|
||||
// 3. Actualizar las facturas con el ID del lote
|
||||
var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura);
|
||||
bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction);
|
||||
if (!actualizadas) throw new DataException("No se pudieron actualizar las facturas con la información del lote.");
|
||||
@@ -115,19 +99,21 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
|
||||
private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction)
|
||||
{
|
||||
// Idealmente, esto debería estar en el repositorio para optimizar la consulta.
|
||||
// Por simplicidad del ejemplo, lo hacemos aquí.
|
||||
var facturas = await _facturaRepository.GetByPeriodoAsync(periodo);
|
||||
var resultado = new List<(Factura, Suscriptor)>();
|
||||
|
||||
foreach (var f in facturas.Where(fa => fa.Estado == "Pendiente de Cobro"))
|
||||
// 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 suscripcion = await _suscripcionRepository.GetByIdAsync(f.IdSuscripcion);
|
||||
if (suscripcion == null) continue;
|
||||
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor);
|
||||
if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue;
|
||||
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor);
|
||||
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).", f.IdSuscriptor);
|
||||
continue;
|
||||
}
|
||||
var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida);
|
||||
if (formaPago != null && formaPago.RequiereCBU)
|
||||
{
|
||||
@@ -137,83 +123,105 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
return resultado;
|
||||
}
|
||||
|
||||
// --- Métodos de Formateo de Campos ---
|
||||
private string FormatString(string? value, int length) => (value ?? "").PadRight(length).Substring(0, length);
|
||||
private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0');
|
||||
private string ConvertirCbuBanelcoASnp(string cbu22)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) return "".PadRight(26);
|
||||
try
|
||||
{
|
||||
string bloque1 = cbu22.Substring(0, 8);
|
||||
string bloque2 = cbu22.Substring(8);
|
||||
return $"0{bloque1}000{bloque2}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al parsear y convertir CBU de 22 dígitos: {CBU}", cbu22);
|
||||
return "".PadRight(26);
|
||||
}
|
||||
}
|
||||
|
||||
private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros)
|
||||
// --- Helpers de Formateo ---
|
||||
private string FormatString(string? value, int length) => (value ?? "").PadRight(length);
|
||||
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
|
||||
{
|
||||
"DNI" => "0096",
|
||||
"CUIT" => "0080",
|
||||
"CUIL" => "0086",
|
||||
"LE" => "0089",
|
||||
"LC" => "0090",
|
||||
_ => "0000"
|
||||
};
|
||||
|
||||
private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("00"); // Tipo de Registro
|
||||
sb.Append(FormatString(NRO_PRESTACION, 6));
|
||||
sb.Append("C"); // Servicio
|
||||
sb.Append("00");
|
||||
sb.Append(FormatNumericString(NRO_PRESTACION, 6));
|
||||
sb.Append("C");
|
||||
sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
|
||||
sb.Append("1"); // Identificación de Archivo (ej. '1' para el primer envío del día)
|
||||
sb.Append(FormatString(identificacionArchivo.ToString(), 1));
|
||||
sb.Append(FormatString(ORIGEN_EMPRESA, 7));
|
||||
sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); // 12 enteros + 2 decimales
|
||||
sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
|
||||
sb.Append(FormatNumeric(cantidadRegistros, 7));
|
||||
sb.Append(FormatString("", 304)); // Libre
|
||||
sb.Append(FormatString("", 304));
|
||||
sb.Append("\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor)
|
||||
{
|
||||
string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!);
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("0101"); // Tipo de Registro
|
||||
sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación Cliente
|
||||
sb.Append(FormatString(suscriptor.CBU, 26)); // CBU
|
||||
|
||||
// Referencia Unívoca: Usaremos ID Factura para asegurar unicidad
|
||||
sb.Append("0370");
|
||||
sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22));
|
||||
sb.Append(cbu26);
|
||||
sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15));
|
||||
|
||||
sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); // Fecha 1er Vto
|
||||
sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); // Importe 1er Vto
|
||||
|
||||
// Campos opcionales o con valores fijos
|
||||
sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vto
|
||||
sb.Append(FormatNumeric(0, 14)); // Importe 2do Vto
|
||||
sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vto
|
||||
sb.Append(FormatNumeric(0, 14)); // Importe 3er Vto
|
||||
sb.Append("0"); // Moneda (0 = Pesos)
|
||||
sb.Append(FormatString("", 3)); // Motivo Rechazo
|
||||
sb.Append(FormatString(suscriptor.TipoDocumento, 4));
|
||||
sb.Append(FormatString(suscriptor.NroDocumento, 11));
|
||||
|
||||
// El resto son campos opcionales que rellenamos con espacios/ceros
|
||||
sb.Append(FormatString("", 22)); // Nueva ID Cliente
|
||||
sb.Append(FormatNumeric(0, 26)); // Nuevo CBU
|
||||
sb.Append(FormatNumeric(0, 14)); // Importe Mínimo
|
||||
sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vto
|
||||
sb.Append(FormatString("", 22)); // ID Cuenta Anterior
|
||||
sb.Append(FormatString("", 40)); // Mensaje ATM
|
||||
sb.Append(FormatString($"Suscripcion {factura.Periodo}", 10)); // Concepto Factura
|
||||
sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro
|
||||
sb.Append(FormatNumeric(0, 14)); // Importe Cobrado
|
||||
sb.Append(FormatNumeric(0, 8)); // Fecha Acreditación
|
||||
sb.Append(FormatString("", 26)); // Libre
|
||||
sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd"));
|
||||
sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14));
|
||||
sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento
|
||||
sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento
|
||||
sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento
|
||||
sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento
|
||||
sb.Append("0");
|
||||
sb.Append(FormatString("", 3));
|
||||
sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4));
|
||||
sb.Append(FormatNumericString(suscriptor.NroDocumento, 11));
|
||||
sb.Append(FormatString("", 22));
|
||||
sb.Append(FormatString("", 26));
|
||||
sb.Append(FormatNumeric(0, 14));
|
||||
sb.Append(FormatNumeric(0, 8));
|
||||
sb.Append(FormatString("", 22));
|
||||
sb.Append(FormatString("", 40));
|
||||
sb.Append(FormatString($"Susc.{factura.Periodo}", 10));
|
||||
sb.Append(FormatNumeric(0, 8));
|
||||
sb.Append(FormatNumeric(0, 14));
|
||||
sb.Append(FormatNumeric(0, 8));
|
||||
sb.Append(FormatString("", 26));
|
||||
sb.Append("\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros)
|
||||
private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("99"); // Tipo de Registro
|
||||
sb.Append(FormatString(NRO_PRESTACION, 6));
|
||||
sb.Append("C"); // Servicio
|
||||
sb.Append("99");
|
||||
sb.Append(FormatNumericString(NRO_PRESTACION, 6));
|
||||
sb.Append("C");
|
||||
sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
|
||||
sb.Append("1"); // Identificación de Archivo
|
||||
sb.Append(FormatString(identificacionArchivo.ToString(), 1));
|
||||
sb.Append(FormatString(ORIGEN_EMPRESA, 7));
|
||||
sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
|
||||
sb.Append(FormatNumeric(cantidadRegistros, 7));
|
||||
sb.Append(FormatString("", 304)); // Libre
|
||||
// No se añade \r\n al final del último registro
|
||||
sb.Append(FormatString("", 304));
|
||||
sb.Append("\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario)
|
||||
{
|
||||
// Se mantiene la lógica original para procesar el archivo de respuesta del banco.
|
||||
|
||||
var respuesta = new ProcesamientoLoteResponseDto();
|
||||
if (archivo == null || archivo.Length == 0)
|
||||
{
|
||||
@@ -231,28 +239,17 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
string? linea;
|
||||
while ((linea = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
// Ignorar header/trailer si los hubiera (basado en el formato real)
|
||||
if (linea.Length < 20) continue;
|
||||
|
||||
respuesta.TotalRegistrosLeidos++;
|
||||
|
||||
// =================================================================
|
||||
// === ESTA ES LA LÓGICA DE PARSEO QUE SE DEBE AJUSTAR ===
|
||||
// === CON EL FORMATO REAL DEL ARCHIVO DE RESPUESTA ===
|
||||
// =================================================================
|
||||
// Asunción: Pos 1-15: Referencia, Pos 16-17: Estado, Pos 18-20: Rechazo
|
||||
var referencia = linea.Substring(0, 15).Trim();
|
||||
var estadoProceso = linea.Substring(15, 2).Trim();
|
||||
var motivoRechazo = linea.Substring(17, 3).Trim();
|
||||
// Asumimos que podemos extraer el IdFactura de la referencia
|
||||
if (!int.TryParse(referencia.Replace("SUSC-", ""), out int idFactura))
|
||||
{
|
||||
respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'.");
|
||||
continue;
|
||||
}
|
||||
// =================================================================
|
||||
// === FIN DE LA LÓGICA DE PARSEO ===
|
||||
// =================================================================
|
||||
|
||||
var factura = await _facturaRepository.GetByIdAsync(idFactura);
|
||||
if (factura == null)
|
||||
@@ -264,27 +261,24 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var nuevoPago = new Pago
|
||||
{
|
||||
IdFactura = idFactura,
|
||||
FechaPago = DateTime.Now.Date, // O la fecha que venga en el archivo
|
||||
IdFormaPago = 1, // 1 = Débito Automático
|
||||
FechaPago = DateTime.Now.Date,
|
||||
IdFormaPago = 1, // Se asume una forma de pago para el débito.
|
||||
Monto = factura.ImporteFinal,
|
||||
IdUsuarioRegistro = idUsuario,
|
||||
Referencia = $"Lote {factura.IdLoteDebito} - Banco"
|
||||
};
|
||||
|
||||
if (estadoProceso == "AP") // "AP" = Aprobado (Asunción)
|
||||
if (estadoProceso == "AP")
|
||||
{
|
||||
nuevoPago.Estado = "Aprobado";
|
||||
await _pagoRepository.CreateAsync(nuevoPago, transaction);
|
||||
await _facturaRepository.UpdateEstadoAsync(idFactura, "Pagada", transaction);
|
||||
await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction);
|
||||
respuesta.PagosAprobados++;
|
||||
}
|
||||
else // Asumimos que cualquier otra cosa es Rechazado
|
||||
else
|
||||
{
|
||||
nuevoPago.Estado = "Rechazado";
|
||||
await _pagoRepository.CreateAsync(nuevoPago, transaction);
|
||||
factura.Estado = "Rechazada";
|
||||
factura.MotivoRechazo = motivoRechazo;
|
||||
// Necesitamos un método en el repo para actualizar estado y motivo
|
||||
await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction);
|
||||
respuesta.PagosRechazados++;
|
||||
}
|
||||
|
||||
@@ -4,49 +4,103 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Distribucion;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
|
||||
using GestionIntegral.Api.Data.Repositories.Usuarios;
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
using GestionIntegral.Api.Models.Comunicaciones;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public class FacturacionService : IFacturacionService
|
||||
{
|
||||
private readonly ILoteDeEnvioRepository _loteDeEnvioRepository;
|
||||
private readonly IUsuarioRepository _usuarioRepository;
|
||||
private readonly ISuscripcionRepository _suscripcionRepository;
|
||||
private readonly IFacturaRepository _facturaRepository;
|
||||
private readonly IEmpresaRepository _empresaRepository;
|
||||
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
|
||||
private readonly IPrecioRepository _precioRepository;
|
||||
private readonly IPromocionRepository _promocionRepository;
|
||||
private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación
|
||||
private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ISuscriptorRepository _suscriptorRepository;
|
||||
private readonly IAjusteRepository _ajusteRepository;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IPublicacionRepository _publicacionRepository;
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<FacturacionService> _logger;
|
||||
private readonly string _facturasPdfPath;
|
||||
private const string LogoUrl = "https://www.eldia.com/img/header/eldia.png";
|
||||
|
||||
public FacturacionService(
|
||||
ISuscripcionRepository suscripcionRepository,
|
||||
IFacturaRepository facturaRepository,
|
||||
IEmpresaRepository empresaRepository,
|
||||
IFacturaDetalleRepository facturaDetalleRepository,
|
||||
IPrecioRepository precioRepository,
|
||||
IPromocionRepository promocionRepository,
|
||||
IRecargoZonaRepository recargoZonaRepository,
|
||||
ISuscriptorRepository suscriptorRepository,
|
||||
DbConnectionFactory connectionFactory,
|
||||
IAjusteRepository ajusteRepository,
|
||||
IEmailService emailService,
|
||||
ILogger<FacturacionService> logger)
|
||||
IPublicacionRepository publicacionRepository,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<FacturacionService> logger,
|
||||
IConfiguration configuration,
|
||||
ILoteDeEnvioRepository loteDeEnvioRepository,
|
||||
IUsuarioRepository usuarioRepository)
|
||||
{
|
||||
_loteDeEnvioRepository = loteDeEnvioRepository;
|
||||
_usuarioRepository = usuarioRepository;
|
||||
_suscripcionRepository = suscripcionRepository;
|
||||
_facturaRepository = facturaRepository;
|
||||
_empresaRepository = empresaRepository;
|
||||
_facturaDetalleRepository = facturaDetalleRepository;
|
||||
_precioRepository = precioRepository;
|
||||
_promocionRepository = promocionRepository;
|
||||
_recargoZonaRepository = recargoZonaRepository;
|
||||
_suscriptorRepository = suscriptorRepository;
|
||||
_connectionFactory = connectionFactory;
|
||||
_ajusteRepository = ajusteRepository;
|
||||
_emailService = emailService;
|
||||
_publicacionRepository = publicacionRepository;
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
_facturasPdfPath = configuration.GetValue<string>("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF";
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
|
||||
public async Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
|
||||
{
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodo, idUsuario);
|
||||
var periodoActual = new DateTime(anio, mes, 1);
|
||||
var periodoActualStr = periodoActual.ToString("yyyy-MM");
|
||||
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodoActualStr, idUsuario);
|
||||
|
||||
// --- INICIO: Creación del Lote de Envío ---
|
||||
var lote = await _loteDeEnvioRepository.CreateAsync(new LoteDeEnvio
|
||||
{
|
||||
FechaInicio = DateTime.Now,
|
||||
Periodo = periodoActualStr,
|
||||
Origen = "FacturacionMensual",
|
||||
Estado = "Iniciado",
|
||||
IdUsuarioDisparo = idUsuario
|
||||
});
|
||||
// --- FIN: Creación del Lote de Envío ---
|
||||
|
||||
var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync();
|
||||
if (ultimoPeriodoFacturadoStr != null)
|
||||
{
|
||||
var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture);
|
||||
if (periodoActual != ultimoPeriodo.AddMonths(1))
|
||||
{
|
||||
var periodoEsperado = ultimoPeriodo.AddMonths(1).ToString("MMMM 'de' yyyy", new CultureInfo("es-ES"));
|
||||
return (false, $"Error: No se puede generar la facturación de {periodoActual:MMMM 'de' yyyy}. El siguiente período a generar es {periodoEsperado}.", null);
|
||||
}
|
||||
}
|
||||
|
||||
var facturasCreadas = new List<Factura>();
|
||||
int facturasGeneradas = 0;
|
||||
int emailsEnviados = 0;
|
||||
int emailsFallidos = 0;
|
||||
var erroresDetallados = new List<EmailLogDto>();
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
@@ -54,104 +108,521 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
|
||||
try
|
||||
{
|
||||
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction);
|
||||
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction);
|
||||
if (!suscripcionesActivas.Any())
|
||||
{
|
||||
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0);
|
||||
// Si no hay nada que facturar, consideramos el proceso exitoso pero sin resultados.
|
||||
lote.Estado = "Completado";
|
||||
lote.FechaFin = DateTime.Now;
|
||||
await _loteDeEnvioRepository.UpdateAsync(lote);
|
||||
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", null);
|
||||
}
|
||||
|
||||
int facturasGeneradas = 0;
|
||||
foreach (var suscripcion in suscripcionesActivas)
|
||||
var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>();
|
||||
foreach (var s in suscripcionesActivas)
|
||||
{
|
||||
var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction);
|
||||
if (facturaExistente != null)
|
||||
var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion);
|
||||
if (pub != null)
|
||||
{
|
||||
_logger.LogWarning("Ya existe una factura (ID: {IdFactura}) para la suscripción ID {IdSuscripcion} en el período {Periodo}. Se omite.", facturaExistente.IdFactura, suscripcion.IdSuscripcion, periodo);
|
||||
continue;
|
||||
suscripcionesConEmpresa.Add((s, pub.IdEmpresa));
|
||||
}
|
||||
}
|
||||
|
||||
// --- LÓGICA DE PROMOCIONES ---
|
||||
var primerDiaMes = new DateTime(anio, mes, 1);
|
||||
var promocionesAplicables = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, primerDiaMes, transaction);
|
||||
var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa });
|
||||
|
||||
decimal importeBruto = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
|
||||
decimal descuentoTotal = 0;
|
||||
|
||||
// Aplicar promociones de descuento
|
||||
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "Porcentaje"))
|
||||
foreach (var grupo in gruposParaFacturar)
|
||||
{
|
||||
descuentoTotal += (importeBruto * promo.Valor) / 100;
|
||||
}
|
||||
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "MontoFijo"))
|
||||
int idSuscriptor = grupo.Key.IdSuscriptor;
|
||||
int idEmpresa = grupo.Key.IdEmpresa;
|
||||
decimal importeBrutoTotal = 0;
|
||||
decimal descuentoPromocionesTotal = 0;
|
||||
var detallesParaFactura = new List<FacturaDetalle>();
|
||||
foreach (var item in grupo)
|
||||
{
|
||||
descuentoTotal += promo.Valor;
|
||||
}
|
||||
// La bonificación de días se aplicaría idealmente dentro de CalcularImporteParaSuscripcion,
|
||||
// pero por simplicidad, aquí solo manejamos descuentos sobre el total.
|
||||
|
||||
if (importeBruto <= 0)
|
||||
{
|
||||
_logger.LogInformation("Suscripción ID {IdSuscripcion} no tiene importe a facturar para el período {Periodo}. Se omite.", suscripcion.IdSuscripcion, periodo);
|
||||
continue;
|
||||
}
|
||||
|
||||
var importeFinal = importeBruto - descuentoTotal;
|
||||
if (importeFinal < 0) importeFinal = 0; // El importe no puede ser negativo
|
||||
|
||||
var nuevaFactura = new Factura
|
||||
var suscripcion = item.Suscripcion;
|
||||
decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
|
||||
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction);
|
||||
decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones);
|
||||
importeBrutoTotal += importeBrutoSusc;
|
||||
descuentoPromocionesTotal += descuentoSusc;
|
||||
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion);
|
||||
detallesParaFactura.Add(new FacturaDetalle
|
||||
{
|
||||
IdSuscripcion = suscripcion.IdSuscripcion,
|
||||
Periodo = periodo,
|
||||
Descripcion = $"Corresponde a {publicacion?.Nombre ?? "N/A"}",
|
||||
ImporteBruto = importeBrutoSusc,
|
||||
DescuentoAplicado = descuentoSusc,
|
||||
ImporteNeto = importeBrutoSusc - descuentoSusc
|
||||
});
|
||||
}
|
||||
var ultimoDiaDelMes = periodoActual.AddMonths(1).AddDays(-1);
|
||||
var ajustesPendientes = await _ajusteRepository.GetAjustesPendientesHastaFechaAsync(idSuscriptor, idEmpresa, ultimoDiaDelMes, transaction);
|
||||
decimal totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto);
|
||||
var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes;
|
||||
if (importeFinal < 0) importeFinal = 0;
|
||||
if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue;
|
||||
var nuevaFactura = new Factura
|
||||
{
|
||||
IdSuscriptor = idSuscriptor,
|
||||
Periodo = periodoActualStr,
|
||||
FechaEmision = DateTime.Now.Date,
|
||||
FechaVencimiento = new DateTime(anio, mes, 10).AddMonths(1),
|
||||
ImporteBruto = importeBruto,
|
||||
DescuentoAplicado = descuentoTotal,
|
||||
FechaVencimiento = new DateTime(anio, mes, 10),
|
||||
ImporteBruto = importeBrutoTotal,
|
||||
DescuentoAplicado = descuentoPromocionesTotal,
|
||||
ImporteFinal = importeFinal,
|
||||
Estado = "Pendiente de Facturar"
|
||||
EstadoPago = "Pendiente",
|
||||
EstadoFacturacion = "Pendiente de Facturar",
|
||||
TipoFactura = "Mensual"
|
||||
};
|
||||
|
||||
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
|
||||
if (facturaCreada == null) throw new DataException($"No se pudo crear el registro de factura para la suscripción ID {suscripcion.IdSuscripcion}");
|
||||
if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}");
|
||||
|
||||
facturasCreadas.Add(facturaCreada);
|
||||
foreach (var detalle in detallesParaFactura)
|
||||
{
|
||||
detalle.IdFactura = facturaCreada.IdFactura;
|
||||
await _facturaDetalleRepository.CreateAsync(detalle, transaction);
|
||||
}
|
||||
if (ajustesPendientes.Any())
|
||||
{
|
||||
await _ajusteRepository.MarcarAjustesComoAplicadosAsync(ajustesPendientes.Select(a => a.IdAjuste), facturaCreada.IdFactura, transaction);
|
||||
}
|
||||
facturasGeneradas++;
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas);
|
||||
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", facturasGeneradas);
|
||||
_logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr);
|
||||
|
||||
if (facturasCreadas.Any())
|
||||
{
|
||||
var suscriptoresAnotificar = facturasCreadas.Select(f => f.IdSuscriptor).Distinct().ToList();
|
||||
_logger.LogInformation("Iniciando envío automático de avisos para {Count} suscriptores.", suscriptoresAnotificar.Count);
|
||||
|
||||
foreach (var idSuscriptor in suscriptoresAnotificar)
|
||||
{
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); // Necesitamos el objeto suscriptor
|
||||
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email))
|
||||
{
|
||||
emailsFallidos++;
|
||||
erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor?.NombreCompleto ?? $"ID Suscriptor {idSuscriptor}", Error = "Suscriptor sin email válido." });
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor, lote.IdLoteDeEnvio, idUsuario);
|
||||
emailsEnviados++;
|
||||
}
|
||||
catch (Exception exEmail)
|
||||
{
|
||||
emailsFallidos++;
|
||||
erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor.Email, Error = exEmail.Message });
|
||||
_logger.LogError(exEmail, "Falló el envío automático de email para el suscriptor ID {IdSuscriptor}", idSuscriptor);
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("{EmailsEnviados} avisos de vencimiento enviados automáticamente.", emailsEnviados);
|
||||
}
|
||||
|
||||
lote.Estado = "Completado";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodo);
|
||||
return (false, "Error interno del servidor al generar la facturación.", 0);
|
||||
lote.Estado = "Fallido";
|
||||
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodoActualStr);
|
||||
return (false, "Error interno del servidor al generar la facturación.", null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lote.FechaFin = DateTime.Now;
|
||||
lote.TotalCorreos = emailsEnviados + emailsFallidos;
|
||||
lote.TotalEnviados = emailsEnviados;
|
||||
lote.TotalFallidos = emailsFallidos;
|
||||
await _loteDeEnvioRepository.UpdateAsync(lote);
|
||||
}
|
||||
|
||||
var resultadoEnvio = new LoteDeEnvioResumenDto
|
||||
{
|
||||
IdLoteDeEnvio = lote.IdLoteDeEnvio,
|
||||
Periodo = periodoActualStr,
|
||||
TotalCorreos = lote.TotalCorreos,
|
||||
TotalEnviados = lote.TotalEnviados,
|
||||
TotalFallidos = lote.TotalFallidos,
|
||||
ErroresDetallados = erroresDetallados
|
||||
};
|
||||
|
||||
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", resultadoEnvio);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes)
|
||||
{
|
||||
var lotes = await _loteDeEnvioRepository.GetAllAsync(anio, mes);
|
||||
if (!lotes.Any())
|
||||
{
|
||||
return Enumerable.Empty<LoteDeEnvioHistorialDto>();
|
||||
}
|
||||
|
||||
var idsUsuarios = lotes.Select(l => l.IdUsuarioDisparo).Distinct();
|
||||
var usuarios = (await _usuarioRepository.GetByIdsAsync(idsUsuarios)).ToDictionary(u => u.Id);
|
||||
|
||||
return lotes.Select(l => new LoteDeEnvioHistorialDto
|
||||
{
|
||||
IdLoteDeEnvio = l.IdLoteDeEnvio,
|
||||
FechaInicio = l.FechaInicio,
|
||||
Periodo = l.Periodo,
|
||||
Estado = l.Estado,
|
||||
TotalCorreos = l.TotalCorreos,
|
||||
TotalEnviados = l.TotalEnviados,
|
||||
TotalFallidos = l.TotalFallidos,
|
||||
NombreUsuarioDisparo = usuarios.TryGetValue(l.IdUsuarioDisparo, out var user)
|
||||
? $"{user.Nombre} {user.Apellido}"
|
||||
: "Usuario Desconocido"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(
|
||||
int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura)
|
||||
{
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura);
|
||||
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo);
|
||||
var empresas = await _empresaRepository.GetAllAsync(null, null);
|
||||
|
||||
var resumenes = facturasData
|
||||
.GroupBy(data => data.Factura.IdSuscriptor)
|
||||
.Select(grupo =>
|
||||
{
|
||||
var primerItem = grupo.First();
|
||||
var facturasConsolidadas = grupo.Select(itemFactura =>
|
||||
{
|
||||
var empresa = empresas.FirstOrDefault(e => e.IdEmpresa == itemFactura.IdEmpresa);
|
||||
return new FacturaConsolidadaDto
|
||||
{
|
||||
IdFactura = itemFactura.Factura.IdFactura,
|
||||
NombreEmpresa = empresa?.Nombre ?? "N/A",
|
||||
ImporteFinal = itemFactura.Factura.ImporteFinal,
|
||||
EstadoPago = itemFactura.Factura.EstadoPago,
|
||||
EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
|
||||
NumeroFactura = itemFactura.Factura.NumeroFactura,
|
||||
TotalPagado = itemFactura.TotalPagado,
|
||||
|
||||
// Faltaba esta línea para pasar el tipo de factura al frontend.
|
||||
TipoFactura = itemFactura.Factura.TipoFactura,
|
||||
|
||||
Detalles = detallesData
|
||||
.Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
|
||||
.Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto })
|
||||
.ToList(),
|
||||
|
||||
// Pasamos el id del suscriptor para facilitar las cosas en el frontend
|
||||
IdSuscriptor = itemFactura.Factura.IdSuscriptor
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return new ResumenCuentaSuscriptorDto
|
||||
{
|
||||
IdSuscriptor = primerItem.Factura.IdSuscriptor,
|
||||
NombreSuscriptor = primerItem.NombreSuscriptor,
|
||||
Facturas = facturasConsolidadas,
|
||||
ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
|
||||
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal - f.TotalPagado)
|
||||
};
|
||||
});
|
||||
|
||||
return resumenes.ToList();
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario)
|
||||
{
|
||||
try
|
||||
{
|
||||
var factura = await _facturaRepository.GetByIdAsync(idFactura);
|
||||
if (factura == null) return (false, "Factura no encontrada.", null);
|
||||
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número asignado.", null);
|
||||
if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada.", null);
|
||||
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(factura.IdSuscriptor);
|
||||
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.", null);
|
||||
|
||||
byte[]? pdfAttachment = null;
|
||||
string? pdfFileName = null;
|
||||
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
|
||||
|
||||
if (File.Exists(rutaCompleta))
|
||||
{
|
||||
pdfAttachment = await File.ReadAllBytesAsync(rutaCompleta);
|
||||
pdfFileName = $"Factura_{factura.NumeroFactura}.pdf";
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura}", factura.NumeroFactura);
|
||||
return (false, "No se encontró el archivo PDF correspondiente en el servidor.", null);
|
||||
}
|
||||
|
||||
string asunto = $"Factura Electrónica - Período {factura.Periodo}";
|
||||
string cuerpoHtml = ConstruirCuerpoEmailFacturaPdf(suscriptor, factura);
|
||||
|
||||
// Pasamos los nuevos parámetros de contexto al EmailService.
|
||||
await _emailService.EnviarEmailAsync(
|
||||
destinatarioEmail: suscriptor.Email,
|
||||
destinatarioNombre: suscriptor.NombreCompleto,
|
||||
asunto: asunto,
|
||||
cuerpoHtml: cuerpoHtml,
|
||||
attachment: pdfAttachment,
|
||||
attachmentName: pdfFileName,
|
||||
origen: "EnvioManualPDF",
|
||||
referenciaId: $"Factura-{idFactura}",
|
||||
idUsuarioDisparo: idUsuario);
|
||||
|
||||
_logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor);
|
||||
|
||||
return (true, null, suscriptor.Email);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura);
|
||||
// El error ya será logueado por EmailService, pero lo relanzamos para que el controller lo maneje.
|
||||
// En este caso, simplemente devolvemos la tupla de error.
|
||||
return (false, "Ocurrió un error al intentar enviar el email con la factura.", null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction)
|
||||
/// <summary>
|
||||
/// Construye y envía un email consolidado con el resumen de todas las facturas de un suscriptor para un período.
|
||||
/// Este método está diseñado para ser llamado desde un proceso masivo como la facturación mensual.
|
||||
/// </summary>
|
||||
private async Task EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor, int idLoteDeEnvio, int idUsuarioDisparo)
|
||||
{
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
|
||||
// La lógica de try/catch ahora está en el método llamador (GenerarFacturacionMensual)
|
||||
// para poder contar los fallos y actualizar el lote de envío.
|
||||
|
||||
var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo);
|
||||
if (!facturasConEmpresa.Any())
|
||||
{
|
||||
// Si no hay facturas, no hay nada que enviar. Esto no debería ocurrir si se llama desde GenerarFacturacionMensual.
|
||||
_logger.LogWarning("Se intentó enviar aviso para Suscriptor ID {IdSuscriptor} en período {Periodo}, pero no se encontraron facturas.", idSuscriptor, periodo);
|
||||
return;
|
||||
}
|
||||
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor);
|
||||
// La validación de si el suscriptor tiene email ya se hace en el método llamador.
|
||||
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email))
|
||||
{
|
||||
// Lanzamos una excepción para que el método llamador la capture y la cuente como un fallo.
|
||||
throw new InvalidOperationException($"El suscriptor ID {idSuscriptor} no es válido o no tiene una dirección de email registrada.");
|
||||
}
|
||||
|
||||
var resumenHtml = new StringBuilder();
|
||||
var adjuntos = new List<(byte[] content, string name)>();
|
||||
|
||||
foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada"))
|
||||
{
|
||||
var factura = item.Factura;
|
||||
var nombreEmpresa = item.NombreEmpresa;
|
||||
var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
|
||||
|
||||
resumenHtml.Append($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen para {nombreEmpresa}</h4>");
|
||||
resumenHtml.Append("<table style='width: 100%; border-collapse: collapse; font-size: 0.9em;'>");
|
||||
|
||||
foreach (var detalle in detalles)
|
||||
{
|
||||
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee;'>{detalle.Descripcion}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right;'>${detalle.ImporteNeto:N2}</td></tr>");
|
||||
}
|
||||
|
||||
var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura);
|
||||
if (ajustes.Any())
|
||||
{
|
||||
foreach (var ajuste in ajustes)
|
||||
{
|
||||
bool esCredito = ajuste.TipoAjuste == "Credito";
|
||||
string colorMonto = esCredito ? "#5cb85c" : "#d9534f";
|
||||
string signo = esCredito ? "-" : "+";
|
||||
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee; font-style: italic;'>Ajuste: {ajuste.Motivo}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right; color: {colorMonto}; font-style: italic;'>{signo} ${ajuste.Monto:N2}</td></tr>");
|
||||
}
|
||||
}
|
||||
|
||||
resumenHtml.Append($"<tr style='font-weight: bold;'><td style='padding: 5px;'>Subtotal</td><td style='padding: 5px; text-align: right;'>${factura.ImporteFinal:N2}</td></tr>");
|
||||
resumenHtml.Append("</table>");
|
||||
|
||||
if (!string.IsNullOrEmpty(factura.NumeroFactura))
|
||||
{
|
||||
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
|
||||
if (File.Exists(rutaCompleta))
|
||||
{
|
||||
byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta);
|
||||
string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
|
||||
adjuntos.Add((pdfBytes, pdfFileName));
|
||||
_logger.LogInformation("PDF adjuntado para envío a Suscriptor ID {IdSuscriptor}: {FileName}", idSuscriptor, pdfFileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal);
|
||||
string asunto = $"Resumen de Cuenta - Período {periodo}";
|
||||
string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral);
|
||||
|
||||
await _emailService.EnviarEmailConsolidadoAsync(
|
||||
destinatarioEmail: suscriptor.Email,
|
||||
destinatarioNombre: suscriptor.NombreCompleto,
|
||||
asunto: asunto,
|
||||
cuerpoHtml: cuerpoHtml,
|
||||
adjuntos: adjuntos,
|
||||
origen: "FacturacionMensual",
|
||||
referenciaId: $"Suscriptor-{idSuscriptor}",
|
||||
idUsuarioDisparo: idUsuarioDisparo, // Se pasa el ID del usuario que inició el cierre
|
||||
idLoteDeEnvio: idLoteDeEnvio // Se pasa el ID del lote
|
||||
);
|
||||
|
||||
// El logging de éxito o fallo ahora lo hace el EmailService, por lo que este log ya no es estrictamente necesario,
|
||||
// pero lo mantenemos para tener un registro de alto nivel en el log del FacturacionService.
|
||||
_logger.LogInformation("Llamada a EmailService completada para Suscriptor ID {IdSuscriptor} en el período {Periodo}.", idSuscriptor, periodo);
|
||||
}
|
||||
|
||||
private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral)
|
||||
{
|
||||
return $@"
|
||||
<div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
|
||||
<div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
|
||||
<div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
|
||||
<img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
|
||||
<h2>Resumen de su Cuenta</h2>
|
||||
</div>
|
||||
<div style='padding: 20px; color: #333;'>
|
||||
<h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
|
||||
<p>Le enviamos el resumen de su cuenta para el período <strong>{periodo}</strong>.</p>
|
||||
|
||||
<!-- Aquí se insertan las tablas de resumen generadas dinámicamente -->
|
||||
{resumenHtml}
|
||||
|
||||
<hr style='border: none; border-top: 1px solid #eee; margin: 20px 0;'/>
|
||||
<table style='width: 100%;'>
|
||||
<tr>
|
||||
<td style='font-size: 1.2em; font-weight: bold;'>TOTAL A ABONAR:</td>
|
||||
<td style='font-size: 1.4em; font-weight: bold; text-align: right; color: #34515e;'>${totalGeneral:N2}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style='margin-top: 25px;'>Si su pago es por débito automático, los importes se debitarán de su cuenta. Si utiliza otro medio de pago, por favor, regularice su situación.</p>
|
||||
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
|
||||
</div>
|
||||
<div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
|
||||
<p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
|
||||
<p>© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>";
|
||||
}
|
||||
|
||||
private string ConstruirCuerpoEmailFacturaPdf(Suscriptor suscriptor, Factura factura)
|
||||
{
|
||||
return $@"
|
||||
<div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
|
||||
<div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
|
||||
<div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
|
||||
<img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
|
||||
<h2>Factura Electrónica Adjunta</h2>
|
||||
</div>
|
||||
<div style='padding: 20px; color: #333;'>
|
||||
<h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
|
||||
<p>Le enviamos adjunta su factura correspondiente al período <strong>{factura.Periodo}</strong>.</p>
|
||||
<h4 style='border-bottom: 2px solid #34515e; padding-bottom: 5px; margin-top: 30px;'>Resumen de la Factura</h4>
|
||||
<table style='width: 100%; border-collapse: collapse; margin-top: 15px;'>
|
||||
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Número de Factura:</td><td style='padding: 8px; text-align: right;'>{factura.NumeroFactura}</td></tr>
|
||||
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Período:</td><td style='padding: 8px; text-align: right;'>{factura.Periodo}</td></tr>
|
||||
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Fecha de Envío:</td><td style='padding: 8px; text-align: right;'>{factura.FechaEmision:dd/MM/yyyy}</td></tr>
|
||||
<tr style='background-color: #f2f2f2;'><td style='padding: 12px; font-weight: bold; font-size: 1.1em;'>IMPORTE TOTAL:</td><td style='padding: 12px; text-align: right; font-weight: bold; font-size: 1.2em; color: #34515e;'>${factura.ImporteFinal:N2}</td></tr>
|
||||
</table>
|
||||
<p style='margin-top: 30px;'>Puede descargar y guardar el archivo PDF adjunto para sus registros.</p>
|
||||
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
|
||||
</div>
|
||||
<div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
|
||||
<p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
|
||||
<p>© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>";
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(numeroFactura))
|
||||
{
|
||||
return (false, "El número de factura no puede estar vacío.");
|
||||
}
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
var factura = await _facturaRepository.GetByIdAsync(idFactura);
|
||||
if (factura == null)
|
||||
{
|
||||
return (false, "La factura especificada no existe.");
|
||||
}
|
||||
if (factura.EstadoPago == "Anulada")
|
||||
{
|
||||
return (false, "No se puede modificar una factura anulada.");
|
||||
}
|
||||
|
||||
var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction);
|
||||
if (!actualizado)
|
||||
{
|
||||
throw new DataException("La actualización del número de factura falló en el repositorio.");
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Número de factura para Factura ID {IdFactura} actualizado a {NumeroFactura} por Usuario ID {IdUsuario}", idFactura, numeroFactura, idUsuario);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error al actualizar número de factura para Factura ID {IdFactura}", idFactura);
|
||||
return (false, "Error interno al actualizar el número de factura.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction)
|
||||
{
|
||||
decimal importeTotal = 0;
|
||||
var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet();
|
||||
var fechaActual = new DateTime(anio, mes, 1);
|
||||
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, fechaActual, transaction);
|
||||
var promocionesDeBonificacion = promociones.Where(p => p.TipoEfecto == "BonificarEntregaDia").ToList();
|
||||
|
||||
while (fechaActual.Month == mes)
|
||||
{
|
||||
// La suscripción debe estar activa en este día
|
||||
if (fechaActual.Date >= suscripcion.FechaInicio.Date &&
|
||||
(suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date))
|
||||
if (fechaActual.Date >= suscripcion.FechaInicio.Date && (suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date))
|
||||
{
|
||||
var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek);
|
||||
if (diasDeEntrega.Contains(diaSemanaChar))
|
||||
{
|
||||
decimal precioDelDia = 0;
|
||||
var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction);
|
||||
if (precioActivo != null)
|
||||
{
|
||||
importeTotal += GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek);
|
||||
precioDelDia = GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date);
|
||||
}
|
||||
|
||||
bool diaBonificado = promocionesDeBonificacion.Any(promo => EvaluarCondicionPromocion(promo, fechaActual));
|
||||
if (diaBonificado)
|
||||
{
|
||||
precioDelDia = 0;
|
||||
_logger.LogInformation("Día {Fecha} bonificado para suscripción {IdSuscripcion} por promoción.", fechaActual.ToShortDateString(), suscripcion.IdSuscripcion);
|
||||
}
|
||||
importeTotal += precioDelDia;
|
||||
}
|
||||
}
|
||||
fechaActual = fechaActual.AddDays(1);
|
||||
@@ -159,72 +630,30 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
return importeTotal;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes)
|
||||
private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha)
|
||||
{
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo);
|
||||
|
||||
return facturasData.Select(data => new FacturaDto
|
||||
switch (promocion.TipoCondicion)
|
||||
{
|
||||
IdFactura = data.Factura.IdFactura,
|
||||
IdSuscripcion = data.Factura.IdSuscripcion,
|
||||
Periodo = data.Factura.Periodo,
|
||||
FechaEmision = data.Factura.FechaEmision.ToString("yyyy-MM-dd"),
|
||||
FechaVencimiento = data.Factura.FechaVencimiento.ToString("yyyy-MM-dd"),
|
||||
ImporteFinal = data.Factura.ImporteFinal,
|
||||
Estado = data.Factura.Estado,
|
||||
NumeroFactura = data.Factura.NumeroFactura,
|
||||
NombreSuscriptor = data.NombreSuscriptor,
|
||||
NombrePublicacion = data.NombrePublicacion
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura)
|
||||
{
|
||||
var factura = await _facturaRepository.GetByIdAsync(idFactura);
|
||||
if (factura == null) return (false, "Factura no encontrada.");
|
||||
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura aún no tiene un número asignado por ARCA.");
|
||||
|
||||
var suscripcion = await _suscripcionRepository.GetByIdAsync(factura.IdSuscripcion);
|
||||
if (suscripcion == null) return (false, "Suscripción asociada no encontrada.");
|
||||
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor);
|
||||
if (suscriptor == null) return (false, "Suscriptor asociado no encontrado.");
|
||||
if (string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no tiene una dirección de email configurada.");
|
||||
|
||||
try
|
||||
{
|
||||
var asunto = $"Tu factura del Diario El Día - Período {factura.Periodo}";
|
||||
var cuerpo = $@"
|
||||
<h1>Hola {suscriptor.NombreCompleto},</h1>
|
||||
<p>Te adjuntamos los detalles de tu factura para el período {factura.Periodo}.</p>
|
||||
<ul>
|
||||
<li><strong>Número de Factura:</strong> {factura.NumeroFactura}</li>
|
||||
<li><strong>Importe Total:</strong> ${factura.ImporteFinal:N2}</li>
|
||||
<li><strong>Fecha de Vencimiento:</strong> {factura.FechaVencimiento:dd/MM/yyyy}</li>
|
||||
</ul>
|
||||
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
|
||||
<p><em>Diario El Día</em></p>";
|
||||
|
||||
await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpo);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Falló el envío de email para la factura ID {IdFactura}", idFactura);
|
||||
return (false, "Ocurrió un error al intentar enviar el email.");
|
||||
case "Siempre": return true;
|
||||
case "DiaDeSemana":
|
||||
int diaSemanaActual = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek;
|
||||
return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActual;
|
||||
case "PrimerDiaSemanaDelMes":
|
||||
int diaSemanaActualMes = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek;
|
||||
return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActualMes && fecha.Day <= 7;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetCharDiaSemana(DayOfWeek dia) => dia switch
|
||||
{
|
||||
DayOfWeek.Sunday => "D",
|
||||
DayOfWeek.Monday => "L",
|
||||
DayOfWeek.Tuesday => "M",
|
||||
DayOfWeek.Wednesday => "X",
|
||||
DayOfWeek.Thursday => "J",
|
||||
DayOfWeek.Friday => "V",
|
||||
DayOfWeek.Saturday => "S",
|
||||
DayOfWeek.Sunday => "Dom",
|
||||
DayOfWeek.Monday => "Lun",
|
||||
DayOfWeek.Tuesday => "Mar",
|
||||
DayOfWeek.Wednesday => "Mie",
|
||||
DayOfWeek.Thursday => "Jue",
|
||||
DayOfWeek.Friday => "Vie",
|
||||
DayOfWeek.Saturday => "Sab",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
@@ -239,5 +668,14 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
DayOfWeek.Saturday => precio.Sabado ?? 0,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private decimal CalcularDescuentoPromociones(decimal importeBruto, IEnumerable<Promocion> promociones)
|
||||
{
|
||||
return promociones.Where(p => p.TipoEfecto.Contains("Descuento")).Sum(p =>
|
||||
p.TipoEfecto == "DescuentoPorcentajeTotal"
|
||||
? (importeBruto * p.ValorEfecto) / 100
|
||||
: p.ValorEfecto
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public interface IAjusteService
|
||||
{
|
||||
Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta);
|
||||
Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario);
|
||||
Task<(bool Exito, string? Error)> ActualizarAjuste(int idAjuste, UpdateAjusteDto updateDto);
|
||||
Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
using System.Data;
|
||||
using GestionIntegral.Api.Dtos.Comunicaciones;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public interface IFacturacionService
|
||||
{
|
||||
Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
|
||||
Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes);
|
||||
Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura);
|
||||
Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
|
||||
Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes);
|
||||
Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(
|
||||
int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura);
|
||||
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<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,13 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public interface ISuscripcionService
|
||||
{
|
||||
Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor);
|
||||
Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion);
|
||||
Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor);
|
||||
Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario);
|
||||
Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario);
|
||||
Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion);
|
||||
Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion);
|
||||
Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion);
|
||||
Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario);
|
||||
Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, AsignarPromocionDto dto, int idUsuario);
|
||||
Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion);
|
||||
}
|
||||
}
|
||||
@@ -66,45 +66,58 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura);
|
||||
if (factura == null) return (null, "La factura especificada no existe.");
|
||||
if (factura.Estado == "Pagada") return (null, "La factura ya se encuentra pagada.");
|
||||
if (factura.Estado == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
|
||||
if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
|
||||
|
||||
var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago);
|
||||
if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida.");
|
||||
|
||||
var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction);
|
||||
|
||||
var nuevoPago = new Pago
|
||||
{
|
||||
IdFactura = createDto.IdFactura,
|
||||
FechaPago = createDto.FechaPago,
|
||||
IdFormaPago = createDto.IdFormaPago,
|
||||
Monto = createDto.Monto,
|
||||
Estado = "Aprobado", // Los pagos manuales se asumen aprobados
|
||||
Estado = "Aprobado",
|
||||
Referencia = createDto.Referencia,
|
||||
Observaciones = createDto.Observaciones,
|
||||
IdUsuarioRegistro = idUsuario
|
||||
};
|
||||
|
||||
// 1. Crear el registro del pago
|
||||
// Creamos el nuevo pago
|
||||
var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction);
|
||||
if (pagoCreado == null) throw new DataException("No se pudo registrar el pago.");
|
||||
|
||||
// 2. Si el monto pagado es igual o mayor al importe de la factura, actualizar la factura
|
||||
// (Permitimos pago mayor por si hay redondeos, etc.)
|
||||
if (pagoCreado.Monto >= factura.ImporteFinal)
|
||||
var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto;
|
||||
|
||||
// Nueva lógica para manejar todos los estados de pago
|
||||
string nuevoEstadoPago = factura.EstadoPago;
|
||||
if (nuevoTotalPagado >= factura.ImporteFinal)
|
||||
{
|
||||
bool actualizado = await _facturaRepository.UpdateEstadoAsync(factura.IdFactura, "Pagada", transaction);
|
||||
if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'.");
|
||||
nuevoEstadoPago = "Pagada";
|
||||
}
|
||||
else if (nuevoTotalPagado > 0)
|
||||
{
|
||||
nuevoEstadoPago = "Pagada Parcialmente";
|
||||
}
|
||||
// Si nuevoTotalPagado es 0, el estado no cambia.
|
||||
|
||||
// Solo actualizamos si el estado calculado es diferente al actual.
|
||||
if (nuevoEstadoPago != factura.EstadoPago)
|
||||
{
|
||||
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, nuevoEstadoPago, transaction);
|
||||
if (!actualizado) throw new DataException($"No se pudo actualizar el estado de la factura a '{nuevoEstadoPago}'.");
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario);
|
||||
|
||||
var dto = await MapToDto(pagoCreado);
|
||||
var dto = await MapToDto(pagoCreado); // MapToDto ahora es más simple
|
||||
return (dto, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -28,8 +28,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
IdPromocion = promo.IdPromocion,
|
||||
Descripcion = promo.Descripcion,
|
||||
TipoPromocion = promo.TipoPromocion,
|
||||
Valor = promo.Valor,
|
||||
TipoEfecto = promo.TipoEfecto,
|
||||
ValorEfecto = promo.ValorEfecto,
|
||||
TipoCondicion = promo.TipoCondicion,
|
||||
ValorCondicion = promo.ValorCondicion,
|
||||
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
|
||||
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
|
||||
Activa = promo.Activa
|
||||
@@ -58,8 +60,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var nuevaPromocion = new Promocion
|
||||
{
|
||||
Descripcion = createDto.Descripcion,
|
||||
TipoPromocion = createDto.TipoPromocion,
|
||||
Valor = createDto.Valor,
|
||||
TipoEfecto = createDto.TipoEfecto,
|
||||
ValorEfecto = createDto.ValorEfecto,
|
||||
TipoCondicion = createDto.TipoCondicion,
|
||||
ValorCondicion = createDto.ValorCondicion,
|
||||
FechaInicio = createDto.FechaInicio,
|
||||
FechaFin = createDto.FechaFin,
|
||||
Activa = createDto.Activa,
|
||||
@@ -96,10 +100,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
|
||||
}
|
||||
|
||||
// Mapeo
|
||||
existente.Descripcion = updateDto.Descripcion;
|
||||
existente.TipoPromocion = updateDto.TipoPromocion;
|
||||
existente.Valor = updateDto.Valor;
|
||||
existente.TipoEfecto = updateDto.TipoEfecto;
|
||||
existente.ValorEfecto = updateDto.ValorEfecto;
|
||||
existente.TipoCondicion = updateDto.TipoCondicion;
|
||||
existente.ValorCondicion = updateDto.ValorCondicion;
|
||||
existente.FechaInicio = updateDto.FechaInicio;
|
||||
existente.FechaFin = updateDto.FechaFin;
|
||||
existente.Activa = updateDto.Activa;
|
||||
|
||||
@@ -4,6 +4,7 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
@@ -13,31 +14,42 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
private readonly ISuscriptorRepository _suscriptorRepository;
|
||||
private readonly IPublicacionRepository _publicacionRepository;
|
||||
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 DbConnectionFactory _connectionFactory;
|
||||
|
||||
public SuscripcionService(
|
||||
ISuscripcionRepository suscripcionRepository,
|
||||
ISuscriptorRepository suscriptorRepository,
|
||||
IPublicacionRepository publicacionRepository,
|
||||
IPromocionRepository promocionRepository,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<SuscripcionService> logger)
|
||||
IFacturaRepository facturaRepository,
|
||||
IFacturaDetalleRepository facturaDetalleRepository,
|
||||
IFacturacionService facturacionService,
|
||||
ILogger<SuscripcionService> logger,
|
||||
DbConnectionFactory connectionFactory)
|
||||
{
|
||||
_suscripcionRepository = suscripcionRepository;
|
||||
_suscriptorRepository = suscriptorRepository;
|
||||
_publicacionRepository = publicacionRepository;
|
||||
_promocionRepository = promocionRepository;
|
||||
_connectionFactory = connectionFactory;
|
||||
_facturaRepository = facturaRepository;
|
||||
_facturaDetalleRepository = facturaDetalleRepository;
|
||||
_facturacionService = facturacionService;
|
||||
_logger = logger;
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto
|
||||
{
|
||||
IdPromocion = promo.IdPromocion,
|
||||
Descripcion = promo.Descripcion,
|
||||
TipoPromocion = promo.TipoPromocion,
|
||||
Valor = promo.Valor,
|
||||
TipoEfecto = promo.TipoEfecto,
|
||||
ValorEfecto = promo.ValorEfecto,
|
||||
TipoCondicion = promo.TipoCondicion,
|
||||
ValorCondicion = promo.ValorCondicion,
|
||||
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
|
||||
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
|
||||
Activa = promo.Activa
|
||||
@@ -85,6 +97,15 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
return (null, "La publicación no existe.");
|
||||
if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio)
|
||||
return (null, "La fecha de fin no puede ser anterior a la fecha de inicio.");
|
||||
if ((createDto.Estado == "Cancelada" || createDto.Estado == "Pausada") && !createDto.FechaFin.HasValue)
|
||||
{
|
||||
return (null, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'.");
|
||||
}
|
||||
|
||||
if (createDto.Estado == "Activa")
|
||||
{
|
||||
createDto.FechaFin = null;
|
||||
}
|
||||
|
||||
var nuevaSuscripcion = new Suscripcion
|
||||
{
|
||||
@@ -106,6 +127,53 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction);
|
||||
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();
|
||||
_logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario);
|
||||
return (await MapToDto(creada), null);
|
||||
@@ -123,6 +191,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion);
|
||||
if (existente == null) return (false, "Suscripción no encontrada.");
|
||||
|
||||
// Validación de lógica de negocio en el backend
|
||||
if ((updateDto.Estado == "Cancelada" || updateDto.Estado == "Pausada") && !updateDto.FechaFin.HasValue)
|
||||
{
|
||||
return (false, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'.");
|
||||
}
|
||||
|
||||
// Si el estado es 'Activa', nos aseguramos de que la FechaFin sea nula.
|
||||
if (updateDto.Estado == "Activa")
|
||||
{
|
||||
updateDto.FechaFin = null;
|
||||
}
|
||||
|
||||
if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio)
|
||||
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
|
||||
|
||||
@@ -154,47 +234,67 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion)
|
||||
public async Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion)
|
||||
{
|
||||
var promociones = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion);
|
||||
return promociones.Select(MapPromocionToDto);
|
||||
var asignaciones = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion);
|
||||
return asignaciones.Select(a => new PromocionAsignadaDto
|
||||
{
|
||||
IdPromocion = a.Promocion.IdPromocion,
|
||||
Descripcion = a.Promocion.Descripcion,
|
||||
TipoEfecto = a.Promocion.TipoEfecto,
|
||||
ValorEfecto = a.Promocion.ValorEfecto,
|
||||
TipoCondicion = a.Promocion.TipoCondicion,
|
||||
ValorCondicion = a.Promocion.ValorCondicion,
|
||||
FechaInicio = a.Promocion.FechaInicio.ToString("yyyy-MM-dd"),
|
||||
FechaFin = a.Promocion.FechaFin?.ToString("yyyy-MM-dd"),
|
||||
Activa = a.Promocion.Activa,
|
||||
VigenciaDesdeAsignacion = a.Asignacion.VigenciaDesde.ToString("yyyy-MM-dd"),
|
||||
VigenciaHastaAsignacion = a.Asignacion.VigenciaHasta?.ToString("yyyy-MM-dd")
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion)
|
||||
{
|
||||
var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true);
|
||||
var promosAsignadas = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion);
|
||||
var idsAsignadas = promosAsignadas.Select(p => p.IdPromocion).ToHashSet();
|
||||
var promosAsignadasData = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion);
|
||||
var idsAsignadas = promosAsignadasData.Select(p => p.Promocion.IdPromocion).ToHashSet();
|
||||
|
||||
return todasLasPromosActivas
|
||||
.Where(p => !idsAsignadas.Contains(p.IdPromocion))
|
||||
.Select(MapPromocionToDto);
|
||||
.Select(MapPromocionToDto); // Usa el helper que ya creamos
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario)
|
||||
public async Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, AsignarPromocionDto dto, int idUsuario)
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
// Validaciones
|
||||
if (await _suscripcionRepository.GetByIdAsync(idSuscripcion) == null) return (false, "Suscripción no encontrada.");
|
||||
if (await _promocionRepository.GetByIdAsync(idPromocion) == null) return (false, "Promoción no encontrada.");
|
||||
if (await _promocionRepository.GetByIdAsync(dto.IdPromocion) == null) return (false, "Promoción no encontrada.");
|
||||
|
||||
await _suscripcionRepository.AsignarPromocionAsync(idSuscripcion, idPromocion, idUsuario, transaction);
|
||||
var nuevaAsignacion = new SuscripcionPromocion
|
||||
{
|
||||
IdSuscripcion = idSuscripcion,
|
||||
IdPromocion = dto.IdPromocion,
|
||||
IdUsuarioAsigno = idUsuario,
|
||||
VigenciaDesde = dto.VigenciaDesde,
|
||||
VigenciaHasta = dto.VigenciaHasta
|
||||
};
|
||||
|
||||
await _suscripcionRepository.AsignarPromocionAsync(nuevaAsignacion, transaction);
|
||||
transaction.Commit();
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Capturar error de Primary Key duplicada
|
||||
if (ex.Message.Contains("PRIMARY KEY constraint"))
|
||||
{
|
||||
return (false, "Esta promoción ya está asignada a la suscripción.");
|
||||
}
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error al asignar promoción {IdPromocion} a suscripción {IdSuscripcion}", idPromocion, idSuscripcion);
|
||||
_logger.LogError(ex, "Error al asignar promoción {IdPromocion} a suscripción {IdSuscripcion}", dto.IdPromocion, idSuscripcion);
|
||||
return (false, "Error interno al asignar la promoción.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
// Archivo: GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs
|
||||
|
||||
using GestionIntegral.Api.Data;
|
||||
using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
@@ -58,10 +51,42 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
|
||||
public async Task<IEnumerable<SuscriptorDto>> ObtenerTodos(string? nombreFilter, string? nroDocFilter, bool soloActivos)
|
||||
{
|
||||
// 1. Obtener todos los suscriptores en una sola consulta
|
||||
var suscriptores = await _suscriptorRepository.GetAllAsync(nombreFilter, nroDocFilter, soloActivos);
|
||||
var dtosTasks = suscriptores.Select(s => MapToDto(s));
|
||||
var dtos = await Task.WhenAll(dtosTasks);
|
||||
return dtos.Where(dto => dto != null).Select(dto => dto!);
|
||||
if (!suscriptores.Any())
|
||||
{
|
||||
return Enumerable.Empty<SuscriptorDto>();
|
||||
}
|
||||
|
||||
// 2. Obtener todas las formas de pago en una sola consulta
|
||||
// y convertirlas a un diccionario para una búsqueda rápida (O(1) en lugar de O(n)).
|
||||
var formasDePago = (await _formaPagoRepository.GetAllAsync())
|
||||
.ToDictionary(fp => fp.IdFormaPago);
|
||||
|
||||
// 3. Mapear en memoria, evitando múltiples llamadas a la base de datos.
|
||||
var dtos = suscriptores.Select(s =>
|
||||
{
|
||||
// Busca la forma de pago en el diccionario en memoria.
|
||||
formasDePago.TryGetValue(s.IdFormaPagoPreferida, out var formaPago);
|
||||
|
||||
return new SuscriptorDto
|
||||
{
|
||||
IdSuscriptor = s.IdSuscriptor,
|
||||
NombreCompleto = s.NombreCompleto,
|
||||
Email = s.Email,
|
||||
Telefono = s.Telefono,
|
||||
Direccion = s.Direccion,
|
||||
TipoDocumento = s.TipoDocumento,
|
||||
NroDocumento = s.NroDocumento,
|
||||
CBU = s.CBU,
|
||||
IdFormaPagoPreferida = s.IdFormaPagoPreferida,
|
||||
NombreFormaPagoPreferida = formaPago?.Nombre ?? "Desconocida", // Asigna el nombre
|
||||
Observaciones = s.Observaciones,
|
||||
Activo = s.Activo
|
||||
};
|
||||
});
|
||||
|
||||
return dtos;
|
||||
}
|
||||
|
||||
public async Task<SuscriptorDto?> ObtenerPorId(int id)
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AppSettings": {
|
||||
"FacturasPdfPath": "E:\\Facturas"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
|
||||
"Issuer": "GestionIntegralApi",
|
||||
@@ -13,11 +16,11 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"MailSettings": {
|
||||
"SmtpHost": "smtp.yourprovider.com",
|
||||
"SmtpHost": "192.168.5.201",
|
||||
"SmtpPort": 587,
|
||||
"SenderName": "Diario El Día - Suscripciones",
|
||||
"SenderEmail": "suscripciones@eldia.com",
|
||||
"SmtpUser": "your-smtp-username",
|
||||
"SmtpPass": "your-smtp-password"
|
||||
"SenderName": "Club - Diario El Día",
|
||||
"SenderEmail": "alertas@eldia.com",
|
||||
"SmtpUser": "alertas@eldia.com",
|
||||
"SmtpPass": "@Alertas713550@"
|
||||
}
|
||||
}
|
||||
159
Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx
Normal file
159
Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
|
||||
import type { CreateAjusteDto } from '../../../models/dtos/Suscripciones/CreateAjusteDto';
|
||||
import type { UpdateAjusteDto } from '../../../models/dtos/Suscripciones/UpdateAjusteDto';
|
||||
import type { AjusteDto } from '../../../models/dtos/Suscripciones/AjusteDto';
|
||||
import type { EmpresaDropdownDto } from '../../../models/dtos/Distribucion/EmpresaDropdownDto';
|
||||
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '95%', sm: '80%', md: '500px' },
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24, p: 4,
|
||||
};
|
||||
|
||||
// --- TIPO UNIFICADO PARA EL ESTADO DEL FORMULARIO ---
|
||||
type AjusteFormData = Partial<CreateAjusteDto & UpdateAjusteDto>;
|
||||
|
||||
interface AjusteFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => Promise<void>;
|
||||
initialData?: AjusteDto | null;
|
||||
idSuscriptor: number;
|
||||
errorMessage?: string | null;
|
||||
clearErrorMessage: () => void;
|
||||
empresas: EmpresaDropdownDto[];
|
||||
}
|
||||
|
||||
const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage, initialData, empresas }) => {
|
||||
const [formData, setFormData] = useState<AjusteFormData>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
||||
|
||||
const isEditing = Boolean(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const fechaParaFormulario = initialData?.fechaAjuste
|
||||
? initialData.fechaAjuste.split(' ')[0]
|
||||
: new Date().toISOString().split('T')[0];
|
||||
|
||||
setFormData({
|
||||
idSuscriptor: initialData?.idSuscriptor || idSuscriptor,
|
||||
idEmpresa: initialData?.idEmpresa || undefined, // undefined para que el placeholder se muestre
|
||||
fechaAjuste: fechaParaFormulario,
|
||||
tipoAjuste: initialData?.tipoAjuste || 'Credito',
|
||||
monto: initialData?.monto || undefined,
|
||||
motivo: initialData?.motivo || ''
|
||||
});
|
||||
setLocalErrors({});
|
||||
}
|
||||
}, [open, initialData, idSuscriptor]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!formData.idEmpresa) errors.idEmpresa = "Debe seleccionar una empresa.";
|
||||
if (!formData.fechaAjuste) errors.fechaAjuste = "La fecha es obligatoria.";
|
||||
if (!formData.tipoAjuste) errors.tipoAjuste = "Seleccione un tipo.";
|
||||
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
|
||||
if (!formData.motivo?.trim()) errors.motivo = "El motivo es obligatorio.";
|
||||
setLocalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev: AjusteFormData) => ({
|
||||
...prev,
|
||||
[name]: name === 'monto' && value !== '' ? parseFloat(value) : value
|
||||
}));
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
const handleSelectChange = (e: SelectChangeEvent<string | number>) => { // Acepta string o number
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev: AjusteFormData) => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearErrorMessage();
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
let success = false;
|
||||
try {
|
||||
if (isEditing && initialData) {
|
||||
await onSubmit(formData as UpdateAjusteDto, initialData.idAjuste);
|
||||
} else {
|
||||
await onSubmit(formData as CreateAjusteDto);
|
||||
}
|
||||
success = true;
|
||||
} catch (error) {
|
||||
success = false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (success) onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6">{isEditing ? 'Editar Ajuste Manual' : 'Registrar Ajuste Manual'}</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||
|
||||
<TextField name="fechaAjuste" label="Fecha del Ajuste" type="date" value={formData.fechaAjuste || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaAjuste} helperText={localErrors.fechaAjuste} />
|
||||
<FormControl fullWidth margin="dense" required error={!!localErrors.idEmpresa}>
|
||||
<InputLabel id="empresa-label">Empresa</InputLabel>
|
||||
<Select
|
||||
name="idEmpresa"
|
||||
labelId="empresa-label"
|
||||
value={formData.idEmpresa || ''}
|
||||
onChange={handleSelectChange}
|
||||
label="Empresa"
|
||||
>
|
||||
{empresas.map((empresa) => (
|
||||
<MenuItem key={empresa.idEmpresa} value={empresa.idEmpresa}>
|
||||
{empresa.nombre}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{localErrors.idEmpresa && <Typography color="error" variant="caption">{localErrors.idEmpresa}</Typography>}
|
||||
</FormControl>
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.tipoAjuste}>
|
||||
<InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel>
|
||||
<Select name="tipoAjuste" labelId="tipo-ajuste-label" value={formData.tipoAjuste || ''} onChange={handleSelectChange} label="Tipo de Ajuste">
|
||||
<MenuItem value="Credito">Crédito (Descuento a favor del cliente)</MenuItem>
|
||||
<MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField name="monto" label="Monto" type="number" value={formData.monto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.monto} helperText={localErrors.monto} InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} inputProps={{ step: "0.01" }} />
|
||||
<TextField name="motivo" label="Motivo" value={formData.motivo || ''} onChange={handleInputChange} required fullWidth margin="dense" multiline rows={3} error={!!localErrors.motivo} helperText={localErrors.motivo} />
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Nota: Este ajuste se aplicará a la factura de la <strong>empresa seleccionada</strong> en el período correspondiente a la "Fecha del Ajuste".
|
||||
</Alert>
|
||||
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
|
||||
<Button type="submit" variant="contained" disabled={loading}>
|
||||
{loading ? <CircularProgress size={24} /> : 'Guardar Ajuste'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AjusteFormModal;
|
||||
@@ -1,12 +1,26 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material';
|
||||
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider, type SelectChangeEvent, TextField } from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto';
|
||||
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
|
||||
import type { PromocionAsignadaDto } from '../../../models/dtos/Suscripciones/PromocionAsignadaDto';
|
||||
import type { AsignarPromocionDto } from '../../../models/dtos/Suscripciones/AsignarPromocionDto';
|
||||
import suscripcionService from '../../../services/Suscripciones/suscripcionService';
|
||||
|
||||
const modalStyle = { /* ... */ };
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '95%', sm: '80%', md: '600px' },
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto'
|
||||
};
|
||||
|
||||
interface GestionarPromocionesSuscripcionModalProps {
|
||||
open: boolean;
|
||||
@@ -15,12 +29,15 @@ interface GestionarPromocionesSuscripcionModalProps {
|
||||
}
|
||||
|
||||
const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => {
|
||||
const [asignadas, setAsignadas] = useState<PromocionDto[]>([]);
|
||||
const [asignadas, setAsignadas] = useState<PromocionAsignadaDto[]>([]);
|
||||
const [disponibles, setDisponibles] = useState<PromocionDto[]>([]);
|
||||
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
|
||||
const [vigenciaDesde, setVigenciaDesde] = useState('');
|
||||
const [vigenciaHasta, setVigenciaHasta] = useState('');
|
||||
|
||||
const cargarDatos = useCallback(async () => {
|
||||
if (!suscripcion) return;
|
||||
setLoading(true);
|
||||
@@ -40,16 +57,30 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
|
||||
}, [suscripcion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (open && suscripcion) {
|
||||
cargarDatos();
|
||||
setSelectedPromo('');
|
||||
setVigenciaDesde(suscripcion.fechaInicio);
|
||||
setVigenciaHasta('');
|
||||
}
|
||||
}, [open, cargarDatos]);
|
||||
}, [open, suscripcion]);
|
||||
|
||||
const handleAsignar = async () => {
|
||||
if (!suscripcion || !selectedPromo) return;
|
||||
if (!suscripcion || !selectedPromo || !vigenciaDesde) {
|
||||
setError("Debe seleccionar una promoción y una fecha de inicio.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, Number(selectedPromo));
|
||||
const dto: AsignarPromocionDto = {
|
||||
idPromocion: Number(selectedPromo),
|
||||
vigenciaDesde: vigenciaDesde,
|
||||
vigenciaHasta: vigenciaHasta || null
|
||||
};
|
||||
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, dto);
|
||||
setSelectedPromo('');
|
||||
setVigenciaDesde(suscripcion.fechaInicio);
|
||||
setVigenciaHasta('');
|
||||
cargarDatos();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Error al asignar la promoción.");
|
||||
@@ -58,12 +89,32 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
|
||||
|
||||
const handleQuitar = async (idPromocion: number) => {
|
||||
if (!suscripcion) return;
|
||||
setError(null);
|
||||
if (window.confirm("¿Está seguro de que desea quitar esta promoción de la suscripción?")) {
|
||||
try {
|
||||
await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion);
|
||||
cargarDatos();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Error al quitar la promoción.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string | null) => {
|
||||
if (!dateString) return 'Indefinida';
|
||||
const parts = dateString.split('-');
|
||||
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||||
};
|
||||
|
||||
const formatSecondaryText = (promo: PromocionAsignadaDto): string => {
|
||||
let text = '';
|
||||
switch (promo.tipoEfecto) {
|
||||
case 'DescuentoPorcentajeTotal': text = `Descuento Total: ${promo.valorEfecto}%`; break;
|
||||
case 'DescuentoMontoFijoTotal': text = `Descuento Total: $${promo.valorEfecto.toFixed(2)}`; break;
|
||||
case 'BonificarEntregaDia': text = 'Bonificación de Día'; break;
|
||||
default: text = 'Tipo desconocido';
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
if (!suscripcion) return null;
|
||||
@@ -73,30 +124,39 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6">Gestionar Promociones</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Suscripción a: {suscripcion.nombrePublicacion}
|
||||
Suscripción a: <strong>{suscripcion.nombrePublicacion}</strong>
|
||||
</Typography>
|
||||
{error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
|
||||
{loading ? <CircularProgress /> : (
|
||||
{loading ? <CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> : (
|
||||
<>
|
||||
<Typography sx={{ mt: 2 }}>Promociones Asignadas</Typography>
|
||||
<Typography sx={{ mt: 2, fontWeight: 'medium' }}>Promociones Asignadas</Typography>
|
||||
<List dense>
|
||||
{asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>}
|
||||
{asignadas.map(p => (
|
||||
<ListItem key={p.idPromocion} secondaryAction={<IconButton edge="end" onClick={() => handleQuitar(p.idPromocion)}><DeleteIcon /></IconButton>}>
|
||||
<ListItemText primary={p.descripcion} secondary={`Tipo: ${p.tipoPromocion}, Valor: ${p.valor}`} />
|
||||
<ListItemText
|
||||
primary={p.descripcion}
|
||||
secondary={`Vigente del ${formatDate(p.vigenciaDesdeAsignacion)} al ${formatDate(p.vigenciaHastaAsignacion)} - ${formatSecondaryText(p)}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography>Asignar Nueva Promoción</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
|
||||
<InputLabel>Promociones Disponibles</InputLabel>
|
||||
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e) => setSelectedPromo(e.target.value)}>
|
||||
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e: SelectChangeEvent<number | string>) => setSelectedPromo(e.target.value)}>
|
||||
{disponibles.map(p => <MenuItem key={p.idPromocion} value={p.idPromocion}>{p.descripcion}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo}><AddCircleOutlineIcon /></Button>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField label="Vigencia Desde" type="date" value={vigenciaDesde} onChange={(e) => setVigenciaDesde(e.target.value)} required fullWidth size="small" InputLabelProps={{ shrink: true }} />
|
||||
<TextField label="Vigencia Hasta (Opcional)" type="date" value={vigenciaHasta} onChange={(e) => setVigenciaHasta(e.target.value)} fullWidth size="small" InputLabelProps={{ shrink: true }} />
|
||||
</Box>
|
||||
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo} sx={{ mt: 2 }} startIcon={<AddCircleOutlineIcon />}>
|
||||
Asignar
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Modal, Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, Tooltip, IconButton, CircularProgress, Alert } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto';
|
||||
|
||||
interface HistorialEnviosModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
logs: EmailLogDto[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
titulo: string;
|
||||
}
|
||||
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '95%', sm: '80%', md: '700px' },
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24, p: 4,
|
||||
borderRadius: 2,
|
||||
};
|
||||
|
||||
const HistorialEnviosModal: React.FC<HistorialEnviosModalProps> = ({ open, onClose, logs, isLoading, error, titulo }) => {
|
||||
const formatDisplayDateTime = (dateString: string): string => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('es-AR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" component="h2">{titulo}</Typography>
|
||||
<IconButton onClick={onClose}><CloseIcon /></IconButton>
|
||||
</Box>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}><CircularProgress /></Box>
|
||||
) : error ? (
|
||||
<Alert severity="error">{error}</Alert>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Fecha de Envío</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Estado</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Destinatario</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Asunto</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">No se han registrado envíos.</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{formatDisplayDateTime(log.fechaEnvio)}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={log.estado === 'Fallido' ? (log.error || 'Error desconocido') : ''} arrow>
|
||||
<Chip
|
||||
label={log.estado}
|
||||
color={log.estado === 'Enviado' ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>{log.destinatarioEmail}</TableCell>
|
||||
<TableCell>{log.asunto}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistorialEnviosModal;
|
||||
@@ -1,8 +1,6 @@
|
||||
// Archivo: Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
|
||||
import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto';
|
||||
import type { FacturaConsolidadaDto } from '../../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
|
||||
import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto';
|
||||
import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto';
|
||||
import formaPagoService from '../../../services/Suscripciones/formaPagoService';
|
||||
@@ -25,17 +23,19 @@ interface PagoManualModalProps {
|
||||
open: boolean;
|
||||
onClose: () => 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;
|
||||
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 [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingFormasPago, setLoadingFormasPago] = useState(false);
|
||||
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
||||
const saldoPendiente = factura ? factura.importeFinal - factura.totalPagado : 0;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFormasDePago = async () => {
|
||||
@@ -54,18 +54,26 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
|
||||
fetchFormasDePago();
|
||||
setFormData({
|
||||
idFactura: factura.idFactura,
|
||||
monto: factura.importeFinal,
|
||||
monto: saldoPendiente,
|
||||
fechaPago: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
setLocalErrors({});
|
||||
}
|
||||
}, [open, factura]);
|
||||
}, [open, factura, saldoPendiente]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago.";
|
||||
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
|
||||
if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria.";
|
||||
|
||||
const monto = formData.monto ?? 0;
|
||||
|
||||
if (monto <= 0) {
|
||||
errors.monto = "El monto debe ser mayor a cero.";
|
||||
} else if (monto > saldoPendiente) {
|
||||
errors.monto = `El monto no puede superar el saldo pendiente de $${saldoPendiente.toFixed(2)}.`;
|
||||
}
|
||||
|
||||
setLocalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
@@ -109,8 +117,11 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6">Registrar Pago Manual</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Factura #{factura.idFactura} para {factura.nombreSuscriptor}
|
||||
<Typography variant="body1" color="text.secondary" gutterBottom>
|
||||
Para: {nombreSuscriptor}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Saldo Pendiente: ${saldoPendiente.toFixed(2)}
|
||||
</Typography>
|
||||
<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} />
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
|
||||
FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox,
|
||||
type SelectChangeEvent, InputAdornment } from '@mui/material';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, type SelectChangeEvent, InputAdornment } from '@mui/material';
|
||||
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
|
||||
import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto';
|
||||
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '95%', sm: '80%', md: '600px' },
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto'
|
||||
boxShadow: 24, p: 4,
|
||||
maxHeight: '90vh', overflowY: 'auto'
|
||||
};
|
||||
|
||||
const tiposPromocion = [
|
||||
{ value: 'Porcentaje', label: 'Descuento Porcentual (%)' },
|
||||
{ value: 'MontoFijo', label: 'Descuento de Monto Fijo ($)' },
|
||||
// { value: 'BonificacionDias', label: 'Bonificación de Días' }, // Descomentar para futuras implementaciones
|
||||
const tiposEfecto = [
|
||||
{ value: 'DescuentoPorcentajeTotal', label: 'Descuento en Porcentaje (%) sobre el total' },
|
||||
{ value: 'DescuentoMontoFijoTotal', label: 'Descuento en Monto Fijo ($) sobre el total' },
|
||||
{ value: 'BonificarEntregaDia', label: 'Bonificar / Día Gratis (Precio del día = $0)' },
|
||||
];
|
||||
const tiposCondicion = [
|
||||
{ value: 'Siempre', label: 'Siempre (en todos los días de entrega)' },
|
||||
{ value: 'DiaDeSemana', label: 'Un día de la semana específico' },
|
||||
{ value: 'PrimerDiaSemanaDelMes', label: 'El primer día de la semana del mes' },
|
||||
];
|
||||
const diasSemana = [
|
||||
{ value: 1, label: 'Lunes' }, { value: 2, label: 'Martes' }, { value: 3, label: 'Miércoles' },
|
||||
{ value: 4, label: 'Jueves' }, { value: 5, label: 'Viernes' }, { value: 6, label: 'Sábado' },
|
||||
{ value: 7, label: 'Domingo' }
|
||||
];
|
||||
|
||||
interface PromocionFormModalProps {
|
||||
@@ -38,18 +43,22 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
||||
|
||||
const isEditing = Boolean(initialData);
|
||||
|
||||
const necesitaValorCondicion = formData.tipoCondicion === 'DiaDeSemana' || formData.tipoCondicion === 'PrimerDiaSemanaDelMes';
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData(initialData || {
|
||||
const defaults = {
|
||||
descripcion: '',
|
||||
tipoPromocion: 'Porcentaje',
|
||||
valor: 0,
|
||||
tipoEfecto: 'DescuentoPorcentajeTotal' as const,
|
||||
valorEfecto: 0,
|
||||
tipoCondicion: 'Siempre' as const,
|
||||
valorCondicion: null,
|
||||
fechaInicio: new Date().toISOString().split('T')[0],
|
||||
activa: true
|
||||
});
|
||||
};
|
||||
setFormData(initialData ? { ...initialData } : defaults);
|
||||
setLocalErrors({});
|
||||
}
|
||||
}, [open, initialData]);
|
||||
@@ -57,10 +66,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.';
|
||||
if (!formData.tipoPromocion) errors.tipoPromocion = 'El tipo de promoción es obligatorio.';
|
||||
if (!formData.valor || formData.valor <= 0) errors.valor = 'El valor debe ser mayor a cero.';
|
||||
if (formData.tipoPromocion === 'Porcentaje' && (formData.valor ?? 0) > 100) {
|
||||
errors.valor = 'El valor para porcentaje no puede ser mayor a 100.';
|
||||
if (!formData.tipoEfecto) errors.tipoEfecto = 'El tipo de efecto es obligatorio.';
|
||||
if (formData.tipoEfecto !== 'BonificarEntregaDia' && (!formData.valorEfecto || formData.valorEfecto <= 0)) {
|
||||
errors.valorEfecto = 'El valor debe ser mayor a cero.';
|
||||
}
|
||||
if (formData.tipoEfecto === 'DescuentoPorcentajeTotal' && formData.valorEfecto && formData.valorEfecto > 100) {
|
||||
errors.valorEfecto = 'El valor para porcentaje no puede ser mayor a 100.';
|
||||
}
|
||||
if (!formData.tipoCondicion) errors.tipoCondicion = 'La condición es obligatoria.';
|
||||
if (necesitaValorCondicion && !formData.valorCondicion) {
|
||||
errors.valorCondicion = "Debe seleccionar un día para esta condición.";
|
||||
}
|
||||
if (!formData.fechaInicio) errors.fechaInicio = 'La fecha de inicio es obligatoria.';
|
||||
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
|
||||
@@ -72,7 +87,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
const finalValue = type === 'checkbox' ? checked : (type === 'number' ? parseFloat(value) : value);
|
||||
const finalValue = type === 'checkbox' ? checked : (name === 'valorEfecto' && value !== '' ? parseFloat(value) : value);
|
||||
setFormData(prev => ({ ...prev, [name]: finalValue }));
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
@@ -80,7 +95,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
|
||||
const handleSelectChange = (e: SelectChangeEvent<any>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
const newFormData = { ...formData, [name]: value };
|
||||
|
||||
if (name === 'tipoCondicion' && value === 'Siempre') {
|
||||
newFormData.valorCondicion = null;
|
||||
}
|
||||
if (name === 'tipoEfecto' && value === 'BonificarEntregaDia') {
|
||||
newFormData.valorEfecto = 0; // Bonificar no necesita valor
|
||||
}
|
||||
|
||||
setFormData(newFormData);
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
@@ -93,11 +117,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
setLoading(true);
|
||||
let success = false;
|
||||
try {
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
fechaFin: formData.fechaFin || null
|
||||
} as CreatePromocionDto | UpdatePromocionDto;
|
||||
|
||||
const dataToSubmit = { ...formData, fechaFin: formData.fechaFin || null } as CreatePromocionDto | UpdatePromocionDto;
|
||||
await onSubmit(dataToSubmit, initialData?.idPromocion);
|
||||
success = true;
|
||||
} catch (error) {
|
||||
@@ -111,28 +131,39 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6" component="h2">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
|
||||
<Typography variant="h6">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||
<TextField name="descripcion" label="Descripción" value={formData.descripcion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.descripcion} helperText={localErrors.descripcion} disabled={loading} autoFocus />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<FormControl fullWidth margin="dense" sx={{flex: 2}} error={!!localErrors.tipoPromocion}>
|
||||
<InputLabel id="tipo-promo-label" required>Tipo</InputLabel>
|
||||
<Select name="tipoPromocion" labelId="tipo-promo-label" value={formData.tipoPromocion || ''} onChange={handleSelectChange} label="Tipo" disabled={loading}>
|
||||
{tiposPromocion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.tipoEfecto}>
|
||||
<InputLabel>Efecto de la Promoción</InputLabel>
|
||||
<Select name="tipoEfecto" value={formData.tipoEfecto || ''} onChange={handleSelectChange} label="Efecto de la Promoción" disabled={loading}>
|
||||
{tiposEfecto.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField name="valor" label="Valor" type="number" value={formData.valor || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{flex: 1}} error={!!localErrors.valor} helperText={localErrors.valor} disabled={loading}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoPromocion === 'Porcentaje' ? '%' : '$'}</InputAdornment> }}
|
||||
{formData.tipoEfecto !== 'BonificarEntregaDia' && (
|
||||
<TextField name="valorEfecto" label="Valor" type="number" value={formData.valorEfecto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.valorEfecto} helperText={localErrors.valorEfecto} disabled={loading}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoEfecto === 'DescuentoPorcentajeTotal' ? '%' : '$'}</InputAdornment> }}
|
||||
inputProps={{ step: "0.01" }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
)}
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.tipoCondicion}>
|
||||
<InputLabel>Condición de Aplicación</InputLabel>
|
||||
<Select name="tipoCondicion" value={formData.tipoCondicion || ''} onChange={handleSelectChange} label="Condición de Aplicación" disabled={loading}>
|
||||
{tiposCondicion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{necesitaValorCondicion && (
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.valorCondicion}>
|
||||
<InputLabel>Día de la Semana</InputLabel>
|
||||
<Select name="valorCondicion" value={formData.valorCondicion || ''} onChange={handleSelectChange} label="Día de la Semana" disabled={loading}>
|
||||
{diasSemana.map(d => <MenuItem key={d.value} value={d.value}>{d.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
|
||||
<TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} />
|
||||
<TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} />
|
||||
</Box>
|
||||
|
||||
<FormControlLabel control={<Checkbox name="activa" checked={formData.activa ?? true} onChange={handleInputChange} disabled={loading}/>} label="Promoción Activa" sx={{mt: 1}} />
|
||||
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Dialog, DialogTitle, DialogContent, IconButton, Tabs, Tab, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Paper, Chip, CircularProgress, Alert } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto';
|
||||
import facturacionService from '../../../services/Suscripciones/facturacionService';
|
||||
|
||||
interface ResultadoEnvioModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
loteId: number | null;
|
||||
periodo: string;
|
||||
}
|
||||
|
||||
const ResultadoEnvioModal: React.FC<ResultadoEnvioModalProps> = ({ open, onClose, loteId, periodo }) => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [logs, setLogs] = useState<EmailLogDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && loteId) {
|
||||
const fetchDetails = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await facturacionService.getDetallesLoteEnvio(loteId);
|
||||
setLogs(data);
|
||||
} catch (err) {
|
||||
setError('No se pudieron cargar los detalles del envío.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDetails();
|
||||
}
|
||||
}, [open, loteId]);
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (activeTab === 1) return logs.filter(log => log.estado === 'Enviado');
|
||||
if (activeTab === 2) return logs.filter(log => log.estado === 'Fallido');
|
||||
return logs; // Tab 0 es 'Todos'
|
||||
}, [logs, activeTab]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>
|
||||
Detalle del Lote de Envío - Período {periodo}
|
||||
<IconButton onClick={onClose} sx={{ position: 'absolute', right: 8, top: 8 }}><CloseIcon /></IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tabs value={activeTab} onChange={(_e, newValue) => setActiveTab(newValue)}>
|
||||
<Tab label={`Todos (${logs.length})`} />
|
||||
<Tab label={`Enviados (${logs.filter(l => l.estado === 'Enviado').length})`} />
|
||||
<Tab label={`Fallidos (${logs.filter(l => l.estado === 'Fallido').length})`} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{loading ? <CircularProgress /> : error ? <Alert severity="error">{error}</Alert> :
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Destinatario</TableCell>
|
||||
<TableCell>Asunto</TableCell>
|
||||
<TableCell>Estado</TableCell>
|
||||
<TableCell>Detalle del Error</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredLogs.map((log, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{log.destinatarioEmail}</TableCell>
|
||||
<TableCell>{log.asunto}</TableCell>
|
||||
<TableCell><Chip label={log.estado} color={log.estado === 'Enviado' ? 'success' : 'error'} size="small" /></TableCell>
|
||||
<TableCell sx={{ color: 'error.main' }}>{log.error || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultadoEnvioModal;
|
||||
@@ -25,10 +25,10 @@ const modalStyle = {
|
||||
};
|
||||
|
||||
const dias = [
|
||||
{ label: 'Lunes', value: 'L' }, { label: 'Martes', value: 'M' },
|
||||
{ label: 'Miércoles', value: 'X' }, { label: 'Jueves', value: 'J' },
|
||||
{ label: 'Viernes', value: 'V' }, { label: 'Sábado', value: 'S' },
|
||||
{ label: 'Domingo', value: 'D' }
|
||||
{ label: 'Lunes', value: 'Lun' }, { label: 'Martes', value: 'Mar' },
|
||||
{ label: 'Miércoles', value: 'Mie' }, { label: 'Jueves', value: 'Jue' },
|
||||
{ label: 'Viernes', value: 'Vie' }, { label: 'Sábado', value: 'Sab' },
|
||||
{ label: 'Domingo', value: 'Dom' }
|
||||
];
|
||||
|
||||
interface SuscripcionFormModalProps {
|
||||
@@ -41,7 +41,6 @@ interface SuscripcionFormModalProps {
|
||||
clearErrorMessage: () => void;
|
||||
}
|
||||
|
||||
// Usamos una interfaz local que contenga todos los campos posibles del formulario
|
||||
interface FormState {
|
||||
idPublicacion?: number | '';
|
||||
fechaInicio?: string;
|
||||
@@ -92,6 +91,13 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación.";
|
||||
if (!formData.fechaInicio?.trim()) errors.fechaInicio = 'La Fecha de Inicio es obligatoria.';
|
||||
|
||||
// --- INICIO DE LA MODIFICACIÓN ---
|
||||
if (formData.estado !== 'Activa' && !formData.fechaFin) {
|
||||
errors.fechaFin = 'La Fecha de Fin es obligatoria si el estado es Pausada o Cancelada.';
|
||||
}
|
||||
// --- FIN DE LA MODIFICACIÓN ---
|
||||
|
||||
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
|
||||
errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.';
|
||||
}
|
||||
@@ -123,6 +129,27 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
// --- INICIO DE LA MODIFICACIÓN ---
|
||||
const handleEstadoChange = (e: SelectChangeEvent<'Activa' | 'Pausada' | 'Cancelada'>) => {
|
||||
const nuevoEstado = e.target.value as 'Activa' | 'Pausada' | 'Cancelada';
|
||||
const hoy = new Date().toISOString().split('T')[0];
|
||||
|
||||
setFormData(prev => {
|
||||
const newState = { ...prev, estado: nuevoEstado };
|
||||
|
||||
if ((nuevoEstado === 'Cancelada' || nuevoEstado === 'Pausada') && !prev.fechaFin) {
|
||||
newState.fechaFin = hoy;
|
||||
} else if (nuevoEstado === 'Activa') {
|
||||
newState.fechaFin = null; // Limpiar la fecha de fin si se reactiva
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
// Limpiar errores al cambiar
|
||||
if (localErrors.fechaFin) setLocalErrors(prev => ({ ...prev, fechaFin: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
// --- FIN DE LA MODIFICACIÓN ---
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearErrorMessage();
|
||||
@@ -133,7 +160,7 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
|
||||
try {
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
fechaFin: formData.fechaFin || null,
|
||||
fechaFin: formData.estado === 'Activa' ? null : formData.fechaFin, // Asegurarse de que fechaFin es null si está activa
|
||||
diasEntrega: Array.from(selectedDays),
|
||||
};
|
||||
|
||||
@@ -173,11 +200,25 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
|
||||
<TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} />
|
||||
<TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} />
|
||||
<TextField
|
||||
name="fechaFin"
|
||||
label={formData.estado !== 'Activa' ? "Fecha Fin (Requerida)" : "Fecha Fin (Automática)"}
|
||||
type="date"
|
||||
value={formData.fechaFin || ''}
|
||||
onChange={handleInputChange}
|
||||
fullWidth
|
||||
margin="dense"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
error={!!localErrors.fechaFin}
|
||||
helperText={localErrors.fechaFin}
|
||||
disabled={loading || formData.estado === 'Activa'} // Deshabilitado si está activa
|
||||
required={formData.estado !== 'Activa'} // Requerido si no está activa
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel id="estado-label">Estado</InputLabel>
|
||||
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleSelectChange} label="Estado" disabled={loading}>
|
||||
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleEstadoChange} label="Estado" disabled={loading}>
|
||||
<MenuItem value="Activa">Activa</MenuItem>
|
||||
<MenuItem value="Pausada">Pausada</MenuItem>
|
||||
<MenuItem value="Cancelada">Cancelada</MenuItem>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Archivo: Frontend/src/components/Modals/Suscripciones/SuscriptorFormModal.tsx
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material'; // 1. Importar SelectChangeEvent
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material';
|
||||
import type { SuscriptorDto } from '../../../models/dtos/Suscripciones/SuscriptorDto';
|
||||
import type { CreateSuscriptorDto } from '../../../models/dtos/Suscripciones/CreateSuscriptorDto';
|
||||
import type { UpdateSuscriptorDto } from '../../../models/dtos/Suscripciones/UpdateSuscriptorDto';
|
||||
@@ -31,9 +29,7 @@ interface SuscriptorFormModalProps {
|
||||
clearErrorMessage: () => void;
|
||||
}
|
||||
|
||||
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage
|
||||
}) => {
|
||||
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage }) => {
|
||||
const [formData, setFormData] = useState<Partial<CreateSuscriptorDto>>({});
|
||||
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -59,9 +55,18 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
|
||||
if (open) {
|
||||
fetchFormasDePago();
|
||||
setFormData(initialData || {
|
||||
nombreCompleto: '', tipoDocumento: 'DNI', nroDocumento: '', cbu: ''
|
||||
});
|
||||
const dataParaFormulario: Partial<CreateSuscriptorDto> = {
|
||||
nombreCompleto: initialData?.nombreCompleto || '',
|
||||
email: initialData?.email || '',
|
||||
telefono: initialData?.telefono || '',
|
||||
direccion: initialData?.direccion || '',
|
||||
tipoDocumento: initialData?.tipoDocumento || 'DNI',
|
||||
nroDocumento: initialData?.nroDocumento || '',
|
||||
cbu: initialData?.cbu || '',
|
||||
idFormaPagoPreferida: initialData?.idFormaPagoPreferida,
|
||||
observaciones: initialData?.observaciones || ''
|
||||
};
|
||||
setFormData(dataParaFormulario);
|
||||
setLocalErrors({});
|
||||
}
|
||||
}, [open, initialData]);
|
||||
@@ -73,36 +78,61 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
if (!formData.tipoDocumento) errors.tipoDocumento = 'El tipo de documento es obligatorio.';
|
||||
if (!formData.nroDocumento?.trim()) errors.nroDocumento = 'El número de documento es obligatorio.';
|
||||
if (!formData.idFormaPagoPreferida) errors.idFormaPagoPreferida = 'La forma de pago es obligatoria.';
|
||||
if (CBURequerido && (!formData.cbu || formData.cbu.trim().length !== 22)) {
|
||||
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.';
|
||||
|
||||
// Validar formato de Nro de Documento (solo números)
|
||||
if (formData.nroDocumento && !/^[0-9]+$/.test(formData.nroDocumento)) {
|
||||
errors.nroDocumento = 'El documento solo debe contener números.';
|
||||
}
|
||||
|
||||
// Validar formato de Email
|
||||
if (formData.email && !/^\S+@\S+\.\S+$/.test(formData.email)) {
|
||||
errors.email = 'El formato del email no es válido.';
|
||||
}
|
||||
|
||||
// Validar formato y longitud de CBU
|
||||
if (CBURequerido) {
|
||||
if (!formData.cbu || !/^[0-9]+$/.test(formData.cbu) || formData.cbu.length !== 22) {
|
||||
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos numéricos.';
|
||||
}
|
||||
} else if (formData.cbu && formData.cbu.trim().length > 0 && (!/^[0-9]+$/.test(formData.cbu) || formData.cbu.length !== 26)) {
|
||||
errors.cbu = 'El CBU debe tener 22 dígitos numéricos o estar vacío.';
|
||||
}
|
||||
|
||||
setLocalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// --- HANDLER DE INPUT MEJORADO ---
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// Prevenir entrada de caracteres no numéricos para CBU y NroDocumento
|
||||
if (name === 'cbu' || name === 'nroDocumento') {
|
||||
const numericValue = value.replace(/[^0-9]/g, '');
|
||||
setFormData(prev => ({ ...prev, [name]: numericValue }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) {
|
||||
setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
}
|
||||
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
// 2. Crear un handler específico para los Select
|
||||
const handleSelectChange = (e: SelectChangeEvent<any>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) {
|
||||
setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
const newFormData = { ...formData, [name]: value };
|
||||
|
||||
if (name === 'idFormaPagoPreferida') {
|
||||
const formaDePagoSeleccionada = formasDePago.find(fp => fp.idFormaPago === value);
|
||||
if (formaDePagoSeleccionada && !formaDePagoSeleccionada.requiereCBU) {
|
||||
newFormData.cbu = '';
|
||||
}
|
||||
}
|
||||
setFormData(newFormData);
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
clearErrorMessage();
|
||||
@@ -111,7 +141,12 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
setLoading(true);
|
||||
let success = false;
|
||||
try {
|
||||
const dataToSubmit = formData as CreateSuscriptorDto | UpdateSuscriptorDto;
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
idFormaPagoPreferida: Number(formData.idFormaPagoPreferida),
|
||||
cbu: formData.cbu?.trim() || null
|
||||
} as CreateSuscriptorDto | UpdateSuscriptorDto;
|
||||
|
||||
await onSubmit(dataToSubmit, initialData?.idSuscriptor);
|
||||
success = true;
|
||||
} catch (error) {
|
||||
@@ -140,26 +175,47 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<FormControl margin="dense" sx={{ minWidth: 120 }}>
|
||||
<InputLabel id="tipo-doc-label">Tipo</InputLabel>
|
||||
{/* 3. Aplicar el nuevo handler a los Selects */}
|
||||
<Select labelId="tipo-doc-label" name="tipoDocumento" value={formData.tipoDocumento || 'DNI'} onChange={handleSelectChange} label="Tipo" disabled={loading}>
|
||||
<MenuItem value="DNI">DNI</MenuItem>
|
||||
<MenuItem value="CUIT">CUIT</MenuItem>
|
||||
<MenuItem value="CUIL">CUIL</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField name="nroDocumento" label="Nro Documento" value={formData.nroDocumento || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{ flex: 2 }} error={!!localErrors.nroDocumento} helperText={localErrors.nroDocumento} disabled={loading} />
|
||||
<TextField name="nroDocumento" label="Nro Documento" value={formData.nroDocumento || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{ flex: 2 }} error={!!localErrors.nroDocumento} helperText={localErrors.nroDocumento} disabled={loading} inputProps={{ maxLength: 11 }} />
|
||||
</Box>
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.idFormaPagoPreferida}>
|
||||
<InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel>
|
||||
{/* 3. Aplicar el nuevo handler a los Selects */}
|
||||
<Select labelId="forma-pago-label" name="idFormaPagoPreferida" value={formData.idFormaPagoPreferida || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loading || loadingFormasPago}>
|
||||
<Select
|
||||
labelId="forma-pago-label"
|
||||
name="idFormaPagoPreferida"
|
||||
value={loadingFormasPago ? '' : formData.idFormaPagoPreferida || ''}
|
||||
onChange={handleSelectChange}
|
||||
label="Forma de Pago"
|
||||
disabled={loading || loadingFormasPago}
|
||||
>
|
||||
{loadingFormasPago && <MenuItem value=""><em>Cargando...</em></MenuItem>}
|
||||
|
||||
{formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)}
|
||||
</Select>
|
||||
{localErrors.idFormaPagoPreferida && <Typography color="error" variant="caption">{localErrors.idFormaPagoPreferida}</Typography>}
|
||||
</FormControl>
|
||||
|
||||
{CBURequerido && (
|
||||
<TextField name="cbu" label="CBU" value={formData.cbu || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.cbu} helperText={localErrors.cbu} disabled={loading} inputProps={{ maxLength: 22 }} />
|
||||
<TextField
|
||||
name="cbu"
|
||||
label="CBU"
|
||||
value={formData.cbu || ''}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
fullWidth
|
||||
margin="dense"
|
||||
error={!!localErrors.cbu}
|
||||
helperText={localErrors.cbu}
|
||||
disabled={loading}
|
||||
inputProps={{ maxLength: 22 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} />
|
||||
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>}
|
||||
|
||||
@@ -14,18 +14,18 @@ const SECCION_PERMISSIONS_PREFIX = "SS";
|
||||
// Mapeo de codAcc de sección a su módulo conceptual
|
||||
const getModuloFromSeccionCodAcc = (codAcc: string): string | null => {
|
||||
if (codAcc === "SS001") return "Distribución";
|
||||
if (codAcc === "SS007") return "Suscripciones";
|
||||
if (codAcc === "SS002") return "Contables";
|
||||
if (codAcc === "SS003") return "Impresión";
|
||||
if (codAcc === "SS004") return "Reportes";
|
||||
if (codAcc === "SS005") return "Radios";
|
||||
if (codAcc === "SS006") return "Usuarios";
|
||||
if (codAcc === "SS005") return "Radios";
|
||||
return null;
|
||||
};
|
||||
|
||||
// Función para determinar el módulo conceptual de un permiso individual
|
||||
const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
|
||||
const moduloLower = permisoModulo.toLowerCase();
|
||||
|
||||
if (moduloLower.includes("distribuidores") ||
|
||||
moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas"
|
||||
moduloLower.includes("publicaciones distribución") ||
|
||||
@@ -36,6 +36,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
|
||||
moduloLower.includes("ctrl. devoluciones")) {
|
||||
return "Distribución";
|
||||
}
|
||||
if (moduloLower.includes("suscripciones")) {
|
||||
return "Suscripciones";
|
||||
}
|
||||
if (moduloLower.includes("cuentas pagos") ||
|
||||
moduloLower.includes("cuentas notas") ||
|
||||
moduloLower.includes("cuentas tipos pagos")) {
|
||||
@@ -89,7 +92,7 @@ const PermisosChecklist: React.FC<PermisosChecklistProps> = ({
|
||||
return acc;
|
||||
}, {} as Record<string, PermisoAsignadoDto[]>);
|
||||
|
||||
const ordenModulosPrincipales = ["Distribución", "Contables", "Impresión", "Radios", "Usuarios", "Reportes", "Permisos (Definición)"];
|
||||
const ordenModulosPrincipales = ["Distribución", "Suscripciones", "Contables", "Impresión", "Usuarios", "Reportes", "Radios","Permisos (Definición)"];
|
||||
// Añadir módulos que solo tienen permiso de sección (como Radios) pero no hijos (aún)
|
||||
permisosDeSeccion.forEach(ps => {
|
||||
const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
|
||||
FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox
|
||||
} from '@mui/material';
|
||||
import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto';
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
// src/hooks/usePermissions.ts
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const usePermissions = () => {
|
||||
const { user } = useAuth(); // user aquí es de tipo UserContextData | null
|
||||
const { user } = useAuth();
|
||||
|
||||
const tienePermiso = (codigoPermisoRequerido: string): boolean => {
|
||||
if (!user) { // Si no hay usuario logueado
|
||||
// Envolvemos la función en useCallback.
|
||||
// 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;
|
||||
}
|
||||
if (user.esSuperAdmin) { // SuperAdmin tiene todos los permisos
|
||||
if (user.esSuperAdmin) {
|
||||
return true;
|
||||
}
|
||||
// Verificar si la lista de permisos del usuario incluye el código requerido
|
||||
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 {
|
||||
tienePermiso,
|
||||
isSuperAdmin: user?.esSuperAdmin ?? false,
|
||||
|
||||
8
Frontend/src/models/dtos/Comunicaciones/EmailLogDto.ts
Normal file
8
Frontend/src/models/dtos/Comunicaciones/EmailLogDto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface EmailLogDto {
|
||||
fechaEnvio: string; // Formato ISO de fecha y hora
|
||||
estado: 'Enviado' | 'Fallido';
|
||||
asunto: string;
|
||||
destinatarioEmail: string;
|
||||
error?: string | null;
|
||||
nombreUsuarioDisparo?: string | null;
|
||||
}
|
||||
23
Frontend/src/models/dtos/Comunicaciones/LoteDeEnvioDto.ts
Normal file
23
Frontend/src/models/dtos/Comunicaciones/LoteDeEnvioDto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { EmailLogDto } from "./EmailLogDto";
|
||||
|
||||
// Representa el resumen inmediato que se muestra tras el cierre
|
||||
export interface LoteDeEnvioResumenDto {
|
||||
idLoteDeEnvio: number;
|
||||
periodo: string;
|
||||
totalCorreos: number;
|
||||
totalEnviados: number;
|
||||
totalFallidos: number;
|
||||
erroresDetallados: EmailLogDto[]; // Lista de errores inmediatos
|
||||
}
|
||||
|
||||
// Representa una fila en la tabla de historial
|
||||
export interface LoteDeEnvioHistorialDto {
|
||||
idLoteDeEnvio: number;
|
||||
fechaInicio: string;
|
||||
periodo: string;
|
||||
estado: string;
|
||||
totalCorreos: number;
|
||||
totalEnviados: number;
|
||||
totalFallidos: number;
|
||||
nombreUsuarioDisparo: string;
|
||||
}
|
||||
15
Frontend/src/models/dtos/Suscripciones/AjusteDto.ts
Normal file
15
Frontend/src/models/dtos/Suscripciones/AjusteDto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface AjusteDto {
|
||||
idAjuste: number;
|
||||
fechaAjuste: string;
|
||||
idSuscriptor: number;
|
||||
idEmpresa: number;
|
||||
nombreEmpresa?: string;
|
||||
tipoAjuste: 'Credito' | 'Debito';
|
||||
monto: number;
|
||||
motivo: string;
|
||||
estado: 'Pendiente' | 'Aplicado' | 'Anulado';
|
||||
idFacturaAplicado?: number | null;
|
||||
numeroFacturaAplicado?: string | null;
|
||||
fechaAlta: string; // "yyyy-MM-dd HH:mm"
|
||||
nombreUsuarioAlta: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface AsignarPromocionDto {
|
||||
idPromocion: number;
|
||||
vigenciaDesde: string; // "yyyy-MM-dd"
|
||||
vigenciaHasta?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface CreateAjusteDto {
|
||||
idEmpresa: number;
|
||||
fechaAjuste: string;
|
||||
idSuscriptor: number;
|
||||
tipoAjuste: 'Credito' | 'Debito';
|
||||
monto: number;
|
||||
motivo: string;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
export interface CreatePromocionDto {
|
||||
descripcion: string;
|
||||
tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias';
|
||||
valor: number;
|
||||
tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia';
|
||||
valorEfecto: number;
|
||||
tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes';
|
||||
valorCondicion?: number | null;
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
activa: boolean;
|
||||
|
||||
@@ -4,6 +4,6 @@ export interface CreateSuscripcionDto {
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
estado: 'Activa' | 'Pausada' | 'Cancelada';
|
||||
diasEntrega: string[]; // ["L", "M", "X"]
|
||||
diasEntrega: string[]; // ["Lun", "Mar", "Mie"]
|
||||
observaciones?: string | null;
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
export interface FacturaDetalleDto {
|
||||
descripcion: string;
|
||||
importeNeto: number;
|
||||
}
|
||||
|
||||
export interface FacturaDto {
|
||||
idFactura: number;
|
||||
idSuscripcion: number;
|
||||
periodo: string; // "YYYY-MM"
|
||||
fechaEmision: string; // "yyyy-MM-dd"
|
||||
fechaVencimiento: string; // "yyyy-MM-dd"
|
||||
idSuscriptor: number;
|
||||
periodo: string;
|
||||
fechaEmision: string;
|
||||
fechaVencimiento: string;
|
||||
importeFinal: number;
|
||||
estado: string;
|
||||
totalPagado: number;
|
||||
saldoPendiente: number;
|
||||
estadoPago: string;
|
||||
estadoFacturacion: string;
|
||||
numeroFactura?: string | null;
|
||||
|
||||
// Datos enriquecidos para la UI
|
||||
nombreSuscriptor: string;
|
||||
nombrePublicacion: string;
|
||||
detalles: FacturaDetalleDto[]; // <-- AÑADIR ESTA LÍNEA
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type PromocionDto } from "./PromocionDto";
|
||||
|
||||
export interface PromocionAsignadaDto extends PromocionDto {
|
||||
vigenciaDesdeAsignacion: string; // "yyyy-MM-dd"
|
||||
vigenciaHastaAsignacion?: string | null;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
export interface PromocionDto {
|
||||
idPromocion: number;
|
||||
descripcion: string;
|
||||
tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias';
|
||||
valor: number;
|
||||
tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia';
|
||||
valorEfecto: number;
|
||||
tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes';
|
||||
valorCondicion?: number | null;
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
activa: boolean;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// DTO para el detalle de cada línea dentro de una factura (cada suscripción)
|
||||
export interface FacturaDetalleDto {
|
||||
descripcion: string;
|
||||
importeNeto: number;
|
||||
}
|
||||
|
||||
// DTO para cada factura individual (por empresa) dentro del resumen consolidado
|
||||
export interface FacturaConsolidadaDto {
|
||||
idFactura: number;
|
||||
nombreEmpresa: string;
|
||||
importeFinal: number;
|
||||
estadoPago: string;
|
||||
estadoFacturacion: string;
|
||||
numeroFactura?: string | null;
|
||||
totalPagado: number;
|
||||
tipoFactura: 'Mensual' | 'Alta';
|
||||
detalles: FacturaDetalleDto[];
|
||||
// Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers
|
||||
idSuscriptor: number;
|
||||
}
|
||||
|
||||
// DTO principal que agrupa todo por suscriptor para la vista de consulta
|
||||
export interface ResumenCuentaSuscriptorDto {
|
||||
idSuscriptor: number;
|
||||
nombreSuscriptor: string;
|
||||
saldoPendienteTotal: number;
|
||||
importeTotal: number;
|
||||
facturas: FacturaConsolidadaDto[];
|
||||
}
|
||||
@@ -6,6 +6,6 @@ export interface SuscripcionDto {
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
estado: 'Activa' | 'Pausada' | 'Cancelada';
|
||||
diasEntrega: string; // "L,M,X,J,V,S,D"
|
||||
diasEntrega: string; // "Lun,Mar,Mie,Jue,Vie,Sab,Dom"
|
||||
observaciones?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface UpdateAjusteDto {
|
||||
idEmpresa: number;
|
||||
fechaAjuste: string; // "yyyy-MM-dd"
|
||||
tipoAjuste: 'Credito' | 'Debito';
|
||||
monto: number;
|
||||
motivo: string;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Alert, Paper } from '@mui/material';
|
||||
import reporteService from '../../services/Reportes/reportesService';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import SeleccionaReporteDistribucionSuscripciones from './SeleccionaReporteDistribucionSuscripciones';
|
||||
|
||||
const ReporteDistribucionSuscripcionesPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeVerReporte = isSuperAdmin || tienePermiso("RR011");
|
||||
|
||||
const handleGenerateReport = async (params: { fechaDesde: string; fechaHasta: string; }) => {
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const { fileContent, fileName } = await reporteService.getReporteDistribucionSuscripcionesPdf(params.fechaDesde, params.fechaHasta);
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([fileContent], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (err: any) {
|
||||
let message = 'Ocurrió un error al generar el reporte.';
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
if (err.response.status === 404) {
|
||||
message = "No se encontraron datos para los parámetros seleccionados.";
|
||||
} else if (err.response.data instanceof Blob && err.response.data.type === "application/json") {
|
||||
const errorText = await err.response.data.text();
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
message = errorJson.message || message;
|
||||
} catch {
|
||||
message = errorText || message;
|
||||
}
|
||||
}
|
||||
}
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!puedeVerReporte) {
|
||||
return <Alert severity="error">No tiene permiso para ver este reporte.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', pt: 4 }}>
|
||||
<Paper elevation={3} sx={{ borderRadius: '8px' }}>
|
||||
<SeleccionaReporteDistribucionSuscripciones
|
||||
onGenerarReporte={handleGenerateReport}
|
||||
isLoading={loading}
|
||||
apiErrorMessage={apiError}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReporteDistribucionSuscripcionesPage;
|
||||
@@ -0,0 +1,70 @@
|
||||
// Archivo: Frontend/src/pages/Reportes/ReporteFacturasPublicidadPage.tsx
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Alert, Paper } from '@mui/material';
|
||||
import reporteService from '../../services/Reportes/reportesService';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import SeleccionaReporteFacturasPublicidad from './SeleccionaReporteFacturasPublicidad';
|
||||
|
||||
const ReporteFacturasPublicidadPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeVerReporte = isSuperAdmin || tienePermiso("RR010");
|
||||
|
||||
const handleGenerateReport = async (params: { anio: number; mes: number; }) => {
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const { fileContent, fileName } = await reporteService.getReporteFacturasPublicidadPdf(params.anio, params.mes);
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([fileContent], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (err: any) {
|
||||
let message = 'Ocurrió un error al generar el reporte.';
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
if (err.response.status === 404) {
|
||||
message = "No se encontraron datos para los parámetros seleccionados.";
|
||||
} else if (err.response.data instanceof Blob && err.response.data.type === "application/json") {
|
||||
const errorText = await err.response.data.text();
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
message = errorJson.message || message;
|
||||
} catch {
|
||||
message = errorText || message;
|
||||
}
|
||||
}
|
||||
}
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!puedeVerReporte) {
|
||||
return <Alert severity="error">No tiene permiso para ver este reporte.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', pt: 4 }}>
|
||||
<Paper elevation={3} sx={{ borderRadius: '8px' }}>
|
||||
<SeleccionaReporteFacturasPublicidad
|
||||
onGenerarReporte={handleGenerateReport}
|
||||
isLoading={loading}
|
||||
apiErrorMessage={apiError}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReporteFacturasPublicidadPage;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user