Refactor: Mejora la lógica de facturación y la UI
Este commit introduce una refactorización significativa en el módulo de
suscripciones para alinear el sistema con reglas de negocio clave:
facturación consolidada por empresa, cobro a mes adelantado con
imputación de ajustes diferida, y una interfaz de usuario más clara.
Backend:
- **Facturación por Empresa:** Se modifica `FacturacionService` para
agrupar las suscripciones por cliente y empresa, generando una
factura consolidada para cada combinación. Esto asegura la correcta
separación fiscal.
- **Imputación de Ajustes:** Se ajusta la lógica para que la facturación
de un período (ej. Septiembre) aplique únicamente los ajustes
pendientes cuya fecha corresponde al período anterior (Agosto).
- **Cierre Secuencial:** Se implementa una validación en
`GenerarFacturacionMensual` que impide generar la facturación de un
período si el anterior no ha sido cerrado, garantizando el orden
cronológico.
- **Emails Consolidados:** El proceso de notificación automática al
generar el cierre ahora envía un único email consolidado por
suscriptor, detallando los cargos de todas sus facturas/empresas.
- **Envío de PDF Individual:** Se refactoriza el endpoint de envío manual
para que opere sobre una `idFactura` individual y adjunte el PDF
correspondiente si existe.
- **Repositorios Mejorados:** Se optimizan y añaden métodos en
`FacturaRepository` y `AjusteRepository` para soportar los nuevos
requisitos de filtrado y consulta de datos consolidados.
Frontend:
- **Separación de Vistas:** La página de "Facturación" se divide en dos:
- `ProcesosPage`: Para acciones masivas (generar cierre, archivo de
débito, procesar respuesta).
- `ConsultaFacturasPage`: Una nueva página dedicada a buscar,
filtrar y gestionar facturas individuales con una interfaz de doble
acordeón (Suscriptor -> Empresa).
- **Filtros Avanzados:** La página `ConsultaFacturasPage` ahora incluye
filtros por nombre de suscriptor, estado de pago y estado de
facturación.
- **Filtros de Fecha por Defecto:** La página de "Cuenta Corriente"
ahora filtra por el mes actual por defecto para mejorar el rendimiento
y la usabilidad.
- **Validación de Fechas:** Se añade lógica en los filtros de fecha para
impedir la selección de rangos inválidos.
- **Validación de Monto de Pago:** El modal de pago manual ahora impide
registrar un monto superior al saldo pendiente de la factura.
This commit is contained in:
@@ -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,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,7 @@ namespace GestionIntegral.Api.Controllers
|
||||
private const string PermisoVerReporteConsumoBobinas = "RR007";
|
||||
private const string PermisoVerReporteNovedadesCanillas = "RR004";
|
||||
private const string PermisoVerReporteListadoDistMensual = "RR009";
|
||||
private const string PermisoVerReporteFacturasPublicidad = "RR010";
|
||||
|
||||
public ReportesController(
|
||||
IReportesService reportesService,
|
||||
@@ -1676,5 +1670,54 @@ namespace GestionIntegral.Api.Controllers
|
||||
return StatusCode(500, "Error interno al generar el PDF del reporte.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("suscripciones/facturas-para-publicidad/pdf")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,10 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
// GET: api/suscriptores/{idSuscriptor}/ajustes
|
||||
[HttpGet("~/api/suscriptores/{idSuscriptor:int}/ajustes")]
|
||||
[ProducesResponseType(typeof(IEnumerable<AjusteDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetAjustesPorSuscriptor(int idSuscriptor)
|
||||
public async Task<IActionResult> GetAjustesPorSuscriptor(int idSuscriptor, [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
|
||||
var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor);
|
||||
var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor, fechaDesde, fechaHasta);
|
||||
return Ok(ajustes);
|
||||
}
|
||||
|
||||
@@ -74,5 +74,21 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
|
||||
return Ok(new { message = "Ajuste anulado correctamente." });
|
||||
}
|
||||
|
||||
// PUT: api/ajustes/{id}
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,8 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
{
|
||||
private readonly IFacturacionService _facturacionService;
|
||||
private readonly ILogger<FacturacionController> _logger;
|
||||
|
||||
// Permiso para generar facturación (a crear en la BD)
|
||||
private const string PermisoGenerarFacturacion = "SU006";
|
||||
private const string PermisoGestionarFacturacion = "SU006";
|
||||
private const string PermisoEnviarEmail = "SU009";
|
||||
|
||||
public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger)
|
||||
{
|
||||
@@ -28,67 +27,94 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
private int? GetCurrentUserId()
|
||||
{
|
||||
if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId;
|
||||
_logger.LogWarning("No se pudo obtener el UserId del token JWT en FacturacionController.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// POST: api/facturacion/{anio}/{mes}
|
||||
[HttpPost("{anio:int}/{mes:int}")]
|
||||
public async Task<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 Ok(new { message = mensaje, facturasGeneradas });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// 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)
|
||||
// POST: api/facturacion/{idFactura}/enviar-factura-pdf
|
||||
[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);
|
||||
var (exito, error) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura);
|
||||
|
||||
if (!exito)
|
||||
{
|
||||
return BadRequest(new { message = error });
|
||||
}
|
||||
return Ok(new { message = "Email con factura PDF enviado a la cola de procesamiento." });
|
||||
}
|
||||
|
||||
return Ok(new { message = "Email enviado a la cola de procesamiento." });
|
||||
// POST: api/facturacion/{anio}/{mes}/suscriptor/{idSuscriptor}/enviar-aviso
|
||||
[HttpPost("{anio:int}/{mes:int}/suscriptor/{idSuscriptor:int}/enviar-aviso")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> EnviarAvisoPorEmail(int anio, int mes, int idSuscriptor)
|
||||
{
|
||||
// Usamos el permiso de enviar email
|
||||
if (!TienePermiso("SU009")) return Forbid();
|
||||
|
||||
var (exito, error) = await _facturacionService.EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor);
|
||||
|
||||
if (!exito)
|
||||
{
|
||||
if (error != null && (error.Contains("no encontrada") || error.Contains("no es válido")))
|
||||
{
|
||||
return NotFound(new { message = error });
|
||||
}
|
||||
return BadRequest(new { message = error });
|
||||
}
|
||||
|
||||
return Ok(new { message = "Email consolidado para el suscriptor ha sido enviado a la cola de procesamiento." });
|
||||
}
|
||||
|
||||
// GET: api/facturacion/{anio}/{mes}
|
||||
[HttpGet("{anio:int}/{mes:int}")]
|
||||
public async Task<IActionResult> GetFacturas(
|
||||
int anio, int mes,
|
||||
[FromQuery] string? nombreSuscriptor,
|
||||
[FromQuery] string? estadoPago,
|
||||
[FromQuery] string? estadoFacturacion)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
|
||||
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." });
|
||||
|
||||
var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion);
|
||||
return Ok(resumenes);
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("{anio:int}/{mes:int}")]
|
||||
public async Task<IActionResult> GenerarFacturacion(int anio, int mes)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El año y el mes proporcionados no son válidos." });
|
||||
var (exito, mensaje, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
|
||||
if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje });
|
||||
return Ok(new { message = mensaje, facturasGeneradas });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -45,5 +45,6 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -547,5 +547,50 @@ 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Dapper;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using System.Data;
|
||||
using System.Text;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
@@ -15,36 +16,72 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Ajuste ajuste, IDbTransaction transaction)
|
||||
{
|
||||
const string sql = @"
|
||||
UPDATE dbo.susc_Ajustes SET
|
||||
FechaAjuste = @FechaAjuste,
|
||||
TipoAjuste = @TipoAjuste,
|
||||
Monto = @Monto,
|
||||
Motivo = @Motivo
|
||||
WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';"; // Solo se pueden editar los pendientes
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
var rows = await transaction.Connection.ExecuteAsync(sql, ajuste, transaction);
|
||||
return rows == 1;
|
||||
}
|
||||
|
||||
// Actualizar también el CreateAsync
|
||||
public async Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT INTO dbo.susc_Ajustes (IdSuscriptor, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta)
|
||||
INSERT INTO dbo.susc_Ajustes (IdSuscriptor, FechaAjuste, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES (@IdSuscriptor, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());";
|
||||
|
||||
VALUES (@IdSuscriptor, @FechaAjuste, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());";
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
|
||||
return await transaction.Connection.QuerySingleOrDefaultAsync<Ajuste>(sql, nuevoAjuste, transaction);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor)
|
||||
public async Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor ORDER BY FechaAlta DESC;";
|
||||
var sqlBuilder = new StringBuilder("SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor");
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("IdSuscriptor", idSuscriptor);
|
||||
|
||||
if (fechaDesde.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND FechaAjuste >= @FechaDesde");
|
||||
parameters.Add("FechaDesde", fechaDesde.Value.Date);
|
||||
}
|
||||
if (fechaHasta.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND FechaAjuste <= @FechaHasta");
|
||||
parameters.Add("FechaHasta", fechaHasta.Value.Date);
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY FechaAlta DESC;");
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<Ajuste>(sql, new { IdSuscriptor = idSuscriptor });
|
||||
return await connection.QueryAsync<Ajuste>(sqlBuilder.ToString(), parameters);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Ajuste>> GetAjustesPendientesPorSuscriptorAsync(int idSuscriptor, IDbTransaction transaction)
|
||||
public async Task<IEnumerable<Ajuste>> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, DateTime fechaHasta, IDbTransaction transaction)
|
||||
{
|
||||
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor AND Estado = 'Pendiente';";
|
||||
const string sql = @"
|
||||
SELECT * FROM dbo.susc_Ajustes
|
||||
WHERE IdSuscriptor = @IdSuscriptor
|
||||
AND Estado = 'Pendiente'
|
||||
AND FechaAjuste <= @FechaHasta;"; // La condición clave es que la fecha del ajuste sea HASTA la fecha límite
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
return await transaction.Connection.QueryAsync<Ajuste>(sql, new { IdSuscriptor = idSuscriptor }, transaction);
|
||||
return await transaction.Connection.QueryAsync<Ajuste>(sql, new { idSuscriptor, FechaHasta = fechaHasta }, transaction);
|
||||
}
|
||||
|
||||
public async Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction)
|
||||
@@ -89,5 +126,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
var rows = await transaction.Connection.ExecuteAsync(sql, new { IdAjuste = idAjuste, IdUsuario = idUsuario }, transaction);
|
||||
return rows == 1;
|
||||
}
|
||||
|
||||
public async Task<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)
|
||||
@@ -48,25 +60,21 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sqlInsert = @"
|
||||
INSERT INTO dbo.susc_Facturas
|
||||
(IdSuscripcion, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,
|
||||
DescuentoAplicado, ImporteFinal, Estado)
|
||||
INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES
|
||||
(@IdSuscripcion, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,
|
||||
@DescuentoAplicado, @ImporteFinal, @Estado);";
|
||||
VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);";
|
||||
|
||||
return await transaction.Connection.QuerySingleAsync<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 +84,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sql = "UPDATE dbo.susc_Facturas SET NumeroFactura = @NumeroFactura, Estado = 'Pendiente de Cobro' WHERE IdFactura = @IdFactura;";
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, IdFactura = idFactura }, transaction);
|
||||
const string sql = @"
|
||||
UPDATE dbo.susc_Facturas SET
|
||||
NumeroFactura = @NumeroFactura,
|
||||
EstadoFacturacion = 'Facturado'
|
||||
WHERE IdFactura = @IdFactura;";
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, idFactura }, transaction);
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
@@ -87,59 +99,116 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
}
|
||||
const string sql = "UPDATE dbo.susc_Facturas SET IdLoteDebito = @IdLoteDebito, Estado = 'Enviada a Débito' WHERE IdFactura IN @IdsFacturas;";
|
||||
const string sql = "UPDATE dbo.susc_Facturas SET IdLoteDebito = @IdLoteDebito WHERE IdFactura IN @IdsFacturas;";
|
||||
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdLoteDebito = idLoteDebito, IdsFacturas = idsFacturas }, transaction);
|
||||
return rowsAffected == idsFacturas.Count();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo)
|
||||
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT f.*, s.NombreCompleto AS NombreSuscriptor, p.Nombre AS NombrePublicacion
|
||||
FROM dbo.susc_Facturas f
|
||||
JOIN dbo.susc_Suscripciones sc ON f.IdSuscripcion = sc.IdSuscripcion
|
||||
JOIN dbo.susc_Suscriptores s ON sc.IdSuscriptor = s.IdSuscriptor
|
||||
JOIN dbo.dist_dtPublicaciones p ON sc.IdPublicacion = p.Id_Publicacion
|
||||
WHERE f.Periodo = @Periodo
|
||||
ORDER BY s.NombreCompleto;
|
||||
";
|
||||
var sqlBuilder = new StringBuilder(@"
|
||||
WITH FacturaConEmpresa AS (
|
||||
-- Esta subconsulta obtiene el IdEmpresa para cada factura basándose en la primera suscripción que encuentra en sus detalles.
|
||||
-- Esto es seguro porque nuestra lógica de negocio asegura que todos los detalles de una factura pertenecen a la misma empresa.
|
||||
SELECT
|
||||
f.IdFactura,
|
||||
(SELECT TOP 1 p.Id_Empresa
|
||||
FROM dbo.susc_FacturaDetalles fd
|
||||
JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion
|
||||
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
|
||||
WHERE fd.IdFactura = f.IdFactura) AS IdEmpresa
|
||||
FROM dbo.susc_Facturas f
|
||||
WHERE f.Periodo = @Periodo
|
||||
)
|
||||
SELECT
|
||||
f.*,
|
||||
s.NombreCompleto AS NombreSuscriptor,
|
||||
fce.IdEmpresa,
|
||||
(SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos pg WHERE pg.IdFactura = f.IdFactura AND pg.Estado = 'Aprobado') AS TotalPagado
|
||||
FROM dbo.susc_Facturas f
|
||||
JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor
|
||||
JOIN FacturaConEmpresa fce ON f.IdFactura = fce.IdFactura
|
||||
WHERE f.Periodo = @Periodo");
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("Periodo", periodo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(nombreSuscriptor))
|
||||
{
|
||||
sqlBuilder.Append(" AND s.NombreCompleto LIKE @NombreSuscriptor");
|
||||
parameters.Add("NombreSuscriptor", $"%{nombreSuscriptor}%");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(estadoPago))
|
||||
{
|
||||
sqlBuilder.Append(" AND f.EstadoPago = @EstadoPago");
|
||||
parameters.Add("EstadoPago", estadoPago);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(estadoFacturacion))
|
||||
{
|
||||
sqlBuilder.Append(" AND f.EstadoFacturacion = @EstadoFacturacion");
|
||||
parameters.Add("EstadoFacturacion", estadoFacturacion);
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;");
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
var result = await connection.QueryAsync<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>> 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
// Archivo: GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs
|
||||
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
public interface IAjusteRepository
|
||||
{
|
||||
Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction);
|
||||
Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor);
|
||||
Task<IEnumerable<Ajuste>> GetAjustesPendientesPorSuscriptorAsync(int idSuscriptor, IDbTransaction transaction);
|
||||
Task<Ajuste?> GetByIdAsync(int idAjuste);
|
||||
Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction);
|
||||
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, 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);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
{
|
||||
Task<Factura?> GetByIdAsync(int idFactura);
|
||||
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<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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
OUTPUT INSERTED.*
|
||||
VALUES
|
||||
(@IdFactura, @FechaPago, @IdFormaPago, @Monto, @Estado, @Referencia, @Observaciones, @IdUsuarioRegistro);";
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
return await transaction.Connection.QuerySingleAsync<Pago>(sqlInsert, nuevoPago, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
public async Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas)
|
||||
{
|
||||
var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones");
|
||||
if(soloActivas)
|
||||
if (soloActivas)
|
||||
{
|
||||
sql.Append(" WHERE Activa = 1");
|
||||
}
|
||||
@@ -39,10 +39,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
public async Task<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
|
||||
AND p.FechaInicio <= @FechaPeriodo
|
||||
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo);";
|
||||
|
||||
AND p.Activa = 1
|
||||
-- 1. La promoción general debe estar activa en el período
|
||||
AND p.FechaInicio <= @FechaPeriodo
|
||||
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo)
|
||||
-- 2. La asignación específica al cliente debe estar activa en el período
|
||||
AND sp.VigenciaDesde <= @FechaPeriodo
|
||||
AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);";
|
||||
if (transaction?.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
|
||||
}
|
||||
|
||||
return await transaction.Connection.QueryAsync<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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,9 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
return Enumerable.Empty<Suscripcion>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
@@ -61,7 +60,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
AND su.Activo = 1
|
||||
AND s.FechaInicio <= @UltimoDiaMes
|
||||
AND (s.FechaFin IS NULL OR s.FechaFin >= @PrimerDiaMes);";
|
||||
|
||||
|
||||
if (transaction == null || transaction.Connection == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
|
||||
@@ -85,7 +84,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
VALUES
|
||||
(@IdSuscriptor, @IdPublicacion, @FechaInicio, @FechaFin, @Estado, @DiasEntrega,
|
||||
@Observaciones, @IdUsuarioAlta, GETDATE());";
|
||||
|
||||
|
||||
return await transaction.Connection.QuerySingleAsync<Suscripcion>(sqlInsert, nuevaSuscripcion, transaction);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
WHERE sp.IdSuscripcion = @IdSuscripcion;";
|
||||
|
||||
SELECT sp.*, p.*
|
||||
FROM dbo.susc_SuscripcionPromociones sp
|
||||
JOIN dbo.susc_Promociones p ON sp.IdPromocion = p.IdPromocion
|
||||
WHERE sp.IdSuscripcion = @IdSuscripcion;";
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<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);";
|
||||
|
||||
await transaction.Connection.ExecuteAsync(sql,
|
||||
new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion, IdUsuario = idUsuario },
|
||||
transaction);
|
||||
INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno, VigenciaDesde, VigenciaHasta, FechaAsignacion)
|
||||
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());";
|
||||
|
||||
await transaction.Connection.ExecuteAsync(sql, asignacion, transaction);
|
||||
}
|
||||
|
||||
public async Task<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public int IdAjuste { get; set; }
|
||||
public int IdSuscriptor { get; set; }
|
||||
public string FechaAjuste { get; set; } = string.Empty;
|
||||
public string TipoAjuste { get; set; } = string.Empty;
|
||||
public decimal Monto { get; set; }
|
||||
public string Motivo { get; set; } = string.Empty;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
[Required]
|
||||
public int IdSuscriptor { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime FechaAjuste { get; set; }
|
||||
|
||||
[Required]
|
||||
[RegularExpression("^(Credito|Debito)$", ErrorMessage = "El tipo de ajuste debe ser 'Credito' o 'Debito'.")]
|
||||
public string TipoAjuste { get; set; } = string.Empty;
|
||||
|
||||
@@ -7,22 +7,25 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class CreatePromocionDto
|
||||
{
|
||||
[Required(ErrorMessage = "La descripción es obligatoria.")]
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Descripcion { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "El tipo de promoción es obligatorio.")]
|
||||
public string TipoPromocion { get; set; } = string.Empty;
|
||||
[Required]
|
||||
public string TipoEfecto { get; set; } = string.Empty; // Corregido
|
||||
|
||||
[Required(ErrorMessage = "El valor es obligatorio.")]
|
||||
[Range(0.01, 99999999.99, ErrorMessage = "El valor debe ser positivo.")]
|
||||
public decimal Valor { get; set; }
|
||||
[Required]
|
||||
[Range(0, 99999999.99)] // Se permite 0 para bonificaciones
|
||||
public decimal ValorEfecto { get; set; } // Corregido
|
||||
|
||||
[Required(ErrorMessage = "La fecha de inicio es obligatoria.")]
|
||||
public DateTime FechaInicio { get; set; }
|
||||
|
||||
public DateTime? FechaFin { get; set; }
|
||||
[Required]
|
||||
public string TipoCondicion { get; set; } = string.Empty;
|
||||
|
||||
public int? ValorCondicion { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime FechaInicio { get; set; }
|
||||
public DateTime? FechaFin { get; set; }
|
||||
public bool Activa { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class FacturaConsolidadaDto
|
||||
{
|
||||
public int IdFactura { get; set; }
|
||||
public string NombreEmpresa { get; set; } = string.Empty;
|
||||
public decimal ImporteFinal { get; set; }
|
||||
public string EstadoPago { get; set; } = string.Empty;
|
||||
public string EstadoFacturacion { get; set; } = string.Empty;
|
||||
public string? NumeroFactura { get; set; }
|
||||
public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
/// <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,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones;
|
||||
public class UpdateAjusteDto
|
||||
{
|
||||
[Required]
|
||||
public DateTime FechaAjuste { get; set; }
|
||||
[Required]
|
||||
[RegularExpression("^(Credito|Debito)$")]
|
||||
public string TipoAjuste { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Range(0.01, 999999.99)]
|
||||
public decimal Monto { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(250)]
|
||||
public string Motivo { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace GestionIntegral.Api.Models.Suscripciones
|
||||
{
|
||||
public int IdAjuste { get; set; }
|
||||
public int IdSuscriptor { get; set; }
|
||||
public DateTime FechaAjuste { get; set; }
|
||||
public string TipoAjuste { get; set; } = string.Empty;
|
||||
public decimal Monto { get; set; }
|
||||
public string Motivo { get; set; } = string.Empty;
|
||||
|
||||
@@ -3,14 +3,15 @@ namespace GestionIntegral.Api.Models.Suscripciones
|
||||
public class Factura
|
||||
{
|
||||
public int IdFactura { get; set; }
|
||||
public int IdSuscripcion { get; set; }
|
||||
public int IdSuscriptor { get; set; }
|
||||
public string Periodo { get; set; } = string.Empty;
|
||||
public DateTime FechaEmision { get; set; }
|
||||
public DateTime FechaVencimiento { get; set; }
|
||||
public decimal ImporteBruto { get; set; }
|
||||
public decimal DescuentoAplicado { get; set; }
|
||||
public decimal ImporteFinal { get; set; }
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public string EstadoPago { get; set; } = string.Empty;
|
||||
public string EstadoFacturacion { get; set; } = string.Empty;
|
||||
public string? NumeroFactura { get; set; }
|
||||
public int? IdLoteDebito { get; set; }
|
||||
public string? MotivoRechazo { get; set; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ 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>();
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml)
|
||||
public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, byte[]? attachment = null, string? attachmentName = null)
|
||||
{
|
||||
var email = new MimeMessage();
|
||||
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
|
||||
@@ -26,6 +26,10 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
email.Subject = asunto;
|
||||
|
||||
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
|
||||
if (attachment != null && !string.IsNullOrEmpty(attachmentName))
|
||||
{
|
||||
builder.Attachments.Add(attachmentName, attachment, ContentType.Parse("application/pdf"));
|
||||
}
|
||||
email.Body = builder.ToMessageBody();
|
||||
|
||||
using var smtp = new SmtpClient();
|
||||
@@ -46,5 +50,44 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
await smtp.DisconnectAsync(true);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EnviarEmailConsolidadoAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, List<(byte[] content, string name)> adjuntos)
|
||||
{
|
||||
var email = new MimeMessage();
|
||||
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
|
||||
email.From.Add(email.Sender);
|
||||
email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail));
|
||||
email.Subject = asunto;
|
||||
|
||||
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
|
||||
|
||||
if (adjuntos != null)
|
||||
{
|
||||
foreach (var adjunto in adjuntos)
|
||||
{
|
||||
builder.Attachments.Add(adjunto.name, adjunto.content, ContentType.Parse("application/pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
email.Body = builder.ToMessageBody();
|
||||
|
||||
using var smtp = new SmtpClient();
|
||||
try
|
||||
{
|
||||
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
|
||||
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
|
||||
await smtp.SendAsync(email);
|
||||
_logger.LogInformation("Email consolidado enviado exitosamente a {Destinatario}", destinatarioEmail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al enviar email consolidado a {Destinatario}", destinatarioEmail);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await smtp.DisconnectAsync(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
|
||||
{
|
||||
public interface IEmailService
|
||||
{
|
||||
Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml);
|
||||
Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, byte[]? attachment = null, string? attachmentName = null);
|
||||
Task EnviarEmailConsolidadoAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, List<(byte[] content, string name)> adjuntos);
|
||||
}
|
||||
}
|
||||
@@ -72,5 +72,6 @@ namespace GestionIntegral.Api.Services.Reportes
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,25 @@ 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
IdAjuste = ajuste.IdAjuste,
|
||||
IdSuscriptor = ajuste.IdSuscriptor,
|
||||
FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"),
|
||||
TipoAjuste = ajuste.TipoAjuste,
|
||||
Monto = ajuste.Monto,
|
||||
Motivo = ajuste.Motivo,
|
||||
@@ -47,9 +48,9 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor)
|
||||
public async Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta)
|
||||
{
|
||||
var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor);
|
||||
var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor, fechaDesde, fechaHasta);
|
||||
var dtosTasks = ajustes.Select(a => MapToDto(a));
|
||||
var dtos = await Task.WhenAll(dtosTasks);
|
||||
return dtos.Where(dto => dto != null)!;
|
||||
@@ -62,10 +63,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
return (null, "El suscriptor especificado no existe.");
|
||||
}
|
||||
|
||||
|
||||
var nuevoAjuste = new Ajuste
|
||||
{
|
||||
IdSuscriptor = createDto.IdSuscriptor,
|
||||
FechaAjuste = createDto.FechaAjuste.Date,
|
||||
TipoAjuste = createDto.TipoAjuste,
|
||||
Monto = createDto.Monto,
|
||||
Motivo = createDto.Motivo,
|
||||
@@ -119,5 +121,36 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
return (false, "Error interno al anular el ajuste.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> ActualizarAjuste(int idAjuste, UpdateAjusteDto updateDto)
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste);
|
||||
if (ajuste == null) return (false, "Ajuste no encontrado.");
|
||||
if (ajuste.Estado != "Pendiente") return (false, $"No se puede modificar un ajuste en estado '{ajuste.Estado}'.");
|
||||
|
||||
ajuste.FechaAjuste = updateDto.FechaAjuste;
|
||||
ajuste.TipoAjuste = updateDto.TipoAjuste;
|
||||
ajuste.Monto = updateDto.Monto;
|
||||
ajuste.Motivo = updateDto.Motivo;
|
||||
|
||||
var actualizado = await _ajusteRepository.UpdateAsync(ajuste, transaction);
|
||||
if (!actualizado) throw new DataException("La actualización falló o el ajuste ya no estaba pendiente.");
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Ajuste ID {IdAjuste} actualizado.", idAjuste);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error al actualizar ajuste ID {IdAjuste}", idAjuste);
|
||||
return (false, "Error interno al actualizar el ajuste.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
// Archivo: GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs
|
||||
|
||||
using GestionIntegral.Api.Data;
|
||||
using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using System.Data;
|
||||
using System.Text;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.IO;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
@@ -19,21 +18,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
private readonly IFacturaRepository _facturaRepository;
|
||||
private readonly ISuscriptorRepository _suscriptorRepository;
|
||||
private readonly ISuscripcionRepository _suscripcionRepository;
|
||||
private readonly ILoteDebitoRepository _loteDebitoRepository;
|
||||
private readonly IFormaPagoRepository _formaPagoRepository;
|
||||
private readonly IPagoRepository _pagoRepository;
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<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)
|
||||
|
||||
public DebitoAutomaticoService(
|
||||
IFacturaRepository facturaRepository,
|
||||
ISuscriptorRepository suscriptorRepository,
|
||||
ISuscripcionRepository suscripcionRepository,
|
||||
ILoteDebitoRepository loteDebitoRepository,
|
||||
IFormaPagoRepository formaPagoRepository,
|
||||
IPagoRepository pagoRepository,
|
||||
@@ -42,7 +38,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
_facturaRepository = facturaRepository;
|
||||
_suscriptorRepository = suscriptorRepository;
|
||||
_suscripcionRepository = suscripcionRepository;
|
||||
_loteDebitoRepository = loteDebitoRepository;
|
||||
_formaPagoRepository = formaPagoRepository;
|
||||
_pagoRepository = pagoRepository;
|
||||
@@ -61,9 +56,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
|
||||
try
|
||||
{
|
||||
// Buscamos facturas que están listas para ser enviadas al cobro.
|
||||
var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction);
|
||||
|
||||
if (!facturasParaDebito.Any())
|
||||
{
|
||||
return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado.");
|
||||
@@ -73,7 +66,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var cantidadRegistros = facturasParaDebito.Count();
|
||||
var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt";
|
||||
|
||||
// 1. Crear el Lote de Débito
|
||||
var nuevoLote = new LoteDebito
|
||||
{
|
||||
Periodo = periodo,
|
||||
@@ -85,18 +77,14 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction);
|
||||
if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito.");
|
||||
|
||||
// 2. Generar el contenido del archivo
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros));
|
||||
|
||||
foreach (var item in facturasParaDebito)
|
||||
{
|
||||
sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor));
|
||||
}
|
||||
|
||||
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros));
|
||||
|
||||
// 3. Actualizar las facturas con el ID del lote
|
||||
var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura);
|
||||
bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction);
|
||||
if (!actualizadas) throw new DataException("No se pudieron actualizar las facturas con la información del lote.");
|
||||
@@ -115,17 +103,12 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
|
||||
private async Task<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 facturasDelPeriodo = await _facturaRepository.GetByPeriodoAsync(periodo);
|
||||
var resultado = new List<(Factura, Suscriptor)>();
|
||||
|
||||
foreach (var f in facturas.Where(fa => fa.Estado == "Pendiente de Cobro"))
|
||||
|
||||
foreach (var f in facturasDelPeriodo.Where(fa => fa.EstadoPago == "Pendiente"))
|
||||
{
|
||||
var suscripcion = await _suscripcionRepository.GetByIdAsync(f.IdSuscripcion);
|
||||
if (suscripcion == null) continue;
|
||||
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor);
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor);
|
||||
if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue;
|
||||
|
||||
var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida);
|
||||
@@ -231,28 +214,17 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
string? linea;
|
||||
while ((linea = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
// Ignorar header/trailer si los hubiera (basado en el formato real)
|
||||
if (linea.Length < 20) continue;
|
||||
|
||||
respuesta.TotalRegistrosLeidos++;
|
||||
|
||||
// =================================================================
|
||||
// === ESTA ES LA LÓGICA DE PARSEO QUE SE DEBE AJUSTAR ===
|
||||
// === CON EL FORMATO REAL DEL ARCHIVO DE RESPUESTA ===
|
||||
// =================================================================
|
||||
// Asunción: Pos 1-15: Referencia, Pos 16-17: Estado, Pos 18-20: Rechazo
|
||||
var referencia = linea.Substring(0, 15).Trim();
|
||||
var estadoProceso = linea.Substring(15, 2).Trim();
|
||||
var motivoRechazo = linea.Substring(17, 3).Trim();
|
||||
// Asumimos que podemos extraer el IdFactura de la referencia
|
||||
if (!int.TryParse(referencia.Replace("SUSC-", ""), out int idFactura))
|
||||
{
|
||||
respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'.");
|
||||
continue;
|
||||
}
|
||||
// =================================================================
|
||||
// === FIN DE LA LÓGICA DE PARSEO ===
|
||||
// =================================================================
|
||||
|
||||
var factura = await _facturaRepository.GetByIdAsync(idFactura);
|
||||
if (factura == null)
|
||||
@@ -264,27 +236,24 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var nuevoPago = new Pago
|
||||
{
|
||||
IdFactura = idFactura,
|
||||
FechaPago = DateTime.Now.Date, // O la fecha que venga en el archivo
|
||||
IdFormaPago = 1, // 1 = Débito Automático
|
||||
FechaPago = DateTime.Now.Date,
|
||||
IdFormaPago = 1,
|
||||
Monto = factura.ImporteFinal,
|
||||
IdUsuarioRegistro = idUsuario,
|
||||
Referencia = $"Lote {factura.IdLoteDebito} - Banco"
|
||||
};
|
||||
|
||||
if (estadoProceso == "AP") // "AP" = Aprobado (Asunción)
|
||||
if (estadoProceso == "AP")
|
||||
{
|
||||
nuevoPago.Estado = "Aprobado";
|
||||
await _pagoRepository.CreateAsync(nuevoPago, transaction);
|
||||
await _facturaRepository.UpdateEstadoAsync(idFactura, "Pagada", transaction);
|
||||
await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction);
|
||||
respuesta.PagosAprobados++;
|
||||
}
|
||||
else // Asumimos que cualquier otra cosa es Rechazado
|
||||
else
|
||||
{
|
||||
nuevoPago.Estado = "Rechazado";
|
||||
await _pagoRepository.CreateAsync(nuevoPago, transaction);
|
||||
factura.Estado = "Rechazada";
|
||||
factura.MotivoRechazo = motivoRechazo;
|
||||
// Necesitamos un método en el repo para actualizar estado y motivo
|
||||
await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction);
|
||||
respuesta.PagosRechazados++;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ using GestionIntegral.Api.Models.Distribucion;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using GestionIntegral.Api.Services.Comunicaciones;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
@@ -13,40 +15,393 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
private readonly ISuscripcionRepository _suscripcionRepository;
|
||||
private readonly IFacturaRepository _facturaRepository;
|
||||
private readonly IEmpresaRepository _empresaRepository;
|
||||
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
|
||||
private readonly IPrecioRepository _precioRepository;
|
||||
private readonly IPromocionRepository _promocionRepository;
|
||||
private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación
|
||||
private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ISuscriptorRepository _suscriptorRepository;
|
||||
private readonly IAjusteRepository _ajusteRepository;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IPublicacionRepository _publicacionRepository;
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<FacturacionService> _logger;
|
||||
private readonly string _facturasPdfPath;
|
||||
|
||||
public FacturacionService(
|
||||
ISuscripcionRepository suscripcionRepository,
|
||||
IFacturaRepository facturaRepository,
|
||||
IEmpresaRepository empresaRepository,
|
||||
IFacturaDetalleRepository facturaDetalleRepository,
|
||||
IPrecioRepository precioRepository,
|
||||
IPromocionRepository promocionRepository,
|
||||
IRecargoZonaRepository recargoZonaRepository,
|
||||
ISuscriptorRepository suscriptorRepository,
|
||||
DbConnectionFactory connectionFactory,
|
||||
IAjusteRepository ajusteRepository,
|
||||
IEmailService emailService,
|
||||
ILogger<FacturacionService> logger)
|
||||
IPublicacionRepository publicacionRepository,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<FacturacionService> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_suscripcionRepository = suscripcionRepository;
|
||||
_facturaRepository = facturaRepository;
|
||||
_empresaRepository = empresaRepository;
|
||||
_facturaDetalleRepository = facturaDetalleRepository;
|
||||
_precioRepository = precioRepository;
|
||||
_promocionRepository = promocionRepository;
|
||||
_recargoZonaRepository = recargoZonaRepository;
|
||||
_suscriptorRepository = suscriptorRepository;
|
||||
_connectionFactory = connectionFactory;
|
||||
_ajusteRepository = ajusteRepository;
|
||||
_emailService = emailService;
|
||||
_publicacionRepository = publicacionRepository;
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
_facturasPdfPath = configuration.GetValue<string>("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF";
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
|
||||
{
|
||||
var periodoActual = new DateTime(anio, mes, 1);
|
||||
var periodoActualStr = periodoActual.ToString("yyyy-MM");
|
||||
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodoActualStr, idUsuario);
|
||||
|
||||
var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync();
|
||||
if (ultimoPeriodoFacturadoStr != null)
|
||||
{
|
||||
var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture);
|
||||
if (periodoActual != ultimoPeriodo.AddMonths(1))
|
||||
{
|
||||
var periodoEsperado = ultimoPeriodo.AddMonths(1).ToString("MMMM 'de' yyyy", new CultureInfo("es-ES"));
|
||||
return (false, $"Error: No se puede generar la facturación de {periodoActual:MMMM 'de' yyyy}. El siguiente período a generar es {periodoEsperado}.", 0);
|
||||
}
|
||||
}
|
||||
|
||||
var facturasCreadas = new List<Factura>();
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction);
|
||||
if (!suscripcionesActivas.Any())
|
||||
{
|
||||
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0);
|
||||
}
|
||||
|
||||
// 1. Enriquecer las suscripciones con el IdEmpresa de su publicación
|
||||
var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>();
|
||||
foreach (var s in suscripcionesActivas)
|
||||
{
|
||||
var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion);
|
||||
if (pub != null)
|
||||
{
|
||||
suscripcionesConEmpresa.Add((s, pub.IdEmpresa));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Agrupar por la combinación (Suscriptor, Empresa)
|
||||
var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa });
|
||||
|
||||
int facturasGeneradas = 0;
|
||||
foreach (var grupo in gruposParaFacturar)
|
||||
{
|
||||
int idSuscriptor = grupo.Key.IdSuscriptor;
|
||||
int idEmpresa = grupo.Key.IdEmpresa;
|
||||
|
||||
// La verificación de existencia ahora debe ser más específica, pero por ahora la omitimos
|
||||
// para no añadir otro método al repositorio. Asumimos que no se corre dos veces.
|
||||
|
||||
decimal importeBrutoTotal = 0;
|
||||
decimal descuentoPromocionesTotal = 0;
|
||||
var detallesParaFactura = new List<FacturaDetalle>();
|
||||
|
||||
// 3. Calcular el importe para cada suscripción DENTRO del grupo
|
||||
foreach (var item in grupo)
|
||||
{
|
||||
var suscripcion = item.Suscripcion;
|
||||
decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
|
||||
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction);
|
||||
decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones);
|
||||
|
||||
importeBrutoTotal += importeBrutoSusc;
|
||||
descuentoPromocionesTotal += descuentoSusc;
|
||||
|
||||
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion);
|
||||
detallesParaFactura.Add(new FacturaDetalle
|
||||
{
|
||||
IdSuscripcion = suscripcion.IdSuscripcion,
|
||||
Descripcion = $"Corresponde a {publicacion?.Nombre ?? "N/A"}",
|
||||
ImporteBruto = importeBrutoSusc,
|
||||
DescuentoAplicado = descuentoSusc,
|
||||
ImporteNeto = importeBrutoSusc - descuentoSusc
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Aplicar ajustes. Se aplican a la PRIMERA factura que se genere para el cliente.
|
||||
var ultimoDiaDelMes = periodoActual.AddMonths(1).AddDays(-1);
|
||||
var ajustesPendientes = await _ajusteRepository.GetAjustesPendientesHastaFechaAsync(idSuscriptor, ultimoDiaDelMes, transaction);
|
||||
decimal totalAjustes = 0;
|
||||
|
||||
// Verificamos si este grupo es el "primero" para este cliente para no aplicar ajustes varias veces
|
||||
bool esPrimerGrupoParaCliente = !facturasCreadas.Any(f => f.IdSuscriptor == idSuscriptor);
|
||||
if (esPrimerGrupoParaCliente)
|
||||
{
|
||||
totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto);
|
||||
}
|
||||
|
||||
var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes;
|
||||
if (importeFinal < 0) importeFinal = 0;
|
||||
if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue;
|
||||
|
||||
// 5. Crear UNA factura por cada grupo (Suscriptor + Empresa)
|
||||
var nuevaFactura = new Factura
|
||||
{
|
||||
IdSuscriptor = idSuscriptor,
|
||||
Periodo = periodoActualStr,
|
||||
FechaEmision = DateTime.Now.Date,
|
||||
FechaVencimiento = new DateTime(anio, mes, 10),
|
||||
ImporteBruto = importeBrutoTotal,
|
||||
DescuentoAplicado = descuentoPromocionesTotal,
|
||||
ImporteFinal = importeFinal,
|
||||
EstadoPago = "Pendiente",
|
||||
EstadoFacturacion = "Pendiente de Facturar"
|
||||
};
|
||||
|
||||
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
|
||||
if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}");
|
||||
|
||||
facturasCreadas.Add(facturaCreada);
|
||||
|
||||
foreach (var detalle in detallesParaFactura)
|
||||
{
|
||||
detalle.IdFactura = facturaCreada.IdFactura;
|
||||
await _facturaDetalleRepository.CreateAsync(detalle, transaction);
|
||||
}
|
||||
|
||||
if (esPrimerGrupoParaCliente && ajustesPendientes.Any())
|
||||
{
|
||||
await _ajusteRepository.MarcarAjustesComoAplicadosAsync(ajustesPendientes.Select(a => a.IdAjuste), facturaCreada.IdFactura, transaction);
|
||||
}
|
||||
facturasGeneradas++;
|
||||
}
|
||||
// --- FIN DE LA LÓGICA DE AGRUPACIÓN ---
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr);
|
||||
|
||||
// --- INICIO DE LA LÓGICA DE ENVÍO CONSOLIDADO AUTOMÁTICO ---
|
||||
int emailsEnviados = 0;
|
||||
if (facturasCreadas.Any())
|
||||
{
|
||||
// Agrupamos las facturas creadas por suscriptor para enviar un solo email
|
||||
var suscriptoresAnotificar = facturasCreadas.Select(f => f.IdSuscriptor).Distinct().ToList();
|
||||
_logger.LogInformation("Iniciando envío automático de avisos para {Count} suscriptores.", suscriptoresAnotificar.Count);
|
||||
|
||||
foreach (var idSuscriptor in suscriptoresAnotificar)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (envioExitoso, _) = await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor);
|
||||
if (envioExitoso) emailsEnviados++;
|
||||
}
|
||||
catch (Exception exEmail)
|
||||
{
|
||||
_logger.LogError(exEmail, "Falló el envío automático de email para el suscriptor ID {IdSuscriptor}", idSuscriptor);
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("{EmailsEnviados} avisos de vencimiento enviados automáticamente.", emailsEnviados);
|
||||
}
|
||||
|
||||
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas y se enviaron {emailsEnviados} notificaciones.", facturasGeneradas);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodoActualStr);
|
||||
return (false, "Error interno del servidor al generar la facturación.", 0);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion)
|
||||
{
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodo, idUsuario);
|
||||
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion);
|
||||
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo
|
||||
var empresas = await _empresaRepository.GetAllAsync(null, null);
|
||||
|
||||
var resumenes = facturasData
|
||||
.GroupBy(data => data.Factura.IdSuscriptor)
|
||||
.Select(grupo =>
|
||||
{
|
||||
var primerItem = grupo.First();
|
||||
var facturasConsolidadas = grupo.Select(itemFactura =>
|
||||
{
|
||||
var empresa = empresas.FirstOrDefault(e => e.IdEmpresa == itemFactura.IdEmpresa);
|
||||
return new FacturaConsolidadaDto
|
||||
{
|
||||
IdFactura = itemFactura.Factura.IdFactura,
|
||||
NombreEmpresa = empresa?.Nombre ?? "N/A",
|
||||
ImporteFinal = itemFactura.Factura.ImporteFinal,
|
||||
EstadoPago = itemFactura.Factura.EstadoPago,
|
||||
EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
|
||||
NumeroFactura = itemFactura.Factura.NumeroFactura,
|
||||
Detalles = detallesData
|
||||
.Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
|
||||
.Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto })
|
||||
.ToList()
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return new ResumenCuentaSuscriptorDto
|
||||
{
|
||||
IdSuscriptor = primerItem.Factura.IdSuscriptor,
|
||||
NombreSuscriptor = primerItem.NombreSuscriptor,
|
||||
Facturas = facturasConsolidadas,
|
||||
ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
|
||||
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.EstadoPago == "Pagada" ? 0 : f.ImporteFinal)
|
||||
};
|
||||
});
|
||||
|
||||
return resumenes.ToList();
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura)
|
||||
{
|
||||
try
|
||||
{
|
||||
var factura = await _facturaRepository.GetByIdAsync(idFactura);
|
||||
if (factura == null) return (false, "Factura no encontrada.");
|
||||
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número oficial asignado para generar el PDF.");
|
||||
if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada.");
|
||||
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(factura.IdSuscriptor);
|
||||
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.");
|
||||
|
||||
var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
|
||||
var primeraSuscripcionId = detalles.FirstOrDefault()?.IdSuscripcion ?? 0;
|
||||
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(primeraSuscripcionId);
|
||||
var empresa = await _empresaRepository.GetByIdAsync(publicacion?.IdEmpresa ?? 0);
|
||||
|
||||
// --- LÓGICA DE BÚSQUEDA Y ADJUNTO DE PDF ---
|
||||
byte[]? pdfAttachment = null;
|
||||
string? pdfFileName = null;
|
||||
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
|
||||
|
||||
if (File.Exists(rutaCompleta))
|
||||
{
|
||||
pdfAttachment = await File.ReadAllBytesAsync(rutaCompleta);
|
||||
pdfFileName = $"Factura_{empresa?.Nombre?.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
|
||||
_logger.LogInformation("Adjuntando PDF encontrado en: {Ruta}", rutaCompleta);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Se intentó enviar la factura {NumeroFactura} pero no se encontró el PDF en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
|
||||
return (false, "No se encontró el archivo PDF correspondiente en el servidor. Verifique que el archivo exista y el nombre coincida con el número de factura.");
|
||||
}
|
||||
|
||||
string asunto = $"Tu Factura Oficial - Diario El Día - Período {factura.Periodo}";
|
||||
string cuerpoHtml = $"<div style='font-family: Arial, sans-serif;'><h2>Hola {suscriptor.NombreCompleto},</h2><p>Adjuntamos tu factura oficial número <strong>{factura.NumeroFactura}</strong> correspondiente al período <strong>{factura.Periodo}</strong>.</p><p>Gracias por ser parte de nuestra comunidad de lectores.</p><p><em>Diario El Día</em></p></div>";
|
||||
|
||||
await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, pdfAttachment, pdfFileName);
|
||||
_logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura);
|
||||
return (false, "Ocurrió un error al intentar enviar el email con la factura.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor)
|
||||
{
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
try
|
||||
{
|
||||
var facturas = await _facturaRepository.GetListBySuscriptorYPeriodoAsync(idSuscriptor, periodo);
|
||||
if (!facturas.Any()) return (false, "No se encontraron facturas para este suscriptor en el período.");
|
||||
|
||||
var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor);
|
||||
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.");
|
||||
|
||||
var resumenHtml = new StringBuilder();
|
||||
var adjuntos = new List<(byte[] content, string name)>();
|
||||
|
||||
foreach (var factura in facturas.Where(f => f.EstadoPago != "Anulada"))
|
||||
{
|
||||
var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
|
||||
if (!detalles.Any()) continue;
|
||||
|
||||
var primeraSuscripcionId = detalles.First().IdSuscripcion;
|
||||
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(primeraSuscripcionId);
|
||||
var empresa = await _empresaRepository.GetByIdAsync(publicacion?.IdEmpresa ?? 0);
|
||||
|
||||
resumenHtml.Append($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen de Suscripción</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>");
|
||||
}
|
||||
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_{empresa?.Nombre?.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
|
||||
adjuntos.Add((pdfBytes, pdfFileName));
|
||||
_logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var totalGeneral = facturas.Where(f => f.EstadoPago != "Anulada").Sum(f => f.ImporteFinal);
|
||||
string asunto = $"Resumen de Cuenta - Diario El Día - Período {periodo}";
|
||||
string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral);
|
||||
|
||||
await _emailService.EnviarEmailConsolidadoAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, adjuntos);
|
||||
_logger.LogInformation("Email consolidado para Suscriptor ID {IdSuscriptor} enviado para el período {Periodo}.", idSuscriptor, periodo);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Falló el envío de email consolidado para el suscriptor ID {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo);
|
||||
return (false, "Ocurrió un error al intentar enviar el email consolidado.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral)
|
||||
{
|
||||
return $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: auto; border: 1px solid #ddd; padding: 20px;'>
|
||||
<h3 style='color: #333;'>Hola {suscriptor.NombreCompleto},</h2>
|
||||
<p>Le enviamos el resumen de su cuenta para el período <strong>{periodo}</strong>.</p>
|
||||
{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:</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>
|
||||
<p style='font-size: 0.9em; color: #777;'><em>Diario El Día</em></p>
|
||||
</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();
|
||||
@@ -54,77 +409,31 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
|
||||
try
|
||||
{
|
||||
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction);
|
||||
if (!suscripcionesActivas.Any())
|
||||
var factura = await _facturaRepository.GetByIdAsync(idFactura);
|
||||
if (factura == null)
|
||||
{
|
||||
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0);
|
||||
return (false, "La factura especificada no existe.");
|
||||
}
|
||||
if (factura.EstadoPago == "Anulada")
|
||||
{
|
||||
return (false, "No se puede modificar una factura anulada.");
|
||||
}
|
||||
|
||||
int facturasGeneradas = 0;
|
||||
foreach (var suscripcion in suscripcionesActivas)
|
||||
var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction);
|
||||
if (!actualizado)
|
||||
{
|
||||
var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction);
|
||||
if (facturaExistente != null)
|
||||
{
|
||||
_logger.LogWarning("Ya existe una factura (ID: {IdFactura}) para la suscripción ID {IdSuscripcion} en el período {Periodo}. Se omite.", facturaExistente.IdFactura, suscripcion.IdSuscripcion, periodo);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- LÓGICA DE PROMOCIONES ---
|
||||
var primerDiaMes = new DateTime(anio, mes, 1);
|
||||
var promocionesAplicables = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, primerDiaMes, transaction);
|
||||
|
||||
decimal importeBruto = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
|
||||
decimal descuentoTotal = 0;
|
||||
|
||||
// Aplicar promociones de descuento
|
||||
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "Porcentaje"))
|
||||
{
|
||||
descuentoTotal += (importeBruto * promo.Valor) / 100;
|
||||
}
|
||||
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "MontoFijo"))
|
||||
{
|
||||
descuentoTotal += promo.Valor;
|
||||
}
|
||||
// La bonificación de días se aplicaría idealmente dentro de CalcularImporteParaSuscripcion,
|
||||
// pero por simplicidad, aquí solo manejamos descuentos sobre el total.
|
||||
|
||||
if (importeBruto <= 0)
|
||||
{
|
||||
_logger.LogInformation("Suscripción ID {IdSuscripcion} no tiene importe a facturar para el período {Periodo}. Se omite.", suscripcion.IdSuscripcion, periodo);
|
||||
continue;
|
||||
}
|
||||
|
||||
var importeFinal = importeBruto - descuentoTotal;
|
||||
if (importeFinal < 0) importeFinal = 0; // El importe no puede ser negativo
|
||||
|
||||
var nuevaFactura = new Factura
|
||||
{
|
||||
IdSuscripcion = suscripcion.IdSuscripcion,
|
||||
Periodo = periodo,
|
||||
FechaEmision = DateTime.Now.Date,
|
||||
FechaVencimiento = new DateTime(anio, mes, 10).AddMonths(1),
|
||||
ImporteBruto = importeBruto,
|
||||
DescuentoAplicado = descuentoTotal,
|
||||
ImporteFinal = importeFinal,
|
||||
Estado = "Pendiente de Facturar"
|
||||
};
|
||||
|
||||
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
|
||||
if (facturaCreada == null) throw new DataException($"No se pudo crear el registro de factura para la suscripción ID {suscripcion.IdSuscripcion}");
|
||||
|
||||
facturasGeneradas++;
|
||||
throw new DataException("La actualización del número de factura falló en el repositorio.");
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas);
|
||||
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", facturasGeneradas);
|
||||
_logger.LogInformation("Número de factura para Factura ID {IdFactura} actualizado a {NumeroFactura} por Usuario ID {IdUsuario}", idFactura, numeroFactura, idUsuario);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodo);
|
||||
return (false, "Error interno del servidor al generar la facturación.", 0);
|
||||
_logger.LogError(ex, "Error al actualizar número de factura para Factura ID {IdFactura}", idFactura);
|
||||
return (false, "Error interno al actualizar el número de factura.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,25 +442,34 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
decimal importeTotal = 0;
|
||||
var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet();
|
||||
var fechaActual = new DateTime(anio, mes, 1);
|
||||
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, fechaActual, transaction);
|
||||
var promocionesDeBonificacion = promociones.Where(p => p.TipoEfecto == "BonificarEntregaDia").ToList();
|
||||
|
||||
while (fechaActual.Month == mes)
|
||||
{
|
||||
// La suscripción debe estar activa en este día
|
||||
if (fechaActual.Date >= suscripcion.FechaInicio.Date &&
|
||||
(suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date))
|
||||
if (fechaActual.Date >= suscripcion.FechaInicio.Date && (suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date))
|
||||
{
|
||||
var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek);
|
||||
if (diasDeEntrega.Contains(diaSemanaChar))
|
||||
{
|
||||
decimal precioDelDia = 0;
|
||||
var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction);
|
||||
if (precioActivo != null)
|
||||
{
|
||||
importeTotal += GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek);
|
||||
precioDelDia = GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date);
|
||||
}
|
||||
|
||||
bool diaBonificado = promocionesDeBonificacion.Any(promo => EvaluarCondicionPromocion(promo, fechaActual));
|
||||
if (diaBonificado)
|
||||
{
|
||||
precioDelDia = 0;
|
||||
_logger.LogInformation("Día {Fecha} bonificado para suscripción {IdSuscripcion} por promoción.", fechaActual.ToShortDateString(), suscripcion.IdSuscripcion);
|
||||
}
|
||||
importeTotal += precioDelDia;
|
||||
}
|
||||
}
|
||||
fechaActual = fechaActual.AddDays(1);
|
||||
@@ -159,72 +477,30 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
return importeTotal;
|
||||
}
|
||||
|
||||
public async Task<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 +515,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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public interface IAjusteService
|
||||
{
|
||||
Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor);
|
||||
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,15 @@
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public interface IFacturacionService
|
||||
{
|
||||
Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
|
||||
Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes);
|
||||
Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura);
|
||||
Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion);
|
||||
Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor);
|
||||
Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura);
|
||||
Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario);
|
||||
}
|
||||
}
|
||||
@@ -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,67 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura);
|
||||
if (factura == null) return (null, "La factura especificada no existe.");
|
||||
if (factura.Estado == "Pagada") return (null, "La factura ya se encuentra pagada.");
|
||||
if (factura.Estado == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
|
||||
|
||||
// Usar EstadoPago para la validación
|
||||
if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
|
||||
|
||||
var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago);
|
||||
if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida.");
|
||||
|
||||
// Obtenemos la suma de pagos ANTERIORES
|
||||
var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction);
|
||||
|
||||
var nuevoPago = new Pago
|
||||
{
|
||||
IdFactura = createDto.IdFactura,
|
||||
FechaPago = createDto.FechaPago,
|
||||
IdFormaPago = createDto.IdFormaPago,
|
||||
Monto = createDto.Monto,
|
||||
Estado = "Aprobado", // Los pagos manuales se asumen aprobados
|
||||
Estado = "Aprobado",
|
||||
Referencia = createDto.Referencia,
|
||||
Observaciones = createDto.Observaciones,
|
||||
IdUsuarioRegistro = idUsuario
|
||||
};
|
||||
|
||||
// 1. Crear el registro del pago
|
||||
// Creamos el nuevo pago
|
||||
var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction);
|
||||
if (pagoCreado == null) throw new DataException("No se pudo registrar el pago.");
|
||||
|
||||
// 2. Si el monto pagado es igual o mayor al importe de la factura, actualizar la factura
|
||||
// (Permitimos pago mayor por si hay redondeos, etc.)
|
||||
if (pagoCreado.Monto >= factura.ImporteFinal)
|
||||
// Calculamos el nuevo total EN MEMORIA
|
||||
var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto;
|
||||
|
||||
// Comparamos y actualizamos el estado si es necesario
|
||||
// CORRECCIÓN: Usar EstadoPago y el método correcto del repositorio
|
||||
if (factura.EstadoPago != "Pagada" && nuevoTotalPagado >= factura.ImporteFinal)
|
||||
{
|
||||
bool actualizado = await _facturaRepository.UpdateEstadoAsync(factura.IdFactura, "Pagada", transaction);
|
||||
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, "Pagada", transaction);
|
||||
if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'.");
|
||||
}
|
||||
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario);
|
||||
|
||||
var dto = await MapToDto(pagoCreado);
|
||||
|
||||
// Construimos el DTO de respuesta SIN volver a consultar la base de datos
|
||||
var usuario = await _usuarioRepository.GetByIdAsync(idUsuario);
|
||||
var dto = new PagoDto
|
||||
{
|
||||
IdPago = pagoCreado.IdPago,
|
||||
IdFactura = pagoCreado.IdFactura,
|
||||
FechaPago = pagoCreado.FechaPago.ToString("yyyy-MM-dd"),
|
||||
IdFormaPago = pagoCreado.IdFormaPago,
|
||||
NombreFormaPago = formaPago.Nombre,
|
||||
Monto = pagoCreado.Monto,
|
||||
Estado = pagoCreado.Estado,
|
||||
Referencia = pagoCreado.Referencia,
|
||||
Observaciones = pagoCreado.Observaciones,
|
||||
IdUsuarioRegistro = pagoCreado.IdUsuarioRegistro,
|
||||
NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
|
||||
};
|
||||
|
||||
return (dto, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,12 @@ using GestionIntegral.Api.Data.Repositories.Distribucion;
|
||||
using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
@@ -36,8 +41,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
IdPromocion = promo.IdPromocion,
|
||||
Descripcion = promo.Descripcion,
|
||||
TipoPromocion = promo.TipoPromocion,
|
||||
Valor = promo.Valor,
|
||||
TipoEfecto = promo.TipoEfecto,
|
||||
ValorEfecto = promo.ValorEfecto,
|
||||
TipoCondicion = promo.TipoCondicion,
|
||||
ValorCondicion = promo.ValorCondicion,
|
||||
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
|
||||
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
|
||||
Activa = promo.Activa
|
||||
@@ -154,47 +161,67 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<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
|
||||
{
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AppSettings": {
|
||||
"FacturasPdfPath": "C:\\Ruta\\A\\Tus\\FacturasPDF"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
|
||||
"Issuer": "GestionIntegralApi",
|
||||
@@ -13,11 +16,11 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"MailSettings": {
|
||||
"SmtpHost": "smtp.yourprovider.com",
|
||||
"SmtpHost": "mail.eldia.com",
|
||||
"SmtpPort": 587,
|
||||
"SenderName": "Diario El Día - Suscripciones",
|
||||
"SenderEmail": "suscripciones@eldia.com",
|
||||
"SmtpUser": "your-smtp-username",
|
||||
"SmtpPass": "your-smtp-password"
|
||||
"SenderName": "Club - Diario El Día",
|
||||
"SenderEmail": "alertas@eldia.com",
|
||||
"SmtpUser": "alertas@eldia.com",
|
||||
"SmtpPass": "@Alertas713550@"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user