24 Commits

Author SHA1 Message Date
1020555db6 Feat TestModels 2025-11-18 13:11:33 -03:00
615cf282a1 Feat Detecciones en Bobinas y Montos 2025-11-10 15:36:25 -03:00
d040099b9a Feat Control Distribuidores 2025-11-10 15:06:10 -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
52 changed files with 1338 additions and 749 deletions

View File

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

View File

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

View File

@@ -41,9 +41,11 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
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 =>
{
@@ -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 =>
{
row.RelativeItem().Text("Ingresados por Remito:").SemiBold();
row.RelativeItem().AlignRight().Text(Model.TotalIngresadosPorRemito.ToString("N0"));
}); // <-- SOLUCIÓN: Borde sólido simple.
});
foreach (var item in Model.Detalles)
{
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();
@@ -90,7 +93,8 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
r.RelativeItem().Text("Devueltos");
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().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);
@@ -112,13 +117,15 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
if (isBold) valueText.SemiBold();
};
// Usamos bordes superiores para separar las líneas de total
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(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(2).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));
finalCol.Item().BorderTop(1).BorderColor(Colors.Grey.Lighten2).PaddingTop(2).Row(row => AddTotalRow(row, "Sobrantes", $"-{Model.TotalSobrantes.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));
// << CAMBIO: Reducido el padding superior de 2 a 1 en las siguientes líneas >>
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(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(1).Row(row => AddTotalRow(row, "Total Devolución", Model.TotalDevolucionGeneral.ToString("N0"), false));
// << CAMBIO: Reducido el padding superior de 5 a 3 >>
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).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

@@ -19,22 +19,31 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
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)
{
{
container.Page(page =>
{
page.Size(PageSizes.A5);
page.Margin(1, Unit.Centimetre);
page.Size(PageSizes.A4);
page.Margin(5, Unit.Millimetre);
page.DefaultTextStyle(x => x.FontFamily("Arial").FontSize(9));
page.Header().Element(ComposeHeader);
page.Content().Element(ComposeContent);
page.Content().Column(mainColumn =>
{
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)
{

View File

@@ -40,6 +40,7 @@ namespace GestionIntegral.Api.Controllers
private const string PermisoVerReporteListadoDistMensual = "RR009";
private const string PermisoVerReporteFacturasPublicidad = "RR010";
private const string PermisoVerReporteDistSuscripciones = "RR011";
private const string PermisoVerReportesSecretaria = "RR012";
public ReportesController(
IReportesService reportesService,
@@ -526,7 +527,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
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);
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'." });
@@ -540,7 +541,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
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);
@@ -577,7 +578,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
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);
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'." });
@@ -591,7 +592,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
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);
@@ -628,7 +629,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
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);
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." });
@@ -642,7 +643,7 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
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);
@@ -677,13 +678,18 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[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();
// Pasar el nuevo parámetro al servicio
var (canillas, canillasAcc, canillasAll, canillasFechaLiq, canillasAccFechaLiq,
ctrlDevolucionesRemitos, ctrlDevolucionesParaDistCan, ctrlDevolucionesOtrosDias, error) =
await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa);
await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa, esAccionista);
if (error != null) return BadRequest(new { message = error });
@@ -718,14 +724,20 @@ namespace GestionIntegral.Api.Controllers
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[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();
// Pasar el nuevo parámetro al servicio
var (
canillas, canillasAcc, canillasAll, canillasFechaLiq, canillasAccFechaLiq,
remitos, ctrlDevoluciones, _, error
) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa);
) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa, esAccionista);
if (error != null) return BadRequest(new { message = error });
@@ -794,11 +806,11 @@ namespace GestionIntegral.Api.Controllers
_, // canillasAll
_, // canillasFechaLiq
_, // canillasAccFechaLiq
ctrlDevolucionesRemitosData, // Para SP_ObtenerCtrlDevoluciones -> DataSet "DSObtenerCtrlDevoluciones"
ctrlDevolucionesParaDistCanData, // Para SP_DistCanillasCantidadEntradaSalida -> DataSet "DSCtrlDevoluciones"
ctrlDevolucionesOtrosDiasData, // Para SP_DistCanillasCantidadEntradaSalidaOtrosDias -> DataSet "DSCtrlDevolucionesOtrosDias"
ctrlDevolucionesRemitosData,
ctrlDevolucionesParaDistCanData,
ctrlDevolucionesOtrosDiasData,
error
) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa); // Reutilizamos este método
) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa, null);
if (error != null) return BadRequest(new { message = error });
@@ -832,7 +844,7 @@ namespace GestionIntegral.Api.Controllers
var (
_, _, _, _, _, // Datos no utilizados
remitos, detalles, otrosDias, error
) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa);
) = await _reportesService.ObtenerReporteDistribucionCanillasAsync(fecha, idEmpresa, null);
if (error != null) return BadRequest(new { message = error });

View File

@@ -75,12 +75,13 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
int anio, int mes,
[FromQuery] string? nombreSuscriptor,
[FromQuery] string? estadoPago,
[FromQuery] string? estadoFacturacion)
[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);
var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura);
return Ok(resumenes);
}

View File

@@ -16,7 +16,7 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
Task<PagoDistribuidor?> CreateAsync(PagoDistribuidor nuevoPago, int idUsuario, IDbTransaction transaction);
Task<bool> UpdateAsync(PagoDistribuidor pagoAActualizar, 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(
DateTime? fechaDesde, DateTime? fechaHasta,
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();
parameters.Add("ReciboParam", recibo);
parameters.Add("TipoMovParam", tipoMovimiento);
@@ -85,12 +86,12 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
try
{
using var connection = _cf.CreateConnection();
return await connection.ExecuteScalarAsync<bool>(sqlBuilder.ToString(), parameters);
return await connection.QuerySingleOrDefaultAsync<PagoDistribuidor>(sqlBuilder.ToString(), parameters);
}
catch (Exception ex)
{
_log.LogError(ex, "Error en ExistsByReciboAndTipoMovimientoAsync. Recibo: {Recibo}, Tipo: {Tipo}", recibo, tipoMovimiento);
return true; // Asumir que existe en caso de error para prevenir duplicados
_log.LogError(ex, "Error en GetByReciboAndTipoMovimientoAsync. Recibo: {Recibo}, Tipo: {Tipo}", recibo, tipoMovimiento);
throw; // Relanzar para que el servicio lo maneje
}
}

View File

@@ -48,5 +48,7 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
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

@@ -653,5 +653,39 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
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

@@ -59,10 +59,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sqlInsert = @"
INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion)
INSERT INTO dbo.susc_Facturas
(IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,
DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion, TipoFactura)
OUTPUT INSERTED.*
VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);";
VALUES
(@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,
@DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion, @TipoFactura);";
return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction);
}
@@ -104,7 +109,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return rowsAffected == idsFacturas.Count();
}
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion)
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(
string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura)
{
var sqlBuilder = new StringBuilder(@"
WITH FacturaConEmpresa AS (
@@ -149,6 +155,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
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

View File

@@ -15,7 +15,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction);
Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction);
Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction);
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion);
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(
string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura);
Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);
Task<string?> GetUltimoPeriodoFacturadoAsync();
Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo);

View File

@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>4923d7ee-0944-456c-abcd-d6ce13ba8485</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />

View File

@@ -8,6 +8,9 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
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

@@ -15,5 +15,6 @@ namespace GestionIntegral.Api.Models.Suscripciones
public string? NumeroFactura { get; set; }
public int? IdLoteDebito { get; set; }
public string? MotivoRechazo { get; set; }
public string TipoFactura { get; set; } = string.Empty;
}
}

View File

@@ -24,10 +24,6 @@ using GestionIntegral.Api.Models.Comunicaciones;
using GestionIntegral.Api.Services.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Comunicaciones;
// Carga las variables de entorno desde el archivo .env al inicio de la aplicación.
// Debe ser la primera línea para que la configuración esté disponible para el 'builder'.
DotNetEnv.Env.Load();
var builder = WebApplication.CreateBuilder(args);
// --- Registros de Servicios ---

View File

@@ -4,6 +4,8 @@ using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace GestionIntegral.Api.Services.Comunicaciones
{
@@ -88,6 +90,30 @@ namespace GestionIntegral.Api.Services.Comunicaciones
using var smtp = new SmtpClient();
try
{
// Se añade una política de validación de certificado personalizada.
// Esto es necesario para entornos de desarrollo o redes internas donde
// el nombre del host al que nos conectamos (ej. una IP) no coincide
// con el nombre en el certificado SSL (ej. mail.eldia.com).
smtp.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
{
// Si no hay errores, el certificado es válido.
if (sslPolicyErrors == SslPolicyErrors.None)
return true;
// Si el único error es que el nombre no coincide (RemoteCertificateNameMismatch)
// Y el certificado es el que esperamos (emitido para "mail.eldia.com"),
// entonces lo aceptamos como válido.
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch) && certificate != null && certificate.Subject.Contains("CN=mail.eldia.com"))
{
_logger.LogWarning("Se aceptó un certificado SSL con 'Name Mismatch' para el host de confianza 'mail.eldia.com'.");
return true;
}
// Para cualquier otro error, rechazamos el certificado.
_logger.LogError("Error de validación de certificado SSL: {Errors}", sslPolicyErrors);
return false;
};
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
await smtp.SendAsync(emailMessage);
@@ -95,20 +121,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones
log.Estado = "Enviado";
_logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
}
catch (SmtpCommandException scEx)
{
_logger.LogError(scEx, "Error de comando SMTP al enviar a {Destinatario}. StatusCode: {StatusCode}", destinatario, scEx.StatusCode);
log.Estado = "Fallido";
log.Error = $"Error del servidor: ({scEx.StatusCode}) {scEx.Message}";
throw;
}
catch (AuthenticationException authEx)
{
_logger.LogError(authEx, "Error de autenticación con el servidor SMTP.");
log.Estado = "Fallido";
log.Error = "Error de autenticación. Revise las credenciales de correo.";
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);

View File

@@ -93,8 +93,18 @@ namespace GestionIntegral.Api.Services.Contables
return (null, "Tipo de pago no válido.");
if (await _empresaRepo.GetByIdAsync(createDto.IdEmpresa) == null)
return (null, "Empresa no válida.");
if (await _pagoRepo.ExistsByReciboAndTipoMovimientoAsync(createDto.Recibo, createDto.TipoMovimiento))
return (null, $"Ya existe un pago '{createDto.TipoMovimiento}' con el número de recibo '{createDto.Recibo}'.");
var pagoExistente = await _pagoRepo.GetByReciboAndTipoMovimientoAsync(createDto.Recibo, createDto.TipoMovimiento);
if (pagoExistente != null)
{
// Si encontramos un duplicado, obtenemos los detalles para el mensaje de error
var distribuidor = await _distribuidorRepo.GetByIdSimpleAsync(pagoExistente.IdDistribuidor);
var empresa = await _empresaRepo.GetByIdAsync(pagoExistente.IdEmpresa);
string mensajeError = $"El recibo N° {createDto.Recibo} ya fue registrado como '{pagoExistente.TipoMovimiento}' el {pagoExistente.Fecha:dd/MM/yyyy} " +
$"para el distribuidor '{distribuidor?.Nombre ?? "Desconocido"}' en la empresa '{empresa?.Nombre ?? "Desconocida"}'.";
return (null, mensajeError);
}
var nuevoPago = new PagoDistribuidor
{

View File

@@ -32,11 +32,11 @@ namespace GestionIntegral.Api.Services.Reportes
IEnumerable<DetalleDistribucionCanillaAllDto> CanillasAll,
IEnumerable<DetalleDistribucionCanillaDto> CanillasFechaLiq,
IEnumerable<DetalleDistribucionCanillaDto> CanillasAccFechaLiq,
IEnumerable<ObtenerCtrlDevolucionesDto> CtrlDevolucionesRemitos, // Para SP_ObtenerCtrlDevoluciones
IEnumerable<ControlDevolucionesReporteDto> CtrlDevolucionesParaDistCan, // Para SP_DistCanillasCantidadEntradaSalida
IEnumerable<DevueltosOtrosDiasDto> CtrlDevolucionesOtrosDias, // <--- NUEVO para SP_DistCanillasCantidadEntradaSalidaOtrosDias
IEnumerable<ObtenerCtrlDevolucionesDto> CtrlDevolucionesRemitos,
IEnumerable<ControlDevolucionesReporteDto> CtrlDevolucionesParaDistCan,
IEnumerable<DevueltosOtrosDiasDto> CtrlDevolucionesOtrosDias,
string? Error
)> ObtenerReporteDistribucionCanillasAsync(DateTime fecha, int idEmpresa);
)> ObtenerReporteDistribucionCanillasAsync(DateTime fecha, int idEmpresa, bool? esAccionista);
// Reporte Tiradas por Publicación y Secciones (RR008)
Task<(IEnumerable<TiradasPublicacionesSeccionesDto> Data, string? Error)> ObtenerTiradasPublicacionesSeccionesAsync(int idPublicacion, DateTime fechaDesde, DateTime fechaHasta, int idPlanta);

View File

@@ -227,21 +227,57 @@ namespace GestionIntegral.Api.Services.Reportes
IEnumerable<ControlDevolucionesReporteDto> CtrlDevolucionesParaDistCan,
IEnumerable<DevueltosOtrosDiasDto> CtrlDevolucionesOtrosDias,
string? Error
)> ObtenerReporteDistribucionCanillasAsync(DateTime fecha, int idEmpresa)
)> ObtenerReporteDistribucionCanillasAsync(DateTime fecha, int idEmpresa, bool? esAccionista)
{
try
{
var canillasTask = _reportesRepository.GetDetalleDistribucionCanillasPubliAsync(fecha, idEmpresa);
var canillasAccTask = _reportesRepository.GetDetalleDistribucionCanillasAccPubliAsync(fecha, idEmpresa);
// Función helper para convertir fechas a UTC
Func<IEnumerable<DetalleDistribucionCanillaDto>, IEnumerable<DetalleDistribucionCanillaDto>> toUtc =
items => items?.Select(c => { if (c.Fecha.HasValue) c.Fecha = DateTime.SpecifyKind(c.Fecha.Value.Date, DateTimeKind.Utc); return c; }).ToList()
?? Enumerable.Empty<DetalleDistribucionCanillaDto>();
// --- NUEVA LÓGICA PARA "TODAS LAS EMPRESAS" ---
if (idEmpresa == 0)
{
Task<IEnumerable<DetalleDistribucionCanillaDto>> canillasTask = Task.FromResult(Enumerable.Empty<DetalleDistribucionCanillaDto>());
Task<IEnumerable<DetalleDistribucionCanillaDto>> canillasAccTask = Task.FromResult(Enumerable.Empty<DetalleDistribucionCanillaDto>());
if (esAccionista == true) // Solo accionistas
{
canillasAccTask = _reportesRepository.GetDetalleDistribucionCanillasAccPubli_AllEmpresasAsync(fecha);
}
else // Solo canillitas (o si es null, por defecto canillitas)
{
canillasTask = _reportesRepository.GetDetalleDistribucionCanillasPubli_AllEmpresasAsync(fecha);
}
await Task.WhenAll(canillasTask, canillasAccTask);
return (
toUtc(await canillasTask),
toUtc(await canillasAccTask),
Enumerable.Empty<DetalleDistribucionCanillaAllDto>(), // El resumen no aplica
Enumerable.Empty<DetalleDistribucionCanillaDto>(), // Liquidaciones de otras fechas no aplican en esta vista simplificada
Enumerable.Empty<DetalleDistribucionCanillaDto>(),
Enumerable.Empty<ObtenerCtrlDevolucionesDto>(), // Control de devoluciones no aplica
Enumerable.Empty<ControlDevolucionesReporteDto>(),
Enumerable.Empty<DevueltosOtrosDiasDto>(),
null
);
}
// --- LÓGICA ORIGINAL PARA UNA EMPRESA ESPECÍFICA ---
var canillasTaskOriginal = _reportesRepository.GetDetalleDistribucionCanillasPubliAsync(fecha, idEmpresa);
var canillasAccTaskOriginal = _reportesRepository.GetDetalleDistribucionCanillasAccPubliAsync(fecha, idEmpresa);
var canillasAllTask = _reportesRepository.GetDetalleDistribucionCanillasAllPubliAsync(fecha, idEmpresa);
var canillasFechaLiqTask = _reportesRepository.GetDetalleDistribucionCanillasPubliFechaLiqAsync(fecha, idEmpresa);
var canillasAccFechaLiqTask = _reportesRepository.GetDetalleDistribucionCanillasAccPubliFechaLiqAsync(fecha, idEmpresa);
var ctrlDevolucionesRemitosTask = _reportesRepository.GetReporteObtenerCtrlDevolucionesAsync(fecha, idEmpresa); // SP_ObtenerCtrlDevoluciones
var ctrlDevolucionesParaDistCanTask = _reportesRepository.GetReporteCtrlDevolucionesParaDistCanAsync(fecha, idEmpresa); // SP_DistCanillasCantidadEntradaSalida
var ctrlDevolucionesOtrosDiasTask = _reportesRepository.GetEntradaSalidaOtrosDiasAsync(fecha, idEmpresa); // SP_DistCanillasCantidadEntradaSalidaOtrosDias
var ctrlDevolucionesRemitosTask = _reportesRepository.GetReporteObtenerCtrlDevolucionesAsync(fecha, idEmpresa);
var ctrlDevolucionesParaDistCanTask = _reportesRepository.GetReporteCtrlDevolucionesParaDistCanAsync(fecha, idEmpresa);
var ctrlDevolucionesOtrosDiasTask = _reportesRepository.GetEntradaSalidaOtrosDiasAsync(fecha, idEmpresa);
await Task.WhenAll(
canillasTask, canillasAccTask, canillasAllTask,
canillasTaskOriginal, canillasAccTaskOriginal, canillasAllTask,
canillasFechaLiqTask, canillasAccFechaLiqTask,
ctrlDevolucionesRemitosTask, ctrlDevolucionesParaDistCanTask,
ctrlDevolucionesOtrosDiasTask
@@ -250,13 +286,9 @@ namespace GestionIntegral.Api.Services.Reportes
var detallesOriginales = await ctrlDevolucionesParaDistCanTask ?? Enumerable.Empty<ControlDevolucionesReporteDto>();
var detallesOrdenados = detallesOriginales.OrderBy(d => d.Tipo).ToList();
Func<IEnumerable<DetalleDistribucionCanillaDto>, IEnumerable<DetalleDistribucionCanillaDto>> toUtc =
items => items?.Select(c => { if (c.Fecha.HasValue) c.Fecha = DateTime.SpecifyKind(c.Fecha.Value.Date, DateTimeKind.Utc); return c; }).ToList()
?? Enumerable.Empty<DetalleDistribucionCanillaDto>();
return (
toUtc(await canillasTask),
toUtc(await canillasAccTask),
toUtc(await canillasTaskOriginal),
toUtc(await canillasAccTaskOriginal),
await canillasAllTask ?? Enumerable.Empty<DetalleDistribucionCanillaAllDto>(),
toUtc(await canillasFechaLiqTask),
toUtc(await canillasAccFechaLiqTask),

View File

@@ -17,8 +17,8 @@ namespace GestionIntegral.Api.Services.Suscripciones
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<DebitoAutomaticoService> _logger;
private const string NRO_PRESTACION = "123456";
private const string ORIGEN_EMPRESA = "ELDIA";
private const string NRO_PRESTACION = "26435"; // Reemplazar por el número real
private const string ORIGEN_EMPRESA = "EMPRESA";
public DebitoAutomaticoService(
IFacturaRepository facturaRepository,
@@ -40,9 +40,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario)
{
// Se define la identificación del archivo.
// Este número debe ser gestionado para no repetirse en archivos generados
// para la misma prestación y fecha.
// Este número debe ser gestionado para no repetirse. Por ahora, lo mantenemos como 1.
const int identificacionArchivo = 1;
var periodo = $"{anio}-{mes:D2}";
@@ -62,8 +60,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal);
var cantidadRegistros = facturasParaDebito.Count();
// Se utiliza la variable 'identificacionArchivo' para nombrar el archivo.
var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt";
var nuevoLote = new LoteDebito
@@ -78,13 +74,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito.");
var sb = new StringBuilder();
// Se pasa la 'identificacionArchivo' al método que crea el Header.
sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
foreach (var item in facturasParaDebito)
{
sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor));
}
// Se pasa la 'identificacionArchivo' al método que crea el Trailer.
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura);
@@ -108,17 +102,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
var facturas = await _facturaRepository.GetByPeriodoAsync(periodo);
var resultado = new List<(Factura, Suscriptor)>();
foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente"))
// Filtramos por estado Y POR TIPO DE FACTURA
foreach (var f in facturas.Where(fa =>
(fa.EstadoPago == "Pendiente" || fa.EstadoPago == "Pagada Parcialmente" || fa.EstadoPago == "Rechazada") &&
fa.TipoFactura == "Mensual"
))
{
var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor);
// Se valida que el CBU de Banelco (22 caracteres) exista antes de intentar la conversión.
if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22)
{
_logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", suscriptor?.IdSuscriptor);
_logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", f.IdSuscriptor);
continue;
}
var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida);
if (formaPago != null && formaPago.RequiereCBU)
{
@@ -128,26 +123,13 @@ namespace GestionIntegral.Api.Services.Suscripciones
return resultado;
}
// Lógica de conversión de CBU.
private string ConvertirCbuBanelcoASnp(string cbu22)
{
if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22)
{
_logger.LogError("Se intentó convertir un CBU inválido de {Length} caracteres. Se devolverá un campo vacío.", cbu22?.Length ?? 0);
// Devolver un string de 26 espacios/ceros según la preferencia del banco para campos erróneos.
return "".PadRight(26);
}
// El formato SNP de 26 se obtiene insertando un "0" al inicio y "000" después del 8vo caracter del CBU de 22.
// Formato Banelco (22): [BBBSSSSX] [T....Y]
// Posiciones: (0-7) (8-21)
// Formato SNP (26): 0[BBBSSSSX]000[T....Y]
if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) return "".PadRight(26);
try
{
string bloque1 = cbu22.Substring(0, 8); // Contiene código de banco, sucursal y DV del bloque 1.
string bloque2 = cbu22.Substring(8); // Contiene el resto de la cadena.
// Reconstruir en formato SNP de 26 dígitos según el instructivo.
string bloque1 = cbu22.Substring(0, 8);
string bloque2 = cbu22.Substring(8);
return $"0{bloque1}000{bloque2}";
}
catch (Exception ex)
@@ -157,9 +139,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
}
}
// --- Métodos de Formateo y Mapeo ---
// --- Helpers de Formateo ---
private string FormatString(string? value, int length) => (value ?? "").PadRight(length);
private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0');
private string FormatNumericString(string? value, int length) => (value ?? "").PadLeft(length, '0');
private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch
{
"DNI" => "0096",
@@ -167,17 +150,17 @@ namespace GestionIntegral.Api.Services.Suscripciones
"CUIL" => "0086",
"LE" => "0089",
"LC" => "0090",
_ => "0000" // Tipo no especificado o C.I. Policía Federal según anexo.
_ => "0000"
};
private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
{
var sb = new StringBuilder();
sb.Append("00"); // Tipo de Registro Header
sb.Append(FormatString(NRO_PRESTACION, 6));
sb.Append("C"); // Servicio: Sistema Nacional de Pagos
sb.Append("00");
sb.Append(FormatNumericString(NRO_PRESTACION, 6));
sb.Append("C");
sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo
sb.Append(FormatString(identificacionArchivo.ToString(), 1));
sb.Append(FormatString(ORIGEN_EMPRESA, 7));
sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
sb.Append(FormatNumeric(cantidadRegistros, 7));
@@ -188,35 +171,33 @@ namespace GestionIntegral.Api.Services.Suscripciones
private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor)
{
// Convertimos el CBU de 22 (Banelco) a 26 (SNP) antes de usarlo.
string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!);
var sb = new StringBuilder();
sb.Append("0370"); // Tipo de Registro Detalle (Orden de Débito)
sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación de Cliente
sb.Append(FormatString(cbu26, 26)); // CBU en formato SNP de 26 caracteres.
sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); // Referencia Unívoca de la factura.
sb.Append("0370");
sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22));
sb.Append(cbu26);
sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15));
sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd"));
sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14));
sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento
sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento
sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento
sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento
sb.Append("0"); // Moneda (0 = Pesos)
sb.Append(FormatString("", 3)); // Motivo Rechazo (vacío en el envío)
sb.Append("0");
sb.Append(FormatString("", 3));
sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4));
sb.Append(FormatString(suscriptor.NroDocumento, 11));
sb.Append(FormatString("", 22)); // Nueva ID Cliente
sb.Append(FormatString("", 26)); // Nueva CBU
sb.Append(FormatNumeric(0, 14)); // Importe Mínimo
sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vencimiento
sb.Append(FormatString("", 22)); // Identificación Cuenta Anterior
sb.Append(FormatString("", 40)); // Mensaje ATM
sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); // Concepto Factura
sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro
sb.Append(FormatNumeric(0, 14)); // Importe Cobrado
sb.Append(FormatNumeric(0, 8)); // Fecha de Acreditamiento
sb.Append(FormatString("", 26)); // Libre
sb.Append(FormatNumericString(suscriptor.NroDocumento, 11));
sb.Append(FormatString("", 22));
sb.Append(FormatString("", 26));
sb.Append(FormatNumeric(0, 14));
sb.Append(FormatNumeric(0, 8));
sb.Append(FormatString("", 22));
sb.Append(FormatString("", 40));
sb.Append(FormatString($"Susc.{factura.Periodo}", 10));
sb.Append(FormatNumeric(0, 8));
sb.Append(FormatNumeric(0, 14));
sb.Append(FormatNumeric(0, 8));
sb.Append(FormatString("", 26));
sb.Append("\r\n");
return sb.ToString();
}
@@ -224,16 +205,16 @@ namespace GestionIntegral.Api.Services.Suscripciones
private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
{
var sb = new StringBuilder();
sb.Append("99"); // Tipo de Registro Trailer
sb.Append(FormatString(NRO_PRESTACION, 6));
sb.Append("C"); // Servicio: Sistema Nacional de Pagos
sb.Append("99");
sb.Append(FormatNumericString(NRO_PRESTACION, 6));
sb.Append("C");
sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo
sb.Append(FormatString(identificacionArchivo.ToString(), 1));
sb.Append(FormatString(ORIGEN_EMPRESA, 7));
sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
sb.Append(FormatNumeric(cantidadRegistros, 7));
sb.Append(FormatString("", 304));
// La última línea del archivo no lleva salto de línea (\r\n).
sb.Append("\r\n");
return sb.ToString();
}

View File

@@ -171,10 +171,13 @@ namespace GestionIntegral.Api.Services.Suscripciones
DescuentoAplicado = descuentoPromocionesTotal,
ImporteFinal = importeFinal,
EstadoPago = "Pendiente",
EstadoFacturacion = "Pendiente de Facturar"
EstadoFacturacion = "Pendiente de Facturar",
TipoFactura = "Mensual"
};
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}");
facturasCreadas.Add(facturaCreada);
foreach (var detalle in detallesParaFactura)
{
@@ -278,11 +281,12 @@ namespace GestionIntegral.Api.Services.Suscripciones
});
}
public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion)
public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(
int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura)
{
var periodo = $"{anio}-{mes:D2}";
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion);
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura);
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo);
var empresas = await _empresaRepository.GetAllAsync(null, null);
var resumenes = facturasData
@@ -301,10 +305,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
EstadoPago = itemFactura.Factura.EstadoPago,
EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
NumeroFactura = itemFactura.Factura.NumeroFactura,
TotalPagado = itemFactura.TotalPagado,
// Faltaba esta línea para pasar el tipo de factura al frontend.
TipoFactura = itemFactura.Factura.TipoFactura,
Detalles = detallesData
.Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
.Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto })
.ToList()
.ToList(),
// Pasamos el id del suscriptor para facilitar las cosas en el frontend
IdSuscriptor = itemFactura.Factura.IdSuscriptor
};
}).ToList();
@@ -314,7 +326,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
NombreSuscriptor = primerItem.NombreSuscriptor,
Facturas = facturasConsolidadas,
ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.EstadoPago == "Pagada" ? 0 : f.ImporteFinal)
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal - f.TotalPagado)
};
});
@@ -578,7 +590,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
}
}
private async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction)
public async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction)
{
decimal importeTotal = 0;
var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet();

View File

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

View File

@@ -70,14 +70,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura);
if (factura == null) return (null, "La factura especificada no existe.");
// Usar EstadoPago para la validación
if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago);
if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida.");
// Obtenemos la suma de pagos ANTERIORES
var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction);
var nuevoPago = new Pago
@@ -96,37 +93,31 @@ namespace GestionIntegral.Api.Services.Suscripciones
var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction);
if (pagoCreado == null) throw new DataException("No se pudo registrar el pago.");
// Calculamos el nuevo total EN MEMORIA
var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto;
// Comparamos y actualizamos el estado si es necesario
// CORRECCIÓN: Usar EstadoPago y el método correcto del repositorio
if (factura.EstadoPago != "Pagada" && nuevoTotalPagado >= factura.ImporteFinal)
// Nueva lógica para manejar todos los estados de pago
string nuevoEstadoPago = factura.EstadoPago;
if (nuevoTotalPagado >= factura.ImporteFinal)
{
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, "Pagada", transaction);
if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'.");
nuevoEstadoPago = "Pagada";
}
else if (nuevoTotalPagado > 0)
{
nuevoEstadoPago = "Pagada Parcialmente";
}
// Si nuevoTotalPagado es 0, el estado no cambia.
// Solo actualizamos si el estado calculado es diferente al actual.
if (nuevoEstadoPago != factura.EstadoPago)
{
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, nuevoEstadoPago, transaction);
if (!actualizado) throw new DataException($"No se pudo actualizar el estado de la factura a '{nuevoEstadoPago}'.");
}
transaction.Commit();
_logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario);
// Construimos el DTO de respuesta SIN volver a consultar la base de datos
var usuario = await _usuarioRepository.GetByIdAsync(idUsuario);
var dto = new PagoDto
{
IdPago = pagoCreado.IdPago,
IdFactura = pagoCreado.IdFactura,
FechaPago = pagoCreado.FechaPago.ToString("yyyy-MM-dd"),
IdFormaPago = pagoCreado.IdFormaPago,
NombreFormaPago = formaPago.Nombre,
Monto = pagoCreado.Monto,
Estado = pagoCreado.Estado,
Referencia = pagoCreado.Referencia,
Observaciones = pagoCreado.Observaciones,
IdUsuarioRegistro = pagoCreado.IdUsuarioRegistro,
NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
};
var dto = await MapToDto(pagoCreado); // MapToDto ahora es más simple
return (dto, null);
}
catch (Exception ex)

View File

@@ -3,12 +3,8 @@ using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using System.Globalization;
namespace GestionIntegral.Api.Services.Suscripciones
{
@@ -18,23 +14,32 @@ namespace GestionIntegral.Api.Services.Suscripciones
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly IPublicacionRepository _publicacionRepository;
private readonly IPromocionRepository _promocionRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly IFacturaRepository _facturaRepository;
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
private readonly IFacturacionService _facturacionService;
private readonly ILogger<SuscripcionService> _logger;
private readonly DbConnectionFactory _connectionFactory;
public SuscripcionService(
ISuscripcionRepository suscripcionRepository,
ISuscriptorRepository suscriptorRepository,
IPublicacionRepository publicacionRepository,
IPromocionRepository promocionRepository,
DbConnectionFactory connectionFactory,
ILogger<SuscripcionService> logger)
IFacturaRepository facturaRepository,
IFacturaDetalleRepository facturaDetalleRepository,
IFacturacionService facturacionService,
ILogger<SuscripcionService> logger,
DbConnectionFactory connectionFactory)
{
_suscripcionRepository = suscripcionRepository;
_suscriptorRepository = suscriptorRepository;
_publicacionRepository = publicacionRepository;
_promocionRepository = promocionRepository;
_connectionFactory = connectionFactory;
_facturaRepository = facturaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_facturacionService = facturacionService;
_logger = logger;
_connectionFactory = connectionFactory;
}
private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto
@@ -122,6 +127,53 @@ namespace GestionIntegral.Api.Services.Suscripciones
var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction);
if (creada == null) throw new DataException("Error al crear la suscripción.");
var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync();
if (ultimoPeriodoFacturadoStr != null)
{
var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture);
var periodoSuscripcion = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1);
if (periodoSuscripcion <= ultimoPeriodo)
{
_logger.LogInformation("Suscripción en período ya cerrado detectada. Generando factura de alta pro-rata.");
decimal importeProporcional = await _facturacionService.CalcularImporteParaSuscripcion(creada, creada.FechaInicio.Year, creada.FechaInicio.Month, transaction);
if (importeProporcional > 0)
{
var facturaDeAlta = new Factura
{
IdSuscriptor = creada.IdSuscriptor,
Periodo = creada.FechaInicio.ToString("yyyy-MM"),
FechaEmision = DateTime.Now.Date,
FechaVencimiento = DateTime.Now.AddDays(10).Date,
ImporteBruto = importeProporcional,
ImporteFinal = importeProporcional,
EstadoPago = "Pendiente",
EstadoFacturacion = "Pendiente de Facturar",
TipoFactura = "Alta"
};
var facturaCreada = await _facturaRepository.CreateAsync(facturaDeAlta, transaction);
if (facturaCreada == null) throw new DataException("No se pudo crear la factura de alta.");
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(creada.IdPublicacion);
var finDeMes = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1).AddMonths(1).AddDays(-1);
await _facturaDetalleRepository.CreateAsync(new FacturaDetalle
{
IdFactura = facturaCreada.IdFactura,
IdSuscripcion = creada.IdSuscripcion,
Descripcion = $"Suscripción proporcional {publicacion?.Nombre} ({creada.FechaInicio:dd/MM} al {finDeMes:dd/MM})",
ImporteBruto = importeProporcional,
ImporteNeto = importeProporcional,
DescuentoAplicado = 0
}, transaction);
_logger.LogInformation("Factura de alta #{IdFactura} por ${Importe} generada para la nueva suscripción #{IdSuscripcion}.", facturaCreada.IdFactura, importeProporcional, creada.IdSuscripcion);
}
}
}
transaction.Commit();
_logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario);
return (await MapToDto(creada), null);

View File

@@ -16,11 +16,11 @@
},
"AllowedHosts": "*",
"MailSettings": {
"SmtpHost": "",
"SmtpPort": 0,
"SenderName": "",
"SenderEmail": "",
"SmtpUser": "",
"SmtpPass": ""
"SmtpHost": "192.168.5.201",
"SmtpPort": 587,
"SenderName": "Club - Diario El Día",
"SenderEmail": "alertas@eldia.com",
"SmtpUser": "alertas@eldia.com",
"SmtpPass": "@Alertas713550@"
}
}

View File

@@ -64,6 +64,7 @@ const PagoDistribuidorFormModal: React.FC<PagoDistribuidorFormModalProps> = ({
const isEditing = Boolean(initialData);
useEffect(() => {
// Esta función se encarga de cargar los datos de los dropdowns.
const fetchDropdownData = async () => {
setLoadingDropdowns(true);
try {
@@ -133,7 +134,9 @@ const PagoDistribuidorFormModal: React.FC<PagoDistribuidorFormModalProps> = ({
idTipoPago: Number(idTipoPago),
detalle: detalle || undefined,
};
// << INICIO DE LA CORRECCIÓN >>
await onSubmit(dataToSubmit, initialData.idPago);
// << FIN DE LA CORRECCIÓN >>
} else {
const dataToSubmit: CreatePagoDistribuidorDto = {
idDistribuidor: Number(idDistribuidor),
@@ -147,7 +150,9 @@ const PagoDistribuidorFormModal: React.FC<PagoDistribuidorFormModalProps> = ({
};
await onSubmit(dataToSubmit);
}
onClose();
} catch (error: any) {
console.error("Error en submit de PagoDistribuidorFormModal:", error);
} finally {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto';
import type { FacturaConsolidadaDto } from '../../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto';
import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto';
import formaPagoService from '../../../services/Suscripciones/formaPagoService';
@@ -23,17 +23,19 @@ interface PagoManualModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreatePagoDto) => Promise<void>;
factura: FacturaDto | null;
factura: FacturaConsolidadaDto | null;
nombreSuscriptor: string; // Se pasa el nombre del suscriptor como prop-
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, errorMessage, clearErrorMessage }) => {
const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, nombreSuscriptor, errorMessage, clearErrorMessage }) => {
const [formData, setFormData] = useState<Partial<CreatePagoDto>>({});
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
const [loading, setLoading] = useState(false);
const [loadingFormasPago, setLoadingFormasPago] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const saldoPendiente = factura ? factura.importeFinal - factura.totalPagado : 0;
useEffect(() => {
const fetchFormasDePago = async () => {
@@ -52,12 +54,12 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
fetchFormasDePago();
setFormData({
idFactura: factura.idFactura,
monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto
monto: saldoPendiente,
fechaPago: new Date().toISOString().split('T')[0]
});
setLocalErrors({});
}
}, [open, factura]);
}, [open, factura, saldoPendiente]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
@@ -65,13 +67,11 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria.";
const monto = formData.monto ?? 0;
const saldo = factura?.saldoPendiente ?? 0;
if (monto <= 0) {
errors.monto = "El monto debe ser mayor a cero.";
} else if (monto > saldo) {
// Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje
errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`;
} else if (monto > saldoPendiente) {
errors.monto = `El monto no puede superar el saldo pendiente de $${saldoPendiente.toFixed(2)}.`;
}
setLocalErrors(errors);
@@ -117,8 +117,11 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">Registrar Pago Manual</Typography>
<Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}>
Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)}
<Typography variant="body1" color="text.secondary" gutterBottom>
Para: {nombreSuscriptor}
</Typography>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Saldo Pendiente: ${saldoPendiente.toFixed(2)}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} />

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,8 @@ export interface FacturaConsolidadaDto {
estadoPago: string;
estadoFacturacion: string;
numeroFactura?: string | null;
totalPagado: number;
tipoFactura: 'Mensual' | 'Alta';
detalles: FacturaDetalleDto[];
// Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers
idSuscriptor: number;

View File

@@ -28,11 +28,11 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
const [pagos, setPagos] = useState<PagoDistribuidorDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [pageApiErrorMessage, setPageApiErrorMessage] = useState<string | null>(null);
const [modalApiErrorMessage, setModalApiErrorMessage] = useState<string | null>(null);
// Filtros
const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]); //useState('');
const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);//useState('');
const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroIdDistribuidor, setFiltroIdDistribuidor] = useState<number | string>('');
const [filtroIdEmpresa, setFiltroIdEmpresa] = useState<number | string>('');
const [filtroTipoMov, setFiltroTipoMov] = useState<'Recibido' | 'Realizado' | ''>('');
@@ -50,7 +50,6 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
const [selectedRow, setSelectedRow] = useState<PagoDistribuidorDto | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
// Permisos CP001 (Ver), CP002 (Crear), CP003 (Modificar), CP004 (Eliminar)
const puedeVer = isSuperAdmin || tienePermiso("CP001");
const puedeCrear = isSuperAdmin || tienePermiso("CP002");
const puedeModificar = isSuperAdmin || tienePermiso("CP003");
@@ -65,15 +64,21 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
]);
setDistribuidores(distData);
setEmpresas(empData);
} catch (err) { console.error(err); setError("Error al cargar opciones de filtro.");
} catch (err) {
console.error(err); setError("Error al cargar opciones de filtro.");
} finally { setLoadingFiltersDropdown(false); }
}, []);
useEffect(() => { fetchFiltersDropdownData(); }, [fetchFiltersDropdownData]);
const clearModalApiErrorMessage = useCallback(() => {
setModalApiErrorMessage(null);
}, []);
const cargarPagos = useCallback(async () => {
if (!puedeVer) { setError("No tiene permiso."); setLoading(false); return; }
setLoading(true); setError(null); setApiErrorMessage(null);
setLoading(true); setError(null); setPageApiErrorMessage(null);
try {
const params = {
fechaDesde: filtroFechaDesde || null, fechaHasta: filtroFechaHasta || null,
@@ -83,19 +88,20 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
};
const data = await pagoDistribuidorService.getAllPagosDistribuidor(params);
setPagos(data);
} catch (err) { console.error(err); setError('Error al cargar los pagos.');
} catch (err) {
console.error(err); setError('Error al cargar los pagos.');
} finally { setLoading(false); }
}, [puedeVer, filtroFechaDesde, filtroFechaHasta, filtroIdDistribuidor, filtroIdEmpresa, filtroTipoMov]);
useEffect(() => { cargarPagos(); }, [cargarPagos]);
const handleOpenModal = (item?: PagoDistribuidorDto) => {
setEditingPago(item || null); setApiErrorMessage(null); setModalOpen(true);
setEditingPago(item || null); setModalApiErrorMessage(null); setModalOpen(true);
};
const handleCloseModal = () => { setModalOpen(false); setEditingPago(null); };
const handleSubmitModal = async (data: CreatePagoDistribuidorDto | UpdatePagoDistribuidorDto, idPago?: number) => {
setApiErrorMessage(null);
setModalApiErrorMessage(null);
try {
if (idPago && editingPago) {
await pagoDistribuidorService.updatePagoDistribuidor(idPago, data as UpdatePagoDistribuidorDto);
@@ -105,15 +111,19 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
cargarPagos();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el pago.';
setApiErrorMessage(message); throw err;
setModalApiErrorMessage(message);
throw err;
}
};
const handleDelete = async (idPago: number) => {
if (window.confirm(`¿Seguro de eliminar este pago (ID: ${idPago})? Esta acción revertirá el impacto en el saldo.`)) {
setApiErrorMessage(null);
setPageApiErrorMessage(null);
try { await pagoDistribuidorService.deletePagoDistribuidor(idPago); cargarPagos(); }
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; setApiErrorMessage(msg); }
catch (err: any) {
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.';
setPageApiErrorMessage(msg);
}
}
handleMenuClose();
};
@@ -128,7 +138,15 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
setRowsPerPage(parseInt(event.target.value, 10)); setPage(0);
};
const displayData = pagos.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
const formatDate = (dateString?: string | null) => dateString ? new Date(dateString + 'T00:00:00Z').toLocaleDateString('es-AR') : '-';
const formatDate = (dateString?: string | null): string => {
if (!dateString) return '-';
const datePart = dateString.split('T')[0];
const parts = datePart.split('-');
if (parts.length === 3) {
return `${parts[2]}/${parts[1]}/${parts[0]}`;
}
return datePart;
};
if (!loading && !puedeVer && !loadingFiltersDropdown) return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado."}</Alert></Box>;
@@ -136,25 +154,25 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Pagos de Distribuidores</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small"/></Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center', mb: 2}}>
<TextField label="Fecha Desde" type="date" size="small" value={filtroFechaDesde} onChange={(e) => setFiltroFechaDesde(e.target.value)} InputLabelProps={{ shrink: true }} sx={{minWidth: 170}}/>
<TextField label="Fecha Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{minWidth: 170}}/>
<FormControl size="small" sx={{minWidth: 200, flexGrow: 1}} disabled={loadingFiltersDropdown}>
<Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center', mb: 2 }}>
<TextField label="Fecha Desde" type="date" size="small" value={filtroFechaDesde} onChange={(e) => setFiltroFechaDesde(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} />
<TextField label="Fecha Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} />
<FormControl size="small" sx={{ minWidth: 200, flexGrow: 1 }} disabled={loadingFiltersDropdown}>
<InputLabel>Distribuidor</InputLabel>
<Select value={filtroIdDistribuidor} label="Distribuidor" onChange={(e) => setFiltroIdDistribuidor(e.target.value as number | string)}>
<MenuItem value=""><em>Todos</em></MenuItem>
{distribuidores.map(d => <MenuItem key={d.idDistribuidor} value={d.idDistribuidor}>{d.nombre}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{minWidth: 200, flexGrow: 1}} disabled={loadingFiltersDropdown}>
<FormControl size="small" sx={{ minWidth: 200, flexGrow: 1 }} disabled={loadingFiltersDropdown}>
<InputLabel>Empresa (Saldo)</InputLabel>
<Select value={filtroIdEmpresa} label="Empresa (Saldo)" onChange={(e) => setFiltroIdEmpresa(e.target.value as number | string)}>
<MenuItem value=""><em>Todas</em></MenuItem>
{empresas.map(e => <MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{minWidth: 150, flexGrow: 1}}>
<FormControl size="small" sx={{ minWidth: 150, flexGrow: 1 }}>
<InputLabel>Tipo Mov.</InputLabel>
<Select value={filtroTipoMov} label="Tipo Mov." onChange={(e) => setFiltroTipoMov(e.target.value as 'Recibido' | 'Realizado' | '')}>
<MenuItem value=""><em>Todos</em></MenuItem>
@@ -167,8 +185,8 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
</Paper>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && <Alert severity="error" sx={{my: 2}}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>}
{error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{pageApiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{pageApiErrorMessage}</Alert>}
{!loading && !error && puedeVer && (
<TableContainer component={Paper}>
@@ -189,12 +207,12 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
<TableCell>{formatDate(p.fecha)}</TableCell><TableCell>{p.nombreDistribuidor}</TableCell>
<TableCell>{p.nombreEmpresa}</TableCell>
<TableCell>
<Chip label={p.tipoMovimiento} color={p.tipoMovimiento === 'Recibido' ? 'success' : 'warning'} size="small"/>
<Chip label={p.tipoMovimiento} color={p.tipoMovimiento === 'Recibido' ? 'success' : 'warning'} size="small" />
</TableCell>
<TableCell>{p.recibo}</TableCell>
<TableCell align="right">${p.monto.toFixed(2)}</TableCell>
<TableCell>{p.nombreTipoPago}</TableCell>
<TableCell><Tooltip title={p.detalle || ''}><Box sx={{maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}>{p.detalle || '-'}</Box></Tooltip></TableCell>
<TableCell><Tooltip title={p.detalle || ''}><Box sx={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.detalle || '-'}</Box></Tooltip></TableCell>
{(puedeModificar || puedeEliminar) && (
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, p)} disabled={!puedeModificar && !puedeEliminar}><MoreVertIcon /></IconButton>
@@ -214,15 +232,18 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{puedeModificar && selectedRow && (
<MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{mr:1}}/> Modificar</MenuItem>)}
<MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} /> Modificar</MenuItem>)}
{puedeEliminar && selectedRow && (
<MenuItem onClick={() => handleDelete(selectedRow.idPago)}><DeleteIcon fontSize="small" sx={{mr:1}}/> Eliminar</MenuItem>)}
<MenuItem onClick={() => handleDelete(selectedRow.idPago)}><DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Eliminar</MenuItem>)}
</Menu>
<PagoDistribuidorFormModal
open={modalOpen} onClose={handleCloseModal} onSubmit={handleSubmitModal}
initialData={editingPago} errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
open={modalOpen}
onClose={handleCloseModal}
onSubmit={handleSubmitModal}
initialData={editingPago}
errorMessage={modalApiErrorMessage}
clearErrorMessage={clearModalApiErrorMessage}
/>
</Box>
);

View File

@@ -8,6 +8,7 @@ import reportesService from '../../services/Reportes/reportesService';
import type { ReporteDistribucionCanillasResponseDto } from '../../models/dtos/Reportes/ReporteDistribucionCanillasResponseDto';
import SeleccionaReporteDetalleDistribucionCanillas from './SeleccionaReporteDetalleDistribucionCanillas';
import * as XLSX from 'xlsx';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
// Para el tipo del footer en DataGridSectionProps
@@ -81,9 +82,12 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
const [currentParams, setCurrentParams] = useState<{
fecha: string;
idEmpresa: number;
esAccionista: boolean;
nombreEmpresa?: string;
} | null>(null);
const [pdfSoloTotales, setPdfSoloTotales] = useState(false);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("MC005");
const initialTotals: TotalesComunes = { totalCantSalida: 0, totalCantEntrada: 0, vendidos: 0, totalRendir: 0 };
const [totalesCanillas, setTotalesCanillas] = useState<TotalesComunes>(initialTotals);
@@ -115,16 +119,29 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
const handleGenerarReporte = useCallback(async (params: {
fecha: string;
idEmpresa: number;
esAccionista: boolean;
}) => {
if (!puedeVerReporte) {
setError("No tiene permiso para generar este reporte.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
setApiErrorParams(null);
let nombreEmpresa = `Empresa ID ${params.idEmpresa}`;
if (params.idEmpresa !== 0) {
const empresaService = (await import('../../services/Distribucion/empresaService')).default;
const empData = await empresaService.getEmpresaById(params.idEmpresa);
setCurrentParams({ ...params, nombreEmpresa: empData?.nombre });
setReportData(null);
nombreEmpresa = empData?.nombre ?? nombreEmpresa;
} else {
nombreEmpresa = "TODAS";
}
// Resetear totales
setCurrentParams({ ...params, nombreEmpresa });
setReportData(null);
setTotalesCanillas(initialTotals);
setTotalesAccionistas(initialTotals);
setTotalesCanillasOtraFecha(initialTotals);
@@ -140,7 +157,7 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
const processedData = {
canillas: addIds(data.canillas, 'can'),
canillasAccionistas: addIds(data.canillasAccionistas, 'acc'),
canillasTodos: addIds(data.canillasTodos, 'all'), // Aún necesita IDs para DataGridSection
canillasTodos: addIds(data.canillasTodos, 'all'),
canillasLiquidadasOtraFecha: addIds(data.canillasLiquidadasOtraFecha, 'canliq'),
canillasAccionistasLiquidadasOtraFecha: addIds(data.canillasAccionistasLiquidadasOtraFecha, 'accliq'),
controlDevolucionesDetalle: addIds(data.controlDevolucionesDetalle, 'cdd'),
@@ -167,7 +184,7 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
} finally {
setLoading(false);
}
}, []);
}, [puedeVerReporte]);
const handleVolverAParametros = useCallback(() => {
setShowParamSelector(true);
@@ -188,10 +205,9 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
if (data && data.length > 0) {
const exportedData = data.map(item => {
const row: Record<string, any> = {};
// Excluir el 'id' generado para DataGrid si existe
const { id, ...itemData } = item;
Object.keys(fields).forEach(key => {
row[fields[key]] = (itemData as any)[key]; // Usar itemData
row[fields[key]] = (itemData as any)[key];
if (key === 'fecha' && (itemData as any)[key]) {
row[fields[key]] = new Date((itemData as any)[key]).toLocaleDateString('es-AR', { timeZone: 'UTC' });
}
@@ -215,18 +231,18 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
}
};
// Definición de campos para la exportación
const fieldsCanillaAccionista = { publicacion: "Publicación", canilla: "Canilla", totalCantSalida: "Llevados", totalCantEntrada: "Devueltos", vendidos: "Vendidos", totalRendir: "A Rendir" };
const fieldsCanillaAccionistaFechaLiq = { publicacion: "Publicación", canilla: "Canilla", fecha: "Fecha Mov.", totalCantSalida: "Llevados", totalCantEntrada: "Devueltos", vendidos: "Vendidos", totalRendir: "A Rendir" };
const fieldsTodos = { publicacion: "Publicación", tipoVendedor: "Tipo", totalCantSalida: "Llevados", totalCantEntrada: "Devueltos", vendidos: "Vendidos", totalRendir: "A Rendir" };
formatAndSheet(reportData.canillas, "Canillitas_Dia", fieldsCanillaAccionista);
formatAndSheet(reportData.canillasAccionistas, "Accionistas_Dia", fieldsCanillaAccionista);
if (currentParams?.idEmpresa !== 0) {
formatAndSheet(reportData.canillasTodos, "Resumen_Dia", fieldsTodos);
}
formatAndSheet(reportData.canillasLiquidadasOtraFecha, "Canillitas_OtrasFechas", fieldsCanillaAccionistaFechaLiq);
formatAndSheet(reportData.canillasAccionistasLiquidadasOtraFecha, "Accionistas_OtrasFechas", fieldsCanillaAccionistaFechaLiq);
let fileName = "ReporteDetalleDistribucionCanillitas";
if (currentParams) {
fileName += `_${currentParams.nombreEmpresa?.replace(/\s+/g, '') ?? `Emp${currentParams.idEmpresa}`}`;
@@ -265,8 +281,6 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
}
}, [currentParams]);
// --- Definiciones de Columnas ---
const commonColumns: GridColDef[] = [
{ field: 'publicacion', headerName: 'Publicación', width: 200, flex: 1.2 },
{ field: 'canilla', headerName: 'Canillita', width: 220, flex: 1.3 },
@@ -295,8 +309,7 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
{ field: 'totalRendir', headerName: 'A Rendir', type: 'number', width: 150, align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(Number(value)) },
];
// --- Custom Footers ---
const createCustomFooterComponent = (totals: TotalesComunes, columnsDef: GridColDef[]): CustomFooterType => { // Especificar el tipo de retorno
const createCustomFooterComponent = (totals: TotalesComunes, columnsDef: GridColDef[]): CustomFooterType => {
const getCellStyle = (colConfig: GridColDef | undefined, isPlaceholder: boolean = false) => {
if (!colConfig) return { width: 100, textAlign: 'right' as const, pr: isPlaceholder ? 0 : 1, fontWeight: 'bold' };
const defaultWidth = colConfig.field === 'publicacion' ? 200 : (colConfig.field === 'canilla' || colConfig.field === 'tipoVendedor' ? 150 : 100);
@@ -310,10 +323,9 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
};
};
// eslint-disable-next-line react/display-name
const FooterComponent: CustomFooterType = (props) => ( // El componente debe aceptar props
<GridFooterContainer {...props} sx={{ // Pasar props y combinar sx
...(props.sx as any), // Castear props.sx temporalmente si es necesario
const FooterComponent: CustomFooterType = (props) => (
<GridFooterContainer {...props} sx={{
...(props.sx as any),
justifyContent: 'space-between', alignItems: 'center', width: '100%',
borderTop: (theme) => `1px solid ${theme.palette.divider}`, minHeight: '52px',
}}>
@@ -339,6 +351,7 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
return FooterComponent;
};
// Usar los componentes creados con useMemo
const FooterCanillas = useMemo(() => createCustomFooterComponent(totalesCanillas, commonColumns), [totalesCanillas]);
const FooterAccionistas = useMemo(() => createCustomFooterComponent(totalesAccionistas, commonColumns), [totalesAccionistas]);
const FooterCanillasOtraFecha = useMemo(() => createCustomFooterComponent(totalesCanillasOtraFecha, commonColumnsWithFecha), [totalesCanillasOtraFecha]);
@@ -346,12 +359,16 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
const FooterResumen = useMemo(() => createCustomFooterComponent(totalesResumen, columnsTodos), [totalesResumen, columnsTodos]);
if (showParamSelector) {
if (!loading && !puedeVerReporte) {
return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>;
}
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}>
<Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}>
<SeleccionaReporteDetalleDistribucionCanillas
onGenerarReporte={handleGenerarReporte}
onCancel={handleVolverAParametros} // Aunque el componente no lo use directamente.
onCancel={handleVolverAParametros}
isLoading={loading}
apiErrorMessage={apiErrorParams}
/>
@@ -360,17 +377,45 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
);
}
const renderContent = () => {
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>;
if (error && !loading) return <Alert severity="info" sx={{ my: 2 }}>{error}</Alert>;
if (!reportData) return <Typography sx={{ mt: 2, fontStyle: 'italic' }}>No se encontraron datos.</Typography>;
if (currentParams?.idEmpresa === 0) {
if (currentParams.esAccionista) {
return <DataGridSection title="Accionistas (Todas las Empresas)" data={reportData.canillasAccionistas || []} columns={commonColumns} footerComponent={FooterAccionistas} />;
}
return <DataGridSection title="Canillitas (Todas las Empresas)" data={reportData.canillas || []} columns={commonColumns} footerComponent={FooterCanillas} />;
}
return (
<>
<DataGridSection title="Canillitas" data={reportData.canillas || []} columns={commonColumns} footerComponent={FooterCanillas} />
<DataGridSection title="Accionistas" data={reportData.canillasAccionistas || []} columns={commonColumns} footerComponent={FooterAccionistas} />
<DataGridSection title="Resumen por Tipo de Vendedor" data={reportData.canillasTodos || []} columns={columnsTodos} footerComponent={FooterResumen} />
{reportData.canillasLiquidadasOtraFecha && reportData.canillasLiquidadasOtraFecha.length > 0 &&
<DataGridSection title="Canillitas (Liquidados de Otras Fechas)" data={reportData.canillasLiquidadasOtraFecha} columns={commonColumnsWithFecha} footerComponent={FooterCanillasOtraFecha} />}
{reportData.canillasAccionistasLiquidadasOtraFecha && reportData.canillasAccionistasLiquidadasOtraFecha.length > 0 &&
<DataGridSection title="Accionistas (Liquidados de Otras Fechas)" data={reportData.canillasAccionistasLiquidadasOtraFecha} columns={commonColumnsWithFecha} footerComponent={FooterAccionistasOtraFecha} />}
</>
);
};
return (
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Typography variant="h5">Reporte: Detalle Distribución Canillitas ({currentParams?.nombreEmpresa}) - {currentParams?.fecha ? new Date(currentParams.fecha + 'T00:00:00').toLocaleDateString('es-AR', { timeZone: 'UTC' }) : ''}</Typography>
<Typography variant="h5">Reporte: Detalle Distribución ({currentParams?.nombreEmpresa}) - {currentParams?.fecha ? new Date(currentParams.fecha + 'T00:00:00').toLocaleDateString('es-AR', { timeZone: 'UTC' }) : ''}</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button onClick={() => handleGenerarYAbrirPdf(false)} variant="contained" disabled={loadingPdf || !reportData || !!error} size="small">
{loadingPdf && !pdfSoloTotales ? <CircularProgress size={20} color="inherit" /> : "PDF Detalle"}
</Button>
{currentParams?.idEmpresa !== 0 && (
<Button onClick={() => handleGenerarYAbrirPdf(true)} variant="contained" color="secondary" disabled={loadingPdf || !reportData || !!error} size="small">
{loadingPdf && pdfSoloTotales ? <CircularProgress size={20} color="inherit" /> : "PDF Totales"}
</Button>
)}
<Button onClick={() => handleGenerarYAbrirPdf(false)} variant="contained" disabled={loadingPdf || !reportData || !!error} size="small">
{loadingPdf && !pdfSoloTotales ? <CircularProgress size={20} color="inherit" /> : "PDF Detalle"}
</Button>
<Button onClick={handleExportToExcel} variant="outlined" disabled={!reportData || !!error} size="small">
Exportar a Excel
</Button>
@@ -379,34 +424,7 @@ const ReporteDetalleDistribucionCanillasPage: React.FC = () => {
</Button>
</Box>
</Box>
{loading && <Box sx={{ textAlign: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && <Alert severity="info" sx={{ my: 2 }}>{error}</Alert>}
{!loading && !error && reportData && (
<>
<DataGridSection title="Canillitas" data={reportData.canillas || []} columns={commonColumns} footerComponent={FooterCanillas} />
<DataGridSection title="Accionistas" data={reportData.canillasAccionistas || []} columns={commonColumns} footerComponent={FooterAccionistas} />
<DataGridSection
title="Resumen por Tipo de Vendedor"
data={reportData.canillasTodos || []}
columns={columnsTodos}
footerComponent={FooterResumen} // <-- PASAR EL FOOTER
height={220} // El height ya no es necesario si autoHeight está activado por tener footer
/>
{reportData.canillasLiquidadasOtraFecha && reportData.canillasLiquidadasOtraFecha.length > 0 &&
<DataGridSection title="Canillitas (Liquidados de Otras Fechas)" data={reportData.canillasLiquidadasOtraFecha} columns={commonColumnsWithFecha} footerComponent={FooterCanillasOtraFecha} />}
{reportData.canillasAccionistasLiquidadasOtraFecha && reportData.canillasAccionistasLiquidadasOtraFecha.length > 0 &&
<DataGridSection title="Accionistas (Liquidados de Otras Fechas)" data={reportData.canillasAccionistasLiquidadasOtraFecha} columns={commonColumnsWithFecha} footerComponent={FooterAccionistasOtraFecha} />}
</>
)}
{!loading && !error && reportData &&
Object.values(reportData).every(arr => !arr || arr.length === 0) &&
<Typography sx={{ mt: 2, fontStyle: 'italic' }}>No se encontraron datos para los criterios seleccionados.</Typography>
}
{renderContent()}
</Box>
);
};

View File

@@ -3,92 +3,80 @@ import { Box, Paper, Typography, List, ListItemButton, ListItemText, Collapse, C
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { usePermissions } from '../../hooks/usePermissions';
// Definición de los módulos de reporte con sus categorías, etiquetas y rutas
const allReportModules: { category: string; label: string; path: string }[] = [
{ category: 'Existencia Papel', label: 'Existencia de Papel', path: 'existencia-papel' },
{ category: 'Movimientos Bobinas', label: 'Movimiento de Bobinas', path: 'movimiento-bobinas' },
{ category: 'Movimientos Bobinas', label: 'Mov. Bobinas por Estado', path: 'movimiento-bobinas-estado' },
{ category: 'Listados Distribución', label: 'Distribución Distribuidores', path: 'listado-distribucion-distribuidores' },
{ category: 'Listados Distribución', label: 'Distribución Canillas', path: 'listado-distribucion-canillas' },
{ category: 'Listados Distribución', label: 'Distribución General', path: 'listado-distribucion-general' },
{ category: 'Listados Distribución', label: 'Distrib. Canillas (Importe)', path: 'listado-distribucion-canillas-importe' },
{ category: 'Listados Distribución', label: 'Detalle Distribución Canillas', path: 'detalle-distribucion-canillas' },
{ category: 'Secretaría', label: 'Venta Mensual Secretaría', path: 'venta-mensual-secretaria' },
{ category: 'Tiradas por Publicación', label: 'Tiradas Publicación/Sección', path: 'tiradas-publicaciones-secciones' },
{ category: 'Consumos Bobinas', label: 'Consumo Bobinas/Sección', path: 'consumo-bobinas-seccion' },
{ category: 'Consumos Bobinas', label: 'Consumo Bobinas/PubPublicación', path: 'consumo-bobinas-publicacion' },
{ category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas' },
{ category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores' },
{ category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' },
{ category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' },
{ category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' },
{ category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' },
{ category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion' },
const allReportModules: { category: string; label: string; path: string; requiredPermission: string; }[] = [
{ category: 'Existencia Papel', label: 'Existencia de Papel', path: 'existencia-papel', requiredPermission: 'RR005' },
{ category: 'Movimientos Bobinas', label: 'Movimiento de Bobinas', path: 'movimiento-bobinas', requiredPermission: 'RR006' },
{ category: 'Movimientos Bobinas', label: 'Mov. Bobinas por Estado', path: 'movimiento-bobinas-estado', requiredPermission: 'RR006' },
{ category: 'Listados Distribución', label: 'Distribución Distribuidores', path: 'listado-distribucion-distribuidores', requiredPermission: 'RR002' },
{ category: 'Listados Distribución', label: 'Distribución Canillas', path: 'listado-distribucion-canillas', requiredPermission: 'RR002' },
{ category: 'Listados Distribución', label: 'Distribución General', path: 'listado-distribucion-general', requiredPermission: 'RR002' },
{ category: 'Listados Distribución', label: 'Distrib. Canillas (Importe)', path: 'listado-distribucion-canillas-importe', requiredPermission: 'RR002' },
{ category: 'Listados Distribución', label: 'Detalle Distribución Canillas', path: 'detalle-distribucion-canillas', requiredPermission: 'MC005' },
{ category: 'Secretaría', label: 'Reportes de Ventas', path: 'venta-mensual-secretaria', requiredPermission: 'RR012' },
{ category: 'Tiradas por Publicación', label: 'Tiradas Publicación/Sección', path: 'tiradas-publicaciones-secciones', requiredPermission: 'RR008' },
{ category: 'Consumos Bobinas', label: 'Consumo Bobinas/Sección', path: 'consumo-bobinas-seccion', requiredPermission: 'RR007' },
{ category: 'Consumos Bobinas', label: 'Consumo Bobinas/Publicación', path: 'consumo-bobinas-publicacion', requiredPermission: 'RR007' },
{ category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas', requiredPermission: 'RR007' },
{ category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores', requiredPermission: 'RR001' },
{ category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones', requiredPermission: 'RR003' },
{ category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas', requiredPermission: 'RR004' },
{ category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual', requiredPermission: 'RR009' },
{ category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad', requiredPermission: 'RR010' },
{ category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion', requiredPermission: 'RR011' },
];
const predefinedCategoryOrder = [
'Balance de Cuentas',
'Listados Distribución',
'Ctrl. Devoluciones',
'Novedades de Canillitas',
'Suscripciones',
'Existencia Papel',
'Movimientos Bobinas',
'Consumos Bobinas',
'Tiradas por Publicación',
'Secretaría',
'Balance de Cuentas', 'Listados Distribución', 'Ctrl. Devoluciones',
'Novedades de Canillitas', 'Suscripciones', 'Existencia Papel',
'Movimientos Bobinas', 'Consumos Bobinas', 'Tiradas por Publicación', 'Secretaría',
];
const ReportesIndexPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [expandedCategory, setExpandedCategory] = useState<string | false>(false);
const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true);
const { tienePermiso, isSuperAdmin } = usePermissions();
const uniqueCategories = useMemo(() => predefinedCategoryOrder, []);
const accessibleReportModules = useMemo(() => {
return allReportModules.filter(module =>
isSuperAdmin || tienePermiso(module.requiredPermission)
);
}, [isSuperAdmin, tienePermiso]);
const accessibleCategories = useMemo(() => {
const categoriesWithAccess = new Set(accessibleReportModules.map(r => r.category));
return predefinedCategoryOrder.filter(category => categoriesWithAccess.has(category));
}, [accessibleReportModules]);
useEffect(() => {
const currentBasePath = '/reportes';
const pathParts = location.pathname.substring(currentBasePath.length + 1).split('/');
const subPathSegment = pathParts[0];
let activeReportFoundInEffect = false;
if (subPathSegment && subPathSegment !== "") { // Asegurarse que subPathSegment no esté vacío
const activeReport = allReportModules.find(module => module.path === subPathSegment);
if (subPathSegment) {
const activeReport = accessibleReportModules.find(module => module.path === subPathSegment);
if (activeReport) {
setExpandedCategory(activeReport.category);
activeReportFoundInEffect = true;
} else {
// Si la URL apunta a un reporte que no es accesible, no expandimos nada
setExpandedCategory(false);
}
} else {
// Si estamos en la página base (/reportes), expandimos la primera categoría disponible.
if (accessibleCategories.length > 0) {
setExpandedCategory(accessibleCategories[0]);
} else {
setExpandedCategory(false);
}
}
if (location.pathname === currentBasePath && allReportModules.length > 0 && isLoadingInitialNavigation) {
let firstReportToNavigate: { category: string; label: string; path: string } | null = null;
for (const category of uniqueCategories) {
const reportsInCat = allReportModules.filter(r => r.category === category);
if (reportsInCat.length > 0) {
firstReportToNavigate = reportsInCat[0];
break;
}
}
if (firstReportToNavigate) {
navigate(firstReportToNavigate.path, { replace: true });
activeReportFoundInEffect = true;
}
}
// Solo se establece a false si no estamos en el proceso de navegación inicial O si no se encontró reporte
if (!activeReportFoundInEffect || location.pathname !== currentBasePath) {
// No hay navegación automática, solo manejamos el estado de carga.
setIsLoadingInitialNavigation(false);
}
}, [location.pathname, navigate, uniqueCategories, isLoadingInitialNavigation]);
}, [location.pathname, accessibleReportModules, accessibleCategories]);
const handleCategoryClick = (categoryName: string) => {
setExpandedCategory(prev => (prev === categoryName ? false : categoryName));
@@ -99,12 +87,10 @@ const ReportesIndexPage: React.FC = () => {
};
const isReportActive = (reportPath: string) => {
return location.pathname === `/reportes/${reportPath}` || location.pathname.startsWith(`/reportes/${reportPath}/`);
return location.pathname.startsWith(`/reportes/${reportPath}`);
};
// Si isLoadingInitialNavigation es true Y estamos en /reportes, mostrar loader
// Esto evita mostrar el loader si se navega directamente a un sub-reporte.
if (isLoadingInitialNavigation && (location.pathname === '/reportes' || location.pathname === '/reportes/')) {
if (isLoadingInitialNavigation) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}>
<CircularProgress />
@@ -113,44 +99,25 @@ const ReportesIndexPage: React.FC = () => {
}
return (
// Contenedor principal que se adaptará a su padre
// Eliminamos 'height: calc(100vh - 64px)' y cualquier margen/padding que controle el espacio exterior
<Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
{/* Panel Lateral para Navegación */}
<Paper
elevation={0} // Sin elevación para que se sienta más integrado si el fondo es el mismo
square // Bordes rectos
sx={{
width: { xs: 220, sm: 250, md: 280 }, // Ancho responsivo del panel lateral
<Paper elevation={0} square sx={{
width: { xs: 220, sm: 250, md: 280 },
minWidth: { xs: 200, sm: 220 },
height: '100%', // Ocupa toda la altura del Box padre
height: '100%',
borderRight: (theme) => `1px solid ${theme.palette.divider}`,
overflowY: 'auto',
bgcolor: 'background.paper', // O el color que desees para el menú
// display: 'flex', flexDirection: 'column' // Para que el título y la lista usen el espacio vertical
}}
>
{/* Título del Menú Lateral */}
<Box
sx={{
p: 1.5, // Padding interno para el título
// borderBottom: (theme) => `1px solid ${theme.palette.divider}`, // Opcional: separador
// position: 'sticky', // Si quieres que el título quede fijo al hacer scroll en la lista
// top: 0,
// zIndex: 1,
// bgcolor: 'background.paper' // Necesario si es sticky y tiene scroll la lista
}}
>
<Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 /* Pequeño margen para alinear con items */ }}>
bgcolor: 'background.paper',
}}>
<Box sx={{ p: 1.5 }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 }}>
Reportes
</Typography>
</Box>
{/* Lista de Categorías y Reportes */}
{uniqueCategories.length > 0 ? (
<List component="nav" dense sx={{ pt: 0 }} /* Quitar padding superior de la lista si el título ya lo tiene */ >
{uniqueCategories.map((category) => {
const reportsInCategory = allReportModules.filter(r => r.category === category);
{accessibleCategories.length > 0 ? (
<List component="nav" dense sx={{ pt: 0 }}>
{accessibleCategories.map((category) => {
const reportsInCategory = accessibleReportModules.filter(r => r.category === category);
const isExpanded = expandedCategory === category;
return (
@@ -158,25 +125,13 @@ const ReportesIndexPage: React.FC = () => {
<ListItemButton
onClick={() => handleCategoryClick(category)}
sx={{
// py: 1.2, // Ajustar padding vertical de items de categoría
// backgroundColor: isExpanded ? 'action.selected' : 'transparent',
borderLeft: isExpanded ? (theme) => `4px solid ${theme.palette.primary.main}` : '4px solid transparent',
pr: 1, // Menos padding a la derecha para dar espacio al ícono expander
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<ListItemText
primary={category}
primaryTypographyProps={{
fontWeight: isExpanded ? 'bold' : 'normal',
// color: isExpanded ? 'primary.main' : 'text.primary'
}}
/>
{reportsInCategory.length > 0 && (isExpanded ? <ExpandLess /> : <ExpandMore />)}
pr: 1,
'&:hover': { backgroundColor: 'action.hover' }
}}>
<ListItemText primary={category} primaryTypographyProps={{ fontWeight: isExpanded ? 'bold' : 'normal' }}/>
{isExpanded ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
{reportsInCategory.length > 0 && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding dense>
{reportsInCategory.map((report) => (
@@ -185,62 +140,39 @@ const ReportesIndexPage: React.FC = () => {
selected={isReportActive(report.path)}
onClick={() => handleReportClick(report.path)}
sx={{
pl: 3.5, // Indentación para los reportes (ajustar si se cambió el padding del título)
py: 0.8, // Padding vertical de items de reporte
pl: 3.5, py: 0.8,
...(isReportActive(report.path) && {
backgroundColor: (theme) => theme.palette.action.selected, // Un color de fondo sutil
borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`, // Un borde para el activo
'& .MuiListItemText-primary': {
fontWeight: 'medium', // O 'bold'
// color: 'primary.main'
},
backgroundColor: (theme) => theme.palette.action.selected,
borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`,
'& .MuiListItemText-primary': { fontWeight: 'medium' },
}),
'&:hover': {
backgroundColor: (theme) => theme.palette.action.hover
}
}}
>
'&:hover': { backgroundColor: (theme) => theme.palette.action.hover }
}}>
<ListItemText primary={report.label} primaryTypographyProps={{ variant: 'body2' }}/>
</ListItemButton>
))}
</List>
</Collapse>
)}
{reportsInCategory.length === 0 && isExpanded && (
<ListItemText
primary="No hay reportes en esta categoría."
sx={{ pl: 3.5, fontStyle: 'italic', color: 'text.secondary', py:1, typography: 'body2' }}
/>
)}
</React.Fragment>
);
})}
</List>
) : (
<Typography sx={{p:2, fontStyle: 'italic'}}>No hay categorías configuradas.</Typography>
<Typography sx={{p:2, fontStyle: 'italic'}}>No tiene acceso a ningún reporte.</Typography>
)}
</Paper>
{/* Área Principal para el Contenido del Reporte */}
<Box
component="main"
sx={{
flexGrow: 1, // Ocupa el espacio restante
p: { xs: 1, sm: 2, md: 3 }, // Padding interno para el contenido, responsivo
overflowY: 'auto',
height: '100%', // Ocupa toda la altura del Box padre
bgcolor: 'grey.100' // Un color de fondo diferente para distinguir el área de contenido
}}
>
{/* El Outlet renderiza el componente del reporte específico */}
{(!location.pathname.startsWith('/reportes/') || !allReportModules.some(r => isReportActive(r.path))) && location.pathname !== '/reportes/' && location.pathname !== '/reportes' && !isLoadingInitialNavigation && (
<Box component="main" sx={{
flexGrow: 1, p: { xs: 1, sm: 2, md: 3 },
overflowY: 'auto', height: '100%', bgcolor: 'grey.100'
}}>
{/* Lógica para mostrar el mensaje de bienvenida */}
{location.pathname === '/reportes' && !isLoadingInitialNavigation && (
<Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}>
El reporte solicitado no existe o la ruta no es válida.
</Typography>
)}
{(location.pathname === '/reportes/' || location.pathname === '/reportes') && !allReportModules.some(r => isReportActive(r.path)) && !isLoadingInitialNavigation && (
<Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}>
{allReportModules.length > 0 ? "Seleccione una categoría y un reporte del menú lateral." : "No hay reportes configurados."}
{accessibleReportModules.length > 0
? "Seleccione una categoría y un reporte del menú lateral."
: "No tiene acceso a ningún reporte."
}
</Typography>
)}
<Outlet />

View File

@@ -1,16 +1,16 @@
import React, { useState, useEffect } from 'react';
import {
Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem
FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup, ToggleButton
} from '@mui/material';
import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
import empresaService from '../../services/Distribucion/empresaService';
interface SeleccionaReporteDetalleDistribucionCanillasProps {
onGenerarReporte: (params: {
fecha: string;
idEmpresa: number;
// soloTotales: boolean; // Podríamos añadirlo si el usuario elige la versión del PDF
esAccionista: boolean; // Añadimos este parámetro
}) => Promise<void>;
onCancel: () => void;
isLoading?: boolean;
@@ -24,9 +24,9 @@ const SeleccionaReporteDetalleDistribucionCanillas: React.FC<SeleccionaReporteDe
}) => {
const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]);
const [idEmpresa, setIdEmpresa] = useState<number | string>('');
// const [soloTotales, setSoloTotales] = useState<boolean>(false); // Si se añade la opción
const [esAccionista, setEsAccionista] = useState<boolean>(false); // Nuevo estado
const [empresas, setEmpresas] = useState<EmpresaDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
@@ -34,8 +34,9 @@ const SeleccionaReporteDetalleDistribucionCanillas: React.FC<SeleccionaReporteDe
const fetchEmpresas = async () => {
setLoadingDropdowns(true);
try {
const data = await empresaService.getAllEmpresas(); // Asume que este servicio existe
setEmpresas(data);
const data = await empresaService.getEmpresasDropdown();
// Añadimos la opción "TODAS" al principio
setEmpresas([{ idEmpresa: 0, nombre: 'TODAS' }, ...data]);
} catch (error) {
console.error("Error al cargar empresas:", error);
setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar empresas.' }));
@@ -49,7 +50,8 @@ const SeleccionaReporteDetalleDistribucionCanillas: React.FC<SeleccionaReporteDe
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!fecha) errors.fecha = 'La fecha es obligatoria.';
if (!idEmpresa) errors.idEmpresa = 'Debe seleccionar una empresa.';
// El idEmpresa ya no puede estar vacío, porque se preselecciona "TODAS" o una empresa
if (idEmpresa === '') errors.idEmpresa = 'Debe seleccionar una empresa.';
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
@@ -59,14 +61,14 @@ const SeleccionaReporteDetalleDistribucionCanillas: React.FC<SeleccionaReporteDe
onGenerarReporte({
fecha,
idEmpresa: Number(idEmpresa),
// soloTotales // Si se añade la opción
esAccionista // Pasamos el nuevo parámetro
});
};
return (
<Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 380 }}>
<Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 420 }}>
<Typography variant="h6" gutterBottom>
Parámetros: Detalle Distribución Canillitas
Parámetros: Detalle Distribución Canillas
</Typography>
<TextField
label="Fecha"
@@ -89,26 +91,32 @@ const SeleccionaReporteDetalleDistribucionCanillas: React.FC<SeleccionaReporteDe
value={idEmpresa}
onChange={(e) => { setIdEmpresa(e.target.value as number); setLocalErrors(p => ({ ...p, idEmpresa: null })); }}
>
<MenuItem value="" disabled><em>Seleccione una empresa</em></MenuItem>
{empresas.map((e) => (
<MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>
))}
</Select>
{localErrors.idEmpresa && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idEmpresa}</Typography>}
</FormControl>
{/*
<FormControlLabel
control={
<Checkbox
checked={soloTotales}
onChange={(e) => setSoloTotales(e.target.checked)}
{/* Selector condicional para Canillitas/Accionistas */}
{idEmpresa === 0 && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'medium' }}>
Mostrar para todas las empresas
</Typography>
<ToggleButtonGroup
value={esAccionista ? 'accionistas' : 'canillitas'}
exclusive
onChange={(_, value) => { if (value !== null) setEsAccionista(value === 'accionistas'); }}
aria-label="Tipo de Vendedor"
disabled={isLoading}
/>
}
label="Generar solo resumen de totales (PDF)"
sx={{ mt: 1, mb: 1 }}
/>
*/}
color="primary"
>
<ToggleButton value="canillitas">Canillitas</ToggleButton>
<ToggleButton value="accionistas">Accionistas</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{apiErrorMessage && <Alert severity="error" sx={{ mt: 2 }}>{apiErrorMessage}</Alert>}
{localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</Alert>}

View File

@@ -3,7 +3,7 @@ import {
Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem
} from '@mui/material';
import type { PublicacionDto } from '../../models/dtos/Distribucion/PublicacionDto';
import type { PublicacionDropdownDto } from '../../models/dtos/Distribucion/PublicacionDropdownDto';
import publicacionService from '../../services/Distribucion/publicacionService';
interface SeleccionaReporteListadoDistribucionCanillasProps {
@@ -26,7 +26,7 @@ const SeleccionaReporteListadoDistribucionCanillas: React.FC<SeleccionaReporteLi
const [fechaDesde, setFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [fechaHasta, setFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);
const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]);
const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
@@ -34,7 +34,7 @@ const SeleccionaReporteListadoDistribucionCanillas: React.FC<SeleccionaReporteLi
const fetchPublicaciones = async () => {
setLoadingDropdowns(true);
try {
const data = await publicacionService.getAllPublicaciones(undefined, undefined);
const data = await publicacionService.getPublicacionesForDropdown(undefined);
setPublicaciones(data.map(p => p));
} catch (error) {
console.error("Error al cargar publicaciones:", error);

View File

@@ -24,8 +24,9 @@ const meses = [
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
];
const estadosPago = ['Pendiente', 'Pagada', 'Rechazada', 'Anulada'];
const estadosPago = ['Pendiente', 'Pagada', 'Pagada Parcialmente', 'Rechazada', 'Anulada'];
const estadosFacturacion = ['Pendiente de Facturar', 'Facturado'];
const tiposFactura = ['Mensual', 'Alta'];
const SuscriptorRow: React.FC<{
resumen: ResumenCuentaSuscriptorDto;
@@ -33,50 +34,60 @@ const SuscriptorRow: React.FC<{
handleOpenHistorial: (factura: FacturaConsolidadaDto) => void;
}> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => {
const [open, setOpen] = useState(false);
const formatCurrency = (value: number) => `$${value.toFixed(2)}`;
return (
<React.Fragment>
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover>
<TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
<TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell>
<TableCell align="right">
<Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>${resumen.saldoPendienteTotal.toFixed(2)}</Typography>
<Typography variant="caption" color="text.secondary">de ${resumen.importeTotal.toFixed(2)}</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>{formatCurrency(resumen.saldoPendienteTotal)}</Typography>
<Typography variant="caption" color="text.secondary">de {formatCurrency(resumen.importeTotal)}</Typography>
</TableCell>
<TableCell colSpan={5}></TableCell>
<TableCell colSpan={6}></TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={9}> {/* <-- Ajustado para la nueva columna */}
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}>
<Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Empresa</TableCell><TableCell align="right">Importe</TableCell>
<TableCell>Estado Pago</TableCell><TableCell>Estado Facturación</TableCell>
<TableCell>Nro. Factura</TableCell><TableCell align="right">Acciones</TableCell>
<TableCell>Empresa</TableCell>
<TableCell align="right">Importe Total</TableCell>
<TableCell align="right">Pagado</TableCell>
<TableCell align="right">Saldo</TableCell>
<TableCell>Tipo Factura</TableCell>
<TableCell>Estado Pago</TableCell>
<TableCell>Estado Facturación</TableCell>
<TableCell>Nro. Factura</TableCell>
<TableCell align="right">Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{resumen.facturas.map((factura) => (
{resumen.facturas.map((factura) => {
const saldo = factura.importeFinal - factura.totalPagado;
return (
<TableRow key={factura.idFactura}>
<TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell>
<TableCell align="right">${factura.importeFinal.toFixed(2)}</TableCell>
<TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default')} /></TableCell>
<TableCell align="right">{formatCurrency(factura.importeFinal)}</TableCell>
<TableCell align="right" sx={{ color: 'success.dark' }}>{formatCurrency(factura.totalPagado)}</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold', color: saldo > 0 ? 'error.main' : 'inherit' }}>{formatCurrency(saldo)}</TableCell>
<TableCell>
<Chip label={factura.tipoFactura} size="small" color={factura.tipoFactura === 'Alta' ? 'secondary' : 'default'} />
</TableCell>
<TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Pagada Parcialmente' ? 'primary' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default'))} /></TableCell>
<TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell>
<TableCell>{factura.numeroFactura || '-'}</TableCell>
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}>
<MoreVertIcon />
</IconButton>
<Tooltip title="Ver Historial de Envíos">
<IconButton onClick={() => handleOpenHistorial(factura)}>
<MailOutlineIcon />
</IconButton>
</Tooltip>
<IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}><MoreVertIcon /></IconButton>
<Tooltip title="Ver Historial de Envíos"><IconButton onClick={() => handleOpenHistorial(factura)}><MailOutlineIcon /></IconButton></Tooltip>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</Box>
@@ -102,6 +113,7 @@ const ConsultaFacturasPage: React.FC = () => {
const [filtroNombre, setFiltroNombre] = useState('');
const [filtroEstadoPago, setFiltroEstadoPago] = useState('');
const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState('');
const [filtroTipoFactura, setFiltroTipoFactura] = useState('');
const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@@ -121,7 +133,8 @@ const ConsultaFacturasPage: React.FC = () => {
selectedMes,
filtroNombre || undefined,
filtroEstadoPago || undefined,
filtroEstadoFacturacion || undefined
filtroEstadoFacturacion || undefined,
filtroTipoFactura || undefined
);
setResumenes(data);
} catch (err) {
@@ -130,7 +143,7 @@ const ConsultaFacturasPage: React.FC = () => {
} finally {
setLoading(false);
}
}, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]);
}, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion, filtroTipoFactura]);
useEffect(() => {
const timer = setTimeout(() => {
@@ -218,6 +231,17 @@ const ConsultaFacturasPage: React.FC = () => {
<TextField label="Buscar por Suscriptor" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1, minWidth: '200px' }} />
<FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Pago</InputLabel><Select value={filtroEstadoPago} label="Estado de Pago" onChange={(e) => setFiltroEstadoPago(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosPago.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl>
<FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Facturación</InputLabel><Select value={filtroEstadoFacturacion} label="Estado de Facturación" onChange={(e) => setFiltroEstadoFacturacion(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosFacturacion.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl>
<FormControl sx={{ minWidth: 200 }} size="small">
<InputLabel>Tipo de Factura</InputLabel>
<Select
value={filtroTipoFactura}
label="Tipo de Factura"
onChange={(e) => setFiltroTipoFactura(e.target.value)}
>
<MenuItem value=""><em>Todos</em></MenuItem>
{tiposFactura.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)}
</Select>
</FormControl>
</Box>
</Paper>
@@ -226,7 +250,7 @@ const ConsultaFacturasPage: React.FC = () => {
<TableContainer component={Paper}>
<Table aria-label="collapsible table">
<TableHead><TableRow><TableCell /><TableCell>Suscriptor</TableCell><TableCell align="right">Saldo Total / Importe Total</TableCell><TableCell colSpan={5}></TableCell></TableRow></TableHead>
<TableHead><TableRow><TableCell /><TableCell>Suscriptor</TableCell><TableCell align="right">Saldo Total / Importe Total</TableCell><TableCell colSpan={6}></TableCell></TableRow></TableHead>
<TableBody>
{loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>)
: resumenes.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>)
@@ -252,22 +276,9 @@ const ConsultaFacturasPage: React.FC = () => {
open={pagoModalOpen}
onClose={handleClosePagoModal}
onSubmit={handleSubmitPagoModal}
factura={
selectedFactura ? {
idFactura: selectedFactura.idFactura,
nombreSuscriptor: resumenes.find(r => r.idSuscriptor === resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor)?.nombreSuscriptor || '',
importeFinal: selectedFactura.importeFinal,
saldoPendiente: selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal,
idSuscriptor: resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor || 0,
periodo: '',
fechaEmision: '',
fechaVencimiento: '',
totalPagado: selectedFactura.importeFinal - (selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal),
estadoPago: selectedFactura.estadoPago,
estadoFacturacion: selectedFactura.estadoFacturacion,
numeroFactura: selectedFactura.numeroFactura,
detalles: selectedFactura.detalles,
} : null
factura={selectedFactura}
nombreSuscriptor={
resumenes.find(r => r.idSuscriptor === selectedFactura?.idSuscriptor)?.nombreSuscriptor || ''
}
errorMessage={apiError}
clearErrorMessage={() => setApiError(null)}

View File

@@ -16,11 +16,12 @@ const SECCION_PERMISSIONS_PREFIX = "SS";
const getModuloFromSeccionCodAcc = (codAcc: string): string | null => {
if (codAcc === "SS001") return "Distribución";
if (codAcc === "SS007") return "Suscripciones";
if (codAcc === "SS002") return "Contables";
if (codAcc === "SS003") return "Impresión";
if (codAcc === "SS004") return "Reportes";
if (codAcc === "SS005") return "Radios";
if (codAcc === "SS006") return "Usuarios";
if (codAcc === "SS005") return "Radios";
return null;
};
@@ -38,6 +39,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
moduloLower.includes("salidas otros destinos")) {
return "Distribución";
}
if (moduloLower.includes("suscripciones")) {
return "Suscripciones";
}
if (moduloLower.includes("cuentas pagos") ||
moduloLower.includes("cuentas notas") ||
moduloLower.includes("cuentas tipos pagos")) {
@@ -50,9 +54,6 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
moduloLower.includes("tipos bobinas")) {
return "Impresión";
}
if (moduloLower.includes("radios")) {
return "Radios";
}
if (moduloLower.includes("usuarios") ||
moduloLower.includes("perfiles")) {
return "Usuarios";
@@ -63,6 +64,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
if (moduloLower.includes("permisos")) {
return "Permisos (Definición)";
}
if (moduloLower.includes("radios")) {
return "Radios";
}
return permisoModulo;
};

View File

@@ -1,11 +1,9 @@
// src/routes/AppRoutes.tsx
import React, { type JSX } from 'react';
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import LoginPage from '../pages/LoginPage';
import HomePage from '../pages/HomePage';
import { useAuth } from '../contexts/AuthContext';
import MainLayout from '../layouts/MainLayout';
import { Typography } from '@mui/material';
import SectionProtectedRoute from './SectionProtectedRoute';
// Distribución
@@ -267,7 +265,6 @@ const AppRoutes = () => {
<ReportesIndexPage />
</SectionProtectedRoute>}
>
<Route index element={<Typography sx={{ p: 2 }}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */}
<Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} />
<Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} />
<Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} />

View File

@@ -20,6 +20,7 @@ import type { NovedadesCanillasReporteDto } from '../../models/dtos/Reportes/Nov
import type { CanillaGananciaReporteDto } from '../../models/dtos/Reportes/CanillaGananciaReporteDto';
import type { ListadoDistCanMensualDiariosDto } from '../../models/dtos/Reportes/ListadoDistCanMensualDiariosDto';
import type { ListadoDistCanMensualPubDto } from '../../models/dtos/Reportes/ListadoDistCanMensualPubDto';
import axios from 'axios';
interface GetExistenciaPapelParams {
fechaDesde: string; // yyyy-MM-dd
@@ -212,21 +213,40 @@ const getVentaMensualSecretariaTirDevoPdf = async (params: { fechaDesde: string;
const getReporteDistribucionCanillas = async (params: {
fecha: string;
idEmpresa: number;
esAccionista?: boolean; // Hacerlo opcional
}): Promise<ReporteDistribucionCanillasResponseDto> => {
try {
const response = await apiClient.get<ReporteDistribucionCanillasResponseDto>('/reportes/distribucion-canillas', { params });
return response.data;
} catch (error) {
console.error('Error al obtener datos del reporte de distribución de canillas:', error);
throw error;
}
};
const getReporteDistribucionCanillasPdf = async (params: {
fecha: string;
idEmpresa: number;
soloTotales: boolean; // Nuevo parámetro
esAccionista?: boolean; // Opcional
soloTotales: boolean;
}): Promise<Blob> => {
const response = await apiClient.get('/reportes/distribucion-canillas/pdf', { // La ruta no necesita cambiar si el backend lo maneja
params, // soloTotales se enviará como query param si el backend lo espera así
responseType: 'blob',
try {
const response = await apiClient.get('/reportes/distribucion-canillas/pdf', {
params,
responseType: 'blob'
});
return response.data;
} catch (error) {
console.error('Error al generar PDF del reporte de distribución de canillas:', error);
if (axios.isAxiosError(error) && error.response?.data) {
// Si el error es un JSON dentro de un Blob, hay que leerlo
if (error.response.data.type === 'application/json') {
const errorJson = JSON.parse(await error.response.data.text());
throw new Error(errorJson.message || "Error al generar PDF");
}
}
throw error;
}
};
const getTiradasPublicacionesSecciones = async (params: {

View File

@@ -19,11 +19,16 @@ const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLot
return response.data;
};
const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise<ResumenCuentaSuscriptorDto[]> => {
const getResumenesDeCuentaPorPeriodo = async (
anio: number, mes: number,
nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string,
tipoFactura?: string
): Promise<ResumenCuentaSuscriptorDto[]> => {
const params = new URLSearchParams();
if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor);
if (estadoPago) params.append('estadoPago', estadoPago);
if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion);
if (tipoFactura) params.append('tipoFactura', tipoFactura);
const queryString = params.toString();
const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`;

View File

@@ -33,19 +33,18 @@ apiClient.interceptors.response.use(
(error) => {
// Cualquier código de estado que este fuera del rango de 2xx causa la ejecución de esta función
if (axios.isAxiosError(error) && error.response) {
if (error.response.status === 401) {
// Token inválido o expirado
console.warn("Error 401: Token inválido o expirado. Deslogueando...");
// Verificamos si la petición fallida NO fue al endpoint de login.
const isLoginAttempt = error.config?.url?.endsWith('/auth/login');
// Solo activamos el deslogueo automático si el error 401 NO es de un intento de login.
if (error.response.status === 401 && !isLoginAttempt) {
console.warn("Error 401 (Token inválido o expirado) detectado. Deslogueando...");
// Limpiar localStorage y recargar la página.
// AuthContext se encargará de redirigir a /login al recargar porque no encontrará token.
localStorage.removeItem('authToken');
localStorage.removeItem('authUser'); // Asegurar limpiar también el usuario
// Forzar un hard refresh para que AuthContext se reinicialice y redirija
// Esto también limpiará cualquier estado de React.
// --- Mostrar mensaje antes de redirigir ---
localStorage.removeItem('authUser');
alert("Tu sesión ha expirado o no es válida. Serás redirigido a la página de inicio de sesión.");
window.location.href = '/login'; // Redirección más directa
window.location.href = '/login';
}
}
// Es importante devolver el error para que el componente que hizo la llamada pueda manejarlo también si es necesario

View File

@@ -13,7 +13,6 @@ def insertar_alerta_en_db(cursor, tipo_alerta, id_entidad, entidad, mensaje, fec
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
"""
try:
# Asegurarse de que los valores numéricos opcionales sean None si no se proporcionan
p_dev = float(porc_devolucion) if porc_devolucion is not None else None
c_env = int(cant_enviada) if cant_enviada is not None else None
c_dev = int(cant_devuelta) if cant_devuelta is not None else None
@@ -31,6 +30,9 @@ DB_DATABASE = 'SistemaGestion'
CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;'
MODEL_INDIVIDUAL_FILE = 'modelo_anomalias.joblib'
MODEL_SISTEMA_FILE = 'modelo_sistema_anomalias.joblib'
MODEL_DIST_FILE = 'modelo_anomalias_dist.joblib'
MODEL_DANADAS_FILE = 'modelo_danadas.joblib'
MODEL_MONTOS_FILE = 'modelo_montos.joblib'
# --- 2. Determinar Fecha ---
if len(sys.argv) > 1:
@@ -46,11 +48,12 @@ except Exception as e:
print(f"CRITICAL: No se pudo conectar a la base de datos. Error: {e}")
exit()
# --- 3. DETECCIÓN INDIVIDUAL (CANILLITAS) ---
# --- FASE 1: Detección de Anomalías Individuales (Canillitas) ---
print("\n--- FASE 1: Detección de Anomalías Individuales (Canillitas) ---")
if not os.path.exists(MODEL_INDIVIDUAL_FILE):
print(f"ADVERTENCIA: Modelo individual '{MODEL_INDIVIDUAL_FILE}' no encontrado.")
else:
# ... (esta sección se mantiene exactamente igual que antes) ...
model_individual = joblib.load(MODEL_INDIVIDUAL_FILE)
query_individual = f"""
SELECT esc.Id_Canilla AS id_canilla, esc.Fecha AS fecha, esc.CantSalida AS cantidad_enviada, esc.CantEntrada AS cantidad_devuelta, c.NomApe AS nombre_canilla
@@ -81,15 +84,16 @@ else:
cant_devuelta=row['cantidad_devuelta'],
porc_devolucion=row['porcentaje_devolucion'])
else:
print("INFO: No se encontraron anomalías individuales significativas.")
print("INFO: No se encontraron anomalías individuales significativas en canillitas.")
else:
print("INFO: No hay datos de canillitas para analizar en la fecha seleccionada.")
# --- 4. DETECCIÓN DE SISTEMA ---
# --- FASE 2: Detección de Anomalías de Sistema ---
print("\n--- FASE 2: Detección de Anomalías de Sistema ---")
if not os.path.exists(MODEL_SISTEMA_FILE):
print(f"ADVERTENCIA: Modelo de sistema '{MODEL_SISTEMA_FILE}' no encontrado.")
else:
# ... (esta sección se mantiene exactamente igual que antes) ...
model_sistema = joblib.load(MODEL_SISTEMA_FILE)
query_agregada = f"""
SELECT CAST(Fecha AS DATE) AS fecha_dia, DATEPART(weekday, Fecha) as dia_semana,
@@ -128,7 +132,161 @@ else:
mensaje=mensaje,
fecha_anomalia=target_date.date())
# --- 5. Finalización ---
# --- FASE 3: Detección de Anomalías Individuales (Distribuidores) ---
print("\n--- FASE 3: Detección de Anomalías Individuales (Distribuidores) ---")
if not os.path.exists(MODEL_DIST_FILE):
print(f"ADVERTENCIA: Modelo de distribuidores '{MODEL_DIST_FILE}' no encontrado.")
else:
model_dist = joblib.load(MODEL_DIST_FILE)
query_dist = f"""
SELECT
es.Id_Distribuidor AS id_distribuidor,
d.Nombre AS nombre_distribuidor,
CAST(es.Fecha AS DATE) AS fecha,
SUM(CASE WHEN es.TipoMovimiento = 'Salida' THEN es.Cantidad ELSE 0 END) as cantidad_enviada,
SUM(CASE WHEN es.TipoMovimiento = 'Entrada' THEN es.Cantidad ELSE 0 END) as cantidad_devuelta
FROM
dist_EntradasSalidas es
JOIN
dist_dtDistribuidores d ON es.Id_Distribuidor = d.Id_Distribuidor
WHERE
CAST(es.Fecha AS DATE) = '{target_date.strftime('%Y-%m-%d')}'
GROUP BY
es.Id_Distribuidor, d.Nombre, CAST(es.Fecha AS DATE)
HAVING
SUM(CASE WHEN es.TipoMovimiento = 'Salida' THEN es.Cantidad ELSE 0 END) > 0
"""
df_dist_new = pd.read_sql(query_dist, cnxn)
if not df_dist_new.empty:
df_dist_new['porcentaje_devolucion'] = (df_dist_new['cantidad_devuelta'] / df_dist_new['cantidad_enviada']).fillna(0) * 100
df_dist_new['dia_semana'] = pd.to_datetime(df_dist_new['fecha']).dt.dayofweek
features_dist = ['id_distribuidor', 'porcentaje_devolucion', 'dia_semana']
X_dist_new = df_dist_new[features_dist]
df_dist_new['anomalia'] = model_dist.predict(X_dist_new)
anomalias_dist_detectadas = df_dist_new[df_dist_new['anomalia'] == -1]
if not anomalias_dist_detectadas.empty:
for index, row in anomalias_dist_detectadas.iterrows():
mensaje = f"Devolución inusual del {row['porcentaje_devolucion']:.2f}% para el distribuidor '{row['nombre_distribuidor']}'."
insertar_alerta_en_db(cursor,
tipo_alerta='DevolucionAnomalaDist',
id_entidad=row['id_distribuidor'],
entidad='Distribuidor',
mensaje=mensaje,
fecha_anomalia=row['fecha'],
cant_enviada=row['cantidad_enviada'],
cant_devuelta=row['cantidad_devuelta'],
porc_devolucion=row['porcentaje_devolucion'])
else:
print("INFO: No se encontraron anomalías individuales significativas en distribuidores.")
else:
print("INFO: No hay datos de distribuidores para analizar en la fecha seleccionada.")
# --- FASE 4: Detección de Anomalías en Bobinas Dañadas ---
print("\n--- FASE 4: Detección de Anomalías en Bobinas Dañadas ---")
if not os.path.exists(MODEL_DANADAS_FILE):
print(f"ADVERTENCIA: Modelo de bobinas dañadas '{MODEL_DANADAS_FILE}' no encontrado.")
else:
model_danadas = joblib.load(MODEL_DANADAS_FILE)
query_danadas = f"""
SELECT
h.Id_Planta as id_planta,
p.Nombre as nombre_planta,
DATEPART(weekday, h.FechaMod) as dia_semana,
COUNT(DISTINCT h.Id_Bobina) as cantidad_danadas
FROM
bob_StockBobinas_H h
JOIN
bob_dtPlantas p ON h.Id_Planta = p.Id_Planta
WHERE
h.Id_EstadoBobina = 3 -- Asumiendo ID 3 para 'Dañada'
AND h.TipoMod = 'Estado: Dañada'
AND CAST(h.FechaMod AS DATE) = '{target_date.strftime('%Y-%m-%d')}'
GROUP BY
h.Id_Planta, p.Nombre, DATEPART(weekday, h.FechaMod)
"""
df_danadas_new = pd.read_sql(query_danadas, cnxn)
if not df_danadas_new.empty:
for index, row in df_danadas_new.iterrows():
features_danadas = ['id_planta', 'dia_semana', 'cantidad_danadas']
X_danadas_new = row[features_danadas].to_frame().T
prediction = model_danadas.predict(X_danadas_new)
if prediction[0] == -1:
mensaje = f"Se registraron {row['cantidad_danadas']} bobina(s) dañada(s) en la Planta '{row['nombre_planta']}', un valor inusualmente alto."
insertar_alerta_en_db(cursor,
tipo_alerta='ExcesoBobinasDañadas',
id_entidad=row['id_planta'],
entidad='Planta',
mensaje=mensaje,
fecha_anomalia=target_date.date())
print(f"INFO: Análisis de {len(df_danadas_new)} planta(s) con bobinas dañadas completado.")
else:
print("INFO: No se registraron bobinas dañadas en la fecha seleccionada.")
# --- FASE 5: Detección de Anomalías en Montos Contables ---
print("\n--- FASE 5: Detección de Anomalías en Montos Contables ---")
if not os.path.exists(MODEL_MONTOS_FILE):
print(f"ADVERTENCIA: Modelo de montos contables '{MODEL_MONTOS_FILE}' no encontrado.")
else:
model_montos = joblib.load(MODEL_MONTOS_FILE)
# Consulta unificada para obtener todas las transacciones del día
query_transacciones = f"""
SELECT 'Distribuidor' AS entidad, p.Id_Distribuidor AS id_entidad, d.Nombre as nombre_entidad, p.Id_Empresa as id_empresa, p.Fecha as fecha, p.TipoMovimiento as tipo_transaccion, p.Monto as monto
FROM cue_PagosDistribuidor p JOIN dist_dtDistribuidores d ON p.Id_Distribuidor = d.Id_Distribuidor
WHERE CAST(p.Fecha AS DATE) = '{target_date.strftime('%Y-%m-%d')}'
UNION ALL
SELECT
CASE WHEN cd.Destino = 'Distribuidores' THEN 'Distribuidor' ELSE 'Canillita' END AS entidad,
cd.Id_Destino AS id_entidad,
COALESCE(d.Nombre, c.NomApe) as nombre_entidad,
cd.Id_Empresa as id_empresa,
cd.Fecha as fecha,
cd.Tipo as tipo_transaccion,
cd.Monto as monto
FROM cue_CreditosDebitos cd
LEFT JOIN dist_dtDistribuidores d ON cd.Id_Destino = d.Id_Distribuidor AND cd.Destino = 'Distribuidores'
LEFT JOIN dist_dtCanillas c ON cd.Id_Destino = c.Id_Canilla AND cd.Destino = 'Canillas'
WHERE CAST(cd.Fecha AS DATE) = '{target_date.strftime('%Y-%m-%d')}'
"""
df_transacciones_new = pd.read_sql(query_transacciones, cnxn)
if not df_transacciones_new.empty:
# Aplicar exactamente el mismo pre-procesamiento que en el entrenamiento
df_transacciones_new['tipo_transaccion_cat'] = pd.Categorical(df_transacciones_new['tipo_transaccion']).codes
df_transacciones_new['dia_semana'] = pd.to_datetime(df_transacciones_new['fecha']).dt.dayofweek
features = ['id_entidad', 'id_empresa', 'tipo_transaccion_cat', 'dia_semana', 'monto']
X_new = df_transacciones_new[features]
df_transacciones_new['anomalia'] = model_montos.predict(X_new)
anomalias_detectadas = df_transacciones_new[df_transacciones_new['anomalia'] == -1]
if not anomalias_detectadas.empty:
for index, row in anomalias_detectadas.iterrows():
tipo_alerta = 'MontoInusualPago' if row['tipo_transaccion'] in ['Recibido', 'Realizado'] else 'MontoInusualNota'
mensaje = f"Se registró un '{row['tipo_transaccion']}' de ${row['monto']:,} para '{row['nombre_entidad']}', un valor atípico."
insertar_alerta_en_db(cursor,
tipo_alerta=tipo_alerta,
id_entidad=row['id_entidad'],
entidad=row['entidad'],
mensaje=mensaje,
fecha_anomalia=row['fecha'].date())
else:
print("INFO: No se encontraron anomalías en los montos contables registrados.")
else:
print("INFO: No hay transacciones contables para analizar en la fecha seleccionada.")
# --- Finalización ---
cnxn.commit()
cnxn.close()
print("\n--- DETECCIÓN COMPLETA ---")

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,63 @@
import pandas as pd
from sklearn.ensemble import IsolationForest
import joblib
import pyodbc
from datetime import datetime, timedelta
print("--- INICIANDO SCRIPT DE ENTRENAMIENTO (BOBINAS DAÑADAS) ---")
# --- 1. Configuración ---
DB_SERVER = 'TECNICA3'
DB_DATABASE = 'SistemaGestion'
CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;'
MODEL_FILE = 'modelo_danadas.joblib'
CONTAMINATION_RATE = 0.02 # Un 2% de los días podrían tener una cantidad anómala de bobinas dañadas (ajustable)
# --- 2. Carga de Datos desde SQL Server ---
try:
print(f"Conectando a la base de datos '{DB_DATABASE}' en '{DB_SERVER}'...")
cnxn = pyodbc.connect(CONNECTION_STRING)
fecha_limite = datetime.now() - timedelta(days=730)
query = f"""
SELECT
CAST(h.FechaMod AS DATE) as fecha,
DATEPART(weekday, h.FechaMod) as dia_semana,
h.Id_Planta as id_planta,
COUNT(DISTINCT h.Id_Bobina) as cantidad_danadas
FROM
bob_StockBobinas_H h
WHERE
h.Id_EstadoBobina = 3 -- 3 es el ID del estado 'Dañada'
AND h.FechaMod >= '{fecha_limite.strftime('%Y-%m-%d')}'
GROUP BY
CAST(h.FechaMod AS DATE),
DATEPART(weekday, h.FechaMod),
h.Id_Planta
"""
print("Ejecutando consulta para obtener historial de bobinas dañadas...")
df = pd.read_sql(query, cnxn)
cnxn.close()
except Exception as e:
print(f"Error al conectar o consultar la base de datos: {e}")
exit()
if df.empty:
print("No se encontraron datos de entrenamiento de bobinas dañadas en el último año. Saliendo.")
exit()
# --- 3. Preparación de Datos ---
print(f"Preparando {len(df)} registros agregados para el entrenamiento...")
# Las características serán la planta, el día de la semana y la cantidad de bobinas dañadas ese día
features = ['id_planta', 'dia_semana', 'cantidad_danadas']
X = df[features]
# --- 4. Entrenamiento y Guardado ---
print(f"Entrenando el modelo de bobinas dañadas con tasa de contaminación de {CONTAMINATION_RATE}...")
model = IsolationForest(n_estimators=100, contamination=CONTAMINATION_RATE, random_state=42)
model.fit(X)
joblib.dump(model, MODEL_FILE)
print(f"--- ENTRENAMIENTO DE BOBINAS DAÑADAS COMPLETADO. Modelo guardado en '{MODEL_FILE}' ---")

View File

@@ -0,0 +1,68 @@
import pandas as pd
from sklearn.ensemble import IsolationForest
import joblib
import pyodbc
from datetime import datetime, timedelta
print("--- INICIANDO SCRIPT DE ENTRENAMIENTO (DISTRIBUIDORES) ---")
# --- 1. Configuración ---
DB_SERVER = 'TECNICA3'
DB_DATABASE = 'SistemaGestion'
CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;'
MODEL_FILE = 'modelo_anomalias_dist.joblib'
CONTAMINATION_RATE = 0.01 # Un 1% de contaminación
# --- 2. Carga de Datos desde SQL Server ---
try:
print(f"Conectando a la base de datos '{DB_DATABASE}' en '{DB_SERVER}'...")
cnxn = pyodbc.connect(CONNECTION_STRING)
fecha_limite = datetime.now() - timedelta(days=730)
query = f"""
SELECT
Id_Distribuidor AS id_distribuidor,
CAST(Fecha AS DATE) AS fecha,
SUM(CASE WHEN TipoMovimiento = 'Salida' THEN Cantidad ELSE 0 END) as cantidad_enviada,
SUM(CASE WHEN TipoMovimiento = 'Entrada' THEN Cantidad ELSE 0 END) as cantidad_devuelta
FROM
dist_EntradasSalidas
WHERE
Fecha >= '{fecha_limite.strftime('%Y-%m-%d')}'
GROUP BY
Id_Distribuidor, CAST(Fecha AS DATE)
HAVING
SUM(CASE WHEN TipoMovimiento = 'Salida' THEN Cantidad ELSE 0 END) > 0
"""
print("Ejecutando consulta para obtener datos de entrenamiento de distribuidores...")
df = pd.read_sql(query, cnxn)
cnxn.close()
except Exception as e:
print(f"Error al conectar o consultar la base de datos: {e}")
exit()
if df.empty:
print("No se encontraron datos de entrenamiento de distribuidores en el último año. Saliendo.")
exit()
# --- 3. Preparación de Datos ---
print(f"Preparando {len(df)} registros para el entrenamiento del modelo de distribuidores...")
# Se usa (df['cantidad_enviada'] + 0.001) para evitar división por cero
df['porcentaje_devolucion'] = (df['cantidad_devuelta'] / (df['cantidad_enviada'] + 0.001)) * 100
df.fillna(0, inplace=True)
df['porcentaje_devolucion'] = df['porcentaje_devolucion'].clip(0, 100)
df['dia_semana'] = pd.to_datetime(df['fecha']).dt.dayofweek
features = ['id_distribuidor', 'porcentaje_devolucion', 'dia_semana']
X = df[features]
# --- 4. Entrenamiento y Guardado ---
print(f"Entrenando el modelo con tasa de contaminación de {CONTAMINATION_RATE}...")
model = IsolationForest(n_estimators=100, contamination=CONTAMINATION_RATE, random_state=42)
model.fit(X)
joblib.dump(model, MODEL_FILE)
print(f"--- ENTRENAMIENTO DE DISTRIBUIDORES COMPLETADO. Modelo guardado en '{MODEL_FILE}' ---")

View File

@@ -0,0 +1,92 @@
import pandas as pd
from sklearn.ensemble import IsolationForest
import joblib
import pyodbc
from datetime import datetime, timedelta
print("--- INICIANDO SCRIPT DE ENTRENAMIENTO (MONTOS CONTABLES) ---")
# --- 1. Configuración ---
DB_SERVER = 'TECNICA3'
DB_DATABASE = 'SistemaGestion'
CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;'
MODEL_FILE = 'modelo_montos.joblib'
CONTAMINATION_RATE = 0.002
# --- 2. Carga de Datos de Múltiples Tablas ---
try:
print(f"Conectando a la base de datos '{DB_DATABASE}' en '{DB_SERVER}'...")
cnxn = pyodbc.connect(CONNECTION_STRING)
fecha_limite = datetime.now() - timedelta(days=730) # Usamos 2 años de datos para tener más contexto financiero
# Query para Pagos a Distribuidores
query_pagos = f"""
SELECT
'Distribuidor' AS entidad,
Id_Distribuidor AS id_entidad,
Id_Empresa AS id_empresa,
Fecha AS fecha,
TipoMovimiento AS tipo_transaccion,
Monto AS monto
FROM
cue_PagosDistribuidor
WHERE
Fecha >= '{fecha_limite.strftime('%Y-%m-%d')}'
"""
# Query para Notas de Crédito/Débito
query_notas = f"""
SELECT
CASE
WHEN Destino = 'Distribuidores' THEN 'Distribuidor'
WHEN Destino = 'Canillas' THEN 'Canillita'
ELSE 'Desconocido'
END AS entidad,
Id_Destino AS id_entidad,
Id_Empresa AS id_empresa,
Fecha AS fecha,
Tipo AS tipo_transaccion,
Monto AS monto
FROM
cue_CreditosDebitos
WHERE
Fecha >= '{fecha_limite.strftime('%Y-%m-%d')}'
"""
print("Ejecutando consultas para obtener datos de pagos y notas...")
df_pagos = pd.read_sql(query_pagos, cnxn)
df_notas = pd.read_sql(query_notas, cnxn)
cnxn.close()
except Exception as e:
print(f"Error al conectar o consultar la base de datos: {e}")
exit()
# --- 3. Unificación y Preparación de Datos ---
if df_pagos.empty and df_notas.empty:
print("No se encontraron datos de entrenamiento en el período seleccionado. Saliendo.")
exit()
# Combinamos ambos dataframes
df = pd.concat([df_pagos, df_notas], ignore_index=True)
print(f"Preparando {len(df)} registros contables para el entrenamiento...")
# Feature Engineering: Convertir textos a números categóricos
# Esto ayuda al modelo a entender "Recibido", "Credito", etc., como categorías distintas.
df['tipo_transaccion_cat'] = pd.Categorical(df['tipo_transaccion']).codes
df['dia_semana'] = df['fecha'].dt.dayofweek
# Las características para el modelo serán el contexto de la transacción y su monto
features = ['id_entidad', 'id_empresa', 'tipo_transaccion_cat', 'dia_semana', 'monto']
X = df[features]
# --- 4. Entrenamiento y Guardado ---
print(f"Entrenando el modelo de montos contables con tasa de contaminación de {CONTAMINATION_RATE}...")
model = IsolationForest(n_estimators=100, contamination=CONTAMINATION_RATE, random_state=42)
model.fit(X)
joblib.dump(model, MODEL_FILE)
print(f"--- ENTRENAMIENTO DE MONTOS COMPLETADO. Modelo guardado en '{MODEL_FILE}' ---")

View File

@@ -60,6 +60,22 @@ El sistema está organizado en varios módulos clave para cubrir todas las área
- **Autenticación Segura:** Mediante JSON Web Tokens (JWT).
- **Auditoría:** Todas las modificaciones a los datos maestros y transacciones importantes se registran en tablas de historial (`_H`).
### 📨 Suscripciones
- **Gestión de Suscriptores:** ABM completo de clientes, incluyendo datos de contacto, dirección de entrega y forma de pago preferida.
- **Ciclo de Vida de la Suscripción:** Creación y administración de suscripciones por cliente y publicación, con fechas de inicio, fin, días de entrega y estados (`Activa`, `Pausada`, `Cancelada`).
- **Facturación Proporcional (Pro-rata):** El sistema genera automáticamente una "Factura de Alta" por el monto proporcional cuando un cliente se suscribe en un período ya cerrado, evitando cobros excesivos en la primera factura.
- **Gestión de Promociones:** ABM de promociones (ej. descuentos porcentuales, bonificación de días) y asignación a suscripciones específicas con vigencia definida.
- **Cuenta Corriente del Suscriptor:** Administración de ajustes manuales (`Crédito`/`Débito`) para manejar situaciones excepcionales como notas de crédito, devoluciones o cargos especiales.
- **Procesos de Cierre Mensual:**
- **Generación de Cierre:** Proceso masivo que calcula y genera todas las facturas del período, aplicando promociones y ajustes.
- **Notificaciones Automáticas:** Envío automático de resúmenes de cuenta por email a cada suscriptor al generar el cierre.
- **Gestión de Débito Automático:**
- **Generación de Archivo:** Creación del archivo de texto plano en formato "Pago Directo" para el Banco Galicia. Las "Facturas de Alta" se excluyen automáticamente de este proceso.
- **Procesamiento de Respuesta:** Herramienta para procesar el archivo de respuesta del banco, actualizando los estados de pago (`Pagada`/`Rechazada`) de forma masiva.
- **Auditoría de Comunicaciones:**
- **Log de Envíos:** Registro detallado de cada correo electrónico enviado (individual o masivo), incluyendo estado (`Enviado`/`Fallido`) y mensajes de error.
- **Historial de Envíos:** Interfaz para consultar el historial de notificaciones enviadas por cada factura o por cada lote de cierre mensual.
---
## 🛠️ Stack Tecnológico

View File

@@ -7,9 +7,14 @@ services:
- shared-net
environment:
- DB_SA_PASSWORD=${DB_SA_PASSWORD}
- ConnectionStrings__DefaultConnection=Server=db-sqlserver;Database=SistemaGestion;User ID=sa;Password=${DB_SA_PASSWORD};TrustServerCertificate=True;
- ConnectionStrings__DefaultConnection=Server=db-sqlserver;Database=SistemaGestion;User ID=sa;Password=${DB_SA_PASSWORD};MultipleActiveResultSets=True;TrustServerCertificate=True;
ports:
- "8081:8080"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# --- Servicio del Frontend ---
web-gestion:
@@ -21,6 +26,11 @@ services:
- "8080:80"
depends_on:
- api-gestion
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks: