Compare commits

..

22 Commits

Author SHA1 Message Date
c27dc2a0ba Fix deploy
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 6m15s
2025-09-10 12:30:07 -03:00
24b1c07342 Test Up
Some checks failed
Optimized Build and Deploy / remote-build-and-deploy (push) Failing after 11s
2025-09-10 11:29:25 -03:00
cb64bbc1f5 Actualizar README.md
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m36s
2025-08-14 13:15:23 +00:00
057310ca47 Fix(Suscripciones): Insert en db arreglado y muestra en UI tipo factura
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m25s
- Se arregla error al insertar en la db el registro de factura "Alta"
- Se arregla UI por falla en la visualización del tipo de factura en la tabla de gestión de facturas.
2025-08-13 15:53:34 -03:00
e95c851e5b Feat(suscripciones): Implementa facturación pro-rata para altas y excluye del débito automático
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m45s
Se introduce una refactorización mayor del ciclo de facturación para manejar correctamente las suscripciones que inician en un período ya cerrado. Esto soluciona el problema de cobrar un mes completo a un nuevo suscriptor, mejorando la transparencia y la experiencia del cliente.

###  Nuevas Características y Lógica de Negocio

- **Facturación Pro-rata Automática (Factura de Alta):**
    - Al crear una nueva suscripción cuya fecha de inicio corresponde a un período de facturación ya cerrado, el sistema ahora calcula automáticamente el costo proporcional por los días restantes de ese mes.
    - Se genera de forma inmediata una nueva factura de tipo "Alta" por este monto parcial, separándola del ciclo de facturación mensual regular.

- **Exclusión del Débito Automático para Facturas de Alta:**
    - Se implementa una regla de negocio clave: las facturas de tipo "Alta" son **excluidas** del proceso de generación del archivo de débito automático para el banco.
    - Esto fuerza a que el primer cobro (el proporcional) se gestione a través de un medio de pago manual (efectivo, transferencia, etc.), evitando cargos inesperados en la cuenta bancaria del cliente.
    - El débito automático comenzará a operar normalmente a partir del primer ciclo de facturación completo.

### 🔄 Cambios en el Backend

- **Base de Datos:**
    - Se ha añadido la columna `TipoFactura` (`varchar(20)`) a la tabla `susc_Facturas`.
    - Se ha implementado una `CHECK constraint` para permitir únicamente los valores 'Mensual' y 'Alta'.

- **Servicios:**
    - **`SuscripcionService`:** Ahora contiene la lógica para detectar una alta retroactiva, invocar al `FacturacionService` para el cálculo pro-rata y crear la "Factura de Alta" y su detalle correspondiente dentro de la misma transacción.
    - **`FacturacionService`:** Expone públicamente el método `CalcularImporteParaSuscripcion` y se ha actualizado `ObtenerResumenesDeCuentaPorPeriodo` para que envíe la propiedad `TipoFactura` al frontend.
    - **`DebitoAutomaticoService`:** El método `GetFacturasParaDebito` ahora filtra y excluye explícitamente las facturas donde `TipoFactura = 'Alta'`.

### 🎨 Mejoras en la Interfaz de Usuario (Frontend)

- **`ConsultaFacturasPage.tsx`:**
    - **Nueva Columna:** Se ha añadido una columna "Tipo Factura" en la tabla de detalle, que muestra un `Chip` distintivo para identificar fácilmente las facturas de "Alta".
    - **Nuevo Filtro:** Se ha agregado un nuevo menú desplegable para filtrar la vista por "Tipo de Factura" (`Todas`, `Mensual`, `Alta`), permitiendo a los administradores auditar rápidamente los nuevos ingresos.
2025-08-13 14:55:24 -03:00
038faefd35 Fix: Formato de Archivo de Débito Modificado
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 4m23s
2025-08-12 12:49:56 -03:00
da50c052f1 Fix: Configuración SMTP
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 6m46s
- No se toma la configuración SMTP del .env por falla de lecturas.
- Se incluyen las configuraciones en appsettings.json
2025-08-12 11:18:30 -03:00
5781713b13 Fix: Menú Reportes
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 7m12s
- Fix del menú de reportes que impedía el recorrido del mismo.
- Se quita la apertura predeterminada de una opción del menú de Reportes.
2025-08-12 10:33:36 -03:00
9f8d577265 Limpieza de Comantarios
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 7m59s
2025-08-11 15:44:16 -03:00
b594a48fde Feat: Se modifican visual de menú reportes
- Se limita la visual del menú de reportes a los usuarios según los permisos de acceso.
- Se soluciona bug en mensaje al ingresar usuario y/o clave inválidos.
2025-08-11 15:42:23 -03:00
2e7d1e36be Feat(suscripciones): Implementa manejo de pagos parciales en facturas
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 8m3s
Se introduce una refactorización completa del sistema de registro de pagos para manejar correctamente los abonos parciales, asegurando que el estado de la factura y el saldo pendiente se reflejen con precisión tanto en el backend como en la interfaz de usuario.

### 🐛 Problema Solucionado

- Anteriormente, el sistema no reconocía los pagos parciales. Una factura permanecía en estado "Pendiente" hasta que el monto total era cubierto, y la interfaz de usuario siempre mostraba el 100% del saldo como pendiente, lo cual era incorrecto y confuso.

###  Nuevas Características y Mejoras

- **Nuevo Estado de Factura "Pagada Parcialmente":**
    - Se introduce un nuevo estado para las facturas que han recibido uno o más pagos pero cuyo saldo aún no es cero.
    - El `PagoService` ahora actualiza el estado de la factura a "Pagada Parcialmente" cuando recibe un abono que no cubre el total.

- **Mejoras en la Interfaz de Usuario (`ConsultaFacturasPage`):**
    - **Nuevas Columnas:** Se han añadido las columnas "Pagado" y "Saldo" a la tabla de detalle de facturas, mostrando explícitamente el monto abonado y el restante.
    - **Visualización de Estado:** El `Chip` de estado ahora muestra "Pagada Parcialmente" con un color distintivo (azul/primary) para una rápida identificación visual.
    - **Cálculo de Saldo Correcto:** El saldo pendiente total por suscriptor y el saldo para el modal de pago manual ahora se calculan correctamente, restando el `totalPagado` del `importeFinal`.

### 🔄 Cambios en el Backend

- **`PagoService`:** Se actualizó la lógica para establecer el estado de la factura (`Pendiente`, `Pagada Parcialmente`, `Pagada`) basado en el `nuevoTotalPagado` después de registrar un pago.
- **`FacturacionService`:** El método `ObtenerResumenesDeCuentaPorPeriodo` ahora calcula correctamente el `SaldoPendienteTotal` y pasa la propiedad `TotalPagado` al DTO del frontend.
- **DTOs:** Se actualizó `FacturaConsolidadaDto` para incluir la propiedad `TotalPagado`.
2025-08-11 15:15:08 -03:00
dd2277fce2 Fix: Mail Host
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 9m57s
2025-08-11 14:35:02 -03:00
9412556fa8 Feat: Se añade seccion de permisos para Suscripciones
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 7m51s
- Se añade la lista de asignación de permisos de Suscripciones a la UI
- Se añade el permiso de acceso a los reportes de suscripciones
2025-08-11 12:36:51 -03:00
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
9e248efc84 Fix: CreatePromocionesDto
- Se separa el dto para el update del create.
2025-08-01 13:43:07 -03:00
84187a66df Feat: Implementa flujo completo de facturación y promociones
Este commit introduce la funcionalidad completa para la facturación mensual,
la gestión de promociones y la comunicación con el cliente en el módulo
de suscripciones.

Backend:
- Se añade el servicio de Facturación que calcula automáticamente los importes
  mensuales basándose en las suscripciones activas, días de entrega y precios.
- Se implementa el servicio DebitoAutomaticoService, capaz de generar el
  archivo de texto plano para "Pago Directo Galicia" y de procesar el
  archivo de respuesta para la conciliación de pagos.
- Se desarrolla el ABM completo para Promociones (Servicio, Repositorio,
  Controlador y DTOs), permitiendo la creación de descuentos por porcentaje
  o monto fijo.
- Se implementa la lógica para asignar y desasignar promociones a suscripciones
  específicas.
- Se añade un servicio de envío de email (EmailService) integrado con MailKit
  y un endpoint para notificar facturas a los clientes.
- Se crea la lógica para registrar pagos manuales (efectivo, tarjeta, etc.)
  y actualizar el estado de las facturas.
- Se añaden todos los permisos necesarios a la base de datos para
  segmentar el acceso a las nuevas funcionalidades.

Frontend:
- Se crea la página de Facturación, que permite al usuario seleccionar un
  período, generar la facturación, listar los resultados y generar el archivo
  de débito para el banco.
- Se implementa la funcionalidad para subir y procesar el archivo de
  respuesta del banco, actualizando la UI en consecuencia.
- Se añade la página completa para el ABM de Promociones.
- Se integra un modal en la gestión de suscripciones para asignar y
  desasignar promociones a un cliente.
- Se añade la opción "Enviar Email" en el menú de acciones de las facturas,
  conectada al nuevo endpoint del backend.
- Se completan y corrigen los componentes `PagoManualModal` y `FacturacionPage`
  para incluir la lógica de registro de pagos y solucionar errores de TypeScript.
2025-08-01 12:53:17 -03:00
135 changed files with 7902 additions and 691 deletions

View File

@@ -26,12 +26,6 @@ jobs:
set -e set -e
echo "--- INICIO DEL DESPLIEGUE OPTIMIZADO ---" echo "--- INICIO DEL DESPLIEGUE OPTIMIZADO ---"
# --- Asegurar que el Stack de la Base de Datos esté corriendo ---
echo "Asegurando que el stack de la base de datos esté activo..."
cd /opt/shared-services/database
# El comando 'up -d' es idempotente. Si ya está corriendo, no hace nada.
docker compose up -d
# 1. Preparar entorno # 1. Preparar entorno
TEMP_DIR=$(mktemp -d) TEMP_DIR=$(mktemp -d)
REPO_OWNER="dmolinari" REPO_OWNER="dmolinari"

3
.gitignore vendored
View File

@@ -19,9 +19,6 @@ lerna-debug.log*
# Variables de entorno # 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.local
.env.development.local .env.development.local
.env.test.local .env.test.local

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;
using GestionIntegral.Api.Dtos.Reportes.ViewModels; using GestionIntegral.Api.Dtos.Reportes.ViewModels;
using QuestPDF.Fluent; using QuestPDF.Fluent;
using QuestPDF.Helpers; using QuestPDF.Helpers;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq;
namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates 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 GestionIntegral.Api.Services.Reportes;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; 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.Dtos.Reportes;
using GestionIntegral.Api.Data.Repositories.Impresion; using GestionIntegral.Api.Data.Repositories.Impresion;
using System.IO;
using System.Linq;
using GestionIntegral.Api.Data.Repositories.Distribucion; using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Services.Distribucion; using GestionIntegral.Api.Services.Distribucion;
using GestionIntegral.Api.Services.Pdf; using GestionIntegral.Api.Services.Pdf;
@@ -45,6 +38,8 @@ namespace GestionIntegral.Api.Controllers
private const string PermisoVerReporteConsumoBobinas = "RR007"; private const string PermisoVerReporteConsumoBobinas = "RR007";
private const string PermisoVerReporteNovedadesCanillas = "RR004"; private const string PermisoVerReporteNovedadesCanillas = "RR004";
private const string PermisoVerReporteListadoDistMensual = "RR009"; private const string PermisoVerReporteListadoDistMensual = "RR009";
private const string PermisoVerReporteFacturasPublicidad = "RR010";
private const string PermisoVerReporteDistSuscripciones = "RR011";
public ReportesController( public ReportesController(
IReportesService reportesService, IReportesService reportesService,
@@ -1676,5 +1671,88 @@ namespace GestionIntegral.Api.Controllers
return StatusCode(500, "Error interno al generar el PDF del reporte."); 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

@@ -0,0 +1,93 @@
// Archivo: GestionIntegral.Api/Controllers/Suscripciones/DebitosController.cs
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Services.Suscripciones;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using System.Text;
namespace GestionIntegral.Api.Controllers.Suscripciones
{
[Route("api/debitos")]
[ApiController]
[Authorize]
public class DebitosController : ControllerBase
{
private readonly IDebitoAutomaticoService _debitoService;
private readonly ILogger<DebitosController> _logger;
// Permiso para generar archivos de débito (a crear en BD)
private const string PermisoGenerarDebitos = "SU007";
public DebitosController(IDebitoAutomaticoService debitoService, ILogger<DebitosController> logger)
{
_debitoService = debitoService;
_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;
}
// POST: api/debitos/{anio}/{mes}/generar-archivo
[HttpPost("{anio:int}/{mes:int}/generar-archivo")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GenerarArchivo(int anio, int mes)
{
if (!TienePermiso(PermisoGenerarDebitos)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (contenido, nombreArchivo, error) = await _debitoService.GenerarArchivoPagoDirecto(anio, mes, userId.Value);
if (error != null)
{
// Si el error es "No se encontraron facturas", es un 404. Otros son 400.
if (error.Contains("No se encontraron"))
{
return NotFound(new { message = error });
}
return BadRequest(new { message = error });
}
if (string.IsNullOrEmpty(contenido) || string.IsNullOrEmpty(nombreArchivo))
{
return StatusCode(500, new { message = "El servicio no pudo generar el contenido del archivo correctamente." });
}
// Devolver el archivo para descarga
var fileBytes = Encoding.UTF8.GetBytes(contenido);
return File(fileBytes, "text/plain", nombreArchivo);
}
// POST: api/debitos/procesar-respuesta
[HttpPost("procesar-respuesta")]
[ProducesResponseType(typeof(ProcesamientoLoteResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ProcesarArchivoRespuesta(IFormFile archivo)
{
// Usamos el mismo permiso de generar débitos para procesar la respuesta.
if (!TienePermiso(PermisoGenerarDebitos)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var resultado = await _debitoService.ProcesarArchivoRespuesta(archivo, userId.Value);
if (resultado.Errores.Any() && resultado.PagosAprobados == 0 && resultado.PagosRechazados == 0)
{
return BadRequest(resultado);
}
return Ok(resultado);
}
}
}

View File

@@ -0,0 +1,126 @@
using GestionIntegral.Api.Dtos.Comunicaciones;
using GestionIntegral.Api.Services.Comunicaciones;
using GestionIntegral.Api.Services.Suscripciones;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace GestionIntegral.Api.Controllers.Suscripciones
{
[Route("api/facturacion")]
[ApiController]
[Authorize]
public class FacturacionController : ControllerBase
{
private readonly IFacturacionService _facturacionService;
private readonly ILogger<FacturacionController> _logger;
private readonly IEmailLogService _emailLogService;
private const string PermisoGestionarFacturacion = "SU006";
private const string PermisoEnviarEmail = "SU009";
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);
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;
}
[HttpPut("{idFactura:int}/numero-factura")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> UpdateNumeroFactura(int idFactura, [FromBody] string numeroFactura)
{
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _facturacionService.ActualizarNumeroFactura(idFactura, numeroFactura, userId.Value);
if (!exito)
{
if (error != null && error.Contains("no existe")) return NotFound(new { message = error });
return BadRequest(new { message = error });
}
return NoContent();
}
[HttpPost("{idFactura:int}/enviar-factura-pdf")]
public async Task<IActionResult> EnviarFacturaPdf(int 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 });
}
var mensajeExito = $"El email con la factura PDF se ha enviado correctamente a {emailDestino}.";
return Ok(new { message = mensajeExito });
}
[HttpGet("{anio:int}/{mes:int}")]
public async Task<IActionResult> GetFacturas(
int anio, int mes,
[FromQuery] string? nombreSuscriptor,
[FromQuery] string? estadoPago,
[FromQuery] string? estadoFacturacion,
[FromQuery] string? tipoFactura)
{
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." });
var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura);
return Ok(resumenes);
}
[HttpPost("{anio:int}/{mes:int}")]
public async Task<IActionResult> GenerarFacturacion(int anio, int mes)
{
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El año y el mes proporcionados no son válidos." });
var (exito, mensaje, resultadoEnvio) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje });
return Ok(new { message = mensaje, resultadoEnvio });
}
[HttpGet("historial-lotes-envio")]
[ProducesResponseType(typeof(IEnumerable<LoteDeEnvioHistorialDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistorialLotesEnvio([FromQuery] int? anio, [FromQuery] int? mes)
{
if (!TienePermiso("SU006")) return Forbid();
var historial = await _facturacionService.ObtenerHistorialLotesEnvio(anio, mes);
return Ok(historial);
}
// Endpoint para el historial de envíos de una factura individual
[HttpGet("{idFactura:int}/historial-envios")]
[ProducesResponseType(typeof(IEnumerable<EmailLogDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistorialEnvios(int idFactura)
{
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); // Reutilizamos el permiso
// Construimos la referencia que se guarda en el log
string referencia = $"Factura-{idFactura}";
var historial = await _emailLogService.ObtenerHistorialPorReferencia(referencia);
return Ok(historial);
}
}
}

View File

@@ -0,0 +1,69 @@
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/pagos")]
[ApiController]
[Authorize]
public class PagosController : ControllerBase
{
private readonly IPagoService _pagoService;
private readonly ILogger<PagosController> _logger;
// Permiso para registrar pagos manuales (a crear en BD)
private const string PermisoRegistrarPago = "SU008";
public PagosController(IPagoService pagoService, ILogger<PagosController> logger)
{
_pagoService = pagoService;
_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/facturas/{idFactura}/pagos
[HttpGet("~/api/facturas/{idFactura:int}/pagos")]
[ProducesResponseType(typeof(IEnumerable<PagoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetPagosPorFactura(int idFactura)
{
// Se podría usar un permiso de "Ver Facturación"
if (!TienePermiso("SU006")) return Forbid();
var pagos = await _pagoService.ObtenerPagosPorFacturaId(idFactura);
return Ok(pagos);
}
// POST: api/pagos
[HttpPost]
[ProducesResponseType(typeof(PagoDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> RegistrarPago([FromBody] CreatePagoDto createDto)
{
if (!TienePermiso(PermisoRegistrarPago)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (dto, error) = await _pagoService.RegistrarPagoManual(createDto, userId.Value);
if (error != null) return BadRequest(new { message = error });
if (dto == null) return StatusCode(StatusCodes.Status500InternalServerError, "Error al registrar el pago.");
// No tenemos un "GetById" para pagos, así que devolvemos el objeto con un 201.
return StatusCode(201, dto);
}
}
}

View File

@@ -0,0 +1,90 @@
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/promociones")]
[ApiController]
[Authorize]
public class PromocionesController : ControllerBase
{
private readonly IPromocionService _promocionService;
private readonly ILogger<PromocionesController> _logger;
// Permiso a crear en BD
private const string PermisoGestionarPromociones = "SU010";
public PromocionesController(IPromocionService promocionService, ILogger<PromocionesController> logger)
{
_promocionService = promocionService;
_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/promociones
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool soloActivas = true)
{
if (!TienePermiso(PermisoGestionarPromociones)) return Forbid();
var promociones = await _promocionService.ObtenerTodas(soloActivas);
return Ok(promociones);
}
// GET: api/promociones/{id}
[HttpGet("{id:int}", Name = "GetPromocionById")]
public async Task<IActionResult> GetById(int id)
{
if (!TienePermiso(PermisoGestionarPromociones)) return Forbid();
var promocion = await _promocionService.ObtenerPorId(id);
if (promocion == null) return NotFound();
return Ok(promocion);
}
// POST: api/promociones
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreatePromocionDto createDto)
{
if (!TienePermiso(PermisoGestionarPromociones)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (dto, error) = await _promocionService.Crear(createDto, userId.Value);
if (error != null) return BadRequest(new { message = error });
if (dto == null) return StatusCode(500, "Error al crear la promoción.");
return CreatedAtRoute("GetPromocionById", new { id = dto.IdPromocion }, dto);
}
// PUT: api/promociones/{id}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdatePromocionDto updateDto)
{
if (!TienePermiso(PermisoGestionarPromociones)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _promocionService.Actualizar(id, updateDto, userId.Value);
if (!exito)
{
if (error != null && error.Contains("no encontrada")) return NotFound(new { message = error });
return BadRequest(new { message = error });
}
return NoContent();
}
}
}

View File

@@ -93,5 +93,46 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
} }
return NoContent(); return NoContent();
} }
// GET: api/suscripciones/{idSuscripcion}/promociones
[HttpGet("{idSuscripcion:int}/promociones")]
public async Task<IActionResult> GetPromocionesAsignadas(int idSuscripcion)
{
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
var promos = await _suscripcionService.ObtenerPromocionesAsignadas(idSuscripcion);
return Ok(promos);
}
// GET: api/suscripciones/{idSuscripcion}/promociones-disponibles
[HttpGet("{idSuscripcion:int}/promociones-disponibles")]
public async Task<IActionResult> GetPromocionesDisponibles(int idSuscripcion)
{
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
var promos = await _suscripcionService.ObtenerPromocionesDisponibles(idSuscripcion);
return Ok(promos);
}
// 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, dto, userId.Value);
if (!exito) return BadRequest(new { message = error });
return Ok();
}
// DELETE: api/suscripciones/{idSuscripcion}/promociones/{idPromocion}
[HttpDelete("{idSuscripcion:int}/promociones/{idPromocion:int}")]
public async Task<IActionResult> QuitarPromocion(int idSuscripcion, int idPromocion)
{
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
var (exito, error) = await _suscripcionService.QuitarPromocion(idSuscripcion, idPromocion);
if (!exito) return BadRequest(new { message = error });
return NoContent();
}
} }
} }

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<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla);
Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
Task<IEnumerable<ListadoDistCanMensualPubDto>> GetReporteMensualPorPublicacionAsync(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 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 Dapper;
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data; using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
@@ -19,7 +24,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<Factura?> GetByIdAsync(int idFactura) 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(); using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idFactura }); 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 }); 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) if (transaction == null || transaction.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
} }
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) public async Task<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction)
@@ -47,26 +59,27 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
} }
const string sqlInsert = @" const string sqlInsert = @"
INSERT INTO dbo.susc_Facturas INSERT INTO dbo.susc_Facturas
(IdSuscripcion, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,
DescuentoAplicado, ImporteFinal, Estado) DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion, TipoFactura)
OUTPUT INSERTED.* OUTPUT INSERTED.*
VALUES VALUES
(@IdSuscripcion, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,
@DescuentoAplicado, @ImporteFinal, @Estado);"; @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion, @TipoFactura);";
return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); 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) if (transaction == null || transaction.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
} }
const string sql = "UPDATE dbo.susc_Facturas SET Estado = @NuevoEstado WHERE IdFactura = @IdFactura;"; const string sql = "UPDATE dbo.susc_Facturas SET EstadoPago = @NuevoEstadoPago WHERE IdFactura = @IdFactura;";
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstado = nuevoEstado, IdFactura = idFactura }, transaction); var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, idFactura }, transaction);
return rowsAffected == 1; return rowsAffected == 1;
} }
@@ -76,8 +89,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); 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;"; const string sql = @"
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, IdFactura = idFactura }, transaction); 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; return rowsAffected == 1;
} }
@@ -87,9 +104,168 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
} }
const string 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); var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdLoteDebito = idLoteDebito, IdsFacturas = idsFacturas }, transaction);
return rowsAffected == idsFacturas.Count(); return rowsAffected == idsFacturas.Count();
} }
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(
string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura)
{
var sqlBuilder = new StringBuilder(@"
WITH FacturaConEmpresa AS (
-- Esta subconsulta obtiene el IdEmpresa para cada factura basándose en la primera suscripción que encuentra en sus detalles.
-- Esto es seguro porque nuestra lógica de negocio asegura que todos los detalles de una factura pertenecen a la misma empresa.
SELECT
f.IdFactura,
(SELECT TOP 1 p.Id_Empresa
FROM dbo.susc_FacturaDetalles fd
JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
WHERE fd.IdFactura = f.IdFactura) AS IdEmpresa
FROM dbo.susc_Facturas f
WHERE f.Periodo = @Periodo
)
SELECT
f.*,
s.NombreCompleto AS NombreSuscriptor,
fce.IdEmpresa,
(SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos pg WHERE pg.IdFactura = f.IdFactura AND pg.Estado = 'Aprobado') AS TotalPagado
FROM dbo.susc_Facturas f
JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor
JOIN FacturaConEmpresa fce ON f.IdFactura = fce.IdFactura
WHERE f.Periodo = @Periodo");
var parameters = new DynamicParameters();
parameters.Add("Periodo", periodo);
if (!string.IsNullOrWhiteSpace(nombreSuscriptor))
{
sqlBuilder.Append(" AND s.NombreCompleto LIKE @NombreSuscriptor");
parameters.Add("NombreSuscriptor", $"%{nombreSuscriptor}%");
}
if (!string.IsNullOrWhiteSpace(estadoPago))
{
sqlBuilder.Append(" AND f.EstadoPago = @EstadoPago");
parameters.Add("EstadoPago", estadoPago);
}
if (!string.IsNullOrWhiteSpace(estadoFacturacion))
{
sqlBuilder.Append(" AND f.EstadoFacturacion = @EstadoFacturacion");
parameters.Add("EstadoFacturacion", estadoFacturacion);
}
if (!string.IsNullOrWhiteSpace(tipoFactura))
{
sqlBuilder.Append(" AND f.TipoFactura = @TipoFactura");
parameters.Add("TipoFactura", tipoFactura);
}
sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;");
try
{
using var connection = _connectionFactory.CreateConnection();
var result = await connection.QueryAsync<Factura, string, 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, int, decimal)>();
}
}
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
EstadoPago = @NuevoEstadoPago,
MotivoRechazo = @MotivoRechazo
WHERE IdFactura = @IdFactura;";
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,11 +6,19 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public interface IFacturaRepository public interface IFacturaRepository
{ {
Task<Factura?> GetByIdAsync(int idFactura); Task<Factura?> GetByIdAsync(int idFactura);
Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids);
Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo); 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<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> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction);
Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction);
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(
string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura);
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<IEnumerable<Pago>> GetByFacturaIdAsync(int idFactura);
Task<Pago?> CreateAsync(Pago nuevoPago, IDbTransaction transaction); Task<Pago?> CreateAsync(Pago nuevoPago, IDbTransaction transaction);
Task<decimal> GetTotalPagadoAprobadoAsync(int idFactura, IDbTransaction transaction);
} }
} }

View File

@@ -0,0 +1,15 @@
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public interface IPromocionRepository
{
Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas);
Task<Promocion?> GetByIdAsync(int id);
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,10 +5,13 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
public interface ISuscripcionRepository public interface ISuscripcionRepository
{ {
Task<IEnumerable<Suscripcion>> GetBySuscriptorIdAsync(int idSuscriptor);
Task<Suscripcion?> GetByIdAsync(int idSuscripcion); 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<Suscripcion?> CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction);
Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction); Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction);
Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, 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; 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

@@ -0,0 +1,118 @@
using Dapper;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
using System.Text;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public class PromocionRepository : IPromocionRepository
{
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<PromocionRepository> _logger;
public PromocionRepository(DbConnectionFactory factory, ILogger<PromocionRepository> logger)
{
_connectionFactory = factory;
_logger = logger;
}
public async Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas)
{
var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones");
if (soloActivas)
{
sql.Append(" WHERE Activa = 1");
}
sql.Append(" ORDER BY FechaInicio DESC;");
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Promocion>(sql.ToString());
}
public async Task<Promocion?> GetByIdAsync(int id)
{
const string sql = "SELECT * FROM dbo.susc_Promociones WHERE IdPromocion = @Id;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<Promocion>(sql, new { Id = id });
}
public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction)
{
const string sql = @"
INSERT INTO dbo.susc_Promociones
(Descripcion, TipoEfecto, ValorEfecto, TipoCondicion, ValorCondicion,
FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta)
OUTPUT INSERTED.*
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.");
}
return await transaction.Connection.QuerySingleAsync<Promocion>(sql, nuevaPromocion, transaction);
}
public async Task<bool> UpdateAsync(Promocion promocion, IDbTransaction transaction)
{
const string sql = @"
UPDATE dbo.susc_Promociones SET
Descripcion = @Descripcion,
TipoPromocion = @TipoPromocion,
Valor = @Valor,
FechaInicio = @FechaInicio,
FechaFin = @FechaFin,
Activa = @Activa
WHERE IdPromocion = @IdPromocion;";
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, promocion, transaction);
return rows == 1;
}
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
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);";
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) 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 year = int.Parse(periodo.Split('-')[0]);
var month = int.Parse(periodo.Split('-')[1]); var month = int.Parse(periodo.Split('-')[1]);
var primerDiaMes = new DateTime(year, month, 1); var primerDiaMes = new DateTime(year, month, 1);
@@ -111,5 +110,47 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
var rowsAffected = await transaction.Connection.ExecuteAsync(sqlUpdate, suscripcion, transaction); var rowsAffected = await transaction.Connection.ExecuteAsync(sqlUpdate, suscripcion, transaction);
return rowsAffected == 1; return rowsAffected == 1;
} }
public async Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion)
{
const string sql = @"
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();
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(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, VigenciaDesde, VigenciaHasta, FechaAsignacion)
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());";
await transaction.Connection.ExecuteAsync(sql, asignacion, transaction);
}
public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction)
{
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 = "DELETE FROM dbo.susc_SuscripcionPromociones WHERE IdSuscripcion = @IdSuscripcion AND IdPromocion = @IdPromocion;";
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.Models.Usuarios; // Para Usuario
using GestionIntegral.Api.Dtos.Usuarios.Auditoria; using GestionIntegral.Api.Dtos.Usuarios.Auditoria;
using System.Collections.Generic; using System.Collections.Generic;
@@ -10,6 +12,7 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
{ {
Task<IEnumerable<Usuario>> GetAllAsync(string? userFilter, string? nombreFilter); Task<IEnumerable<Usuario>> GetAllAsync(string? userFilter, string? nombreFilter);
Task<Usuario?> GetByIdAsync(int id); 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?> 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<Usuario?> CreateAsync(Usuario nuevoUsuario, int idUsuarioCreador, IDbTransaction transaction);
Task<bool> UpdateAsync(Usuario usuarioAActualizar, int idUsuarioModificador, 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> DeleteAsync(int id, int idUsuarioModificador, IDbTransaction transaction);
Task<bool> SetPasswordAsync(int userId, string newHash, string newSalt, bool debeCambiarClave, 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); 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<IEnumerable<(Usuario Usuario, string NombrePerfil)>> GetAllWithProfileNameAsync(string? userFilter, string? nombreFilter);
Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id); Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id);
Task<IEnumerable<UsuarioHistorialDto>> GetHistorialByUsuarioIdAsync(int idUsuarioAfectado, DateTime? fechaDesde, DateTime? fechaHasta); Task<IEnumerable<UsuarioHistorialDto>> GetHistorialByUsuarioIdAsync(int idUsuarioAfectado, DateTime? fechaDesde, DateTime? fechaHasta);

View File

@@ -1,12 +1,8 @@
using Dapper; using Dapper;
using GestionIntegral.Api.Models.Usuarios; using GestionIntegral.Api.Models.Usuarios;
using GestionIntegral.Api.Dtos.Usuarios.Auditoria; using GestionIntegral.Api.Dtos.Usuarios.Auditoria;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Data; using System.Data;
using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Usuarios namespace GestionIntegral.Api.Data.Repositories.Usuarios
{ {
@@ -88,7 +84,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
} }
} }
public async Task<Usuario?> GetByIdAsync(int id) public async Task<Usuario?> GetByIdAsync(int id)
{ {
const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id = @Id"; const string sql = "SELECT * FROM dbo.gral_Usuarios WHERE Id = @Id";
@@ -103,6 +98,33 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
return null; 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) public async Task<(Usuario? Usuario, string? NombrePerfil)> GetByIdWithProfileNameAsync(int id)
{ {
const string sql = @" const string sql = @"
@@ -128,7 +150,6 @@ namespace GestionIntegral.Api.Data.Repositories.Usuarios
} }
} }
public async Task<Usuario?> GetByUsernameAsync(string username) public async Task<Usuario?> GetByUsernameAsync(string username)
{ {
// Esta es la misma que en AuthRepository, si se unifican, se puede eliminar una. // Esta es la misma que en AuthRepository, si se unifican, se puede eliminar una.

View File

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

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,12 @@
namespace GestionIntegral.Api.Models.Comunicaciones
{
public class MailSettings
{
public string SmtpHost { get; set; } = string.Empty;
public int SmtpPort { get; set; }
public string SenderName { get; set; } = string.Empty;
public string SenderEmail { get; set; } = string.Empty;
public string SmtpUser { get; set; } = string.Empty;
public string SmtpPass { get; set; } = string.Empty;
}
}

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

@@ -0,0 +1,29 @@
// Archivo: GestionIntegral.Api/Dtos/Suscripciones/CreatePagoDto.cs
using System;
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class CreatePagoDto
{
[Required]
public int IdFactura { get; set; }
[Required]
public DateTime FechaPago { get; set; }
[Required(ErrorMessage = "Debe seleccionar una forma de pago.")]
public int IdFormaPago { get; set; }
[Required(ErrorMessage = "El monto es obligatorio.")]
[Range(0.01, 99999999.99, ErrorMessage = "El monto debe ser un valor positivo.")]
public decimal Monto { get; set; }
[StringLength(100)]
public string? Referencia { get; set; } // Nro. de comprobante, etc.
[StringLength(250)]
public string? Observaciones { get; set; }
}
}

View File

@@ -0,0 +1,31 @@
// Archivo: GestionIntegral.Api/Dtos/Suscripciones/CreatePromocionDto.cs
using System;
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class CreatePromocionDto
{
[Required]
[StringLength(200)]
public string Descripcion { get; set; } = string.Empty;
[Required]
public string TipoEfecto { get; set; } = string.Empty; // Corregido
[Required]
[Range(0, 99999999.99)] // Se permite 0 para bonificaciones
public decimal ValorEfecto { get; set; } // Corregido
[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; using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones namespace GestionIntegral.Api.Dtos.Suscripciones
@@ -13,6 +15,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
public string? Email { get; set; } public string? Email { get; set; }
[StringLength(50)] [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; } public string? Telefono { get; set; }
[Required(ErrorMessage = "La dirección es obligatoria.")] [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.")] [Required(ErrorMessage = "El número de documento es obligatorio.")]
[StringLength(11)] [StringLength(11)]
[RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")]
public string NroDocumento { get; set; } = string.Empty; public string NroDocumento { get; set; } = string.Empty;
[StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")] [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; } public string? CBU { get; set; }
[Required(ErrorMessage = "La forma de pago es obligatoria.")] [Required(ErrorMessage = "La forma de pago es obligatoria.")]

View File

@@ -0,0 +1,16 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class FacturaConsolidadaDto
{
public int IdFactura { get; set; }
public string NombreEmpresa { get; set; } = string.Empty;
public decimal ImporteFinal { get; set; }
public string EstadoPago { get; set; } = string.Empty;
public string EstadoFacturacion { get; set; } = string.Empty;
public string? NumeroFactura { get; set; }
public decimal TotalPagado { get; set; }
public string TipoFactura { get; set; } = string.Empty;
public int IdSuscriptor { get; set; }
public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
}
}

View File

@@ -0,0 +1,25 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
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 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 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; }
public string NombreSuscriptor { get; set; } = string.Empty;
public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
}
}

View File

@@ -0,0 +1,17 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class PagoDto
{
public int IdPago { get; set; }
public int IdFactura { get; set; }
public string FechaPago { get; set; } = string.Empty; // "yyyy-MM-dd"
public int IdFormaPago { get; set; }
public string NombreFormaPago { get; set; } = string.Empty; // Enriquecido
public decimal Monto { get; set; }
public string Estado { get; set; } = string.Empty; // "Aprobado", "Rechazado"
public string? Referencia { get; set; }
public string? Observaciones { get; set; }
public int IdUsuarioRegistro { get; set; }
public string NombreUsuarioRegistro { get; set; } = string.Empty; // Enriquecido
}
}

View File

@@ -0,0 +1,11 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class ProcesamientoLoteResponseDto
{
public int TotalRegistrosLeidos { get; set; }
public int PagosAprobados { get; set; }
public int PagosRechazados { get; set; }
public List<string> Errores { get; set; } = new List<string>();
public string MensajeResumen { get; set; } = string.Empty;
}
}

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

@@ -0,0 +1,15 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class PromocionDto
{
public int IdPromocion { get; set; }
public string Descripcion { get; set; } = string.Empty;
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

@@ -0,0 +1,15 @@
// Archivo: GestionIntegral.Api/Dtos/Suscripciones/UpdatePromocionDto.cs
namespace GestionIntegral.Api.Dtos.Suscripciones
{
/// <summary>
/// DTO para actualizar una promoción. Hereda todas las propiedades y validaciones
/// de CreatePromocionDto, ya que por ahora son idénticas.
/// </summary>
public class UpdatePromocionDto : CreatePromocionDto
{
// No se necesitan propiedades adicionales por el momento.
// Si en el futuro se necesitara una validación diferente para la actualización,
// se podrían añadir o sobrescribir propiedades aquí.
}
}

View File

@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones 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 public class UpdateSuscriptorDto
{ {
[Required(ErrorMessage = "El nombre completo es obligatorio.")] [Required(ErrorMessage = "El nombre completo es obligatorio.")]
@@ -14,6 +13,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
public string? Email { get; set; } public string? Email { get; set; }
[StringLength(50)] [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; } public string? Telefono { get; set; }
[Required(ErrorMessage = "La dirección es obligatoria.")] [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.")] [Required(ErrorMessage = "El número de documento es obligatorio.")]
[StringLength(11)] [StringLength(11)]
[RegularExpression("^[0-9]*$", ErrorMessage = "El número de documento solo puede contener números.")]
public string NroDocumento { get; set; } = string.Empty; public string NroDocumento { get; set; } = string.Empty;
[StringLength(22, MinimumLength = 22, ErrorMessage = "El CBU debe tener 22 dígitos.")] [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; } public string? CBU { get; set; }
[Required(ErrorMessage = "La forma de pago es obligatoria.")] [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,16 +3,18 @@ namespace GestionIntegral.Api.Models.Suscripciones
public class Factura public class Factura
{ {
public int IdFactura { get; set; } public int IdFactura { get; set; }
public int IdSuscripcion { get; set; } public int IdSuscriptor { get; set; }
public string Periodo { get; set; } = string.Empty; public string Periodo { get; set; } = string.Empty;
public DateTime FechaEmision { get; set; } public DateTime FechaEmision { get; set; }
public DateTime FechaVencimiento { get; set; } public DateTime FechaVencimiento { get; set; }
public decimal ImporteBruto { get; set; } public decimal ImporteBruto { get; set; }
public decimal DescuentoAplicado { get; set; } public decimal DescuentoAplicado { get; set; }
public decimal ImporteFinal { 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 string? NumeroFactura { get; set; }
public int? IdLoteDebito { get; set; } public int? IdLoteDebito { get; set; }
public string? MotivoRechazo { get; set; } public string? MotivoRechazo { get; set; }
public string TipoFactura { get; set; } = string.Empty;
} }
} }

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

@@ -0,0 +1,17 @@
namespace GestionIntegral.Api.Models.Suscripciones
{
public class Promocion
{
public int IdPromocion { get; set; }
public string Descripcion { get; set; } = string.Empty;
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; }
public int IdUsuarioAlta { get; set; }
public DateTime FechaAlta { get; set; }
}
}

View File

@@ -0,0 +1,12 @@
namespace GestionIntegral.Api.Models.Suscripciones
{
public class SuscripcionPromocion
{
public int IdSuscripcion { get; set; }
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

@@ -20,6 +20,9 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
using GestionIntegral.Api.Services.Anomalia; using GestionIntegral.Api.Services.Anomalia;
using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Services.Suscripciones; using GestionIntegral.Api.Services.Suscripciones;
using GestionIntegral.Api.Models.Comunicaciones;
using GestionIntegral.Api.Services.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -103,18 +106,31 @@ builder.Services.AddScoped<IQuestPdfGenerator, QuestPdfGenerator>();
builder.Services.AddScoped<IAlertaService, AlertaService>(); builder.Services.AddScoped<IAlertaService, AlertaService>();
// --- Suscripciones --- // --- Suscripciones ---
// Repositorios
builder.Services.AddScoped<IFormaPagoRepository, FormaPagoRepository>(); builder.Services.AddScoped<IFormaPagoRepository, FormaPagoRepository>();
builder.Services.AddScoped<ISuscriptorRepository, SuscriptorRepository>(); builder.Services.AddScoped<ISuscriptorRepository, SuscriptorRepository>();
builder.Services.AddScoped<ISuscripcionRepository, SuscripcionRepository>(); builder.Services.AddScoped<ISuscripcionRepository, SuscripcionRepository>();
builder.Services.AddScoped<IFacturaRepository, FacturaRepository>(); builder.Services.AddScoped<IFacturaRepository, FacturaRepository>();
builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>(); builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>();
builder.Services.AddScoped<IPagoRepository, PagoRepository>(); builder.Services.AddScoped<IPagoRepository, PagoRepository>();
builder.Services.AddScoped<IPromocionRepository, PromocionRepository>();
builder.Services.AddScoped<IAjusteRepository, AjusteRepository>();
builder.Services.AddScoped<IFacturaDetalleRepository, FacturaDetalleRepository>();
// Servicios
builder.Services.AddScoped<IFormaPagoService, FormaPagoService>(); builder.Services.AddScoped<IFormaPagoService, FormaPagoService>();
builder.Services.AddScoped<ISuscriptorService, SuscriptorService>(); builder.Services.AddScoped<ISuscriptorService, SuscriptorService>();
builder.Services.AddScoped<ISuscripcionService, SuscripcionService>(); builder.Services.AddScoped<ISuscripcionService, SuscripcionService>();
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 --- // --- SERVICIO DE HEALTH CHECKS ---
// Añadimos una comprobación específica para SQL Server. // 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

@@ -0,0 +1,149 @@
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
using GestionIntegral.Api.Models.Comunicaciones;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace GestionIntegral.Api.Services.Comunicaciones
{
public class EmailService : IEmailService
{
private readonly MailSettings _mailSettings;
private readonly ILogger<EmailService> _logger;
private readonly IEmailLogRepository _emailLogRepository;
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,
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);
email.From.Add(email.Sender);
email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail));
email.Subject = asunto;
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
if (attachment != null && !string.IsNullOrEmpty(attachmentName))
{
builder.Attachments.Add(attachmentName, attachment, ContentType.Parse("application/pdf"));
}
email.Body = builder.ToMessageBody();
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
}
public async Task EnviarEmailConsolidadoAsync(
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
List<(byte[] content, string name)> adjuntos,
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null,
int? idLoteDeEnvio = null)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
email.From.Add(email.Sender);
email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail));
email.Subject = asunto;
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
if (adjuntos != null)
{
foreach (var adjunto in adjuntos)
{
builder.Attachments.Add(adjunto.name, adjunto.content, ContentType.Parse("application/pdf"));
}
}
email.Body = builder.ToMessageBody();
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
}
private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo, int? idLoteDeEnvio)
{
var destinatario = emailMessage.To.Mailboxes.FirstOrDefault()?.Address ?? "desconocido";
var log = new EmailLog
{
FechaEnvio = DateTime.Now,
DestinatarioEmail = destinatario,
Asunto = emailMessage.Subject,
Origen = origen,
ReferenciaId = referenciaId,
IdUsuarioDisparo = idUsuarioDisparo,
IdLoteDeEnvio = idLoteDeEnvio
};
using var smtp = new SmtpClient();
try
{
// Se añade una política de validación de certificado personalizada.
// Esto es necesario para entornos de desarrollo o redes internas donde
// el nombre del host al que nos conectamos (ej. una IP) no coincide
// con el nombre en el certificado SSL (ej. mail.eldia.com).
smtp.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
{
// Si no hay errores, el certificado es válido.
if (sslPolicyErrors == SslPolicyErrors.None)
return true;
// Si el único error es que el nombre no coincide (RemoteCertificateNameMismatch)
// Y el certificado es el que esperamos (emitido para "mail.eldia.com"),
// entonces lo aceptamos como válido.
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch) && certificate != null && certificate.Subject.Contains("CN=mail.eldia.com"))
{
_logger.LogWarning("Se aceptó un certificado SSL con 'Name Mismatch' para el host de confianza 'mail.eldia.com'.");
return true;
}
// Para cualquier otro error, rechazamos el certificado.
_logger.LogError("Error de validación de certificado SSL: {Errors}", sslPolicyErrors);
return false;
};
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
await smtp.SendAsync(emailMessage);
log.Estado = "Enviado";
_logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
}
catch (Exception ex)
{
_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

@@ -0,0 +1,55 @@
namespace GestionIntegral.Api.Services.Comunicaciones
{
public interface IEmailService
{
/// <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, IEnumerable<SaldoDto> Saldos,
string? Error string? Error
)> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta); )> 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<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<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<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.Reportes;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Reportes; 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 namespace GestionIntegral.Api.Services.Reportes
{ {
public class ReportesService : IReportesService public class ReportesService : IReportesService
{ {
private readonly IReportesRepository _reportesRepository; 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; 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; _reportesRepository = reportesRepository;
_facturaRepository = facturaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_publicacionRepository = publicacionRepository;
_empresaRepository = empresaRepository;
_suscriptorRepository = suscriptorRepository;
_suscripcionRepository = suscripcionRepository;
_logger = logger; _logger = logger;
} }
@@ -520,5 +530,49 @@ namespace GestionIntegral.Api.Services.Reportes
return (Enumerable.Empty<ListadoDistCanMensualPubDto>(), "Error al obtener datos del reporte (por publicación)."); 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

@@ -0,0 +1,301 @@
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
using System.Text;
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class DebitoAutomaticoService : IDebitoAutomaticoService
{
private readonly IFacturaRepository _facturaRepository;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly ILoteDebitoRepository _loteDebitoRepository;
private readonly IFormaPagoRepository _formaPagoRepository;
private readonly IPagoRepository _pagoRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<DebitoAutomaticoService> _logger;
private const string NRO_PRESTACION = "26435"; // Reemplazar por el número real
private const string ORIGEN_EMPRESA = "EMPRESA";
public DebitoAutomaticoService(
IFacturaRepository facturaRepository,
ISuscriptorRepository suscriptorRepository,
ILoteDebitoRepository loteDebitoRepository,
IFormaPagoRepository formaPagoRepository,
IPagoRepository pagoRepository,
DbConnectionFactory connectionFactory,
ILogger<DebitoAutomaticoService> logger)
{
_facturaRepository = facturaRepository;
_suscriptorRepository = suscriptorRepository;
_loteDebitoRepository = loteDebitoRepository;
_formaPagoRepository = formaPagoRepository;
_pagoRepository = pagoRepository;
_connectionFactory = connectionFactory;
_logger = logger;
}
public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario)
{
// Este número debe ser gestionado para no repetirse. Por ahora, lo mantenemos como 1.
const int identificacionArchivo = 1;
var periodo = $"{anio}-{mes:D2}";
var fechaGeneracion = DateTime.Now;
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
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.");
}
var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal);
var cantidadRegistros = facturasParaDebito.Count();
var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt";
var nuevoLote = new LoteDebito
{
Periodo = periodo,
NombreArchivo = nombreArchivo,
ImporteTotal = importeTotal,
CantidadRegistros = cantidadRegistros,
IdUsuarioGeneracion = idUsuario
};
var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction);
if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito.");
var sb = new StringBuilder();
sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
foreach (var item in facturasParaDebito)
{
sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor));
}
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
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.");
transaction.Commit();
_logger.LogInformation("Archivo de débito {NombreArchivo} generado exitosamente para el período {Periodo}.", nombreArchivo, periodo);
return (sb.ToString(), nombreArchivo, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error crítico al generar el archivo de débito para el período {Periodo}", periodo);
return (null, null, $"Error interno: {ex.Message}");
}
}
private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction)
{
var facturas = await _facturaRepository.GetByPeriodoAsync(periodo);
var resultado = new List<(Factura, Suscriptor)>();
// Filtramos por estado Y POR TIPO DE FACTURA
foreach (var f in facturas.Where(fa =>
(fa.EstadoPago == "Pendiente" || fa.EstadoPago == "Pagada Parcialmente" || fa.EstadoPago == "Rechazada") &&
fa.TipoFactura == "Mensual"
))
{
var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor);
if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22)
{
_logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", f.IdSuscriptor);
continue;
}
var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida);
if (formaPago != null && formaPago.RequiereCBU)
{
resultado.Add((f, suscriptor));
}
}
return resultado;
}
private string ConvertirCbuBanelcoASnp(string cbu22)
{
if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) return "".PadRight(26);
try
{
string bloque1 = cbu22.Substring(0, 8);
string bloque2 = cbu22.Substring(8);
return $"0{bloque1}000{bloque2}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al parsear y convertir CBU de 22 dígitos: {CBU}", cbu22);
return "".PadRight(26);
}
}
// --- Helpers de Formateo ---
private string FormatString(string? value, int length) => (value ?? "").PadRight(length);
private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0');
private string FormatNumericString(string? value, int length) => (value ?? "").PadLeft(length, '0');
private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch
{
"DNI" => "0096",
"CUIT" => "0080",
"CUIL" => "0086",
"LE" => "0089",
"LC" => "0090",
_ => "0000"
};
private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
{
var sb = new StringBuilder();
sb.Append("00");
sb.Append(FormatNumericString(NRO_PRESTACION, 6));
sb.Append("C");
sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
sb.Append(FormatString(identificacionArchivo.ToString(), 1));
sb.Append(FormatString(ORIGEN_EMPRESA, 7));
sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
sb.Append(FormatNumeric(cantidadRegistros, 7));
sb.Append(FormatString("", 304));
sb.Append("\r\n");
return sb.ToString();
}
private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor)
{
string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!);
var sb = new StringBuilder();
sb.Append("0370");
sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22));
sb.Append(cbu26);
sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15));
sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd"));
sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14));
sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento
sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento
sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento
sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento
sb.Append("0");
sb.Append(FormatString("", 3));
sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4));
sb.Append(FormatNumericString(suscriptor.NroDocumento, 11));
sb.Append(FormatString("", 22));
sb.Append(FormatString("", 26));
sb.Append(FormatNumeric(0, 14));
sb.Append(FormatNumeric(0, 8));
sb.Append(FormatString("", 22));
sb.Append(FormatString("", 40));
sb.Append(FormatString($"Susc.{factura.Periodo}", 10));
sb.Append(FormatNumeric(0, 8));
sb.Append(FormatNumeric(0, 14));
sb.Append(FormatNumeric(0, 8));
sb.Append(FormatString("", 26));
sb.Append("\r\n");
return sb.ToString();
}
private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
{
var sb = new StringBuilder();
sb.Append("99");
sb.Append(FormatNumericString(NRO_PRESTACION, 6));
sb.Append("C");
sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
sb.Append(FormatString(identificacionArchivo.ToString(), 1));
sb.Append(FormatString(ORIGEN_EMPRESA, 7));
sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
sb.Append(FormatNumeric(cantidadRegistros, 7));
sb.Append(FormatString("", 304));
sb.Append("\r\n");
return sb.ToString();
}
public async Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario)
{
// Se mantiene la lógica original para procesar el archivo de respuesta del banco.
var respuesta = new ProcesamientoLoteResponseDto();
if (archivo == null || archivo.Length == 0)
{
respuesta.Errores.Add("No se proporcionó ningún archivo o el archivo está vacío.");
return respuesta;
}
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
using var reader = new StreamReader(archivo.OpenReadStream());
string? linea;
while ((linea = await reader.ReadLineAsync()) != null)
{
if (linea.Length < 20) continue;
respuesta.TotalRegistrosLeidos++;
var referencia = linea.Substring(0, 15).Trim();
var estadoProceso = linea.Substring(15, 2).Trim();
var motivoRechazo = linea.Substring(17, 3).Trim();
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;
}
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null)
{
respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: La factura con ID {idFactura} no fue encontrada en el sistema.");
continue;
}
var nuevoPago = new Pago
{
IdFactura = idFactura,
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")
{
nuevoPago.Estado = "Aprobado";
await _pagoRepository.CreateAsync(nuevoPago, transaction);
await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction);
respuesta.PagosAprobados++;
}
else
{
nuevoPago.Estado = "Rechazado";
await _pagoRepository.CreateAsync(nuevoPago, transaction);
await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction);
respuesta.PagosRechazados++;
}
}
transaction.Commit();
respuesta.MensajeResumen = $"Archivo procesado. Leídos: {respuesta.TotalRegistrosLeidos}, Aprobados: {respuesta.PagosAprobados}, Rechazados: {respuesta.PagosRechazados}.";
_logger.LogInformation(respuesta.MensajeResumen);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error crítico al procesar archivo de respuesta de débito.");
respuesta.Errores.Add($"Error fatal en el procesamiento: {ex.Message}");
}
return respuesta;
}
}
}

View File

@@ -0,0 +1,681 @@
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Distribucion;
using GestionIntegral.Api.Models.Suscripciones;
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 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,
ISuscriptorRepository suscriptorRepository,
IAjusteRepository ajusteRepository,
IEmailService emailService,
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;
_suscriptorRepository = suscriptorRepository;
_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, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
{
var periodoActual = new DateTime(anio, mes, 1);
var periodoActualStr = periodoActual.ToString("yyyy-MM");
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodoActualStr, idUsuario);
// --- 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();
using var transaction = connection.BeginTransaction();
try
{
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction);
if (!suscripcionesActivas.Any())
{
// 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);
}
var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>();
foreach (var s in suscripcionesActivas)
{
var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion);
if (pub != null)
{
suscripcionesConEmpresa.Add((s, pub.IdEmpresa));
}
}
var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa });
foreach (var grupo in gruposParaFacturar)
{
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)
{
var suscripcion = item.Suscripcion;
decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction);
decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones);
importeBrutoTotal += importeBrutoSusc;
descuentoPromocionesTotal += descuentoSusc;
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion);
detallesParaFactura.Add(new FacturaDetalle
{
IdSuscripcion = suscripcion.IdSuscripcion,
Descripcion = $"Corresponde a {publicacion?.Nombre ?? "N/A"}",
ImporteBruto = importeBrutoSusc,
DescuentoAplicado = descuentoSusc,
ImporteNeto = importeBrutoSusc - descuentoSusc
});
}
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),
ImporteBruto = importeBrutoTotal,
DescuentoAplicado = descuentoPromocionesTotal,
ImporteFinal = importeFinal,
EstadoPago = "Pendiente",
EstadoFacturacion = "Pendiente de Facturar",
TipoFactura = "Mensual"
};
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
if (facturaCreada == null) throw new DataException($"No se pudo crear 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 {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 { }
lote.Estado = "Fallido";
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodoActualStr);
return (false, "Error interno del servidor al generar la facturación.", null);
}
finally
{
lote.FechaFin = DateTime.Now;
lote.TotalCorreos = emailsEnviados + emailsFallidos;
lote.TotalEnviados = emailsEnviados;
lote.TotalFallidos = emailsFallidos;
await _loteDeEnvioRepository.UpdateAsync(lote);
}
var resultadoEnvio = new LoteDeEnvioResumenDto
{
IdLoteDeEnvio = lote.IdLoteDeEnvio,
Periodo = periodoActualStr,
TotalCorreos = lote.TotalCorreos,
TotalEnviados = lote.TotalEnviados,
TotalFallidos = lote.TotalFallidos,
ErroresDetallados = erroresDetallados
};
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", resultadoEnvio);
}
public async Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes)
{
var lotes = await _loteDeEnvioRepository.GetAllAsync(anio, mes);
if (!lotes.Any())
{
return Enumerable.Empty<LoteDeEnvioHistorialDto>();
}
var idsUsuarios = lotes.Select(l => l.IdUsuarioDisparo).Distinct();
var usuarios = (await _usuarioRepository.GetByIdsAsync(idsUsuarios)).ToDictionary(u => u.Id);
return lotes.Select(l => new LoteDeEnvioHistorialDto
{
IdLoteDeEnvio = l.IdLoteDeEnvio,
FechaInicio = l.FechaInicio,
Periodo = l.Periodo,
Estado = l.Estado,
TotalCorreos = l.TotalCorreos,
TotalEnviados = l.TotalEnviados,
TotalFallidos = l.TotalFallidos,
NombreUsuarioDisparo = usuarios.TryGetValue(l.IdUsuarioDisparo, out var user)
? $"{user.Nombre} {user.Apellido}"
: "Usuario Desconocido"
});
}
public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(
int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura)
{
var periodo = $"{anio}-{mes:D2}";
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura);
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo);
var empresas = await _empresaRepository.GetAllAsync(null, null);
var resumenes = facturasData
.GroupBy(data => data.Factura.IdSuscriptor)
.Select(grupo =>
{
var primerItem = grupo.First();
var facturasConsolidadas = grupo.Select(itemFactura =>
{
var empresa = empresas.FirstOrDefault(e => e.IdEmpresa == itemFactura.IdEmpresa);
return new FacturaConsolidadaDto
{
IdFactura = itemFactura.Factura.IdFactura,
NombreEmpresa = empresa?.Nombre ?? "N/A",
ImporteFinal = itemFactura.Factura.ImporteFinal,
EstadoPago = itemFactura.Factura.EstadoPago,
EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
NumeroFactura = itemFactura.Factura.NumeroFactura,
TotalPagado = itemFactura.TotalPagado,
// Faltaba esta línea para pasar el tipo de factura al frontend.
TipoFactura = itemFactura.Factura.TipoFactura,
Detalles = detallesData
.Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
.Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto })
.ToList(),
// Pasamos el id del suscriptor para facilitar las cosas en el frontend
IdSuscriptor = itemFactura.Factura.IdSuscriptor
};
}).ToList();
return new ResumenCuentaSuscriptorDto
{
IdSuscriptor = primerItem.Factura.IdSuscriptor,
NombreSuscriptor = primerItem.NombreSuscriptor,
Facturas = facturasConsolidadas,
ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal - f.TotalPagado)
};
});
return resumenes.ToList();
}
public async Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario)
{
try
{
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null) return (false, "Factura no encontrada.", null);
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número asignado.", null);
if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada.", null);
var suscriptor = await _suscriptorRepository.GetByIdAsync(factura.IdSuscriptor);
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.", null);
byte[]? pdfAttachment = null;
string? pdfFileName = null;
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
if (File.Exists(rutaCompleta))
{
pdfAttachment = await File.ReadAllBytesAsync(rutaCompleta);
pdfFileName = $"Factura_{factura.NumeroFactura}.pdf";
}
else
{
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura}", factura.NumeroFactura);
return (false, "No se encontró el archivo PDF correspondiente en el servidor.", null);
}
string asunto = $"Factura Electrónica - Período {factura.Periodo}";
string cuerpoHtml = ConstruirCuerpoEmailFacturaPdf(suscriptor, factura);
// Pasamos los nuevos parámetros de contexto al EmailService.
await _emailService.EnviarEmailAsync(
destinatarioEmail: suscriptor.Email,
destinatarioNombre: suscriptor.NombreCompleto,
asunto: asunto,
cuerpoHtml: cuerpoHtml,
attachment: pdfAttachment,
attachmentName: pdfFileName,
origen: "EnvioManualPDF",
referenciaId: $"Factura-{idFactura}",
idUsuarioDisparo: idUsuario);
_logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor);
return (true, null, suscriptor.Email);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura);
// El error ya será logueado por EmailService, pero lo relanzamos para que el controller lo maneje.
// En este caso, simplemente devolvemos la tupla de error.
return (false, "Ocurrió un error al intentar enviar el email con la factura.", null);
}
}
/// <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.");
}
}
public async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction)
{
decimal importeTotal = 0;
var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet();
var fechaActual = new DateTime(anio, mes, 1);
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, fechaActual, transaction);
var promocionesDeBonificacion = promociones.Where(p => p.TipoEfecto == "BonificarEntregaDia").ToList();
while (fechaActual.Month == mes)
{
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)
{
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);
}
return importeTotal;
}
private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha)
{
switch (promocion.TipoCondicion)
{
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 => "Dom",
DayOfWeek.Monday => "Lun",
DayOfWeek.Tuesday => "Mar",
DayOfWeek.Wednesday => "Mie",
DayOfWeek.Thursday => "Jue",
DayOfWeek.Friday => "Vie",
DayOfWeek.Saturday => "Sab",
_ => ""
};
private decimal GetPrecioDelDia(Precio precio, DayOfWeek dia) => dia switch
{
DayOfWeek.Sunday => precio.Domingo ?? 0,
DayOfWeek.Monday => precio.Lunes ?? 0,
DayOfWeek.Tuesday => precio.Martes ?? 0,
DayOfWeek.Wednesday => precio.Miercoles ?? 0,
DayOfWeek.Thursday => precio.Jueves ?? 0,
DayOfWeek.Friday => precio.Viernes ?? 0,
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

@@ -0,0 +1,10 @@
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IDebitoAutomaticoService
{
Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario);
Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario);
}
}

View File

@@ -0,0 +1,18 @@
using System.Data;
using GestionIntegral.Api.Dtos.Comunicaciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IFacturacionService
{
Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes);
Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(
int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura);
Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario);
Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario);
Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction);
}
}

View File

@@ -0,0 +1,14 @@
// Archivo: GestionIntegral.Api/Services/Suscripciones/IPagoService.cs
using GestionIntegral.Api.Dtos.Suscripciones;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IPagoService
{
Task<IEnumerable<PagoDto>> ObtenerPagosPorFacturaId(int idFactura);
Task<(PagoDto? Pago, string? Error)> RegistrarPagoManual(CreatePagoDto createDto, int idUsuario);
}
}

View File

@@ -0,0 +1,12 @@
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IPromocionService
{
Task<IEnumerable<PromocionDto>> ObtenerTodas(bool soloActivas);
Task<PromocionDto?> ObtenerPorId(int id);
Task<(PromocionDto? Promocion, string? Error)> Crear(CreatePromocionDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> Actualizar(int id, UpdatePromocionDto updateDto, int idUsuario);
}
}

View File

@@ -4,9 +4,13 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
public interface ISuscripcionService public interface ISuscripcionService
{ {
Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor);
Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion); Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion);
Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor);
Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario); Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario); Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario);
Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion);
Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion);
Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, AsignarPromocionDto dto, int idUsuario);
Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion);
} }
} }

View File

@@ -0,0 +1,131 @@
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;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class PagoService : IPagoService
{
private readonly IPagoRepository _pagoRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly IFormaPagoRepository _formaPagoRepository;
private readonly IUsuarioRepository _usuarioRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<PagoService> _logger;
public PagoService(
IPagoRepository pagoRepository,
IFacturaRepository facturaRepository,
IFormaPagoRepository formaPagoRepository,
IUsuarioRepository usuarioRepository,
DbConnectionFactory connectionFactory,
ILogger<PagoService> logger)
{
_pagoRepository = pagoRepository;
_facturaRepository = facturaRepository;
_formaPagoRepository = formaPagoRepository;
_usuarioRepository = usuarioRepository;
_connectionFactory = connectionFactory;
_logger = logger;
}
private async Task<PagoDto?> MapToDto(Pago pago)
{
if (pago == null) return null;
var formaPago = await _formaPagoRepository.GetByIdAsync(pago.IdFormaPago);
var usuario = await _usuarioRepository.GetByIdAsync(pago.IdUsuarioRegistro);
return new PagoDto
{
IdPago = pago.IdPago,
IdFactura = pago.IdFactura,
FechaPago = pago.FechaPago.ToString("yyyy-MM-dd"),
IdFormaPago = pago.IdFormaPago,
NombreFormaPago = formaPago?.Nombre ?? "Desconocida",
Monto = pago.Monto,
Estado = pago.Estado,
Referencia = pago.Referencia,
Observaciones = pago.Observaciones,
IdUsuarioRegistro = pago.IdUsuarioRegistro,
NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "Usuario Desconocido"
};
}
public async Task<IEnumerable<PagoDto>> ObtenerPagosPorFacturaId(int idFactura)
{
var pagos = await _pagoRepository.GetByFacturaIdAsync(idFactura);
var dtosTasks = pagos.Select(p => MapToDto(p));
var dtos = await Task.WhenAll(dtosTasks);
return dtos.Where(dto => dto != null)!;
}
public async Task<(PagoDto? Pago, string? Error)> RegistrarPagoManual(CreatePagoDto createDto, int idUsuario)
{
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.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago);
if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida.");
var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction);
var nuevoPago = new Pago
{
IdFactura = createDto.IdFactura,
FechaPago = createDto.FechaPago,
IdFormaPago = createDto.IdFormaPago,
Monto = createDto.Monto,
Estado = "Aprobado",
Referencia = createDto.Referencia,
Observaciones = createDto.Observaciones,
IdUsuarioRegistro = idUsuario
};
// Creamos el nuevo pago
var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction);
if (pagoCreado == null) throw new DataException("No se pudo registrar el pago.");
var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto;
// Nueva lógica para manejar todos los estados de pago
string nuevoEstadoPago = factura.EstadoPago;
if (nuevoTotalPagado >= factura.ImporteFinal)
{
nuevoEstadoPago = "Pagada";
}
else if (nuevoTotalPagado > 0)
{
nuevoEstadoPago = "Pagada Parcialmente";
}
// Si nuevoTotalPagado es 0, el estado no cambia.
// Solo actualizamos si el estado calculado es diferente al actual.
if (nuevoEstadoPago != factura.EstadoPago)
{
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, nuevoEstadoPago, transaction);
if (!actualizado) throw new DataException($"No se pudo actualizar el estado de la factura a '{nuevoEstadoPago}'.");
}
transaction.Commit();
_logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario);
var dto = await MapToDto(pagoCreado); // MapToDto ahora es más simple
return (dto, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al registrar pago manual para Factura ID {IdFactura}", createDto.IdFactura);
return (null, $"Error interno al registrar el pago: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,132 @@
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class PromocionService : IPromocionService
{
private readonly IPromocionRepository _promocionRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<PromocionService> _logger;
public PromocionService(
IPromocionRepository promocionRepository,
DbConnectionFactory connectionFactory,
ILogger<PromocionService> logger)
{
_promocionRepository = promocionRepository;
_connectionFactory = connectionFactory;
_logger = logger;
}
private PromocionDto MapToDto(Promocion promo)
{
return new PromocionDto
{
IdPromocion = promo.IdPromocion,
Descripcion = promo.Descripcion,
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
};
}
public async Task<IEnumerable<PromocionDto>> ObtenerTodas(bool soloActivas)
{
var promociones = await _promocionRepository.GetAllAsync(soloActivas);
return promociones.Select(MapToDto);
}
public async Task<PromocionDto?> ObtenerPorId(int id)
{
var promocion = await _promocionRepository.GetByIdAsync(id);
return promocion != null ? MapToDto(promocion) : null;
}
public async Task<(PromocionDto? Promocion, string? Error)> Crear(CreatePromocionDto createDto, int idUsuario)
{
if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio)
{
return (null, "La fecha de fin no puede ser anterior a la fecha de inicio.");
}
var nuevaPromocion = new Promocion
{
Descripcion = createDto.Descripcion,
TipoEfecto = createDto.TipoEfecto,
ValorEfecto = createDto.ValorEfecto,
TipoCondicion = createDto.TipoCondicion,
ValorCondicion = createDto.ValorCondicion,
FechaInicio = createDto.FechaInicio,
FechaFin = createDto.FechaFin,
Activa = createDto.Activa,
IdUsuarioAlta = idUsuario
};
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var promocionCreada = await _promocionRepository.CreateAsync(nuevaPromocion, transaction);
if (promocionCreada == null) throw new DataException("Error al crear la promoción.");
transaction.Commit();
_logger.LogInformation("Promoción ID {Id} creada por Usuario ID {UserId}.", promocionCreada.IdPromocion, idUsuario);
return (MapToDto(promocionCreada), null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al crear promoción: {Descripcion}", createDto.Descripcion);
return (null, $"Error interno al crear la promoción: {ex.Message}");
}
}
public async Task<(bool Exito, string? Error)> Actualizar(int id, UpdatePromocionDto updateDto, int idUsuario)
{
var existente = await _promocionRepository.GetByIdAsync(id);
if (existente == null) return (false, "Promoción no encontrada.");
if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio)
{
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
}
existente.Descripcion = updateDto.Descripcion;
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;
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var actualizado = await _promocionRepository.UpdateAsync(existente, transaction);
if (!actualizado) throw new DataException("Error al actualizar la promoción.");
transaction.Commit();
_logger.LogInformation("Promoción ID {Id} actualizada por Usuario ID {UserId}.", id, idUsuario);
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al actualizar promoción ID: {Id}", id);
return (false, $"Error interno al actualizar: {ex.Message}");
}
}
}
}

View File

@@ -4,6 +4,7 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using System.Data; using System.Data;
using System.Globalization;
namespace GestionIntegral.Api.Services.Suscripciones namespace GestionIntegral.Api.Services.Suscripciones
{ {
@@ -12,23 +13,48 @@ namespace GestionIntegral.Api.Services.Suscripciones
private readonly ISuscripcionRepository _suscripcionRepository; private readonly ISuscripcionRepository _suscripcionRepository;
private readonly ISuscriptorRepository _suscriptorRepository; private readonly ISuscriptorRepository _suscriptorRepository;
private readonly IPublicacionRepository _publicacionRepository; private readonly IPublicacionRepository _publicacionRepository;
private readonly DbConnectionFactory _connectionFactory; private readonly IPromocionRepository _promocionRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
private readonly IFacturacionService _facturacionService;
private readonly ILogger<SuscripcionService> _logger; private readonly ILogger<SuscripcionService> _logger;
private readonly DbConnectionFactory _connectionFactory;
public SuscripcionService( public SuscripcionService(
ISuscripcionRepository suscripcionRepository, ISuscripcionRepository suscripcionRepository,
ISuscriptorRepository suscriptorRepository, ISuscriptorRepository suscriptorRepository,
IPublicacionRepository publicacionRepository, IPublicacionRepository publicacionRepository,
DbConnectionFactory connectionFactory, IPromocionRepository promocionRepository,
ILogger<SuscripcionService> logger) IFacturaRepository facturaRepository,
IFacturaDetalleRepository facturaDetalleRepository,
IFacturacionService facturacionService,
ILogger<SuscripcionService> logger,
DbConnectionFactory connectionFactory)
{ {
_suscripcionRepository = suscripcionRepository; _suscripcionRepository = suscripcionRepository;
_suscriptorRepository = suscriptorRepository; _suscriptorRepository = suscriptorRepository;
_publicacionRepository = publicacionRepository; _publicacionRepository = publicacionRepository;
_connectionFactory = connectionFactory; _promocionRepository = promocionRepository;
_facturaRepository = facturaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_facturacionService = facturacionService;
_logger = logger; _logger = logger;
_connectionFactory = connectionFactory;
} }
private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto
{
IdPromocion = promo.IdPromocion,
Descripcion = promo.Descripcion,
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
};
private async Task<SuscripcionDto?> MapToDto(Suscripcion suscripcion) private async Task<SuscripcionDto?> MapToDto(Suscripcion suscripcion)
{ {
if (suscripcion == null) return null; if (suscripcion == null) return null;
@@ -71,6 +97,15 @@ namespace GestionIntegral.Api.Services.Suscripciones
return (null, "La publicación no existe."); return (null, "La publicación no existe.");
if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio) if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio)
return (null, "La fecha de fin no puede ser anterior a la fecha de inicio."); 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 var nuevaSuscripcion = new Suscripcion
{ {
@@ -92,6 +127,53 @@ namespace GestionIntegral.Api.Services.Suscripciones
var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction); var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction);
if (creada == null) throw new DataException("Error al crear la suscripción."); if (creada == null) throw new DataException("Error al crear la suscripción.");
var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync();
if (ultimoPeriodoFacturadoStr != null)
{
var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture);
var periodoSuscripcion = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1);
if (periodoSuscripcion <= ultimoPeriodo)
{
_logger.LogInformation("Suscripción en período ya cerrado detectada. Generando factura de alta pro-rata.");
decimal importeProporcional = await _facturacionService.CalcularImporteParaSuscripcion(creada, creada.FechaInicio.Year, creada.FechaInicio.Month, transaction);
if (importeProporcional > 0)
{
var facturaDeAlta = new Factura
{
IdSuscriptor = creada.IdSuscriptor,
Periodo = creada.FechaInicio.ToString("yyyy-MM"),
FechaEmision = DateTime.Now.Date,
FechaVencimiento = DateTime.Now.AddDays(10).Date,
ImporteBruto = importeProporcional,
ImporteFinal = importeProporcional,
EstadoPago = "Pendiente",
EstadoFacturacion = "Pendiente de Facturar",
TipoFactura = "Alta"
};
var facturaCreada = await _facturaRepository.CreateAsync(facturaDeAlta, transaction);
if (facturaCreada == null) throw new DataException("No se pudo crear la factura de alta.");
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(creada.IdPublicacion);
var finDeMes = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1).AddMonths(1).AddDays(-1);
await _facturaDetalleRepository.CreateAsync(new FacturaDetalle
{
IdFactura = facturaCreada.IdFactura,
IdSuscripcion = creada.IdSuscripcion,
Descripcion = $"Suscripción proporcional {publicacion?.Nombre} ({creada.FechaInicio:dd/MM} al {finDeMes:dd/MM})",
ImporteBruto = importeProporcional,
ImporteNeto = importeProporcional,
DescuentoAplicado = 0
}, transaction);
_logger.LogInformation("Factura de alta #{IdFactura} por ${Importe} generada para la nueva suscripción #{IdSuscripcion}.", facturaCreada.IdFactura, importeProporcional, creada.IdSuscripcion);
}
}
}
transaction.Commit(); transaction.Commit();
_logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario); _logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario);
return (await MapToDto(creada), null); return (await MapToDto(creada), null);
@@ -109,6 +191,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion); var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion);
if (existente == null) return (false, "Suscripción no encontrada."); 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) if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio)
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio."); return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
@@ -139,5 +233,91 @@ namespace GestionIntegral.Api.Services.Suscripciones
return (false, $"Error interno: {ex.Message}"); return (false, $"Error interno: {ex.Message}");
} }
} }
public async Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion)
{
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 promosAsignadasData = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion);
var idsAsignadas = promosAsignadasData.Select(p => p.Promocion.IdPromocion).ToHashSet();
return todasLasPromosActivas
.Where(p => !idsAsignadas.Contains(p.IdPromocion))
.Select(MapPromocionToDto); // Usa el helper que ya creamos
}
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
{
if (await _suscripcionRepository.GetByIdAsync(idSuscripcion) == null) return (false, "Suscripción no encontrada.");
if (await _promocionRepository.GetByIdAsync(dto.IdPromocion) == null) return (false, "Promoción no encontrada.");
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)
{
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}", dto.IdPromocion, idSuscripcion);
return (false, "Error interno al asignar la promoción.");
}
}
public async Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion)
{
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var exito = await _suscripcionRepository.QuitarPromocionAsync(idSuscripcion, idPromocion, transaction);
if (!exito) return (false, "La promoción no estaba asignada a esta suscripción.");
transaction.Commit();
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al quitar promoción {IdPromocion} de suscripción {IdSuscripcion}", idPromocion, idSuscripcion);
return (false, "Error interno al quitar la promoción.");
}
}
} }
} }

View File

@@ -1,15 +1,8 @@
// Archivo: GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs
using GestionIntegral.Api.Data; using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data; using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones 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) 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 suscriptores = await _suscriptorRepository.GetAllAsync(nombreFilter, nroDocFilter, soloActivos);
var dtosTasks = suscriptores.Select(s => MapToDto(s)); if (!suscriptores.Any())
var dtos = await Task.WhenAll(dtosTasks); {
return dtos.Where(dto => dto != null).Select(dto => dto!); 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) 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); _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); return (true, null);
} }
catch(Exception ex) catch (Exception ex)
{ {
try { transaction.Rollback(); } catch { } try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al cambiar estado del suscriptor ID: {IdSuscriptor}", id); _logger.LogError(ex, "Error al cambiar estado del suscriptor ID: {IdSuscriptor}", id);

View File

@@ -5,11 +5,22 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AppSettings": {
"FacturasPdfPath": "E:\\Facturas"
},
"Jwt": { "Jwt": {
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2", "Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
"Issuer": "GestionIntegralApi", "Issuer": "GestionIntegralApi",
"Audience": "GestionIntegralClient", "Audience": "GestionIntegralClient",
"DurationInHours": 8 "DurationInHours": 8
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"MailSettings": {
"SmtpHost": "192.168.5.201",
"SmtpPort": 587,
"SenderName": "Club - Diario El Día",
"SenderEmail": "alertas@eldia.com",
"SmtpUser": "alertas@eldia.com",
"SmtpPass": "@Alertas713550@"
}
} }

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

@@ -0,0 +1,171 @@
import React, { useState, useEffect, useCallback } from 'react';
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 = {
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;
onClose: () => void;
suscripcion: SuscripcionDto | null;
}
const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => {
const [asignadas, setAsignadas] = useState<PromocionAsignadaDto[]>([]);
const [disponibles, setDisponibles] = useState<PromocionDto[]>([]);
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);
setError(null);
try {
const [asignadasData, disponiblesData] = await Promise.all([
suscripcionService.getPromocionesAsignadas(suscripcion.idSuscripcion),
suscripcionService.getPromocionesDisponibles(suscripcion.idSuscripcion)
]);
setAsignadas(asignadasData);
setDisponibles(disponiblesData);
} catch (err) {
setError("Error al cargar las promociones.");
} finally {
setLoading(false);
}
}, [suscripcion]);
useEffect(() => {
if (open && suscripcion) {
cargarDatos();
setSelectedPromo('');
setVigenciaDesde(suscripcion.fechaInicio);
setVigenciaHasta('');
}
}, [open, suscripcion]);
const handleAsignar = async () => {
if (!suscripcion || !selectedPromo || !vigenciaDesde) {
setError("Debe seleccionar una promoción y una fecha de inicio.");
return;
}
setError(null);
try {
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.");
}
};
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;
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">Gestionar Promociones</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Suscripción a: <strong>{suscripcion.nombrePublicacion}</strong>
</Typography>
{error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{loading ? <CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> : (
<>
<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={`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={{ mt: 1 }}>
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel>Promociones Disponibles</InputLabel>
<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>
<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>
</>
)}
<Box sx={{ mt: 3, textAlign: 'right' }}>
<Button onClick={onClose}>Cerrar</Button>
</Box>
</Box>
</Modal>
);
};
export default GestionarPromocionesSuscripcionModal;

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

@@ -0,0 +1,152 @@
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 { FacturaConsolidadaDto } from '../../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto';
import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto';
import formaPagoService from '../../../services/Suscripciones/formaPagoService';
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,
maxHeight: '90vh',
overflowY: 'auto'
};
interface PagoManualModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreatePagoDto) => Promise<void>;
factura: FacturaConsolidadaDto | null;
nombreSuscriptor: string; // Se pasa el nombre del suscriptor como prop-
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, nombreSuscriptor, errorMessage, clearErrorMessage }) => {
const [formData, setFormData] = useState<Partial<CreatePagoDto>>({});
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
const [loading, setLoading] = useState(false);
const [loadingFormasPago, setLoadingFormasPago] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const saldoPendiente = factura ? factura.importeFinal - factura.totalPagado : 0;
useEffect(() => {
const fetchFormasDePago = async () => {
setLoadingFormasPago(true);
try {
const data = await formaPagoService.getAllFormasDePago();
setFormasDePago(data.filter(fp => !fp.requiereCBU));
} catch (error) {
setLocalErrors(prev => ({ ...prev, formasDePago: 'Error al cargar formas de pago.' }));
} finally {
setLoadingFormasPago(false);
}
};
if (open && factura) {
fetchFormasDePago();
setFormData({
idFactura: factura.idFactura,
monto: saldoPendiente,
fechaPago: new Date().toISOString().split('T')[0]
});
setLocalErrors({});
}
}, [open, factura, saldoPendiente]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago.";
if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria.";
const monto = formData.monto ?? 0;
if (monto <= 0) {
errors.monto = "El monto debe ser mayor a cero.";
} else if (monto > saldoPendiente) {
errors.monto = `El monto no puede superar el saldo pendiente de $${saldoPendiente.toFixed(2)}.`;
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const finalValue = name === 'monto' && value !== '' ? parseFloat(value) : value;
setFormData(prev => ({ ...prev, [name]: finalValue }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...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 {
await onSubmit(formData as CreatePagoDto);
success = true;
} catch (error) {
success = false;
} finally {
setLoading(false);
if (success) onClose();
}
};
if (!factura) return null;
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">Registrar Pago Manual</Typography>
<Typography variant="body1" color="text.secondary" gutterBottom>
Para: {nombreSuscriptor}
</Typography>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Saldo Pendiente: ${saldoPendiente.toFixed(2)}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} />
<FormControl fullWidth margin="dense" error={!!localErrors.idFormaPago}>
<InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel>
<Select name="idFormaPago" labelId="forma-pago-label" value={formData.idFormaPago || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loadingFormasPago}>
{formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)}
</Select>
</FormControl>
<TextField name="monto" label="Monto Pagado" 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="referencia" label="Referencia (Opcional)" value={formData.referencia || ''} onChange={handleInputChange} fullWidth margin="dense" />
<TextField name="observaciones" label="Observaciones (Opcional)" value={formData.observaciones || ''} onChange={handleInputChange} fullWidth margin="dense" multiline rows={2} />
{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 || loadingFormasPago}>
{loading ? <CircularProgress size={24} /> : 'Registrar Pago'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default PagoManualModal;

View File

@@ -0,0 +1,183 @@
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 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%',
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'
};
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 {
open: boolean;
onClose: () => void;
onSubmit: (data: CreatePromocionDto | UpdatePromocionDto, id?: number) => Promise<void>;
initialData?: PromocionDto | null;
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage }) => {
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) {
const defaults = {
descripcion: '',
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]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.';
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)) {
errors.fechaFin = 'La fecha de fin no puede ser anterior a la de inicio.';
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
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();
};
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target;
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();
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
clearErrorMessage();
if (!validate()) return;
setLoading(true);
let success = false;
try {
const dataToSubmit = { ...formData, fechaFin: formData.fechaFin || null } as CreatePromocionDto | UpdatePromocionDto;
await onSubmit(dataToSubmit, initialData?.idPromocion);
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 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 />
<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>
{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" }}
/>
)}
<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>}
<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'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default PromocionFormModal;

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

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Modal, Box, Typography, TextField, Button, CircularProgress, Alert, Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, FormGroup, FormControlLabel, FormControl, InputLabel, Select, MenuItem, FormGroup, FormControlLabel,
Checkbox, type SelectChangeEvent, Paper Checkbox, type SelectChangeEvent, Paper
} from '@mui/material'; } from '@mui/material';
import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto'; import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto';
import type { CreateSuscripcionDto } from '../../../models/dtos/Suscripciones/CreateSuscripcionDto'; import type { CreateSuscripcionDto } from '../../../models/dtos/Suscripciones/CreateSuscripcionDto';
@@ -25,10 +25,10 @@ const modalStyle = {
}; };
const dias = [ const dias = [
{ label: 'Lunes', value: 'L' }, { label: 'Martes', value: 'M' }, { label: 'Lunes', value: 'Lun' }, { label: 'Martes', value: 'Mar' },
{ label: 'Miércoles', value: 'X' }, { label: 'Jueves', value: 'J' }, { label: 'Miércoles', value: 'Mie' }, { label: 'Jueves', value: 'Jue' },
{ label: 'Viernes', value: 'V' }, { label: 'Sábado', value: 'S' }, { label: 'Viernes', value: 'Vie' }, { label: 'Sábado', value: 'Sab' },
{ label: 'Domingo', value: 'D' } { label: 'Domingo', value: 'Dom' }
]; ];
interface SuscripcionFormModalProps { interface SuscripcionFormModalProps {
@@ -41,13 +41,12 @@ interface SuscripcionFormModalProps {
clearErrorMessage: () => void; clearErrorMessage: () => void;
} }
// Usamos una interfaz local que contenga todos los campos posibles del formulario
interface FormState { interface FormState {
idPublicacion?: number | ''; idPublicacion?: number | '';
fechaInicio?: string; fechaInicio?: string;
fechaFin?: string | null; fechaFin?: string | null;
estado?: 'Activa' | 'Pausada' | 'Cancelada'; estado?: 'Activa' | 'Pausada' | 'Cancelada';
observaciones?: string; observaciones?: string;
} }
const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, initialData, errorMessage, clearErrorMessage }) => { const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, initialData, errorMessage, clearErrorMessage }) => {
@@ -92,8 +91,15 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
const errors: { [key: string]: string | null } = {}; const errors: { [key: string]: string | null } = {};
if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación."; if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación.";
if (!formData.fechaInicio?.trim()) errors.fechaInicio = 'La Fecha de Inicio es obligatoria.'; 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)) { 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.'; errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.';
} }
if (selectedDays.size === 0) errors.diasEntrega = "Debe seleccionar al menos un día de entrega."; if (selectedDays.size === 0) errors.diasEntrega = "Debe seleccionar al menos un día de entrega.";
setLocalErrors(errors); setLocalErrors(errors);
@@ -105,7 +111,7 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
if (newSelection.has(dayValue)) newSelection.delete(dayValue); if (newSelection.has(dayValue)) newSelection.delete(dayValue);
else newSelection.add(dayValue); else newSelection.add(dayValue);
setSelectedDays(newSelection); setSelectedDays(newSelection);
if (localErrors.diasEntrega) setLocalErrors(prev => ({...prev, diasEntrega: null})); if (localErrors.diasEntrega) setLocalErrors(prev => ({ ...prev, diasEntrega: null }));
if (errorMessage) clearErrorMessage(); if (errorMessage) clearErrorMessage();
}; };
@@ -123,6 +129,27 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
if (errorMessage) clearErrorMessage(); 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>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
clearErrorMessage(); clearErrorMessage();
@@ -133,7 +160,7 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
try { try {
const dataToSubmit = { const dataToSubmit = {
...formData, ...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), diasEntrega: Array.from(selectedDays),
}; };
@@ -156,43 +183,57 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
<Box sx={modalStyle}> <Box sx={modalStyle}>
<Typography variant="h6" component="h2">{isEditing ? 'Editar Suscripción' : 'Nueva Suscripción'}</Typography> <Typography variant="h6" component="h2">{isEditing ? 'Editar Suscripción' : 'Nueva Suscripción'}</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<FormControl fullWidth margin="dense" error={!!localErrors.idPublicacion}> <FormControl fullWidth margin="dense" error={!!localErrors.idPublicacion}>
<InputLabel id="pub-label" required>Publicación</InputLabel> <InputLabel id="pub-label" required>Publicación</InputLabel>
<Select name="idPublicacion" labelId="pub-label" value={formData.idPublicacion || ''} onChange={handleSelectChange} label="Publicación" disabled={loading || loadingPubs || isEditing}> <Select name="idPublicacion" labelId="pub-label" value={formData.idPublicacion || ''} onChange={handleSelectChange} label="Publicación" disabled={loading || loadingPubs || isEditing}>
{publicaciones.map(p => <MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre} ({p.nombreEmpresa})</MenuItem>)} {publicaciones.map(p => <MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre} ({p.nombreEmpresa})</MenuItem>)}
</Select> </Select>
{localErrors.idPublicacion && <Typography color="error" variant="caption">{localErrors.idPublicacion}</Typography>} {localErrors.idPublicacion && <Typography color="error" variant="caption">{localErrors.idPublicacion}</Typography>}
</FormControl> </FormControl>
<Typography sx={{ mt: 2, mb: 1, color: localErrors.diasEntrega ? 'error.main' : 'inherit' }}>Días de Entrega *</Typography> <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)' }}> <Paper variant="outlined" sx={{ p: 1, borderColor: localErrors.diasEntrega ? 'error.main' : 'rgba(0, 0, 0, 0.23)' }}>
<FormGroup row> <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> </FormGroup>
</Paper> </Paper>
{localErrors.diasEntrega && <Typography color="error" variant="caption">{localErrors.diasEntrega}</Typography>} {localErrors.diasEntrega && <Typography color="error" variant="caption">{localErrors.diasEntrega}</Typography>}
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}> <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="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
</Box> name="fechaFin"
<FormControl fullWidth margin="dense"> label={formData.estado !== 'Activa' ? "Fecha Fin (Requerida)" : "Fecha Fin (Automática)"}
<InputLabel id="estado-label">Estado</InputLabel> type="date"
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleSelectChange} label="Estado" disabled={loading}> value={formData.fechaFin || ''}
<MenuItem value="Activa">Activa</MenuItem> onChange={handleInputChange}
<MenuItem value="Pausada">Pausada</MenuItem> fullWidth
<MenuItem value="Cancelada">Cancelada</MenuItem> margin="dense"
</Select> InputLabelProps={{ shrink: true }}
</FormControl> error={!!localErrors.fechaFin}
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} /> helperText={localErrors.fechaFin}
disabled={loading || formData.estado === 'Activa'} // Deshabilitado si está activa
required={formData.estado !== 'Activa'} // Requerido si no está activa
/>
</Box>
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>} <FormControl fullWidth margin="dense">
<InputLabel id="estado-label">Estado</InputLabel>
<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>
</Select>
</FormControl>
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} />
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> {errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" disabled={loading || loadingPubs}> <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{loading ? <CircularProgress size={24} /> : 'Guardar'} <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
</Button> <Button type="submit" variant="contained" disabled={loading || loadingPubs}>
</Box> {loading ? <CircularProgress size={24} /> : 'Guardar'}
</Button>
</Box>
</Box> </Box>
</Box> </Box>
</Modal> </Modal>

View File

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

View File

@@ -14,18 +14,18 @@ const SECCION_PERMISSIONS_PREFIX = "SS";
// Mapeo de codAcc de sección a su módulo conceptual // Mapeo de codAcc de sección a su módulo conceptual
const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { const getModuloFromSeccionCodAcc = (codAcc: string): string | null => {
if (codAcc === "SS001") return "Distribución"; if (codAcc === "SS001") return "Distribución";
if (codAcc === "SS007") return "Suscripciones";
if (codAcc === "SS002") return "Contables"; if (codAcc === "SS002") return "Contables";
if (codAcc === "SS003") return "Impresión"; if (codAcc === "SS003") return "Impresión";
if (codAcc === "SS004") return "Reportes"; if (codAcc === "SS004") return "Reportes";
if (codAcc === "SS005") return "Radios";
if (codAcc === "SS006") return "Usuarios"; if (codAcc === "SS006") return "Usuarios";
if (codAcc === "SS005") return "Radios";
return null; return null;
}; };
// Función para determinar el módulo conceptual de un permiso individual // Función para determinar el módulo conceptual de un permiso individual
const getModuloConceptualDelPermiso = (permisoModulo: string): string => { const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
const moduloLower = permisoModulo.toLowerCase(); const moduloLower = permisoModulo.toLowerCase();
if (moduloLower.includes("distribuidores") || if (moduloLower.includes("distribuidores") ||
moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas" moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas"
moduloLower.includes("publicaciones distribución") || moduloLower.includes("publicaciones distribución") ||
@@ -36,6 +36,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
moduloLower.includes("ctrl. devoluciones")) { moduloLower.includes("ctrl. devoluciones")) {
return "Distribución"; return "Distribución";
} }
if (moduloLower.includes("suscripciones")) {
return "Suscripciones";
}
if (moduloLower.includes("cuentas pagos") || if (moduloLower.includes("cuentas pagos") ||
moduloLower.includes("cuentas notas") || moduloLower.includes("cuentas notas") ||
moduloLower.includes("cuentas tipos pagos")) { moduloLower.includes("cuentas tipos pagos")) {
@@ -89,7 +92,7 @@ const PermisosChecklist: React.FC<PermisosChecklistProps> = ({
return acc; return acc;
}, {} as Record<string, PermisoAsignadoDto[]>); }, {} as Record<string, PermisoAsignadoDto[]>);
const ordenModulosPrincipales = ["Distribución", "Contables", "Impresión", "Radios", "Usuarios", "Reportes", "Permisos (Definición)"]; const ordenModulosPrincipales = ["Distribución", "Suscripciones", "Contables", "Impresión", "Usuarios", "Reportes", "Radios","Permisos (Definición)"];
// Añadir módulos que solo tienen permiso de sección (como Radios) pero no hijos (aún) // Añadir módulos que solo tienen permiso de sección (como Radios) pero no hijos (aún)
permisosDeSeccion.forEach(ps => { permisosDeSeccion.forEach(ps => {
const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc); const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc);

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox
} from '@mui/material'; } from '@mui/material';
import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto'; import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto';

View File

@@ -1,22 +1,22 @@
// src/hooks/usePermissions.ts
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useCallback } from 'react';
export const usePermissions = () => { export const usePermissions = () => {
const { user } = useAuth(); // user aquí es de tipo UserContextData | null const { user } = useAuth();
const tienePermiso = (codigoPermisoRequerido: string): boolean => { // Envolvemos la función en useCallback.
if (!user) { // Si no hay usuario logueado // Su dependencia es [user], por lo que la función solo se
// volverá a crear si el objeto 'user' cambia (ej. al iniciar/cerrar sesión).
const tienePermiso = useCallback((codigoPermisoRequerido: string): boolean => {
if (!user) {
return false; return false;
} }
if (user.esSuperAdmin) { // SuperAdmin tiene todos los permisos if (user.esSuperAdmin) {
return true; return true;
} }
// Verificar si la lista de permisos del usuario incluye el código requerido
return user.permissions?.includes(codigoPermisoRequerido) ?? false; return user.permissions?.includes(codigoPermisoRequerido) ?? false;
}; }, [user]);
// También puede exportar el objeto user completo si se necesita en otros lugares
// o propiedades específicas como idPerfil, esSuperAdmin.
return { return {
tienePermiso, tienePermiso,
isSuperAdmin: user?.esSuperAdmin ?? false, isSuperAdmin: user?.esSuperAdmin ?? false,

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

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