Compare commits

..

7 Commits

Author SHA1 Message Date
8c194b8441 Refactor: Externaliza configuración de MailSettings a archivo .env
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 6m31s
Para mejorar la seguridad y seguir las mejores prácticas, se ha externalizado la configuración sensible de `MailSettings` (credenciales SMTP) del archivo `appsettings.json` a un archivo `.env` no versionado.

### Cambios Realizados

- **Implementación de .env:**
    - Se ha creado un archivo `.env` en la raíz del proyecto para almacenar las variables de entorno relacionadas con el servicio de correo.
    - Se ha añadido el paquete NuGet `DotNetEnv` al proyecto para permitir la carga de este archivo.

- **Modificación del Arranque:**
    - Se ha modificado `Program.cs` para que cargue las variables del archivo `.env` al inicio de la aplicación, haciéndolas disponibles para el sistema de configuración de .NET.

- **Limpieza de `appsettings.json`:**
    - Se han eliminado los valores sensibles (usuario, contraseña, etc.) de la sección `MailSettings` en `appsettings.json`. El archivo ahora sirve como plantilla de la estructura de configuración sin exponer credenciales.
2025-08-11 11:29:14 -03:00
1a288fcfa5 Feat: Implementa auditoría de envíos masivos y mejora de procesos
Se introduce un sistema completo para auditar los envíos masivos de correos durante el cierre mensual y se refactoriza la interfaz de usuario de procesos para una mayor claridad y escalabilidad. Además, se mejora la lógica de negocio para la gestión de bajas de suscripciones.

###  Nuevas Características

- **Auditoría de Envíos Masivos (Cierre Mensual):**
    - Se crea una nueva tabla `com_LotesDeEnvio` para registrar cada ejecución del proceso de facturación mensual.
    - El `FacturacionService` ahora crea un "lote" al iniciar el cierre, registra el resultado de cada envío de email individual asociándolo a dicho lote, y actualiza las estadísticas finales (enviados, fallidos) al terminar.
    - Se implementa un nuevo `LotesEnvioController` con un endpoint para consultar los detalles de cualquier lote de envío histórico.

### 🔄 Refactorización y Mejoras

- **Rediseño de la Página de Procesos:**
    - La antigua página "Facturación" se renombra a `CierreYProcesosPage` y se rediseña completamente utilizando una interfaz de Pestañas (Tabs).
    - **Pestaña "Procesos Mensuales":** Aisla las acciones principales (Generar Cierre, Archivo de Débito, Procesar Respuesta), mostrando un resumen del resultado del último envío.
    - **Pestaña "Historial de Cierres":** Muestra una tabla con todos los lotes de envío pasados y permite al usuario ver los detalles de cada uno en un modal.

- **Filtros para el Historial de Cierres:**
    - Se añaden filtros por Mes y Año a la pestaña de "Historial de Cierres", permitiendo al usuario buscar y auditar procesos pasados de manera eficiente. El filtrado se realiza en el backend para un rendimiento óptimo.

- **Lógica de `FechaFin` Obligatoria para Bajas:**
    - Se implementa una regla de negocio crucial: al cambiar el estado de una suscripción a "Pausada" o "Cancelada", ahora es obligatorio establecer una `FechaFin`.
    - **Frontend:** El modal de suscripciones ahora gestiona esto automáticamente, haciendo el campo `FechaFin` requerido y visible según el estado seleccionado.
    - **Backend:** Se añade una validación en `SuscripcionService` como segunda capa de seguridad para garantizar la integridad de los datos.

### 🐛 Corrección de Errores

- **Reporte de Distribución:** Se corrigió un bug en la generación del PDF donde la columna de fecha no mostraba la "Fecha de Baja" para las suscripciones finalizadas. Ahora se muestra la fecha correcta según la sección (Altas o Bajas).
- **Errores de Compilación y Dependencias:** Se solucionaron varios errores de compilación en el backend, principalmente relacionados con la falta de registro de los nuevos repositorios (`ILoteDeEnvioRepository`, `IEmailLogService`, etc.) en el contenedor de inyección de dependencias (`Program.cs`).
- **Errores de Tipado en Frontend:** Se corrigieron múltiples errores de TypeScript en `CierreYProcesosPage` debidos a la inconsistencia entre `PascalCase` (C#) y `camelCase` (JSON/TypeScript), asegurando un mapeo correcto de los datos de la API.
2025-08-11 11:14:03 -03:00
7dc0940001 Feat: Implementa auditoría de envíos de email y mejora la UX
Se introduce un sistema completo de logging para todas las comunicaciones por correo electrónico y se realizan mejoras significativas en la experiencia del usuario, tanto en la retroalimentación del sistema como en la estética de los emails enviados al cliente.

###  Nuevas Características

- **Auditoría y Log de Envíos de Email:**
    - Se ha creado una nueva tabla `com_EmailLogs` en la base de datos para registrar cada intento de envío de correo.
    - El `EmailService` ahora centraliza toda la lógica de logging, registrando automáticamente la fecha, destinatario, asunto, estado (`Enviado` o `Fallido`), y mensajes de error detallados.
    - Se implementó un nuevo `EmailLogService` y `EmailLogRepository` para gestionar estos registros.

- **Historial de Envíos en la Interfaz de Usuario:**
    - Se añade un nuevo ícono de "Historial" (<span style="color: #607d8b;">&#x1F4E7;</span>) junto a cada factura en la página de "Consulta de Facturas".
    - Al hacer clic, se abre un modal que muestra una tabla detallada con todos los intentos de envío para esa factura, incluyendo el estado y el motivo del error (si lo hubo).
    - Esto proporciona una trazabilidad completa y una herramienta de diagnóstico para el usuario final.

### 🔄 Refactorización y Mejoras

- **Mensajes de Éxito Dinámicos:**
    - Se ha mejorado la retroalimentación al enviar una factura por PDF. El sistema ahora muestra un mensaje de éxito específico, como "El email... se ha enviado correctamente a suscriptor@email.com", en lugar de un mensaje técnico genérico.
    - Se ajustó la cadena de llamadas (`Controller` -> `Service`) para que el email del destinatario esté disponible para la respuesta de la API.

- **Diseño Unificado de Emails:**
    - Se ha rediseñado el template HTML para el "Aviso de Cuenta Mensual" para que coincida con la estética del email de "Envío de Factura PDF".
    - Ambos correos ahora presentan un diseño profesional y consistente, con cabecera, logo y pie de página, reforzando la imagen de marca.

- **Manejo de Errores de Email Mejorado:**
    - El `EmailService` ahora captura excepciones específicas de la librería `MailKit` (ej. `SmtpCommandException`).
    - Esto permite registrar en el log errores mucho más precisos y útiles, como rechazos de destinatarios por parte del servidor (`User unknown`), fallos de autenticación, etc., que ahora son visibles en el `Tooltip` del historial.
2025-08-09 21:12:11 -03:00
5a806eda38 Feat: Mejora UI de Cuenta Corriente y corrige colores en email de aviso
Este commit introduce significativas mejoras de usabilidad en la página de gestión de ajustes del suscriptor y corrige la representación visual de los ajustes en el email de notificación mensual.

###  Nuevas Características y Mejoras de UI

- **Nuevos Filtros en Cuenta Corriente del Suscriptor:**
    - Se han añadido nuevos filtros desplegables en la página de "Cuenta Corriente" para filtrar los ajustes por **Estado** (`Pendiente`, `Aplicado`, `Anulado`) y por **Tipo** (`Crédito`, `Débito`).
    - Esta mejora permite a los usuarios encontrar registros específicos de manera mucho más rápida y eficiente, especialmente para suscriptores con un largo historial de ajustes.

- **Visualización Mejorada del Estado de Ajuste:**
    - La columna "Estado" en la tabla de ajustes ahora muestra el número de factura oficial (ej. `A-0001-12345`) si un ajuste ha sido aplicado y la factura ya está numerada.
    - Si la factura aún no tiene un número oficial, se muestra una referencia al ID interno (ej. `ID Interno #64`) para mantener la trazabilidad.
    - Para soportar esto, se ha enriquecido el `AjusteDto` en el backend para incluir el `NumeroFacturaAplicado`.

### 🐛 Corrección y Refactorización

- **Corrección de Colores en Email de Aviso:**
    - Se han invertido los colores de los montos de ajuste en el email de aviso mensual enviado al cliente para alinearlos con la perspectiva del usuario.
    - **Créditos** (descuentos a favor del cliente) ahora se muestran en **verde** (positivo).
    - **Débitos** (cargos extra) ahora se muestran en **rojo** (negativo).
    - Este cambio mejora drásticamente la claridad del resumen de cuenta y evita posibles confusiones.

### ⚙️ Cambios Técnicos de Soporte

- Se ha añadido el método `GetByIdsAsync` al `IFacturaRepository` para optimizar la obtención de datos de múltiples facturas en una sola consulta, evitando el problema N+1.
- El `AjusteService` ha sido actualizado para utilizar este nuevo método y poblar eficientemente la información de la factura en el DTO de ajuste que se envía al frontend.
2025-08-09 18:16:56 -03:00
21c5c1d7d9 Feat: Implementa Reporte de Distribución de Suscripciones y Refactoriza Gestión de Ajustes
Se introduce una nueva funcionalidad de reporte crucial para la logística y se realiza una refactorización mayor del sistema de ajustes para garantizar la correcta imputación contable.

###  Nuevas Características

- **Nuevo Reporte de Distribución de Suscripciones (RR011):**
    - Se añade un nuevo reporte en PDF que genera un listado de todas las suscripciones activas en un rango de fechas.
    - El reporte está diseñado para el equipo de reparto, incluyendo datos clave como nombre del suscriptor, dirección, teléfono, días de entrega y observaciones.
    - Se implementa el endpoint, la lógica de servicio, la consulta a la base de datos y el template de QuestPDF en el backend.
    - Se crea la página correspondiente en el frontend (React) con su selector de fechas y se añade la ruta y el enlace en el menú de reportes.

### 🔄 Refactorización Mayor

- **Asociación de Ajustes a Empresas:**
    - Se refactoriza por completo la entidad `Ajuste` para incluir una referencia obligatoria a una `IdEmpresa`.
    - **Motivo:** Corregir un error crítico en la lógica de negocio donde los ajustes de un suscriptor se aplicaban a la primera factura generada, sin importar a qué empresa correspondía el ajuste.
    - Se provee un script de migración SQL para actualizar el esquema de la base de datos (`susc_Ajustes`).
    - Se actualizan todos los DTOs, repositorios y servicios (backend) para manejar la nueva relación.
    - Se modifica el `FacturacionService` para que ahora aplique los ajustes pendientes correspondientes a cada empresa dentro de su bucle de facturación.
    - Se actualiza el formulario de creación/edición de ajustes en el frontend (React) para incluir un selector de empresa obligatorio.

### ️ Optimizaciones de Rendimiento

- **Solución de N+1 Queries:**
    - Se optimiza el método `ObtenerTodos` en `SuscriptorService` para obtener todas las formas de pago en una única consulta en lugar de una por cada suscriptor.
    - Se optimiza el método `ObtenerAjustesPorSuscriptor` en `AjusteService` para obtener todos los nombres de usuarios y empresas en dos consultas masivas, en lugar de N consultas individuales.
    - Se añade el método `GetByIdsAsync` al `IUsuarioRepository` y su implementación para soportar esta optimización.

### 🐛 Corrección de Errores

- Se corrigen múltiples errores en el script de migración de base de datos (uso de `GO` dentro de transacciones, error de "columna inválida").
- Se soluciona un error de SQL (`INSERT` statement) en `AjusteRepository` que impedía la creación de nuevos ajustes.
- Se corrige un bug en el `AjusteService` que causaba que el nombre de la empresa apareciera como "N/A" en la UI debido a un mapeo incompleto en el método optimizado.
- Se corrige la lógica de generación de emails en `FacturacionService` para mostrar correctamente el nombre de la empresa en cada sección del resumen de cuenta.
2025-08-09 17:39:21 -03:00
899e0a173f 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.
2025-08-08 09:48:15 -03:00
9cfe9d012e Feat: Implementa ABM y anulación de ajustes manuales
Este commit introduce la funcionalidad completa para la gestión de
ajustes manuales (créditos/débitos) en la cuenta corriente de un
suscriptor, cerrando un requerimiento clave detectado en el análisis
del flujo de trabajo manual.

Backend:
- Se añade la tabla `susc_Ajustes` para registrar movimientos manuales.
- Se crean el Modelo, DTOs, Repositorio y Servicio (`AjusteService`)
  para el ABM completo de los ajustes.
- Se implementa la lógica para anular ajustes que se encuentren en estado
  "Pendiente", registrando el usuario y fecha de anulación para
  mantener la trazabilidad.
- Se integra la lógica de aplicación de ajustes pendientes en el
  `FacturacionService`, afectando el `ImporteFinal` de la factura
  generada.
- Se añaden los nuevos endpoints en `AjustesController` para crear,
  listar y anular ajustes.

Frontend:
- Se crea el componente `CuentaCorrienteSuscriptorTab` para mostrar
  el historial de ajustes de un cliente.
- Se desarrolla el modal `AjusteFormModal` que permite a los usuarios
  registrar nuevos créditos o débitos.
- Se integra una nueva pestaña "Cuenta Corriente / Ajustes" en la
  vista de gestión de un suscriptor.
- Se añade la funcionalidad de "Anular" en la tabla de ajustes,
  permitiendo a los usuarios corregir errores antes del ciclo de
  facturación.
2025-08-01 14:38:15 -03:00
114 changed files with 5659 additions and 1335 deletions

3
.gitignore vendored
View File

@@ -19,9 +19,6 @@ lerna-debug.log*
# Variables de entorno
# -------------------------------
# Nunca subas tus claves de API, contraseñas de BD, etc.
# Crea un archivo .env.example con las variables vacías para guiar a otros desarrolladores.
.env
.env.local
.env.development.local
.env.test.local

View File

@@ -0,0 +1,12 @@
# ================================================
# VARIABLES DE ENTORNO PARA LA CONFIGURACIÓN DE CORREO
# ================================================
# El separador de doble guion bajo (__) se usa para mapear la jerarquía del JSON.
# MailSettings:SmtpHost se convierte en MailSettings__SmtpHost
MailSettings__SmtpHost="mail.eldia.com"
MailSettings__SmtpPort=587
MailSettings__SenderName="Club - Diario El Día"
MailSettings__SenderEmail="alertas@eldia.com"
MailSettings__SmtpUser="alertas@eldia.com"
MailSettings__SmtpPass="@Alertas713550@"

View File

@@ -0,0 +1,40 @@
using GestionIntegral.Api.Dtos.Comunicaciones;
using GestionIntegral.Api.Services.Comunicaciones;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace GestionIntegral.Api.Controllers.Comunicaciones
{
[Route("api/lotes-envio")]
[ApiController]
[Authorize]
public class LotesEnvioController : ControllerBase
{
private readonly IEmailLogService _emailLogService;
public LotesEnvioController(IEmailLogService emailLogService)
{
_emailLogService = emailLogService;
}
// GET: api/lotes-envio/123/detalles
[HttpGet("{idLote:int}/detalles")]
[ProducesResponseType(typeof(IEnumerable<EmailLogDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDetallesLote(int idLote)
{
// Reutilizamos un permiso existente, ya que esta es una función de auditoría relacionada.
var tienePermiso = User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == "SU006");
if (!tienePermiso)
{
return Forbid();
}
var detalles = await _emailLogService.ObtenerDetallesPorLoteId(idLote);
// Devolvemos OK con un array vacío si no hay resultados, el frontend lo manejará.
return Ok(detalles);
}
}
}

View File

@@ -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
{

View File

@@ -0,0 +1,152 @@
using GestionIntegral.Api.Dtos.Reportes.ViewModels;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
{
public class DistribucionSuscripcionesDocument : IDocument
{
public DistribucionSuscripcionesViewModel Model { get; }
public DistribucionSuscripcionesDocument(DistribucionSuscripcionesViewModel model)
{
Model = model;
}
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
public void Compose(IDocumentContainer container)
{
container.Page(page =>
{
page.Margin(1, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontFamily("Arial").FontSize(9));
page.Header().Element(ComposeHeader);
page.Content().Element(ComposeContent);
page.Footer().AlignCenter().Text(x => { x.Span("Página "); x.CurrentPageNumber(); });
});
}
void ComposeHeader(IContainer container)
{
container.Column(column =>
{
column.Item().Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("Reporte de Distribución de Suscripciones").SemiBold().FontSize(14);
col.Item().Text($"Período: {Model.FechaDesde} al {Model.FechaHasta}").FontSize(11);
});
row.ConstantItem(150).AlignRight().Text($"Generado: {Model.FechaGeneracion}");
});
column.Item().PaddingTop(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten2);
});
}
void ComposeContent(IContainer container)
{
container.PaddingTop(10).Column(column =>
{
column.Spacing(20); // Espacio entre elementos principales (sección de altas y sección de bajas)
// --- Sección 1: Altas y Activas ---
column.Item().Column(colAltas =>
{
colAltas.Item().Text("Altas y Suscripciones Activas en el Período").Bold().FontSize(14).Underline();
colAltas.Item().PaddingBottom(10).Text("Listado de suscriptores que deben recibir entregas en el período seleccionado.");
if (!Model.DatosAgrupadosAltas.Any())
{
colAltas.Item().PaddingTop(10).Text("No se encontraron suscripciones activas para este período.").Italic();
}
else
{
foreach (var empresa in Model.DatosAgrupadosAltas)
{
colAltas.Item().Element(c => ComposeTablaEmpresa(c, empresa, esBaja: false));
}
}
});
// --- Sección 2: Bajas ---
if (Model.DatosAgrupadosBajas.Any())
{
column.Item().PageBreak(); // Salto de página para separar las secciones
column.Item().Column(colBajas =>
{
colBajas.Item().Text("Bajas de Suscripciones en el Período").Bold().FontSize(14).Underline().FontColor(Colors.Red.Medium);
colBajas.Item().PaddingBottom(10).Text("Listado de suscriptores cuya suscripción finalizó. NO se les debe entregar a partir de su 'Fecha de Baja'.");
foreach (var empresa in Model.DatosAgrupadosBajas)
{
colBajas.Item().Element(c => ComposeTablaEmpresa(c, empresa, esBaja: true));
}
});
}
});
}
void ComposeTablaEmpresa(IContainer container, GrupoEmpresa empresa, bool esBaja)
{
container.Column(column =>
{
// Cabecera de la EMPRESA (ej. EL DIA)
column.Item().Background(Colors.Grey.Lighten2).Padding(5).Text(empresa.NombreEmpresa).Bold().FontSize(12);
// Contenedor para las tablas de las publicaciones de esta empresa
column.Item().PaddingTop(5).Column(colPub =>
{
colPub.Spacing(10); // Espacio entre cada tabla de publicación
foreach (var publicacion in empresa.Publicaciones)
{
colPub.Item().Element(c => ComposeTablaPublicacion(c, publicacion, esBaja));
}
});
});
}
void ComposeTablaPublicacion(IContainer container, GrupoPublicacion publicacion, bool esBaja)
{
// Se envuelve la tabla en una columna para poder ponerle un título simple arriba.
container.Column(column =>
{
column.Item().PaddingLeft(2).PaddingBottom(2).Text(publicacion.NombrePublicacion).SemiBold().FontSize(10);
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2.5f); // Nombre
columns.RelativeColumn(3); // Dirección
columns.RelativeColumn(1.5f); // Teléfono
columns.ConstantColumn(65); // Fecha Inicio / Baja
columns.RelativeColumn(1.5f); // Días
columns.RelativeColumn(2.5f); // Observaciones
});
table.Header(header =>
{
header.Cell().BorderBottom(1).Padding(2).Text("Suscriptor").SemiBold();
header.Cell().BorderBottom(1).Padding(2).Text("Dirección").SemiBold();
header.Cell().BorderBottom(1).Padding(2).Text("Teléfono").SemiBold();
header.Cell().BorderBottom(1).Padding(2).Text(esBaja ? "Fecha de Baja" : "Fecha Inicio").SemiBold();
header.Cell().BorderBottom(1).Padding(2).Text("Días Entrega").SemiBold();
header.Cell().BorderBottom(1).Padding(2).Text("Observaciones").SemiBold();
});
foreach (var item in publicacion.Suscripciones)
{
table.Cell().Padding(2).Text(item.NombreSuscriptor);
table.Cell().Padding(2).Text(item.Direccion);
table.Cell().Padding(2).Text(item.Telefono ?? "-");
var fecha = esBaja ? item.FechaFin : item.FechaInicio;
table.Cell().Padding(2).Text(fecha?.ToString("dd/MM/yyyy") ?? "-");
table.Cell().Padding(2).Text(item.DiasEntrega);
table.Cell().Padding(2).Text(item.Observaciones ?? "-");
}
});
});
}
}
}

View File

@@ -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();
});
}
}
}

View File

@@ -1,15 +1,8 @@
using GestionIntegral.Api.Services.Reportes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Reporting.NETCore;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using GestionIntegral.Api.Dtos.Reportes;
using GestionIntegral.Api.Data.Repositories.Impresion;
using System.IO;
using System.Linq;
using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Services.Distribucion;
using GestionIntegral.Api.Services.Pdf;
@@ -45,6 +38,8 @@ namespace GestionIntegral.Api.Controllers
private const string PermisoVerReporteConsumoBobinas = "RR007";
private const string PermisoVerReporteNovedadesCanillas = "RR004";
private const string PermisoVerReporteListadoDistMensual = "RR009";
private const string PermisoVerReporteFacturasPublicidad = "RR010";
private const string PermisoVerReporteDistSuscripciones = "RR011";
public ReportesController(
IReportesService reportesService,
@@ -1676,5 +1671,88 @@ namespace GestionIntegral.Api.Controllers
return StatusCode(500, "Error interno al generar el PDF del reporte.");
}
}
[HttpGet("suscripciones/facturas-para-publicidad/pdf")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GetReporteFacturasPublicidadPdf([FromQuery] int anio, [FromQuery] int mes)
{
if (!TienePermiso(PermisoVerReporteFacturasPublicidad)) return Forbid();
var (data, error) = await _reportesService.ObtenerFacturasParaReportePublicidad(anio, mes);
if (error != null) return BadRequest(new { message = error });
if (data == null || !data.Any())
{
return NotFound(new { message = "No hay facturas pagadas y pendientes de facturar para el período seleccionado." });
}
try
{
// --- INICIO DE LA LÓGICA DE AGRUPACIÓN ---
var datosAgrupados = data
.GroupBy(f => f.IdEmpresa)
.Select(g => new DatosEmpresaViewModel
{
NombreEmpresa = g.First().NombreEmpresa,
Facturas = g.ToList()
})
.OrderBy(e => e.NombreEmpresa);
var viewModel = new FacturasPublicidadViewModel
{
DatosPorEmpresa = datosAgrupados,
Periodo = new DateTime(anio, mes, 1).ToString("MMMM yyyy", new CultureInfo("es-ES")),
FechaGeneracion = DateTime.Now.ToString("dd/MM/yyyy HH:mm")
};
// --- FIN DE LA LÓGICA DE AGRUPACIÓN ---
var document = new FacturasPublicidadDocument(viewModel);
byte[] pdfBytes = await _pdfGenerator.GeneratePdfAsync(document);
string fileName = $"ReportePublicidad_Suscripciones_{anio}-{mes:D2}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al generar PDF para Reporte de Facturas a Publicidad.");
return StatusCode(500, "Error interno al generar el PDF del reporte.");
}
}
[HttpGet("suscripciones/distribucion/pdf")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public async Task<IActionResult> GetReporteDistribucionSuscripcionesPdf([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta)
{
if (!TienePermiso(PermisoVerReporteDistSuscripciones)) return Forbid();
var (altas, bajas, error) = await _reportesService.ObtenerReporteDistribucionSuscripcionesAsync(fechaDesde, fechaHasta);
if (error != null) return BadRequest(new { message = error });
if ((altas == null || !altas.Any()) && (bajas == null || !bajas.Any()))
{
return NotFound(new { message = "No se encontraron suscripciones activas ni bajas para el período seleccionado." });
}
try
{
var viewModel = new DistribucionSuscripcionesViewModel(altas ?? Enumerable.Empty<DistribucionSuscripcionDto>(), bajas ?? Enumerable.Empty<DistribucionSuscripcionDto>())
{
FechaDesde = fechaDesde.ToString("dd/MM/yyyy"),
FechaHasta = fechaHasta.ToString("dd/MM/yyyy"),
FechaGeneracion = DateTime.Now.ToString("dd/MM/yyyy HH:mm")
};
var document = new DistribucionSuscripcionesDocument(viewModel);
byte[] pdfBytes = await _pdfGenerator.GeneratePdfAsync(document);
string fileName = $"ReporteDistribucionSuscripciones_{fechaDesde:yyyyMMdd}_al_{fechaHasta:yyyyMMdd}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al generar PDF para Reporte de Distribución de Suscripciones.");
return StatusCode(500, "Error interno al generar el PDF del reporte.");
}
}
}
}

View File

@@ -0,0 +1,94 @@
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Services.Suscripciones;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace GestionIntegral.Api.Controllers.Suscripciones
{
[Route("api/ajustes")]
[ApiController]
[Authorize]
public class AjustesController : ControllerBase
{
private readonly IAjusteService _ajusteService;
private readonly ILogger<AjustesController> _logger;
// Permiso a crear en BD
private const string PermisoGestionarAjustes = "SU011";
public AjustesController(IAjusteService ajusteService, ILogger<AjustesController> logger)
{
_ajusteService = ajusteService;
_logger = logger;
}
private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc);
private int? GetCurrentUserId()
{
if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId;
return null;
}
// GET: api/suscriptores/{idSuscriptor}/ajustes
[HttpGet("~/api/suscriptores/{idSuscriptor:int}/ajustes")]
[ProducesResponseType(typeof(IEnumerable<AjusteDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAjustesPorSuscriptor(int idSuscriptor, [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta)
{
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor, fechaDesde, fechaHasta);
return Ok(ajustes);
}
// POST: api/ajustes
[HttpPost]
[ProducesResponseType(typeof(AjusteDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateAjuste([FromBody] CreateAjusteDto createDto)
{
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (dto, error) = await _ajusteService.CrearAjusteManual(createDto, userId.Value);
if (error != null) return BadRequest(new { message = error });
if (dto == null) return StatusCode(500, "Error al crear el ajuste.");
// Devolvemos el objeto creado con un 201
return StatusCode(201, dto);
}
// POST: api/ajustes/{id}/anular
[HttpPost("{id:int}/anular")]
public async Task<IActionResult> Anular(int id)
{
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _ajusteService.AnularAjuste(id, userId.Value);
if (!exito) return BadRequest(new { message = error });
return Ok(new { message = "Ajuste anulado correctamente." });
}
// PUT: api/ajustes/{id}
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateAjuste(int id, [FromBody] UpdateAjusteDto updateDto)
{
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var (exito, error) = await _ajusteService.ActualizarAjuste(id, updateDto);
if (!exito)
{
if (error != null && error.Contains("no encontrado")) return NotFound(new { message = error });
return BadRequest(new { message = error });
}
return NoContent();
}
}
}

View File

@@ -1,4 +1,5 @@
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Dtos.Comunicaciones;
using GestionIntegral.Api.Services.Comunicaciones;
using GestionIntegral.Api.Services.Suscripciones;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -13,14 +14,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
{
private readonly IFacturacionService _facturacionService;
private readonly ILogger<FacturacionController> _logger;
private readonly IEmailLogService _emailLogService;
private const string PermisoGestionarFacturacion = "SU006";
private const string PermisoEnviarEmail = "SU009";
// Permiso para generar facturación (a crear en la BD)
private const string PermisoGenerarFacturacion = "SU006";
public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger)
public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger, IEmailLogService emailLogService)
{
_facturacionService = facturacionService;
_logger = logger;
_emailLogService = emailLogService;
}
private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc);
@@ -28,67 +30,96 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
private int? GetCurrentUserId()
{
if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId;
_logger.LogWarning("No se pudo obtener el UserId del token JWT en FacturacionController.");
return null;
}
// POST: api/facturacion/{anio}/{mes}
[HttpPost("{anio:int}/{mes:int}")]
public async Task<IActionResult> GenerarFacturacion(int anio, int mes)
[HttpPut("{idFactura:int}/numero-factura")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> UpdateNumeroFactura(int idFactura, [FromBody] string numeroFactura)
{
if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid();
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
if (anio < 2020 || mes < 1 || mes > 12)
{
return BadRequest(new { message = "El año y el mes proporcionados no son válidos." });
}
var (exito, mensaje, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
var (exito, error) = await _facturacionService.ActualizarNumeroFactura(idFactura, numeroFactura, userId.Value);
if (!exito)
{
return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje });
if (error != null && error.Contains("no existe")) return NotFound(new { message = error });
return BadRequest(new { message = error });
}
return NoContent();
}
return Ok(new { message = mensaje, facturasGeneradas });
}
// GET: api/facturacion/{anio}/{mes}
[HttpGet("{anio:int}/{mes:int}")]
[ProducesResponseType(typeof(IEnumerable<FacturaDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetFacturas(int anio, int mes)
[HttpPost("{idFactura:int}/enviar-factura-pdf")]
public async Task<IActionResult> EnviarFacturaPdf(int idFactura)
{
// Usamos el permiso de generar facturación también para verlas.
if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid();
if (anio < 2020 || mes < 1 || mes > 12)
{
return BadRequest(new { message = "El período no es válido." });
}
var facturas = await _facturacionService.ObtenerFacturasPorPeriodo(anio, mes);
return Ok(facturas);
}
// POST: api/facturacion/{idFactura}/enviar-email
[HttpPost("{idFactura:int}/enviar-email")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> EnviarEmail(int idFactura)
{
// Usaremos un nuevo permiso para esta acción
if (!TienePermiso("SU009")) return Forbid();
var (exito, error) = await _facturacionService.EnviarFacturaPorEmail(idFactura);
if (!TienePermiso(PermisoEnviarEmail)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error, emailDestino) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura, userId.Value);
if (!exito)
{
return BadRequest(new { message = error });
}
return Ok(new { message = "Email enviado a la cola de procesamiento." });
var mensajeExito = $"El email con la factura PDF se ha enviado correctamente a {emailDestino}.";
return Ok(new { message = mensajeExito });
}
[HttpGet("{anio:int}/{mes:int}")]
public async Task<IActionResult> GetFacturas(
int anio, int mes,
[FromQuery] string? nombreSuscriptor,
[FromQuery] string? estadoPago,
[FromQuery] string? estadoFacturacion)
{
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." });
var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion);
return Ok(resumenes);
}
[HttpPost("{anio:int}/{mes:int}")]
public async Task<IActionResult> GenerarFacturacion(int anio, int mes)
{
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El año y el mes proporcionados no son válidos." });
var (exito, mensaje, resultadoEnvio) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje });
return Ok(new { message = mensaje, resultadoEnvio });
}
[HttpGet("historial-lotes-envio")]
[ProducesResponseType(typeof(IEnumerable<LoteDeEnvioHistorialDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistorialLotesEnvio([FromQuery] int? anio, [FromQuery] int? mes)
{
if (!TienePermiso("SU006")) return Forbid();
var historial = await _facturacionService.ObtenerHistorialLotesEnvio(anio, mes);
return Ok(historial);
}
// Endpoint para el historial de envíos de una factura individual
[HttpGet("{idFactura:int}/historial-envios")]
[ProducesResponseType(typeof(IEnumerable<EmailLogDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistorialEnvios(int idFactura)
{
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); // Reutilizamos el permiso
// Construimos la referencia que se guarda en el log
string referencia = $"Factura-{idFactura}";
var historial = await _emailLogService.ObtenerHistorialPorReferencia(referencia);
return Ok(historial);
}
}
}

View File

@@ -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();
}

View File

@@ -0,0 +1,65 @@
using Dapper;
using GestionIntegral.Api.Models.Comunicaciones;
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
{
public class EmailLogRepository : IEmailLogRepository
{
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<EmailLogRepository> _logger;
public EmailLogRepository(DbConnectionFactory connectionFactory, ILogger<EmailLogRepository> logger)
{
_connectionFactory = connectionFactory;
_logger = logger;
}
public async Task CreateAsync(EmailLog log)
{
const string sql = @"
INSERT INTO dbo.com_EmailLogs
(FechaEnvio, DestinatarioEmail, Asunto, Estado, Error, IdUsuarioDisparo, Origen, ReferenciaId, IdLoteDeEnvio)
VALUES
(@FechaEnvio, @DestinatarioEmail, @Asunto, @Estado, @Error, @IdUsuarioDisparo, @Origen, @ReferenciaId, @IdLoteDeEnvio);";
using var connection = _connectionFactory.CreateConnection();
await connection.ExecuteAsync(sql, log);
}
public async Task<IEnumerable<EmailLog>> GetByReferenceAsync(string referenciaId)
{
const string sql = @"
SELECT * FROM dbo.com_EmailLogs
WHERE ReferenciaId = @ReferenciaId
ORDER BY FechaEnvio DESC;";
try
{
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<EmailLog>(sql, new { ReferenciaId = referenciaId });
}
catch (System.Exception ex)
{
_logger.LogError(ex, "Error al obtener logs de email por ReferenciaId: {ReferenciaId}", referenciaId);
return Enumerable.Empty<EmailLog>();
}
}
public async Task<IEnumerable<EmailLog>> GetByLoteIdAsync(int idLoteDeEnvio)
{
// Ordenamos por Estado descendente para que los 'Fallidos' aparezcan primero
const string sql = @"
SELECT * FROM dbo.com_EmailLogs
WHERE IdLoteDeEnvio = @IdLoteDeEnvio
ORDER BY Estado DESC, FechaEnvio DESC;";
try
{
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<EmailLog>(sql, new { IdLoteDeEnvio = idLoteDeEnvio });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener logs de email por IdLoteDeEnvio: {IdLoteDeEnvio}", idLoteDeEnvio);
return Enumerable.Empty<EmailLog>();
}
}
}
}

View File

@@ -0,0 +1,26 @@
using GestionIntegral.Api.Models.Comunicaciones;
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
{
public interface IEmailLogRepository
{
/// <summary>
/// Guarda un nuevo registro de log de email en la base de datos.
/// </summary>
Task CreateAsync(EmailLog log);
/// <summary>
/// Obtiene todos los registros de log de email que coinciden con una referencia específica.
/// </summary>
/// <param name="referenciaId">El identificador de la entidad (ej. "Factura-59").</param>
/// <returns>Una colección de registros de log de email.</returns>
Task<IEnumerable<EmailLog>> GetByReferenceAsync(string referenciaId);
/// <summary>
/// Obtiene todos los registros de log de email que pertenecen a un lote de envío masivo.
/// </summary>
/// <param name="idLoteDeEnvio">El ID del lote de envío.</param>
/// <returns>Una colección de registros de log de email.</returns>
Task<IEnumerable<EmailLog>> GetByLoteIdAsync(int idLoteDeEnvio);
}
}

View File

@@ -0,0 +1,12 @@
using GestionIntegral.Api.Models.Comunicaciones;
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
{
public interface ILoteDeEnvioRepository
{
Task<LoteDeEnvio> CreateAsync(LoteDeEnvio lote);
Task<bool> UpdateAsync(LoteDeEnvio lote);
Task<IEnumerable<LoteDeEnvio>> GetAllAsync(int? anio, int? mes);
Task<LoteDeEnvio?> GetByIdAsync(int id);
}
}

View File

@@ -0,0 +1,69 @@
using System.Text;
using Dapper;
using GestionIntegral.Api.Models.Comunicaciones;
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
{
public class LoteDeEnvioRepository : ILoteDeEnvioRepository
{
private readonly DbConnectionFactory _connectionFactory;
public LoteDeEnvioRepository(DbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<LoteDeEnvio> CreateAsync(LoteDeEnvio lote)
{
const string sql = @"
INSERT INTO dbo.com_LotesDeEnvio (FechaInicio, Periodo, Origen, Estado, IdUsuarioDisparo)
OUTPUT INSERTED.*
VALUES (@FechaInicio, @Periodo, @Origen, @Estado, @IdUsuarioDisparo);";
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<LoteDeEnvio>(sql, lote);
}
public async Task<bool> UpdateAsync(LoteDeEnvio lote)
{
const string sql = @"
UPDATE dbo.com_LotesDeEnvio SET
FechaFin = @FechaFin,
Estado = @Estado,
TotalCorreos = @TotalCorreos,
TotalEnviados = @TotalEnviados,
TotalFallidos = @TotalFallidos
WHERE IdLoteDeEnvio = @IdLoteDeEnvio;";
using var connection = _connectionFactory.CreateConnection();
var rows = await connection.ExecuteAsync(sql, lote);
return rows == 1;
}
public async Task<IEnumerable<LoteDeEnvio>> GetAllAsync(int? anio, int? mes)
{
var sqlBuilder = new StringBuilder("SELECT * FROM dbo.com_LotesDeEnvio WHERE 1=1");
var parameters = new DynamicParameters();
if (anio.HasValue)
{
sqlBuilder.Append(" AND YEAR(FechaInicio) = @Anio");
parameters.Add("Anio", anio.Value);
}
if (mes.HasValue)
{
sqlBuilder.Append(" AND MONTH(FechaInicio) = @Mes");
parameters.Add("Mes", mes.Value);
}
sqlBuilder.Append(" ORDER BY FechaInicio DESC;");
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<LoteDeEnvio>(sqlBuilder.ToString(), parameters);
}
public async Task<LoteDeEnvio?> GetByIdAsync(int id)
{
const string sql = "SELECT * FROM dbo.com_LotesDeEnvio WHERE IdLoteDeEnvio = @Id;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<LoteDeEnvio>(sql, new { Id = id });
}
}
}

View File

@@ -45,5 +45,8 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
Task<IEnumerable<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla);
Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
Task<IEnumerable<ListadoDistCanMensualPubDto>> GetReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
Task<IEnumerable<FacturasParaReporteDto>> GetDatosReportePublicidadAsync(string periodo);
Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesActivasAsync(DateTime fechaDesde, DateTime fechaHasta);
Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesBajasAsync(DateTime fechaDesde, DateTime fechaHasta);
}
}

View File

@@ -547,5 +547,111 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
commandType: CommandType.StoredProcedure, commandTimeout: 120
);
}
public async Task<IEnumerable<FacturasParaReporteDto>> GetDatosReportePublicidadAsync(string periodo)
{
// Esta consulta une todas las tablas necesarias para obtener los datos del reporte
const string sql = @"
SELECT
f.IdFactura,
f.Periodo,
s.NombreCompleto AS NombreSuscriptor,
s.TipoDocumento,
s.NroDocumento,
f.ImporteFinal,
e.Id_Empresa AS IdEmpresa,
e.Nombre AS NombreEmpresa
FROM dbo.susc_Facturas f
JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor
-- Usamos una subconsulta para obtener la empresa de forma segura
JOIN (
SELECT DISTINCT
fd.IdFactura,
p.Id_Empresa
FROM dbo.susc_FacturaDetalles fd
JOIN dbo.susc_Suscripciones sub ON fd.IdSuscripcion = sub.IdSuscripcion
JOIN dbo.dist_dtPublicaciones p ON sub.IdPublicacion = p.Id_Publicacion
) AS FacturaEmpresa ON f.IdFactura = FacturaEmpresa.IdFactura
JOIN dbo.dist_dtEmpresas e ON FacturaEmpresa.Id_Empresa = e.Id_Empresa
WHERE
f.Periodo = @Periodo
AND f.EstadoPago = 'Pagada'
AND f.EstadoFacturacion = 'Pendiente de Facturar'
ORDER BY
e.Nombre, s.NombreCompleto;
";
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<FacturasParaReporteDto>(sql, new { Periodo = periodo });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al ejecutar la consulta para el Reporte de Publicidad para el período {Periodo}", periodo);
return Enumerable.Empty<FacturasParaReporteDto>();
}
}
public async Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesActivasAsync(DateTime fechaDesde, DateTime fechaHasta)
{
const string sql = @"
SELECT
e.Nombre AS NombreEmpresa, p.Nombre AS NombrePublicacion,
sus.NombreCompleto AS NombreSuscriptor, sus.Direccion, sus.Telefono,
s.FechaInicio, s.FechaFin, s.DiasEntrega, s.Observaciones
FROM dbo.susc_Suscripciones s
JOIN dbo.susc_Suscriptores sus ON s.IdSuscriptor = sus.IdSuscriptor
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
JOIN dbo.dist_dtEmpresas e ON p.Id_Empresa = e.Id_Empresa
WHERE
-- --- INICIO DE LA CORRECCIÓN ---
-- Se asegura de que SOLO se incluyan suscripciones y suscriptores ACTIVOS.
s.Estado = 'Activa' AND sus.Activo = 1
-- --- FIN DE LA CORRECCIÓN ---
AND s.FechaInicio <= @FechaHasta
AND (s.FechaFin IS NULL OR s.FechaFin >= @FechaDesde)
ORDER BY e.Nombre, p.Nombre, sus.NombreCompleto;";
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<DistribucionSuscripcionDto>(sql, new { FechaDesde = fechaDesde, FechaHasta = fechaHasta });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener datos para Reporte de Distribución (Activas).");
return Enumerable.Empty<DistribucionSuscripcionDto>();
}
}
public async Task<IEnumerable<DistribucionSuscripcionDto>> GetDistribucionSuscripcionesBajasAsync(DateTime fechaDesde, DateTime fechaHasta)
{
const string sql = @"
SELECT
e.Nombre AS NombreEmpresa, p.Nombre AS NombrePublicacion,
sus.NombreCompleto AS NombreSuscriptor, sus.Direccion, sus.Telefono,
s.FechaInicio, s.FechaFin, s.DiasEntrega, s.Observaciones
FROM dbo.susc_Suscripciones s
JOIN dbo.susc_Suscriptores sus ON s.IdSuscriptor = sus.IdSuscriptor
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
JOIN dbo.dist_dtEmpresas e ON p.Id_Empresa = e.Id_Empresa
WHERE
-- La lógica aquí es correcta: buscamos cualquier suscripción cuya fecha de fin
-- caiga dentro del rango de fechas seleccionado.
s.FechaFin BETWEEN @FechaDesde AND @FechaHasta
ORDER BY e.Nombre, p.Nombre, s.FechaFin, sus.NombreCompleto;";
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<DistribucionSuscripcionDto>(sql, new { FechaDesde = fechaDesde, FechaHasta = fechaHasta });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener datos para Reporte de Distribución (Bajas).");
return Enumerable.Empty<DistribucionSuscripcionDto>();
}
}
}
}

View File

@@ -0,0 +1,139 @@
using Dapper;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
using System.Text;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public class AjusteRepository : IAjusteRepository
{
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<AjusteRepository> _logger;
public AjusteRepository(DbConnectionFactory factory, ILogger<AjusteRepository> logger)
{
_connectionFactory = factory;
_logger = logger;
}
public async Task<bool> UpdateAsync(Ajuste ajuste, IDbTransaction transaction)
{
const string sql = @"
UPDATE dbo.susc_Ajustes SET
IdEmpresa = @IdEmpresa,
FechaAjuste = @FechaAjuste,
TipoAjuste = @TipoAjuste,
Monto = @Monto,
Motivo = @Motivo
WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
var rows = await transaction.Connection.ExecuteAsync(sql, ajuste, transaction);
return rows == 1;
}
public async Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction)
{
const string sql = @"
INSERT INTO dbo.susc_Ajustes (IdSuscriptor, IdEmpresa, FechaAjuste, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta)
OUTPUT INSERTED.*
VALUES (@IdSuscriptor, @IdEmpresa, @FechaAjuste, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
return await transaction.Connection.QuerySingleOrDefaultAsync<Ajuste>(sql, nuevoAjuste, transaction);
}
public async Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta)
{
var sqlBuilder = new StringBuilder("SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor");
var parameters = new DynamicParameters();
parameters.Add("IdSuscriptor", idSuscriptor);
if (fechaDesde.HasValue)
{
sqlBuilder.Append(" AND FechaAjuste >= @FechaDesde");
parameters.Add("FechaDesde", fechaDesde.Value.Date);
}
if (fechaHasta.HasValue)
{
sqlBuilder.Append(" AND FechaAjuste <= @FechaHasta");
parameters.Add("FechaHasta", fechaHasta.Value.Date);
}
sqlBuilder.Append(" ORDER BY FechaAlta DESC;");
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Ajuste>(sqlBuilder.ToString(), parameters);
}
public async Task<IEnumerable<Ajuste>> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, int idEmpresa, DateTime fechaHasta, IDbTransaction transaction)
{
const string sql = @"
SELECT * FROM dbo.susc_Ajustes
WHERE IdSuscriptor = @IdSuscriptor
AND IdEmpresa = @IdEmpresa
AND Estado = 'Pendiente'
AND FechaAjuste <= @FechaHasta;";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
return await transaction.Connection.QueryAsync<Ajuste>(sql, new { idSuscriptor, idEmpresa, FechaHasta = fechaHasta }, transaction);
}
public async Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction)
{
if (!idsAjustes.Any()) return true;
const string sql = @"
UPDATE dbo.susc_Ajustes SET
Estado = 'Aplicado',
IdFacturaAplicado = @IdFactura
WHERE IdAjuste IN @IdsAjustes;";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdsAjustes = idsAjustes, IdFactura = idFactura }, transaction);
return rowsAffected == idsAjustes.Count();
}
public async Task<Ajuste?> GetByIdAsync(int idAjuste)
{
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdAjuste = @IdAjuste;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<Ajuste>(sql, new { idAjuste });
}
public async Task<bool> AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction)
{
const string sql = @"
UPDATE dbo.susc_Ajustes SET
Estado = 'Anulado',
IdUsuarioAnulo = @IdUsuario,
FechaAnulacion = GETDATE()
WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
var rows = await transaction.Connection.ExecuteAsync(sql, new { IdAjuste = idAjuste, IdUsuario = idUsuario }, transaction);
return rows == 1;
}
public async Task<IEnumerable<Ajuste>> GetAjustesPorIdFacturaAsync(int idFactura)
{
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdFacturaAplicado = @IdFactura;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Ajuste>(sql, new { IdFactura = idFactura });
}
}
}

View File

@@ -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>();
}
}
}
}

View File

@@ -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,161 @@ 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
var sqlBuilder = new StringBuilder(@"
WITH FacturaConEmpresa AS (
-- Esta subconsulta obtiene el IdEmpresa para cada factura basándose en la primera suscripción que encuentra en sus detalles.
-- Esto es seguro porque nuestra lógica de negocio asegura que todos los detalles de una factura pertenecen a la misma empresa.
SELECT
f.IdFactura,
(SELECT TOP 1 p.Id_Empresa
FROM dbo.susc_FacturaDetalles fd
JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
WHERE fd.IdFactura = f.IdFactura) AS IdEmpresa
FROM dbo.susc_Facturas f
JOIN dbo.susc_Suscripciones sc ON f.IdSuscripcion = sc.IdSuscripcion
JOIN dbo.susc_Suscriptores s ON sc.IdSuscriptor = s.IdSuscriptor
JOIN dbo.dist_dtPublicaciones p ON sc.IdPublicacion = p.Id_Publicacion
WHERE f.Periodo = @Periodo
ORDER BY s.NombreCompleto;
";
)
SELECT
f.*,
s.NombreCompleto AS NombreSuscriptor,
fce.IdEmpresa,
(SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos pg WHERE pg.IdFactura = f.IdFactura AND pg.Estado = 'Aprobado') AS TotalPagado
FROM dbo.susc_Facturas f
JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor
JOIN FacturaConEmpresa fce ON f.IdFactura = fce.IdFactura
WHERE f.Periodo = @Periodo");
var parameters = new DynamicParameters();
parameters.Add("Periodo", periodo);
if (!string.IsNullOrWhiteSpace(nombreSuscriptor))
{
sqlBuilder.Append(" AND s.NombreCompleto LIKE @NombreSuscriptor");
parameters.Add("NombreSuscriptor", $"%{nombreSuscriptor}%");
}
if (!string.IsNullOrWhiteSpace(estadoPago))
{
sqlBuilder.Append(" AND f.EstadoPago = @EstadoPago");
parameters.Add("EstadoPago", estadoPago);
}
if (!string.IsNullOrWhiteSpace(estadoFacturacion))
{
sqlBuilder.Append(" AND f.EstadoFacturacion = @EstadoFacturacion");
parameters.Add("EstadoFacturacion", estadoFacturacion);
}
sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;");
try
{
using var connection = _connectionFactory.CreateConnection();
var result = await connection.QueryAsync<Factura, string, string, (Factura, string, string)>(
sql,
(factura, suscriptor, publicacion) => (factura, suscriptor, publicacion),
new { Periodo = periodo },
splitOn: "NombreSuscriptor,NombrePublicacion"
var result = await connection.QueryAsync<Factura, string, int, decimal, (Factura, string, int, decimal)>(
sqlBuilder.ToString(),
(factura, suscriptor, idEmpresa, totalPagado) => (factura, suscriptor, idEmpresa, totalPagado),
parameters,
splitOn: "NombreSuscriptor,IdEmpresa,TotalPagado"
);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener facturas enriquecidas para el período {Periodo}", periodo);
return Enumerable.Empty<(Factura, string, string)>();
return Enumerable.Empty<(Factura, string, int, decimal)>();
}
}
public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction)
public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction)
{
if (transaction == null || transaction.Connection == null)
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = @"
UPDATE dbo.susc_Facturas SET
Estado = @NuevoEstado,
EstadoPago = @NuevoEstadoPago,
MotivoRechazo = @MotivoRechazo
WHERE IdFactura = @IdFactura;";
var rowsAffected = await transaction.Connection.ExecuteAsync(
sql,
new { NuevoEstado = nuevoEstado, MotivoRechazo = motivoRechazo, IdFactura = idFactura },
transaction
);
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, MotivoRechazo = motivoRechazo, idFactura }, transaction);
return rowsAffected == 1;
}
public async Task<string?> GetUltimoPeriodoFacturadoAsync()
{
const string sql = "SELECT TOP 1 Periodo FROM dbo.susc_Facturas ORDER BY Periodo DESC;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<string>(sql);
}
public async Task<IEnumerable<(Factura Factura, string NombreEmpresa)>> GetFacturasConEmpresaAsync(int idSuscriptor, string periodo)
{
// Esta consulta es más robusta y eficiente. Obtiene la factura y el nombre de la empresa en una sola llamada.
const string sql = @"
SELECT f.*, e.Nombre AS NombreEmpresa
FROM dbo.susc_Facturas f
OUTER APPLY (
SELECT TOP 1 emp.Nombre
FROM dbo.susc_FacturaDetalles fd
JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
JOIN dbo.dist_dtEmpresas emp ON p.Id_Empresa = emp.Id_Empresa
WHERE fd.IdFactura = f.IdFactura
) e
WHERE f.IdSuscriptor = @IdSuscriptor AND f.Periodo = @Periodo;";
try
{
using var connection = _connectionFactory.CreateConnection();
var result = await connection.QueryAsync<Factura, string, (Factura, string)>(
sql,
(factura, nombreEmpresa) => (factura, nombreEmpresa ?? "N/A"), // Asignamos "N/A" si no encuentra empresa
new { IdSuscriptor = idSuscriptor, Periodo = periodo },
splitOn: "NombreEmpresa"
);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener facturas con empresa para suscriptor {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo);
return Enumerable.Empty<(Factura, string)>();
}
}
public async Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo)
{
// Consulta simplificada pero robusta.
const string sql = @"
SELECT * FROM dbo.susc_Facturas
WHERE Periodo = @Periodo
AND EstadoPago = 'Pagada'
AND EstadoFacturacion = 'Pendiente de Facturar';";
try
{
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Factura>(sql, new { Periodo = periodo });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener facturas pagadas pendientes de facturar para el período {Periodo}", periodo);
return Enumerable.Empty<Factura>();
}
}
public async Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids)
{
if (ids == null || !ids.Any())
{
return Enumerable.Empty<Factura>();
}
const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura IN @Ids;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Factura>(sql, new { Ids = ids });
}
}
}

View File

@@ -0,0 +1,22 @@
// Archivo: GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs
using GestionIntegral.Api.Models.Suscripciones;
using System;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public interface IAjusteRepository
{
Task<Ajuste?> GetByIdAsync(int idAjuste);
Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction);
Task<bool> UpdateAsync(Ajuste ajuste, IDbTransaction transaction);
Task<bool> AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction);
Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta);
Task<IEnumerable<Ajuste>> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, int idEmpresa, DateTime fechaHasta, IDbTransaction transaction);
Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction);
Task<IEnumerable<Ajuste>> GetAjustesPorIdFacturaAsync(int idFactura);
}
}

View File

@@ -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);
}
}

View File

@@ -6,13 +6,18 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public interface IFacturaRepository
{
Task<Factura?> GetByIdAsync(int idFactura);
Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids);
Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo);
Task<Factura?> GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction);
Task<Factura?> GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction);
Task<IEnumerable<Factura>> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo);
Task<IEnumerable<(Factura Factura, string NombreEmpresa)>> GetFacturasConEmpresaAsync(int idSuscriptor, string periodo);
Task<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction);
Task<bool> UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction);
Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction);
Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction);
Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction);
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo);
Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction);
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion);
Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);
Task<string?> GetUltimoPeriodoFacturadoAsync();
Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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
-- 1. La promoción general debe estar activa en el período
AND p.FechaInicio <= @FechaPeriodo
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo);";
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo)
-- 2. La asignación específica al cliente debe estar activa en el período
AND sp.VigenciaDesde <= @FechaPeriodo
AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
return await transaction.Connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion, FechaPeriodo = fechaPeriodo }, transaction);
}
// Versión SIN transacción, para solo lectura
public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo)
{
const string sql = @"
SELECT p.*
FROM dbo.susc_Promociones p
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion
AND p.Activa = 1
-- 1. La promoción general debe estar activa en el período
AND p.FechaInicio <= @FechaPeriodo
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo)
-- 2. La asignación específica al cliente debe estar activa en el período
AND sp.VigenciaDesde <= @FechaPeriodo
AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Promocion>(sql, new { idSuscripcion, FechaPeriodo = fechaPeriodo });
}
}
}

View File

@@ -47,7 +47,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction)
{
// Lógica para determinar el rango del período (ej. '2023-11')
var year = int.Parse(periodo.Split('-')[0]);
var month = int.Parse(periodo.Split('-')[1]);
var primerDiaMes = new DateTime(year, month, 1);
@@ -112,30 +111,35 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return rowsAffected == 1;
}
public async Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion)
public async Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion)
{
const string sql = @"
SELECT p.* FROM dbo.susc_Promociones p
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
SELECT sp.*, p.*
FROM dbo.susc_SuscripcionPromociones sp
JOIN dbo.susc_Promociones p ON sp.IdPromocion = p.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion });
var result = await connection.QueryAsync<SuscripcionPromocion, Promocion, (SuscripcionPromocion, Promocion)>(
sql,
(asignacion, promocion) => (asignacion, promocion),
new { IdSuscripcion = idSuscripcion },
splitOn: "IdPromocion"
);
return result;
}
public async Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction)
public async Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction)
{
if (transaction == null || transaction.Connection == null)
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = @"
INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno)
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuario);";
INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno, VigenciaDesde, VigenciaHasta, FechaAsignacion)
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());";
await transaction.Connection.ExecuteAsync(sql,
new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion, IdUsuario = idUsuario },
transaction);
await transaction.Connection.ExecuteAsync(sql, asignacion, transaction);
}
public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction)
@@ -145,7 +149,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = "DELETE FROM dbo.susc_SuscripcionPromociones WHERE IdSuscripcion = @IdSuscripcion AND IdPromocion = @IdPromocion;";
var rows = await transaction.Connection.ExecuteAsync(sql, new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion }, transaction);
var rows = await transaction.Connection.ExecuteAsync(sql, new { idSuscripcion, idPromocion }, transaction);
return rows == 1;
}
}

View File

@@ -1,3 +1,5 @@
// Archivo: GestionIntegral.Api/Data/Repositories/Usuarios/IUsuarioRepository.cs
using GestionIntegral.Api.Models.Usuarios; // Para Usuario
using GestionIntegral.Api.Dtos.Usuarios.Auditoria;
using System.Collections.Generic;
@@ -10,6 +12,7 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
{
Task<IEnumerable<Usuario>> GetAllAsync(string? userFilter, string? nombreFilter);
Task<Usuario?> GetByIdAsync(int id);
Task<IEnumerable<Usuario>> GetByIdsAsync(IEnumerable<int> ids);
Task<Usuario?> GetByUsernameAsync(string username); // Ya existe en IAuthRepository, pero lo duplicamos para cohesión del CRUD
Task<Usuario?> CreateAsync(Usuario nuevoUsuario, int idUsuarioCreador, IDbTransaction transaction);
Task<bool> UpdateAsync(Usuario usuarioAActualizar, int idUsuarioModificador, IDbTransaction transaction);
@@ -17,7 +20,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
// Task<bool> DeleteAsync(int id, int idUsuarioModificador, IDbTransaction transaction);
Task<bool> SetPasswordAsync(int userId, string newHash, string newSalt, bool debeCambiarClave, int idUsuarioModificador, IDbTransaction transaction);
Task<bool> UserExistsAsync(string username, int? excludeId = null);
// Para el DTO de listado
Task<IEnumerable<(Usuario Usuario, string NombrePerfil)>> GetAllWithProfileNameAsync(string? userFilter, string? nombreFilter);
Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id);
Task<IEnumerable<UsuarioHistorialDto>> GetHistorialByUsuarioIdAsync(int idUsuarioAfectado, DateTime? fechaDesde, DateTime? fechaHasta);

View File

@@ -1,12 +1,8 @@
using Dapper;
using GestionIntegral.Api.Models.Usuarios;
using GestionIntegral.Api.Dtos.Usuarios.Auditoria;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Usuarios
{
@@ -88,7 +84,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
}
}
public async Task<Usuario?> GetByIdAsync(int id)
{
const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id = @Id";
@@ -103,6 +98,33 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
return null;
}
}
public async Task<IEnumerable<Usuario>> GetByIdsAsync(IEnumerable<int> ids)
{
// 1. Validar si la lista de IDs está vacía para evitar una consulta innecesaria a la BD.
if (ids == null || !ids.Any())
{
return Enumerable.Empty<Usuario>();
}
// 2. Definir la consulta. Dapper manejará la expansión de la cláusula IN de forma segura.
const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id IN @Ids";
try
{
// 3. Crear conexión y ejecutar la consulta.
using var connection = _connectionFactory.CreateConnection();
// 4. Pasar la colección de IDs como parámetro. El nombre 'Ids' debe coincidir con el placeholder '@Ids'.
return await connection.QueryAsync<Usuario>(sql, new { Ids = ids });
}
catch (Exception ex)
{
// 5. Registrar el error y devolver una lista vacía en caso de fallo para no romper la aplicación.
_logger.LogError(ex, "Error al obtener Usuarios por lista de IDs.");
return Enumerable.Empty<Usuario>();
}
}
public async Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id)
{
const string sql = @"
@@ -128,7 +150,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
}
}
public async Task<Usuario?> GetByUsernameAsync(string username)
{
// Esta es la misma que en AuthRepository, si se unifican, se puede eliminar una.

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />

View File

@@ -0,0 +1,16 @@
namespace GestionIntegral.Api.Models.Comunicaciones
{
public class EmailLog
{
public int IdEmailLog { get; set; }
public DateTime FechaEnvio { get; set; }
public string DestinatarioEmail { get; set; } = string.Empty;
public string Asunto { get; set; } = string.Empty;
public string Estado { get; set; } = string.Empty;
public string? Error { get; set; }
public int? IdUsuarioDisparo { get; set; }
public string? Origen { get; set; }
public string? ReferenciaId { get; set; }
public int? IdLoteDeEnvio { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
namespace GestionIntegral.Api.Models.Comunicaciones
{
public class LoteDeEnvio
{
public int IdLoteDeEnvio { get; set; }
public DateTime FechaInicio { get; set; }
public DateTime? FechaFin { get; set; }
public string Periodo { get; set; } = string.Empty;
public string Origen { get; set; } = string.Empty;
public string Estado { get; set; } = string.Empty;
public int TotalCorreos { get; set; }
public int TotalEnviados { get; set; }
public int TotalFallidos { get; set; }
public int IdUsuarioDisparo { get; set; }
}
}

View File

@@ -0,0 +1,20 @@
namespace GestionIntegral.Api.Dtos.Comunicaciones
{
/// <summary>
/// Representa un registro de historial de envío de correo para ser mostrado en la interfaz de usuario.
/// </summary>
public class EmailLogDto
{
public DateTime FechaEnvio { get; set; }
public string Estado { get; set; } = string.Empty;
public string Asunto { get; set; } = string.Empty;
public string DestinatarioEmail { get; set; } = string.Empty;
public string? Error { get; set; }
/// <summary>
/// Nombre del usuario que inició la acción de envío (ej. "Juan Pérez").
/// Puede ser "Sistema" si el envío fue automático (ej. Cierre Mensual).
/// </summary>
public string? NombreUsuarioDisparo { get; set; }
}
}

View File

@@ -0,0 +1,26 @@
namespace GestionIntegral.Api.Dtos.Comunicaciones
{
// DTO para el feedback inmediato
public class LoteDeEnvioResumenDto
{
public int IdLoteDeEnvio { get; set; }
public string Periodo { get; set; } = string.Empty;
public int TotalCorreos { get; set; }
public int TotalEnviados { get; set; }
public int TotalFallidos { get; set; }
public List<EmailLogDto> ErroresDetallados { get; set; } = new();
}
// DTO para la tabla de historial
public class LoteDeEnvioHistorialDto
{
public int IdLoteDeEnvio { get; set; }
public DateTime FechaInicio { get; set; }
public string Periodo { get; set; } = string.Empty;
public string Estado { get; set; } = string.Empty;
public int TotalCorreos { get; set; }
public int TotalEnviados { get; set; }
public int TotalFallidos { get; set; }
public string NombreUsuarioDisparo { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,11 @@
using GestionIntegral.Api.Dtos.Comunicaciones;
public class LoteDeEnvioResumenDto
{
public int IdLoteDeEnvio { get; set; }
public required string Periodo { get; set; }
public int TotalCorreos { get; set; }
public int TotalEnviados { get; set; }
public int TotalFallidos { get; set; }
public List<EmailLogDto> ErroresDetallados { get; set; } = new();
}

View File

@@ -0,0 +1,15 @@
namespace GestionIntegral.Api.Dtos.Reportes
{
public class DistribucionSuscripcionDto
{
public string NombreEmpresa { get; set; } = string.Empty;
public string NombrePublicacion { get; set; } = string.Empty;
public string NombreSuscriptor { get; set; } = string.Empty;
public string Direccion { get; set; } = string.Empty;
public string? Telefono { get; set; }
public DateTime FechaInicio { get; set; }
public DateTime? FechaFin { get; set; }
public string DiasEntrega { get; set; } = string.Empty;
public string? Observaciones { get; set; }
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,55 @@
namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
{
/// <summary>
/// Representa una agrupación de suscripciones por publicación para el reporte.
/// </summary>
public class GrupoPublicacion
{
public string NombrePublicacion { get; set; } = string.Empty;
public IEnumerable<DistribucionSuscripcionDto> Suscripciones { get; set; } = Enumerable.Empty<DistribucionSuscripcionDto>();
}
/// <summary>
/// Representa una agrupación de publicaciones por empresa para el reporte.
/// </summary>
public class GrupoEmpresa
{
public string NombreEmpresa { get; set; } = string.Empty;
public IEnumerable<GrupoPublicacion> Publicaciones { get; set; } = Enumerable.Empty<GrupoPublicacion>();
}
public class DistribucionSuscripcionesViewModel
{
public IEnumerable<GrupoEmpresa> DatosAgrupadosAltas { get; }
public IEnumerable<GrupoEmpresa> DatosAgrupadosBajas { get; }
public string FechaDesde { get; set; } = string.Empty;
public string FechaHasta { get; set; } = string.Empty;
public string FechaGeneracion { get; set; } = string.Empty;
public DistribucionSuscripcionesViewModel(IEnumerable<DistribucionSuscripcionDto> altas, IEnumerable<DistribucionSuscripcionDto> bajas)
{
// Función local para evitar repetir el código de agrupación
Func<IEnumerable<DistribucionSuscripcionDto>, IEnumerable<GrupoEmpresa>> agruparDatos = (suscripciones) =>
{
return suscripciones
.GroupBy(s => s.NombreEmpresa)
.Select(gEmpresa => new GrupoEmpresa
{
NombreEmpresa = gEmpresa.Key,
Publicaciones = gEmpresa
.GroupBy(s => s.NombrePublicacion)
.Select(gPub => new GrupoPublicacion
{
NombrePublicacion = gPub.Key,
Suscripciones = gPub.OrderBy(s => s.NombreSuscriptor).ToList()
})
.OrderBy(p => p.NombrePublicacion)
})
.OrderBy(e => e.NombreEmpresa);
};
DatosAgrupadosAltas = agruparDatos(altas);
DatosAgrupadosBajas = agruparDatos(bajas);
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,19 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class AjusteDto
{
public int IdAjuste { get; set; }
public int IdSuscriptor { get; set; }
public int IdEmpresa { get; set; }
public string? NombreEmpresa { get; set; }
public string FechaAjuste { get; set; } = string.Empty;
public string TipoAjuste { get; set; } = string.Empty;
public decimal Monto { get; set; }
public string Motivo { get; set; } = string.Empty;
public string Estado { get; set; } = string.Empty;
public int? IdFacturaAplicado { get; set; }
public string? NumeroFacturaAplicado { get; set; }
public string FechaAlta { get; set; } = string.Empty;
public string NombreUsuarioAlta { get; set; } = string.Empty;
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class CreateAjusteDto
{
[Required]
public int IdSuscriptor { get; set; }
[Required]
public int IdEmpresa { get; set; }
[Required]
public DateTime FechaAjuste { get; set; }
[Required]
[RegularExpression("^(Credito|Debito)$", ErrorMessage = "El tipo de ajuste debe ser 'Credito' o 'Debito'.")]
public string TipoAjuste { get; set; } = string.Empty;
[Required]
[Range(0.01, 999999.99, ErrorMessage = "El monto debe ser un valor positivo.")]
public decimal Monto { get; set; }
[Required(ErrorMessage = "El motivo es obligatorio.")]
[StringLength(250)]
public string Motivo { get; set; } = string.Empty;
}
}

View File

@@ -7,22 +7,25 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class CreatePromocionDto
{
[Required(ErrorMessage = "La descripción es obligatoria.")]
[Required]
[StringLength(200)]
public string Descripcion { get; set; } = string.Empty;
[Required(ErrorMessage = "El tipo de promoción es obligatorio.")]
public string TipoPromocion { get; set; } = string.Empty;
[Required]
public string TipoEfecto { get; set; } = string.Empty; // Corregido
[Required(ErrorMessage = "El valor es obligatorio.")]
[Range(0.01, 99999999.99, ErrorMessage = "El valor debe ser positivo.")]
public decimal Valor { get; set; }
[Required]
[Range(0, 99999999.99)] // Se permite 0 para bonificaciones
public decimal ValorEfecto { get; set; } // Corregido
[Required(ErrorMessage = "La fecha de inicio es obligatoria.")]
[Required]
public string TipoCondicion { get; set; } = string.Empty;
public int? ValorCondicion { get; set; }
[Required]
public DateTime FechaInicio { get; set; }
public DateTime? FechaFin { get; set; }
public bool Activa { get; set; } = true;
}
}

View File

@@ -1,3 +1,5 @@
// Archivo: GestionIntegral.Api/Dtos/Suscripciones/CreateSuscriptorDto.cs
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones
@@ -13,6 +15,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
public string? Email { get; set; }
[StringLength(50)]
[RegularExpression(@"^[0-9\s\+\-\(\)]*$", ErrorMessage = "El teléfono solo puede contener números y los símbolos +, -, () y espacios.")]
public string? Telefono { get; set; }
[Required(ErrorMessage = "La dirección es obligatoria.")]
@@ -25,9 +28,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
[Required(ErrorMessage = "El número de documento es obligatorio.")]
[StringLength(11)]
[RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")]
public string NroDocumento { get; set; } = string.Empty;
[StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")]
[RegularExpression("^[0-9]*$", ErrorMessage = "El CBU solo puede contener números.")]
public string? CBU { get; set; }
[Required(ErrorMessage = "La forma de pago es obligatoria.")]

View File

@@ -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>();
}
}

View File

@@ -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>();
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}

View File

@@ -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>();
}
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones;
public class UpdateAjusteDto
{
[Required]
public int IdEmpresa { get; set; }
[Required]
public DateTime FechaAjuste { get; set; }
[Required]
[RegularExpression("^(Credito|Debito)$")]
public string TipoAjuste { get; set; } = string.Empty;
[Required]
[Range(0.01, 999999.99)]
public decimal Monto { get; set; }
[Required]
[StringLength(250)]
public string Motivo { get; set; } = string.Empty;
}

View File

@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones
{
// Es idéntico al CreateDto, pero se mantiene separado por si las reglas de validación cambian.
public class UpdateSuscriptorDto
{
[Required(ErrorMessage = "El nombre completo es obligatorio.")]
@@ -14,6 +13,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
public string? Email { get; set; }
[StringLength(50)]
[RegularExpression(@"^[0-9\s\+\-\(\)]*$", ErrorMessage = "El teléfono solo puede contener números y los símbolos +, -, () y espacios.")]
public string? Telefono { get; set; }
[Required(ErrorMessage = "La dirección es obligatoria.")]
@@ -26,9 +26,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
[Required(ErrorMessage = "El número de documento es obligatorio.")]
[StringLength(11)]
[RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")]
public string NroDocumento { get; set; } = string.Empty;
[StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")]
[RegularExpression("^[0-9]*$", ErrorMessage = "El CBU solo puede contener números.")]
public string? CBU { get; set; }
[Required(ErrorMessage = "La forma de pago es obligatoria.")]

View File

@@ -0,0 +1,19 @@
namespace GestionIntegral.Api.Models.Suscripciones
{
public class Ajuste
{
public int IdAjuste { get; set; }
public int IdSuscriptor { get; set; }
public int IdEmpresa { get; set; }
public DateTime FechaAjuste { get; set; }
public string TipoAjuste { get; set; } = string.Empty;
public decimal Monto { get; set; }
public string Motivo { get; set; } = string.Empty;
public string Estado { get; set; } = string.Empty;
public int? IdFacturaAplicado { get; set; }
public int IdUsuarioAlta { get; set; }
public DateTime FechaAlta { get; set; }
public int? IdUsuarioAnulo { get; set; }
public DateTime? FechaAnulacion { get; set; }
}
}

View File

@@ -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; }

View File

@@ -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; }
}

View File

@@ -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; }

View File

@@ -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; }
}
}

View File

@@ -22,6 +22,11 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Services.Suscripciones;
using GestionIntegral.Api.Models.Comunicaciones;
using GestionIntegral.Api.Services.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
// Carga las variables de entorno desde el archivo .env al inicio de la aplicación.
// Debe ser la primera línea para que la configuración esté disponible para el 'builder'.
DotNetEnv.Env.Load();
var builder = WebApplication.CreateBuilder(args);
@@ -112,6 +117,8 @@ builder.Services.AddScoped<IFacturaRepository, FacturaRepository>();
builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>();
builder.Services.AddScoped<IPagoRepository, PagoRepository>();
builder.Services.AddScoped<IPromocionRepository, PromocionRepository>();
builder.Services.AddScoped<IAjusteRepository, AjusteRepository>();
builder.Services.AddScoped<IFacturaDetalleRepository, FacturaDetalleRepository>();
builder.Services.AddScoped<IFormaPagoService, FormaPagoService>();
builder.Services.AddScoped<ISuscriptorService, SuscriptorService>();
@@ -120,10 +127,14 @@ builder.Services.AddScoped<IFacturacionService, FacturacionService>();
builder.Services.AddScoped<IDebitoAutomaticoService, DebitoAutomaticoService>();
builder.Services.AddScoped<IPagoService, PagoService>();
builder.Services.AddScoped<IPromocionService, PromocionService>();
builder.Services.AddScoped<IAjusteService, AjusteService>();
// --- Comunicaciones ---
builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings"));
builder.Services.AddTransient<IEmailService, EmailService>();
builder.Services.AddScoped<IEmailLogRepository, EmailLogRepository>();
builder.Services.AddScoped<IEmailLogService, EmailLogService>();
builder.Services.AddScoped<ILoteDeEnvioRepository, LoteDeEnvioRepository>();
// --- SERVICIO DE HEALTH CHECKS ---
// Añadimos una comprobación específica para SQL Server.

View File

@@ -0,0 +1,87 @@
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Usuarios;
using GestionIntegral.Api.Dtos.Comunicaciones;
namespace GestionIntegral.Api.Services.Comunicaciones
{
public class EmailLogService : IEmailLogService
{
private readonly IEmailLogRepository _emailLogRepository;
private readonly IUsuarioRepository _usuarioRepository;
public EmailLogService(IEmailLogRepository emailLogRepository, IUsuarioRepository usuarioRepository)
{
_emailLogRepository = emailLogRepository;
_usuarioRepository = usuarioRepository;
}
public async Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId)
{
var logs = await _emailLogRepository.GetByReferenceAsync(referenciaId);
if (!logs.Any())
{
return Enumerable.Empty<EmailLogDto>();
}
// Optimización N+1: Obtener todos los usuarios necesarios en una sola consulta
var idsUsuarios = logs
.Where(l => l.IdUsuarioDisparo.HasValue)
.Select(l => l.IdUsuarioDisparo!.Value)
.Distinct();
var usuariosDict = new Dictionary<int, string>();
if (idsUsuarios.Any())
{
var usuarios = await _usuarioRepository.GetByIdsAsync(idsUsuarios);
usuariosDict = usuarios.ToDictionary(u => u.Id, u => $"{u.Nombre} {u.Apellido}");
}
// Mapear a DTO
return logs.Select(log => new EmailLogDto
{
FechaEnvio = log.FechaEnvio,
Estado = log.Estado,
Asunto = log.Asunto,
DestinatarioEmail = log.DestinatarioEmail,
Error = log.Error,
NombreUsuarioDisparo = log.IdUsuarioDisparo.HasValue
? usuariosDict.GetValueOrDefault(log.IdUsuarioDisparo.Value, "Usuario Desconocido")
: "Sistema"
});
}
public async Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio)
{
var logs = await _emailLogRepository.GetByLoteIdAsync(idLoteDeEnvio);
if (!logs.Any())
{
return Enumerable.Empty<EmailLogDto>();
}
// Reutilizamos la misma lógica de optimización N+1 que ya teníamos
var idsUsuarios = logs
.Where(l => l.IdUsuarioDisparo.HasValue)
.Select(l => l.IdUsuarioDisparo!.Value)
.Distinct();
var usuariosDict = new Dictionary<int, string>();
if (idsUsuarios.Any())
{
var usuarios = await _usuarioRepository.GetByIdsAsync(idsUsuarios);
usuariosDict = usuarios.ToDictionary(u => u.Id, u => $"{u.Nombre} {u.Apellido}");
}
return logs.Select(log => new EmailLogDto
{
FechaEnvio = log.FechaEnvio,
Estado = log.Estado,
Asunto = log.Asunto,
DestinatarioEmail = log.DestinatarioEmail,
Error = log.Error,
NombreUsuarioDisparo = log.IdUsuarioDisparo.HasValue
? usuariosDict.GetValueOrDefault(log.IdUsuarioDisparo.Value, "Usuario Desconocido")
: "Sistema"
});
}
}
}

View File

@@ -1,3 +1,4 @@
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
using GestionIntegral.Api.Models.Comunicaciones;
using MailKit.Net.Smtp;
using MailKit.Security;
@@ -10,14 +11,23 @@ namespace GestionIntegral.Api.Services.Comunicaciones
{
private readonly MailSettings _mailSettings;
private readonly ILogger<EmailService> _logger;
private readonly IEmailLogRepository _emailLogRepository;
public EmailService(IOptions<MailSettings> mailSettings, ILogger<EmailService> logger)
public EmailService(
IOptions<MailSettings> mailSettings,
ILogger<EmailService> logger,
IEmailLogRepository emailLogRepository)
{
_mailSettings = mailSettings.Value;
_logger = logger;
_emailLogRepository = emailLogRepository;
}
public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml)
public async Task EnviarEmailAsync(
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
byte[]? attachment = null, string? attachmentName = null,
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null,
int? idLoteDeEnvio = null)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
@@ -26,25 +36,102 @@ namespace GestionIntegral.Api.Services.Comunicaciones
email.Subject = asunto;
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
if (attachment != null && !string.IsNullOrEmpty(attachmentName))
{
builder.Attachments.Add(attachmentName, attachment, ContentType.Parse("application/pdf"));
}
email.Body = builder.ToMessageBody();
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
}
public async Task EnviarEmailConsolidadoAsync(
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
List<(byte[] content, string name)> adjuntos,
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null,
int? idLoteDeEnvio = null)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
email.From.Add(email.Sender);
email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail));
email.Subject = asunto;
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
if (adjuntos != null)
{
foreach (var adjunto in adjuntos)
{
builder.Attachments.Add(adjunto.name, adjunto.content, ContentType.Parse("application/pdf"));
}
}
email.Body = builder.ToMessageBody();
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
}
private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo, int? idLoteDeEnvio)
{
var destinatario = emailMessage.To.Mailboxes.FirstOrDefault()?.Address ?? "desconocido";
var log = new EmailLog
{
FechaEnvio = DateTime.Now,
DestinatarioEmail = destinatario,
Asunto = emailMessage.Subject,
Origen = origen,
ReferenciaId = referenciaId,
IdUsuarioDisparo = idUsuarioDisparo,
IdLoteDeEnvio = idLoteDeEnvio
};
using var smtp = new SmtpClient();
try
{
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
await smtp.SendAsync(email);
_logger.LogInformation("Email enviado exitosamente a {Destinatario}", destinatarioEmail);
await smtp.SendAsync(emailMessage);
log.Estado = "Enviado";
_logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
}
catch (SmtpCommandException scEx)
{
_logger.LogError(scEx, "Error de comando SMTP al enviar a {Destinatario}. StatusCode: {StatusCode}", destinatario, scEx.StatusCode);
log.Estado = "Fallido";
log.Error = $"Error del servidor: ({scEx.StatusCode}) {scEx.Message}";
throw;
}
catch (AuthenticationException authEx)
{
_logger.LogError(authEx, "Error de autenticación con el servidor SMTP.");
log.Estado = "Fallido";
log.Error = "Error de autenticación. Revise las credenciales de correo.";
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al enviar email a {Destinatario}", destinatarioEmail);
throw; // Relanzar para que el servicio que lo llamó sepa que falló
_logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
log.Estado = "Fallido";
log.Error = ex.Message;
throw;
}
finally
{
if (smtp.IsConnected)
{
await smtp.DisconnectAsync(true);
}
try
{
await _emailLogRepository.CreateAsync(log);
}
catch (Exception logEx)
{
_logger.LogError(logEx, "FALLO CRÍTICO: No se pudo guardar el log del email para {Destinatario}", destinatario);
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
using GestionIntegral.Api.Dtos.Comunicaciones;
namespace GestionIntegral.Api.Services.Comunicaciones
{
public interface IEmailLogService
{
Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId);
Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio);
}
}

View File

@@ -2,6 +2,54 @@ namespace GestionIntegral.Api.Services.Comunicaciones
{
public interface IEmailService
{
Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml);
/// <summary>
/// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar un archivo.
/// Este método también registra automáticamente el resultado del envío en la base de datos.
/// </summary>
/// <param name="destinatarioEmail">La dirección de correo del destinatario.</param>
/// <param name="destinatarioNombre">El nombre del destinatario.</param>
/// <param name="asunto">El asunto del correo.</param>
/// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param>
/// <param name="attachment">Los bytes del archivo a adjuntar (opcional).</param>
/// <param name="attachmentName">El nombre del archivo adjunto (requerido si se provee attachment).</param>
/// <param name="origen">Identificador del proceso que dispara el email (ej. "EnvioManualPDF"). Para logging.</param>
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Factura-59"). Para logging.</param>
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
/// <param name="idLoteDeEnvio">ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging.</param>
Task EnviarEmailAsync(
string destinatarioEmail,
string destinatarioNombre,
string asunto,
string cuerpoHtml,
byte[]? attachment = null,
string? attachmentName = null,
string? origen = null,
string? referenciaId = null,
int? idUsuarioDisparo = null,
int? idLoteDeEnvio = null);
/// <summary>
/// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar múltiples archivos.
/// Este método también registra automáticamente el resultado del envío en la base de datos.
/// </summary>
/// <param name="destinatarioEmail">La dirección de correo del destinatario.</param>
/// <param name="destinatarioNombre">El nombre del destinatario.</param>
/// <param name="asunto">El asunto del correo.</param>
/// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param>
/// <param name="adjuntos">Una lista de tuplas que contienen los bytes y el nombre de cada archivo a adjuntar.</param>
/// <param name="origen">Identificador del proceso que dispara el email (ej. "FacturacionMensual"). Para logging.</param>
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Suscriptor-3"). Para logging.</param>
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
/// <param name="idLoteDeEnvio">ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging.</param>
Task EnviarEmailConsolidadoAsync(
string destinatarioEmail,
string destinatarioNombre,
string asunto,
string cuerpoHtml,
List<(byte[] content, string name)> adjuntos,
string? origen = null,
string? referenciaId = null,
int? idUsuarioDisparo = null,
int? idLoteDeEnvio = null);
}
}

View File

@@ -61,16 +61,11 @@ namespace GestionIntegral.Api.Services.Reportes
IEnumerable<SaldoDto> Saldos,
string? Error
)> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta);
Task<(IEnumerable<ListadoDistribucionDistSimpleDto> Simple, IEnumerable<ListadoDistribucionDistPromedioDiaDto> Promedios, string? Error)> ObtenerListadoDistribucionDistribuidoresAsync(int idDistribuidor, int idPublicacion, DateTime fechaDesde, DateTime fechaHasta);
Task<(
IEnumerable<LiquidacionCanillaDetalleDto> Detalles,
IEnumerable<LiquidacionCanillaGananciaDto> Ganancias,
string? Error
)> ObtenerDatosTicketLiquidacionAsync(DateTime fecha, int idCanilla);
Task<(IEnumerable<LiquidacionCanillaDetalleDto> Detalles, IEnumerable<LiquidacionCanillaGananciaDto> Ganancias, string? Error)> ObtenerDatosTicketLiquidacionAsync(DateTime fecha, int idCanilla);
Task<(IEnumerable<ListadoDistCanMensualDiariosDto> Data, string? Error)> ObtenerReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
Task<(IEnumerable<ListadoDistCanMensualPubDto> Data, string? Error)> ObtenerReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
Task<(IEnumerable<FacturasParaReporteDto> Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes);
Task<(IEnumerable<DistribucionSuscripcionDto> Altas, IEnumerable<DistribucionSuscripcionDto> Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta);
}
}

View File

@@ -1,21 +1,31 @@
using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Data.Repositories.Reportes;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Reportes;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Reportes
{
public class ReportesService : IReportesService
{
private readonly IReportesRepository _reportesRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
private readonly IPublicacionRepository _publicacionRepository;
private readonly IEmpresaRepository _empresaRepository;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly ILogger<ReportesService> _logger;
public ReportesService(IReportesRepository reportesRepository, ILogger<ReportesService> logger)
public ReportesService(IReportesRepository reportesRepository, IFacturaRepository facturaRepository, IFacturaDetalleRepository facturaDetalleRepository, IPublicacionRepository publicacionRepository, IEmpresaRepository empresaRepository
, ISuscriptorRepository suscriptorRepository, ISuscripcionRepository suscripcionRepository, ILogger<ReportesService> logger)
{
_reportesRepository = reportesRepository;
_facturaRepository = facturaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_publicacionRepository = publicacionRepository;
_empresaRepository = empresaRepository;
_suscriptorRepository = suscriptorRepository;
_suscripcionRepository = suscripcionRepository;
_logger = logger;
}
@@ -520,5 +530,49 @@ namespace GestionIntegral.Api.Services.Reportes
return (Enumerable.Empty<ListadoDistCanMensualPubDto>(), "Error al obtener datos del reporte (por publicación).");
}
}
public async Task<(IEnumerable<FacturasParaReporteDto> Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes)
{
if (anio < 2020 || mes < 1 || mes > 12)
{
return (Enumerable.Empty<FacturasParaReporteDto>(), "Período no válido.");
}
var periodo = $"{anio}-{mes:D2}";
try
{
// Llamada directa al nuevo método del repositorio
var data = await _reportesRepository.GetDatosReportePublicidadAsync(periodo);
return (data, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error en servicio al obtener datos para reporte de publicidad para el período {Periodo}", periodo);
return (new List<FacturasParaReporteDto>(), "Error interno al generar el reporte.");
}
}
public async Task<(IEnumerable<DistribucionSuscripcionDto> Altas, IEnumerable<DistribucionSuscripcionDto> Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta)
{
if (fechaDesde > fechaHasta)
{
return (Enumerable.Empty<DistribucionSuscripcionDto>(), Enumerable.Empty<DistribucionSuscripcionDto>(), "La fecha 'Desde' no puede ser mayor que la fecha 'Hasta'.");
}
try
{
// Ejecutamos ambas consultas en paralelo para mayor eficiencia
var altasTask = _reportesRepository.GetDistribucionSuscripcionesActivasAsync(fechaDesde, fechaHasta);
var bajasTask = _reportesRepository.GetDistribucionSuscripcionesBajasAsync(fechaDesde, fechaHasta);
await Task.WhenAll(altasTask, bajasTask);
return (await altasTask, await bajasTask, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error en servicio al obtener datos para reporte de distribución de suscripciones.");
return (Enumerable.Empty<DistribucionSuscripcionDto>(), Enumerable.Empty<DistribucionSuscripcionDto>(), "Error interno al generar el reporte.");
}
}
}
}

View File

@@ -0,0 +1,225 @@
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Data.Repositories.Usuarios;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
using GestionIntegral.Api.Data.Repositories.Distribucion;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class AjusteService : IAjusteService
{
private readonly IAjusteRepository _ajusteRepository;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly IUsuarioRepository _usuarioRepository;
private readonly IEmpresaRepository _empresaRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<AjusteService> _logger;
public AjusteService(
IAjusteRepository ajusteRepository,
ISuscriptorRepository suscriptorRepository,
IUsuarioRepository usuarioRepository,
IEmpresaRepository empresaRepository,
IFacturaRepository facturaRepository,
DbConnectionFactory connectionFactory,
ILogger<AjusteService> logger)
{
_ajusteRepository = ajusteRepository;
_suscriptorRepository = suscriptorRepository;
_usuarioRepository = usuarioRepository;
_empresaRepository = empresaRepository;
_facturaRepository = facturaRepository;
_connectionFactory = connectionFactory;
_logger = logger;
}
private async Task<AjusteDto?> MapToDto(Ajuste ajuste)
{
if (ajuste == null) return null;
var usuario = await _usuarioRepository.GetByIdAsync(ajuste.IdUsuarioAlta);
var empresa = await _empresaRepository.GetByIdAsync(ajuste.IdEmpresa);
return new AjusteDto
{
IdAjuste = ajuste.IdAjuste,
IdSuscriptor = ajuste.IdSuscriptor,
IdEmpresa = ajuste.IdEmpresa,
NombreEmpresa = empresa?.Nombre ?? "N/A",
FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"),
TipoAjuste = ajuste.TipoAjuste,
Monto = ajuste.Monto,
Motivo = ajuste.Motivo,
Estado = ajuste.Estado,
IdFacturaAplicado = ajuste.IdFacturaAplicado,
FechaAlta = ajuste.FechaAlta.ToString("yyyy-MM-dd HH:mm"),
NombreUsuarioAlta = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
};
}
public async Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta)
{
var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor, fechaDesde, fechaHasta);
if (!ajustes.Any())
{
return Enumerable.Empty<AjusteDto>();
}
// 1. Recolectar IDs de usuarios, empresas Y FACTURAS
var idsUsuarios = ajustes.Select(a => a.IdUsuarioAlta).Distinct().ToList();
var idsEmpresas = ajustes.Select(a => a.IdEmpresa).Distinct().ToList();
var idsFacturas = ajustes.Where(a => a.IdFacturaAplicado.HasValue)
.Select(a => a.IdFacturaAplicado!.Value)
.Distinct().ToList();
// 2. Obtener todos los datos necesarios en consultas masivas
var usuariosTask = _usuarioRepository.GetByIdsAsync(idsUsuarios);
var empresasTask = _empresaRepository.GetAllAsync(null, null);
var facturasTask = _facturaRepository.GetByIdsAsync(idsFacturas);
await Task.WhenAll(usuariosTask, empresasTask, facturasTask);
// 3. Convertir a diccionarios para búsqueda rápida
var usuariosDict = (await usuariosTask).ToDictionary(u => u.Id);
var empresasDict = (await empresasTask).ToDictionary(e => e.IdEmpresa);
var facturasDict = (await facturasTask).ToDictionary(f => f.IdFactura);
// 4. Mapear en memoria, ahora con la información de la factura disponible
var dtos = ajustes.Select(ajuste =>
{
usuariosDict.TryGetValue(ajuste.IdUsuarioAlta, out var usuario);
empresasDict.TryGetValue(ajuste.IdEmpresa, out var empresa);
// Buscar la factura en el diccionario si el ajuste está aplicado
facturasDict.TryGetValue(ajuste.IdFacturaAplicado ?? 0, out var factura);
return new AjusteDto
{
IdAjuste = ajuste.IdAjuste,
IdSuscriptor = ajuste.IdSuscriptor,
IdEmpresa = ajuste.IdEmpresa,
NombreEmpresa = empresa?.Nombre ?? "N/A",
FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"),
TipoAjuste = ajuste.TipoAjuste,
Monto = ajuste.Monto,
Motivo = ajuste.Motivo,
Estado = ajuste.Estado,
IdFacturaAplicado = ajuste.IdFacturaAplicado,
NumeroFacturaAplicado = factura?.NumeroFactura,
FechaAlta = ajuste.FechaAlta.ToString("yyyy-MM-dd HH:mm"),
NombreUsuarioAlta = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
};
});
return dtos;
}
public async Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario)
{
var suscriptor = await _suscriptorRepository.GetByIdAsync(createDto.IdSuscriptor);
if (suscriptor == null)
{
return (null, "El suscriptor especificado no existe.");
}
var empresa = await _empresaRepository.GetByIdAsync(createDto.IdEmpresa);
if (empresa == null)
{
return (null, "La empresa especificada no existe.");
}
var nuevoAjuste = new Ajuste
{
IdSuscriptor = createDto.IdSuscriptor,
IdEmpresa = createDto.IdEmpresa,
FechaAjuste = createDto.FechaAjuste.Date,
TipoAjuste = createDto.TipoAjuste,
Monto = createDto.Monto,
Motivo = createDto.Motivo,
IdUsuarioAlta = idUsuario
};
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var ajusteCreado = await _ajusteRepository.CreateAsync(nuevoAjuste, transaction);
if (ajusteCreado == null) throw new DataException("Error al crear el registro de ajuste.");
transaction.Commit();
_logger.LogInformation("Ajuste manual ID {IdAjuste} creado para Suscriptor ID {IdSuscriptor} por Usuario ID {IdUsuario}", ajusteCreado.IdAjuste, ajusteCreado.IdSuscriptor, idUsuario);
var dto = await MapToDto(ajusteCreado);
return (dto, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al crear ajuste manual para Suscriptor ID {IdSuscriptor}", createDto.IdSuscriptor);
return (null, "Error interno al registrar el ajuste.");
}
}
public async Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario)
{
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste);
if (ajuste == null) return (false, "Ajuste no encontrado.");
if (ajuste.Estado != "Pendiente") return (false, $"No se puede anular un ajuste en estado '{ajuste.Estado}'.");
var exito = await _ajusteRepository.AnularAjusteAsync(idAjuste, idUsuario, transaction);
if (!exito) throw new DataException("No se pudo anular el ajuste.");
transaction.Commit();
_logger.LogInformation("Ajuste ID {IdAjuste} anulado por Usuario ID {IdUsuario}", idAjuste, idUsuario);
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al anular ajuste ID {IdAjuste}", idAjuste);
return (false, "Error interno al anular el ajuste.");
}
}
public async Task<(bool Exito, string? Error)> ActualizarAjuste(int idAjuste, UpdateAjusteDto updateDto)
{
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste);
if (ajuste == null) return (false, "Ajuste no encontrado.");
if (ajuste.Estado != "Pendiente") return (false, $"No se puede modificar un ajuste en estado '{ajuste.Estado}'.");
var empresa = await _empresaRepository.GetByIdAsync(updateDto.IdEmpresa);
if (empresa == null) return (false, "La empresa especificada no existe.");
ajuste.IdEmpresa = updateDto.IdEmpresa;
ajuste.FechaAjuste = updateDto.FechaAjuste;
ajuste.TipoAjuste = updateDto.TipoAjuste;
ajuste.Monto = updateDto.Monto;
ajuste.Motivo = updateDto.Motivo;
var actualizado = await _ajusteRepository.UpdateAsync(ajuste, transaction);
if (!actualizado) throw new DataException("La actualización falló o el ajuste ya no estaba pendiente.");
transaction.Commit();
_logger.LogInformation("Ajuste ID {IdAjuste} actualizado.", idAjuste);
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al actualizar ajuste ID {IdAjuste}", idAjuste);
return (false, "Error interno al actualizar el ajuste.");
}
}
}
}

View File

@@ -1,16 +1,8 @@
// Archivo: GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Data;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Collections.Generic;
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
@@ -19,21 +11,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
private readonly IFacturaRepository _facturaRepository;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly ILoteDebitoRepository _loteDebitoRepository;
private readonly IFormaPagoRepository _formaPagoRepository;
private readonly IPagoRepository _pagoRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<DebitoAutomaticoService> _logger;
// --- CONSTANTES DEL BANCO (Mover a appsettings.json si es necesario) ---
private const string NRO_PRESTACION = "123456"; // Nro. de prestación asignado por el banco
private const string ORIGEN_EMPRESA = "ELDIA"; // Nombre de la empresa (7 chars)
private const string NRO_PRESTACION = "123456";
private const string ORIGEN_EMPRESA = "ELDIA";
public DebitoAutomaticoService(
IFacturaRepository facturaRepository,
ISuscriptorRepository suscriptorRepository,
ISuscripcionRepository suscripcionRepository,
ILoteDebitoRepository loteDebitoRepository,
IFormaPagoRepository formaPagoRepository,
IPagoRepository pagoRepository,
@@ -42,7 +31,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
_facturaRepository = facturaRepository;
_suscriptorRepository = suscriptorRepository;
_suscripcionRepository = suscripcionRepository;
_loteDebitoRepository = loteDebitoRepository;
_formaPagoRepository = formaPagoRepository;
_pagoRepository = pagoRepository;
@@ -52,6 +40,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario)
{
// Se define la identificación del archivo.
// Este número debe ser gestionado para no repetirse en archivos generados
// para la misma prestación y fecha.
const int identificacionArchivo = 1;
var periodo = $"{anio}-{mes:D2}";
var fechaGeneracion = DateTime.Now;
@@ -61,9 +54,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
try
{
// Buscamos facturas que están listas para ser enviadas al cobro.
var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction);
if (!facturasParaDebito.Any())
{
return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado.");
@@ -71,9 +62,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal);
var cantidadRegistros = facturasParaDebito.Count();
var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt";
// 1. Crear el Lote de Débito
// Se utiliza la variable 'identificacionArchivo' para nombrar el archivo.
var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt";
var nuevoLote = new LoteDebito
{
Periodo = periodo,
@@ -85,18 +77,16 @@ 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));
// Se pasa la 'identificacionArchivo' al método que crea el Header.
sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
foreach (var item in facturasParaDebito)
{
sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor));
}
// Se pasa la 'identificacionArchivo' al método que crea el Trailer.
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros));
// 3. Actualizar las facturas con el ID del lote
var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura);
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,18 +105,19 @@ namespace GestionIntegral.Api.Services.Suscripciones
private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction)
{
// Idealmente, esto debería estar en el repositorio para optimizar la consulta.
// Por simplicidad del ejemplo, lo hacemos aquí.
var facturas = await _facturaRepository.GetByPeriodoAsync(periodo);
var resultado = new List<(Factura, Suscriptor)>();
foreach (var f in facturas.Where(fa => fa.Estado == "Pendiente de Cobro"))
foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente"))
{
var suscripcion = await _suscripcionRepository.GetByIdAsync(f.IdSuscripcion);
if (suscripcion == null) continue;
var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor);
var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor);
if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue;
// Se valida que el CBU de Banelco (22 caracteres) exista antes de intentar la conversión.
if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22)
{
_logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", suscriptor?.IdSuscriptor);
continue;
}
var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida);
if (formaPago != null && formaPago.RequiereCBU)
@@ -137,83 +128,119 @@ namespace GestionIntegral.Api.Services.Suscripciones
return resultado;
}
// --- Métodos de Formateo de Campos ---
private string FormatString(string? value, int length) => (value ?? "").PadRight(length).Substring(0, length);
private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0');
// Lógica de conversión de CBU.
private string ConvertirCbuBanelcoASnp(string cbu22)
{
if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22)
{
_logger.LogError("Se intentó convertir un CBU inválido de {Length} caracteres. Se devolverá un campo vacío.", cbu22?.Length ?? 0);
// Devolver un string de 26 espacios/ceros según la preferencia del banco para campos erróneos.
return "".PadRight(26);
}
private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros)
// El formato SNP de 26 se obtiene insertando un "0" al inicio y "000" después del 8vo caracter del CBU de 22.
// Formato Banelco (22): [BBBSSSSX] [T....Y]
// Posiciones: (0-7) (8-21)
// Formato SNP (26): 0[BBBSSSSX]000[T....Y]
try
{
string bloque1 = cbu22.Substring(0, 8); // Contiene código de banco, sucursal y DV del bloque 1.
string bloque2 = cbu22.Substring(8); // Contiene el resto de la cadena.
// Reconstruir en formato SNP de 26 dígitos según el instructivo.
return $"0{bloque1}000{bloque2}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al parsear y convertir CBU de 22 dígitos: {CBU}", cbu22);
return "".PadRight(26);
}
}
// --- Métodos de Formateo y Mapeo ---
private string FormatString(string? value, int length) => (value ?? "").PadRight(length);
private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0');
private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch
{
"DNI" => "0096",
"CUIT" => "0080",
"CUIL" => "0086",
"LE" => "0089",
"LC" => "0090",
_ => "0000" // Tipo no especificado o C.I. Policía Federal según anexo.
};
private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
{
var sb = new StringBuilder();
sb.Append("00"); // Tipo de Registro
sb.Append("00"); // Tipo de Registro Header
sb.Append(FormatString(NRO_PRESTACION, 6));
sb.Append("C"); // Servicio
sb.Append("C"); // Servicio: Sistema Nacional de Pagos
sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
sb.Append("1"); // Identificación de Archivo (ej. '1' para el primer envío del día)
sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo
sb.Append(FormatString(ORIGEN_EMPRESA, 7));
sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); // 12 enteros + 2 decimales
sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
sb.Append(FormatNumeric(cantidadRegistros, 7));
sb.Append(FormatString("", 304)); // Libre
sb.Append(FormatString("", 304));
sb.Append("\r\n");
return sb.ToString();
}
private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor)
{
// Convertimos el CBU de 22 (Banelco) a 26 (SNP) antes de usarlo.
string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!);
var sb = new StringBuilder();
sb.Append("0101"); // Tipo de Registro
sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación Cliente
sb.Append(FormatString(suscriptor.CBU, 26)); // CBU
// Referencia Unívoca: Usaremos ID Factura para asegurar unicidad
sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15));
sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); // Fecha 1er Vto
sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); // Importe 1er Vto
// Campos opcionales o con valores fijos
sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vto
sb.Append(FormatNumeric(0, 14)); // Importe 2do Vto
sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vto
sb.Append(FormatNumeric(0, 14)); // Importe 3er Vto
sb.Append("0370"); // Tipo de Registro Detalle (Orden de Débito)
sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación de Cliente
sb.Append(FormatString(cbu26, 26)); // CBU en formato SNP de 26 caracteres.
sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); // Referencia Unívoca de la factura.
sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd"));
sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14));
sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento
sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento
sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento
sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento
sb.Append("0"); // Moneda (0 = Pesos)
sb.Append(FormatString("", 3)); // Motivo Rechazo
sb.Append(FormatString(suscriptor.TipoDocumento, 4));
sb.Append(FormatString("", 3)); // Motivo Rechazo (vacío en el envío)
sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4));
sb.Append(FormatString(suscriptor.NroDocumento, 11));
// El resto son campos opcionales que rellenamos con espacios/ceros
sb.Append(FormatString("", 22)); // Nueva ID Cliente
sb.Append(FormatNumeric(0, 26)); // Nuevo CBU
sb.Append(FormatString("", 26)); // Nueva CBU
sb.Append(FormatNumeric(0, 14)); // Importe Mínimo
sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vto
sb.Append(FormatString("", 22)); // ID Cuenta Anterior
sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vencimiento
sb.Append(FormatString("", 22)); // Identificación Cuenta Anterior
sb.Append(FormatString("", 40)); // Mensaje ATM
sb.Append(FormatString($"Suscripcion {factura.Periodo}", 10)); // Concepto Factura
sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); // Concepto Factura
sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro
sb.Append(FormatNumeric(0, 14)); // Importe Cobrado
sb.Append(FormatNumeric(0, 8)); // Fecha Acreditación
sb.Append(FormatNumeric(0, 8)); // Fecha de Acreditamiento
sb.Append(FormatString("", 26)); // Libre
sb.Append("\r\n");
return sb.ToString();
}
private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros)
private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
{
var sb = new StringBuilder();
sb.Append("99"); // Tipo de Registro
sb.Append("99"); // Tipo de Registro Trailer
sb.Append(FormatString(NRO_PRESTACION, 6));
sb.Append("C"); // Servicio
sb.Append("C"); // Servicio: Sistema Nacional de Pagos
sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
sb.Append("1"); // Identificación de Archivo
sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo
sb.Append(FormatString(ORIGEN_EMPRESA, 7));
sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
sb.Append(FormatNumeric(cantidadRegistros, 7));
sb.Append(FormatString("", 304)); // Libre
// No se añade \r\n al final del último registro
sb.Append(FormatString("", 304));
// La última línea del archivo no lleva salto de línea (\r\n).
return sb.ToString();
}
public async Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario)
{
// Se mantiene la lógica original para procesar el archivo de respuesta del banco.
var respuesta = new ProcesamientoLoteResponseDto();
if (archivo == null || archivo.Length == 0)
{
@@ -231,28 +258,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 +280,24 @@ namespace GestionIntegral.Api.Services.Suscripciones
var nuevoPago = new Pago
{
IdFactura = idFactura,
FechaPago = DateTime.Now.Date, // O la fecha que venga en el archivo
IdFormaPago = 1, // 1 = Débito Automático
FechaPago = DateTime.Now.Date,
IdFormaPago = 1, // Se asume una forma de pago para el débito.
Monto = factura.ImporteFinal,
IdUsuarioRegistro = idUsuario,
Referencia = $"Lote {factura.IdLoteDebito} - Banco"
};
if (estadoProceso == "AP") // "AP" = Aprobado (Asunción)
if (estadoProceso == "AP")
{
nuevoPago.Estado = "Aprobado";
await _pagoRepository.CreateAsync(nuevoPago, transaction);
await _facturaRepository.UpdateEstadoAsync(idFactura, "Pagada", transaction);
await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction);
respuesta.PagosAprobados++;
}
else // Asumimos que cualquier otra cosa es Rechazado
else
{
nuevoPago.Estado = "Rechazado";
await _pagoRepository.CreateAsync(nuevoPago, transaction);
factura.Estado = "Rechazada";
factura.MotivoRechazo = motivoRechazo;
// Necesitamos un método en el repo para actualizar estado y motivo
await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction);
respuesta.PagosRechazados++;
}

View File

@@ -4,49 +4,103 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Distribucion;
using GestionIntegral.Api.Models.Suscripciones;
using GestionIntegral.Api.Services.Comunicaciones;
using System.Data;
using System.Globalization;
using System.Text;
using GestionIntegral.Api.Services.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Usuarios;
using GestionIntegral.Api.Dtos.Comunicaciones;
using GestionIntegral.Api.Models.Comunicaciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class FacturacionService : IFacturacionService
{
private readonly ILoteDeEnvioRepository _loteDeEnvioRepository;
private readonly IUsuarioRepository _usuarioRepository;
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly IEmpresaRepository _empresaRepository;
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
private readonly IPrecioRepository _precioRepository;
private readonly IPromocionRepository _promocionRepository;
private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación
private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor
private readonly DbConnectionFactory _connectionFactory;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly IAjusteRepository _ajusteRepository;
private readonly IEmailService _emailService;
private readonly IPublicacionRepository _publicacionRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<FacturacionService> _logger;
private readonly string _facturasPdfPath;
private const string LogoUrl = "https://www.eldia.com/img/header/eldia.png";
public FacturacionService(
ISuscripcionRepository suscripcionRepository,
IFacturaRepository facturaRepository,
IEmpresaRepository empresaRepository,
IFacturaDetalleRepository facturaDetalleRepository,
IPrecioRepository precioRepository,
IPromocionRepository promocionRepository,
IRecargoZonaRepository recargoZonaRepository,
ISuscriptorRepository suscriptorRepository,
DbConnectionFactory connectionFactory,
IAjusteRepository ajusteRepository,
IEmailService emailService,
ILogger<FacturacionService> logger)
IPublicacionRepository publicacionRepository,
DbConnectionFactory connectionFactory,
ILogger<FacturacionService> logger,
IConfiguration configuration,
ILoteDeEnvioRepository loteDeEnvioRepository,
IUsuarioRepository usuarioRepository)
{
_loteDeEnvioRepository = loteDeEnvioRepository;
_usuarioRepository = usuarioRepository;
_suscripcionRepository = suscripcionRepository;
_facturaRepository = facturaRepository;
_empresaRepository = empresaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_precioRepository = precioRepository;
_promocionRepository = promocionRepository;
_recargoZonaRepository = recargoZonaRepository;
_suscriptorRepository = suscriptorRepository;
_connectionFactory = connectionFactory;
_ajusteRepository = ajusteRepository;
_emailService = emailService;
_publicacionRepository = publicacionRepository;
_connectionFactory = connectionFactory;
_logger = logger;
_facturasPdfPath = configuration.GetValue<string>("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF";
}
public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
public async Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
{
var periodo = $"{anio}-{mes:D2}";
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodo, idUsuario);
var periodoActual = new DateTime(anio, mes, 1);
var periodoActualStr = periodoActual.ToString("yyyy-MM");
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodoActualStr, idUsuario);
// --- INICIO: Creación del Lote de Envío ---
var lote = await _loteDeEnvioRepository.CreateAsync(new LoteDeEnvio
{
FechaInicio = DateTime.Now,
Periodo = periodoActualStr,
Origen = "FacturacionMensual",
Estado = "Iniciado",
IdUsuarioDisparo = idUsuario
});
// --- FIN: Creación del Lote de Envío ---
var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync();
if (ultimoPeriodoFacturadoStr != null)
{
var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture);
if (periodoActual != ultimoPeriodo.AddMonths(1))
{
var periodoEsperado = ultimoPeriodo.AddMonths(1).ToString("MMMM 'de' yyyy", new CultureInfo("es-ES"));
return (false, $"Error: No se puede generar la facturación de {periodoActual:MMMM 'de' yyyy}. El siguiente período a generar es {periodoEsperado}.", null);
}
}
var facturasCreadas = new List<Factura>();
int facturasGeneradas = 0;
int emailsEnviados = 0;
int emailsFallidos = 0;
var erroresDetallados = new List<EmailLogDto>();
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
@@ -54,77 +108,473 @@ namespace GestionIntegral.Api.Services.Suscripciones
try
{
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction);
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction);
if (!suscripcionesActivas.Any())
{
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0);
// Si no hay nada que facturar, consideramos el proceso exitoso pero sin resultados.
lote.Estado = "Completado";
lote.FechaFin = DateTime.Now;
await _loteDeEnvioRepository.UpdateAsync(lote);
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", null);
}
int facturasGeneradas = 0;
foreach (var suscripcion in suscripcionesActivas)
var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>();
foreach (var s in suscripcionesActivas)
{
var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction);
if (facturaExistente != null)
var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion);
if (pub != null)
{
_logger.LogWarning("Ya existe una factura (ID: {IdFactura}) para la suscripción ID {IdSuscripcion} en el período {Periodo}. Se omite.", facturaExistente.IdFactura, suscripcion.IdSuscripcion, periodo);
continue;
suscripcionesConEmpresa.Add((s, pub.IdEmpresa));
}
}
// --- LÓGICA DE PROMOCIONES ---
var primerDiaMes = new DateTime(anio, mes, 1);
var promocionesAplicables = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, primerDiaMes, transaction);
var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa });
decimal importeBruto = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
decimal descuentoTotal = 0;
// Aplicar promociones de descuento
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "Porcentaje"))
foreach (var grupo in gruposParaFacturar)
{
descuentoTotal += (importeBruto * promo.Valor) / 100;
}
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "MontoFijo"))
int idSuscriptor = grupo.Key.IdSuscriptor;
int idEmpresa = grupo.Key.IdEmpresa;
decimal importeBrutoTotal = 0;
decimal descuentoPromocionesTotal = 0;
var detallesParaFactura = new List<FacturaDetalle>();
foreach (var item in grupo)
{
descuentoTotal += promo.Valor;
}
// La bonificación de días se aplicaría idealmente dentro de CalcularImporteParaSuscripcion,
// pero por simplicidad, aquí solo manejamos descuentos sobre el total.
if (importeBruto <= 0)
{
_logger.LogInformation("Suscripción ID {IdSuscripcion} no tiene importe a facturar para el período {Periodo}. Se omite.", suscripcion.IdSuscripcion, periodo);
continue;
}
var importeFinal = importeBruto - descuentoTotal;
if (importeFinal < 0) importeFinal = 0; // El importe no puede ser negativo
var nuevaFactura = new Factura
var suscripcion = item.Suscripcion;
decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction);
decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones);
importeBrutoTotal += importeBrutoSusc;
descuentoPromocionesTotal += descuentoSusc;
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion);
detallesParaFactura.Add(new FacturaDetalle
{
IdSuscripcion = suscripcion.IdSuscripcion,
Periodo = periodo,
Descripcion = $"Corresponde a {publicacion?.Nombre ?? "N/A"}",
ImporteBruto = importeBrutoSusc,
DescuentoAplicado = descuentoSusc,
ImporteNeto = importeBrutoSusc - descuentoSusc
});
}
var ultimoDiaDelMes = periodoActual.AddMonths(1).AddDays(-1);
var ajustesPendientes = await _ajusteRepository.GetAjustesPendientesHastaFechaAsync(idSuscriptor, idEmpresa, ultimoDiaDelMes, transaction);
decimal totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto);
var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes;
if (importeFinal < 0) importeFinal = 0;
if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue;
var nuevaFactura = new Factura
{
IdSuscriptor = idSuscriptor,
Periodo = periodoActualStr,
FechaEmision = DateTime.Now.Date,
FechaVencimiento = new DateTime(anio, mes, 10).AddMonths(1),
ImporteBruto = importeBruto,
DescuentoAplicado = descuentoTotal,
FechaVencimiento = new DateTime(anio, mes, 10),
ImporteBruto = importeBrutoTotal,
DescuentoAplicado = descuentoPromocionesTotal,
ImporteFinal = importeFinal,
Estado = "Pendiente de Facturar"
EstadoPago = "Pendiente",
EstadoFacturacion = "Pendiente de Facturar"
};
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
if (facturaCreada == null) throw new DataException($"No se pudo crear el registro de factura para la suscripción ID {suscripcion.IdSuscripcion}");
if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}");
facturasCreadas.Add(facturaCreada);
foreach (var detalle in detallesParaFactura)
{
detalle.IdFactura = facturaCreada.IdFactura;
await _facturaDetalleRepository.CreateAsync(detalle, transaction);
}
if (ajustesPendientes.Any())
{
await _ajusteRepository.MarcarAjustesComoAplicadosAsync(ajustesPendientes.Select(a => a.IdAjuste), facturaCreada.IdFactura, transaction);
}
facturasGeneradas++;
}
transaction.Commit();
_logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas);
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", facturasGeneradas);
_logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr);
if (facturasCreadas.Any())
{
var suscriptoresAnotificar = facturasCreadas.Select(f => f.IdSuscriptor).Distinct().ToList();
_logger.LogInformation("Iniciando envío automático de avisos para {Count} suscriptores.", suscriptoresAnotificar.Count);
foreach (var idSuscriptor in suscriptoresAnotificar)
{
var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); // Necesitamos el objeto suscriptor
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email))
{
emailsFallidos++;
erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor?.NombreCompleto ?? $"ID Suscriptor {idSuscriptor}", Error = "Suscriptor sin email válido." });
continue;
}
try
{
await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor, lote.IdLoteDeEnvio, idUsuario);
emailsEnviados++;
}
catch (Exception exEmail)
{
emailsFallidos++;
erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor.Email, Error = exEmail.Message });
_logger.LogError(exEmail, "Falló el envío automático de email para el suscriptor ID {IdSuscriptor}", idSuscriptor);
}
}
_logger.LogInformation("{EmailsEnviados} avisos de vencimiento enviados automáticamente.", emailsEnviados);
}
lote.Estado = "Completado";
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodo);
return (false, "Error interno del servidor al generar la facturación.", 0);
lote.Estado = "Fallido";
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodoActualStr);
return (false, "Error interno del servidor al generar la facturación.", null);
}
finally
{
lote.FechaFin = DateTime.Now;
lote.TotalCorreos = emailsEnviados + emailsFallidos;
lote.TotalEnviados = emailsEnviados;
lote.TotalFallidos = emailsFallidos;
await _loteDeEnvioRepository.UpdateAsync(lote);
}
var resultadoEnvio = new LoteDeEnvioResumenDto
{
IdLoteDeEnvio = lote.IdLoteDeEnvio,
Periodo = periodoActualStr,
TotalCorreos = lote.TotalCorreos,
TotalEnviados = lote.TotalEnviados,
TotalFallidos = lote.TotalFallidos,
ErroresDetallados = erroresDetallados
};
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", resultadoEnvio);
}
public async Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes)
{
var lotes = await _loteDeEnvioRepository.GetAllAsync(anio, mes);
if (!lotes.Any())
{
return Enumerable.Empty<LoteDeEnvioHistorialDto>();
}
var idsUsuarios = lotes.Select(l => l.IdUsuarioDisparo).Distinct();
var usuarios = (await _usuarioRepository.GetByIdsAsync(idsUsuarios)).ToDictionary(u => u.Id);
return lotes.Select(l => new LoteDeEnvioHistorialDto
{
IdLoteDeEnvio = l.IdLoteDeEnvio,
FechaInicio = l.FechaInicio,
Periodo = l.Periodo,
Estado = l.Estado,
TotalCorreos = l.TotalCorreos,
TotalEnviados = l.TotalEnviados,
TotalFallidos = l.TotalFallidos,
NombreUsuarioDisparo = usuarios.TryGetValue(l.IdUsuarioDisparo, out var user)
? $"{user.Nombre} {user.Apellido}"
: "Usuario Desconocido"
});
}
public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion)
{
var periodo = $"{anio}-{mes:D2}";
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion);
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo
var empresas = await _empresaRepository.GetAllAsync(null, null);
var resumenes = facturasData
.GroupBy(data => data.Factura.IdSuscriptor)
.Select(grupo =>
{
var primerItem = grupo.First();
var facturasConsolidadas = grupo.Select(itemFactura =>
{
var empresa = empresas.FirstOrDefault(e => e.IdEmpresa == itemFactura.IdEmpresa);
return new FacturaConsolidadaDto
{
IdFactura = itemFactura.Factura.IdFactura,
NombreEmpresa = empresa?.Nombre ?? "N/A",
ImporteFinal = itemFactura.Factura.ImporteFinal,
EstadoPago = itemFactura.Factura.EstadoPago,
EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
NumeroFactura = itemFactura.Factura.NumeroFactura,
Detalles = detallesData
.Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
.Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto })
.ToList()
};
}).ToList();
return new ResumenCuentaSuscriptorDto
{
IdSuscriptor = primerItem.Factura.IdSuscriptor,
NombreSuscriptor = primerItem.NombreSuscriptor,
Facturas = facturasConsolidadas,
ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.EstadoPago == "Pagada" ? 0 : f.ImporteFinal)
};
});
return resumenes.ToList();
}
public async Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario)
{
try
{
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null) return (false, "Factura no encontrada.", null);
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número asignado.", null);
if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada.", null);
var suscriptor = await _suscriptorRepository.GetByIdAsync(factura.IdSuscriptor);
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.", null);
byte[]? pdfAttachment = null;
string? pdfFileName = null;
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
if (File.Exists(rutaCompleta))
{
pdfAttachment = await File.ReadAllBytesAsync(rutaCompleta);
pdfFileName = $"Factura_{factura.NumeroFactura}.pdf";
}
else
{
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura}", factura.NumeroFactura);
return (false, "No se encontró el archivo PDF correspondiente en el servidor.", null);
}
string asunto = $"Factura Electrónica - Período {factura.Periodo}";
string cuerpoHtml = ConstruirCuerpoEmailFacturaPdf(suscriptor, factura);
// Pasamos los nuevos parámetros de contexto al EmailService.
await _emailService.EnviarEmailAsync(
destinatarioEmail: suscriptor.Email,
destinatarioNombre: suscriptor.NombreCompleto,
asunto: asunto,
cuerpoHtml: cuerpoHtml,
attachment: pdfAttachment,
attachmentName: pdfFileName,
origen: "EnvioManualPDF",
referenciaId: $"Factura-{idFactura}",
idUsuarioDisparo: idUsuario);
_logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor);
return (true, null, suscriptor.Email);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura);
// El error ya será logueado por EmailService, pero lo relanzamos para que el controller lo maneje.
// En este caso, simplemente devolvemos la tupla de error.
return (false, "Ocurrió un error al intentar enviar el email con la factura.", null);
}
}
/// <summary>
/// Construye y envía un email consolidado con el resumen de todas las facturas de un suscriptor para un período.
/// Este método está diseñado para ser llamado desde un proceso masivo como la facturación mensual.
/// </summary>
private async Task EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor, int idLoteDeEnvio, int idUsuarioDisparo)
{
var periodo = $"{anio}-{mes:D2}";
// La lógica de try/catch ahora está en el método llamador (GenerarFacturacionMensual)
// para poder contar los fallos y actualizar el lote de envío.
var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo);
if (!facturasConEmpresa.Any())
{
// Si no hay facturas, no hay nada que enviar. Esto no debería ocurrir si se llama desde GenerarFacturacionMensual.
_logger.LogWarning("Se intentó enviar aviso para Suscriptor ID {IdSuscriptor} en período {Periodo}, pero no se encontraron facturas.", idSuscriptor, periodo);
return;
}
var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor);
// La validación de si el suscriptor tiene email ya se hace en el método llamador.
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email))
{
// Lanzamos una excepción para que el método llamador la capture y la cuente como un fallo.
throw new InvalidOperationException($"El suscriptor ID {idSuscriptor} no es válido o no tiene una dirección de email registrada.");
}
var resumenHtml = new StringBuilder();
var adjuntos = new List<(byte[] content, string name)>();
foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada"))
{
var factura = item.Factura;
var nombreEmpresa = item.NombreEmpresa;
var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
resumenHtml.Append($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen para {nombreEmpresa}</h4>");
resumenHtml.Append("<table style='width: 100%; border-collapse: collapse; font-size: 0.9em;'>");
foreach (var detalle in detalles)
{
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee;'>{detalle.Descripcion}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right;'>${detalle.ImporteNeto:N2}</td></tr>");
}
var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura);
if (ajustes.Any())
{
foreach (var ajuste in ajustes)
{
bool esCredito = ajuste.TipoAjuste == "Credito";
string colorMonto = esCredito ? "#5cb85c" : "#d9534f";
string signo = esCredito ? "-" : "+";
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee; font-style: italic;'>Ajuste: {ajuste.Motivo}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right; color: {colorMonto}; font-style: italic;'>{signo} ${ajuste.Monto:N2}</td></tr>");
}
}
resumenHtml.Append($"<tr style='font-weight: bold;'><td style='padding: 5px;'>Subtotal</td><td style='padding: 5px; text-align: right;'>${factura.ImporteFinal:N2}</td></tr>");
resumenHtml.Append("</table>");
if (!string.IsNullOrEmpty(factura.NumeroFactura))
{
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
if (File.Exists(rutaCompleta))
{
byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta);
string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
adjuntos.Add((pdfBytes, pdfFileName));
_logger.LogInformation("PDF adjuntado para envío a Suscriptor ID {IdSuscriptor}: {FileName}", idSuscriptor, pdfFileName);
}
else
{
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
}
}
}
var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal);
string asunto = $"Resumen de Cuenta - Período {periodo}";
string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral);
await _emailService.EnviarEmailConsolidadoAsync(
destinatarioEmail: suscriptor.Email,
destinatarioNombre: suscriptor.NombreCompleto,
asunto: asunto,
cuerpoHtml: cuerpoHtml,
adjuntos: adjuntos,
origen: "FacturacionMensual",
referenciaId: $"Suscriptor-{idSuscriptor}",
idUsuarioDisparo: idUsuarioDisparo, // Se pasa el ID del usuario que inició el cierre
idLoteDeEnvio: idLoteDeEnvio // Se pasa el ID del lote
);
// El logging de éxito o fallo ahora lo hace el EmailService, por lo que este log ya no es estrictamente necesario,
// pero lo mantenemos para tener un registro de alto nivel en el log del FacturacionService.
_logger.LogInformation("Llamada a EmailService completada para Suscriptor ID {IdSuscriptor} en el período {Periodo}.", idSuscriptor, periodo);
}
private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral)
{
return $@"
<div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
<div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
<div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
<img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
<h2>Resumen de su Cuenta</h2>
</div>
<div style='padding: 20px; color: #333;'>
<h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
<p>Le enviamos el resumen de su cuenta para el período <strong>{periodo}</strong>.</p>
<!-- Aquí se insertan las tablas de resumen generadas dinámicamente -->
{resumenHtml}
<hr style='border: none; border-top: 1px solid #eee; margin: 20px 0;'/>
<table style='width: 100%;'>
<tr>
<td style='font-size: 1.2em; font-weight: bold;'>TOTAL A ABONAR:</td>
<td style='font-size: 1.4em; font-weight: bold; text-align: right; color: #34515e;'>${totalGeneral:N2}</td>
</tr>
</table>
<p style='margin-top: 25px;'>Si su pago es por débito automático, los importes se debitarán de su cuenta. Si utiliza otro medio de pago, por favor, regularice su situación.</p>
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
</div>
<div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
<p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
<p>&copy; {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
</div>
</div>
</div>";
}
private string ConstruirCuerpoEmailFacturaPdf(Suscriptor suscriptor, Factura factura)
{
return $@"
<div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
<div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
<div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
<img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
<h2>Factura Electrónica Adjunta</h2>
</div>
<div style='padding: 20px; color: #333;'>
<h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
<p>Le enviamos adjunta su factura correspondiente al período <strong>{factura.Periodo}</strong>.</p>
<h4 style='border-bottom: 2px solid #34515e; padding-bottom: 5px; margin-top: 30px;'>Resumen de la Factura</h4>
<table style='width: 100%; border-collapse: collapse; margin-top: 15px;'>
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Número de Factura:</td><td style='padding: 8px; text-align: right;'>{factura.NumeroFactura}</td></tr>
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Período:</td><td style='padding: 8px; text-align: right;'>{factura.Periodo}</td></tr>
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Fecha de Envío:</td><td style='padding: 8px; text-align: right;'>{factura.FechaEmision:dd/MM/yyyy}</td></tr>
<tr style='background-color: #f2f2f2;'><td style='padding: 12px; font-weight: bold; font-size: 1.1em;'>IMPORTE TOTAL:</td><td style='padding: 12px; text-align: right; font-weight: bold; font-size: 1.2em; color: #34515e;'>${factura.ImporteFinal:N2}</td></tr>
</table>
<p style='margin-top: 30px;'>Puede descargar y guardar el archivo PDF adjunto para sus registros.</p>
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
</div>
<div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
<p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
<p>&copy; {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
</div>
</div>
</div>";
}
public async Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario)
{
if (string.IsNullOrWhiteSpace(numeroFactura))
{
return (false, "El número de factura no puede estar vacío.");
}
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null)
{
return (false, "La factura especificada no existe.");
}
if (factura.EstadoPago == "Anulada")
{
return (false, "No se puede modificar una factura anulada.");
}
var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction);
if (!actualizado)
{
throw new DataException("La actualización del número de factura falló en el repositorio.");
}
transaction.Commit();
_logger.LogInformation("Número de factura para Factura ID {IdFactura} actualizado a {NumeroFactura} por Usuario ID {IdUsuario}", idFactura, numeroFactura, idUsuario);
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al actualizar número de factura para Factura ID {IdFactura}", idFactura);
return (false, "Error interno al actualizar el número de factura.");
}
}
@@ -133,25 +583,34 @@ namespace GestionIntegral.Api.Services.Suscripciones
decimal importeTotal = 0;
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 +618,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 +656,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
);
}
}
}

View File

@@ -0,0 +1,12 @@
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IAjusteService
{
Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta);
Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> ActualizarAjuste(int idAjuste, UpdateAjusteDto updateDto);
Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario);
}
}

View File

@@ -1,11 +1,14 @@
using GestionIntegral.Api.Dtos.Comunicaciones;
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IFacturacionService
{
Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes);
Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura);
Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes);
Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion);
Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario);
Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario);
}
}

View File

@@ -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);
}
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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
@@ -85,6 +92,15 @@ namespace GestionIntegral.Api.Services.Suscripciones
return (null, "La publicación no existe.");
if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio)
return (null, "La fecha de fin no puede ser anterior a la fecha de inicio.");
if ((createDto.Estado == "Cancelada" || createDto.Estado == "Pausada") && !createDto.FechaFin.HasValue)
{
return (null, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'.");
}
if (createDto.Estado == "Activa")
{
createDto.FechaFin = null;
}
var nuevaSuscripcion = new Suscripcion
{
@@ -123,6 +139,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion);
if (existente == null) return (false, "Suscripción no encontrada.");
// Validación de lógica de negocio en el backend
if ((updateDto.Estado == "Cancelada" || updateDto.Estado == "Pausada") && !updateDto.FechaFin.HasValue)
{
return (false, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'.");
}
// Si el estado es 'Activa', nos aseguramos de que la FechaFin sea nula.
if (updateDto.Estado == "Activa")
{
updateDto.FechaFin = null;
}
if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio)
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
@@ -154,47 +182,67 @@ namespace GestionIntegral.Api.Services.Suscripciones
}
}
public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion)
public async Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion)
{
var promociones = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion);
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.");
}
}

View File

@@ -1,15 +1,8 @@
// Archivo: GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones
{
@@ -58,10 +51,42 @@ namespace GestionIntegral.Api.Services.Suscripciones
public async Task<IEnumerable<SuscriptorDto>> ObtenerTodos(string? nombreFilter, string? nroDocFilter, bool soloActivos)
{
// 1. Obtener todos los suscriptores en una sola consulta
var suscriptores = await _suscriptorRepository.GetAllAsync(nombreFilter, nroDocFilter, soloActivos);
var dtosTasks = suscriptores.Select(s => MapToDto(s));
var dtos = await Task.WhenAll(dtosTasks);
return dtos.Where(dto => dto != null).Select(dto => dto!);
if (!suscriptores.Any())
{
return Enumerable.Empty<SuscriptorDto>();
}
// 2. Obtener todas las formas de pago en una sola consulta
// y convertirlas a un diccionario para una búsqueda rápida (O(1) en lugar de O(n)).
var formasDePago = (await _formaPagoRepository.GetAllAsync())
.ToDictionary(fp => fp.IdFormaPago);
// 3. Mapear en memoria, evitando múltiples llamadas a la base de datos.
var dtos = suscriptores.Select(s =>
{
// Busca la forma de pago en el diccionario en memoria.
formasDePago.TryGetValue(s.IdFormaPagoPreferida, out var formaPago);
return new SuscriptorDto
{
IdSuscriptor = s.IdSuscriptor,
NombreCompleto = s.NombreCompleto,
Email = s.Email,
Telefono = s.Telefono,
Direccion = s.Direccion,
TipoDocumento = s.TipoDocumento,
NroDocumento = s.NroDocumento,
CBU = s.CBU,
IdFormaPagoPreferida = s.IdFormaPagoPreferida,
NombreFormaPagoPreferida = formaPago?.Nombre ?? "Desconocida", // Asigna el nombre
Observaciones = s.Observaciones,
Activo = s.Activo
};
});
return dtos;
}
public async Task<SuscriptorDto?> ObtenerPorId(int id)
@@ -204,7 +229,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
_logger.LogInformation("El estado del Suscriptor ID {IdSuscriptor} se cambió a {Estado} por el Usuario ID {IdUsuario}.", id, activar ? "Activo" : "Inactivo", idUsuario);
return (true, null);
}
catch(Exception ex)
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al cambiar estado del suscriptor ID: {IdSuscriptor}", id);

View File

@@ -5,6 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AppSettings": {
"FacturasPdfPath": "E:\\Facturas"
},
"Jwt": {
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
"Issuer": "GestionIntegralApi",
@@ -13,11 +16,11 @@
},
"AllowedHosts": "*",
"MailSettings": {
"SmtpHost": "smtp.yourprovider.com",
"SmtpPort": 587,
"SenderName": "Diario El Día - Suscripciones",
"SenderEmail": "suscripciones@eldia.com",
"SmtpUser": "your-smtp-username",
"SmtpPass": "your-smtp-password"
}
"SmtpHost": "",
"SmtpPort": 0,
"SenderName": "",
"SenderEmail": "",
"SmtpUser": "",
"SmtpPass": ""
}
}

View File

@@ -0,0 +1,159 @@
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { CreateAjusteDto } from '../../../models/dtos/Suscripciones/CreateAjusteDto';
import type { UpdateAjusteDto } from '../../../models/dtos/Suscripciones/UpdateAjusteDto';
import type { AjusteDto } from '../../../models/dtos/Suscripciones/AjusteDto';
import type { EmpresaDropdownDto } from '../../../models/dtos/Distribucion/EmpresaDropdownDto';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: '80%', md: '500px' },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24, p: 4,
};
// --- TIPO UNIFICADO PARA EL ESTADO DEL FORMULARIO ---
type AjusteFormData = Partial<CreateAjusteDto & UpdateAjusteDto>;
interface AjusteFormModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => Promise<void>;
initialData?: AjusteDto | null;
idSuscriptor: number;
errorMessage?: string | null;
clearErrorMessage: () => void;
empresas: EmpresaDropdownDto[];
}
const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage, initialData, empresas }) => {
const [formData, setFormData] = useState<AjusteFormData>({});
const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData);
useEffect(() => {
if (open) {
const fechaParaFormulario = initialData?.fechaAjuste
? initialData.fechaAjuste.split(' ')[0]
: new Date().toISOString().split('T')[0];
setFormData({
idSuscriptor: initialData?.idSuscriptor || idSuscriptor,
idEmpresa: initialData?.idEmpresa || undefined, // undefined para que el placeholder se muestre
fechaAjuste: fechaParaFormulario,
tipoAjuste: initialData?.tipoAjuste || 'Credito',
monto: initialData?.monto || undefined,
motivo: initialData?.motivo || ''
});
setLocalErrors({});
}
}, [open, initialData, idSuscriptor]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.idEmpresa) errors.idEmpresa = "Debe seleccionar una empresa.";
if (!formData.fechaAjuste) errors.fechaAjuste = "La fecha es obligatoria.";
if (!formData.tipoAjuste) errors.tipoAjuste = "Seleccione un tipo.";
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
if (!formData.motivo?.trim()) errors.motivo = "El motivo es obligatorio.";
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev: AjusteFormData) => ({
...prev,
[name]: name === 'monto' && value !== '' ? parseFloat(value) : value
}));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSelectChange = (e: SelectChangeEvent<string | number>) => { // Acepta string o number
const { name, value } = e.target;
setFormData((prev: AjusteFormData) => ({ ...prev, [name]: value }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
clearErrorMessage();
if (!validate()) return;
setLoading(true);
let success = false;
try {
if (isEditing && initialData) {
await onSubmit(formData as UpdateAjusteDto, initialData.idAjuste);
} else {
await onSubmit(formData as CreateAjusteDto);
}
success = true;
} catch (error) {
success = false;
} finally {
setLoading(false);
if (success) onClose();
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">{isEditing ? 'Editar Ajuste Manual' : 'Registrar Ajuste Manual'}</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="fechaAjuste" label="Fecha del Ajuste" type="date" value={formData.fechaAjuste || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaAjuste} helperText={localErrors.fechaAjuste} />
<FormControl fullWidth margin="dense" required error={!!localErrors.idEmpresa}>
<InputLabel id="empresa-label">Empresa</InputLabel>
<Select
name="idEmpresa"
labelId="empresa-label"
value={formData.idEmpresa || ''}
onChange={handleSelectChange}
label="Empresa"
>
{empresas.map((empresa) => (
<MenuItem key={empresa.idEmpresa} value={empresa.idEmpresa}>
{empresa.nombre}
</MenuItem>
))}
</Select>
{localErrors.idEmpresa && <Typography color="error" variant="caption">{localErrors.idEmpresa}</Typography>}
</FormControl>
<FormControl fullWidth margin="dense" error={!!localErrors.tipoAjuste}>
<InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel>
<Select name="tipoAjuste" labelId="tipo-ajuste-label" value={formData.tipoAjuste || ''} onChange={handleSelectChange} label="Tipo de Ajuste">
<MenuItem value="Credito">Crédito (Descuento a favor del cliente)</MenuItem>
<MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem>
</Select>
</FormControl>
<TextField name="monto" label="Monto" type="number" value={formData.monto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.monto} helperText={localErrors.monto} InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} inputProps={{ step: "0.01" }} />
<TextField name="motivo" label="Motivo" value={formData.motivo || ''} onChange={handleInputChange} required fullWidth margin="dense" multiline rows={3} error={!!localErrors.motivo} helperText={localErrors.motivo} />
<Alert severity="info" sx={{ mt: 2 }}>
Nota: Este ajuste se aplicará a la factura de la <strong>empresa seleccionada</strong> en el período correspondiente a la "Fecha del Ajuste".
</Alert>
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Guardar Ajuste'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default AjusteFormModal;

View File

@@ -1,12 +1,26 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material';
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider, type SelectChangeEvent, TextField } from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteIcon from '@mui/icons-material/Delete';
import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto';
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
import type { PromocionAsignadaDto } from '../../../models/dtos/Suscripciones/PromocionAsignadaDto';
import type { AsignarPromocionDto } from '../../../models/dtos/Suscripciones/AsignarPromocionDto';
import suscripcionService from '../../../services/Suscripciones/suscripcionService';
const modalStyle = { /* ... */ };
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: '80%', md: '600px' },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflowY: 'auto'
};
interface GestionarPromocionesSuscripcionModalProps {
open: boolean;
@@ -15,12 +29,15 @@ interface GestionarPromocionesSuscripcionModalProps {
}
const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => {
const [asignadas, setAsignadas] = useState<PromocionDto[]>([]);
const [asignadas, setAsignadas] = useState<PromocionAsignadaDto[]>([]);
const [disponibles, setDisponibles] = useState<PromocionDto[]>([]);
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
const [vigenciaDesde, setVigenciaDesde] = useState('');
const [vigenciaHasta, setVigenciaHasta] = useState('');
const cargarDatos = useCallback(async () => {
if (!suscripcion) return;
setLoading(true);
@@ -40,16 +57,30 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
}, [suscripcion]);
useEffect(() => {
if (open) {
if (open && suscripcion) {
cargarDatos();
setSelectedPromo('');
setVigenciaDesde(suscripcion.fechaInicio);
setVigenciaHasta('');
}
}, [open, cargarDatos]);
}, [open, suscripcion]);
const handleAsignar = async () => {
if (!suscripcion || !selectedPromo) return;
if (!suscripcion || !selectedPromo || !vigenciaDesde) {
setError("Debe seleccionar una promoción y una fecha de inicio.");
return;
}
setError(null);
try {
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, Number(selectedPromo));
const dto: AsignarPromocionDto = {
idPromocion: Number(selectedPromo),
vigenciaDesde: vigenciaDesde,
vigenciaHasta: vigenciaHasta || null
};
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, dto);
setSelectedPromo('');
setVigenciaDesde(suscripcion.fechaInicio);
setVigenciaHasta('');
cargarDatos();
} catch (err: any) {
setError(err.response?.data?.message || "Error al asignar la promoción.");
@@ -58,12 +89,32 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
const handleQuitar = async (idPromocion: number) => {
if (!suscripcion) return;
setError(null);
if (window.confirm("¿Está seguro de que desea quitar esta promoción de la suscripción?")) {
try {
await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion);
cargarDatos();
} catch (err: any) {
setError(err.response?.data?.message || "Error al quitar la promoción.");
}
}
};
const formatDate = (dateString?: string | null) => {
if (!dateString) return 'Indefinida';
const parts = dateString.split('-');
return `${parts[2]}/${parts[1]}/${parts[0]}`;
};
const formatSecondaryText = (promo: PromocionAsignadaDto): string => {
let text = '';
switch (promo.tipoEfecto) {
case 'DescuentoPorcentajeTotal': text = `Descuento Total: ${promo.valorEfecto}%`; break;
case 'DescuentoMontoFijoTotal': text = `Descuento Total: $${promo.valorEfecto.toFixed(2)}`; break;
case 'BonificarEntregaDia': text = 'Bonificación de Día'; break;
default: text = 'Tipo desconocido';
}
return text;
};
if (!suscripcion) return null;
@@ -73,30 +124,39 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
<Box sx={modalStyle}>
<Typography variant="h6">Gestionar Promociones</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Suscripción a: {suscripcion.nombrePublicacion}
Suscripción a: <strong>{suscripcion.nombrePublicacion}</strong>
</Typography>
{error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{loading ? <CircularProgress /> : (
{loading ? <CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> : (
<>
<Typography sx={{ mt: 2 }}>Promociones Asignadas</Typography>
<Typography sx={{ mt: 2, fontWeight: 'medium' }}>Promociones Asignadas</Typography>
<List dense>
{asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>}
{asignadas.map(p => (
<ListItem key={p.idPromocion} secondaryAction={<IconButton edge="end" onClick={() => handleQuitar(p.idPromocion)}><DeleteIcon /></IconButton>}>
<ListItemText primary={p.descripcion} secondary={`Tipo: ${p.tipoPromocion}, Valor: ${p.valor}`} />
<ListItemText
primary={p.descripcion}
secondary={`Vigente del ${formatDate(p.vigenciaDesdeAsignacion)} al ${formatDate(p.vigenciaHastaAsignacion)} - ${formatSecondaryText(p)}`}
/>
</ListItem>
))}
</List>
<Divider sx={{ my: 2 }} />
<Typography>Asignar Nueva Promoción</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}>
<FormControl fullWidth size="small">
<Box sx={{ mt: 1 }}>
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel>Promociones Disponibles</InputLabel>
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e) => setSelectedPromo(e.target.value)}>
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e: SelectChangeEvent<number | string>) => setSelectedPromo(e.target.value)}>
{disponibles.map(p => <MenuItem key={p.idPromocion} value={p.idPromocion}>{p.descripcion}</MenuItem>)}
</Select>
</FormControl>
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo}><AddCircleOutlineIcon /></Button>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField label="Vigencia Desde" type="date" value={vigenciaDesde} onChange={(e) => setVigenciaDesde(e.target.value)} required fullWidth size="small" InputLabelProps={{ shrink: true }} />
<TextField label="Vigencia Hasta (Opcional)" type="date" value={vigenciaHasta} onChange={(e) => setVigenciaHasta(e.target.value)} fullWidth size="small" InputLabelProps={{ shrink: true }} />
</Box>
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo} sx={{ mt: 2 }} startIcon={<AddCircleOutlineIcon />}>
Asignar
</Button>
</Box>
</>
)}

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Modal, Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, Tooltip, IconButton, CircularProgress, Alert } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto';
interface HistorialEnviosModalProps {
open: boolean;
onClose: () => void;
logs: EmailLogDto[];
isLoading: boolean;
error: string | null;
titulo: string;
}
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: '80%', md: '700px' },
bgcolor: 'background.paper',
boxShadow: 24, p: 4,
borderRadius: 2,
};
const HistorialEnviosModal: React.FC<HistorialEnviosModalProps> = ({ open, onClose, logs, isLoading, error, titulo }) => {
const formatDisplayDateTime = (dateString: string): string => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('es-AR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" component="h2">{titulo}</Typography>
<IconButton onClick={onClose}><CloseIcon /></IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}><CircularProgress /></Box>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Fecha de Envío</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Estado</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Destinatario</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Asunto</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">No se han registrado envíos.</TableCell>
</TableRow>
) : (
logs.map((log, index) => (
<TableRow key={index}>
<TableCell>{formatDisplayDateTime(log.fechaEnvio)}</TableCell>
<TableCell>
<Tooltip title={log.estado === 'Fallido' ? (log.error || 'Error desconocido') : ''} arrow>
<Chip
label={log.estado}
color={log.estado === 'Enviado' ? 'success' : 'error'}
size="small"
/>
</Tooltip>
</TableCell>
<TableCell>{log.destinatarioEmail}</TableCell>
<TableCell>{log.asunto}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
</Modal>
);
};
export default HistorialEnviosModal;

View File

@@ -1,5 +1,3 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto';
@@ -54,7 +52,7 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
fetchFormasDePago();
setFormData({
idFactura: factura.idFactura,
monto: factura.importeFinal,
monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto
fechaPago: new Date().toISOString().split('T')[0]
});
setLocalErrors({});
@@ -64,8 +62,18 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago.";
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria.";
const monto = formData.monto ?? 0;
const saldo = factura?.saldoPendiente ?? 0;
if (monto <= 0) {
errors.monto = "El monto debe ser mayor a cero.";
} else if (monto > saldo) {
// Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje
errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`;
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
@@ -109,8 +117,8 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">Registrar Pago Manual</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Factura #{factura.idFactura} para {factura.nombreSuscriptor}
<Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}>
Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} />

View File

@@ -1,28 +1,33 @@
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox,
type SelectChangeEvent, InputAdornment } from '@mui/material';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: '80%', md: '600px' },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflowY: 'auto'
boxShadow: 24, p: 4,
maxHeight: '90vh', overflowY: 'auto'
};
const tiposPromocion = [
{ value: 'Porcentaje', label: 'Descuento Porcentual (%)' },
{ value: 'MontoFijo', label: 'Descuento de Monto Fijo ($)' },
// { value: 'BonificacionDias', label: 'Bonificación de Días' }, // Descomentar para futuras implementaciones
const tiposEfecto = [
{ value: 'DescuentoPorcentajeTotal', label: 'Descuento en Porcentaje (%) sobre el total' },
{ value: 'DescuentoMontoFijoTotal', label: 'Descuento en Monto Fijo ($) sobre el total' },
{ value: 'BonificarEntregaDia', label: 'Bonificar / Día Gratis (Precio del día = $0)' },
];
const tiposCondicion = [
{ value: 'Siempre', label: 'Siempre (en todos los días de entrega)' },
{ value: 'DiaDeSemana', label: 'Un día de la semana específico' },
{ value: 'PrimerDiaSemanaDelMes', label: 'El primer día de la semana del mes' },
];
const diasSemana = [
{ value: 1, label: 'Lunes' }, { value: 2, label: 'Martes' }, { value: 3, label: 'Miércoles' },
{ value: 4, label: 'Jueves' }, { value: 5, label: 'Viernes' }, { value: 6, label: 'Sábado' },
{ value: 7, label: 'Domingo' }
];
interface PromocionFormModalProps {
@@ -38,18 +43,22 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({});
const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData);
const necesitaValorCondicion = formData.tipoCondicion === 'DiaDeSemana' || formData.tipoCondicion === 'PrimerDiaSemanaDelMes';
useEffect(() => {
if (open) {
setFormData(initialData || {
const defaults = {
descripcion: '',
tipoPromocion: 'Porcentaje',
valor: 0,
tipoEfecto: 'DescuentoPorcentajeTotal' as const,
valorEfecto: 0,
tipoCondicion: 'Siempre' as const,
valorCondicion: null,
fechaInicio: new Date().toISOString().split('T')[0],
activa: true
});
};
setFormData(initialData ? { ...initialData } : defaults);
setLocalErrors({});
}
}, [open, initialData]);
@@ -57,10 +66,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.';
if (!formData.tipoPromocion) errors.tipoPromocion = 'El tipo de promoción es obligatorio.';
if (!formData.valor || formData.valor <= 0) errors.valor = 'El valor debe ser mayor a cero.';
if (formData.tipoPromocion === 'Porcentaje' && (formData.valor ?? 0) > 100) {
errors.valor = 'El valor para porcentaje no puede ser mayor a 100.';
if (!formData.tipoEfecto) errors.tipoEfecto = 'El tipo de efecto es obligatorio.';
if (formData.tipoEfecto !== 'BonificarEntregaDia' && (!formData.valorEfecto || formData.valorEfecto <= 0)) {
errors.valorEfecto = 'El valor debe ser mayor a cero.';
}
if (formData.tipoEfecto === 'DescuentoPorcentajeTotal' && formData.valorEfecto && formData.valorEfecto > 100) {
errors.valorEfecto = 'El valor para porcentaje no puede ser mayor a 100.';
}
if (!formData.tipoCondicion) errors.tipoCondicion = 'La condición es obligatoria.';
if (necesitaValorCondicion && !formData.valorCondicion) {
errors.valorCondicion = "Debe seleccionar un día para esta condición.";
}
if (!formData.fechaInicio) errors.fechaInicio = 'La fecha de inicio es obligatoria.';
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
@@ -72,7 +87,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
const finalValue = type === 'checkbox' ? checked : (type === 'number' ? parseFloat(value) : value);
const finalValue = type === 'checkbox' ? checked : (name === 'valorEfecto' && value !== '' ? parseFloat(value) : value);
setFormData(prev => ({ ...prev, [name]: finalValue }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
@@ -80,7 +95,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
const newFormData = { ...formData, [name]: value };
if (name === 'tipoCondicion' && value === 'Siempre') {
newFormData.valorCondicion = null;
}
if (name === 'tipoEfecto' && value === 'BonificarEntregaDia') {
newFormData.valorEfecto = 0; // Bonificar no necesita valor
}
setFormData(newFormData);
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
@@ -93,11 +117,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
setLoading(true);
let success = false;
try {
const dataToSubmit = {
...formData,
fechaFin: formData.fechaFin || null
} as CreatePromocionDto | UpdatePromocionDto;
const dataToSubmit = { ...formData, fechaFin: formData.fechaFin || null } as CreatePromocionDto | UpdatePromocionDto;
await onSubmit(dataToSubmit, initialData?.idPromocion);
success = true;
} catch (error) {
@@ -111,28 +131,39 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
<Typography variant="h6">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="descripcion" label="Descripción" value={formData.descripcion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.descripcion} helperText={localErrors.descripcion} disabled={loading} autoFocus />
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControl fullWidth margin="dense" sx={{flex: 2}} error={!!localErrors.tipoPromocion}>
<InputLabel id="tipo-promo-label" required>Tipo</InputLabel>
<Select name="tipoPromocion" labelId="tipo-promo-label" value={formData.tipoPromocion || ''} onChange={handleSelectChange} label="Tipo" disabled={loading}>
{tiposPromocion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
<FormControl fullWidth margin="dense" error={!!localErrors.tipoEfecto}>
<InputLabel>Efecto de la Promoción</InputLabel>
<Select name="tipoEfecto" value={formData.tipoEfecto || ''} onChange={handleSelectChange} label="Efecto de la Promoción" disabled={loading}>
{tiposEfecto.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</Select>
</FormControl>
<TextField name="valor" label="Valor" type="number" value={formData.valor || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{flex: 1}} error={!!localErrors.valor} helperText={localErrors.valor} disabled={loading}
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoPromocion === 'Porcentaje' ? '%' : '$'}</InputAdornment> }}
{formData.tipoEfecto !== 'BonificarEntregaDia' && (
<TextField name="valorEfecto" label="Valor" type="number" value={formData.valorEfecto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.valorEfecto} helperText={localErrors.valorEfecto} disabled={loading}
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoEfecto === 'DescuentoPorcentajeTotal' ? '%' : '$'}</InputAdornment> }}
inputProps={{ step: "0.01" }}
/>
</Box>
)}
<FormControl fullWidth margin="dense" error={!!localErrors.tipoCondicion}>
<InputLabel>Condición de Aplicación</InputLabel>
<Select name="tipoCondicion" value={formData.tipoCondicion || ''} onChange={handleSelectChange} label="Condición de Aplicación" disabled={loading}>
{tiposCondicion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</Select>
</FormControl>
{necesitaValorCondicion && (
<FormControl fullWidth margin="dense" error={!!localErrors.valorCondicion}>
<InputLabel>Día de la Semana</InputLabel>
<Select name="valorCondicion" value={formData.valorCondicion || ''} onChange={handleSelectChange} label="Día de la Semana" disabled={loading}>
{diasSemana.map(d => <MenuItem key={d.value} value={d.value}>{d.label}</MenuItem>)}
</Select>
</FormControl>
)}
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
<TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} />
<TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} />
</Box>
<FormControlLabel control={<Checkbox name="activa" checked={formData.activa ?? true} onChange={handleInputChange} disabled={loading}/>} label="Promoción Activa" sx={{mt: 1}} />
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}

View File

@@ -0,0 +1,88 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Box, Dialog, DialogTitle, DialogContent, IconButton, Tabs, Tab, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Paper, Chip, CircularProgress, Alert } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto';
import facturacionService from '../../../services/Suscripciones/facturacionService';
interface ResultadoEnvioModalProps {
open: boolean;
onClose: () => void;
loteId: number | null;
periodo: string;
}
const ResultadoEnvioModal: React.FC<ResultadoEnvioModalProps> = ({ open, onClose, loteId, periodo }) => {
const [activeTab, setActiveTab] = useState(0);
const [logs, setLogs] = useState<EmailLogDto[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open && loteId) {
const fetchDetails = async () => {
setLoading(true);
setError(null);
try {
const data = await facturacionService.getDetallesLoteEnvio(loteId);
setLogs(data);
} catch (err) {
setError('No se pudieron cargar los detalles del envío.');
} finally {
setLoading(false);
}
};
fetchDetails();
}
}, [open, loteId]);
const filteredLogs = useMemo(() => {
if (activeTab === 1) return logs.filter(log => log.estado === 'Enviado');
if (activeTab === 2) return logs.filter(log => log.estado === 'Fallido');
return logs; // Tab 0 es 'Todos'
}, [logs, activeTab]);
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
Detalle del Lote de Envío - Período {periodo}
<IconButton onClick={onClose} sx={{ position: 'absolute', right: 8, top: 8 }}><CloseIcon /></IconButton>
</DialogTitle>
<DialogContent dividers>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={activeTab} onChange={(_e, newValue) => setActiveTab(newValue)}>
<Tab label={`Todos (${logs.length})`} />
<Tab label={`Enviados (${logs.filter(l => l.estado === 'Enviado').length})`} />
<Tab label={`Fallidos (${logs.filter(l => l.estado === 'Fallido').length})`} />
</Tabs>
</Box>
{loading ? <CircularProgress /> : error ? <Alert severity="error">{error}</Alert> :
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Destinatario</TableCell>
<TableCell>Asunto</TableCell>
<TableCell>Estado</TableCell>
<TableCell>Detalle del Error</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredLogs.map((log, index) => (
<TableRow key={index}>
<TableCell>{log.destinatarioEmail}</TableCell>
<TableCell>{log.asunto}</TableCell>
<TableCell><Chip label={log.estado} color={log.estado === 'Enviado' ? 'success' : 'error'} size="small" /></TableCell>
<TableCell sx={{ color: 'error.main' }}>{log.error || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
}
</DialogContent>
</Dialog>
);
};
export default ResultadoEnvioModal;

View File

@@ -25,10 +25,10 @@ const modalStyle = {
};
const dias = [
{ label: 'Lunes', value: 'L' }, { label: 'Martes', value: 'M' },
{ label: 'Miércoles', value: 'X' }, { label: 'Jueves', value: 'J' },
{ label: 'Viernes', value: 'V' }, { label: 'Sábado', value: 'S' },
{ label: 'Domingo', value: 'D' }
{ label: 'Lunes', value: 'Lun' }, { label: 'Martes', value: 'Mar' },
{ label: 'Miércoles', value: 'Mie' }, { label: 'Jueves', value: 'Jue' },
{ label: 'Viernes', value: 'Vie' }, { label: 'Sábado', value: 'Sab' },
{ label: 'Domingo', value: 'Dom' }
];
interface SuscripcionFormModalProps {
@@ -41,7 +41,6 @@ interface SuscripcionFormModalProps {
clearErrorMessage: () => void;
}
// Usamos una interfaz local que contenga todos los campos posibles del formulario
interface FormState {
idPublicacion?: number | '';
fechaInicio?: string;
@@ -92,6 +91,13 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
const errors: { [key: string]: string | null } = {};
if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación.";
if (!formData.fechaInicio?.trim()) errors.fechaInicio = 'La Fecha de Inicio es obligatoria.';
// --- INICIO DE LA MODIFICACIÓN ---
if (formData.estado !== 'Activa' && !formData.fechaFin) {
errors.fechaFin = 'La Fecha de Fin es obligatoria si el estado es Pausada o Cancelada.';
}
// --- FIN DE LA MODIFICACIÓN ---
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.';
}
@@ -105,7 +111,7 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
if (newSelection.has(dayValue)) newSelection.delete(dayValue);
else newSelection.add(dayValue);
setSelectedDays(newSelection);
if (localErrors.diasEntrega) setLocalErrors(prev => ({...prev, diasEntrega: null}));
if (localErrors.diasEntrega) setLocalErrors(prev => ({ ...prev, diasEntrega: null }));
if (errorMessage) clearErrorMessage();
};
@@ -123,6 +129,27 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
if (errorMessage) clearErrorMessage();
};
// --- INICIO DE LA MODIFICACIÓN ---
const handleEstadoChange = (e: SelectChangeEvent<'Activa' | 'Pausada' | 'Cancelada'>) => {
const nuevoEstado = e.target.value as 'Activa' | 'Pausada' | 'Cancelada';
const hoy = new Date().toISOString().split('T')[0];
setFormData(prev => {
const newState = { ...prev, estado: nuevoEstado };
if ((nuevoEstado === 'Cancelada' || nuevoEstado === 'Pausada') && !prev.fechaFin) {
newState.fechaFin = hoy;
} else if (nuevoEstado === 'Activa') {
newState.fechaFin = null; // Limpiar la fecha de fin si se reactiva
}
return newState;
});
// Limpiar errores al cambiar
if (localErrors.fechaFin) setLocalErrors(prev => ({ ...prev, fechaFin: null }));
if (errorMessage) clearErrorMessage();
};
// --- FIN DE LA MODIFICACIÓN ---
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
clearErrorMessage();
@@ -133,7 +160,7 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
try {
const dataToSubmit = {
...formData,
fechaFin: formData.fechaFin || null,
fechaFin: formData.estado === 'Activa' ? null : formData.fechaFin, // Asegurarse de que fechaFin es null si está activa
diasEntrega: Array.from(selectedDays),
};
@@ -166,18 +193,32 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
<Typography sx={{ mt: 2, mb: 1, color: localErrors.diasEntrega ? 'error.main' : 'inherit' }}>Días de Entrega *</Typography>
<Paper variant="outlined" sx={{ p: 1, borderColor: localErrors.diasEntrega ? 'error.main' : 'rgba(0, 0, 0, 0.23)' }}>
<FormGroup row>
{dias.map(d => <FormControlLabel key={d.value} control={<Checkbox checked={selectedDays.has(d.value)} onChange={() => handleDayChange(d.value)} disabled={loading}/>} label={d.label} />)}
{dias.map(d => <FormControlLabel key={d.value} control={<Checkbox checked={selectedDays.has(d.value)} onChange={() => handleDayChange(d.value)} disabled={loading} />} label={d.label} />)}
</FormGroup>
</Paper>
{localErrors.diasEntrega && <Typography color="error" variant="caption">{localErrors.diasEntrega}</Typography>}
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
<TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} />
<TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} />
<TextField
name="fechaFin"
label={formData.estado !== 'Activa' ? "Fecha Fin (Requerida)" : "Fecha Fin (Automática)"}
type="date"
value={formData.fechaFin || ''}
onChange={handleInputChange}
fullWidth
margin="dense"
InputLabelProps={{ shrink: true }}
error={!!localErrors.fechaFin}
helperText={localErrors.fechaFin}
disabled={loading || formData.estado === 'Activa'} // Deshabilitado si está activa
required={formData.estado !== 'Activa'} // Requerido si no está activa
/>
</Box>
<FormControl fullWidth margin="dense">
<InputLabel id="estado-label">Estado</InputLabel>
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleSelectChange} label="Estado" disabled={loading}>
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleEstadoChange} label="Estado" disabled={loading}>
<MenuItem value="Activa">Activa</MenuItem>
<MenuItem value="Pausada">Pausada</MenuItem>
<MenuItem value="Cancelada">Cancelada</MenuItem>

View File

@@ -1,7 +1,5 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/SuscriptorFormModal.tsx
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material'; // 1. Importar SelectChangeEvent
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material';
import type { SuscriptorDto } from '../../../models/dtos/Suscripciones/SuscriptorDto';
import type { CreateSuscriptorDto } from '../../../models/dtos/Suscripciones/CreateSuscriptorDto';
import type { UpdateSuscriptorDto } from '../../../models/dtos/Suscripciones/UpdateSuscriptorDto';
@@ -31,9 +29,7 @@ interface SuscriptorFormModalProps {
clearErrorMessage: () => void;
}
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage
}) => {
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage }) => {
const [formData, setFormData] = useState<Partial<CreateSuscriptorDto>>({});
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
const [loading, setLoading] = useState(false);
@@ -59,9 +55,18 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
if (open) {
fetchFormasDePago();
setFormData(initialData || {
nombreCompleto: '', tipoDocumento: 'DNI', nroDocumento: '', cbu: ''
});
const dataParaFormulario: Partial<CreateSuscriptorDto> = {
nombreCompleto: initialData?.nombreCompleto || '',
email: initialData?.email || '',
telefono: initialData?.telefono || '',
direccion: initialData?.direccion || '',
tipoDocumento: initialData?.tipoDocumento || 'DNI',
nroDocumento: initialData?.nroDocumento || '',
cbu: initialData?.cbu || '',
idFormaPagoPreferida: initialData?.idFormaPagoPreferida,
observaciones: initialData?.observaciones || ''
};
setFormData(dataParaFormulario);
setLocalErrors({});
}
}, [open, initialData]);
@@ -73,36 +78,61 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
if (!formData.tipoDocumento) errors.tipoDocumento = 'El tipo de documento es obligatorio.';
if (!formData.nroDocumento?.trim()) errors.nroDocumento = 'El número de documento es obligatorio.';
if (!formData.idFormaPagoPreferida) errors.idFormaPagoPreferida = 'La forma de pago es obligatoria.';
if (CBURequerido && (!formData.cbu || formData.cbu.trim().length !== 22)) {
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.';
// Validar formato de Nro de Documento (solo números)
if (formData.nroDocumento && !/^[0-9]+$/.test(formData.nroDocumento)) {
errors.nroDocumento = 'El documento solo debe contener números.';
}
// Validar formato de Email
if (formData.email && !/^\S+@\S+\.\S+$/.test(formData.email)) {
errors.email = 'El formato del email no es válido.';
}
// Validar formato y longitud de CBU
if (CBURequerido) {
if (!formData.cbu || !/^[0-9]+$/.test(formData.cbu) || formData.cbu.length !== 22) {
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos numéricos.';
}
} else if (formData.cbu && formData.cbu.trim().length > 0 && (!/^[0-9]+$/.test(formData.cbu) || formData.cbu.length !== 26)) {
errors.cbu = 'El CBU debe tener 22 dígitos numéricos o estar vacío.';
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
// --- HANDLER DE INPUT MEJORADO ---
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// Prevenir entrada de caracteres no numéricos para CBU y NroDocumento
if (name === 'cbu' || name === 'nroDocumento') {
const numericValue = value.replace(/[^0-9]/g, '');
setFormData(prev => ({ ...prev, [name]: numericValue }));
} else {
setFormData(prev => ({ ...prev, [name]: value }));
if (localErrors[name]) {
setLocalErrors(prev => ({ ...prev, [name]: null }));
}
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
// 2. Crear un handler específico para los Select
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (localErrors[name]) {
setLocalErrors(prev => ({ ...prev, [name]: null }));
const newFormData = { ...formData, [name]: value };
if (name === 'idFormaPagoPreferida') {
const formaDePagoSeleccionada = formasDePago.find(fp => fp.idFormaPago === value);
if (formaDePagoSeleccionada && !formaDePagoSeleccionada.requiereCBU) {
newFormData.cbu = '';
}
}
setFormData(newFormData);
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
clearErrorMessage();
@@ -111,7 +141,12 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
setLoading(true);
let success = false;
try {
const dataToSubmit = formData as CreateSuscriptorDto | UpdateSuscriptorDto;
const dataToSubmit = {
...formData,
idFormaPagoPreferida: Number(formData.idFormaPagoPreferida),
cbu: formData.cbu?.trim() || null
} as CreateSuscriptorDto | UpdateSuscriptorDto;
await onSubmit(dataToSubmit, initialData?.idSuscriptor);
success = true;
} catch (error) {
@@ -140,26 +175,47 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControl margin="dense" sx={{ minWidth: 120 }}>
<InputLabel id="tipo-doc-label">Tipo</InputLabel>
{/* 3. Aplicar el nuevo handler a los Selects */}
<Select labelId="tipo-doc-label" name="tipoDocumento" value={formData.tipoDocumento || 'DNI'} onChange={handleSelectChange} label="Tipo" disabled={loading}>
<MenuItem value="DNI">DNI</MenuItem>
<MenuItem value="CUIT">CUIT</MenuItem>
<MenuItem value="CUIL">CUIL</MenuItem>
</Select>
</FormControl>
<TextField name="nroDocumento" label="Nro Documento" value={formData.nroDocumento || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{ flex: 2 }} error={!!localErrors.nroDocumento} helperText={localErrors.nroDocumento} disabled={loading} />
<TextField name="nroDocumento" label="Nro Documento" value={formData.nroDocumento || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{ flex: 2 }} error={!!localErrors.nroDocumento} helperText={localErrors.nroDocumento} disabled={loading} inputProps={{ maxLength: 11 }} />
</Box>
<FormControl fullWidth margin="dense" error={!!localErrors.idFormaPagoPreferida}>
<InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel>
{/* 3. Aplicar el nuevo handler a los Selects */}
<Select labelId="forma-pago-label" name="idFormaPagoPreferida" value={formData.idFormaPagoPreferida || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loading || loadingFormasPago}>
<Select
labelId="forma-pago-label"
name="idFormaPagoPreferida"
value={loadingFormasPago ? '' : formData.idFormaPagoPreferida || ''}
onChange={handleSelectChange}
label="Forma de Pago"
disabled={loading || loadingFormasPago}
>
{loadingFormasPago && <MenuItem value=""><em>Cargando...</em></MenuItem>}
{formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)}
</Select>
{localErrors.idFormaPagoPreferida && <Typography color="error" variant="caption">{localErrors.idFormaPagoPreferida}</Typography>}
</FormControl>
{CBURequerido && (
<TextField name="cbu" label="CBU" value={formData.cbu || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.cbu} helperText={localErrors.cbu} disabled={loading} inputProps={{ maxLength: 22 }} />
<TextField
name="cbu"
label="CBU"
value={formData.cbu || ''}
onChange={handleInputChange}
required
fullWidth
margin="dense"
error={!!localErrors.cbu}
helperText={localErrors.cbu}
disabled={loading}
inputProps={{ maxLength: 22 }}
/>
)}
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} />
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>}

View File

@@ -0,0 +1,8 @@
export interface EmailLogDto {
fechaEnvio: string; // Formato ISO de fecha y hora
estado: 'Enviado' | 'Fallido';
asunto: string;
destinatarioEmail: string;
error?: string | null;
nombreUsuarioDisparo?: string | null;
}

View File

@@ -0,0 +1,23 @@
import type { EmailLogDto } from "./EmailLogDto";
// Representa el resumen inmediato que se muestra tras el cierre
export interface LoteDeEnvioResumenDto {
idLoteDeEnvio: number;
periodo: string;
totalCorreos: number;
totalEnviados: number;
totalFallidos: number;
erroresDetallados: EmailLogDto[]; // Lista de errores inmediatos
}
// Representa una fila en la tabla de historial
export interface LoteDeEnvioHistorialDto {
idLoteDeEnvio: number;
fechaInicio: string;
periodo: string;
estado: string;
totalCorreos: number;
totalEnviados: number;
totalFallidos: number;
nombreUsuarioDisparo: string;
}

View File

@@ -0,0 +1,15 @@
export interface AjusteDto {
idAjuste: number;
fechaAjuste: string;
idSuscriptor: number;
idEmpresa: number;
nombreEmpresa?: string;
tipoAjuste: 'Credito' | 'Debito';
monto: number;
motivo: string;
estado: 'Pendiente' | 'Aplicado' | 'Anulado';
idFacturaAplicado?: number | null;
numeroFacturaAplicado?: string | null;
fechaAlta: string; // "yyyy-MM-dd HH:mm"
nombreUsuarioAlta: string;
}

View File

@@ -0,0 +1,5 @@
export interface AsignarPromocionDto {
idPromocion: number;
vigenciaDesde: string; // "yyyy-MM-dd"
vigenciaHasta?: string | null;
}

View File

@@ -0,0 +1,8 @@
export interface CreateAjusteDto {
idEmpresa: number;
fechaAjuste: string;
idSuscriptor: number;
tipoAjuste: 'Credito' | 'Debito';
monto: number;
motivo: string;
}

View File

@@ -1,7 +1,9 @@
export interface CreatePromocionDto {
descripcion: string;
tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias';
valor: number;
tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia';
valorEfecto: number;
tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes';
valorCondicion?: number | null;
fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null;
activa: boolean;

View File

@@ -4,6 +4,6 @@ export interface CreateSuscripcionDto {
fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null;
estado: 'Activa' | 'Pausada' | 'Cancelada';
diasEntrega: string[]; // ["L", "M", "X"]
diasEntrega: string[]; // ["Lun", "Mar", "Mie"]
observaciones?: string | null;
}

View File

@@ -1,14 +1,20 @@
export interface FacturaDetalleDto {
descripcion: string;
importeNeto: number;
}
export interface FacturaDto {
idFactura: number;
idSuscripcion: number;
periodo: string; // "YYYY-MM"
fechaEmision: string; // "yyyy-MM-dd"
fechaVencimiento: string; // "yyyy-MM-dd"
idSuscriptor: number;
periodo: string;
fechaEmision: string;
fechaVencimiento: string;
importeFinal: number;
estado: string;
totalPagado: number;
saldoPendiente: number;
estadoPago: string;
estadoFacturacion: string;
numeroFactura?: string | null;
// Datos enriquecidos para la UI
nombreSuscriptor: string;
nombrePublicacion: string;
detalles: FacturaDetalleDto[]; // <-- AÑADIR ESTA LÍNEA
}

View File

@@ -0,0 +1,6 @@
import { type PromocionDto } from "./PromocionDto";
export interface PromocionAsignadaDto extends PromocionDto {
vigenciaDesdeAsignacion: string; // "yyyy-MM-dd"
vigenciaHastaAsignacion?: string | null;
}

View File

@@ -1,8 +1,10 @@
export interface PromocionDto {
idPromocion: number;
descripcion: string;
tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias';
valor: number;
tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia';
valorEfecto: number;
tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes';
valorCondicion?: number | null;
fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null;
activa: boolean;

View File

@@ -0,0 +1,27 @@
// DTO para el detalle de cada línea dentro de una factura (cada suscripción)
export interface FacturaDetalleDto {
descripcion: string;
importeNeto: number;
}
// DTO para cada factura individual (por empresa) dentro del resumen consolidado
export interface FacturaConsolidadaDto {
idFactura: number;
nombreEmpresa: string;
importeFinal: number;
estadoPago: string;
estadoFacturacion: string;
numeroFactura?: string | null;
detalles: FacturaDetalleDto[];
// Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers
idSuscriptor: number;
}
// DTO principal que agrupa todo por suscriptor para la vista de consulta
export interface ResumenCuentaSuscriptorDto {
idSuscriptor: number;
nombreSuscriptor: string;
saldoPendienteTotal: number;
importeTotal: number;
facturas: FacturaConsolidadaDto[];
}

View File

@@ -6,6 +6,6 @@ export interface SuscripcionDto {
fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null;
estado: 'Activa' | 'Pausada' | 'Cancelada';
diasEntrega: string; // "L,M,X,J,V,S,D"
diasEntrega: string; // "Lun,Mar,Mie,Jue,Vie,Sab,Dom"
observaciones?: string | null;
}

View File

@@ -0,0 +1,7 @@
export interface UpdateAjusteDto {
idEmpresa: number;
fechaAjuste: string; // "yyyy-MM-dd"
tipoAjuste: 'Credito' | 'Debito';
monto: number;
motivo: string;
}

View File

@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import { Box, Alert, Paper } from '@mui/material';
import reporteService from '../../services/Reportes/reportesService';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import SeleccionaReporteDistribucionSuscripciones from './SeleccionaReporteDistribucionSuscripciones';
const ReporteDistribucionSuscripcionesPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR011");
const handleGenerateReport = async (params: { fechaDesde: string; fechaHasta: string; }) => {
setLoading(true);
setApiError(null);
try {
const { fileContent, fileName } = await reporteService.getReporteDistribucionSuscripcionesPdf(params.fechaDesde, params.fechaHasta);
const url = window.URL.createObjectURL(new Blob([fileContent], { type: 'application/pdf' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err: any) {
let message = 'Ocurrió un error al generar el reporte.';
if (axios.isAxiosError(err) && err.response) {
if (err.response.status === 404) {
message = "No se encontraron datos para los parámetros seleccionados.";
} else if (err.response.data instanceof Blob && err.response.data.type === "application/json") {
const errorText = await err.response.data.text();
try {
const errorJson = JSON.parse(errorText);
message = errorJson.message || message;
} catch {
message = errorText || message;
}
}
}
setApiError(message);
} finally {
setLoading(false);
}
};
if (!puedeVerReporte) {
return <Alert severity="error">No tiene permiso para ver este reporte.</Alert>;
}
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', pt: 4 }}>
<Paper elevation={3} sx={{ borderRadius: '8px' }}>
<SeleccionaReporteDistribucionSuscripciones
onGenerarReporte={handleGenerateReport}
isLoading={loading}
apiErrorMessage={apiError}
/>
</Paper>
</Box>
);
};
export default ReporteDistribucionSuscripcionesPage;

View File

@@ -0,0 +1,70 @@
// Archivo: Frontend/src/pages/Reportes/ReporteFacturasPublicidadPage.tsx
import React, { useState } from 'react';
import { Box, Alert, Paper } from '@mui/material';
import reporteService from '../../services/Reportes/reportesService';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import SeleccionaReporteFacturasPublicidad from './SeleccionaReporteFacturasPublicidad';
const ReporteFacturasPublicidadPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR010");
const handleGenerateReport = async (params: { anio: number; mes: number; }) => {
setLoading(true);
setApiError(null);
try {
const { fileContent, fileName } = await reporteService.getReporteFacturasPublicidadPdf(params.anio, params.mes);
const url = window.URL.createObjectURL(new Blob([fileContent], { type: 'application/pdf' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err: any) {
let message = 'Ocurrió un error al generar el reporte.';
if (axios.isAxiosError(err) && err.response) {
if (err.response.status === 404) {
message = "No se encontraron datos para los parámetros seleccionados.";
} else if (err.response.data instanceof Blob && err.response.data.type === "application/json") {
const errorText = await err.response.data.text();
try {
const errorJson = JSON.parse(errorText);
message = errorJson.message || message;
} catch {
message = errorText || message;
}
}
}
setApiError(message);
} finally {
setLoading(false);
}
};
if (!puedeVerReporte) {
return <Alert severity="error">No tiene permiso para ver este reporte.</Alert>;
}
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', pt: 4 }}>
<Paper elevation={3} sx={{ borderRadius: '8px' }}>
<SeleccionaReporteFacturasPublicidad
onGenerarReporte={handleGenerateReport}
isLoading={loading}
apiErrorMessage={apiError}
/>
</Paper>
</Box>
);
};
export default ReporteFacturasPublicidadPage;

View File

@@ -23,6 +23,8 @@ const allReportModules: { category: string; label: string; path: string }[] = [
{ category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' },
{ category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' },
{ category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' },
{ category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' },
{ category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion' },
];
const predefinedCategoryOrder = [
@@ -30,6 +32,7 @@ const predefinedCategoryOrder = [
'Listados Distribución',
'Ctrl. Devoluciones',
'Novedades de Canillitas',
'Suscripciones',
'Existencia Papel',
'Movimientos Bobinas',
'Consumos Bobinas',

View File

@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { Box, Typography, Button, CircularProgress, Alert, TextField } from '@mui/material';
interface SeleccionaReporteProps {
onGenerarReporte: (params: { fechaDesde: string, fechaHasta: string }) => Promise<void>;
isLoading?: boolean;
apiErrorMessage?: string | null;
}
const SeleccionaReporteDistribucionSuscripciones: React.FC<SeleccionaReporteProps> = ({
onGenerarReporte,
isLoading,
apiErrorMessage
}) => {
const [fechaDesde, setFechaDesde] = useState(new Date().toISOString().split('T')[0]);
const [fechaHasta, setFechaHasta] = useState(new Date().toISOString().split('T')[0]);
const handleGenerar = () => {
onGenerarReporte({ fechaDesde, fechaHasta });
};
return (
<Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 400 }}>
<Typography variant="h6" gutterBottom>
Reporte de Distribución de Suscripciones
</Typography>
<Typography variant="body2" color="text.secondary" sx={{mb: 2}}>
Seleccione un rango de fechas para generar el listado de suscriptores activos y sus detalles de entrega.
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
<TextField
label="Fecha Desde"
type="date"
fullWidth
value={fechaDesde}
onChange={(e) => setFechaDesde(e.target.value)}
InputLabelProps={{ shrink: true }}
disabled={isLoading}
/>
<TextField
label="Fecha Hasta"
type="date"
fullWidth
value={fechaHasta}
onChange={(e) => setFechaHasta(e.target.value)}
InputLabelProps={{ shrink: true }}
disabled={isLoading}
/>
</Box>
{apiErrorMessage && <Alert severity="error" sx={{ mt: 2 }}>{apiErrorMessage}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={handleGenerar} variant="contained" disabled={isLoading}>
{isLoading ? <CircularProgress size={24} /> : 'Generar Reporte'}
</Button>
</Box>
</Box>
);
};
export default SeleccionaReporteDistribucionSuscripciones;

Some files were not shown because too many files have changed in this diff Show More