38 Commits

Author SHA1 Message Date
24eaf18fd9 feat(contables): cierre mensual de cuenta corriente de distribuidor
Permite congelar el saldo de un distribuidor por empresa a una fecha de
corte y bloquear modificaciones retroactivas sobre el período cerrado.
El saldo se calcula sumando movimientos en rango (sin tocar cue_Saldos).
Incluye reapertura controlada exclusivamente por SuperAdmin, reporte con
saldo inicial, atajo "Desde último cierre", y auditoría del ciclo de
vida _H. Permisos CC001/CC002/CC003. Middleware global mapea bloqueos
por período cerrado a HTTP 409.
2026-05-07 12:03:26 -03:00
7e274ef114 Actualizar README.md
Some checks failed
Optimized Build and Deploy / remote-build-and-deploy (push) Has been cancelled
2026-03-25 15:00:39 +00:00
5212e31a03 Feat: Baja Lógica de Distribuidores (Selectores Dropdown)
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 8m32s
2026-03-23 14:09:26 -03:00
9201d7222b Fix: Fechas de Estado Bobinas
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 14m8s
- Las fechas de estado de las bobinas no pueden ser anterior a la fecha de remito (ingreso).
2026-02-11 14:52:58 -03:00
fc27b4b43e feat(Reportes): Ajusta cálculo de promedios en Listado de Distribución
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m58s
Se modifica la lógica de cálculo para la fila "General" en la tabla de promedios del reporte de Listado de Distribución para distribuidores.

**Motivación:**
Por requerimiento explícito del usuario final, el cálculo de los promedios generales (Llevados, Devueltos, Ventas y % Devolución) debe ser un promedio aritmético simple de los valores de los días de la semana mostrados en la tabla (ej. Viernes, Sábado, Domingo), en lugar del promedio ponderado que se calculaba anteriormente basado en los totales generales.

**Cambios Realizados:**

1.  **Backend (`ListadoDistribucionDistribuidoresViewModel.cs`):**
    *   Se actualizó la propiedad `PromedioGeneral` para que calcule sus valores (Promedio\_Llevados, Promedio\_Devueltos, etc.) promediando directamente los valores de la colección `PromediosPorDia`.

2.  **PDF (`ListadoDistribucionDistribuidoresDocument.cs`):**
    *   Se ajustó la lógica de renderizado de la fila "General" para que el porcentaje de devolución también se calcule como el promedio de los porcentajes de los días individuales, asegurando consistencia con el ViewModel.

3.  **Frontend (`ReporteListadoDistribucionPage.tsx`):**
    *   Se modificó el cálculo del estado `totalesPromedios` dentro de la función `handleGenerarReporte`. Ahora, en lugar de usar los totales de la tabla de detalle, suma los valores de la tabla de promedios y los divide por la cantidad de días para obtener un promedio simple.

**Resultado:**
Tanto la interfaz web como el PDF generado ahora muestran en la fila "General" un promedio simple de las filas de promedios diarios, alineándose con la lógica solicitada por el usuario.
2025-12-05 12:25:18 -03:00
35e8d803b9 Fix: Alinea cálculos del PDF y web en distribución de canillitas
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 4m16s
Este commit soluciona varias inconsistencias de cálculo y visualización que existían entre el reporte de distribución para canillitas en la web y el PDF generado.

Los cambios principales incluyen:

- **Cálculo de Promedios Generales:** Se implementa un promedio ponderado utilizando división decimal y redondeo matemático (`MidpointRounding.AwayFromZero`) en el backend. Esto soluciona las diferencias numéricas en los totales ("Prom. Llevados", "Prom. Devueltos", etc.) que eran causadas por la división de enteros.

- **Corrección de % Devolución:** Se ajusta la fórmula en todo el reporte (web y PDF) para calcular correctamente el porcentaje de devolución (`Devueltos / Llevados`) en lugar del porcentaje de venta que se mostraba erróneamente.

- **Orden de Columnas en PDF:** Se corrige el orden de las columnas "Prom. Devueltos" y "Prom. Ventas" que estaban intercambiadas en la fila "General" del PDF.

- **Precisión en Redondeo Final:** Se refina el cálculo del "% Devolución General" para que se base en los totales sin redondear, eliminando una diferencia de 0.01% y logrando una paridad exacta con la interfaz web.
2025-12-04 10:19:36 -03:00
8e1b8d2326 feat: DataGrid y filtro por Fechas en Stock Bobinas
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m15s
Frontend:
- Se reemplazó el componente Table por DataGrid para habilitar ordenamiento y filtrado nativo en cliente.
- Se agregó la UI para filtrar por rango de "Fecha de Estado".
- Se corrigió el tipado de columnas de fecha (`type: 'date'`) implementando un `valueGetter` personalizado que parsea año/mes/día localmente para evitar errores de filtrado por diferencia de Zona Horaria (UTC vs Local).
- Se actualizó `stockBobinaService` para enviar los parámetros `fechaEstadoDesde` y `fechaEstadoHasta`.

Backend:
- Se actualizó `StockBobinasController` para recibir los nuevos parámetros de fecha.
- Se modificó `StockBobinaRepository` implementando la lógica SQL para los nuevos filtros.
2025-11-27 13:49:46 -03:00
bc19e184aa feat: Implementar ingreso de bobinas por lote
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m13s
Se introduce una nueva funcionalidad para el ingreso masivo de bobinas a partir de un único remito. Esto agiliza significativamente la carga de datos y reduce errores al evitar la repetición de la planta, número y fecha de remito.

La implementación incluye:
- Un modal maestro-detalle de dos pasos que primero verifica el remito y luego permite la carga de las bobinas.
- Lógica de autocompletado de fecha y feedback al usuario si el remito ya existe.
- Un nuevo endpoint en el backend para procesar el lote de forma transaccional.
2025-11-20 09:50:54 -03:00
29109cff13 Fix Se deshabilita verificación de remito
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m41s
Pedido por Claudia Acosta:
Motivo: El ex canillita Sergio Mazza opera como distribuidor y no utiliza remitos.
En el campo de remito se le asigna un numero aleatorio para cumplir con el requisito del sistema.
2025-11-18 13:14:24 -03:00
7f1fadfc84 Feat Se Añade Total a Canillas Page
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 1m48s
2025-11-10 15:07:36 -03:00
74f07df960 Fix: Sin Padding Top/Bottom en Ticket Canilla
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 1m56s
2025-11-10 10:33:45 -03:00
6ceb1477ae Fix Tamaño Impresión - Ticket Canilla
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m41s
2025-11-10 10:14:54 -03:00
c049c1e544 Try Limitar Log de Contenedores
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m20s
2025-11-05 15:16:58 -03:00
8c7278ceae Fix: Captura y Muestra del Error Por Recibo Duplicado
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m2s
2025-11-05 13:52:14 -03:00
e8215f8586 Fix: Zona Horaria Eliminada
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m29s
- Se elimina la zona horaria y se refactoriza el formateo de fecha.
2025-11-05 10:51:56 -03:00
bf7d7c22ef Fix MultipleActiveResultSets a True
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 1m45s
- Se Habilita Multiple Active Result Sets (MARS) en SQL Server para permitir a la aplicación tener más de una solicitud pendiente en una única conexión.
2025-11-04 14:13:55 -03:00
2c584e9383 feat(reportes): Permite consulta consolidada en Detalle de Distribución
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m34s
Implementa la funcionalidad para generar el reporte "Detalle de Distribución de Canillas" de forma consolidada para todas las empresas, separando entre Canillitas y Accionistas. Adicionalmente, se realizan correcciones y mejoras visuales en otros reportes.

### Cambios Principales

-   **Frontend (`ReporteDetalleDistribucionCanillasPage`):**
    -   Se añade la opción "TODAS" al selector de Empresas.
    -   Al seleccionar "TODAS", se muestra un nuevo control para elegir entre "Canillitas" o "Accionistas".
    -   La vista del reporte se simplifica en el modo "TODAS", mostrando solo la tabla correspondiente y ocultando el resumen por tipo de vendedor.

-   **Backend (`ReportesService`, `ReportesRepository`):**
    -   Se modifica el servicio para recibir el parámetro `esAccionista`.
    -   Se implementa una nueva lógica que, si `idEmpresa` es 0, llama a los nuevos procedimientos almacenados que consultan todas las empresas.
    -   Se ajusta el `ReportesController` para aceptar y pasar el nuevo parámetro.

### Correcciones

-   **Base de Datos:**
    -   Se añade la función SQL `FN_ObtenerPrecioVigente` para los nuevos Stored Procedures.
    -   Se crean los Stored Procedures `SP_DistCanillasEntradaSalidaPubli_AllEmpresas` y `SP_DistCanillasAccEntradaSalidaPubli_AllEmpresas`.
-   **Backend (`ReportesController`):**
    -   Se corrigen errores de compilación (`CS7036`, `CS8130`) añadiendo el parámetro `esAccionista` faltante en las llamadas al servicio `ObtenerReporteDistribucionCanillasAsync`.

### Mejoras Visuales

-   **PDF (`ControlDevolucionesDocument.cs`):**
    -   Se ajustan los espaciados verticales (`Padding` y `Spacing`) en la plantilla QuestPDF del reporte "Control de Devoluciones" para lograr un diseño más compacto.
2025-11-04 11:51:43 -03:00
e123dae182 Fix Permisos reportes secretaría y Dropdown de reporte
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m27s
2025-10-31 13:00:19 -03:00
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
221 changed files with 11027 additions and 2645 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"

5
.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
@@ -164,3 +161,5 @@ junit.xml
# =================================================================== # ===================================================================
# Fin del archivo .gitignore # Fin del archivo .gitignore
# =================================================================== # ===================================================================
Backend/SQL
.atl

View File

@@ -51,6 +51,7 @@ namespace GestionIntegral.Api.Controllers
private readonly IPerfilService _perfilService; private readonly IPerfilService _perfilService;
private readonly IPermisoService _permisoService; private readonly IPermisoService _permisoService;
private readonly ICambioParadaService _cambioParadaService; private readonly ICambioParadaService _cambioParadaService;
private readonly ICierreCuentaCorrienteService _cierreCcService;
private readonly ILogger<AuditoriaController> _logger; private readonly ILogger<AuditoriaController> _logger;
// Permiso general para ver cualquier auditoría. // Permiso general para ver cualquier auditoría.
@@ -86,6 +87,7 @@ namespace GestionIntegral.Api.Controllers
IPerfilService perfilService, IPerfilService perfilService,
IPermisoService permisoService, IPermisoService permisoService,
ICambioParadaService cambioParadaService, ICambioParadaService cambioParadaService,
ICierreCuentaCorrienteService cierreCcService,
ILogger<AuditoriaController> logger) ILogger<AuditoriaController> logger)
{ {
_usuarioService = usuarioService; _usuarioService = usuarioService;
@@ -116,6 +118,7 @@ namespace GestionIntegral.Api.Controllers
_perfilService = perfilService; _perfilService = perfilService;
_cambioParadaService = cambioParadaService; _cambioParadaService = cambioParadaService;
_permisoService = permisoService; _permisoService = permisoService;
_cierreCcService = cierreCcService;
_logger = logger; _logger = logger;
} }
@@ -692,6 +695,26 @@ namespace GestionIntegral.Api.Controllers
} }
} }
[HttpGet("cierres-cuenta-corriente")]
[ProducesResponseType(typeof(IEnumerable<CierreCuentaCorrienteHistorialDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistorialCierresCuentaCorriente(
[FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta,
[FromQuery] int? idUsuarioModifico, [FromQuery] string? tipoModificacion,
[FromQuery] int? idCierreAfectado)
{
if (!TienePermiso(PermisoVerAuditoria)) return Forbid();
try
{
var historial = await _cierreCcService.ObtenerHistorialAsync(fechaDesde, fechaHasta, idUsuarioModifico, tipoModificacion, idCierreAfectado);
return Ok(historial ?? Enumerable.Empty<CierreCuentaCorrienteHistorialDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error obteniendo historial de Cierres de Cuenta Corriente.");
return StatusCode(500, "Error interno al obtener historial de Cierres de Cuenta Corriente.");
}
}
[HttpGet("cambios-parada-canilla")] [HttpGet("cambios-parada-canilla")]
[ProducesResponseType(typeof(IEnumerable<CambioParadaHistorialDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<CambioParadaHistorialDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistorialCambiosParada( public async Task<IActionResult> GetHistorialCambiosParada(

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

@@ -0,0 +1,168 @@
using GestionIntegral.Api.Dtos.Auditoria;
using GestionIntegral.Api.Dtos.Contables;
using GestionIntegral.Api.Services.Contables;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Controllers.Contables
{
[Route("api/cierres-cc")]
[ApiController]
[Authorize]
public class CierresCuentaCorrienteController : ControllerBase
{
private readonly ICierreCuentaCorrienteService _cierreService;
private readonly ILogger<CierresCuentaCorrienteController> _logger;
// Permisos asignables a perfiles. La reapertura (CC002) NO se valida acá: es exclusiva de SuperAdmin.
private const string PermisoCrear = "CC001";
private const string PermisoVer = "CC003";
public CierresCuentaCorrienteController(
ICierreCuentaCorrienteService cierreService,
ILogger<CierresCuentaCorrienteController> logger)
{
_cierreService = cierreService;
_logger = logger;
}
private bool TienePermiso(string codAcc) =>
User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc);
private bool EsSuperAdmin() => User.IsInRole("SuperAdmin");
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 CierresCuentaCorrienteController.");
return null;
}
// POST: api/cierres-cc
[HttpPost]
[ProducesResponseType(typeof(CierreCuentaCorrienteDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Crear([FromBody] CrearCierreDto dto)
{
if (!TienePermiso(PermisoCrear)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (cierre, errorCode, errorMessage) = await _cierreService.CrearCierreAsync(dto, userId.Value);
if (errorCode != null)
{
int status = errorCode switch
{
"CIERRE_FECHA_ANTERIOR_A_ULTIMO" => StatusCodes.Status409Conflict,
"CIERRE_ERROR_INTERNO" => StatusCodes.Status500InternalServerError,
_ => StatusCodes.Status400BadRequest
};
return StatusCode(status, new { codigo = errorCode, mensaje = errorMessage });
}
return StatusCode(StatusCodes.Status201Created, cierre);
}
// POST: api/cierres-cc/{idCierre}/reabrir
[HttpPost("{idCierre:int}/reabrir")]
[ProducesResponseType(typeof(CierreCuentaCorrienteDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Reabrir(int idCierre, [FromBody] ReabrirCierreDto dto)
{
if (!EsSuperAdmin())
return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (cierre, errorCode, errorMessage) = await _cierreService.ReabrirCierreAsync(idCierre, dto, userId.Value, esSuperAdmin: true);
if (errorCode != null)
{
int status = errorCode switch
{
"CIERRE_NO_ENCONTRADO" => StatusCodes.Status404NotFound,
"CIERRE_PERMISO_DENEGADO" => StatusCodes.Status403Forbidden,
"CIERRE_HAY_POSTERIORES_VIGENTES" => StatusCodes.Status409Conflict,
"CIERRE_YA_ANULADO" => StatusCodes.Status409Conflict,
"CIERRE_ERROR_INTERNO" => StatusCodes.Status500InternalServerError,
_ => StatusCodes.Status400BadRequest
};
return StatusCode(status, new { codigo = errorCode, mensaje = errorMessage });
}
return Ok(cierre);
}
// GET: api/cierres-cc?idDistribuidor=&idEmpresa=&estado=&fechaCorteDesde=&fechaCorteHasta=
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<CierreCuentaCorrienteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetAll(
[FromQuery] int? idDistribuidor,
[FromQuery] int? idEmpresa,
[FromQuery] string? estado,
[FromQuery] DateTime? fechaCorteDesde,
[FromQuery] DateTime? fechaCorteHasta)
{
if (!TienePermiso(PermisoVer)) return Forbid();
var cierres = await _cierreService.GetAllAsync(idDistribuidor, idEmpresa, estado, fechaCorteDesde, fechaCorteHasta);
return Ok(cierres);
}
// GET: api/cierres-cc/{idCierre}
[HttpGet("{idCierre:int}")]
[ProducesResponseType(typeof(CierreCuentaCorrienteDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int idCierre)
{
if (!TienePermiso(PermisoVer)) return Forbid();
var cierre = await _cierreService.GetByIdAsync(idCierre);
if (cierre == null) return NotFound(new { message = $"Cierre #{idCierre} no encontrado." });
return Ok(cierre);
}
// GET: api/cierres-cc/ultimo?idDistribuidor=&idEmpresa=
// Atajo del frontend para autorrellenar "Desde último cierre" en filtros del reporte.
// Acepta CC003 (gestión de cierres) o RR001 (acceso al reporte que CONTIENE este atajo):
// operadores con solo el permiso del reporte deben poder usar el atajo desde la pantalla del reporte.
[HttpGet("ultimo")]
[ProducesResponseType(typeof(UltimoCierreDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetUltimoVigente(
[FromQuery] int idDistribuidor,
[FromQuery] int idEmpresa)
{
if (!TienePermiso(PermisoVer) && !TienePermiso("RR001")) return Forbid();
var ultimo = await _cierreService.GetUltimoVigenteAsync(idDistribuidor, idEmpresa);
if (ultimo == null) return NotFound(new { message = "No hay cierres vigentes para el distribuidor en la empresa indicada." });
return Ok(ultimo);
}
// GET: api/cierres-cc/{idCierre}/historial
[HttpGet("{idCierre:int}/historial")]
[ProducesResponseType(typeof(IEnumerable<CierreCuentaCorrienteHistorialDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetHistorial(int idCierre)
{
if (!TienePermiso(PermisoVer)) return Forbid();
var historial = await _cierreService.GetHistorialAsync(idCierre);
return Ok(historial);
}
}
}

View File

@@ -18,9 +18,8 @@ namespace GestionIntegral.Api.Controllers.Contables
private readonly ISaldoService _saldoService; private readonly ISaldoService _saldoService;
private readonly ILogger<SaldosController> _logger; private readonly ILogger<SaldosController> _logger;
// Define un permiso específico para ver saldos, y otro para ajustarlos (SuperAdmin implícito) // Permiso para ver saldos. El ajuste manual es exclusivo de SuperAdmin (no se valida un permiso asignable).
private const string PermisoVerSaldos = "CS001"; // Ejemplo: Cuentas Saldos Ver private const string PermisoVerSaldos = "CS001";
private const string PermisoAjustarSaldos = "CS002"; // Ejemplo: Cuentas Saldos Ajustar (o solo SuperAdmin)
public SaldosController(ISaldoService saldoService, ILogger<SaldosController> logger) public SaldosController(ISaldoService saldoService, ILogger<SaldosController> logger)
@@ -76,11 +75,11 @@ namespace GestionIntegral.Api.Controllers.Contables
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AjustarSaldoManualmente([FromBody] AjusteSaldoRequestDto ajusteDto) public async Task<IActionResult> AjustarSaldoManualmente([FromBody] AjusteSaldoRequestDto ajusteDto)
{ {
// Esta operación debería ser MUY restringida. Solo SuperAdmin o un permiso muy específico. // El ajuste manual de saldo es operación crítica: solo SuperAdmin. No se admite vía permiso asignable.
if (!User.IsInRole("SuperAdmin") && !TienePermiso(PermisoAjustarSaldos)) if (!User.IsInRole("SuperAdmin"))
{ {
_logger.LogWarning("Intento no autorizado de ajustar saldo por Usuario ID {userId}", GetCurrentUserId() ?? 0); _logger.LogWarning("Intento no autorizado de ajustar saldo por Usuario ID {userId}", GetCurrentUserId() ?? 0);
return Forbid("No tiene permisos para realizar ajustes manuales de saldo."); return Forbid("Solo SuperAdmin puede ajustar saldos manualmente.");
} }
if (!ModelState.IsValid) return BadRequest(ModelState); if (!ModelState.IsValid) return BadRequest(ModelState);
@@ -99,6 +98,10 @@ namespace GestionIntegral.Api.Controllers.Contables
} }
return Ok(saldoActualizado); return Ok(saldoActualizado);
} }
catch (BloqueoPorPeriodoCerradoException)
{
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error crítico al ajustar saldo manualmente."); _logger.LogError(ex, "Error crítico al ajustar saldo manualmente.");

View File

@@ -40,19 +40,19 @@ namespace GestionIntegral.Api.Controllers.Distribucion
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(IEnumerable<DistribuidorDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<DistribuidorDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetAllDistribuidores([FromQuery] string? nombre, [FromQuery] string? nroDoc) public async Task<IActionResult> GetAllDistribuidores([FromQuery] string? nombre, [FromQuery] string? nroDoc, [FromQuery] bool? soloActivos = true)
{ {
if (!TienePermiso(PermisoVer)) return Forbid(); if (!TienePermiso(PermisoVer)) return Forbid();
var distribuidores = await _distribuidorService.ObtenerTodosAsync(nombre, nroDoc); var distribuidores = await _distribuidorService.ObtenerTodosAsync(nombre, nroDoc, soloActivos);
return Ok(distribuidores); return Ok(distribuidores);
} }
[HttpGet("dropdown")] [HttpGet("dropdown")]
[ProducesResponseType(typeof(IEnumerable<DistribuidorDropdownDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<DistribuidorDropdownDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetAllDropdownDistribuidores() public async Task<IActionResult> GetAllDropdownDistribuidores([FromQuery] bool? soloActivos = true)
{ {
var distribuidores = await _distribuidorService.GetAllDropdownAsync(); var distribuidores = await _distribuidorService.GetAllDropdownAsync(soloActivos);
return Ok(distribuidores); return Ok(distribuidores);
} }
@@ -117,6 +117,27 @@ namespace GestionIntegral.Api.Controllers.Distribucion
return NoContent(); return NoContent();
} }
[HttpPut("{id:int}/toggle-baja")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ToggleBajaDistribuidor(int id, [FromBody] ToggleBajaDistribuidorDto dto)
{
if (!TienePermiso(PermisoModificar)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _distribuidorService.ToggleBajaAsync(id, dto.DarDeBaja, dto.FechaBaja, userId.Value);
if (!exito)
{
if (error == "Distribuidor no encontrado.") return NotFound(new { message = error });
return BadRequest(new { message = error });
}
return NoContent();
}
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]

View File

@@ -2,6 +2,7 @@ using GestionIntegral.Api.Dtos.Impresion;
using GestionIntegral.Api.Services.Impresion; using GestionIntegral.Api.Services.Impresion;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@@ -40,6 +41,7 @@ namespace GestionIntegral.Api.Controllers.Impresion
return null; return null;
} }
// GET: api/stockbobinas
// GET: api/stockbobinas // GET: api/stockbobinas
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(IEnumerable<StockBobinaDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<StockBobinaDto>), StatusCodes.Status200OK)]
@@ -47,12 +49,23 @@ namespace GestionIntegral.Api.Controllers.Impresion
public async Task<IActionResult> GetAllStockBobinas( public async Task<IActionResult> GetAllStockBobinas(
[FromQuery] int? idTipoBobina, [FromQuery] string? nroBobina, [FromQuery] int? idPlanta, [FromQuery] int? idTipoBobina, [FromQuery] string? nroBobina, [FromQuery] int? idPlanta,
[FromQuery] int? idEstadoBobina, [FromQuery] string? remito, [FromQuery] int? idEstadoBobina, [FromQuery] string? remito,
[FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta) [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta,
[FromQuery] DateTime? fechaEstadoDesde, [FromQuery] DateTime? fechaEstadoHasta) // <--- Nuevos parámetros agregados
{ {
if (!TienePermiso(PermisoVerStock)) return Forbid(); if (!TienePermiso(PermisoVerStock)) return Forbid();
try try
{ {
var bobinas = await _stockBobinaService.ObtenerTodosAsync(idTipoBobina, nroBobina, idPlanta, idEstadoBobina, remito, fechaDesde, fechaHasta); var bobinas = await _stockBobinaService.ObtenerTodosAsync(
idTipoBobina,
nroBobina,
idPlanta,
idEstadoBobina,
remito,
fechaDesde,
fechaHasta,
fechaEstadoDesde,
fechaEstadoHasta
);
return Ok(bobinas); return Ok(bobinas);
} }
catch (Exception ex) catch (Exception ex)
@@ -172,5 +185,72 @@ namespace GestionIntegral.Api.Controllers.Impresion
} }
return NoContent(); return NoContent();
} }
// GET: api/stockbobinas/verificar-remito
[HttpGet("verificar-remito")]
[ProducesResponseType(typeof(IEnumerable<StockBobinaDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
// CAMBIO: Hacer fechaRemito opcional (nullable)
public async Task<IActionResult> VerificarRemito([FromQuery, BindRequired] int idPlanta, [FromQuery, BindRequired] string remito, [FromQuery] DateTime? fechaRemito)
{
if (!TienePermiso(PermisoIngresarBobina)) return Forbid();
try
{
// Pasamos el parámetro nullable al servicio
var bobinasExistentes = await _stockBobinaService.VerificarRemitoExistenteAsync(idPlanta, remito, fechaRemito);
return Ok(bobinasExistentes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al verificar remito {Remito} para planta {IdPlanta}", remito, idPlanta);
return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al verificar el remito.");
}
}
[HttpPut("actualizar-fecha-remito")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ActualizarFechaRemitoLote([FromBody] UpdateFechaRemitoLoteDto dto)
{
// Reutilizamos el permiso de modificar datos, ya que es una corrección.
if (!TienePermiso(PermisoModificarDatos)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _stockBobinaService.ActualizarFechaRemitoLoteAsync(dto, userId.Value);
if (!exito)
{
return BadRequest(new { message = error });
}
return NoContent();
}
// POST: api/stockbobinas/lote
[HttpPost("lote")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> IngresarLoteBobinas([FromBody] CreateStockBobinaLoteDto loteDto)
{
if (!TienePermiso(PermisoIngresarBobina)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _stockBobinaService.IngresarBobinaLoteAsync(loteDto, userId.Value);
if (!exito)
{
return BadRequest(new { message = error });
}
return NoContent(); // 204 es una buena respuesta para un lote procesado exitosamente sin devolver contenido.
}
} }
} }

View File

@@ -41,9 +41,11 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
void ComposeContent(IContainer container) void ComposeContent(IContainer container)
{ {
container.PaddingTop(1, Unit.Centimetre).Column(column => // << CAMBIO: Reducido el padding superior de 1cm a 5mm >>
container.PaddingTop(5, Unit.Millimetre).Column(column =>
{ {
column.Spacing(15); // << CAMBIO: Reducido el espaciado principal entre elementos de 15 a 10 puntos >>
column.Spacing(10);
column.Item().Row(row => column.Item().Row(row =>
{ {
@@ -59,23 +61,24 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
}); });
}); });
column.Item().PaddingTop(5).Border(1).Background(Colors.Grey.Lighten3).AlignCenter().Padding(2).Text(Model.NombreEmpresa).SemiBold(); column.Item().PaddingTop(3).Border(1).Background(Colors.Grey.Lighten3).AlignCenter().Padding(2).Text(Model.NombreEmpresa).SemiBold();
column.Item().Border(1).Padding(10).Column(innerCol => column.Item().Border(1).Padding(8).Column(innerCol => // << CAMBIO: Padding reducido de 10 a 8 >>
{ {
innerCol.Spacing(5); // << CAMBIO: Reducido el espaciado interno de 5 a 4 >>
innerCol.Spacing(4);
// Fila de "Ingresados por Remito" con borde inferior sólido.
innerCol.Item().BorderBottom(1, Unit.Point).BorderColor(Colors.Grey.Medium).Row(row => innerCol.Item().BorderBottom(1, Unit.Point).BorderColor(Colors.Grey.Medium).Row(row =>
{ {
row.RelativeItem().Text("Ingresados por Remito:").SemiBold(); row.RelativeItem().Text("Ingresados por Remito:").SemiBold();
row.RelativeItem().AlignRight().Text(Model.TotalIngresadosPorRemito.ToString("N0")); row.RelativeItem().AlignRight().Text(Model.TotalIngresadosPorRemito.ToString("N0"));
}); // <-- SOLUCIÓN: Borde sólido simple. });
foreach (var item in Model.Detalles) foreach (var item in Model.Detalles)
{ {
var totalSeccion = item.Devueltos - item.Llevados; var totalSeccion = item.Devueltos - item.Llevados;
innerCol.Item().PaddingTop(5).Row(row => // << CAMBIO: Reducido el padding superior de 5 a 3 >>
innerCol.Item().PaddingTop(3).Row(row =>
{ {
row.ConstantItem(100).Text(item.Tipo).SemiBold(); row.ConstantItem(100).Text(item.Tipo).SemiBold();
@@ -90,7 +93,8 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
r.RelativeItem().Text("Devueltos"); r.RelativeItem().Text("Devueltos");
r.RelativeItem().AlignRight().Text($"{item.Devueltos:N0}"); r.RelativeItem().AlignRight().Text($"{item.Devueltos:N0}");
}); });
sub.Item().BorderTop(1.5f).BorderColor(Colors.Black).PaddingTop(2).Row(r => { // << CAMBIO: Reducido el padding superior de 2 a 1 >>
sub.Item().BorderTop(1.5f).BorderColor(Colors.Black).PaddingTop(1).Row(r => {
r.RelativeItem().Text(t => t.Span("Total").SemiBold()); r.RelativeItem().Text(t => t.Span("Total").SemiBold());
r.RelativeItem().AlignRight().Text(t => t.Span(totalSeccion.ToString("N0")).SemiBold()); r.RelativeItem().AlignRight().Text(t => t.Span(totalSeccion.ToString("N0")).SemiBold());
}); });
@@ -99,7 +103,8 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
} }
}); });
column.Item().PaddingTop(10).Column(finalCol => // << CAMBIO: Reducido el padding superior de 10 a 8 >>
column.Item().PaddingTop(8).Column(finalCol =>
{ {
finalCol.Spacing(2); finalCol.Spacing(2);
@@ -112,13 +117,15 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
if (isBold) valueText.SemiBold(); if (isBold) valueText.SemiBold();
}; };
// Usamos bordes superiores para separar las líneas de total // << CAMBIO: Reducido el padding superior de 2 a 1 en las siguientes líneas >>
finalCol.Item().BorderTop(2f).BorderColor(Colors.Black).PaddingTop(2).Row(row => AddTotalRow(row, "Total Devolución a la Fecha", Model.TotalDevolucionALaFecha.ToString("N0"), false)); finalCol.Item().BorderTop(2f).BorderColor(Colors.Black).PaddingTop(1).Row(row => AddTotalRow(row, "Total Devolución a la Fecha", Model.TotalDevolucionALaFecha.ToString("N0"), false));
finalCol.Item().BorderTop(1).BorderColor(Colors.Grey.Lighten2).PaddingTop(2).Row(row => AddTotalRow(row, "Total Devolución Días Anteriores", Model.TotalDevolucionDiasAnteriores.ToString("N0"), false)); finalCol.Item().BorderTop(1).BorderColor(Colors.Grey.Lighten2).PaddingTop(1).Row(row => AddTotalRow(row, "Total Devolución Días Anteriores", Model.TotalDevolucionDiasAnteriores.ToString("N0"), false));
finalCol.Item().BorderTop(1).BorderColor(Colors.Grey.Lighten2).PaddingTop(2).Row(row => AddTotalRow(row, "Total Devolución", Model.TotalDevolucionGeneral.ToString("N0"), false)); finalCol.Item().BorderTop(1).BorderColor(Colors.Grey.Lighten2).PaddingTop(1).Row(row => AddTotalRow(row, "Total Devolución", Model.TotalDevolucionGeneral.ToString("N0"), false));
finalCol.Item().BorderTop(2f).BorderColor(Colors.Black).PaddingTop(5).Row(row => AddTotalRow(row, "Sin Cargo", Model.TotalSinCargo.ToString("N0"), false)); // << CAMBIO: Reducido el padding superior de 5 a 3 >>
finalCol.Item().BorderTop(1).BorderColor(Colors.Grey.Lighten2).PaddingTop(2).Row(row => AddTotalRow(row, "Sobrantes", $"-{Model.TotalSobrantes.ToString("N0")}", false)); finalCol.Item().BorderTop(2f).BorderColor(Colors.Black).PaddingTop(3).Row(row => AddTotalRow(row, "Sin Cargo", Model.TotalSinCargo.ToString("N0"), false));
finalCol.Item().BorderTop(1).BorderColor(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingTop(5).Row(row => AddTotalRow(row, "Diferencia", Model.DiferenciaFinal.ToString("N0"), true)); finalCol.Item().BorderTop(1).BorderColor(Colors.Grey.Lighten2).PaddingTop(1).Row(row => AddTotalRow(row, "Sobrantes", $"-{Model.TotalSobrantes.ToString("N0")}", false));
// << CAMBIO: Reducido el padding superior de 5 a 3 >>
finalCol.Item().BorderTop(1).BorderColor(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingTop(3).Row(row => AddTotalRow(row, "Diferencia", Model.DiferenciaFinal.ToString("N0"), true));
}); });
}); });
} }

View File

@@ -74,7 +74,6 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
if (Model.DebitosCreditos.Any()) column.Item().Element(ComposeDebCredTable); if (Model.DebitosCreditos.Any()) column.Item().Element(ComposeDebCredTable);
column.Item().Element(ComposeResumenPeriodo); column.Item().Element(ComposeResumenPeriodo);
column.Item().Element(ComposeSaldoFinal);
}); });
} }
@@ -107,7 +106,11 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
header.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(2).AlignRight().Text("Saldo"); header.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(2).AlignRight().Text("Saldo");
}); });
decimal saldoAcumulado = 0; // Inicia en CERO // Fila Saldo Inicial al inicio de la primera tabla — ancla del acumulado
table.Cell().ColumnSpan(6).Border(1).Padding(2).Text(t => t.Span("Saldo Inicial").SemiBold());
table.Cell().Border(1).Padding(2).AlignRight().Text(t => t.Span(Model.SaldoInicial.ToString("C", CultureAr)).SemiBold());
decimal saldoAcumulado = Model.SaldoInicial;
foreach (var item in Model.Movimientos.OrderBy(m => m.Fecha)) foreach (var item in Model.Movimientos.OrderBy(m => m.Fecha))
{ {
saldoAcumulado += (item.Debe - item.Haber); saldoAcumulado += (item.Debe - item.Haber);
@@ -157,7 +160,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
header.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(2).Text("Detalle"); header.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(2).Text("Detalle");
}); });
decimal saldoAcumulado = Model.TotalMovimientos; decimal saldoAcumulado = Model.SaldoInicial + Model.TotalMovimientos;
foreach (var item in Model.Pagos.OrderBy(p => p.Fecha).ThenBy(p => p.Recibo)) foreach (var item in Model.Pagos.OrderBy(p => p.Fecha).ThenBy(p => p.Recibo))
{ {
saldoAcumulado += (item.Debe - item.Haber); saldoAcumulado += (item.Debe - item.Haber);
@@ -204,7 +207,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
header.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(2).AlignRight().Text("Saldo"); header.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(2).AlignRight().Text("Saldo");
}); });
decimal saldoAcumulado = Model.TotalMovimientos + Model.TotalPagos; decimal saldoAcumulado = Model.SaldoInicial + Model.TotalMovimientos + Model.TotalPagos;
foreach (var item in Model.DebitosCreditos.OrderBy(dc => dc.Fecha)) foreach (var item in Model.DebitosCreditos.OrderBy(dc => dc.Fecha))
{ {
saldoAcumulado += (item.Debe - item.Haber); saldoAcumulado += (item.Debe - item.Haber);
@@ -236,25 +239,17 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
row.RelativeItem().Text(label); row.RelativeItem().Text(label);
row.ConstantItem(120).AlignRight().Text(value.ToString("C", CultureAr)); row.ConstantItem(120).AlignRight().Text(value.ToString("C", CultureAr));
}; };
col.Item().Row(row => AddResumenRow(row, "Saldo Inicial", Model.SaldoInicial));
col.Item().Row(row => AddResumenRow(row, "Movimientos", Model.TotalMovimientos)); col.Item().Row(row => AddResumenRow(row, "Movimientos", Model.TotalMovimientos));
col.Item().Row(row => AddResumenRow(row, "Débitos/Créditos", Model.TotalDebitosCreditos)); col.Item().Row(row => AddResumenRow(row, "Débitos/Créditos", Model.TotalDebitosCreditos));
col.Item().Row(row => AddResumenRow(row, "Pagos", Model.TotalPagos)); col.Item().Row(row => AddResumenRow(row, "Pagos", Model.TotalPagos));
col.Item().BorderTop(1.5f).BorderColor(Colors.Black).PaddingTop(5).Row(row => col.Item().BorderTop(1.5f).BorderColor(Colors.Black).PaddingTop(5).Row(row =>
{ {
row.RelativeItem().Text("Total").SemiBold(); row.RelativeItem().Text("Saldo Final").SemiBold();
row.ConstantItem(120).AlignRight().Text(t => t.Span(Model.TotalPeriodo.ToString("C", CultureAr)).SemiBold()); row.ConstantItem(120).AlignRight().Text(t => t.Span(Model.SaldoFinal.ToString("C", CultureAr)).SemiBold());
}); });
}); });
}); });
} }
void ComposeSaldoFinal(IContainer container)
{
container.PaddingTop(5, Unit.Millimetre).AlignLeft().Text(text =>
{
text.Span($"Saldo Total del Distribuidor al {Model.FechaReporte} ").SemiBold().FontSize(12);
text.Span(Model.SaldoDeCuenta.ToString("C", CultureAr)).SemiBold().FontSize(12);
});
}
} }
} }

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

@@ -19,22 +19,31 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
public DocumentMetadata GetMetadata() => DocumentMetadata.Default; public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
// CORRECCIÓN: El método GetSettings ya no es necesario para este diseño.
// La configuración por defecto es suficiente.
// public DocumentSettings GetSettings() => DocumentSettings.Default;
public void Compose(IDocumentContainer container) public void Compose(IDocumentContainer container)
{ {
container.Page(page => container.Page(page =>
{ {
page.Size(PageSizes.A5); page.Size(PageSizes.A4);
page.Margin(1, Unit.Centimetre); page.Margin(5, Unit.Millimetre);
page.DefaultTextStyle(x => x.FontFamily("Arial").FontSize(9)); page.DefaultTextStyle(x => x.FontFamily("Arial").FontSize(9));
page.Header().Element(ComposeHeader); page.Content().Column(mainColumn =>
page.Content().Element(ComposeContent); {
mainColumn.Item()
.AlignCenter()
.Width(PageSizes.A6.Width)
.Height(PageSizes.A6.Height)
.Column(a6ContentColumn =>
{
a6ContentColumn.Item().PaddingRight(10, Unit.Millimetre).PaddingLeft(10, Unit.Millimetre).Column(content =>
{
content.Item().Element(ComposeHeader);
content.Item().Element(ComposeContent);
}); });
} });
});
});
}
void ComposeHeader(IContainer container) void ComposeHeader(IContainer container)
{ {

View File

@@ -148,7 +148,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
foreach (var item in Model.PromediosPorDia.OrderBy(d => dayOrder.GetValueOrDefault(d.Dia, 99))) foreach (var item in Model.PromediosPorDia.OrderBy(d => dayOrder.GetValueOrDefault(d.Dia, 99)))
{ {
var porcDevolucion = item.Llevados > 0 ? (decimal)item.Devueltos * 100 / item.Llevados : 0; var porcDevolucion = item.Promedio_Llevados > 0 ? (decimal)item.Promedio_Devueltos * 100 / item.Promedio_Llevados : 0;
table.Cell().Border(1).Padding(3).Text(item.Dia); table.Cell().Border(1).Padding(3).Text(item.Dia);
table.Cell().Border(1).Padding(3).AlignRight().Text(item.Cant.ToString("N0")); table.Cell().Border(1).Padding(3).AlignRight().Text(item.Cant.ToString("N0"));
@@ -162,7 +162,6 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
var general = Model.PromedioGeneral; var general = Model.PromedioGeneral;
if (general != null) if (general != null)
{ {
var porcDevolucionGeneral = general.Promedio_Llevados > 0 ? (decimal)general.Promedio_Devueltos * 100 / general.Promedio_Llevados : 0;
var boldStyle = TextStyle.Default.SemiBold(); var boldStyle = TextStyle.Default.SemiBold();
table.Cell().Border(1).Padding(3).Text(text => text.Span(general.Dia).Style(boldStyle)); table.Cell().Border(1).Padding(3).Text(text => text.Span(general.Dia).Style(boldStyle));
@@ -170,7 +169,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Llevados.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Llevados.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Devueltos.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Devueltos.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Ventas.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Ventas.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(porcDevolucionGeneral.ToString("F2") + "%").Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(Model.PorcentajeDevolucionGeneral.ToString("F2") + "%").Style(boldStyle));
} }
}); });
}); });

View File

@@ -90,7 +90,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
var llevados = item.Llevados ?? 0; var llevados = item.Llevados ?? 0;
var devueltos = item.Devueltos ?? 0; var devueltos = item.Devueltos ?? 0;
var ventaNetaDia = llevados - devueltos; var ventaNetaDia = llevados - devueltos;
if(llevados > 0) if (llevados > 0)
{ {
ventaNetaAcumulada += ventaNetaDia; ventaNetaAcumulada += ventaNetaDia;
conteoDias++; conteoDias++;
@@ -123,7 +123,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
void ComposePromediosTable(IContainer container) void ComposePromediosTable(IContainer container)
{ {
var dayOrder = new Dictionary<string, int> { { "Lunes", 1 }, { "Martes", 2 }, { "Miércoles", 3 }, { "Jueves", 4 }, { "Viernes", 5 }, { "Sábado", 6 }, { "Domingo", 7 }}; var dayOrder = new Dictionary<string, int> { { "Lunes", 1 }, { "Martes", 2 }, { "Miércoles", 3 }, { "Jueves", 4 }, { "Viernes", 5 }, { "Sábado", 6 }, { "Domingo", 7 } };
container.Table(table => container.Table(table =>
{ {
@@ -161,16 +161,16 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
if (general != null) if (general != null)
{ {
var boldStyle = TextStyle.Default.SemiBold(); var boldStyle = TextStyle.Default.SemiBold();
var llevadosGeneral = general.Llevados ?? 0; // Usamos el total para el % var avgPercentage = Model.PromediosPorDia
var devueltosGeneral = general.Devueltos ?? 0; // Usamos el total para el % .Where(p => p.Dia != "General" && (p.Promedio_Llevados ?? 0) > 0)
var porcDevolucionGeneral = llevadosGeneral > 0 ? (decimal)devueltosGeneral * 100 / llevadosGeneral : 0; .Average(p => (decimal)(p.Promedio_Devueltos ?? 0) * 100 / (p.Promedio_Llevados ?? 1));
table.Cell().Border(1).Padding(3).Text(t => t.Span(general.Dia).Style(boldStyle)); table.Cell().Border(1).Padding(3).Text(t => t.Span(general.Dia).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Cant?.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Cant?.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Llevados?.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Llevados?.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Devueltos?.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Devueltos?.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Ventas?.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Ventas?.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(porcDevolucionGeneral.ToString("F2") + "%").Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(avgPercentage.ToString("F2") + "%").Style(boldStyle));
} }
}); });
} }

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,9 @@ 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";
private const string PermisoVerReportesSecretaria = "RR012";
public ReportesController( public ReportesController(
IReportesService reportesService, IReportesService reportesService,
@@ -531,7 +527,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetVentaMensualSecretariaElDia([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta) public async Task<IActionResult> GetVentaMensualSecretariaElDia([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta)
{ {
if (!TienePermiso(PermisoVerListadoDistribucion)) return Forbid(); // Asumiendo RR002 para todos estos if (!TienePermiso(PermisoVerReportesSecretaria)) return Forbid(); // Asumiendo RR002 para todos estos
var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaElDiaAsync(fechaDesde, fechaHasta); var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaElDiaAsync(fechaDesde, fechaHasta);
if (error != null) return BadRequest(new { message = error }); if (error != null) return BadRequest(new { message = error });
if (data == null || !data.Any()) return NotFound(new { message = "No hay datos para el reporte de ventas 'El Día'." }); if (data == null || !data.Any()) return NotFound(new { message = "No hay datos para el reporte de ventas 'El Día'." });
@@ -545,7 +541,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetVentaMensualSecretariaElDiaPdf([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta) public async Task<IActionResult> GetVentaMensualSecretariaElDiaPdf([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta)
{ {
if (!TienePermiso(PermisoVerListadoDistribucion)) return Forbid(); if (!TienePermiso(PermisoVerReportesSecretaria)) return Forbid();
var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaElDiaAsync(fechaDesde, fechaHasta); var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaElDiaAsync(fechaDesde, fechaHasta);
@@ -582,7 +578,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetVentaMensualSecretariaElPlata([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta) public async Task<IActionResult> GetVentaMensualSecretariaElPlata([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta)
{ {
if (!TienePermiso(PermisoVerListadoDistribucion)) return Forbid(); // Asumiendo RR002 if (!TienePermiso(PermisoVerReportesSecretaria)) return Forbid(); // Asumiendo RR002
var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaElPlataAsync(fechaDesde, fechaHasta); var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaElPlataAsync(fechaDesde, fechaHasta);
if (error != null) return BadRequest(new { message = error }); if (error != null) return BadRequest(new { message = error });
if (data == null || !data.Any()) return NotFound(new { message = "No hay datos para el reporte de ventas 'El Plata'." }); if (data == null || !data.Any()) return NotFound(new { message = "No hay datos para el reporte de ventas 'El Plata'." });
@@ -596,7 +592,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetVentaMensualSecretariaElPlataPdf([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta) public async Task<IActionResult> GetVentaMensualSecretariaElPlataPdf([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta)
{ {
if (!TienePermiso(PermisoVerListadoDistribucion)) return Forbid(); if (!TienePermiso(PermisoVerReportesSecretaria)) return Forbid();
var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaElPlataAsync(fechaDesde, fechaHasta); var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaElPlataAsync(fechaDesde, fechaHasta);
@@ -633,7 +629,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetVentaMensualSecretariaTirDevo([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta) public async Task<IActionResult> GetVentaMensualSecretariaTirDevo([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta)
{ {
if (!TienePermiso(PermisoVerListadoDistribucion)) return Forbid(); // Asumiendo RR002 if (!TienePermiso(PermisoVerReportesSecretaria)) return Forbid(); // Asumiendo RR002
var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaTirDevoAsync(fechaDesde, fechaHasta); var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaTirDevoAsync(fechaDesde, fechaHasta);
if (error != null) return BadRequest(new { message = error }); if (error != null) return BadRequest(new { message = error });
if (data == null || !data.Any()) return NotFound(new { message = "No hay datos para el reporte de tirada/devolución." }); if (data == null || !data.Any()) return NotFound(new { message = "No hay datos para el reporte de tirada/devolución." });
@@ -647,7 +643,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetVentaMensualSecretariaTirDevoPdf([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta) public async Task<IActionResult> GetVentaMensualSecretariaTirDevoPdf([FromQuery] DateTime fechaDesde, [FromQuery] DateTime fechaHasta)
{ {
if (!TienePermiso(PermisoVerListadoDistribucion)) return Forbid(); if (!TienePermiso(PermisoVerReportesSecretaria)) return Forbid();
var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaTirDevoAsync(fechaDesde, fechaHasta); var (data, error) = await _reportesService.ObtenerVentaMensualSecretariaTirDevoAsync(fechaDesde, fechaHasta);
@@ -682,13 +678,18 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetReporteDistribucionCanillasData([FromQuery] DateTime fecha, [FromQuery] int idEmpresa) public async Task<IActionResult> GetReporteDistribucionCanillasData(
[FromQuery] DateTime fecha,
[FromQuery] int idEmpresa,
[FromQuery] bool? esAccionista
)
{ {
if (!TienePermiso(PermisoVerComprobanteLiquidacionCanilla)) return Forbid(); if (!TienePermiso(PermisoVerComprobanteLiquidacionCanilla)) return Forbid();
// Pasar el nuevo parámetro al servicio
var (canillas, canillasAcc, canillasAll, canillasFechaLiq, canillasAccFechaLiq, var (canillas, canillasAcc, canillasAll, canillasFechaLiq, canillasAccFechaLiq,
ctrlDevolucionesRemitos, ctrlDevolucionesParaDistCan, ctrlDevolucionesOtrosDias, error) = ctrlDevolucionesRemitos, ctrlDevolucionesParaDistCan, ctrlDevolucionesOtrosDias, error) =
await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa); await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa, esAccionista);
if (error != null) return BadRequest(new { message = error }); if (error != null) return BadRequest(new { message = error });
@@ -723,14 +724,20 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetReporteDistribucionCanillasPdf([FromQuery] DateTime fecha, [FromQuery] int idEmpresa, [FromQuery] bool soloTotales = false) public async Task<IActionResult> GetReporteDistribucionCanillasPdf(
[FromQuery] DateTime fecha,
[FromQuery] int idEmpresa,
[FromQuery] bool? esAccionista,
[FromQuery] bool soloTotales = false
)
{ {
if (!TienePermiso(PermisoVerComprobanteLiquidacionCanilla)) return Forbid(); if (!TienePermiso(PermisoVerComprobanteLiquidacionCanilla)) return Forbid();
// Pasar el nuevo parámetro al servicio
var ( var (
canillas, canillasAcc, canillasAll, canillasFechaLiq, canillasAccFechaLiq, canillas, canillasAcc, canillasAll, canillasFechaLiq, canillasAccFechaLiq,
remitos, ctrlDevoluciones, _, error remitos, ctrlDevoluciones, _, error
) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa); ) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa, esAccionista);
if (error != null) return BadRequest(new { message = error }); if (error != null) return BadRequest(new { message = error });
@@ -799,11 +806,11 @@ namespace GestionIntegral.Api.Controllers
_, // canillasAll _, // canillasAll
_, // canillasFechaLiq _, // canillasFechaLiq
_, // canillasAccFechaLiq _, // canillasAccFechaLiq
ctrlDevolucionesRemitosData, // Para SP_ObtenerCtrlDevoluciones -> DataSet "DSObtenerCtrlDevoluciones" ctrlDevolucionesRemitosData,
ctrlDevolucionesParaDistCanData, // Para SP_DistCanillasCantidadEntradaSalida -> DataSet "DSCtrlDevoluciones" ctrlDevolucionesParaDistCanData,
ctrlDevolucionesOtrosDiasData, // Para SP_DistCanillasCantidadEntradaSalidaOtrosDias -> DataSet "DSCtrlDevolucionesOtrosDias" ctrlDevolucionesOtrosDiasData,
error error
) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa); // Reutilizamos este método ) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa, null);
if (error != null) return BadRequest(new { message = error }); if (error != null) return BadRequest(new { message = error });
@@ -837,7 +844,7 @@ namespace GestionIntegral.Api.Controllers
var ( var (
_, _, _, _, _, // Datos no utilizados _, _, _, _, _, // Datos no utilizados
remitos, detalles, otrosDias, error remitos, detalles, otrosDias, error
) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa); ) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa, null);
if (error != null) return BadRequest(new { message = error }); if (error != null) return BadRequest(new { message = error });
@@ -887,11 +894,11 @@ namespace GestionIntegral.Api.Controllers
{ {
if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid(); if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid();
var (entradasSalidas, debitosCreditos, pagos, saldos, error) = var (entradasSalidas, debitosCreditos, pagos, saldoInicial, error) =
await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta);
if (error != null) return BadRequest(new { message = error }); if (error != null) return BadRequest(new { message = error });
if (!entradasSalidas.Any() && !debitosCreditos.Any() && !pagos.Any() && !saldos.Any()) if (!entradasSalidas.Any() && !debitosCreditos.Any() && !pagos.Any() && saldoInicial == 0m)
{ {
return NotFound(new { message = "No hay datos para generar el reporte de cuenta del distribuidor." }); return NotFound(new { message = "No hay datos para generar el reporte de cuenta del distribuidor." });
} }
@@ -904,7 +911,7 @@ namespace GestionIntegral.Api.Controllers
EntradasSalidas = entradasSalidas ?? Enumerable.Empty<BalanceCuentaDistDto>(), EntradasSalidas = entradasSalidas ?? Enumerable.Empty<BalanceCuentaDistDto>(),
DebitosCreditos = debitosCreditos ?? Enumerable.Empty<BalanceCuentaDebCredDto>(), DebitosCreditos = debitosCreditos ?? Enumerable.Empty<BalanceCuentaDebCredDto>(),
Pagos = pagos ?? Enumerable.Empty<BalanceCuentaPagosDto>(), Pagos = pagos ?? Enumerable.Empty<BalanceCuentaPagosDto>(),
Saldos = saldos ?? Enumerable.Empty<SaldoDto>(), SaldoInicial = saldoInicial,
NombreDistribuidor = distribuidor.Distribuidor?.Nombre, NombreDistribuidor = distribuidor.Distribuidor?.Nombre,
NombreEmpresa = empresa?.Nombre NombreEmpresa = empresa?.Nombre
}; };
@@ -925,11 +932,11 @@ namespace GestionIntegral.Api.Controllers
{ {
if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid(); if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid();
var (entradasSalidas, debitosCreditos, pagos, saldos, error) = var (entradasSalidas, debitosCreditos, pagos, saldoInicial, error) =
await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta);
if (error != null) return BadRequest(new { message = error }); if (error != null) return BadRequest(new { message = error });
if (!entradasSalidas.Any() && !debitosCreditos.Any() && !pagos.Any()) if (!entradasSalidas.Any() && !debitosCreditos.Any() && !pagos.Any() && saldoInicial == 0m)
{ {
return NotFound(new { message = "No hay datos para generar el reporte de cuenta del distribuidor." }); return NotFound(new { message = "No hay datos para generar el reporte de cuenta del distribuidor." });
} }
@@ -943,7 +950,7 @@ namespace GestionIntegral.Api.Controllers
Movimientos = entradasSalidas, Movimientos = entradasSalidas,
Pagos = pagos, Pagos = pagos,
DebitosCreditos = debitosCreditos, DebitosCreditos = debitosCreditos,
SaldoDeCuenta = saldos.FirstOrDefault()?.Monto ?? 0, // <-- Se asigna a SaldoDeCuenta SaldoInicial = saldoInicial,
NombreDistribuidor = distribuidor.Distribuidor?.Nombre ?? $"Distribuidor ID {idDistribuidor}", NombreDistribuidor = distribuidor.Distribuidor?.Nombre ?? $"Distribuidor ID {idDistribuidor}",
FechaDesde = fechaDesde.ToString("dd/MM/yyyy"), FechaDesde = fechaDesde.ToString("dd/MM/yyyy"),
FechaHasta = fechaHasta.ToString("dd/MM/yyyy"), FechaHasta = fechaHasta.ToString("dd/MM/yyyy"),
@@ -1676,5 +1683,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

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

View File

@@ -112,15 +112,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
return Ok(promos); return Ok(promos);
} }
// POST: api/suscripciones/{idSuscripcion}/promociones/{idPromocion} // POST: api/suscripciones/{idSuscripcion}/promociones
[HttpPost("{idSuscripcion:int}/promociones/{idPromocion:int}")] [HttpPost("{idSuscripcion:int}/promociones")]
public async Task<IActionResult> AsignarPromocion(int idSuscripcion, int idPromocion) public async Task<IActionResult> AsignarPromocion(int idSuscripcion, [FromBody] AsignarPromocionDto dto)
{ {
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
var userId = GetCurrentUserId(); var userId = GetCurrentUserId();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, idPromocion, userId.Value); var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, dto, userId.Value);
if (!exito) return BadRequest(new { message = error }); if (!exito) return BadRequest(new { message = error });
return Ok(); return Ok();
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,435 @@
using Dapper;
using GestionIntegral.Api.Dtos.Auditoria;
using GestionIntegral.Api.Dtos.Contables;
using GestionIntegral.Api.Models.Contables;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Contables
{
public class CierreCuentaCorrienteRepository : ICierreCuentaCorrienteRepository
{
private readonly DbConnectionFactory _cf;
private readonly ILogger<CierreCuentaCorrienteRepository> _log;
public CierreCuentaCorrienteRepository(DbConnectionFactory cf, ILogger<CierreCuentaCorrienteRepository> log)
{
_cf = cf;
_log = log;
}
// Aliases SELECT — mapean Id_X (SQL) → IdX (modelo C#).
private const string SelectModelBase = @"
SELECT
Id_Cierre AS IdCierre,
Id_Distribuidor AS IdDistribuidor,
Id_Empresa AS IdEmpresa,
FechaCorte,
FechaCierre,
SaldoCierre,
Estado,
Justificacion,
Id_Usuario_Cierre AS IdUsuarioCierre,
Id_Usuario_Anula AS IdUsuarioAnula,
FechaAnulacion,
Justificacion_Anulacion AS JustificacionAnulacion
FROM dbo.cue_CierresCuentaCorriente";
public async Task<CierreCuentaCorriente?> GetByIdAsync(int idCierre)
{
var sql = SelectModelBase + " WHERE Id_Cierre = @IdCierreParam;";
try
{
using var connection = _cf.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CierreCuentaCorriente>(sql, new { IdCierreParam = idCierre });
}
catch (Exception ex)
{
_log.LogError(ex, "Error al obtener Cierre por ID: {IdCierre}", idCierre);
return null;
}
}
public async Task<CierreCuentaCorriente?> GetUltimoCierreVigenteAsync(int idDistribuidor, int idEmpresa, IDbTransaction? transaction = null)
{
var sql = @"
SELECT TOP 1
Id_Cierre AS IdCierre,
Id_Distribuidor AS IdDistribuidor,
Id_Empresa AS IdEmpresa,
FechaCorte,
FechaCierre,
SaldoCierre,
Estado,
Justificacion,
Id_Usuario_Cierre AS IdUsuarioCierre,
Id_Usuario_Anula AS IdUsuarioAnula,
FechaAnulacion,
Justificacion_Anulacion AS JustificacionAnulacion
FROM dbo.cue_CierresCuentaCorriente
WHERE Id_Distribuidor = @IdDist
AND Id_Empresa = @IdEmp
AND Estado = 'Activo'
ORDER BY FechaCorte DESC, Id_Cierre DESC;";
var parameters = new { IdDist = idDistribuidor, IdEmp = idEmpresa };
try
{
if (transaction != null)
{
return await transaction.Connection!.QuerySingleOrDefaultAsync<CierreCuentaCorriente>(sql, parameters, transaction);
}
using var connection = _cf.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CierreCuentaCorriente>(sql, parameters);
}
catch (Exception ex)
{
_log.LogError(ex, "Error al obtener último cierre vigente Dist={IdDist} Emp={IdEmp}", idDistribuidor, idEmpresa);
if (transaction != null) throw;
return null;
}
}
public async Task<CierreCuentaCorriente?> GetCierreVigenteParaFechaAsync(int idDistribuidor, int idEmpresa, DateTime fechaOperacion)
{
// Cae en período cerrado si existe un cierre Activo cuya FechaCorte sea >= fechaOperacion (la fecha está dentro del período cerrado).
// Se devuelve el más reciente (TOP 1 ORDER BY FechaCorte DESC) — el más restrictivo desde la perspectiva de la fecha consultada.
const string sql = @"
SELECT TOP 1
Id_Cierre AS IdCierre,
Id_Distribuidor AS IdDistribuidor,
Id_Empresa AS IdEmpresa,
FechaCorte,
FechaCierre,
SaldoCierre,
Estado,
Justificacion,
Id_Usuario_Cierre AS IdUsuarioCierre,
Id_Usuario_Anula AS IdUsuarioAnula,
FechaAnulacion,
Justificacion_Anulacion AS JustificacionAnulacion
FROM dbo.cue_CierresCuentaCorriente
WHERE Id_Distribuidor = @IdDist
AND Id_Empresa = @IdEmp
AND Estado = 'Activo'
AND FechaCorte >= @FechaOp
ORDER BY FechaCorte DESC, Id_Cierre DESC;";
try
{
using var connection = _cf.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CierreCuentaCorriente>(sql, new
{
IdDist = idDistribuidor,
IdEmp = idEmpresa,
FechaOp = fechaOperacion.Date
});
}
catch (Exception ex)
{
_log.LogError(ex, "Error en GetCierreVigenteParaFechaAsync Dist={IdDist} Emp={IdEmp} Fecha={Fecha}", idDistribuidor, idEmpresa, fechaOperacion);
return null;
}
}
public async Task<bool> ExisteCierrePosteriorVigenteAsync(int idDistribuidor, int idEmpresa, DateTime fechaCorte, int? excluirIdCierre = null, IDbTransaction? transaction = null)
{
const string sql = @"
SELECT CASE WHEN EXISTS (
SELECT 1 FROM dbo.cue_CierresCuentaCorriente
WHERE Id_Distribuidor = @IdDist
AND Id_Empresa = @IdEmp
AND Estado = 'Activo'
AND FechaCorte > @FechaCorte
AND (@Excluir IS NULL OR Id_Cierre <> @Excluir)
) THEN 1 ELSE 0 END;";
var parameters = new
{
IdDist = idDistribuidor,
IdEmp = idEmpresa,
FechaCorte = fechaCorte.Date,
Excluir = excluirIdCierre
};
if (transaction != null)
{
return await transaction.Connection!.ExecuteScalarAsync<bool>(sql, parameters, transaction);
}
using var connection = _cf.CreateConnection();
return await connection.ExecuteScalarAsync<bool>(sql, parameters);
}
public async Task<int> CreateAsync(CierreCuentaCorriente cierre, int idUsuarioMod, IDbTransaction transaction)
{
const string sqlInsertMaster = @"
INSERT INTO dbo.cue_CierresCuentaCorriente
(Id_Distribuidor, Id_Empresa, FechaCorte, FechaCierre, SaldoCierre, Estado, Justificacion, Id_Usuario_Cierre)
VALUES
(@IdDistribuidor, @IdEmpresa, @FechaCorte, @FechaCierre, @SaldoCierre, @Estado, @Justificacion, @IdUsuarioCierre);
SELECT CAST(SCOPE_IDENTITY() AS INT);";
var idCierre = await transaction.Connection!.ExecuteScalarAsync<int>(sqlInsertMaster, new
{
cierre.IdDistribuidor,
cierre.IdEmpresa,
FechaCorte = cierre.FechaCorte.Date,
cierre.FechaCierre,
cierre.SaldoCierre,
cierre.Estado,
cierre.Justificacion,
cierre.IdUsuarioCierre
}, transaction);
if (idCierre <= 0) throw new DataException("No se pudo crear el cierre — SCOPE_IDENTITY no devolvió valor.");
const string sqlInsertHist = @"
INSERT INTO dbo.cue_CierresCuentaCorriente_H
(Id_Cierre, Id_Distribuidor, Id_Empresa, FechaCorte, FechaCierre, SaldoCierre,
Estado, Justificacion, Id_Usuario_Cierre,
Id_Usuario_Anula, FechaAnulacion, Justificacion_Anulacion,
TipoMod, Id_Usuario_Mod, FechaMod)
VALUES
(@IdCierre, @IdDistribuidor, @IdEmpresa, @FechaCorte, @FechaCierre, @SaldoCierre,
@Estado, @Justificacion, @IdUsuarioCierre,
NULL, NULL, NULL,
@TipoMod, @IdUsuarioMod, @FechaMod);";
await transaction.Connection!.ExecuteAsync(sqlInsertHist, new
{
IdCierre = idCierre,
cierre.IdDistribuidor,
cierre.IdEmpresa,
FechaCorte = cierre.FechaCorte.Date,
cierre.FechaCierre,
cierre.SaldoCierre,
cierre.Estado,
cierre.Justificacion,
cierre.IdUsuarioCierre,
TipoMod = "Creacion",
IdUsuarioMod = idUsuarioMod,
FechaMod = DateTime.Now
}, transaction);
return idCierre;
}
public async Task<bool> AnularAsync(int idCierre, int idUsuarioAnula, string justificacionAnulacion, int idUsuarioMod, IDbTransaction transaction)
{
// UPDATE atómico: solo cambia Estado si está actualmente Activo. Evita doble anulación concurrente.
const string sqlUpdate = @"
UPDATE dbo.cue_CierresCuentaCorriente
SET Estado = 'Anulado',
Id_Usuario_Anula = @IdUsuarioAnula,
FechaAnulacion = @FechaAnulacion,
Justificacion_Anulacion = @JustificacionAnulacion
WHERE Id_Cierre = @IdCierre
AND Estado = 'Activo';";
var fechaAnulacion = DateTime.Now;
int affected = await transaction.Connection!.ExecuteAsync(sqlUpdate, new
{
IdCierre = idCierre,
IdUsuarioAnula = idUsuarioAnula,
FechaAnulacion = fechaAnulacion,
JustificacionAnulacion = justificacionAnulacion
}, transaction);
if (affected != 1) return false;
// Snapshot post-update para el _H. Trae los valores ya actualizados.
const string sqlSnapshot = SelectModelBase + " WHERE Id_Cierre = @IdCierre;";
var actualizado = await transaction.Connection!.QuerySingleAsync<CierreCuentaCorriente>(sqlSnapshot, new { IdCierre = idCierre }, transaction);
const string sqlInsertHist = @"
INSERT INTO dbo.cue_CierresCuentaCorriente_H
(Id_Cierre, Id_Distribuidor, Id_Empresa, FechaCorte, FechaCierre, SaldoCierre,
Estado, Justificacion, Id_Usuario_Cierre,
Id_Usuario_Anula, FechaAnulacion, Justificacion_Anulacion,
TipoMod, Id_Usuario_Mod, FechaMod)
VALUES
(@IdCierre, @IdDistribuidor, @IdEmpresa, @FechaCorte, @FechaCierre, @SaldoCierre,
@Estado, @Justificacion, @IdUsuarioCierre,
@IdUsuarioAnula, @FechaAnulacion, @JustificacionAnulacion,
@TipoMod, @IdUsuarioMod, @FechaMod);";
await transaction.Connection!.ExecuteAsync(sqlInsertHist, new
{
IdCierre = actualizado.IdCierre,
actualizado.IdDistribuidor,
actualizado.IdEmpresa,
FechaCorte = actualizado.FechaCorte.Date,
actualizado.FechaCierre,
actualizado.SaldoCierre,
actualizado.Estado,
actualizado.Justificacion,
actualizado.IdUsuarioCierre,
actualizado.IdUsuarioAnula,
actualizado.FechaAnulacion,
JustificacionAnulacion = actualizado.JustificacionAnulacion,
TipoMod = "Reapertura",
IdUsuarioMod = idUsuarioMod,
FechaMod = DateTime.Now
}, transaction);
return true;
}
public async Task<IEnumerable<CierreCuentaCorrienteDto>> GetAllAsync(
int? idDistribuidor, int? idEmpresa, string? estado,
DateTime? fechaCorteDesde, DateTime? fechaCorteHasta)
{
var sqlBuilder = new StringBuilder(@"
SELECT
c.Id_Cierre AS IdCierre,
c.Id_Distribuidor AS IdDistribuidor,
d.Nombre AS NombreDistribuidor,
c.Id_Empresa AS IdEmpresa,
e.Nombre AS NombreEmpresa,
CONVERT(varchar(10), c.FechaCorte, 23) AS FechaCorte,
c.FechaCierre,
c.SaldoCierre,
c.Estado,
c.Justificacion,
c.Id_Usuario_Cierre AS IdUsuarioCierre,
(uc.Nombre + ' ' + uc.Apellido) AS NombreUsuarioCierre,
c.Id_Usuario_Anula AS IdUsuarioAnula,
CASE WHEN ua.Id IS NULL THEN NULL ELSE (ua.Nombre + ' ' + ua.Apellido) END AS NombreUsuarioAnula,
c.FechaAnulacion,
c.Justificacion_Anulacion AS JustificacionAnulacion,
CAST(CASE WHEN c.Estado = 'Activo'
AND c.Id_Cierre = (
SELECT TOP 1 c2.Id_Cierre FROM dbo.cue_CierresCuentaCorriente c2
WHERE c2.Id_Distribuidor = c.Id_Distribuidor
AND c2.Id_Empresa = c.Id_Empresa
AND c2.Estado = 'Activo'
ORDER BY c2.FechaCorte DESC, c2.Id_Cierre DESC)
THEN 1 ELSE 0 END AS bit) AS EsUltimoVigente
FROM dbo.cue_CierresCuentaCorriente c
JOIN dbo.dist_dtDistribuidores d ON d.Id_Distribuidor = c.Id_Distribuidor
JOIN dbo.dist_dtEmpresas e ON e.Id_Empresa = c.Id_Empresa
JOIN dbo.gral_Usuarios uc ON uc.Id = c.Id_Usuario_Cierre
LEFT JOIN dbo.gral_Usuarios ua ON ua.Id = c.Id_Usuario_Anula
WHERE 1=1");
var parameters = new DynamicParameters();
if (idDistribuidor.HasValue) { sqlBuilder.Append(" AND c.Id_Distribuidor = @IdDist"); parameters.Add("IdDist", idDistribuidor.Value); }
if (idEmpresa.HasValue) { sqlBuilder.Append(" AND c.Id_Empresa = @IdEmp"); parameters.Add("IdEmp", idEmpresa.Value); }
if (!string.IsNullOrWhiteSpace(estado)) { sqlBuilder.Append(" AND c.Estado = @Estado"); parameters.Add("Estado", estado); }
if (fechaCorteDesde.HasValue) { sqlBuilder.Append(" AND c.FechaCorte >= @FechaDesde"); parameters.Add("FechaDesde", fechaCorteDesde.Value.Date); }
if (fechaCorteHasta.HasValue) { sqlBuilder.Append(" AND c.FechaCorte <= @FechaHasta"); parameters.Add("FechaHasta", fechaCorteHasta.Value.Date); }
sqlBuilder.Append(" ORDER BY c.FechaCorte DESC, c.Id_Cierre DESC;");
try
{
using var connection = _cf.CreateConnection();
return await connection.QueryAsync<CierreCuentaCorrienteDto>(sqlBuilder.ToString(), parameters);
}
catch (Exception ex)
{
_log.LogError(ex, "Error en GetAllAsync de CierresCuentaCorriente.");
return Enumerable.Empty<CierreCuentaCorrienteDto>();
}
}
public async Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> GetHistorialAsync(int idCierre)
{
const string sql = @"
SELECT
h.Id_Historial,
h.Id_Cierre,
h.Id_Distribuidor,
h.Id_Empresa,
h.FechaCorte,
h.FechaCierre,
h.SaldoCierre,
h.Estado,
h.Justificacion,
h.Id_Usuario_Cierre,
h.Id_Usuario_Anula,
h.FechaAnulacion,
h.Justificacion_Anulacion,
h.TipoMod,
h.Id_Usuario_Mod,
(u.Nombre + ' ' + u.Apellido) AS NombreUsuarioModifico,
h.FechaMod
FROM dbo.cue_CierresCuentaCorriente_H h
JOIN dbo.gral_Usuarios u ON u.Id = h.Id_Usuario_Mod
WHERE h.Id_Cierre = @IdCierre
ORDER BY h.FechaMod ASC, h.Id_Historial ASC;";
try
{
using var connection = _cf.CreateConnection();
return await connection.QueryAsync<CierreCuentaCorrienteHistorialDto>(sql, new { IdCierre = idCierre });
}
catch (Exception ex)
{
_log.LogError(ex, "Error en GetHistorialAsync para Cierre ID {IdCierre}", idCierre);
return Enumerable.Empty<CierreCuentaCorrienteHistorialDto>();
}
}
public async Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> ObtenerHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion,
int? idCierreAfectado)
{
// FechaMod cubre el rango inclusive: [fechaDesde 00:00, fechaHasta+1día). Patrón consistente con otros ObtenerHistorial del proyecto.
var sql = @"
SELECT
h.Id_Historial,
h.Id_Cierre,
h.Id_Distribuidor,
h.Id_Empresa,
h.FechaCorte,
h.FechaCierre,
h.SaldoCierre,
h.Estado,
h.Justificacion,
h.Id_Usuario_Cierre,
h.Id_Usuario_Anula,
h.FechaAnulacion,
h.Justificacion_Anulacion,
h.TipoMod,
h.Id_Usuario_Mod,
(u.Nombre + ' ' + u.Apellido) AS NombreUsuarioModifico,
h.FechaMod
FROM dbo.cue_CierresCuentaCorriente_H h
JOIN dbo.gral_Usuarios u ON u.Id = h.Id_Usuario_Mod
WHERE 1 = 1
AND (@FechaDesde IS NULL OR h.FechaMod >= @FechaDesde)
AND (@FechaHasta IS NULL OR h.FechaMod < DATEADD(DAY, 1, @FechaHasta))
AND (@IdUsuarioMod IS NULL OR h.Id_Usuario_Mod = @IdUsuarioMod)
AND (@TipoMod IS NULL OR h.TipoMod = @TipoMod)
AND (@IdCierre IS NULL OR h.Id_Cierre = @IdCierre)
ORDER BY h.FechaMod DESC, h.Id_Historial DESC;";
try
{
using var connection = _cf.CreateConnection();
return await connection.QueryAsync<CierreCuentaCorrienteHistorialDto>(sql, new
{
FechaDesde = fechaDesde?.Date,
FechaHasta = fechaHasta?.Date,
IdUsuarioMod = idUsuarioModifico,
TipoMod = tipoModificacion,
IdCierre = idCierreAfectado
});
}
catch (Exception ex)
{
_log.LogError(ex, "Error en ObtenerHistorialAsync (auditoría) de Cierres CC.");
return Enumerable.Empty<CierreCuentaCorrienteHistorialDto>();
}
}
}
}

View File

@@ -0,0 +1,45 @@
using GestionIntegral.Api.Dtos.Auditoria;
using GestionIntegral.Api.Dtos.Contables;
using GestionIntegral.Api.Models.Contables;
using System;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Contables
{
public interface ICierreCuentaCorrienteRepository
{
Task<CierreCuentaCorriente?> GetByIdAsync(int idCierre);
// Devuelve el último cierre con Estado = 'Activo' para el par (Distribuidor + Empresa).
// Acepta una transacción opcional para usarse dentro del flujo Crear/Reabrir.
Task<CierreCuentaCorriente?> GetUltimoCierreVigenteAsync(int idDistribuidor, int idEmpresa, IDbTransaction? transaction = null);
// Verifica si la fecha cae dentro de un período cerrado: existe un cierre Activo con FechaCorte >= fechaOperacion.
Task<CierreCuentaCorriente?> GetCierreVigenteParaFechaAsync(int idDistribuidor, int idEmpresa, DateTime fechaOperacion);
// True si existe otro cierre Activo con FechaCorte > fechaCorte (excluyendo opcionalmente un cierre puntual).
// Se usa al reabrir para forzar la cascada manual.
Task<bool> ExisteCierrePosteriorVigenteAsync(int idDistribuidor, int idEmpresa, DateTime fechaCorte, int? excluirIdCierre = null, IDbTransaction? transaction = null);
// Crea la fila maestra y registra la entrada inicial en _H con TipoMod='Creacion'. Devuelve el Id_Cierre generado.
Task<int> CreateAsync(CierreCuentaCorriente cierre, int idUsuarioMod, IDbTransaction transaction);
// Marca un cierre como Anulado (UPDATE atómico solo si Estado='Activo') y registra entrada en _H con TipoMod='Reapertura'.
// Devuelve true si se anuló (Estado pasó de Activo a Anulado), false si ya estaba anulado o no existía.
Task<bool> AnularAsync(int idCierre, int idUsuarioAnula, string justificacionAnulacion, int idUsuarioMod, IDbTransaction transaction);
Task<IEnumerable<CierreCuentaCorrienteDto>> GetAllAsync(
int? idDistribuidor, int? idEmpresa, string? estado,
DateTime? fechaCorteDesde, DateTime? fechaCorteHasta);
Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> GetHistorialAsync(int idCierre);
// Auditoría general: filtra el historial cruzado de todos los cierres por rango de FechaMod, usuario, tipo, y opcional Id_Cierre.
Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> ObtenerHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion,
int? idCierreAfectado);
}
}

View File

@@ -16,7 +16,7 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
Task<PagoDistribuidor?> CreateAsync(PagoDistribuidor nuevoPago, int idUsuario, IDbTransaction transaction); Task<PagoDistribuidor?> CreateAsync(PagoDistribuidor nuevoPago, int idUsuario, IDbTransaction transaction);
Task<bool> UpdateAsync(PagoDistribuidor pagoAActualizar, int idUsuario, IDbTransaction transaction); Task<bool> UpdateAsync(PagoDistribuidor pagoAActualizar, int idUsuario, IDbTransaction transaction);
Task<bool> DeleteAsync(int idPago, int idUsuario, IDbTransaction transaction); Task<bool> DeleteAsync(int idPago, int idUsuario, IDbTransaction transaction);
Task<bool> ExistsByReciboAndTipoMovimientoAsync(int recibo, string tipoMovimiento, int? excludeIdPago = null); Task<PagoDistribuidor?> GetByReciboAndTipoMovimientoAsync(int recibo, string tipoMovimiento, int? excludeIdPago = null);
Task<IEnumerable<(PagoDistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync( Task<IEnumerable<(PagoDistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion, int? idUsuarioModifico, string? tipoModificacion,

View File

@@ -70,9 +70,10 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
} }
} }
public async Task<bool> ExistsByReciboAndTipoMovimientoAsync(int recibo, string tipoMovimiento, int? excludeIdPago = null) public async Task<PagoDistribuidor?> GetByReciboAndTipoMovimientoAsync(int recibo, string tipoMovimiento, int? excludeIdPago = null)
{ {
var sqlBuilder = new StringBuilder("SELECT COUNT(1) FROM dbo.cue_PagosDistribuidor WHERE Recibo = @ReciboParam AND TipoMovimiento = @TipoMovParam"); var sqlBuilder = new StringBuilder(SelectQueryBase()); // Reutiliza la consulta base
sqlBuilder.Append(" WHERE Recibo = @ReciboParam AND TipoMovimiento = @TipoMovParam");
var parameters = new DynamicParameters(); var parameters = new DynamicParameters();
parameters.Add("ReciboParam", recibo); parameters.Add("ReciboParam", recibo);
parameters.Add("TipoMovParam", tipoMovimiento); parameters.Add("TipoMovParam", tipoMovimiento);
@@ -85,12 +86,12 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
try try
{ {
using var connection = _cf.CreateConnection(); using var connection = _cf.CreateConnection();
return await connection.ExecuteScalarAsync<bool>(sqlBuilder.ToString(), parameters); return await connection.QuerySingleOrDefaultAsync<PagoDistribuidor>(sqlBuilder.ToString(), parameters);
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.LogError(ex, "Error en ExistsByReciboAndTipoMovimientoAsync. Recibo: {Recibo}, Tipo: {Tipo}", recibo, tipoMovimiento); _log.LogError(ex, "Error en GetByReciboAndTipoMovimientoAsync. Recibo: {Recibo}, Tipo: {Tipo}", recibo, tipoMovimiento);
return true; // Asumir que existe en caso de error para prevenir duplicados throw; // Relanzar para que el servicio lo maneje
} }
} }

View File

@@ -23,7 +23,7 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
public async Task<IEnumerable<int>> GetAllDistribuidorIdsAsync() public async Task<IEnumerable<int>> GetAllDistribuidorIdsAsync()
{ {
var sql = "SELECT Id_Distribuidor FROM dbo.dist_dtDistribuidores"; var sql = "SELECT Id_Distribuidor FROM dbo.dist_dtDistribuidores WHERE Baja = 0";
try try
{ {
using (var connection = _connectionFactory.CreateConnection()) using (var connection = _connectionFactory.CreateConnection())
@@ -138,25 +138,45 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
public async Task<IEnumerable<Saldo>> GetSaldosParaGestionAsync(string? destinoFilter, int? idDestinoFilter, int? idEmpresaFilter) public async Task<IEnumerable<Saldo>> GetSaldosParaGestionAsync(string? destinoFilter, int? idDestinoFilter, int? idEmpresaFilter)
{ {
var sqlBuilder = new StringBuilder("SELECT Id_Saldo AS IdSaldo, Destino, Id_Destino AS IdDestino, Monto, Id_Empresa AS IdEmpresa, FechaUltimaModificacion FROM dbo.cue_Saldos WHERE 1=1"); var sqlBuilder = new StringBuilder(@"
SELECT Id_Saldo AS IdSaldo, Destino, Id_Destino AS IdDestino, Monto, Id_Empresa AS IdEmpresa, FechaUltimaModificacion
FROM dbo.cue_Saldos s
WHERE 1=1");
var parameters = new DynamicParameters(); var parameters = new DynamicParameters();
if (!string.IsNullOrWhiteSpace(destinoFilter)) if (!string.IsNullOrWhiteSpace(destinoFilter))
{ {
sqlBuilder.Append(" AND Destino = @Destino"); sqlBuilder.Append(" AND s.Destino = @Destino");
parameters.Add("Destino", destinoFilter); parameters.Add("Destino", destinoFilter);
// Filtro para excluir distribuidores de baja si el tipo es Distribuidores
// No se aplica a Canillas por requerimiento explícito del usuario
if (destinoFilter == "Distribuidores")
{
sqlBuilder.Append(" AND EXISTS (SELECT 1 FROM dbo.dist_dtDistribuidores d WHERE d.Id_Distribuidor = s.Id_Destino AND d.Baja = 0)");
} }
}
else
{
// Si no hay filtro de destino, aplicamos el filtro de baja solo para Distribuidores
sqlBuilder.Append(@" AND (
(s.Destino = 'Distribuidores' AND EXISTS (SELECT 1 FROM dbo.dist_dtDistribuidores d WHERE d.Id_Distribuidor = s.Id_Destino AND d.Baja = 0))
OR (s.Destino != 'Distribuidores')
)");
}
if (idDestinoFilter.HasValue) if (idDestinoFilter.HasValue)
{ {
sqlBuilder.Append(" AND Id_Destino = @IdDestino"); sqlBuilder.Append(" AND s.Id_Destino = @IdDestino");
parameters.Add("IdDestino", idDestinoFilter.Value); parameters.Add("IdDestino", idDestinoFilter.Value);
} }
if (idEmpresaFilter.HasValue) if (idEmpresaFilter.HasValue)
{ {
sqlBuilder.Append(" AND Id_Empresa = @IdEmpresa"); sqlBuilder.Append(" AND s.Id_Empresa = @IdEmpresa");
parameters.Add("IdEmpresa", idEmpresaFilter.Value); parameters.Add("IdEmpresa", idEmpresaFilter.Value);
} }
sqlBuilder.Append(" ORDER BY Destino, Id_Empresa, Id_Destino;"); sqlBuilder.Append(" ORDER BY s.Destino, s.Id_Empresa, s.Id_Destino;");
using var connection = _connectionFactory.CreateConnection(); using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Saldo>(sqlBuilder.ToString(), parameters); return await connection.QueryAsync<Saldo>(sqlBuilder.ToString(), parameters);

View File

@@ -22,12 +22,12 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
_logger = logger; _logger = logger;
} }
public async Task<IEnumerable<(Distribuidor Distribuidor, string? NombreZona)>> GetAllAsync(string? nombreFilter, string? nroDocFilter) public async Task<IEnumerable<(Distribuidor Distribuidor, string? NombreZona)>> GetAllAsync(string? nombreFilter, string? nroDocFilter, bool? soloActivos = true)
{ {
var sqlBuilder = new StringBuilder(@" var sqlBuilder = new StringBuilder(@"
SELECT SELECT
d.Id_Distribuidor AS IdDistribuidor, d.Nombre, d.Contacto, d.NroDoc, d.Id_Zona AS IdZona, d.Id_Distribuidor AS IdDistribuidor, d.Nombre, d.Contacto, d.NroDoc, d.Id_Zona AS IdZona,
d.Calle, d.Numero, d.Piso, d.Depto, d.Telefono, d.Email, d.Localidad, d.Calle, d.Numero, d.Piso, d.Depto, d.Telefono, d.Email, d.Localidad, d.Baja, d.FechaBaja,
z.Nombre AS NombreZona z.Nombre AS NombreZona
FROM dbo.dist_dtDistribuidores d FROM dbo.dist_dtDistribuidores d
LEFT JOIN dbo.dist_dtZonas z ON d.Id_Zona = z.Id_Zona LEFT JOIN dbo.dist_dtZonas z ON d.Id_Zona = z.Id_Zona
@@ -44,6 +44,11 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
sqlBuilder.Append(" AND d.NroDoc LIKE @NroDocParam"); sqlBuilder.Append(" AND d.NroDoc LIKE @NroDocParam");
parameters.Add("NroDocParam", $"%{nroDocFilter}%"); parameters.Add("NroDocParam", $"%{nroDocFilter}%");
} }
if (soloActivos.HasValue)
{
sqlBuilder.Append(" AND d.Baja = @BajaStatus ");
parameters.Add("BajaStatus", !soloActivos.Value);
}
sqlBuilder.Append(" ORDER BY d.Nombre;"); sqlBuilder.Append(" ORDER BY d.Nombre;");
try try
@@ -63,7 +68,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
} }
} }
public async Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync() public async Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync(bool? soloActivos = true)
{ {
var sqlBuilder = new StringBuilder(@" var sqlBuilder = new StringBuilder(@"
SELECT SELECT
@@ -71,6 +76,13 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
FROM dbo.dist_dtDistribuidores FROM dbo.dist_dtDistribuidores
WHERE 1=1"); WHERE 1=1");
var parameters = new DynamicParameters(); var parameters = new DynamicParameters();
if (soloActivos.HasValue)
{
sqlBuilder.Append(" AND Baja = @BajaStatus ");
parameters.Add("BajaStatus", !soloActivos.Value);
}
sqlBuilder.Append(" ORDER BY Nombre;"); sqlBuilder.Append(" ORDER BY Nombre;");
try try
{ {
@@ -92,7 +104,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
const string sql = @" const string sql = @"
SELECT SELECT
d.Id_Distribuidor AS IdDistribuidor, d.Nombre, d.Contacto, d.NroDoc, d.Id_Zona AS IdZona, d.Id_Distribuidor AS IdDistribuidor, d.Nombre, d.Contacto, d.NroDoc, d.Id_Zona AS IdZona,
d.Calle, d.Numero, d.Piso, d.Depto, d.Telefono, d.Email, d.Localidad, d.Calle, d.Numero, d.Piso, d.Depto, d.Telefono, d.Email, d.Localidad, d.Baja, d.FechaBaja,
z.Nombre AS NombreZona z.Nombre AS NombreZona
FROM dbo.dist_dtDistribuidores d FROM dbo.dist_dtDistribuidores d
LEFT JOIN dbo.dist_dtZonas z ON d.Id_Zona = z.Id_Zona LEFT JOIN dbo.dist_dtZonas z ON d.Id_Zona = z.Id_Zona
@@ -139,7 +151,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
const string sql = @" const string sql = @"
SELECT SELECT
Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona, Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona,
Calle, Numero, Piso, Depto, Telefono, Email, Localidad Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja
FROM dbo.dist_dtDistribuidores FROM dbo.dist_dtDistribuidores
WHERE Id_Distribuidor = @IdParam"; WHERE Id_Distribuidor = @IdParam";
try try
@@ -223,10 +235,10 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
public async Task<Distribuidor?> CreateAsync(Distribuidor nuevoDistribuidor, int idUsuario, IDbTransaction transaction) public async Task<Distribuidor?> CreateAsync(Distribuidor nuevoDistribuidor, int idUsuario, IDbTransaction transaction)
{ {
const string sqlInsert = @" const string sqlInsert = @"
INSERT INTO dbo.dist_dtDistribuidores (Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad) INSERT INTO dbo.dist_dtDistribuidores (Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja)
OUTPUT INSERTED.Id_Distribuidor AS IdDistribuidor, INSERTED.Nombre, INSERTED.Contacto, INSERTED.NroDoc, INSERTED.Id_Zona AS IdZona, OUTPUT INSERTED.Id_Distribuidor AS IdDistribuidor, INSERTED.Nombre, INSERTED.Contacto, INSERTED.NroDoc, INSERTED.Id_Zona AS IdZona,
INSERTED.Calle, INSERTED.Numero, INSERTED.Piso, INSERTED.Depto, INSERTED.Telefono, INSERTED.Email, INSERTED.Localidad INSERTED.Calle, INSERTED.Numero, INSERTED.Piso, INSERTED.Depto, INSERTED.Telefono, INSERTED.Email, INSERTED.Localidad, INSERTED.Baja, INSERTED.FechaBaja
VALUES (@Nombre, @Contacto, @NroDoc, @IdZona, @Calle, @Numero, @Piso, @Depto, @Telefono, @Email, @Localidad);"; VALUES (@Nombre, @Contacto, @NroDoc, @IdZona, @Calle, @Numero, @Piso, @Depto, @Telefono, @Email, @Localidad, 0, NULL);";
var connection = transaction.Connection!; var connection = transaction.Connection!;
var inserted = await connection.QuerySingleAsync<Distribuidor>(sqlInsert, nuevoDistribuidor, transaction); var inserted = await connection.QuerySingleAsync<Distribuidor>(sqlInsert, nuevoDistribuidor, transaction);
@@ -234,8 +246,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
const string sqlInsertHistorico = @" const string sqlInsertHistorico = @"
INSERT INTO dbo.dist_dtDistribuidores_H INSERT INTO dbo.dist_dtDistribuidores_H
(Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Id_Usuario, FechaMod, TipoMod) (Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja, Id_Usuario, FechaMod, TipoMod)
VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);"; VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @BajaParam, @FechaBajaParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);";
await connection.ExecuteAsync(sqlInsertHistorico, new await connection.ExecuteAsync(sqlInsertHistorico, new
{ {
@@ -251,6 +263,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
TelefonoParam = inserted.Telefono, TelefonoParam = inserted.Telefono,
EmailParam = inserted.Email, EmailParam = inserted.Email,
LocalidadParam = inserted.Localidad, LocalidadParam = inserted.Localidad,
BajaParam = inserted.Baja,
FechaBajaParam = inserted.FechaBaja,
IdUsuarioParam = idUsuario, IdUsuarioParam = idUsuario,
FechaModParam = DateTime.Now, FechaModParam = DateTime.Now,
TipoModParam = "Creado" TipoModParam = "Creado"
@@ -263,7 +277,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
var connection = transaction.Connection!; var connection = transaction.Connection!;
var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>( var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>(
@"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona, @"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona,
Calle, Numero, Piso, Depto, Telefono, Email, Localidad Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja
FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdDistribuidorParam", FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdDistribuidorParam",
new { IdDistribuidorParam = distribuidorAActualizar.IdDistribuidor }, transaction); new { IdDistribuidorParam = distribuidorAActualizar.IdDistribuidor }, transaction);
if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado."); if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado.");
@@ -275,8 +289,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
WHERE Id_Distribuidor = @IdDistribuidor;"; WHERE Id_Distribuidor = @IdDistribuidor;";
const string sqlInsertHistorico = @" const string sqlInsertHistorico = @"
INSERT INTO dbo.dist_dtDistribuidores_H INSERT INTO dbo.dist_dtDistribuidores_H
(Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Id_Usuario, FechaMod, TipoMod) (Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja, Id_Usuario, FechaMod, TipoMod)
VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);"; VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @BajaParam, @FechaBajaParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);";
await connection.ExecuteAsync(sqlInsertHistorico, new await connection.ExecuteAsync(sqlInsertHistorico, new
{ {
@@ -292,6 +306,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
TelefonoParam = actual.Telefono, TelefonoParam = actual.Telefono,
EmailParam = actual.Email, EmailParam = actual.Email,
LocalidadParam = actual.Localidad, LocalidadParam = actual.Localidad,
BajaParam = actual.Baja,
FechaBajaParam = actual.FechaBaja,
IdUsuarioParam = idUsuario, IdUsuarioParam = idUsuario,
FechaModParam = DateTime.Now, FechaModParam = DateTime.Now,
TipoModParam = "Actualizado" TipoModParam = "Actualizado"
@@ -306,7 +322,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
var connection = transaction.Connection!; var connection = transaction.Connection!;
var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>( var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>(
@"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona, @"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona,
Calle, Numero, Piso, Depto, Telefono, Email, Localidad Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja
FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdParam", new { IdParam = id }, transaction); FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdParam", new { IdParam = id }, transaction);
if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado."); if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado.");
@@ -314,8 +330,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
const string sqlDelete = "DELETE FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdParam"; const string sqlDelete = "DELETE FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdParam";
const string sqlInsertHistorico = @" const string sqlInsertHistorico = @"
INSERT INTO dbo.dist_dtDistribuidores_H INSERT INTO dbo.dist_dtDistribuidores_H
(Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Id_Usuario, FechaMod, TipoMod) (Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja, Id_Usuario, FechaMod, TipoMod)
VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);"; VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @BajaParam, @FechaBajaParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);";
await connection.ExecuteAsync(sqlInsertHistorico, new await connection.ExecuteAsync(sqlInsertHistorico, new
{ {
@@ -331,6 +347,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
TelefonoParam = actual.Telefono, TelefonoParam = actual.Telefono,
EmailParam = actual.Email, EmailParam = actual.Email,
LocalidadParam = actual.Localidad, LocalidadParam = actual.Localidad,
BajaParam = actual.Baja,
FechaBajaParam = actual.FechaBaja,
IdUsuarioParam = idUsuario, IdUsuarioParam = idUsuario,
FechaModParam = DateTime.Now, FechaModParam = DateTime.Now,
TipoModParam = "Eliminado" TipoModParam = "Eliminado"
@@ -340,6 +358,47 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
return rowsAffected == 1; return rowsAffected == 1;
} }
public async Task<bool> ToggleBajaAsync(int id, bool darDeBaja, DateTime? fechaBaja, int idUsuario, IDbTransaction transaction)
{
var connection = transaction.Connection!;
var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>(
@"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona,
Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja
FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdDistribuidorParam",
new { IdDistribuidorParam = id }, transaction);
if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado para dar de baja/alta.");
const string sqlUpdate = "UPDATE dbo.dist_dtDistribuidores SET Baja = @BajaParam, FechaBaja = @FechaBajaParam WHERE Id_Distribuidor = @IdDistribuidorParam;";
const string sqlInsertHistorico = @"
INSERT INTO dbo.dist_dtDistribuidores_H
(Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja, Id_Usuario, FechaMod, TipoMod)
VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @BajaNuevaParam, @FechaBajaNuevaParam, @IdUsuarioParam, @FechaModParam, @TipoModHistParam);";
await connection.ExecuteAsync(sqlInsertHistorico, new
{
IdDistribuidorParam = actual.IdDistribuidor,
NombreParam = actual.Nombre,
ContactoParam = actual.Contacto,
NroDocParam = actual.NroDoc,
IdZonaParam = actual.IdZona,
CalleParam = actual.Calle,
NumeroParam = actual.Numero,
PisoParam = actual.Piso,
DeptoParam = actual.Depto,
TelefonoParam = actual.Telefono,
EmailParam = actual.Email,
LocalidadParam = actual.Localidad,
BajaNuevaParam = darDeBaja,
FechaBajaNuevaParam = (darDeBaja ? fechaBaja : null),
IdUsuarioParam = idUsuario,
FechaModParam = DateTime.Now,
TipoModHistParam = (darDeBaja ? "Baja" : "Alta")
}, transaction);
var rowsAffected = await connection.ExecuteAsync(sqlUpdate, new { BajaParam = darDeBaja, FechaBajaParam = (darDeBaja ? fechaBaja : null), IdDistribuidorParam = id }, transaction);
return rowsAffected == 1;
}
public async Task<IEnumerable<(DistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync( public async Task<IEnumerable<(DistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion, int? idUsuarioModifico, string? tipoModificacion,

View File

@@ -72,6 +72,13 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
} }
} }
// -------------------------------------------------------------------------------
// Inhabilitada la comprobacion de existencia previa por remito y tipo de movimiento
// Pedido por Claudia Acosta el 18/11/2025
// Motivo: El ex canillita Sergio Mazza opera como distribuidor y no utiliza remitos.
// En el campo de remito se le asigna un numero aleatorio para cumplir con el requisito del sistema.
// -------------------------------------------------------------------------------
/*
public async Task<bool> ExistsByRemitoAndTipoForPublicacionAsync(int remito, string tipoMovimiento, int idPublicacion, int? excludeIdParte = null) public async Task<bool> ExistsByRemitoAndTipoForPublicacionAsync(int remito, string tipoMovimiento, int idPublicacion, int? excludeIdParte = null)
{ {
var sqlBuilder = new StringBuilder("SELECT COUNT(1) FROM dbo.dist_EntradasSalidas WHERE Remito = @RemitoParam AND TipoMovimiento = @TipoMovimientoParam AND Id_Publicacion = @IdPublicacionParam"); var sqlBuilder = new StringBuilder("SELECT COUNT(1) FROM dbo.dist_EntradasSalidas WHERE Remito = @RemitoParam AND TipoMovimiento = @TipoMovimientoParam AND Id_Publicacion = @IdPublicacionParam");
@@ -96,7 +103,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
return true; // Asumir que existe en caso de error para prevenir duplicados return true; // Asumir que existe en caso de error para prevenir duplicados
} }
} }
*/
public async Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction) public async Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction)
{ {

View File

@@ -8,16 +8,17 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
{ {
public interface IDistribuidorRepository public interface IDistribuidorRepository
{ {
Task<IEnumerable<(Distribuidor Distribuidor, string? NombreZona)>> GetAllAsync(string? nombreFilter, string? nroDocFilter); Task<IEnumerable<(Distribuidor Distribuidor, string? NombreZona)>> GetAllAsync(string? nombreFilter, string? nroDocFilter, bool? soloActivos = true);
Task<(Distribuidor? Distribuidor, string? NombreZona)> GetByIdAsync(int id); Task<(Distribuidor? Distribuidor, string? NombreZona)> GetByIdAsync(int id);
Task<Distribuidor?> GetByIdSimpleAsync(int id); // Para uso interno en el servicio Task<Distribuidor?> GetByIdSimpleAsync(int id); // Para uso interno en el servicio
Task<Distribuidor?> CreateAsync(Distribuidor nuevoDistribuidor, int idUsuario, IDbTransaction transaction); Task<Distribuidor?> CreateAsync(Distribuidor nuevoDistribuidor, int idUsuario, IDbTransaction transaction);
Task<bool> UpdateAsync(Distribuidor distribuidorAActualizar, int idUsuario, IDbTransaction transaction); Task<bool> UpdateAsync(Distribuidor distribuidorAActualizar, int idUsuario, IDbTransaction transaction);
Task<bool> DeleteAsync(int id, int idUsuario, IDbTransaction transaction); Task<bool> DeleteAsync(int id, int idUsuario, IDbTransaction transaction);
Task<bool> ToggleBajaAsync(int id, bool darDeBaja, DateTime? fechaBaja, int idUsuario, IDbTransaction transaction);
Task<bool> ExistsByNroDocAsync(string nroDoc, int? excludeIdDistribuidor = null); Task<bool> ExistsByNroDocAsync(string nroDoc, int? excludeIdDistribuidor = null);
Task<bool> ExistsByNameAsync(string nombre, int? excludeIdDistribuidor = null); Task<bool> ExistsByNameAsync(string nombre, int? excludeIdDistribuidor = null);
Task<bool> IsInUseAsync(int id); // Verificar en dist_EntradasSalidas, cue_PagosDistribuidor, dist_PorcPago Task<bool> IsInUseAsync(int id); // Verificar en dist_EntradasSalidas, cue_PagosDistribuidor, dist_PorcPago
Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync(); Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync(bool? soloActivos = true);
Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id); Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id);
Task<IEnumerable<(DistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync( Task<IEnumerable<(DistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,

View File

@@ -13,7 +13,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction); Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction);
Task<bool> UpdateAsync(EntradaSalidaDist esAActualizar, int idUsuario, IDbTransaction transaction); Task<bool> UpdateAsync(EntradaSalidaDist esAActualizar, int idUsuario, IDbTransaction transaction);
Task<bool> DeleteAsync(int idParte, int idUsuario, IDbTransaction transaction); Task<bool> DeleteAsync(int idParte, int idUsuario, IDbTransaction transaction);
Task<bool> ExistsByRemitoAndTipoForPublicacionAsync(int remito, string tipoMovimiento, int idPublicacion, int? excludeIdParte = null); //Task<bool> ExistsByRemitoAndTipoForPublicacionAsync(int remito, string tipoMovimiento, int idPublicacion, int? excludeIdParte = null);
Task<IEnumerable<(EntradaSalidaDistHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync( Task<IEnumerable<(EntradaSalidaDistHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion, int? idUsuarioModifico, string? tipoModificacion,

View File

@@ -30,7 +30,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
d.Nombre AS NombreDistribuidor d.Nombre AS NombreDistribuidor
FROM dbo.dist_PorcPago pp FROM dbo.dist_PorcPago pp
INNER JOIN dbo.dist_dtDistribuidores d ON pp.Id_Distribuidor = d.Id_Distribuidor INNER JOIN dbo.dist_dtDistribuidores d ON pp.Id_Distribuidor = d.Id_Distribuidor
WHERE pp.Id_Publicacion = @IdPublicacionParam WHERE pp.Id_Publicacion = @IdPublicacionParam AND d.Baja = 0
ORDER BY d.Nombre, pp.VigenciaD DESC"; ORDER BY d.Nombre, pp.VigenciaD DESC";
try try
{ {

View File

@@ -15,7 +15,9 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
int? idEstadoBobina, int? idEstadoBobina,
string? remitoFilter, string? remitoFilter,
DateTime? fechaDesde, DateTime? fechaDesde,
DateTime? fechaHasta); DateTime? fechaHasta,
DateTime? fechaEstadoDesde,
DateTime? fechaEstadoHasta);
Task<StockBobina?> GetByIdAsync(int idBobina); Task<StockBobina?> GetByIdAsync(int idBobina);
Task<StockBobina?> GetByNroBobinaAsync(string nroBobina); // Para validar unicidad de NroBobina Task<StockBobina?> GetByNroBobinaAsync(string nroBobina); // Para validar unicidad de NroBobina

View File

@@ -23,7 +23,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
public async Task<IEnumerable<StockBobina>> GetAllAsync( public async Task<IEnumerable<StockBobina>> GetAllAsync(
int? idTipoBobina, string? nroBobinaFilter, int? idPlanta, int? idTipoBobina, string? nroBobinaFilter, int? idPlanta,
int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta) int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaEstadoDesde, DateTime? fechaEstadoHasta)
{ {
var sqlBuilder = new StringBuilder(@" var sqlBuilder = new StringBuilder(@"
SELECT SELECT
@@ -69,6 +69,16 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
sqlBuilder.Append(" AND sb.FechaRemito <= @FechaHastaParam"); sqlBuilder.Append(" AND sb.FechaRemito <= @FechaHastaParam");
parameters.Add("FechaHastaParam", fechaHasta.Value.Date); parameters.Add("FechaHastaParam", fechaHasta.Value.Date);
} }
if (fechaEstadoDesde.HasValue)
{
sqlBuilder.Append(" AND sb.FechaEstado >= @FechaEstadoDesdeParam");
parameters.Add("FechaEstadoDesdeParam", fechaEstadoDesde.Value.Date);
}
if (fechaEstadoHasta.HasValue)
{
sqlBuilder.Append(" AND sb.FechaEstado <= @FechaEstadoHastaParam");
parameters.Add("FechaEstadoHastaParam", fechaEstadoHasta.Value.Date);
}
sqlBuilder.Append(" ORDER BY sb.FechaRemito DESC, sb.NroBobina;"); sqlBuilder.Append(" ORDER BY sb.FechaRemito DESC, sb.NroBobina;");

View File

@@ -45,5 +45,10 @@ 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);
Task<IEnumerable<DetalleDistribucionCanillaDto>> GetDetalleDistribucionCanillasPubli_AllEmpresasAsync(DateTime fecha);
Task<IEnumerable<DetalleDistribucionCanillaDto>> GetDetalleDistribucionCanillasAccPubli_AllEmpresasAsync(DateTime fecha);
} }
} }

View File

@@ -547,5 +547,145 @@ 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>();
}
}
public async Task<IEnumerable<DetalleDistribucionCanillaDto>> GetDetalleDistribucionCanillasPubli_AllEmpresasAsync(DateTime fecha)
{
const string spName = "dbo.SP_DistCanillasEntradaSalidaPubli_AllEmpresas";
var parameters = new DynamicParameters();
parameters.Add("@fecha", fecha, DbType.DateTime);
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<DetalleDistribucionCanillaDto>(spName, parameters, commandType: CommandType.StoredProcedure, commandTimeout: 120);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error SP {SPName}", spName);
return Enumerable.Empty<DetalleDistribucionCanillaDto>();
}
}
public async Task<IEnumerable<DetalleDistribucionCanillaDto>> GetDetalleDistribucionCanillasAccPubli_AllEmpresasAsync(DateTime fecha)
{
const string spName = "dbo.SP_DistCanillasAccEntradaSalidaPubli_AllEmpresas";
var parameters = new DynamicParameters();
parameters.Add("@fecha", fecha, DbType.DateTime);
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<DetalleDistribucionCanillaDto>(spName, parameters, commandType: CommandType.StoredProcedure, commandTimeout: 120);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error SP {SPName}", spName);
return Enumerable.Empty<DetalleDistribucionCanillaDto>();
}
}
} }
} }

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

View File

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

View File

@@ -0,0 +1,22 @@
using System.Data;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public interface IFacturaDetalleRepository
{
/// <summary>
/// Crea un nuevo registro de detalle de factura.
/// </summary>
Task<FacturaDetalle?> CreateAsync(FacturaDetalle nuevoDetalle, IDbTransaction transaction);
/// <summary>
/// Obtiene todos los detalles de una factura específica.
/// </summary>
Task<IEnumerable<FacturaDetalle>> GetDetallesPorFacturaIdAsync(int idFactura);
/// <summary>
/// Obtiene de forma eficiente todos los detalles de todas las facturas de un período específico.
/// </summary>
Task<IEnumerable<FacturaDetalle>> GetDetallesPorPeriodoAsync(string periodo);
}
}

View File

@@ -6,13 +6,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, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo); Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(
Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction); 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

@@ -10,5 +10,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction); Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction);
Task<bool> UpdateAsync(Promocion promocion, 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, IDbTransaction transaction);
Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo);
} }
} }

View File

@@ -5,13 +5,13 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
public interface ISuscripcionRepository 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<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion); Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction);
Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction);
Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, 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

@@ -19,7 +19,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas) public async Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas)
{ {
var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones"); var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones");
if(soloActivas) if (soloActivas)
{ {
sql.Append(" WHERE Activa = 1"); sql.Append(" WHERE Activa = 1");
} }
@@ -39,10 +39,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction) public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction)
{ {
const string sql = @" const string sql = @"
INSERT INTO dbo.susc_Promociones (Descripcion, TipoPromocion, Valor, FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta) INSERT INTO dbo.susc_Promociones
(Descripcion, TipoEfecto, ValorEfecto, TipoCondicion, ValorCondicion,
FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta)
OUTPUT INSERTED.* OUTPUT INSERTED.*
VALUES (@Descripcion, @TipoPromocion, @Valor, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());"; VALUES (@Descripcion, @TipoEfecto, @ValorEfecto, @TipoCondicion,
@ValorCondicion, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());";
if (transaction?.Connection == null) if (transaction?.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
@@ -74,20 +76,43 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction) 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 = @" const string sql = @"
SELECT p.* FROM dbo.susc_Promociones p SELECT p.*
FROM dbo.susc_Promociones p
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion WHERE sp.IdSuscripcion = @IdSuscripcion
AND p.Activa = 1 AND p.Activa = 1
-- 1. La promoción general debe estar activa en el período
AND p.FechaInicio <= @FechaPeriodo AND p.FechaInicio <= @FechaPeriodo
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo);"; AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo)
-- 2. La asignación específica al cliente debe estar activa en el período
AND sp.VigenciaDesde <= @FechaPeriodo
AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);";
if (transaction?.Connection == null) if (transaction?.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); 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); 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);
@@ -112,30 +111,35 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return rowsAffected == 1; return rowsAffected == 1;
} }
public async Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion) public async Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion)
{ {
const string sql = @" const string sql = @"
SELECT p.* FROM dbo.susc_Promociones p SELECT sp.*, p.*
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion FROM dbo.susc_SuscripcionPromociones sp
JOIN dbo.susc_Promociones p ON sp.IdPromocion = p.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion;"; WHERE sp.IdSuscripcion = @IdSuscripcion;";
using var connection = _connectionFactory.CreateConnection(); using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion }); var result = await connection.QueryAsync<SuscripcionPromocion, Promocion, (SuscripcionPromocion, Promocion)>(
sql,
(asignacion, promocion) => (asignacion, promocion),
new { IdSuscripcion = idSuscripcion },
splitOn: "IdPromocion"
);
return result;
} }
public async Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction) public async Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction)
{ {
if (transaction == null || transaction.Connection == null) 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 = @" const string sql = @"
INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno) INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno, VigenciaDesde, VigenciaHasta, FechaAsignacion)
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuario);"; VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());";
await transaction.Connection.ExecuteAsync(sql, await transaction.Connection.ExecuteAsync(sql, asignacion, transaction);
new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion, IdUsuario = idUsuario },
transaction);
} }
public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction) public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction)
@@ -145,7 +149,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); 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;"; const string sql = "DELETE FROM dbo.susc_SuscripcionPromociones WHERE IdSuscripcion = @IdSuscripcion AND IdPromocion = @IdPromocion;";
var rows = await transaction.Connection.ExecuteAsync(sql, new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion }, transaction); var rows = await transaction.Connection.ExecuteAsync(sql, new { idSuscripcion, idPromocion }, transaction);
return rows == 1; 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

@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>4923d7ee-0944-456c-abcd-d6ce13ba8485</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,73 @@
using GestionIntegral.Api.Services.Contables;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Text.Json;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Middleware
{
// Centraliza el mapeo de excepciones semánticas a HTTP responses con cuerpo JSON estandarizado.
// Va PRIMERO en el pipeline para catchear cualquier excepción que escape de los controllers/services.
public class ExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (BloqueoPorPeriodoCerradoException ex)
{
_logger.LogWarning(
"Bloqueo por período cerrado: cierre #{IdCierre} FechaCorte={FechaCorte:yyyy-MM-dd}. Path={Path}",
ex.IdCierre, ex.FechaCorte, context.Request.Path);
await WriteJsonAsync(context, StatusCodes.Status409Conflict, new
{
codigo = "PERIODO_CERRADO_BLOQUEO_OPERACION",
mensaje = ex.Message,
idCierre = ex.IdCierre,
fechaCorte = ex.FechaCorte.ToString("yyyy-MM-dd")
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción no manejada. Path={Path}", context.Request.Path);
await WriteJsonAsync(context, StatusCodes.Status500InternalServerError, new
{
codigo = "ERROR_INTERNO",
mensaje = "Ocurrió un error inesperado al procesar la solicitud."
});
}
}
private static Task WriteJsonAsync(HttpContext context, int statusCode, object body)
{
if (context.Response.HasStarted)
{
// Si los headers ya se enviaron no podemos re-escribir el response. Solo loguear y salir.
return Task.CompletedTask;
}
context.Response.Clear();
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
return context.Response.WriteAsync(JsonSerializer.Serialize(body, JsonOptions));
}
}
}

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,19 @@
using System;
namespace GestionIntegral.Api.Models.Contables
{
public class CierreCuentaCorriente // Corresponde a cue_CierresCuentaCorriente
{
public int IdCierre { get; set; }
public int IdDistribuidor { get; set; }
public int IdEmpresa { get; set; }
public DateTime FechaCorte { get; set; }
public DateTime FechaCierre { get; set; }
public decimal SaldoCierre { get; set; } // money en SQL, decimal en C#
public string Estado { get; set; } = "Activo"; // 'Activo' | 'Anulado'
public string? Justificacion { get; set; }
public int IdUsuarioCierre { get; set; }
public int? IdUsuarioAnula { get; set; }
public DateTime? FechaAnulacion { get; set; }
public string? JustificacionAnulacion { get; set; }
}
}

View File

@@ -0,0 +1,23 @@
using System;
namespace GestionIntegral.Api.Models.Contables
{
public class CierreCuentaCorrienteHistorico // Corresponde a cue_CierresCuentaCorriente_H
{
public int Id_Historial { get; set; }
public int Id_Cierre { get; set; }
public int Id_Distribuidor { get; set; }
public int Id_Empresa { get; set; }
public DateTime FechaCorte { get; set; }
public DateTime FechaCierre { get; set; }
public decimal SaldoCierre { get; set; }
public string Estado { get; set; } = string.Empty;
public string? Justificacion { get; set; }
public int Id_Usuario_Cierre { get; set; }
public int? Id_Usuario_Anula { get; set; }
public DateTime? FechaAnulacion { get; set; }
public string? Justificacion_Anulacion { get; set; }
public string TipoMod { get; set; } = string.Empty; // 'Creacion' | 'Reapertura' | 'Modificacion'
public int Id_Usuario_Mod { get; set; }
public DateTime FechaMod { get; set; }
}
}

View File

@@ -14,5 +14,7 @@ namespace GestionIntegral.Api.Models.Distribucion
public string? Telefono { get; set; } public string? Telefono { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Localidad { get; set; } public string? Localidad { get; set; }
public bool Baja { get; set; } // Baja (bit, NOT NULL, DEFAULT 0)
public DateTime? FechaBaja { get; set; } // FechaBaja (datetime2(0), NULL)
} }
} }

View File

@@ -16,6 +16,8 @@ namespace GestionIntegral.Api.Models.Distribucion
public string? Telefono { get; set; } public string? Telefono { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Localidad { get; set; } public string? Localidad { get; set; }
public bool? Baja { get; set; }
public DateTime? FechaBaja { get; set; }
public int Id_Usuario { get; set; } public int Id_Usuario { get; set; }
public DateTime FechaMod { get; set; } public DateTime FechaMod { get; set; }
public string TipoMod { get; set; } = string.Empty; public string TipoMod { get; set; } = string.Empty;

View File

@@ -0,0 +1,26 @@
using System;
namespace GestionIntegral.Api.Dtos.Auditoria
{
public class CierreCuentaCorrienteHistorialDto
{
public int Id_Historial { get; set; }
public int Id_Cierre { get; set; }
public int Id_Distribuidor { get; set; }
public int Id_Empresa { get; set; }
public DateTime FechaCorte { get; set; }
public DateTime FechaCierre { get; set; }
public decimal SaldoCierre { get; set; }
public string Estado { get; set; } = string.Empty;
public string? Justificacion { get; set; }
public int Id_Usuario_Cierre { get; set; }
public int? Id_Usuario_Anula { get; set; }
public DateTime? FechaAnulacion { get; set; }
public string? Justificacion_Anulacion { get; set; }
public string TipoMod { get; set; } = string.Empty; // 'Creacion' | 'Reapertura' | 'Modificacion'
public int Id_Usuario_Mod { get; set; }
public string NombreUsuarioModifico { get; set; } = string.Empty;
public DateTime FechaMod { get; set; }
}
}

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Contables namespace GestionIntegral.Api.Dtos.Contables
@@ -24,5 +25,11 @@ namespace GestionIntegral.Api.Dtos.Contables
[Required(ErrorMessage = "La justificación del ajuste es obligatoria.")] [Required(ErrorMessage = "La justificación del ajuste es obligatoria.")]
[StringLength(250, MinimumLength = 5, ErrorMessage = "La justificación debe tener entre 5 y 250 caracteres.")] [StringLength(250, MinimumLength = 5, ErrorMessage = "La justificación debe tener entre 5 y 250 caracteres.")]
public string Justificacion { get; set; } = string.Empty; public string Justificacion { get; set; } = string.Empty;
// Fecha lógica de la operación. Se valida contra el último cierre vigente
// del par (Distribuidor + Empresa) para bloquear ajustes en períodos cerrados.
// Distinta de FechaAjuste, que es el momento de ejecución del ajuste en el sistema.
[Required(ErrorMessage = "La fecha de operación es obligatoria.")]
public DateTime FechaOperacion { get; set; }
} }
} }

View File

@@ -0,0 +1,25 @@
using System;
namespace GestionIntegral.Api.Dtos.Contables
{
public class CierreCuentaCorrienteDto
{
public int IdCierre { get; set; }
public int IdDistribuidor { get; set; }
public string NombreDistribuidor { get; set; } = string.Empty;
public int IdEmpresa { get; set; }
public string NombreEmpresa { get; set; } = string.Empty;
public string FechaCorte { get; set; } = string.Empty; // yyyy-MM-dd
public DateTime FechaCierre { get; set; }
public decimal SaldoCierre { get; set; }
public string Estado { get; set; } = string.Empty;
public string? Justificacion { get; set; }
public int IdUsuarioCierre { get; set; }
public string NombreUsuarioCierre { get; set; } = string.Empty;
public int? IdUsuarioAnula { get; set; }
public string? NombreUsuarioAnula { get; set; }
public DateTime? FechaAnulacion { get; set; }
public string? JustificacionAnulacion { get; set; }
public bool EsUltimoVigente { get; set; }
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Contables
{
public class CrearCierreDto
{
[Required(ErrorMessage = "El distribuidor es obligatorio.")]
[Range(1, int.MaxValue, ErrorMessage = "ID de Distribuidor inválido.")]
public int IdDistribuidor { get; set; }
[Required(ErrorMessage = "La empresa es obligatoria.")]
[Range(1, int.MaxValue, ErrorMessage = "ID de Empresa inválido.")]
public int IdEmpresa { get; set; }
[Required(ErrorMessage = "La fecha de corte es obligatoria.")]
public DateTime FechaCorte { get; set; }
[StringLength(500, ErrorMessage = "La justificación no puede superar los 500 caracteres.")]
public string? Justificacion { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Contables
{
public class ReabrirCierreDto
{
[Required(ErrorMessage = "La justificación es obligatoria al reabrir un cierre.")]
[StringLength(500, MinimumLength = 10, ErrorMessage = "La justificación debe tener entre 10 y 500 caracteres.")]
public string Justificacion { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace GestionIntegral.Api.Dtos.Contables
{
public class UltimoCierreDto
{
public int IdCierre { get; set; }
public string FechaCorte { get; set; } = string.Empty; // yyyy-MM-dd
public decimal SaldoCierre { get; set; }
public string Estado { get; set; } = string.Empty;
}
}

View File

@@ -15,5 +15,7 @@ namespace GestionIntegral.Api.Dtos.Distribucion
public string? Telefono { get; set; } public string? Telefono { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Localidad { get; set; } public string? Localidad { get; set; }
public bool Baja { get; set; }
public DateTime? FechaBaja { get; set; }
} }
} }

View File

@@ -0,0 +1,10 @@
using System;
namespace GestionIntegral.Api.Dtos.Distribucion
{
public class ToggleBajaDistribuidorDto
{
public bool DarDeBaja { get; set; }
public DateTime? FechaBaja { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
// Backend/GestionIntegral.Api/Models/Dtos/Impresion/BobinaLoteDetalleDto.cs
namespace GestionIntegral.Api.Dtos.Impresion
{
public class BobinaLoteDetalleDto
{
public int IdTipoBobina { get; set; }
public string NroBobina { get; set; } = string.Empty;
public int Peso { get; set; }
}
}

View File

@@ -0,0 +1,23 @@
// Backend/GestionIntegral.Api/Models/Dtos/Impresion/CreateStockBobinaLoteDto.cs
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Impresion
{
public class CreateStockBobinaLoteDto
{
[Required]
public int IdPlanta { get; set; }
[Required]
[StringLength(15)]
public string Remito { get; set; } = string.Empty;
[Required]
public DateTime FechaRemito { get; set; }
[Required]
[MinLength(1, ErrorMessage = "Debe ingresar al menos una bobina.")]
public List<BobinaLoteDetalleDto> Bobinas { get; set; } = new();
}
}

View File

@@ -0,0 +1,21 @@
// Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateFechaRemitoLoteDto.cs
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Impresion
{
public class UpdateFechaRemitoLoteDto
{
[Required]
public int IdPlanta { get; set; }
[Required]
public required string Remito { get; set; }
[Required]
public DateTime FechaRemitoActual { get; set; } // Para seguridad, nos aseguramos de cambiar el lote correcto
[Required]
public DateTime NuevaFechaRemito { get; set; }
}
}

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

@@ -7,8 +7,12 @@ namespace GestionIntegral.Api.Dtos.Reportes
public IEnumerable<BalanceCuentaDistDto> EntradasSalidas { get; set; } = new List<BalanceCuentaDistDto>(); public IEnumerable<BalanceCuentaDistDto> EntradasSalidas { get; set; } = new List<BalanceCuentaDistDto>();
public IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos { get; set; } = new List<BalanceCuentaDebCredDto>(); public IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos { get; set; } = new List<BalanceCuentaDebCredDto>();
public IEnumerable<BalanceCuentaPagosDto> Pagos { get; set; } = new List<BalanceCuentaPagosDto>(); public IEnumerable<BalanceCuentaPagosDto> Pagos { get; set; } = new List<BalanceCuentaPagosDto>();
public IEnumerable<SaldoDto> Saldos { get; set; } = new List<SaldoDto>(); // O podría ser SaldoDto SaldoActual si siempre es uno public string? NombreDistribuidor { get; set; }
public string? NombreDistribuidor { get; set; } // Para el título del reporte public string? NombreEmpresa { get; set; }
public string? NombreEmpresa { get; set; } // Para el título del reporte
// Saldo a la fecha desde elegida en el filtro:
// - Si existe cierre con FechaCorte < FechaDesde: SaldoCierre + movimientos netos entre fechaCierre+1 y fechaDesde-1.
// - Sin cierres previos: 0.
public decimal SaldoInicial { get; set; }
} }
} }

View File

@@ -11,8 +11,9 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
public IEnumerable<BalanceCuentaPagosDto> Pagos { get; set; } = new List<BalanceCuentaPagosDto>(); public IEnumerable<BalanceCuentaPagosDto> Pagos { get; set; } = new List<BalanceCuentaPagosDto>();
public IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos { get; set; } = new List<BalanceCuentaDebCredDto>(); public IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos { get; set; } = new List<BalanceCuentaDebCredDto>();
// Saldo real de la cuenta, se muestra al final sin usarse en cálculos intermedios. // Saldo inicial del período: snapshot del último cierre + movimientos netos hasta fechaDesde.
public decimal SaldoDeCuenta { get; set; } // 0 si no hay cierre previo.
public decimal SaldoInicial { get; set; }
// --- Parámetros del reporte --- // --- Parámetros del reporte ---
public string NombreDistribuidor { get; set; } = string.Empty; public string NombreDistribuidor { get; set; } = string.Empty;
@@ -24,6 +25,8 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
public decimal TotalMovimientos => Movimientos.Sum(m => m.Debe - m.Haber); public decimal TotalMovimientos => Movimientos.Sum(m => m.Debe - m.Haber);
public decimal TotalPagos => Pagos.Sum(p => p.Debe - p.Haber); public decimal TotalPagos => Pagos.Sum(p => p.Debe - p.Haber);
public decimal TotalDebitosCreditos => DebitosCreditos.Sum(d => d.Debe - d.Haber); public decimal TotalDebitosCreditos => DebitosCreditos.Sum(d => d.Debe - d.Haber);
public decimal TotalPeriodo => TotalMovimientos + TotalPagos + TotalDebitosCreditos;
// Saldo Final = Saldo Inicial + suma neta del período (Debe - Haber por sección).
public decimal SaldoFinal => SaldoInicial + TotalMovimientos + TotalPagos + TotalDebitosCreditos;
} }
} }

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

@@ -30,6 +30,22 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
} }
} }
public decimal PorcentajeDevolucionGeneral
{
get
{
if (PromediosPorDia == null || !PromediosPorDia.Any()) return 0;
var totalPonderadoLlevados = PromediosPorDia.Sum(p => p.Promedio_Llevados * p.Cant);
var totalPonderadoDevueltos = PromediosPorDia.Sum(p => p.Promedio_Devueltos * p.Cant);
if (totalPonderadoLlevados == 0) return 0;
// Calculamos el porcentaje usando los totales ponderados para máxima precisión como lo hace el frontend.
return (decimal)totalPonderadoDevueltos * 100 / totalPonderadoLlevados;
}
}
// --- PROPIEDAD PARA LA FILA "GENERAL" --- // --- PROPIEDAD PARA LA FILA "GENERAL" ---
public ListadoDistribucionCanillasPromedioDiaDto? PromedioGeneral public ListadoDistribucionCanillasPromedioDiaDto? PromedioGeneral
{ {
@@ -37,20 +53,27 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
{ {
if (PromediosPorDia == null || !PromediosPorDia.Any()) return null; if (PromediosPorDia == null || !PromediosPorDia.Any()) return null;
// Sumamos los totales, no promediamos los promedios // Sumamos los totales ponderados para cada columna
var totalLlevados = PromediosPorDia.Sum(p => p.Llevados); var totalPonderadoLlevados = PromediosPorDia.Sum(p => p.Promedio_Llevados * p.Cant);
var totalDevueltos = PromediosPorDia.Sum(p => p.Devueltos); var totalPonderadoDevueltos = PromediosPorDia.Sum(p => p.Promedio_Devueltos * p.Cant);
var totalPonderadoVentas = PromediosPorDia.Sum(p => p.Promedio_Ventas * p.Cant);
var totalDias = PromediosPorDia.Sum(p => p.Cant); var totalDias = PromediosPorDia.Sum(p => p.Cant);
if (totalDias == 0) return null; if (totalDias == 0) return null;
// Usamos Math.Round para un redondeo matemático estándar antes de la conversión.
// MidpointRounding.AwayFromZero asegura que .5 se redondee hacia arriba, igual que en JavaScript.
var promGeneralLlevados = (int)Math.Round((decimal)totalPonderadoLlevados / totalDias, MidpointRounding.AwayFromZero);
var promGeneralDevueltos = (int)Math.Round((decimal)totalPonderadoDevueltos / totalDias, MidpointRounding.AwayFromZero);
var promGeneralVentas = (int)Math.Round((decimal)totalPonderadoVentas / totalDias, MidpointRounding.AwayFromZero);
return new ListadoDistribucionCanillasPromedioDiaDto return new ListadoDistribucionCanillasPromedioDiaDto
{ {
Dia = "General", Dia = "General",
Cant = totalDias, Cant = totalDias,
Promedio_Llevados = totalLlevados / totalDias, Promedio_Llevados = promGeneralLlevados,
Promedio_Devueltos = totalDevueltos / totalDias, Promedio_Devueltos = promGeneralDevueltos,
Promedio_Ventas = (totalLlevados - totalDevueltos) / totalDias Promedio_Ventas = promGeneralVentas
}; };
} }
} }

View File

@@ -20,26 +20,26 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
{ {
get get
{ {
if (DetalleDiario == null || !DetalleDiario.Any()) if (PromediosPorDia == null || !PromediosPorDia.Any())
{ {
return null; return null;
} }
var promediosValidos = PromediosPorDia.Where(p => p.Dia != "General").ToList();
var diasConDatos = DetalleDiario.Count(d => (d.Llevados ?? 0) > 0); if (!promediosValidos.Any()) return null;
if (diasConDatos == 0) return null; var countPromedios = promediosValidos.Count;
var sumPromLlevados = promediosValidos.Sum(p => p.Promedio_Llevados ?? 0);
var totalLlevados = DetalleDiario.Sum(d => d.Llevados ?? 0); var sumPromDevueltos = promediosValidos.Sum(p => p.Promedio_Devueltos ?? 0);
var totalDevueltos = DetalleDiario.Sum(d => d.Devueltos ?? 0); var sumPromVentas = promediosValidos.Sum(p => p.Promedio_Ventas ?? 0);
return new ListadoDistribucionDistPromedioDiaDto return new ListadoDistribucionDistPromedioDiaDto
{ {
Dia = "General", Dia = "General",
Cant = diasConDatos, Cant = promediosValidos.Sum(p => p.Cant ?? 0),
Promedio_Llevados = totalLlevados / diasConDatos, Promedio_Llevados = (int)Math.Round((decimal)sumPromLlevados / countPromedios, MidpointRounding.AwayFromZero),
Promedio_Devueltos = totalDevueltos / diasConDatos, Promedio_Devueltos = (int)Math.Round((decimal)sumPromDevueltos / countPromedios, MidpointRounding.AwayFromZero),
Promedio_Ventas = (totalLlevados - totalDevueltos) / diasConDatos, Promedio_Ventas = (int)Math.Round((decimal)sumPromVentas / countPromedios, MidpointRounding.AwayFromZero),
Llevados = totalLlevados, // Guardamos el total para el cálculo del % Llevados = (int)Math.Round((decimal)sumPromLlevados / countPromedios, MidpointRounding.AwayFromZero),
Devueltos = totalDevueltos // Guardamos el total para el cálculo del % Devueltos = (int)Math.Round((decimal)sumPromDevueltos / countPromedios, MidpointRounding.AwayFromZero)
}; };
} }
} }

View File

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

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class AsignarPromocionDto
{
[Required]
public int IdPromocion { get; set; }
[Required]
public DateTime VigenciaDesde { get; set; }
public DateTime? VigenciaHasta { get; set; }
}
}

View File

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

View File

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

@@ -1,22 +1,25 @@
namespace GestionIntegral.Api.Dtos.Suscripciones namespace GestionIntegral.Api.Dtos.Suscripciones
{ {
/// <summary> public class FacturaDetalleDto
/// DTO para enviar la información de una factura generada al frontend. {
/// Incluye datos enriquecidos como nombres para facilitar su visualización en la UI. public string Descripcion { get; set; } = string.Empty;
/// </summary> public decimal ImporteNeto { get; set; }
}
public class FacturaDto public class FacturaDto
{ {
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; // Formato "YYYY-MM" public string Periodo { get; set; } = string.Empty;
public string FechaEmision { get; set; } = string.Empty; // Formato "yyyy-MM-dd" public string FechaEmision { get; set; } = string.Empty;
public string FechaVencimiento { get; set; } = string.Empty; // Formato "yyyy-MM-dd" public string FechaVencimiento { get; set; } = string.Empty;
public decimal ImporteFinal { get; set; } public decimal ImporteFinal { get; set; }
public string Estado { get; set; } = string.Empty; public decimal TotalPagado { get; set; }
public decimal SaldoPendiente { get; set; }
public string EstadoPago { get; set; } = string.Empty;
public string EstadoFacturacion { get; set; } = string.Empty;
public string? NumeroFactura { get; set; } public string? NumeroFactura { get; set; }
// Datos enriquecidos para la UI, poblados por el servicio
public string NombreSuscriptor { get; set; } = string.Empty; public string NombreSuscriptor { get; set; } = string.Empty;
public string NombrePublicacion { get; set; } = string.Empty; public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
} }
} }

View File

@@ -0,0 +1,8 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class PromocionAsignadaDto : PromocionDto
{
public string VigenciaDesdeAsignacion { get; set; } = string.Empty;
public string? VigenciaHastaAsignacion { get; set; }
}
}

View File

@@ -4,9 +4,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
{ {
public int IdPromocion { get; set; } public int IdPromocion { get; set; }
public string Descripcion { get; set; } = string.Empty; public string Descripcion { get; set; } = string.Empty;
public string TipoPromocion { get; set; } = string.Empty; public string TipoEfecto { get; set; } = string.Empty;
public decimal Valor { get; set; } public decimal ValorEfecto { get; set; }
public string FechaInicio { get; set; } = string.Empty; // yyyy-MM-dd 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 string? FechaFin { get; set; }
public bool Activa { get; set; } public bool Activa { get; set; }
} }

View File

@@ -0,0 +1,11 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class ResumenCuentaSuscriptorDto
{
public int IdSuscriptor { get; set; }
public string NombreSuscriptor { get; set; } = string.Empty;
public decimal SaldoPendienteTotal { get; set; }
public decimal ImporteTotal { get; set; }
public List<FacturaConsolidadaDto> Facturas { get; set; } = new List<FacturaConsolidadaDto>();
}
}

View File

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

View File

@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones 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

@@ -4,8 +4,10 @@ namespace GestionIntegral.Api.Models.Suscripciones
{ {
public int IdPromocion { get; set; } public int IdPromocion { get; set; }
public string Descripcion { get; set; } = string.Empty; public string Descripcion { get; set; } = string.Empty;
public string TipoPromocion { get; set; } = string.Empty; public string TipoEfecto { get; set; } = string.Empty; // Nuevo nombre
public decimal Valor { get; set; } 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 FechaInicio { get; set; }
public DateTime? FechaFin { get; set; } public DateTime? FechaFin { get; set; }
public bool Activa { get; set; } public bool Activa { get; set; }

View File

@@ -6,5 +6,7 @@ namespace GestionIntegral.Api.Models.Suscripciones
public int IdPromocion { get; set; } public int IdPromocion { get; set; }
public DateTime FechaAsignacion { get; set; } public DateTime FechaAsignacion { get; set; }
public int IdUsuarioAsigno { get; set; } public int IdUsuarioAsigno { get; set; }
public DateTime VigenciaDesde { get; set; }
public DateTime? VigenciaHasta { get; set; }
} }
} }

View File

@@ -22,6 +22,8 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Services.Suscripciones; using GestionIntegral.Api.Services.Suscripciones;
using GestionIntegral.Api.Models.Comunicaciones; using GestionIntegral.Api.Models.Comunicaciones;
using GestionIntegral.Api.Services.Comunicaciones; using GestionIntegral.Api.Services.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
using GestionIntegral.Api.Middleware;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -95,6 +97,12 @@ builder.Services.AddScoped<ICambioParadaRepository, CambioParadaRepository>();
builder.Services.AddScoped<ICambioParadaService, CambioParadaService>(); builder.Services.AddScoped<ICambioParadaService, CambioParadaService>();
// Servicio de Saldos // Servicio de Saldos
builder.Services.AddScoped<ISaldoService, SaldoService>(); builder.Services.AddScoped<ISaldoService, SaldoService>();
// Cierre de Cuenta Corriente de Distribuidor
builder.Services.AddMemoryCache();
builder.Services.AddScoped<ICierreCuentaCorrienteRepository, CierreCuentaCorrienteRepository>();
builder.Services.AddScoped<ICierreCuentaCorrienteService, CierreCuentaCorrienteService>();
// Validador de período cerrado: SINGLETON porque mantiene cache en memoria de IMemoryCache que debe ser compartido entre requests.
builder.Services.AddSingleton<IPeriodoCerradoValidator, PeriodoCerradoValidator>();
// Repositorios de Reportes // Repositorios de Reportes
builder.Services.AddScoped<IReportesRepository, ReportesRepository>(); builder.Services.AddScoped<IReportesRepository, ReportesRepository>();
// Servicios de Reportes // Servicios de Reportes
@@ -112,6 +120,8 @@ 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<IPromocionRepository, PromocionRepository>();
builder.Services.AddScoped<IAjusteRepository, AjusteRepository>();
builder.Services.AddScoped<IFacturaDetalleRepository, FacturaDetalleRepository>();
builder.Services.AddScoped<IFormaPagoService, FormaPagoService>(); builder.Services.AddScoped<IFormaPagoService, FormaPagoService>();
builder.Services.AddScoped<ISuscriptorService, SuscriptorService>(); builder.Services.AddScoped<ISuscriptorService, SuscriptorService>();
@@ -120,10 +130,14 @@ builder.Services.AddScoped<IFacturacionService, FacturacionService>();
builder.Services.AddScoped<IDebitoAutomaticoService, DebitoAutomaticoService>(); builder.Services.AddScoped<IDebitoAutomaticoService, DebitoAutomaticoService>();
builder.Services.AddScoped<IPagoService, PagoService>(); builder.Services.AddScoped<IPagoService, PagoService>();
builder.Services.AddScoped<IPromocionService, PromocionService>(); builder.Services.AddScoped<IPromocionService, PromocionService>();
builder.Services.AddScoped<IAjusteService, AjusteService>();
// --- Comunicaciones --- // --- Comunicaciones ---
builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings")); builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings"));
builder.Services.AddTransient<IEmailService, EmailService>(); 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.
@@ -262,6 +276,10 @@ if (app.Environment.IsDevelopment())
// Comenta o elimina la siguiente línea si SÓLO usas http://localhost:5183 // Comenta o elimina la siguiente línea si SÓLO usas http://localhost:5183
//app.UseHttpsRedirection(); //app.UseHttpsRedirection();
// Middleware global de excepciones — debe ir TEMPRANO en el pipeline para catchear cualquier excepción
// que escape de los controllers/services. Mapea BloqueoPorPeriodoCerradoException → 409 con cuerpo JSON estandarizado.
app.UseMiddleware<ExceptionHandlerMiddleware>();
app.UseCors(MyAllowSpecificOrigins); app.UseCors(MyAllowSpecificOrigins);
app.UseAuthentication(); // Debe ir ANTES de UseAuthorization app.UseAuthentication(); // Debe ir ANTES de UseAuthorization

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