From 54581c21ab9d9e4131a49a77d69e29b64dcb8d83 Mon Sep 17 00:00:00 2001 From: Administrator Date: Sat, 29 Aug 2026 12:21:21 -0500 Subject: [PATCH] Actualizar dominios a dashboardapi y dashboardv1 --- .gitignore | 28 + backend/.env | 23 + backend/.env.example | 53 +- backend/.gitignore | 6 - backend/Dockerfile | 29 +- backend/README.md | 74 - backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query | 59 - backend/SQL_QUERY_2/BASE_CUOTAS_2.query | 46 - backend/SQL_QUERY_2/BASE_CURSO_2.query | 38 - backend/SQL_QUERY_2/BASE_FACTURAS_2.query | 51 - backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query | 211 -- backend/cache_manager.py | 25 +- backend/core/__init__.py | 0 backend/core/data_manager.py | 830 ------ backend/data_manager_v2.py | 1055 +++++++ backend/leads_logic.py | 894 ++++++ backend/main.py | 571 ++-- backend/modules/__init__.py | 0 backend/modules/asesores/__init__.py | 0 backend/modules/asesores/processor.py | 86 - backend/modules/cobranza/__init__.py | 0 backend/modules/cobranza/logic.py | 231 -- backend/modules/cobranza/processor.py | 824 ------ backend/modules/ocupabilidad/__init__.py | 0 backend/modules/ocupabilidad/logic.py | 234 -- backend/modules/ocupabilidad/processor.py | 677 ----- backend/modules/rentabilidad/__init__.py | 0 backend/modules/rentabilidad/logic.py | 946 ------ backend/modules/rentabilidad/processor.py | 290 -- backend/modules/saldo_pendiente/__init__.py | 0 backend/modules/saldo_pendiente/logic.py | 193 -- backend/modules/saldo_pendiente/processor.py | 162 -- backend/modules/ventas/__init__.py | 0 backend/modules/ventas/logic.py | 767 ----- backend/requirements.txt | 16 +- backend/services.py | 2560 +++++++++++------ docker-compose.yml | 49 +- frontend/.dockerignore | 6 + frontend/.env.example | 9 +- frontend/.env.production | 1 - frontend/.gitignore | 4 - frontend/Dockerfile | 33 +- frontend/README.md | 29 - frontend/index.html | 4 +- frontend/nginx.conf | 37 + frontend/package-lock.json | 952 +++--- frontend/package.json | 16 +- frontend/public/50x.html | 19 + frontend/src/App.jsx | 74 +- frontend/src/components/CampanitaAlertas.jsx | 92 + frontend/src/components/ConfigModal.jsx | 969 +++++++ frontend/src/components/ErrorBoundary.jsx | 41 + .../src/components/IndicadorActualizacion.jsx | 97 - frontend/src/components/Modal.jsx | 34 - frontend/src/components/Sidebar.jsx | 119 +- frontend/src/components/UI.jsx | 26 +- frontend/src/lib/api.js | 208 +- frontend/src/lib/auth.jsx | 144 - frontend/src/lib/supabase.js | 15 - frontend/src/lib/useColumnasAjustables.jsx | 9 +- frontend/src/lib/vistaActual.js | 23 - frontend/src/main.jsx | 5 +- frontend/src/pages/Asesores.jsx | 105 - frontend/src/pages/Cobranza.jsx | 1313 --------- frontend/src/pages/Comisiones.jsx | 1004 ------- frontend/src/pages/Leads.jsx | 764 +++++ frontend/src/pages/Login.jsx | 82 - frontend/src/pages/Ocupabilidad.jsx | 307 -- frontend/src/pages/OtrosGeneral.jsx | 334 +++ frontend/src/pages/Rentabilidad.jsx | 276 -- frontend/src/pages/Roas.jsx | 138 + frontend/src/pages/SaldoPendiente.jsx | 96 - frontend/src/pages/Usuarios.jsx | 130 - frontend/src/pages/Vendedores.jsx | 423 +++ frontend/src/pages/Ventas.jsx | 272 -- frontend/src/styles.css | 161 +- frontend/vite.config.js | 8 +- 77 files changed, 7679 insertions(+), 11728 deletions(-) create mode 100644 .gitignore create mode 100644 backend/.env delete mode 100644 backend/.gitignore delete mode 100644 backend/README.md delete mode 100644 backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query delete mode 100644 backend/SQL_QUERY_2/BASE_CUOTAS_2.query delete mode 100644 backend/SQL_QUERY_2/BASE_CURSO_2.query delete mode 100644 backend/SQL_QUERY_2/BASE_FACTURAS_2.query delete mode 100644 backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query delete mode 100644 backend/core/__init__.py delete mode 100644 backend/core/data_manager.py create mode 100644 backend/data_manager_v2.py create mode 100644 backend/leads_logic.py delete mode 100644 backend/modules/__init__.py delete mode 100644 backend/modules/asesores/__init__.py delete mode 100644 backend/modules/asesores/processor.py delete mode 100644 backend/modules/cobranza/__init__.py delete mode 100644 backend/modules/cobranza/logic.py delete mode 100644 backend/modules/cobranza/processor.py delete mode 100644 backend/modules/ocupabilidad/__init__.py delete mode 100644 backend/modules/ocupabilidad/logic.py delete mode 100644 backend/modules/ocupabilidad/processor.py delete mode 100644 backend/modules/rentabilidad/__init__.py delete mode 100644 backend/modules/rentabilidad/logic.py delete mode 100644 backend/modules/rentabilidad/processor.py delete mode 100644 backend/modules/saldo_pendiente/__init__.py delete mode 100644 backend/modules/saldo_pendiente/logic.py delete mode 100644 backend/modules/saldo_pendiente/processor.py delete mode 100644 backend/modules/ventas/__init__.py delete mode 100644 backend/modules/ventas/logic.py create mode 100644 frontend/.dockerignore delete mode 100644 frontend/.env.production delete mode 100644 frontend/.gitignore delete mode 100644 frontend/README.md create mode 100644 frontend/nginx.conf create mode 100644 frontend/public/50x.html create mode 100644 frontend/src/components/CampanitaAlertas.jsx create mode 100644 frontend/src/components/ConfigModal.jsx create mode 100644 frontend/src/components/ErrorBoundary.jsx delete mode 100644 frontend/src/components/IndicadorActualizacion.jsx delete mode 100644 frontend/src/components/Modal.jsx delete mode 100644 frontend/src/lib/auth.jsx delete mode 100644 frontend/src/lib/supabase.js delete mode 100644 frontend/src/lib/vistaActual.js delete mode 100644 frontend/src/pages/Asesores.jsx delete mode 100644 frontend/src/pages/Cobranza.jsx delete mode 100644 frontend/src/pages/Comisiones.jsx create mode 100644 frontend/src/pages/Leads.jsx delete mode 100644 frontend/src/pages/Login.jsx delete mode 100644 frontend/src/pages/Ocupabilidad.jsx create mode 100644 frontend/src/pages/OtrosGeneral.jsx delete mode 100644 frontend/src/pages/Rentabilidad.jsx create mode 100644 frontend/src/pages/Roas.jsx delete mode 100644 frontend/src/pages/SaldoPendiente.jsx delete mode 100644 frontend/src/pages/Usuarios.jsx create mode 100644 frontend/src/pages/Vendedores.jsx delete mode 100644 frontend/src/pages/Ventas.jsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..596b42b --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Dependencias del frontend (se regeneran con npm install en el servidor) +node_modules/ +frontend/node_modules/ + +# Build del frontend +dist/ +frontend/dist/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Logs y temporales +*.log +_ELIMINAR/ + +# Scripts auxiliares de esta migracion (no van al repo) +SUBIR_A_GIT.bat +LIMPIAR_PARA_SERVIDOR.bat +APLICAR_ENV.bat +_ENV_NUEVOS/ + +# NOTA: backend/.env SI se versiona a proposito (repo privado interno). +# Si algun dia el repo deja de ser privado, descomenta la linea siguiente: +#backend/.env diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..ba7eb40 --- /dev/null +++ b/backend/.env @@ -0,0 +1,23 @@ +PG_HOST=191.98.134.81 +PG_DATABASE=chatwoot_production +PG_USER=postgres +PG_PASSWORD=2165$%sd3%DFG +PG_PORT=5432 +SQL_SERVER=191.98.134.80 +SQL_DATABASE=BDUS_CK000040_0001 +SQL_USERNAME=ASEBASTIAN +SQL_PASSWORD=24MY36$z>&Uf +GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD_LEADS +SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co +SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y +SUPABASE_TABLA_LEADS=datos_unificados +SUPABASE_PAUTA_URL=https://uztqscimtsihrzgybsyb.supabase.co +SUPABASE_PAUTA_KEY=sb_publishable_aJCh5J6UghfKmSz6Swy4iA_orAqeHb7 +SUPABASE_TABLA_PAUTA=basebi_programacion +CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co +CARTERA_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6dHFzY2ltdHNpaHJ6Z3lic3liIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2MTMyMzQ0MCwiZXhwIjoyMDc2ODk5NDQwfQ.pvqzwJ7fBxggNka3oga7SdhiADIgIVKGEuxGbzHQj0E +SUPABASE_TABLA_CARTERA=cartera_junta +SUPABASE_TABLA_ALIAS=alias_normalizacion +SUPABASE_TABLA_CONJUNTO=conjunto_pauta +META_CSV_URL=https://docs.google.com/spreadsheets/d/e/2PACX-1vQa5BFEeF-p0-6-FuEXDXT4eb_ZK0fkYgLMxK_Ly3EJJPwQBsYDfNsSW09ppMNrH2LIiwVdAnC5q64C/pub?gid=1241390845&single=true&output=csv +CORS_ORIGINS=https://dashboardv1.escueladerefrigeracion.edu.pe,http://dashboardv1.escueladerefrigeracion.edu.pe,https://dashboardapi.escueladerefrigeracion.edu.pe,http://dashboardapi.escueladerefrigeracion.edu.pe,http://localhost:3002,http://localhost:5174,http://192.168.1.107:3002,http://161.132.96.129:3002,* \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index f97f94a..3f43f27 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,19 +1,48 @@ -# === SQL SERVER === -SQL_SERVER=191.98.134.80 -SQL_DATABASE=BDUS_CK000040_0001 -SQL_USERNAME=ASEBASTIAN -SQL_PASSWORD=tu_password_aqui +# ============================================================ +# DASHBOARD LEADS - Plantilla de variables de entorno (backend) +# Copiar como .env y rellenar los valores reales. +# ============================================================ -# === POSTGRESQL (Chatwoot) === +# ── Base de Datos PostgreSQL (Chatwoot) ── PG_HOST=191.98.134.81 PG_DATABASE=chatwoot_production PG_USER=postgres -PG_PASSWORD=tu_password_aqui +PG_PASSWORD=tu_password_postgres_aqui PG_PORT=5432 -# === SUPABASE === -SUPABASE_URL=tu_url_supabase -SUPABASE_KEY=tu_key_supabase +# ── Base de Datos SQL Server (Académico) ── +SQL_SERVER=191.98.134.80 +SQL_DATABASE=BDUS_CK000040_0001 +SQL_USERNAME=tu_usuario_sql_aqui +SQL_PASSWORD=tu_password_sql_aqui +# Driver ODBC. Dejar vacío/comentado para autodetección: +# Windows -> SQL Server | Linux/Docker -> ODBC Driver 18 for SQL Server +#SQL_DRIVER=ODBC Driver 18 for SQL Server -# === GITHUB === -GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD +# ── Repositorio GitHub (mapa de campañas, opcional) ── +GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD_LEADS + +# ── Supabase (Leads) ── +SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co +SUPABASE_KEY=tu_supabase_service_role_key_aqui +SUPABASE_TABLA_LEADS=datos_unificados + +# ── Supabase (Pauta) ── +SUPABASE_PAUTA_URL=https://uztqscimtsihrzgybsyb.supabase.co +SUPABASE_PAUTA_KEY=tu_supabase_pauta_key_aqui +SUPABASE_TABLA_PAUTA=basebi_programacion +SUPABASE_TABLA_PROGRAMA_PAUTAS=programa_pautas + +# ── Supabase (Cartera / catálogos) ── +CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co +CARTERA_KEY=tu_cartera_key_aqui +SUPABASE_TABLA_CARTERA=cartera_junta +SUPABASE_TABLA_ALIAS=alias_normalizacion +SUPABASE_TABLA_CONJUNTO=conjunto_pauta +SUPABASE_TABLA_CAMPANIAS=campanias + +# ── URL Google Sheets Meta CSV ── +META_CSV_URL=https://docs.google.com/spreadsheets/d/e/.../pub?gid=1241390845&single=true&output=csv + +# ── Orígenes CORS permitidos (separados por coma) ── +CORS_ORIGINS=https://dashboardv1.escueladerefrigeracion.edu.pe,http://dashboardv1.escueladerefrigeracion.edu.pe,https://dashboardapi.escueladerefrigeracion.edu.pe,http://dashboardapi.escueladerefrigeracion.edu.pe,http://localhost:3002,http://localhost:5174,http://192.168.1.107:3002,http://161.132.96.129:3002,* diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index bc7d7e6..0000000 --- a/backend/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -.env -__pycache__/ -*.pyc -*.xlsx -venv/ -.venv/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 47616a9..a900e98 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,34 +1,21 @@ -# Usamos una versión estable y probada de Debian 12 (Bookworm) -FROM python:3.10-slim-bookworm +FROM python:3.10-slim -# Evitamos que Python guarde caché o bloquee los logs -ENV PYTHONUNBUFFERED=1 - -# 1. Instalar las herramientas base -RUN apt-get update && apt-get install -y \ - curl apt-transport-https gnupg2 unixodbc-dev \ - # 2. Descargar la llave de Microsoft usando el método moderno (sin apt-key) +# Instalar dependencias del sistema y el Driver Oficial de Microsoft ODBC 18 para SQL Server en Linux +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl gnupg2 ca-certificates unixodbc unixodbc-dev gcc g++ \ && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ - # 3. Agregar el repositorio oficial de Microsoft para Debian 12 - && curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list | tee /etc/apt/sources.list.d/mssql-release.list \ + && curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \ && apt-get update \ - # 4. Instalar el ODBC Driver 17 aceptando los términos automáticamente - && ACCEPT_EULA=Y apt-get install -y msodbcsql17 \ - && apt-get clean \ + && ACCEPT_EULA=Y apt-get install -y msodbcsql18 \ && rm -rf /var/lib/apt/lists/* -# Crear la carpeta de trabajo WORKDIR /app -# Copiar las librerías e instalarlas COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copiar todo el código de tu backend COPY . . -# Exponer el puerto -EXPOSE 8000 +EXPOSE 8001 -# Arrancar FastAPI -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index b0e975d..0000000 --- a/backend/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Backend — Dashboard API (FastAPI) - -API REST que reutiliza toda la lógica de negocio del dashboard original. -El frontend (React) consume estos endpoints. - -## Requisitos - -- Python 3.10+ -- Driver ODBC para SQL Server (ODBC Driver 17 o 18) - -## Instalación - -```bash -cd backend -pip install -r requirements.txt -``` - -En Windows, si falta el driver ODBC de SQL Server, descárgalo de: -https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server - -## Configuración - -El archivo `.env` ya contiene las credenciales (copiado del proyecto original). -Si necesitas cambiarlas, edita `.env` (usa `.env.example` como referencia). - -## Ejecutar - -```bash -python main.py -``` - -El servidor arranca en: **http://localhost:8000** - -- Documentación interactiva: http://localhost:8000/docs -- Health check: http://localhost:8000/api/health - -## Cómo funciona el rendimiento - -1. **Al arrancar** → precarga todos los módulos (datos en memoria) -2. **Refresco en segundo plano** → cada 4 min recarga el caché silenciosamente -3. **Respuestas** → instantáneas porque vienen de memoria -4. El frontend solo pide datos (JSON), nunca recarga toda la página - -## Endpoints principales - -| Método | Ruta | Descripción | -|--------|------|-------------| -| GET | `/api/periodo-actual` | Año y mes actuales | -| GET | `/api/ocupabilidad?ano=&mes=&sede=&programa=` | Datos ocupabilidad | -| GET | `/api/ventas?ano=&mes=` | Datos ventas | -| GET | `/api/ventas/detalle?vendedor=&ano=&mes=&tipo=` | Detalle vendedor | -| GET | `/api/cobranza?ano=&mes=§orista=&agrupacion=` | Datos cobranza | -| GET | `/api/cobranza/detalle?grupo=&ano=&mes=§orista=&agrupacion=` | Estado de cuenta | -| GET | `/api/rentabilidad?ano=&mes=&sede=&programa=` | Datos rentabilidad | -| GET | `/api/rentabilidad/detalle?programa=&ano=&mes=` | Detalle programa | -| GET | `/api/rentabilidad/costos?programa=&ano=&mes=` | Costos programa | -| GET | `/api/saldo-pendiente?tipo_cuota=` | Saldos pendientes | -| GET | `/api/asesores` | Lista de asesores (Chatwoot) | -| POST | `/api/asesores/estado?agent_id=&online=` | Cambiar estado asesor | -| POST | `/api/cache/refresh` | Forzar refresco de caché | - -## Estructura - -``` -backend/ -├── main.py # FastAPI + endpoints -├── services.py # Capa que envuelve la lógica con caché -├── cache_manager.py # Caché en memoria + refresco background -├── requirements.txt -├── .env # Credenciales (no subir a git) -├── core/ # DataManager (reutilizado del original) -├── modules/ # Lógica de negocio (reutilizada del original) -└── SQL_QUERY_2/ # Queries SQL optimizadas -``` diff --git a/backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query b/backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query deleted file mode 100644 index b4ccbec..0000000 --- a/backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query +++ /dev/null @@ -1,59 +0,0 @@ -SELECT - -- Cronograma de Matrícula (T1) - T1.num_matricula, - T1.num_cuota, - T1.fch_vencimiento, - T1.fch_cancelacion, - T1.cod_estado, - T1.imp_total, - T1.imp_total_pagado, - T1.imp_saldo, - T1.imp_dscto, - - -- Matrícula y Programa - M.fch_matricula, - M.fch_retiro, - M.cod_estado AS estado_matricula, - P.dsc_programa, - RP.dsc_det_programa, - M.num_indice, - RP.fch_inicio, - M.cod_moneda, - M.imp_tc, - - -- Sectorista - (ISNULL(EMP.dsc_apellido_paterno, '') + ' ' + - ISNULL(EMP.dsc_apellido_materno, '') + ', ' + - ISNULL(EMP.dsc_nombres, '')) AS dsc_sectorista, - - RP.cod_frecuencia - -FROM - [BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1 -INNER JOIN - sgeca_matricula M - ON T1.cod_localidad = M.cod_localidad - AND T1.num_matricula = M.num_matricula -LEFT JOIN - sgeca_programa P - ON M.cod_programa = P.cod_programa -LEFT JOIN - sgede_RP_programa RP - ON M.cod_programa = RP.cod_programa - AND M.cod_periodo = RP.cod_detalle - AND M.num_indice = RP.num_indice -LEFT JOIN - vtama_cuota C - ON M.cod_cuota = C.cod_cuota -LEFT JOIN - [BDUS_CK000040_0001].[dbo].[sgede_RP_programa_sectorista] SEC_ASIG - ON M.cod_programa = SEC_ASIG.cod_programa - AND M.cod_periodo = SEC_ASIG.cod_detalle - AND M.num_indice = SEC_ASIG.num_indice -LEFT JOIN - [BDUS_CK000040_0001].[dbo].[rhuma_trabajador] EMP - ON SEC_ASIG.cod_trabajador = EMP.cod_trabajador -WHERE - YEAR(RP.fch_inicio) IN (2025, 2026) -ORDER BY - T1.fch_vencimiento ASC; diff --git a/backend/SQL_QUERY_2/BASE_CUOTAS_2.query b/backend/SQL_QUERY_2/BASE_CUOTAS_2.query deleted file mode 100644 index a28f7da..0000000 --- a/backend/SQL_QUERY_2/BASE_CUOTAS_2.query +++ /dev/null @@ -1,46 +0,0 @@ -SELECT - -- Cronograma de Matrícula (T1) - T1.cod_localidad, - T1.num_matricula, - T1.num_refinanciamiento, - T1.num_cuota, - T1.fch_vencimiento, - T1.fch_cancelacion, - T1.cod_estado, - T1.imp_total, - T1.imp_total_pagado, - T1.imp_saldo, - T1.imp_dscto, - - -- Matrícula y Programa - M.fch_matricula, - M.cod_estado AS estado_matricula, - P.dsc_programa, - RP.dsc_det_programa, - RP.fch_inicio, - M.cod_moneda, - C.dsc_cuota, - M.imp_tc -FROM - [BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1 -INNER JOIN - sgeca_matricula M - ON T1.cod_localidad = M.cod_localidad - AND T1.num_matricula = M.num_matricula -LEFT JOIN - sgeca_programa P - ON M.cod_programa = P.cod_programa -LEFT JOIN - sgede_RP_programa RP - ON M.cod_programa = RP.cod_programa - AND M.cod_periodo = RP.cod_detalle - AND M.num_indice = RP.num_indice -LEFT JOIN - vtama_cuota C - ON M.cod_cuota = C.cod_cuota -WHERE - YEAR(RP.fch_inicio) = ? - AND MONTH(RP.fch_inicio) = ? - AND T1.fch_vencimiento > '2020-01-01 00:00:00:000' -ORDER BY - T1.fch_vencimiento ASC; diff --git a/backend/SQL_QUERY_2/BASE_CURSO_2.query b/backend/SQL_QUERY_2/BASE_CURSO_2.query deleted file mode 100644 index 6df2253..0000000 --- a/backend/SQL_QUERY_2/BASE_CURSO_2.query +++ /dev/null @@ -1,38 +0,0 @@ -SELECT - rp.[num_indice], - rp.[dsc_det_programa], - rp.[flg_activo], - rp.[fch_inicio], - rp.[cod_frecuencia], - rp.[cod_estado], - p.[dsc_programa], - ( - SELECT COUNT(*) - FROM sgeca_matricula m - WHERE m.cod_periodo = rp.cod_detalle - AND m.num_indice = rp.num_indice - AND m.cod_estado NOT IN ('ANU', 'SUS') - ) AS 'Inscritos_Totales', - ( - SELECT COUNT(*) - FROM sgeca_matricula m - WHERE m.cod_periodo = rp.cod_detalle - AND m.num_indice = rp.num_indice - AND m.cod_estado = 'RET' - ) AS 'Inscritos_Retirados', - ( - SELECT COUNT(*) - FROM sgeca_matricula m - WHERE m.cod_periodo = rp.cod_detalle - AND m.num_indice = rp.num_indice - AND m.cod_estado NOT IN ('ANU', 'RET', 'SUS') - ) AS 'Inscritos_Activos' -FROM - [BDUS_CK000040_0001].[dbo].[sgede_RP_programa] rp -INNER JOIN [BDUS_CK000040_0001].[dbo].[sgeca_programa] p - ON rp.cod_programa = p.cod_programa -WHERE - YEAR(rp.fch_inicio) = ? - AND MONTH(rp.fch_inicio) = ? -ORDER BY - rp.fch_inicio ASC diff --git a/backend/SQL_QUERY_2/BASE_FACTURAS_2.query b/backend/SQL_QUERY_2/BASE_FACTURAS_2.query deleted file mode 100644 index a1ded1c..0000000 --- a/backend/SQL_QUERY_2/BASE_FACTURAS_2.query +++ /dev/null @@ -1,51 +0,0 @@ -SELECT -comp.[num_correlativo], -comp.[fch_emision], -comp.[fch_cancelacion], -comp.[cod_estado], -comp.[cod_moneda], -comp.[imp_total], -comp.[imp_saldo], -comp.[imp_tc], -comp.[flg_nc], - --- Centro de Responsabilidad -cr_prog.dsc_centroresp AS dsc_cr, - --- Programa académico -rp.dsc_det_programa, -rp.fch_inicio, -prog.dsc_programa, -mat.num_indice, -mat.cod_estado AS estado_matricula, -mat.fch_retiro, - --- Cuotas y matrícula -cxc.num_matricula, -cxc.num_cuota, -COALESCE(cxc.imp_emitido, comp.imp_total) as imp_emitido, - --- Cuota -cron.fch_vencimiento as fch_vencimiento_cuota -FROM [BDUS_CK000040_0001].[dbo].[vtaca_comprobante] comp - -LEFT JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad -AND comp.num_correlativo = cxc.num_correlativo - -LEFT JOIN sgeca_matricula mat ON cxc.cod_localidad_c = mat.cod_localidad -AND cxc.num_matricula = mat.num_matricula - -LEFT JOIN sgede_RP_programa rp ON mat.cod_programa = rp.cod_programa -AND mat.cod_periodo = rp.cod_detalle -AND mat.num_indice = rp.num_indice - -LEFT JOIN sgeca_programa prog ON mat.cod_programa = prog.cod_programa -LEFT JOIN rhuma_centroresponsabilidad cr_prog ON prog.cod_centroresp = cr_prog.cod_centroresp - -LEFT JOIN sgede_cronograma_matricula cron ON cxc.cod_localidad_c = cron.cod_localidad -AND cxc.num_matricula = cron.num_matricula -AND cxc.num_refinanciamiento = cron.num_refinanciamiento -AND cxc.num_cuota = cron.num_cuota - -WHERE comp.fch_emision BETWEEN '01-01-2024 00:00:00.000' AND '31-12-2028 00:00:00.000' -ORDER BY comp.fch_emision ASC; diff --git a/backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query b/backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query deleted file mode 100644 index 82aada5..0000000 --- a/backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query +++ /dev/null @@ -1,211 +0,0 @@ -SELECT - sgede_RP_programa.num_indice, - vtama_localidad.dsc_localidad, - sgeca_matricula.num_matricula, - sgeca_matricula.fch_matricula, - sgeca_matricula.cod_estado AS estado_matricula, - sgema_alumno.dsc_telefono_1, - - ( - SELECT - rhuma_trabajador.dsc_apellido_paterno + ' ' + - SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' + - rhuma_trabajador.dsc_nombres - FROM rhuma_trabajador - WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor - ) AS dsc_vendedor, - - sgeca_matricula.cod_moneda, - - ISNULL(( - SELECT - SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0)) - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - ), 0) AS INV_NETA, - - ( - SELECT vtama_cuota.dsc_cuota - FROM vtama_cuota - WHERE vtama_cuota.cod_cuota = sgeca_matricula.cod_cuota - ) AS dsc_cuota, - - ( - SELECT sgema_beca.dsc_beca - FROM sgema_beca - WHERE sgema_beca.cod_beca = sgeca_matricula.cod_beca - ) AS dsc_beca, - - sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno, - vtama_tipo_documento.dsc_tipo_documento, - sgema_alumno.dsc_documento, - sgeca_programa.dsc_programa, - sgede_RP_programa.dsc_det_programa AS dsc_promocion, - sgede_RP_programa.fch_inicio, - - -- ========================================== - -- SALDOS Y VENCIMIENTOS: MATRICULA Y CUOTAS 1-5 - -- ========================================== - ISNULL(( - SELECT sgede_cronograma_matricula.imp_saldo - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 0 - ), 0) AS imp_saldo_matricula, - - ( - SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 0 - ) AS fch_venc_matricula, - - ISNULL(( - SELECT sgede_cronograma_matricula.imp_saldo - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 1 - ), 0) AS imp_saldo_cuota1, - - ( - SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 1 - ) AS fch_venc_cuota1, - - ( - SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 1 - ) AS fch_cancelacion_cuota1, - - ISNULL(( - SELECT sgede_cronograma_matricula.imp_saldo - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 2 - ), 0) AS imp_saldo_cuota2, - - ( - SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 2 - ) AS fch_venc_cuota2, - - ISNULL(( - SELECT sgede_cronograma_matricula.imp_saldo - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 3 - ), 0) AS imp_saldo_cuota3, - - ( - SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 3 - ) AS fch_venc_cuota3, - - ISNULL(( - SELECT sgede_cronograma_matricula.imp_saldo - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 4 - ), 0) AS imp_saldo_cuota4, - - ( - SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 4 - ) AS fch_venc_cuota4, - - ISNULL(( - SELECT sgede_cronograma_matricula.imp_saldo - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 5 - ), 0) AS imp_saldo_cuota5, - - ( - SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 5 - ) AS fch_venc_cuota5, - - -- ========================================== - -- TIPO DE CAMBIO - -- ========================================== - ISNULL(( - SELECT TOP 1 comp.imp_tc - FROM vtaca_comprobante comp - INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad - AND comp.num_correlativo = cxc.num_correlativo - WHERE cxc.num_matricula = sgeca_matricula.num_matricula - AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad - AND comp.cod_estado NOT IN ('ANU', 'ELI') - ORDER BY comp.fch_emision DESC - ), 0) AS imp_tc, - - -- ========================================== - -- IMPORTE TOTAL PAGADO - -- ========================================== - (SELECT SUM(sgede_cronograma_matricula.imp_total_pagado) - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = sgeca_matricula.num_refinanciamiento) AS imp_total_pagado - -FROM sgeca_matricula -INNER JOIN sgeca_programa - ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa -LEFT JOIN sgede_RP_programa - ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle - AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa - AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice -INNER JOIN sgema_alumno - ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno -INNER JOIN vtama_localidad - ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad -INNER JOIN vtama_tipo_documento - ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento - -WHERE - sgeca_matricula.cod_localidad LIKE 'SCENT' - AND YEAR(sgeca_matricula.fch_matricula) = ? - AND MONTH(sgeca_matricula.fch_matricula) = ? - AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS') -ORDER BY - num_indice ASC diff --git a/backend/cache_manager.py b/backend/cache_manager.py index e267bea..9f63369 100644 --- a/backend/cache_manager.py +++ b/backend/cache_manager.py @@ -1,8 +1,5 @@ # backend/cache_manager.py -""" -Caché global en memoria con refresco en segundo plano. -Independiente de Streamlit. Mantiene los datos calientes para respuestas instantáneas. -""" +"""Caché global en memoria con refresco en segundo plano (igual que el dashboard de ventas).""" import time import threading from typing import Any, Callable, Dict, Tuple @@ -16,37 +13,25 @@ def _make_key(prefix: str, args: tuple) -> str: return prefix + ":" + "|".join(str(a) for a in args) -def cache_get_or_set(prefix: str, args: tuple, loader: Callable[[], Any], - ttl: int = _DEFAULT_TTL) -> Any: - """Devuelve el valor cacheado si está fresco; si no, lo calcula y guarda.""" +def cache_get_or_set(prefix: str, args: tuple, loader: Callable[[], Any], ttl: int = _DEFAULT_TTL) -> Any: key = _make_key(prefix, args) now = time.time() with _LOCK: hit = _CACHE.get(key) if hit and (now - hit[0] < ttl): return hit[1] - # Calcular fuera del lock (puede tardar) value = loader() with _LOCK: _CACHE[key] = (now, value) return value -def cache_set(prefix: str, args: tuple, value: Any) -> None: - key = _make_key(prefix, args) - with _LOCK: - _CACHE[key] = (time.time(), value) - - def cache_keys(): - """Lista las claves actualmente cacheadas (para refrescarlas sin vaciar).""" with _LOCK: return list(_CACHE.keys()) def cache_refresh_existing(loader_for_key): - """Recalcula SOLO las entradas que ya existen, sin vaciar el caché. - Así los meses ya visitados se mantienen calientes y nunca quedan 'fríos'.""" for key in cache_keys(): try: nuevo = loader_for_key(key) @@ -58,7 +43,6 @@ def cache_refresh_existing(loader_for_key): def cache_invalidate(prefix: str = None) -> None: - """Invalida todo el caché, o solo las claves de un prefijo.""" with _LOCK: if prefix is None: _CACHE.clear() @@ -73,13 +57,10 @@ def cache_stats() -> dict: return {"entradas": len(_CACHE), "claves": list(_CACHE.keys())} -# ── Refresco en segundo plano ────────────────────────────────────────────── _background_started = False -def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 240): - """Lanza un hilo que ejecuta refresh_fn cada `interval` segundos (4 min por defecto, - antes de que expire el TTL de 5 min, para mantener el caché siempre caliente).""" +def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 900): global _background_started if _background_started: return diff --git a/backend/core/__init__.py b/backend/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/core/data_manager.py b/backend/core/data_manager.py deleted file mode 100644 index 836a75a..0000000 --- a/backend/core/data_manager.py +++ /dev/null @@ -1,830 +0,0 @@ -# core/data_manager.py -import os -import requests -import json -from datetime import datetime -from dotenv import load_dotenv - -load_dotenv() - -class DataManager: - def __init__(self): - # --- SQL SERVER --- - self.server = os.getenv("SQL_SERVER", "191.98.134.80") - self.database = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001") - self.username = os.getenv("SQL_USERNAME", "ASEBASTIAN") - self.password = os.getenv("SQL_PASSWORD", "") - - # --- POSTGRESQL --- - self.pg_host = os.getenv("PG_HOST", "191.98.134.81") - self.pg_database = os.getenv("PG_DATABASE", "chatwoot_production") - self.pg_user = os.getenv("PG_USER", "postgres") - self.pg_password = os.getenv("PG_PASSWORD", "") - self.pg_port = os.getenv("PG_PORT", "5432") - - # --- SUPABASE --- - self.supabase_url = os.getenv("SUPABASE_URL", "") - self.supabase_key = os.getenv("SUPABASE_KEY", "") - self.supabase_client = None - self._init_supabase() - - # --- GITHUB --- - base = os.getenv("GITHUB_BASE", "https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD") - self.github_json_url = f"{base}/actualizador.json" - self.github_replace_url = f"{base}/REPLACE_CURSO.json" - self.github_meta_url = f"{base}/REPLACE_META.json" - self.github_costos_url = f"{base}/REPLACE_COSTOS.json" - self.github_pronostico_url = f"{base}/BASE_PRONOSTICO.json" - self.github_pendientes_url = f"{base}/BASE_PENDIENTES.json" - self.github_sede_url = f"{base}/sede.json" - self.github_horario_url = f"{base}/HORARIO_ASESOR.json" - self.github_query_url = f"{base}/SQL_QUERY_2/BASE_CURSO_2.query" - self.github_cuota_url = f"{base}/SQL_QUERY_2/BASE_CUOTAS_2.query" - self.github_cronograma_url = f"{base}/SQL_QUERY_2/BASE_CRONOGRAMA_2.query" - self.github_facturas_url = f"{base}/SQL_QUERY_2/BASE_FACTURAS_2.query" - self.github_matriculas_url = f"{base}/SQL_QUERY_2/BASE_MATRICULADOS_2.query" - - # --- Variables de estado --- - self.config_data = {} - self.replace_data = {} - self.meta_data = {} - self.sede_data = {} - self.pronostico_data = {} - self.costos_data = {} - self.historico_pendientes = {} - self.pendientes_data = set() - self.query_sql = "" - self.query_matriculas_sql = "" - self.query_cronograma_sql = "" - self.query_facturas_sql = "" - - # Caché en memoria de las consultas ANUALES de cobranza (evita re-ejecutar - # la consulta del año completo en cada combinación de filtros). - self._cache_cronograma = {} # str(ano) -> filas - self._cache_facturas = {} # str(ano) -> filas - - self.cargar_toda_configuracion() - - # ========================================================================= - # CARGA DE CONFIGURACIÓN - # ========================================================================= - def cargar_toda_configuracion(self): - # OPTIMIZACIÓN: descargar todas las URLs en paralelo - from concurrent.futures import ThreadPoolExecutor - json_tasks = { - "config_data": (self.github_json_url, {"config_general": {"auto_update_minutos": 5}}), - "replace_data": (self.github_replace_url, {}), - "meta_data": (self.github_meta_url, {}), - "sede_data": (self.github_sede_url, {}), - "pronostico_data": (self.github_pronostico_url, {"pronosticos": []}), - "costos_data": (self.github_costos_url, {}), - "_pendientes_raw": (self.github_pendientes_url, {}), - } - text_tasks = { - "query_sql": self.github_query_url, - "query_matriculas_sql": self.github_matriculas_url, - "query_cronograma_sql": self.github_cronograma_url, - "query_facturas_sql": self.github_facturas_url, - } - with ThreadPoolExecutor(max_workers=15) as ex: - json_futs = {k: ex.submit(self._get_json, url, default) for k,(url,default) in json_tasks.items()} - text_futs = {k: ex.submit(self._get_text, url) for k,url in text_tasks.items()} - for k, f in json_futs.items(): setattr(self, k, f.result()) - for k, f in text_futs.items(): setattr(self, k, f.result()) - # pendientes: parsear el dict ya descargado - try: - self.historico_pendientes = self._pendientes_raw.get("historico_pendientes", {}) - except: - self.historico_pendientes = {} - self.pendientes_data = set() - - def _get_json(self, url, default=None): - # 3 intentos, timeout 15s. Si falla, avisa en consola y devuelve el default. - for intento in range(1, 4): - try: - r = requests.get(url, timeout=15) - r.raise_for_status() - return r.json() - except Exception as e: - print(f"⚠️ [GitHub] intento {intento}/3 fallo JSON {url}: {e}") - print(f"❌ [GitHub] NO se pudo descargar (JSON): {url}") - return default or {} - - def _get_text(self, url): - # 3 intentos, timeout 15s. Si falla, avisa en consola y devuelve texto vacio. - for intento in range(1, 4): - try: - r = requests.get(url, timeout=15) - r.raise_for_status() - return r.text - except Exception as e: - print(f"⚠️ [GitHub] intento {intento}/3 fallo TEXT {url}: {e}") - print(f"❌ [GitHub] NO se pudo descargar (TEXT): {url}") - return "" - - def _cargar_pendientes(self): - # Ya cargado en paralelo dentro de cargar_toda_configuracion - if not hasattr(self, 'historico_pendientes'): - self.historico_pendientes = {} - if not hasattr(self, 'pendientes_data'): - self.pendientes_data = set() - - # ========================================================================= - # CONEXIONES A BASE DE DATOS - # ========================================================================= - def get_connection(self): - import pyodbc - conn_str = ( - f"DRIVER={{ODBC Driver 17 for SQL Server}};" # <--- ¡ESTE ES EL CAMBIO MÁGICO! - f"SERVER={self.server};" - f"DATABASE={self.database};" - f"UID={self.username};" - f"PWD={self.password}" - ) - return pyodbc.connect(conn_str) - - def get_pg_connection(self): - try: - import psycopg2 - except ImportError: - raise ImportError( - "psycopg2 no está instalado. Para usar PostgreSQL ejecuta:\n" - "pip install psycopg2-binary" - ) - try: - return psycopg2.connect( - host=self.pg_host, - database=self.pg_database, - user=self.pg_user, - password=self.pg_password, - port=self.pg_port - ) - except Exception as e: - print(f"❌ Error PostgreSQL: {e}") - return None - - def _init_supabase(self): - self.supabase_error = None - try: - from supabase import create_client - if self.supabase_url and self.supabase_key: - self.supabase_client = create_client(self.supabase_url, self.supabase_key) - print(f"✅ Supabase conectado: {self.supabase_url[:30]}...") - else: - self.supabase_error = "SUPABASE_URL o SUPABASE_KEY vacíos" - print(f"⚠️ {self.supabase_error}") - except Exception as e: - self.supabase_client = None - self.supabase_error = f"{type(e).__name__}: {e}" - print(f"❌ Error al conectar Supabase: {self.supabase_error}") - - # ========================================================================= - # CONSULTAS SQL - # ========================================================================= - def ejecutar_consulta_cursos(self, ano, mes): - try: - if not self.query_sql: - raise Exception("No se pudo cargar la query BASE_CURSO") - - ids_invitados = [] - cursos_personalizados = self.config_data.get('cursos_personalizados', {}) - for id_curso, datos in cursos_personalizados.items(): - if 'fch_inicio' in datos: - try: - f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y') - if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes): - ids_invitados.append(str(int(id_curso))) - except: - pass - - sql_final = self.query_sql - if ids_invitados: - ids_str = ",".join(ids_invitados) - if "ORDER BY" in sql_final: - parts = sql_final.split("ORDER BY") - sql_final = parts[0] + f" OR rp.num_indice IN ({ids_str}) \nORDER BY" + parts[1] - else: - sql_final += f" OR rp.num_indice IN ({ids_str})" - - conn = self.get_connection() - cursor = conn.cursor() - cursor.execute(sql_final, ano, mes) - rows = cursor.fetchall() - cols = ['num_indice', 'dsc_det_programa', 'flg_activo', 'fch_inicio', - 'cod_frecuencia', 'cod_estado', 'dsc_programa', - 'Inscritos_Totales', 'Inscritos_Retirados', 'Inscritos_Activos'] - datos = [{cols[i]: row[i] for i in range(len(cols))} for row in rows] - conn.close() - return datos - except Exception as e: - print(f"❌ Error ejecutar_consulta_cursos: {e}") - raise - - def cargar_datos_matriculas(self, ano, mes): - try: - if not self.query_matriculas_sql: - return {} - conn = self.get_connection() - cursor = conn.cursor() - cursor.execute(self.query_matriculas_sql, ano, mes) - rows = cursor.fetchall() - d = {} - for row in rows: - d[row[0]] = d.get(row[0], 0) + 1 - conn.close() - return d - except: - return {} - - def ejecutar_consulta_matriculados_detalle(self, ano, mes): - try: - conn = self.get_connection() - cursor = conn.cursor() - - ids_invitados = [] - cursos_personalizados = self.config_data.get('cursos_personalizados', {}) - for id_curso, datos in cursos_personalizados.items(): - if 'fch_inicio' in datos: - try: - f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y') - if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes): - ids_invitados.append(str(int(id_curso))) - except: - pass - - sql_or = "" - if ids_invitados: - sql_or = f" OR sgede_RP_programa.num_indice IN ({','.join(ids_invitados)}) " - - query = f""" - SELECT - sgede_RP_programa.num_indice, sgeca_matricula.num_matricula, - sgeca_matricula.cod_estado AS estado_matricula, - sgeca_programa.dsc_programa, sgeca_matricula.cod_moneda, - sgema_alumno.dsc_documento, - ISNULL((SELECT SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0)) - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1), 0) AS INV_NETA, - ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula, - ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1, - ISNULL((SELECT TOP 1 comp.imp_tc FROM vtaca_comprobante comp - INNER JOIN sgevi_cuotas_x_comprobante cxc - ON comp.cod_localidad = cxc.cod_localidad - AND comp.num_correlativo = cxc.num_correlativo - WHERE cxc.num_matricula = sgeca_matricula.num_matricula - AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad - AND comp.cod_estado NOT IN ('ANU', 'ELI') - ORDER BY comp.fch_emision DESC), 0) AS imp_tc - FROM sgeca_matricula - INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa - LEFT JOIN sgede_RP_programa - ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle - AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa - AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice - INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno - WHERE sgeca_matricula.cod_localidad LIKE 'SCENT' - AND ((YEAR(sgede_RP_programa.fch_inicio) = ? AND MONTH(sgede_RP_programa.fch_inicio) = ?) {sql_or}) - AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS') - """ - cursor.execute(query, ano, mes) - columns = [col[0] for col in cursor.description] - results = [dict(zip(columns, row)) for row in cursor.fetchall()] - conn.close() - return results - except Exception as e: - print(f"❌ Error ejecutar_consulta_matriculados_detalle: {e}") - return [] - - def consultar_historial_continuidad(self, lista_documentos): - if not lista_documentos: - return {} - try: - dnis = list(set([str(d).strip() for d in lista_documentos if d])) - if not dnis: - return {} - placeholders = ','.join(['?'] * len(dnis)) - query = f""" - SELECT a.dsc_documento, p.dsc_programa, rp.fch_inicio - FROM sgeca_matricula m - INNER JOIN sgema_alumno a ON m.cod_alumno = a.cod_alumno - INNER JOIN sgeca_programa p ON m.cod_programa = p.cod_programa - INNER JOIN sgede_RP_programa rp - ON m.cod_periodo = rp.cod_detalle - AND m.cod_programa = rp.cod_programa - AND m.num_indice = rp.num_indice - WHERE a.dsc_documento IN ({placeholders}) - AND m.cod_estado IN ('ALU', 'PRE', 'RET') - AND m.cod_localidad LIKE 'SCENT' - AND (p.dsc_programa LIKE '%TEAC%' OR p.dsc_programa LIKE '%TERC%' - OR p.dsc_programa LIKE '%AREQUIPA%' OR p.dsc_programa LIKE '%TRUJILLO%' - OR p.dsc_programa LIKE '%PIURA%' OR p.dsc_programa LIKE '%TECNICO ESPECIALISTA%') - """ - conn = self.get_connection() - cursor = conn.cursor() - cursor.execute(query, dnis) - historial = {} - for row in cursor.fetchall(): - dni = str(row[0]).strip() - if dni not in historial: - historial[dni] = [] - historial[dni].append({'programa': row[1], 'fecha': row[2]}) - conn.close() - return historial - except Exception as e: - print(f"❌ Error historial continuidad: {e}") - return {} - - def _get_query_ventas_hibrido(self, lista_ids_vip): - if not lista_ids_vip: - sql_in_clause = "(-1)" - else: - ids_str = [str(x) for x in lista_ids_vip] - sql_in_clause = "(" + ",".join(ids_str) + ")" - - return f""" - SELECT - ( - SELECT - rhuma_trabajador.dsc_apellido_paterno + ' ' + - SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' + - rhuma_trabajador.dsc_nombres - FROM rhuma_trabajador - WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor - ) AS dsc_vendedor, -- [0] - ISNULL(( - SELECT - SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0)) - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - ), 0) AS INV_NETA, -- [1] - ISNULL(( - SELECT sgede_cronograma_matricula.imp_saldo - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 0 - ), 0) AS imp_saldo_matricula, -- [2] - ISNULL(( - SELECT sgede_cronograma_matricula.imp_saldo - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 1 - ), 0) AS imp_saldo_cuota1, -- [3] - - sgeca_matricula.num_matricula, -- [4] - YEAR(sgeca_matricula.fch_matricula) as anio_mat, -- [5] - MONTH(sgeca_matricula.fch_matricula) as mes_mat, -- [6] - - ( - SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion - FROM sgede_cronograma_matricula - WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad - AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula - AND sgede_cronograma_matricula.num_refinanciamiento = 1 - AND sgede_cronograma_matricula.num_cuota = 1 - ) AS fch_cancelacion_cuota1, -- [7] - - sgeca_matricula.cod_moneda, -- [8] - - ISNULL(( - SELECT TOP 1 comp.imp_tc - FROM vtaca_comprobante comp - INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad - AND comp.num_correlativo = cxc.num_correlativo - WHERE cxc.num_matricula = sgeca_matricula.num_matricula - AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad - AND comp.cod_estado NOT IN ('ANU', 'ELI') - ORDER BY comp.fch_emision DESC - ), 1) AS imp_tc, -- [9] - - sgede_RP_programa.dsc_det_programa, -- [10] PROGRAMA (mismo num_indice) - sgede_RP_programa.fch_inicio, -- [11] FECHA INICIO (mismo num_indice) - sgeca_programa.dsc_programa, -- [12] PROGRAMA GENERAL (respaldo) - sgeca_matricula.cod_estado -- [13] ESTADO (ALU/PRE/RET) - - FROM sgeca_matricula - INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa - LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle - AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa - AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice - INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno - INNER JOIN vtama_localidad ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad - INNER JOIN vtama_tipo_documento ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento - WHERE - sgeca_matricula.cod_localidad LIKE 'SCENT' - AND ( - sgeca_matricula.cod_estado IN ('ALU', 'PRE', 'RET') - OR sgeca_matricula.num_matricula IN {sql_in_clause} - ) - AND ( - (YEAR(sgeca_matricula.fch_matricula) = ? AND MONTH(sgeca_matricula.fch_matricula) = ?) - OR - sgeca_matricula.num_matricula IN {sql_in_clause} - OR - EXISTS ( - SELECT 1 FROM sgede_cronograma_matricula crono - WHERE crono.cod_localidad = sgeca_matricula.cod_localidad - AND crono.num_matricula = sgeca_matricula.num_matricula - AND crono.num_cuota = 1 - AND crono.fch_cancelacion IS NOT NULL - AND YEAR(crono.fch_cancelacion) = ? - AND MONTH(crono.fch_cancelacion) = ? - ) - ) - ORDER BY - dsc_vendedor ASC - """ - - def ejecutar_consulta_ventas(self, ano, mes, sede="TODOS", programa="TODOS"): - try: - key_mes = f"{int(mes):02d}-{ano}" - lista_raw = self.historico_pendientes.get(key_mes, []) - - lista_vip_list = [] - for item in lista_raw: - try: lista_vip_list.append(int(item)) - except: pass - - # Lista PURA de BASE_PENDIENTES (sin overrides). Venta Pendientes usa SOLO esto. - set_lista_pendientes = set(lista_vip_list) - - # Incluir matrículas con override de fecha (para que la query las traiga, incluso ANU) - _fov = getattr(self, '_fecha_canc_overrides', None) or {} - for mk in _fov.keys(): - try: lista_vip_list.append(int(float(str(mk)))) - except: pass - - correcciones = self.config_data.get("correcciones_matriculas", {}) - query_hibrido = self._get_query_ventas_hibrido(lista_vip_list) - conn = self.get_connection() - cursor = conn.cursor() - - # Clasificadores para filtros (mismos que Cobranza/Ocupabilidad) - _sede_sel = str(sede or "TODOS").upper() - _prog_sel = str(programa or "TODOS").upper() - def _clasif_sede(dscp): - if not dscp: return "LIMA" - up = str(dscp).upper() - sedes_data = self.sede_data.get("clasificacion_sedes", {}) - for s, data in sedes_data.items(): - if s == "DEFAULT": continue - for pat in data.get("patrones", []): - if pat.upper() in up: return s - _def = self.sede_data.get("clasificacion_default", {}) - return _def.get("sede", "LIMA") if isinstance(_def, dict) else "LIMA" - def _clasif_prog(dscp): - # Clasifica el programa en UNA sola categoría, con prioridad de orden. - # Específicas primero (SEMINARIOS, TEAC, TERC); OTROS y el sobrante al final. - up = str(dscp or "").upper() - fdata = self.meta_data.get("clasificacion_filtro_programa", {}) - orden = ["OTROS", "SEMINARIOS", "TEAC", "TERC"] - for cat in orden: - data = fdata.get(cat, {}) - for pat in data.get("patrones", []): - if pat.upper() in up: - return cat - # Sobrante (no coincide con nada) → DEFAULT - return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS" - - cursor.execute(query_hibrido, ano, mes, ano, mes) - rows = cursor.fetchall() - - ventas_por_vendedor = {} - set_vip_actual = set(lista_vip_list) - - vendedores_excluidos = { - "CASTILLO B. KARINA LISSET", - "CASTILLO B. KARINA LISSET", - "CALDERON S. LISSA GENA", - "CRUZ G. FIORELLA MELISSA", - "URIBE G. MARIA MERCEDES" - } - - for row in rows: - vendedor = row[0] if row[0] else "SIN VENDEDOR" - if vendedor in vendedores_excluidos: continue - - # Filtro SEDE/PROGRAMA (mismas reglas que Ocupabilidad/Cobranza) - _dscp = row[10] if (len(row) > 10 and row[10]) else (row[12] if len(row) > 12 else "") - if _sede_sel != "TODOS" and _clasif_sede(_dscp) != _sede_sel: - continue - if _prog_sel != "TODOS" and _clasif_prog(_dscp) != _prog_sel: - continue - - inv_neta_raw = float(row[1]) if row[1] else 0.0 - cod_moneda = row[8] - imp_tc = float(row[9]) if row[9] else 1.0 - - saldo_mat = float(row[2]) if row[2] else 0.0 - saldo_c1 = float(row[3]) if row[3] else 0.0 - - raw_mat = row[4] - row_year = row[5] - row_month = row[6] - fch_cancelacion_raw = row[7] - - matricula_int = -1 - matricula_str = "" - try: - if raw_mat is not None: - val_str = str(raw_mat).strip() - if val_str.endswith('.0'): val_str = val_str[:-2] - matricula_str = val_str - matricula_int = int(float(val_str)) - except: pass - - if matricula_str in correcciones: - datos_corregidos = correcciones[matricula_str] - if "imp_tc" in datos_corregidos: imp_tc = float(datos_corregidos["imp_tc"]) - if "imp_saldo_cuota1" in datos_corregidos: saldo_c1 = float(datos_corregidos["imp_saldo_cuota1"]) - if "imp_saldo_matricula" in datos_corregidos: saldo_mat = float(datos_corregidos["imp_saldo_matricula"]) - if "fch_cancelacion_cuota1" in datos_corregidos: fch_cancelacion_raw = datos_corregidos["fch_cancelacion_cuota1"] - - # Override de FECHA CANCELACIÓN 1 desde Supabase (prioridad para clasificación) - _fov = getattr(self, '_fecha_canc_overrides', None) - _vacio_forzado = False - if _fov and matricula_str in _fov: - fov_val = _fov[matricula_str] - s = str(fov_val).strip() - if s == "__VACIO__": - # Fecha borrada a propósito → anula la del SQL (no cuenta como pagado) - fch_cancelacion_raw = None - _vacio_forzado = True - else: - # Normalizar dd/mm/yyyy o d/m/yyyy → yyyy-mm-dd - if '/' in s: - p = s.split('/') - if len(p) == 3: - s = f"{p[2]}-{int(p[1]):02d}-{int(p[0]):02d}" - fch_cancelacion_raw = s - - # Prioridad TC: Supabase (mes) > corrección manual > SQL - _tc_ov = getattr(self, '_tc_override_mes', None) - if _tc_ov: imp_tc = float(_tc_ov) - - if cod_moneda == 'DOL': inv_neta = inv_neta_raw * imp_tc - else: inv_neta = inv_neta_raw - - # Override de INVERSIÓN NETA (Supabase): monto final en soles, reemplaza al SQL. - _inv_ov = getattr(self, '_inv_neta_overrides', None) - if _inv_ov and matricula_str in _inv_ov: - inv_neta = float(_inv_ov[matricula_str]) - - if vendedor not in ventas_por_vendedor: - ventas_por_vendedor[vendedor] = { - 'monto': 0.0, 'cantidad': 0, 'monto_pc': 0.0, 'cantidad_pc': 0, - 'monto_pendientes': 0.0, 'cantidad_pendientes': 0 - } - - es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes)) - saldos_ok = (saldo_mat == 0 and saldo_c1 == 0) - - # Si hay override de fecha de cancelación 1 (con fecha), se considera PAGADO. - # Si el override la vació a propósito, NO se fuerza pagado. - tiene_override_fecha = bool(_fov and matricula_str in _fov) and not _vacio_forzado - if tiene_override_fecha: - saldos_ok = True - - # Si es RET y NO pagó (sin override ni saldos), NO entra en la tabla principal. - estado_row = str(row[13]).strip().upper() if len(row) > 13 and row[13] else "" - if estado_row == "RET" and not saldos_ok: - continue - - # ¿Pagó la 1° cuota en el MES del filtro? - pago_en_fecha_correcta = False - if fch_cancelacion_raw: - try: - if isinstance(fch_cancelacion_raw, str): - f_obj = datetime.strptime(fch_cancelacion_raw[:10], '%Y-%m-%d') - f_ano, f_mes = f_obj.year, f_obj.month - else: - f_ano, f_mes = fch_cancelacion_raw.year, fch_cancelacion_raw.month - if str(f_ano) == str(ano) and str(f_mes) == str(mes): - pago_en_fecha_correcta = True - except: pass - - # ¿La matrícula es de un mes ANTERIOR al filtro? (mismo año mes menor, o año anterior) - matricula_mes_pasado = False - try: - ym_mat = int(row_year) * 100 + int(row_month) - ym_filtro = int(ano) * 100 + int(mes) - matricula_mes_pasado = ym_mat < ym_filtro - except: pass - - # COLUMNA 2 y 3 — MES EN CURSO: matrícula del mes + pagó en el mes - if es_venta_del_mes: - ventas_por_vendedor[vendedor]['monto'] += inv_neta - ventas_por_vendedor[vendedor]['cantidad'] += 1 - if saldos_ok and pago_en_fecha_correcta: - ventas_por_vendedor[vendedor]['monto_pc'] += inv_neta - ventas_por_vendedor[vendedor]['cantidad_pc'] += 1 - - # COLUMNA 4 y 5 — MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro - if matricula_int in set_lista_pendientes and pago_en_fecha_correcta: - ventas_por_vendedor[vendedor]['monto_pendientes'] += inv_neta - ventas_por_vendedor[vendedor]['cantidad_pendientes'] += 1 - - datos_ventas = [] - for vendedor, datos in ventas_por_vendedor.items(): - datos_ventas.append({ - 'VENDEDOR': vendedor, - 'MONTO': round(datos['monto'], 2), - 'CANTIDAD': datos['cantidad'], - 'VENTAS_PC': round(datos['monto_pc'], 2), - 'INSCRITOS_PC': datos['cantidad_pc'], - 'PENDIENTES': round(datos['monto_pendientes'], 2), - 'INSCRITOS_PENDIENTES': datos['cantidad_pendientes'] - }) - - datos_ventas.sort(key=lambda x: x['MONTO'], reverse=True) - conn.close() - return datos_ventas - except Exception as e: - return [] - - def ejecutar_consulta_cronograma_cobranza(self, ano): - clave = str(ano) - en_cache = self._cache_cronograma.get(clave) - if en_cache is not None: - return en_cache - try: - if not self.query_cronograma_sql: - return [] - conn = self.get_connection() - cursor = conn.cursor() - cursor.execute(self.query_cronograma_sql) - columns = [col[0] for col in cursor.description] - results = [dict(zip(columns, row)) for row in cursor.fetchall()] - conn.close() - self._cache_cronograma[clave] = results # cachear SOLO si tuvo éxito - return results - except Exception as e: - print(f"❌ Error cronograma cobranza: {e}") - return [] - - def ejecutar_consulta_facturas_cobranza(self, ano): - clave = str(ano) - en_cache = self._cache_facturas.get(clave) - if en_cache is not None: - return en_cache - try: - if not self.query_facturas_sql: - return [] - conn = self.get_connection() - cursor = conn.cursor() - cursor.execute(self.query_facturas_sql) - columns = [col[0] for col in cursor.description] - results = [dict(zip(columns, row)) for row in cursor.fetchall()] - conn.close() - self._cache_facturas[clave] = results # cachear SOLO si tuvo éxito - return results - except Exception as e: - print(f"❌ Error facturas cobranza: {e}") - return [] - - def limpiar_cache_cobranza(self, ano=None): - """Vacía el caché anual de cobranza para forzar re-lectura desde SQL. - Se llama en cada refresco (manual o ciclo de 15 min).""" - if ano is None: - self._cache_cronograma.clear() - self._cache_facturas.clear() - else: - self._cache_cronograma.pop(str(ano), None) - self._cache_facturas.pop(str(ano), None) - - def ejecutar_consulta_saldos_anual(self, ano): - try: - if not self.query_matriculas_sql: - return [] - sql_anual = self.query_matriculas_sql.replace( - "AND MONTH(sgeca_matricula.fch_matricula) = ?", "" - ) - conn = self.get_connection() - cursor = conn.cursor() - cursor.execute(sql_anual, ano) - columns = [col[0] for col in cursor.description] - results = [dict(zip(columns, row)) for row in cursor.fetchall()] - conn.close() - return results - except Exception as e: - print(f"❌ Error saldos anual: {e}") - return [] - - # ========================================================================= - # SUPABASE - OVERRIDES DE COSTOS - # ========================================================================= - def cargar_overrides_costos(self): - if not self.supabase_client: - return {} - try: - response = self.supabase_client.table('costos_overrides').select('*').execute() - overrides = {} - for row in response.data: - idx = str(row.get('num_indice', '')).strip() - tipo = str(row.get('tipo_costo', '')).strip().lower() - if not idx or tipo not in ('inicial', 'actual'): - continue - if idx not in overrides: - overrides[idx] = {} - overrides[idx][tipo] = { - 'epp': row.get('costo_epp'), - 'certificado': row.get('costo_certificado'), - 'docente': row.get('costo_docente'), - 'consumibles': row.get('costo_consumibles'), - 'marketing': row.get('costo_marketing'), - } - return overrides - except Exception as e: - print(f"⚠️ Error overrides Supabase: {e}") - return {} - - - def obtener_fechas_originales(self): - """Descarga todas las fechas originales guardadas en Supabase (inicios reprogramados).""" - if not self.supabase_client: - return {} - try: - respuesta = self.supabase_client.table('fechas_originales_cursos').select('*').execute() - datos = respuesta.data - diccionario_fechas = {} - for fila in datos: - indice = str(fila.get('num_indice')) - fecha = fila.get('fecha_inicio_original') - programa = fila.get('programa', 'CURSO REPROGRAMADO') - diccionario_fechas[indice] = {'fecha': fecha, 'programa': programa} - return diccionario_fechas - except Exception as e: - print(f"Error al cargar fechas originales: {e}") - return {} - - def guardar_fecha_original(self, num_indice, programa, fecha_inicio): - """Guarda silenciosamente un nuevo curso y su fecha inicial en Supabase.""" - if not self.supabase_client: - return False - try: - payload = { - 'num_indice': str(num_indice), - 'programa': str(programa), - 'fecha_inicio_original': str(fecha_inicio) - } - self.supabase_client.table('fechas_originales_cursos').upsert(payload).execute() - return True - except Exception as e: - print(f"Error al guardar fecha original: {e}") - return False - - def guardar_override_costo(self, num_indice, tipo_costo, costos_dict): - if not self.supabase_client: - return False - try: - payload = { - 'num_indice': str(num_indice), - 'tipo_costo': tipo_costo, - 'costo_epp': costos_dict.get('epp'), - 'costo_certificado': costos_dict.get('certificado'), - 'costo_docente': costos_dict.get('docente'), - 'costo_consumibles': costos_dict.get('consumibles'), - 'costo_marketing': costos_dict.get('marketing'), - 'fecha_actualizacion': datetime.now().isoformat(), - } - self.supabase_client.table('costos_overrides').upsert(payload).execute() - return True - except Exception as e: - print(f"❌ Error guardar override: {e}") - return False - - def eliminar_overrides_costo(self, num_indice): - if not self.supabase_client: - return False - try: - self.supabase_client.table('costos_overrides').delete().eq( - 'num_indice', str(num_indice) - ).execute() - return True - except Exception as e: - print(f"❌ Error eliminar overrides: {e}") - return False - - # ========================================================================= - # HELPERS - # ========================================================================= - def get_current_year(self): - return datetime.now().year - - def get_current_month(self): - return datetime.now().month diff --git a/backend/data_manager_v2.py b/backend/data_manager_v2.py new file mode 100644 index 0000000..d313689 --- /dev/null +++ b/backend/data_manager_v2.py @@ -0,0 +1,1055 @@ +# backend/data_manager.py (v2 - reescrito completo) +"""Capa de acceso a datos del módulo LEADS.""" +import os +import time +import requests +from dotenv import load_dotenv + +load_dotenv() + +# ── Mapa de campañas (frase -> programa/sede/rango fechas), igual que el PBI ── +CAMPANIAS = [ + ('📚 Me interesa el', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-10-01', '2025-12-31'), + ('📚 Me interesa el', 'TEAC', '227', 'Lima', 'Domingo', 'Pauta_wsp', '2026-01-15', '2026-03-30'), + ('Hola! 🚨', 'TEAC', '191', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 💯 Me interesa', 'TERC', '193', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 💼', 'TEAC', '195', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'), + ('Hola! 💸', 'TEAC', '197', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'), + ('Hola! ⚡', 'TEAC', '198', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'), + ('Hola! 🦺', 'TERC', '199', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'), + ('Hola! 🍾', 'TEAC', '201', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'), + ('¡Hola! 📚', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'), + ('Hola! 🥶', 'TEAC', '202', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🎯', 'TEAC', '203', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'), + ('Hola! 🌞', 'TERC', '204', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🗿', 'TERC', '205', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'), + ('Hola! 😎', 'TEAC', '206', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'), + ('Hola! 🎓', 'TEAC', '207', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'), + ('Hola! 👀', 'TEAC', '208', 'Trujillo', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🎮', 'TERC', '209', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-30'), + ('Hola! 👾', 'TERC', '210', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-25'), + ('Hola! 🕹️', 'TERC', '211', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'), + ('Hola! 🚀', 'TEAC', '212', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🛸', 'TERC', '213', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🦾', 'Amoniaco', '63a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-15'), + ('Hola! 🛠️', 'Diseño Chillers', '64a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-30'), + ('¡Hola! 🥽', 'VRF', '65a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-02-28'), + ('¡Hola! ❄️ Estoy interesado Seminario', 'Cámaras', '66a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-05-28'), + ('¡Hola! 🤩', 'Diseño de Sistemas', '67a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'), + ('¡Hola! 🙌🏼', 'Metrado y Costeo', '68a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'), + ('¡Hola! 🥽', 'VRF', '69a', 'Lima', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'), + (' 🧊 Más información del Seminario', 'VRF', '71a', 'Arequipa', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'), + (' 📐 Más información del Seminario', 'VRF', '72a', 'Trujillo', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'), + (' 👾 Más información del Seminario en Instalación', 'VRF', '73a', 'Piura', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'), + (' 🖥️ Estoy interesado en sus Seminarios', 'Seminarios', '74a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'), + (' 🙋 Deseo más info. del Diplomado ', 'Diplomado REF', '75a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'), + ('💸 Deseo más info. del Seminario', 'CAD', '76a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'), + ('🦾 Estoy interesado Instalación de Sistemas', 'VRF', '77a', 'Lima', '-', 'Pauta_wsp', '2026-04-16', '2026-07-30'), + ('🙀 Estoy interesado Seminario', 'SUPER.OBRAS', '78a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'), + ('🛠️ más info del Seminario Diseño de Chillers', 'Diseño', '79a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'), + ('👾Estoy interesado en el Seminario Refrigeración', 'CO2', '80a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'), + ('📱 Estoy interesado Instalación', 'VRF', '81a', 'Lima', '-', 'Pauta_wsp', '2026-06-25', '2026-08-30'), + ('🧊 Más información del Seminario', 'VRF', '82a', 'Arequipa', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'), + ('📐 Más información del Seminario', 'VRF', '83a', 'Trujillo', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'), + ('👾 Más información del Seminario', 'VRF', '84a', 'Piura', '-', 'Pauta_wsp', '2026-07-01', '2026-08-30'), + ('🛠️ más info del Seminario Virtual ', 'Diseño de Chillers', '85a', 'Lima', '-', 'Pauta_wsp', '2026-07-01', '2026-09-30'), + ('Hola! 👉', 'TEAC', '214', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🤝', 'TEAC', '215', 'Lima', 'Domingo', 'Pauta_wsp', '2025-12-01', '2026-03-15'), + ('🆕 Me interesa', 'TERC', '217', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🌬️', 'TEAC', '218', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🌍', 'TERC', '219', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🎟️', 'TEAC', '220', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-03-31'), + ('Hola! 🌈', 'TERC', '221', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'), + ('¡Hola! ❄️ Me interesa el programa', 'TEAC', '222', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('Hola! 🔝', 'TERC', '223', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('🔋 Me interesa', 'TEAC', '224', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'), + ('¡Hola! 🌡️', 'TEAC', '225', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'), + ('¡Hola! ☃️', 'TERC', '226', 'Lima', 'Sábado', 'Pauta_wsp', '2026-01-01', '2026-04-15'), + ('¡Hola! 📚', 'TERC', '227', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-10'), + ('¡Hola! 🗺️', 'TEAC', '228', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-05'), + ('¡Hola! 🌤️', 'TEAC', '229', 'Piura', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-30'), + ('¡Hola! ⚡', 'TEAC', '230', 'Trujillo', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-07'), + ('¡Hola! 🎤', 'TERC', '231', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-01', '2026-05-30'), + ('¡Hola! 👾 Me interesa el programa', 'TEAC', '232', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-26', '2026-05-30'), + ('¡Hola! 🍨', 'TEAC', '233', 'Lima', 'Sábado', 'Pauta_wsp', '2026-02-01', '2026-05-30'), + ('¡Hola! 🐶 Me interesa', 'TEAC', '234', 'Piura', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'), + ('¡Hola! 💸 Me interesa', 'TERC', '235', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'), + ('¡Hola! 🗿 Me interesa', 'TERC', '236', 'Trujillo', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'), + ('¡Hola! 🦺 Me interesa', 'TEAC', '237', 'Lima', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'), + ('¡Hola! 🍾 Me interesa', 'TEAC', '238', 'Lima', 'Domingo', 'Pauta_wsp', '2026-03-01', '2026-05-30'), + ('💡 deseo más info del programa', 'TERC', '239', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'), + ('¡Hola! 🎯 Me interesa el programa', 'TERC', '240', 'Lima', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'), + ('¡Hola! 🤝 Me interesa el programa', 'TERC', '241', 'Lima', 'Sábado', 'Pauta_wsp', '2026-04-01', '2026-05-30'), + ('🪐 Me interesa el programa', 'TERC', '242', 'Piura', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'), + ('🎓 Me interesa el programa', 'TEAC', '243', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'), + ('💼 Me interesa el programa ', 'TEAC', '244', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'), + ('☃️ Me interesa el programa', 'TEAC', '245', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'), + ('🔋 Me interesa el programa', 'TERC', '246', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'), + ('😎 Me interesa el programa', 'TEAC', '247', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-16', '2026-07-30'), + ('🌈Me interesa el programa', 'TEAC', '248', 'Lima', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'), + ('⭐ Me interesa el programa', 'TEAC', '249', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'), + ('🕵️ Me interesa el programa', 'TEAC', '250', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'), + ('📈 Me interesa el programa', 'TEAC', '251', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'), + ('🗺️ Me interesa el programa', 'TEAC', '252', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-06', '2026-07-30'), + ('🎮 Me interesa el programa', 'TEAC', '253', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-07-30'), + ('🏞️ Me interesa el programa', 'TERC', '254', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'), + ('⚙️ Me interesa el programa', 'TERC', '255', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'), + ('⚡ Me interesa el programa', 'TERC', '256', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-08', '2026-12-31'), + ('🎟️ Me interesa el programa', 'TERC', '257', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'), + ('🕑 Me interesa el', 'TERC', '258', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'), + ('📚 Me interesa el programa', 'TEAC', '259', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'), + ('🤗 Me interesa el programa', 'TEAC', '260', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'), + ('🕹️ Me interesa el programa', 'TERC', '261', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-11', '2026-12-31'), + ('🐶 Me interesa el programa', 'TEAC', '262', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-11', '2026-08-30'), + ('🍨 Me interesa el programa', 'TERC', '263', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'), + ('⛱️ Me interesa el programa de Aire Acondicionado', 'TEAC', '264', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'), + ('🌤️ Me interesa el programa', 'TEAC', '265', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'), + ('⛱️ Me interesa el programa de Refrigeración comercial', 'TERC', '266', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'), + ('¡Hola! Vengo de su web, quiero saber', 'TEAC', '0', 'Lima', '-', 'Web_Whatsapp', '2025-11-01', '2026-12-31'), +] + + +# Cache en memoria de las campañas leídas de Supabase (expira cada 15 min, +# igual que el dashboard de ventas). Así las campañas nuevas aparecen solas +# sin reiniciar el servidor. +_CAMPANIAS_CACHE = None +_CAMPANIAS_TS = 0.0 +_CAMPANIAS_TTL = 900 # 15 min + + +def _campanias_supabase(): + """Lee las campañas desde la tabla 'campanias' de Supabase (misma fuente que + la cartera). Devuelve lista de tuplas (frase,cargo,codigo,sede,dia,origen,fi,ff). + Si falla o está vacía, cae a la lista hardcodeada CAMPANIAS como respaldo. + El cache expira cada 15 min para tomar campañas nuevas sin reiniciar.""" + global _CAMPANIAS_CACHE, _CAMPANIAS_TS + if _CAMPANIAS_CACHE is not None and (time.time() - _CAMPANIAS_TS < _CAMPANIAS_TTL): + return _CAMPANIAS_CACHE + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") + out = [] + if url and key: + try: + r = requests.get( + f"{url}/rest/v1/{t}", + params={"select": "frase_busqueda,cargo,codigo,sede,dia,origen,fecha_inicio,fecha_fin"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + frase = str(row.get("frase_busqueda") or "") + if not frase: + continue + out.append(( + frase, str(row.get("cargo") or ""), str(row.get("codigo") or ""), + str(row.get("sede") or ""), str(row.get("dia") or ""), + str(row.get("origen") or ""), + str(row.get("fecha_inicio") or "")[:10], + str(row.get("fecha_fin") or "")[:10], + )) + except Exception as e: + print(f"[_campanias_supabase] {e}") + if not out: + print("[_campanias_supabase] sin datos de Supabase -> uso lista hardcodeada") + out = list(CAMPANIAS) + _CAMPANIAS_CACHE = out + _CAMPANIAS_TS = time.time() + return out + + +def invalidar_campanias_cache(): + """Vacía el cache de campañas para forzar re-lectura de Supabase en la + próxima llamada. Se invoca desde refrescar_todo() cada 15 min, para que + las campañas se refresquen junto con el resto del dashboard.""" + global _CAMPANIAS_CACHE, _CAMPANIAS_TS + _CAMPANIAS_CACHE = None + _CAMPANIAS_TS = 0.0 + + +def _values_sql(): + """Construye el bloque VALUES (...) del mapa de campañas para el JOIN. + Lee las campañas desde la tabla 'campanias' de Supabase (ya no hardcodeadas).""" + filas = [] + for frase, cargo, codigo, sede, dia, origen, fi, ff in _campanias_supabase(): + f = frase.replace("'", "''") + filas.append(f"('{f}','{cargo}','{codigo}','{sede}','{dia}','{origen}','{fi}','{ff}')") + return ",\n".join(filas) + + +class DataManager: + def __init__(self): + self.pg_host = os.getenv("PG_HOST", "191.98.134.81") + self.pg_db = os.getenv("PG_DATABASE", "chatwoot_production") + self.pg_user = os.getenv("PG_USER", "postgres") + self.pg_pass = os.getenv("PG_PASSWORD", "") + self.pg_port = os.getenv("PG_PORT", "5432") + self.sql_server = os.getenv("SQL_SERVER", "191.98.134.80") + self.sql_db = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001") + self.sql_user = os.getenv("SQL_USERNAME", "") + self.sql_pass = os.getenv("SQL_PASSWORD", "") + + def pg_conn(self): + import psycopg2 + return psycopg2.connect( + host=self.pg_host, dbname=self.pg_db, user=self.pg_user, + password=self.pg_pass, port=self.pg_port, + ) + + def sql_conn(self): + import pyodbc + # Driver ODBC configurable (SQL_DRIVER). Si no se define, se autodetecta: + # en Windows suele existir "SQL Server"; en Linux/Docker, msodbcsql18/17. + driver = os.getenv("SQL_DRIVER") + if not driver: + installed = pyodbc.drivers() + if "ODBC Driver 18 for SQL Server" in installed: + driver = "ODBC Driver 18 for SQL Server" + elif "ODBC Driver 17 for SQL Server" in installed: + driver = "ODBC Driver 17 for SQL Server" + elif "FreeTDS" in installed: + driver = "FreeTDS" + else: + driver = "SQL Server" + + conn_str = ( + f"DRIVER={{{driver}}};SERVER={self.sql_server};" + f"DATABASE={self.sql_db};UID={self.sql_user};PWD={self.sql_pass};" + "Encrypt=no;TrustServerCertificate=yes;" + ) + return pyodbc.connect(conn_str) + + # ── CHATWOOT: leads de pauta (mapa de campañas, dedup por mensaje) ── + def traer_leads_chatwoot(self): + sql = f""" + SELECT DISTINCT ON (m.id) + -- Normalización IGUAL que el PBI: quitar '+51' y '+', quedando el número + REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono, + u.name AS asesor, + m.created_at AS fecha_creada, + cv.cached_label_list AS etiquetas, + map.cargo AS programa, map.sede AS sede, + map.codigo AS codigo, map.origen AS origen + FROM messages m + JOIN conversations cv ON m.conversation_id = cv.id + JOIN contacts c ON cv.contact_id = c.id + LEFT JOIN users u ON cv.assignee_id = u.id + JOIN (VALUES +{_values_sql()} + ) AS map(frase_busqueda, cargo, codigo, sede, dia, origen, fecha_inicio, fecha_fin) + ON m.content LIKE '%' || map.frase_busqueda || '%' + AND m.created_at >= CAST(map.fecha_inicio AS TIMESTAMP) + AND m.created_at <= CAST(map.fecha_fin AS TIMESTAMP) + INTERVAL '1 day' + WHERE m.sender_type = 'Contact' + ORDER BY m.id ASC + """ + conn = self.pg_conn() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + conn.close() + return filas + + # ── SQL SERVER: cursos ── + def traer_cursos(self): + sql = """ + SELECT rp.num_indice, rp.dsc_det_programa, p.dsc_programa, + rp.cod_frecuencia, rp.fch_inicio, rp.cod_estado, + (SELECT COUNT(*) FROM sgeca_matricula m + WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice + AND m.cod_estado NOT IN ('ANU')) AS Inscritos_Totales, + (SELECT COUNT(*) FROM sgeca_matricula m + WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice + AND m.cod_estado = 'RET') AS Inscritos_Retirados, + (SELECT COUNT(*) FROM sgeca_matricula m + WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice + AND m.cod_estado NOT IN ('ANU','RET','SUS')) AS Inscritos_Activos + FROM sgede_RP_programa rp + INNER JOIN sgeca_programa p ON rp.cod_programa = p.cod_programa + WHERE YEAR(rp.fch_inicio) IN (2025, 2026) + ORDER BY rp.fch_inicio ASC + """ + conn = self.sql_conn() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + conn.close() + return filas + + # ── SUPABASE: mapa num_indice → pauta (tabla basebi_programacion) ── + def traer_pauta_cursos(self): + """Devuelve dict {str(num_indice): {pauta, estado, contar}} desde Supabase. + Fuente: tabla BaseBI_Programacion de SharePoint, subida a Supabase.""" + url = os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("SUPABASE_PAUTA_KEY", "") + tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion") + if not url or not key: + return {} + try: + r = requests.get( + f"{url}/rest/v1/{tabla}", + params={"select": "num_indice,pauta_codigo,estado,contar"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, + timeout=30, + ) + r.raise_for_status() + mapa = {} + for row in r.json(): + ni = row.get("num_indice") + if ni is None: + continue + pa = row.get("pauta_codigo") + mapa[str(ni)] = { + "pauta": (str(pa).strip() if pa not in (None, "") else None), + "estado": (str(row.get("estado") or "").strip().upper()), + "contar": (str(row.get("contar") or "").strip().upper()), + } + return mapa + except Exception as e: + print(f"[traer_pauta_cursos] {e}") + return {} + + # ── SUPABASE: valores de programa/sede no identificados (para la campanita) ── + def traer_no_identificados(self): + """Compara cartera_junta vs alias_normalizacion y devuelve los valores de + programa/sede que NO están en el diccionario (correcto). + Retorna {'programa': [{'valor','veces'}], 'sede': [...]}.""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t_cart = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta") + t_alias = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion") + vacio = {"programa": [], "sede": []} + if not url or not key: + return vacio + headers = {"apikey": key, "Authorization": f"Bearer {key}"} + try: + # 1) valores conocidos (correcto) por tipo + r = requests.get(f"{url}/rest/v1/{t_alias}", + params={"select": "tipo,correcto"}, headers=headers, timeout=30) + r.raise_for_status() + conocidos = {"PROGRAMA": set(), "SEDE": set()} + for row in r.json(): + tipo = str(row.get("tipo") or "").upper() + corr = " ".join(str(row.get("correcto") or "").upper().split()) + if tipo in conocidos: + conocidos[tipo].add(corr) + + # 2) traer de cartera_junta los valores que NO estén en los conocidos + def _desconocidos(columna, tipo): + conj = conocidos.get(tipo, set()) + if not conj: + return [] + lista = ",".join('"' + v.replace('"', '') + '"' for v in conj) + rr = requests.get( + f"{url}/rest/v1/{t_cart}", + params={"select": columna, columna: f"not.in.({lista})", "limit": "5000"}, + headers=headers, timeout=30) + rr.raise_for_status() + cont = {} + for row in rr.json(): + v = " ".join(str(row.get(columna) or "").upper().split()) + cont[v] = cont.get(v, 0) + 1 + return [{"valor": k, "veces": n} + for k, n in sorted(cont.items(), key=lambda x: -x[1])] + + return {"programa": _desconocidos("programa", "PROGRAMA"), + "sede": _desconocidos("sede", "SEDE")} + except Exception as e: + print(f"[traer_no_identificados] {e}") + return vacio + + # ── SUPABASE: diccionario alias (para normalizar campanias como la cartera) ── + def traer_alias_map(self): + """{'PROGRAMA': {alias:correcto}, 'SEDE': {alias:correcto}}.""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion") + out = {"PROGRAMA": {}, "SEDE": {}} + if not url or not key: + return out + try: + r = requests.get(f"{url}/rest/v1/{t}", params={"select": "tipo,alias,correcto"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + tipo = str(row.get("tipo") or "").upper() + al = " ".join(str(row.get("alias") or "").upper().split()) + co = str(row.get("correcto") or "") + if tipo in out: + out[tipo][al] = co + except Exception as e: + print(f"[traer_alias_map] {e}") + return out + + # ── SUPABASE: asesores -> {alias_norm: correcto} y lista de correctos (los vendedores) ── + def traer_asesores(self): + """Devuelve {'map': {alias_norm: correcto}, 'lista': [correctos ordenados]} + de tipo ASESOR (solo con correcto no vacío).""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_ALIAS", "alias_normalizacion") + mapa = {}; correctos = set() + if not url or not key: + return {"map": mapa, "lista": []} + try: + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": "alias,correcto", "tipo": "eq.ASESOR"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + al = " ".join(str(row.get("alias") or "").upper().split()) + co = str(row.get("correcto") or "").strip() + if co: + mapa[al] = co + correctos.add(co) + # el propio correcto tambien es su alias + mapa[" ".join(co.upper().split())] = co + except Exception as e: + print(f"[traer_asesores] {e}") + return {"map": mapa, "lista": sorted(correctos)} + + # ── SUPABASE: mapa codigo → (sede, programa) desde campanias (normalizado) ── + def traer_campanias_map(self): + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") + out = {} + if not url or not key: + return out + alias = self.traer_alias_map() + + def _norm(v, tipo): + s = " ".join(str(v or "").upper().split()) + return alias.get(tipo, {}).get(s, s) + + try: + r = requests.get(f"{url}/rest/v1/{t}", params={"select": "codigo,sede,cargo"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + cod = " ".join(str(row.get("codigo") or "").upper().split()) + if not cod: + continue + out[cod] = (_norm(row.get("sede"), "SEDE"), _norm(row.get("cargo"), "PROGRAMA")) + except Exception as e: + print(f"[traer_campanias_map] {e}") + return out + + # ── SUPABASE: filas de cartera_junta para contar (telefono, sede, programa, fecha) ── + def traer_cartera_rows(self): + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta") + out = [] + if not url or not key: + return out + paso, desde = 1000, 0 + sel = "telefono,sede,programa,fecha_creada,asesor,es_origen,canal,origen_base" + try: + while True: + hdr = {"apikey": key, "Authorization": f"Bearer {key}", + "Range-Unit": "items", "Range": f"{desde}-{desde + paso - 1}"} + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": sel}, + headers=hdr, timeout=60) + r.raise_for_status() + data = r.json() + if not data: + break + out.extend(data) + desde += len(data) # avanza por lo realmente recibido (Supabase puede topar el bloque) + if len(data) < paso: + break + except Exception as e: + print(f"[traer_cartera_rows] {e}") + return out + + # ── SUPABASE: mapa pauta → [conjuntos de anuncios] (tabla conjunto_pauta) ── + def traer_conjunto_pauta(self): + """Devuelve dict {pauta(str): [conjunto, ...]} desde la tabla conjunto_pauta. + Una pauta puede tener varios conjuntos de anuncios.""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta") + out = {} + if not url or not key: + return out + try: + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": "conjunto,pauta"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, + timeout=30) + r.raise_for_status() + for row in r.json(): + pa = str(row.get("pauta") or "").strip() + co = " ".join(str(row.get("conjunto") or "").split()) + if not pa or not co: + continue + out.setdefault(pa, []).append(co) + except Exception as e: + print(f"[traer_conjunto_pauta] {e}") + return out + + # ── SUPABASE: asignacion manual conjunto -> sede + programa (tabla conjunto_sede_programa) ── + def traer_conjunto_sede_programa(self): + """Devuelve {conjunto_UPPER: {"sede":..., "programa":...}} desde la tabla.""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = "conjunto_sede_programa" + out = {} + if not url or not key: + return out + try: + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": "conjunto,sede,programa"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, + timeout=30) + r.raise_for_status() + for row in r.json(): + co = " ".join(str(row.get("conjunto") or "").split()) + if not co: + continue + out[co.upper()] = { + "sede": " ".join(str(row.get("sede") or "").upper().split()), + "programa": " ".join(str(row.get("programa") or "").upper().split()), + } + except Exception as e: + print(f"[traer_conjunto_sede_programa] {e}") + return out + + def guardar_conjunto_sede_programa(self, cambios): + """Upsert de asignaciones. cambios = [{conjunto, sede, programa}, ...].""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = "conjunto_sede_programa" + if not url or not key or not cambios: + return {"guardados": 0} + headers = {"apikey": key, "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "Prefer": "resolution=merge-duplicates,return=minimal"} + payload = [] + for c in cambios: + co = " ".join(str(c.get("conjunto") or "").split()) + if not co: + continue + payload.append({ + "conjunto": co, + "sede": " ".join(str(c.get("sede") or "").upper().split()) or None, + "programa": " ".join(str(c.get("programa") or "").upper().split()) or None, + }) + if not payload: + return {"guardados": 0} + r = requests.post(f"{url}/rest/v1/{t}?on_conflict=conjunto", + json=payload, headers=headers, timeout=30) + r.raise_for_status() + return {"guardados": len(payload)} + + def borrar_conjunto_sede_programa(self, conjunto): + """Borra la asignacion de un conjunto (lo deja sin sede/programa).""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = "conjunto_sede_programa" + co = " ".join(str(conjunto or "").split()) + if not url or not key or not co: + return {"borrado": 0} + headers = {"apikey": key, "Authorization": f"Bearer {key}"} + r = requests.delete(f"{url}/rest/v1/{t}", + params={"conjunto": f"eq.{co}"}, + headers=headers, timeout=30) + r.raise_for_status() + return {"borrado": 1} + + # ── GOOGLE SHEET (CSV publicado): importe gastado por conjunto de anuncios ── + def traer_meta_importe(self): + """Lee el CSV publicado de Meta_Ads_Adsets y devuelve una lista de dicts: + [{conjunto, importe, inicio}, ...] (una por fila del CSV, inicio = 'Inicio del informe'). + Se conserva la fecha para poder filtrar por periodo (Importe Pauta en el Mes).""" + import csv, io + url = os.getenv("META_CSV_URL", "") + out = [] + if not url: + return out + try: + r = requests.get(url, timeout=30) + r.raise_for_status() + r.encoding = "utf-8" + rows = list(csv.reader(io.StringIO(r.text))) + if not rows: + return out + enc = [" ".join(str(h or "").split()).lower() for h in rows[0]] + + def _idx(*claves): + for k in claves: + for i, h in enumerate(enc): + if k in h: + return i + return -1 + + # OJO: buscar "nombre del conjunto..." primero (hay tambien "ID del conjunto..."). + i_conj = _idx("nombre del conjunto de anuncios", "nombre del conjunto") + i_imp = _idx("importe gastado") + i_ini = _idx("inicio del informe") + i_res = _idx("resultados") + if i_conj < 0 or i_imp < 0: + print(f"[traer_meta_importe] columnas no encontradas: {enc}") + return out + for fila in rows[1:]: + if len(fila) <= max(i_conj, i_imp): + continue + co = " ".join(str(fila[i_conj] or "").split()) + if not co: + continue + raw = str(fila[i_imp] or "").strip().replace(",", "") + try: + val = float(raw) if raw else 0.0 + except ValueError: + val = 0.0 + rraw = str(fila[i_res] or "").strip().replace(",", "") if i_res >= 0 and len(fila) > i_res else "" + try: + res = float(rraw) if rraw else 0.0 + except ValueError: + res = 0.0 + ini = str(fila[i_ini] or "").strip() if i_ini >= 0 and len(fila) > i_ini else "" + out.append({"conjunto": co, "importe": val, "resultados": res, "inicio": ini}) + except Exception as e: + print(f"[traer_meta_importe] {e}") + return out + + # ── CHATWOOT: leads asignados (mensaje "Asignado a..."/auto-asignado, ultimo por telefono) ── + def traer_leads_asignados(self): + """Devuelve [{telefono, fecha_asignada(date, -5h), user_name}] tomando el + mensaje de asignacion mas reciente por telefono.""" + sql = """ + SELECT * FROM ( + SELECT DISTINCT ON (c.phone_number) + REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono, + m.id AS message_id, + (m.created_at - INTERVAL '5 hours') AS created_at, + COALESCE(u.name, 'Sin Asesor') AS user_name, + cv.cached_label_list AS etiquetas + FROM messages m + JOIN conversations cv ON m.conversation_id = cv.id + JOIN contacts c ON cv.contact_id = c.id + LEFT JOIN users u ON cv.assignee_id = u.id + WHERE ( + m.content LIKE 'Asignado a %' OR + m.content LIKE '% auto-asignado%' + ) + ORDER BY c.phone_number, m.id DESC + ) AS t + ORDER BY message_id DESC + """ + conn = self.pg_conn() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + conn.close() + return filas + + # ── CHATWOOT: ultima asignacion por telefono + primer mensaje del asesor (respuesta) ── + def traer_asignacion_respuesta(self): + """Por telefono: la ULTIMA asignacion (T1) y el primer mensaje posterior de un + ASESOR real (sender_id 15-25) como respuesta (T2). Fechas en hora Peru (-5h). + Devuelve [{telefono, user_id, user_name, created_at(T1), respuesta_fecha(T2)}]. + Si respuesta_fecha es None -> no respondio.""" + sql = """ + SELECT * FROM ( + SELECT DISTINCT ON (c.phone_number) + REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono, + m.id AS message_id, + (m.created_at - INTERVAL '5 hours') AS created_at, + u.id AS user_id, + COALESCE(u.name, 'Sin Asesor') AS user_name, + cv.cached_label_list AS etiquetas, + (respuesta.created_at - INTERVAL '5 hours') AS respuesta_fecha + FROM messages m + JOIN conversations cv ON m.conversation_id = cv.id + JOIN contacts c ON cv.contact_id = c.id + LEFT JOIN users u ON cv.assignee_id = u.id + LEFT JOIN LATERAL ( + SELECT m2.created_at + FROM messages m2 + WHERE m2.conversation_id = m.conversation_id + AND m2.id > m.id + AND m2.sender_id BETWEEN 15 AND 25 + ORDER BY m2.id ASC + LIMIT 1 + ) AS respuesta ON TRUE + WHERE ( + m.content LIKE 'Asignado a %' OR + m.content LIKE '% auto-asignado%' + ) + ORDER BY c.phone_number, m.id DESC + ) AS t + ORDER BY message_id DESC + """ + conn = self.pg_conn() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + conn.close() + return filas + + # ── CHATWOOT: actividad de asesores (para detectar turno del dia). ── + def traer_actividad_asesores(self): + """Mensajes ENVIADOS por asesores reales (sender_id 15-25), en hora Peru (-5h). + Sirve para detectar el turno de cada asesor cada dia por concentracion de + actividad. Devuelve [{user_id, created_at}]. Liviano: solo id + fecha.""" + sql = """ + SELECT + m.sender_id AS user_id, + (m.created_at - INTERVAL '5 hours') AS created_at + FROM messages m + WHERE m.sender_id BETWEEN 15 AND 25 + """ + conn = self.pg_conn() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + conn.close() + return filas + + # ── SUPABASE: actualizar/insertar la pauta de un conjunto en conjunto_pauta ── + def upsert_conjunto_pauta(self, conjunto, pauta): + """Si el conjunto ya existe en conjunto_pauta, actualiza su pauta; si no, lo crea. + Si pauta viene vacia, elimina el vinculo (deja el conjunto sin pauta).""" + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta") + h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"} + co = str(conjunto).strip(); pa = str(pauta or "").strip() + # ¿existe el conjunto? + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": "conjunto", "conjunto": f"eq.{co}"}, + headers=h, timeout=30) + r.raise_for_status() + existe = len(r.json()) > 0 + if not pa: + # sin pauta -> borrar vinculo si existia + if existe: + requests.delete(f"{url}/rest/v1/{t}", params={"conjunto": f"eq.{co}"}, + headers=h, timeout=30).raise_for_status() + return {"conjunto": co, "pauta": None} + if existe: + requests.patch(f"{url}/rest/v1/{t}", params={"conjunto": f"eq.{co}"}, + headers=h, json={"pauta": pa}, timeout=30).raise_for_status() + else: + requests.post(f"{url}/rest/v1/{t}", headers=h, + json={"conjunto": co, "pauta": pa}, timeout=30).raise_for_status() + return {"conjunto": co, "pauta": pa} + + # ── SUPABASE: conjuntos de anuncios SIN pauta conectada (del Sheet, no en conjunto_pauta) ── + def traer_conjuntos_sin_pauta(self): + """Lista de conjuntos del Google Sheet que NO estan en conjunto_pauta.""" + conj_pauta = self.traer_conjunto_pauta() # {pauta: [conjuntos]} + conectados = set() + for cjs in conj_pauta.values(): + for c in cjs: + conectados.add(" ".join(str(c).split()).upper()) + vistos = {} + for f in self.traer_meta_importe(): + nombre = " ".join(str(f.get("conjunto")).split()) + if not nombre: + continue + if " ".join(nombre.split()).upper() not in conectados: + vistos[nombre] = True + return sorted(vistos.keys()) + + # ── SUPABASE: programa_pautas -> {num_indice: [pautas]} (muchos-a-muchos) ── + def traer_programa_pautas(self): + url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_PROGRAMA_PAUTAS", "programa_pautas") + out = {} + if not url or not key: + return out + try: + r = requests.get(f"{url}/rest/v1/{t}", params={"select": "num_indice,pauta"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + ni = str(row.get("num_indice") or "").strip() + pa = str(row.get("pauta") or "").strip() + if ni and pa: + out.setdefault(ni, []).append(pa) + except Exception as e: + print(f"[traer_programa_pautas] {e}") + return out + + # ── SUPABASE: agregar un vinculo num_indice<->pauta en programa_pautas (upsert) ── + def agregar_programa_pauta(self, num_indice, pauta): + url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_PROGRAMA_PAUTAS", "programa_pautas") + h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"} + ni = str(num_indice).strip(); pa = str(pauta).strip() + # ¿ya existe ese par? + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": "id", "num_indice": f"eq.{ni}", "pauta": f"eq.{pa}"}, + headers=h, timeout=30) + r.raise_for_status() + if len(r.json()) > 0: + return {"num_indice": ni, "pauta": pa, "ya_existia": True} + rr = requests.post(f"{url}/rest/v1/{t}", headers=h, + json={"num_indice": ni, "pauta": pa}, timeout=30) + rr.raise_for_status() + return {"num_indice": ni, "pauta": pa, "ya_existia": False} + + # ── SUPABASE: num_indices que YA usan una pauta en basebi_programacion ── + def num_indices_de_pauta(self, pauta): + url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "") + tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion") + try: + r = requests.get(f"{url}/rest/v1/{tabla}", + params={"select": "num_indice", "pauta_codigo": f"eq.{str(pauta).strip()}"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + return [str(x.get("num_indice")).strip() for x in r.json() if x.get("num_indice") is not None] + except Exception as e: + print(f"[num_indices_de_pauta] {e}") + return [] + + # ── SUPABASE: guardar pauta y/o contar de un num_indice en basebi_programacion (upsert) ── + def guardar_pauta_basebi(self, num_indice, pauta, contar=None): + url = os.getenv("SUPABASE_PAUTA_URL", ""); key = os.getenv("SUPABASE_PAUTA_KEY", "") + tabla = os.getenv("SUPABASE_TABLA_PAUTA", "basebi_programacion") + h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"} + ni = str(num_indice).strip(); pa = str(pauta or "").strip() + campos = {} + if pa: + campos["pauta_codigo"] = pa # solo actualizar pauta si viene con valor + if contar is not None: + campos["contar"] = str(contar).strip().upper() # "SI" / "NO" + if not campos: + return {"num_indice": ni, "sin_cambios": True} + r = requests.get(f"{url}/rest/v1/{tabla}", + params={"select": "num_indice", "num_indice": f"eq.{ni}"}, + headers=h, timeout=30) + r.raise_for_status() + existe = len(r.json()) > 0 + if existe: + rr = requests.patch(f"{url}/rest/v1/{tabla}", + params={"num_indice": f"eq.{ni}"}, + headers=h, json=campos, timeout=30) + else: + rr = requests.post(f"{url}/rest/v1/{tabla}", + headers=h, json={"num_indice": ni, **campos}, timeout=30) + rr.raise_for_status() + return {"num_indice": ni, "pauta": pa, "contar": campos.get("contar"), "creado": not existe} + + # ── SUPABASE: vincular un conjunto de anuncios a una pauta (insert en conjunto_pauta) ── + def guardar_conjunto_pauta(self, conjunto, pauta): + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CONJUNTO", "conjunto_pauta") + h = {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"} + rr = requests.post(f"{url}/rest/v1/{t}", headers=h, + json={"conjunto": str(conjunto).strip(), "pauta": str(pauta).strip()}, + timeout=30) + rr.raise_for_status() + return {"conjunto": conjunto, "pauta": pauta} + + # ── CHATWOOT: plantillas enviadas (para matriz de plantillas cobradas) ── + def traer_plantillas(self): + """Devuelve [{plantilla, telefono, user_name, created_at_peru, + hora_anterior_contacto, siguiente_mensaje_1}] de mensajes de plantilla + con content_attributes vacio (ISBLANK).""" + sql = """ + SELECT + m.additional_attributes -> 'template_params' ->> 'name' AS plantilla, + REPLACE(REPLACE(c.phone_number, '+51', ''), '+', '') AS telefono, + u.name AS user_name, + (m.created_at - INTERVAL '5 hours') AS created_at_peru, + (SELECT (mp.created_at - INTERVAL '5 hours') FROM messages mp + WHERE mp.conversation_id = m.conversation_id AND mp.id < m.id + AND mp.sender_type = 'Contact' ORDER BY mp.id DESC LIMIT 1) AS hora_anterior_contacto, + (SELECT m2.content FROM messages m2 + WHERE m2.conversation_id = m.conversation_id AND m2.id > m.id + AND m2.sender_type = 'Contact' ORDER BY m2.id ASC LIMIT 1) AS siguiente_mensaje_1 + FROM messages m + JOIN conversations cv ON m.conversation_id = cv.id + JOIN contacts c ON cv.contact_id = c.id + LEFT JOIN users u ON cv.assignee_id = u.id + WHERE m.sender_type = 'User' + AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL + AND (m.content_attributes IS NULL + OR m.content_attributes::text = '{}' + OR m.content_attributes::text = 'null') + """ + conn = self.pg_conn() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + filas = [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + conn.close() + return filas + + # ── SUPABASE: campanias -> {codigo(pauta): {sede, cargo}} (crudo, sin normalizar) ── + def traer_campanias_sede_cargo(self): + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") + out = {} + if not url or not key: + return out + try: + r = requests.get(f"{url}/rest/v1/{t}", params={"select": "codigo,sede,cargo"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + cod = str(row.get("codigo") or "").strip() + if cod: + out[cod] = {"sede": str(row.get("sede") or "").strip(), + "cargo": str(row.get("cargo") or "").strip()} + except Exception as e: + print(f"[traer_campanias_sede_cargo] {e}") + return out + + # ── SUPABASE campanias: LISTAR todas (con id) para el apartado Leyenda ── + def listar_campanias(self): + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") + out = [] + if not url or not key: + return out + try: + r = requests.get(f"{url}/rest/v1/{t}", + params={"select": "id,frase_busqueda,cargo,codigo,sede,dia,origen,fecha_inicio,fecha_fin", + "order": "codigo"}, + headers={"apikey": key, "Authorization": f"Bearer {key}"}, timeout=30) + r.raise_for_status() + for row in r.json(): + out.append({ + "id": row.get("id"), + "frase_busqueda": str(row.get("frase_busqueda") or ""), + "cargo": str(row.get("cargo") or ""), + "codigo": str(row.get("codigo") or ""), + "sede": str(row.get("sede") or ""), + "dia": str(row.get("dia") or ""), + "origen": str(row.get("origen") or ""), + "fecha_inicio": str(row.get("fecha_inicio") or "")[:10], + "fecha_fin": str(row.get("fecha_fin") or "")[:10], + }) + except Exception as e: + print(f"[listar_campanias] {e}") + return out + + # ── SUPABASE campanias: AGREGAR una fila ── + def agregar_campania(self, datos): + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") + h = {"apikey": key, "Authorization": f"Bearer {key}", + "Content-Type": "application/json", "Prefer": "return=representation"} + fila = { + "frase_busqueda": str(datos.get("frase_busqueda") or "").strip(), + "cargo": str(datos.get("cargo") or "").strip(), + "codigo": str(datos.get("codigo") or "").strip(), + "sede": str(datos.get("sede") or "").strip(), + "dia": str(datos.get("dia") or "").strip(), + "origen": str(datos.get("origen") or "").strip(), + "fecha_inicio": str(datos.get("fecha_inicio") or "").strip() or None, + "fecha_fin": str(datos.get("fecha_fin") or "").strip() or None, + } + r = requests.post(f"{url}/rest/v1/{t}", headers=h, json=fila, timeout=30) + r.raise_for_status() + return r.json() + + # ── SUPABASE campanias: EDITAR una fila por id ── + def editar_campania(self, cid, datos): + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") + h = {"apikey": key, "Authorization": f"Bearer {key}", + "Content-Type": "application/json", "Prefer": "return=representation"} + fila = { + "frase_busqueda": str(datos.get("frase_busqueda") or "").strip(), + "cargo": str(datos.get("cargo") or "").strip(), + "codigo": str(datos.get("codigo") or "").strip(), + "sede": str(datos.get("sede") or "").strip(), + "dia": str(datos.get("dia") or "").strip(), + "origen": str(datos.get("origen") or "").strip(), + "fecha_inicio": str(datos.get("fecha_inicio") or "").strip() or None, + "fecha_fin": str(datos.get("fecha_fin") or "").strip() or None, + } + r = requests.patch(f"{url}/rest/v1/{t}", params={"id": f"eq.{cid}"}, + headers=h, json=fila, timeout=30) + r.raise_for_status() + return r.json() + + # ── SUPABASE campanias: BORRAR una fila por id ── + def borrar_campania(self, cid): + url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "") + key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "") + t = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias") + h = {"apikey": key, "Authorization": f"Bearer {key}"} + r = requests.delete(f"{url}/rest/v1/{t}", params={"id": f"eq.{cid}"}, headers=h, timeout=30) + r.raise_for_status() + return {"id": cid, "borrado": True} + + # ── SQL SERVER: matrículas ── + def traer_matriculas(self): + sql = """ + SELECT + sgeca_matricula.num_matricula, sgeca_matricula.num_indice, + sgeca_matricula.fch_matricula, + sgeca_matricula.cod_estado AS estado_matricula, + REPLACE(REPLACE(REPLACE(sgema_alumno.dsc_telefono_1,' ',''),'+51',''),'+','') AS dsc_telefono_1, + REPLACE(REPLACE(REPLACE(sgema_alumno.dsc_telefono_2,' ',''),'+51',''),'+','') AS dsc_telefono_2, + sgeca_matricula.cod_moneda, + ISNULL((SELECT SUM(c.imp_total - ISNULL(c.imp_dscto,0)) + FROM sgede_cronograma_matricula c + WHERE c.cod_localidad = sgeca_matricula.cod_localidad + AND c.num_matricula = sgeca_matricula.num_matricula + AND c.num_refinanciamiento = 1), 0) AS INV_NETA, + (SELECT rhuma_trabajador.dsc_apellido_paterno + ' ' + + SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' + + rhuma_trabajador.dsc_nombres + FROM rhuma_trabajador + WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor) AS dsc_vendedor, + sgeca_programa.dsc_programa, + sgede_RP_programa.dsc_det_programa AS dsc_promocion, + ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula + WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad + AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula + AND sgede_cronograma_matricula.num_refinanciamiento = 1 + AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula, + ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula + WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad + AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula + AND sgede_cronograma_matricula.num_refinanciamiento = 1 + AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1 + FROM sgeca_matricula + INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa + LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle + AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa + AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice + INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno + WHERE sgeca_matricula.cod_localidad LIKE 'SCENT' + AND sgeca_matricula.fch_matricula BETWEEN '01-11-2024 00:00:00.000' AND '31-12-2026 23:59:00.000' + AND sgeca_matricula.cod_estado <> 'ANU' + """ + conn = self.sql_conn() + try: + cur = conn.cursor() + cur.execute(sql) + cols = [d[0] for d in cur.description] + rows = cur.fetchall() + finally: + conn.close() + return [dict(zip(cols, r)) for r in rows] diff --git a/backend/leads_logic.py b/backend/leads_logic.py new file mode 100644 index 0000000..b3efe68 --- /dev/null +++ b/backend/leads_logic.py @@ -0,0 +1,894 @@ +# backend/leads_logic.py +""" +Lógica del módulo LEADS. Traduce las medidas DAX del PBI a Python. +Columnas calculadas replicadas: + - Cantidad_Veces -> 1ª aparición de un teléfono = lead único + - Ultima_Etiqueta / ESTADO/OBJECION -> limpieza de cached_label_list + - Tipo_Programa / Sede -> clasificación por dsc_programa +Medidas replicadas: + - Leads_Totales_Pauta_Unico, Leads_Procesados_Pauta_Unicos, + Leads_Procesados_Contactados_Unicos, % Procesados, % Contactados, + Total_Matriculas, Cant_inscritos_Mes, Ocupabilidad, + Cursos (Inicios / Suspendidos / Ya Iniciados), Matrículas por día, + tabla Estado/Objeción. +""" +from datetime import datetime, date + +# Etiquetas de sistema que se eliminan para hallar el estado/objeción real (igual que el DAX) +ETIQUETAS_SISTEMA = { + "atención_humana", "negociación", "supervisor", "grupo_arequipa", + "grupo_trujillo", "sin_respuesta", +} +META_POR_TIPO = { + "PROGRAMAS TEAC": 22, "PROGRAMAS TERC": 22, + "PROVINCIA TEAC": 18, "PROVINCIA TERC": 18, + "SEMINARIOS": 15, +} + + +# ── Helpers de fecha ──────────────────────────────────────────── +def _to_date(v): + if v is None: + return None + if isinstance(v, datetime): + return v.date() + if isinstance(v, date): + return v + s = str(v)[:10] + for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"): + try: + return datetime.strptime(s, fmt).date() + except Exception: + pass + return None + + +def _en_periodo(d, ano, mes, dia): + """True si la fecha cae en el filtro (ano/mes/dia; 'TODOS' = sin filtrar).""" + if d is None: + return False + if ano not in ("TODOS", None) and d.year != int(ano): + return False + if mes not in ("TODOS", None) and d.month != int(mes): + return False + if dia not in ("TODOS", None) and d.day != int(dia): + return False + return True + + +# ── Clasificadores (igual que columnas calculadas DAX) ────────── +def clasificar_tipo_programa(dsc_programa): + up = str(dsc_programa or "").upper() + otros = ["CERTIFICACIÓN", "CERTIFICACION", "CURSO A MEDIDA", "MASTERCLASS", + "TALLER DE REFRIGERACIÓN DOM", "GESTIÓN DE VENTA", "GESTION DE VENTA"] + for o in otros: + if o.upper() in up: + return "OTROS" + return None # el tipo PROGRAMAS/PROVINCIA se arma con sede+programa abajo + + +def clasificar_sede(dsc_programa): + up = str(dsc_programa or "").upper() + if "AREQUIPA" in up: return "AREQUIPA" + if "TRUJILLO" in up: return "TRUJILLO" + if "PIURA" in up: return "PIURA" + return "LIMA" + + +def sede_por_codigo_map(): + """{codigo: SEDE} tomado del mapa de campañas (la sede que le corresponde a + cada código, SIN reasignar por asesor). Usado por el filtro SEDE de leads, + para que coincida con 'sede del código' (no con sede_act).""" + from data_manager_v2 import CAMPANIAS + out = {} + for fila in CAMPANIAS: + cod = str(fila[2]).strip() # índice 2 = codigo + sede = str(fila[3]).strip().upper() # índice 3 = sede + if cod: + out[cod] = sede + return out + + +# ── Grupo de PROGRAMA para el filtro: TEAC / TERC / SEMINARIOS / OTROS ── +def grupo_programa_curso(dsc_programa): + """Reduce el tipo detallado a 4 grupos para el filtro PROGRAMA (cursos/matrículas). + TEAC = PROGRAMAS TEAC + PROVINCIA TEAC + TERC = PROGRAMAS TERC + PROVINCIA TERC + SEMINARIOS = SEMINARIOS + OTROS = OTROS + CARRERA""" + tp = tipo_programa_curso(dsc_programa) + if tp in ("PROGRAMAS TEAC", "PROVINCIA TEAC"): + return "TEAC" + if tp in ("PROGRAMAS TERC", "PROVINCIA TERC"): + return "TERC" + if tp == "SEMINARIOS": + return "SEMINARIOS" + return "OTROS" # OTROS + CARRERA + + +def grupo_programa_lead(cargo): + """Grupo de programa para un LEAD, según su 'cargo' (del mapa de campañas). + TEAC → TEAC, TERC → TERC, resto (VRF/CO2/DIPLOMADO/etc.) → SEMINARIOS.""" + c = str(cargo or "").strip().upper() + if c == "TEAC": + return "TEAC" + if c == "TERC": + return "TERC" + return "SEMINARIOS" + + +def grupo_tipo_cohorte(tp): + """Reduce 'PROGRAMAS TEAC'/'PROVINCIA TEAC'/... a los 3 grupos de la tabla por Sede: + TEAC = PROGRAMAS/PROVINCIA TEAC, TERC = PROGRAMAS/PROVINCIA TERC, resto = SEMINARIOS.""" + t = str(tp or "").upper() + if "TEAC" in t: + return "TEAC" + if "TERC" in t: + return "TERC" + return "SEMINARIOS" + + +# Réplica del DAX Sede_Act: la sede del lead se decide por el ASESOR; +# si el asesor no está en la lista, usa la sede de la campaña. +_SEDE_POR_ASESOR = { + "almendra peralta": "Arequipa", + "juan carlos aguilar": "Piura", + "diego lázaro": "Trujillo", + "diego lazaro": "Trujillo", + "verónica la rosa": "Lima", + "veronica la rosa": "Lima", + "dayana balabarca": "Lima", + "milagros vargas": "Lima", + "carmen montoya": "Lima", + "diana chávez": "Lima", + "diana chavez": "Lima", + "copito rivera": "Lima", +} + + +def sede_act(asesor, sede_campania): + return _SEDE_POR_ASESOR.get(str(asesor or "").strip().lower(), sede_campania) + + +def tipo_programa_cohorte(sede, programa_cat): + """Igual al DAX 'Tipo Programa': combina sede + (TEAC/TERC) -> categoría.""" + s = str(sede or "").upper() + p = str(programa_cat or "").upper() + if s == "LIMA" and p == "TEAC": return "PROGRAMAS TEAC" + if s == "LIMA" and p == "TERC": return "PROGRAMAS TERC" + if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TEAC": return "PROVINCIA TEAC" + if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TERC": return "PROVINCIA TERC" + return "SEMINARIOS" + + +def tipo_programa_curso(dsc_programa): + """Réplica EXACTA del DAX Tipo_Programa (columna calculada de Fact_SQL_Base_Cursos). + Usa CONTAINSSTRING en el mismo ORDEN que el PBI (el orden importa).""" + p = str(dsc_programa or "") + def has(s): # CONTAINSSTRING es sensible a may/min en DAX; comparamos tal cual + return s in p + + # 1) OTROS + if (has("CERTIFICACIÓN") or has("CURSO A MEDIDA") or has("MASTERCLASS") + or has("TALLER DE REFRIGERACIÓN DOM") + or has("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN")): + return "OTROS" + if (has("CERTIFICACION FUNDAMENTOS DE CHILLER MODULAR INVERT- LG") + or has("PIURA - CERTIFICACIÓN MIDEA: TECNOLOGIA INVERTER") + or has("AREQUIPA - CERTIFICACIÓN MIDEA - TECNOLOGÍA INVERTER") + or has("CERTIFICACIÓN: OPERACIÓN Y MANTENIMIENTO DE REFRIGERADORES MIDEA") + or has("TRUJILLO - CERTIFICACIÓN MIDEA: TECNOLOGÍA INVERTER") + or has("CERTIFICACIÓN MIDEA: AIRE ACOND INVERTER (INTROD., FUNC., INST. Y MANT.)")): + return "OTROS" + # 2) CARRERA + if has("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION"): + return "CARRERA" + # 3) PROVINCIA TEAC + if (has("TRUJILLO - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO") + or has("PIURA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO") + or has("AREQUIPA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO") + or has("TRUJILLO - VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")): + return "PROVINCIA TEAC" + # 4) PROVINCIA TERC + if (has("AREQUIPA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL") + or has("PIURA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL") + or has("TRUJILLO - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")): + return "PROVINCIA TERC" + # 5) SEMINARIOS + if (has("CO2") or has("CERTIF") or has("SEM:") or has("MASTERCLASS") or has("DISEÑO") + or has("DIPLOMADO") or has("SEM.") or has("DUCTOS") or has("SEMINARIO") or has("SEMINARIOS")): + return "SEMINARIOS" + # 6) PROGRAMAS TEAC (Lima) + if (has("MANTENIMIENTO EN AIRE ACONDICIONADO") + or has("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO") + or has("TALLER - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO") + or has("CONTINUIDAD-TECNICO ESPECIALISTA EN AA (4M)") + or has("VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")): + return "PROGRAMAS TEAC" + # 7) PROGRAMAS TERC (Lima) + if (has("INSTALACION EN REFRIGERACION COMERCIAL") + or has("MANTENIMIENTO EN REFRIGERACIÓN COMERCIAL") + or has("CONTINUIDAD-TECNICO ESPECIALISTA EN REFRIGERACIÓN COMERCIAL (4M)") + or has("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL")): + return "PROGRAMAS TERC" + return "SEMINARIOS" + + +def meta_curso(dsc_programa): + """Meta por curso según Tipo_Programa (igual que el DAX Meta_Curso): + PROGRAMAS TEAC/TERC=22, PROVINCIA TEAC/TERC=18, resto=15.""" + tp = tipo_programa_curso(dsc_programa) + if tp in ("PROGRAMAS TEAC", "PROGRAMAS TERC"): + return 22 + if tp in ("PROVINCIA TEAC", "PROVINCIA TERC"): + return 18 + return 15 + + +# ── ESTADO/OBJECION (réplica EXACTA del DAX: cadena de SUBSTITUTE) ── +# El DAX quita comas y espacios (pega todas las etiquetas), elimina las de +# sistema, y luego reduce combinaciones concatenadas a un estado final. +_SUST = [ + ("atención_humana", ""), ("negociación", ""), ("supervisor", ""), + ("grupo_arequipa", ""), ("grupo_trujillo", ""), ("sin_respuesta", ""), + ("interesadovendido", "vendido"), ("próximo_inicio", "proxima_fecha"), + ("inicio", ""), ("aprobación", ""), ("negociacion", ""), ("no_acepta", ""), + ("pendiente", ""), + ("revisando_informaciónsólo_consulta", "sólo_consulta"), + ("contacto_iniciadovendido", "vendido"), + ("revisando_informacióninteresado", "interesado"), + ("revisando_informaciónvendido", "vendido"), + ("sólo_consultainteresado", "interesado"), + ("revisando_informaciónno_califica", "no_califica"), + ("interesadopor_pagar", "por_pagar"), + ("revisando_informaciónprecio_elevado", "precio_elevado"), + ("proxima_fechavendido", "vendido"), + ("sólo_consultavendido", "vendido"), + ("vendidointeresado", "vendido"), + ("revisando_informaciónproxima_fecha", "proxima_fecha"), + ("contacto_iniciadosólo_consulta", "sólo_consulta"), +] + + +def estado_objecion(etiquetas): + """Réplica del DAX ESTADO/OBJECION (versión de 28 sustituciones del PBI).""" + if etiquetas is None: + return "no trabajado" + s = str(etiquetas).replace(",", "").replace(" ", "") + for buscar, reemplazar in _SUST: + s = s.replace(buscar, reemplazar) + s = s.strip() + return s if s else "no trabajado" + + +# ── Ultima_Etiqueta (réplica EXACTA del DAX, usada para "Total Contactados") ── +_ULTIMA_NO_TRABAJADO = { + "", "(en blanco)", "aprobación", "atencion humana", "atención humana", + "grupo arequipa", "grupo trujillo", "importacion masiva", "inicio", + "negociacion", "sin respuesta", "pendiente", +} + + +def ultima_etiqueta(etiquetas): + if etiquetas is None or str(etiquetas).strip() == "": + return "NO TRABAJADO" + partes = str(etiquetas).split(", ") + limpia = partes[-1].replace("_", " ").strip() + if limpia in _ULTIMA_NO_TRABAJADO: + return "NO TRABAJADO" + return limpia if limpia else "NO TRABAJADO" + + +# ════════════════════════════════════════════════════════════════ +# PROCESAMIENTO DE LEADS (calcula Cantidad_Veces y agrupa) +# ════════════════════════════════════════════════════════════════ +def procesar_leads(filas_chatwoot): + """Marca el lead único (1ª vez de cada teléfono) y arma estructura limpia. + Ordenamos por fecha para que la 1ª aparición de cada teléfono sea la 'única'.""" + # Ordenar por fecha ascendente (el query puede venir ordenado por id) + filas_chatwoot = sorted(filas_chatwoot, key=lambda f: (_to_date(f.get("fecha_creada")) or date.min)) + # Teléfonos de prueba a excluir (igual que el PBI: 924374783) + EXCLUIR = {"924374783"} + vistos = set() + out = [] + for f in filas_chatwoot: + tel = str(f.get("telefono") or "").strip() + if not tel or tel in EXCLUIR: + continue + es_unico = tel not in vistos + vistos.add(tel) + fecha = _to_date(f.get("fecha_creada")) + asesor = (f.get("asesor") or "").strip() + sede_campania = (f.get("sede") or "").strip() or "SIN SEDE" + sede = sede_act(asesor, sede_campania) # réplica DAX Sede_Act (reasigna por asesor) + cargo = (f.get("programa") or "").strip() # TEAC / TERC / VRF / etc. (del mapa campañas) + out.append({ + "telefono": tel, + "asesor": asesor, + "fecha": fecha, + "estado": estado_objecion(f.get("etiquetas")), + "ultima": ultima_etiqueta(f.get("etiquetas")), + "sede": sede, + "cargo": cargo, # TEAC / TERC / VRF / CO2 / etc. + "tipo_programa": tipo_programa_cohorte(sede, cargo), # PROGRAMAS/PROVINCIA/SEMINARIOS + "codigo": (f.get("codigo") or "").strip() or "-", # código de campaña + "es_unico": es_unico, + }) + return out + + +# ════════════════════════════════════════════════════════════════ +# KPIs DEL MÓDULO LEADS +# ════════════════════════════════════════════════════════════════ +def kpis_leads(leads, cursos, matriculas, ano, mes, dia): + # Leads en el periodo (solo únicos = Cantidad_Veces == 1) + leads_periodo = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)] + + recibidos = len(leads_periodo) + procesados = sum(1 for l in leads_periodo if l["asesor"]) + contactados = sum(1 for l in leads_periodo if l["asesor"] and l["ultima"] != "NO TRABAJADO") + + pct_procesados = (procesados / recibidos) if recibidos > 0 else 0.0 + pct_contactados = (contactados / procesados) if procesados > 0 else 0.0 + + # ── Cursos del periodo (por fecha de inicio) ── + cursos_periodo = [c for c in cursos if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)] + cursos_programados = len(cursos_periodo) + suspendidos = sum(1 for c in cursos_periodo if str(c.get("cod_estado", "")).strip().upper() == "SUS") + hoy = date.today() + iniciados = sum(1 for c in cursos_periodo + if str(c.get("cod_estado", "")).strip().upper() != "SUS" + and (_to_date(c.get("fch_inicio")) or hoy) <= hoy) + + # ── Total matriculados (= medida Matriculas_Totales del PBI) ── + # Cuenta matrículas ALU/PRE cuya FECHA DE MATRÍCULA cae en el mes, + # excluyendo 2 vendedoras. El Calendario del PBI va por fch_matricula. + VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"} + total_matriculados = sum( + 1 for m in matriculas + if str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE") + and str(m.get("dsc_vendedor", "")).strip() not in VENDEDORES_EXCLUIDOS + and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia) + ) + # Ocupabilidad (PBI): SUM(Inscritos) / SUM(Meta). + # Excluye cursos suspendidos (SUS) y num_indice excluidos manualmente. + # (Ver FUTUROS_CAMBIOS.md: mover esta lista a GitHub/Supabase) + CURSOS_EXCLUIDOS = {"1154", "1121"} + cursos_ocup = [c for c in cursos_periodo + if str(c.get("cod_estado", "")).strip().upper() != "SUS" + and str(c.get("num_indice", "")).strip() not in CURSOS_EXCLUIDOS] + sum_meta = sum(meta_curso(c.get("dsc_programa")) for c in cursos_ocup) + sum_inscritos_cursos = sum(int(c.get("Inscritos_Totales", 0) or 0) for c in cursos_ocup) + + # ── Matrículas en los cursos del mes ── + # Matrículas ALU/PRE que: (1) se hicieron en el mes (fch_matricula) Y + # (2) son de un curso que INICIA en el mes (dsc_promocion = dsc_det_programa + # de los cursos del periodo). Réplica de Cant_inscritos_Mes del PBI. + promos_periodo = {str(c.get("dsc_det_programa", "")).strip() + for c in cursos_periodo if str(c.get("dsc_det_programa", "")).strip()} + mats_mes = [m for m in matriculas + if str(m.get("dsc_promocion", "")).strip() in promos_periodo + and str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE") + and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)] + matriculas_mes = len({str(m.get("num_matricula")) for m in mats_mes}) + + ocupabilidad = (sum_inscritos_cursos / sum_meta) if sum_meta > 0 else 0.0 + + return { + "leads_recibidos": recibidos, + "leads_procesados": procesados, + "leads_contactados": contactados, + "pct_procesados": round(pct_procesados * 100, 2), + "pct_contactados": round(pct_contactados * 100, 2), + "total_matriculados": total_matriculados, + "matriculas_mes": matriculas_mes, + "ocupabilidad": round(ocupabilidad * 100, 2), + "cursos_programados": cursos_programados, + "cursos_reprogramados": 0, # requiere SharePoint (fase 2) + "cursos_suspendidos": suspendidos, + "cursos_iniciados": iniciados, + } + + +# ── Tabla Estado/Objeción (agrupa por Ultima_Etiqueta + desglose por asesor) ── +def tabla_estado_objecion(leads, ano, mes, dia): + leads_periodo = [l for l in leads if l["es_unico"] + and l["asesor"] and _en_periodo(l["fecha"], ano, mes, dia)] + agg = {} # estado -> total + por_asesor = {} # estado -> {asesor: cantidad} + telefonos = {} # (estado, asesor) -> [telefonos] + for l in leads_periodo: + est = l["ultima"] or "NO TRABAJADO" + ase = l["asesor"] or "SIN ASESOR" + agg[est] = agg.get(est, 0) + 1 + por_asesor.setdefault(est, {}) + por_asesor[est][ase] = por_asesor[est].get(ase, 0) + 1 + telefonos.setdefault((est, ase), []).append(l["telefono"]) + filas = [] + for estado in sorted(agg.keys(), key=lambda x: -agg[x]): # mayor a menor por cantidad + asesores = sorted(por_asesor[estado].items(), key=lambda x: -x[1]) + filas.append({ + "estado": estado, + "cantidad": agg[estado], + "asesores": [ + {"asesor": a, "cantidad": c, + "telefonos": sorted(telefonos.get((estado, a), []))} + for a, c in asesores + ], + }) + total = sum(agg.values()) + return {"filas": filas, "total": total} + + +# ── Matrículas por día (gráfico de línea) ── +# Cuenta TODAS las matrículas del día (misma base que Total Matriculados): +# estado ALU/PRE, excluyendo las 2 vendedoras, por día de fch_matricula. +def matriculas_por_dia(matriculas, cursos, ano, mes, dia): + VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"} + conteo = {} # dia -> total + por_tipo = {} # dia -> {tipo_programa: cantidad} + for m in matriculas: + if str(m.get("estado_matricula", "")).strip().upper() not in ("ALU", "PRE"): + continue + if str(m.get("dsc_vendedor", "")).strip() in VENDEDORES_EXCLUIDOS: + continue + d = _to_date(m.get("fch_matricula")) + if not _en_periodo(d, ano, mes, dia): + continue + conteo[d.day] = conteo.get(d.day, 0) + 1 + tp = tipo_programa_curso(m.get("dsc_programa")) # TEAC/TERC/PROVINCIA.../SEMINARIOS/OTROS + por_tipo.setdefault(d.day, {}) + por_tipo[d.day][tp] = por_tipo[d.day].get(tp, 0) + 1 + + def _detalle(day): + items = sorted(por_tipo.get(day, {}).items(), key=lambda x: -x[1]) + return [{"tipo": t, "cantidad": c} for t, c in items] + + import calendar + if mes not in ("TODOS", None) and ano not in ("TODOS", None): + ndias = calendar.monthrange(int(ano), int(mes))[1] + return [{"dia": d, "cantidad": conteo.get(d, 0), "detalle": _detalle(d)} + for d in range(1, ndias + 1)] + return [{"dia": d, "cantidad": conteo[d], "detalle": _detalle(d)} + for d in sorted(conteo.keys())] + + +# ── Gráfico "Leads por Programa": serie diaria Totales vs Procesados ── +def leads_por_dia(leads, ano, mes, dia): + lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)] + tot = {} # dia -> totales + proc = {} # dia -> procesados (con asesor) + for l in lp: + d = l["fecha"].day + tot[d] = tot.get(d, 0) + 1 + if l["asesor"]: + proc[d] = proc.get(d, 0) + 1 + import calendar + if mes not in ("TODOS", None) and ano not in ("TODOS", None): + ndias = calendar.monthrange(int(ano), int(mes))[1] + dias = range(1, ndias + 1) + else: + dias = sorted(set(list(tot.keys()) + list(proc.keys()))) + return [{"dia": d, "totales": tot.get(d, 0), "procesados": proc.get(d, 0)} for d in dias] + + +# ── Tabla por Sede_Act -> Tipo_Programa con 4 medidas (cohorte de leads) ── +def tabla_pauta(leads, matriculas, ano, mes, dia, importe_por_pauta_periodo=None, + resultados_por_pauta_periodo=None, campanias_sede_cargo=None): + importe_por_pauta_periodo = importe_por_pauta_periodo or {} + resultados_por_pauta_periodo = resultados_por_pauta_periodo or {} + campanias_sede_cargo = campanias_sede_cargo or {} + # 1) Leads únicos del periodo con su teléfono, fecha, sede y tipo + lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)] + # mapa teléfono -> fecha del lead (para "matrícula posterior al lead") + fecha_lead = {} + for l in lp: + fecha_lead.setdefault(l["telefono"], l["fecha"]) + + # 2) Matrículas que cruzan por teléfono y son POSTERIORES a la fecha del lead + # (réplica de Matriculas_Cohorte_Lead e Inversion_x_Pauta_Cohorte) + mat_por_tel = {} # telefono -> {"mats": set(num_matricula), "inv": float} + for m in matriculas: + tel = str(m.get("dsc_telefono_1") or "").strip() + if tel not in fecha_lead: + continue + fm = _to_date(m.get("fch_matricula")) + fl = fecha_lead[tel] + if not fm or not fl or not (fm > fl): + continue + # INV_NETA_FINAL: si DOL, ×3.34 + try: inv = float(m.get("INV_NETA", 0) or 0) + except: inv = 0.0 + if str(m.get("cod_moneda", "")).strip().upper() == "DOL": + inv *= 3.34 + e = mat_por_tel.setdefault(tel, {"mats": set(), "inv": 0.0}) + e["mats"].add(str(m.get("num_matricula"))) + e["inv"] += inv + + # 3) Agrupar por Sede -> Tipo_Programa -> Código + def _nuevo(): + return {"recibidos": 0, "procesados": 0, "mats": set(), "inv": 0.0} + grupos = {} # sede -> tipo -> codigo -> métricas + for l in lp: + sede = l["sede"]; tp = grupo_tipo_cohorte(l["tipo_programa"]); cod = l.get("codigo", "-"); tel = l["telefono"] + g = grupos.setdefault(sede, {}).setdefault(tp, {}).setdefault(cod, _nuevo()) + g["recibidos"] += 1 + if l["asesor"]: + g["procesados"] += 1 + if tel in mat_por_tel: + g["mats"] |= mat_por_tel[tel]["mats"] + g["inv"] += mat_por_tel[tel]["inv"] + + def _fila(d): + return {"recibidos": d["recibidos"], "procesados": d["procesados"], + "matriculas": len(d["mats"]), "inversion": round(d["inv"], 0)} + + # IMPORTE y RESULTADOS por PAUTA -> se asignan a su (sede, tipo) de CAMPANIAS, + # una sola vez por pauta (no por sede del lead), para NO duplicar. + # imp_st[(sede,tipo)][cod] = importe ; res_st[(sede,tipo)][cod] = resultados + imp_st = {} + res_st = {} + for cod, imp_val in importe_por_pauta_periodo.items(): + info = campanias_sede_cargo.get(str(cod).strip()) + if not info: + continue + se = str(info.get("sede") or "").upper() + tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo"))) + imp_st.setdefault((se, tp), {})[str(cod).strip()] = float(imp_val or 0.0) + for cod, res_val in resultados_por_pauta_periodo.items(): + info = campanias_sede_cargo.get(str(cod).strip()) + if not info: + continue + se = str(info.get("sede") or "").upper() + tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo"))) + res_st.setdefault((se, tp), {})[str(cod).strip()] = int(res_val or 0) + + filas = [] + for sede in sorted(grupos.keys()): + subfilas = [] + s_rec = s_proc = 0; s_mats = set(); s_inv = 0.0; s_imp = 0.0; s_res = 0 + for tp in sorted(grupos[sede].keys()): + cods = grupos[sede][tp] + imp_map = imp_st.get((sede.upper(), tp), {}) # importe por pauta de esta sede/tipo + res_map = res_st.get((sede.upper(), tp), {}) + # métricas del tipo (sumando sus códigos) + t_rec = t_proc = 0; t_mats = set(); t_inv = 0.0; t_imp = 0.0; t_res = 0 + codigos = [] + for cod in sorted(cods.keys()): + d = cods[cod] + imp_cod = float(imp_map.get(str(cod).strip(), 0.0)) + res_cod = int(res_map.get(str(cod).strip(), 0)) + codigos.append({"codigo": cod, **_fila(d), "importe": round(imp_cod, 2), + "resultados": res_cod}) + t_rec += d["recibidos"]; t_proc += d["procesados"] + t_mats |= d["mats"]; t_inv += d["inv"]; t_imp += imp_cod; t_res += res_cod + subfilas.append({ + "tipo": tp, "recibidos": t_rec, "procesados": t_proc, + "matriculas": len(t_mats), "inversion": round(t_inv, 0), + "importe": round(t_imp, 2), "resultados": t_res, "codigos": codigos, + }) + s_rec += t_rec; s_proc += t_proc; s_mats |= t_mats; s_inv += t_inv; s_imp += t_imp; s_res += t_res + filas.append({ + "sede": sede, "recibidos": s_rec, "procesados": s_proc, + "matriculas": len(s_mats), "inversion": round(s_inv, 0), + "importe": round(s_imp, 2), "resultados": s_res, "subfilas": subfilas, + }) + # Totales + t_rec = sum(f["recibidos"] for f in filas) + t_proc = sum(f["procesados"] for f in filas) + t_inv = round(sum(f["inversion"] for f in filas), 0) + # Total de importe/resultados: suma de TODAS las pautas que estan en campanias + # (una vez cada una), aunque no tengan fila con leads. Asi el total no se pierde. + t_imp = round(sum(float(v or 0.0) for cod, v in importe_por_pauta_periodo.items() + if str(cod).strip() in campanias_sede_cargo), 2) + t_res = sum(int(v or 0) for cod, v in resultados_por_pauta_periodo.items() + if str(cod).strip() in campanias_sede_cargo) + # matrículas total: unión global + all_mats = set() + for tel, e in mat_por_tel.items(): + all_mats |= e["mats"] + return {"filas": filas, "total": {"recibidos": t_rec, "procesados": t_proc, + "matriculas": len(all_mats), "inversion": t_inv, + "importe": t_imp, "resultados": t_res}} + + +# ── Personalizado: replica la columna Fact_SQL_Base_Cursos[Personalizado] (M) ── +# Base = dsc_det_programa, limpiando saltos de línea, luego cadena de reemplazos. +_PERS_REEMPLAZOS = [ + ("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO", "TEAC"), + ("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL", "TERC"), + ("TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL", "TERC"), + ("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION ", "CARRERA"), + ("VIRTUAL SEMINARIO SUPERVISION DE OBRAS EN AIRE ACONDICIONADO", "SUPERVISION DE OBRAS"), + ("SEMINARIO APLICACIÓN DE VARIADORES DE FRECUENCIA EN SISTEMAS HVAC", "VARIADORES DE FRECUENCIA"), + ("VIRTUAL - SISTEMAS DE REFRIGERACION CON CO2 - FASE SUBCRITICA Y TRANSCRITICA", "REFRIGERACION CON CO2"), + ("VIRTUAL SEMINARIO DISEÑO DE CAMARAS DE REFRIGERACION CON SISTEMAS CON FREON", "DISEÑO DE CAMARAS"), + ("VIRTUAL SEM. DETERMINACIÓN DE CAPACIDAD DE EQUIPOS DE AIRE ACONDICIONADO- CARGAS TÉRMICAS -(A/A)", "CARGAS TERMICAS"), + ("VIRTUAL SEMINARIO DIBUJO TECNICO Y DISEÑO ASISTIDO POR COMPUTADORA (CAD) PARA HVAC -(A/A)", "DIBUJO TECNICO (CAD)"), + ("VIRTUAL SEMINARIO: SISTEMAS DE REFRIGERACIÓN INDUSTRIAL POR AMONIACO (NH3)", "AMONIACO (NH3)"), + ("SEMINARIO VIRTUAL METRADO , COSTEO Y PRESUPUESTOS DE HVAC (AA)", "METRADO, COSTEO Y PRESUPUESTOS"), + ("CARRERA TECNICA LIMA GESTION DE EMPRESAS", "GESTION DE EMPRESAS"), + ("VIRTUAL SEMINARIO DISEÑO DE CHILLERS PARA PROCESOS INDUSTRIALES DE REFRIGERACIÓN", "DISEÑO DE CHILLERS"), + ("VIRTUAL SEMINARIO PRACTICO VENTILACION DE SOTANO Y PREZURIZACION DE ESCALERA -(A/A)", "VENTILACION DE SOTANO"), + ("SEMINARIO USO DE SOFTWARE EN CÁLCULOS DE AIRE ACONDICIONADO", "SOFTWARE EN CALCULOS DE A/A"), + ("DISEÑO DE SISTEMAS DE AIRE ACONDICIONADO", "DISEÑO DE SISTEMAS DE A/A"), + ("SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE VRV/VRF - SAB", "VOLUMEN VARIABLE VRF"), + # Seminarios VRF por sede y CO2 → nombre corto (la frecuencia se conserva al final) + ("PIURA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "PIURA - VRF"), + ("AREQUIPA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "AREQUIPA - VRF"), + ("TRUJILLO - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "TRUJILLO - VRF"), + ("VIRTUAL - DISEÑO Y OPERACIÓN DE SISTEMAS DE REFRIGERACIÓN CON CO2 EN FASE SUBCRITICA Y TRANSCRITICA", "CO2"), + # Reemplazos sobre Nombre_Programa_Detalle (bloque ductos y otros) + ("VIRTUAL - DIMENSIONAMIENTO DE", ""), + ("METÁLICOS EN AIRE ACONDICIONADO Y VENTILACIÓN", ""), + ("MASTERCLASS: PROGRAMACIÓN DE CONTROLADORES PARA REFRIGERACIÓN", "PROGRAMACION DE CONTROLADORES"), + ("DIPLOMADO INTERNACIONAL DE AIRE ACONDICIONADO", "DIPLOMADO DE A/A"), + # Reemplazos finales (sedes/carreras/masterclass) + ("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "AREQUIPA - CARRERA - TARDE"), + ("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "PIURA - CARRERA - TARDE"), + ("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "TRUJILLO - CARRERA - TARDE"), + ("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "AREQUIPA - CARRERA - MAÑANA"), + ("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "PIURA - CARRERA - MAÑANA"), + ("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "TRUJILLO - CARRERA - MAÑANA"), + ("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - NOC", "GESTIÓN DE VENTA - NOC"), + ("MASTERCLASS SELECCIÓN E INSTALACIÓN DE TARJETAS ELECTRÓNICAS UNIVERSALES EN EQUIPOS DE A/A .INVERTER - MAN", "MASTERCLASS TARJETAS UNIVERSALES - MAN"), + ("MASTERCLASS: SISTEMAS DE A/A CON VRF - SAB", "MASTERCLASS: A/A CON VRF - SAB"), + ("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - TAR", "GESTIÓN DE VENTA - TAR"), +] + + +def personalizado_curso(dsc_programa, cod_frecuencia=""): + """Replica Fact_SQL_Base_Cursos[Personalizado] del PBI: + base = dsc_programa & ' - ' & cod_frecuencia (NO dsc_det_programa).""" + s = f"{str(dsc_programa or '')} - {str(cod_frecuencia or '')}" + # Text.Clean + reemplazar saltos de línea por espacio, luego Trim + s = s.replace("\r", " ").replace("\n", " ") + s = "".join(ch for ch in s if ch >= " " or ch == " ") # Text.Clean (quita control) + s = s.strip() + for buscar, reemplazo in _PERS_REEMPLAZOS: + s = s.replace(buscar, reemplazo) + # colapsar espacios múltiples que pudieran quedar de reemplazos vacíos + s = " ".join(s.split()) + return s + + +# ── Matriz por Curso (num_indice): Personalizado + métricas de leads por pauta ── +# NOTA: las métricas de leads cruzan por el CÓDIGO DE PAUTA del curso, que viene de +# SharePoint (no disponible aún). Por eso salen en 0 hasta conectar esa fuente. +# Ver FUTUROS_CAMBIOS.md punto 5. Lo que sí se calcula: Personalizado y Fecha Inicio. +def matriz_cursos(cursos, leads, ano, mes, dia, pauta_map=None, + campanias_map=None, cartera_all=None, cartera_asig=None, + mat_por_indice=None, cartera_tels=None, cartera_origen=None, + importe_por_pauta=None, importe_por_pauta_periodo=None, + cartera_canal=None, conjunto_por_pauta=None, pautas_por_indice=None): + pauta_map = pauta_map or {} + pautas_por_indice = pautas_por_indice or {} # {num_indice: [pautas]} (muchos-a-muchos) + campanias_map = campanias_map or {} + cartera_all = cartera_all or {} + cartera_asig = cartera_asig or {} + mat_por_indice = mat_por_indice or {} + cartera_tels = cartera_tels or set() + cartera_origen = cartera_origen or {} + importe_por_pauta = importe_por_pauta or {} + importe_por_pauta_periodo = importe_por_pauta_periodo or {} + conjunto_por_pauta = conjunto_por_pauta or {} # {pauta: [conjuntos de anuncios]} + # cartera_canal = {"PAUTA": {"all","asig","tels"}, "OTROS": {...}} para el desglose por canal + cartera_canal = cartera_canal or {} + + CURSOS_EXCLUIDOS = {"1121", "1154"} + + def _excluir(c): + ni = str(c.get("num_indice", "")) + if ni in CURSOS_EXCLUIDOS: + return True + info = pauta_map.get(ni) + if not info: + return False # sin info en Supabase → no se excluye + # Excluir SUSPENDIDO o contar = NO + return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO" + + cursos_periodo = [c for c in cursos + if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia) + and not _excluir(c)] + + # Leads únicos agrupados por codigo (= Pauta). Históricos y del mes (filtro fecha). + leads_unicos = [l for l in leads if l.get("es_unico")] + hist_por_cod = {} # codigo → set teléfonos + hist_ases_cod = {} # codigo → set teléfonos con asesor + mes_por_cod = {} # codigo → set teléfonos (en periodo filtrado) + mes_ases_cod = {} # codigo → set teléfonos con asesor (en periodo) + for l in leads_unicos: + cod = l.get("codigo", "-") + tel = l["telefono"] + hist_por_cod.setdefault(cod, set()).add(tel) + if l.get("asesor"): + hist_ases_cod.setdefault(cod, set()).add(tel) + if _en_periodo(l["fecha"], ano, mes, dia): + mes_por_cod.setdefault(cod, set()).add(tel) + if l.get("asesor"): + mes_ases_cod.setdefault(cod, set()).add(tel) + + # Construir filas con sus medidas + pre = [] + for c in sorted(cursos_periodo, key=lambda x: (_to_date(x.get("fch_inicio")) or date.min)): + nombre = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia")) + fch = _to_date(c.get("fch_inicio")) + ni = str(c.get("num_indice", "")).strip() + # LISTA de pautas del num_indice (muchos-a-muchos). Si no hay en la tabla nueva, + # usa la de basebi (compatibilidad). + cods = pautas_por_indice.get(ni) + if not cods: + _info = pauta_map.get(ni) + cods = [_info.get("pauta")] if _info and _info.get("pauta") else [] + cods = [str(x).strip() for x in cods if x] + # Leads = union de telefonos de TODAS las pautas (sin duplicar) + def _union(mapa): + s = set() + for cd in cods: + s |= mapa.get(cd, set()) + return len(s) + nuevos = _union(hist_por_cod) + nuevos_a = _union(hist_ases_cod) + nuevos_m = _union(mes_por_cod) + nuevos_ma = _union(mes_ases_cod) + pre.append({ + "num_indice": ni, + "personalizado": nombre, + "fch": fch, + "pauta": cods[0] if cods else None, # pauta principal (para mostrar) + "pautas": cods, # todas las pautas (para sumar) + "fecha_inicio": fch.strftime("%d/%m/%Y") if fch else "", + "leads_nuevos": nuevos, + "leads_nuevos_asesor": nuevos_a, + "leads_nuevos_mes": nuevos_m, + "leads_nuevos_mes_asesor": nuevos_ma, + }) + + # Índice HISTÓRICO completo (TODOS los cursos, sin filtro de fecha) por + # personalizado → lista de (fecha, leads_nuevos). Para el acumulado. + hist_cursos = {} + for c in cursos: + if _excluir(c): + continue + nom = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia")) + f = _to_date(c.get("fch_inicio")) + info = pauta_map.get(str(c.get("num_indice", ""))) + cd = info.get("pauta") if info else None + nv = len(hist_por_cod.get(cd, ())) if cd else 0 + hist_cursos.setdefault(nom, []).append((f, nv)) + + # Leads_Acumulados_Historico: suma de leads_nuevos de TODOS los cursos del + # MISMO personalizado con fecha <= la del curso actual (histórico completo). + filas = [] + for r in pre: + acum = sum(nv for (f, nv) in hist_cursos.get(r["personalizado"], []) + if f and r["fch"] and f <= r["fch"]) + # CARTERA TOTAL: teléfonos únicos de cartera_junta con la misma SEDE+PROGRAMA + # del curso (según su pauta → campanias) cuya fecha_creada <= fch_inicio del curso. + # cartera_total = todos; cartera_total_asig = solo con asesor asignado. + cartera_total = 0 + cartera_total_asig = 0 + cod = r.get("pauta") + cods = r.get("pautas") or ([cod] if cod else []) + if cods and r["fch"]: + # Cartera = union de telefonos de la sede+programa de TODAS las pautas del num_indice + tels_all = {}; tels_asig = {} + for cd in cods: + sp = campanias_map.get(str(cd).upper()) + if not sp: + continue + for tel, fx in cartera_all.get(sp, {}).items(): + if fx and (tel not in tels_all or fx < tels_all[tel]): + tels_all[tel] = fx + for tel, fx in cartera_asig.get(sp, {}).items(): + if fx and (tel not in tels_asig or fx < tels_asig[tel]): + tels_asig[tel] = fx + cartera_total = sum(1 for fx in tels_all.values() if fx <= r["fch"]) + cartera_total_asig = sum(1 for fx in tels_asig.values() if fx <= r["fch"]) + # MATRÍCULAS NO IDENTIFICADAS: matrículas de este curso (num_indice) cuyo teléfono + # (tel_1, o tel_2 si el 1 está vacío) NO aparece en cartera_junta. Sin filtro de fecha. + _ni = r["num_indice"] + _ni = _ni[:-2] if _ni.endswith(".0") else _ni + _mats = mat_por_indice.get(_ni, []) # lista de (telefono, fecha_matricula) + _tels_no_iden = [ph for (ph, fm) in _mats if ph not in cartera_tels] + matriculas_no_iden = len(_tels_no_iden) + # MATRICULAS LEADS NUEVOS: matrícula hecha dentro de 45 días DESPUÉS del origen + # del lead (registro es_origen=SI de ese teléfono en la cartera). + mat_leads_nuevos = 0 + mat_leads_antiguos = 0 + _tels_nuevos = [] + _tels_antiguos = [] + for (ph, fm) in _mats: + if not ph or not fm: + continue + forig = cartera_origen.get(ph) + if forig is None: + continue + dias = (fm - forig).days + if 0 <= dias <= 45: + mat_leads_nuevos += 1 + _tels_nuevos.append(ph) + else: + # >45 días, o matrícula anterior al origen del lead (dias<0) → Antiguo + mat_leads_antiguos += 1 + _tels_antiguos.append(ph) + # IMPORTE PAUTA: gasto de Meta Ads sumando TODAS las pautas del num_indice. + importe_pauta = round(sum(float(importe_por_pauta.get(str(cd), 0.0)) for cd in cods), 2) + # IMPORTE PAUTA EN EL MES: igual, pero solo del periodo filtrado. + importe_pauta_mes = round(sum(float(importe_por_pauta_periodo.get(str(cd), 0.0)) for cd in cods), 2) + + # ── SUBFILAS por CANAL (PAUTA / OTROS) ── mismo criterio que la fila padre + # (SEDE+PROGRAMA vía campanias, misma fecha) + filtro de canal desde cartera. + def _subfila(clave): + cc = cartera_canal.get(clave, {}) + c_all = cc.get("all", {}); c_asig = cc.get("asig", {}); c_tels = cc.get("tels", set()) + ct = ct_asig = 0 + l_rec = l_proc = 0 + l_rec_mes = l_proc_mes = 0 + if cod and r["fch"]: + sp = campanias_map.get(str(cod).upper()) + if sp: + fa = c_all.get(sp, {}) + fg = c_asig.get(sp, {}) + # Cartera Total/Asig del canal: fecha_creada <= inicio del curso (igual que padre) + ct = sum(1 for fx in fa.values() if fx and fx <= r["fch"]) + ct_asig = sum(1 for fx in fg.values() if fx and fx <= r["fch"]) + # L. Recibidos/Procesados del canal = misma cartera del canal (mismo corte) + l_rec = ct + l_proc = ct_asig + # "del Mes" = fecha_creada en el periodo filtrado + l_rec_mes = sum(1 for fx in fa.values() if _en_periodo(fx, ano, mes, dia)) + l_proc_mes = sum(1 for fx in fg.values() if _en_periodo(fx, ano, mes, dia)) + # Matrículas del curso, clasificadas por canal: + # - NO identificadas (tel NO en cartera) -> todas a OTROS. + # - identificadas -> al canal de su telefono (c_tels), regla 45 dias. + mt_noiden = mt_nuevos = mt_antiguos = 0 + for (ph, fm) in _mats: + if not ph: + continue + en_cartera = ph in cartera_tels + if not en_cartera: + # no identificada: solo cuenta en la subfila OTROS + if clave == "OTROS": + mt_noiden += 1 + continue + # identificada: solo cuenta si su tel pertenece a ESTE canal + if ph not in c_tels: + continue + forig = cartera_origen.get(ph) + if fm and forig: + d = (fm - forig).days + if 0 <= d <= 45: mt_nuevos += 1 + else: mt_antiguos += 1 + return {"canal": clave, "cartera_total": ct, "cartera_total_asig": ct_asig, + "leads_nuevos": l_rec, "leads_nuevos_asesor": l_proc, + "leads_nuevos_mes": l_rec_mes, "leads_nuevos_mes_asesor": l_proc_mes, + "matriculas_no_iden": mt_noiden, "mat_leads_nuevos": mt_nuevos, + "mat_leads_antiguos": mt_antiguos} + subfilas = [_subfila("PAUTA"), _subfila("WEB"), _subfila("OTROS")] if cartera_canal else [] + + filas.append({ + "num_indice": r["num_indice"], + "personalizado": r["personalizado"], + "fecha_inicio": r["fecha_inicio"], + "pauta": r.get("pauta"), + "importe_pauta": importe_pauta, + "importe_pauta_mes": importe_pauta_mes, + "cartera_total": cartera_total, + "cartera_total_asig": cartera_total_asig, + "matriculas_no_iden": matriculas_no_iden, + "matriculas_no_iden_tels": _tels_no_iden, + "mat_leads_nuevos": mat_leads_nuevos, + "mat_leads_antiguos": mat_leads_antiguos, + "mat_leads_nuevos_tels": _tels_nuevos, + "mat_leads_antiguos_tels": _tels_antiguos, + "leads_acumulados": acum, + "leads_nuevos": r["leads_nuevos"], + "leads_nuevos_asesor": r["leads_nuevos_asesor"], + "leads_nuevos_mes": r["leads_nuevos_mes"], + "leads_nuevos_mes_asesor": r["leads_nuevos_mes_asesor"], + "subfilas_canal": subfilas, + "conjuntos": [cj for cd in cods for cj in conjunto_por_pauta.get(str(cd).strip(), [])], + "contar": (pauta_map.get(str(r["num_indice"]).strip()) or {}).get("contar") or "SI", + }) + return {"filas": filas} diff --git a/backend/main.py b/backend/main.py index 02185c4..6615707 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,483 +1,290 @@ # backend/main.py -""" -API REST del Dashboard — FastAPI. -Reutiliza la lógica de negocio existente en modules/ y core/. -Cada endpoint devuelve JSON; el frontend (React) lo consume. -""" -from fastapi import FastAPI, Query, HTTPException +"""API REST del Dashboard de LEADS (FastAPI).""" +from fastapi import FastAPI, Query, HTTPException, Body from fastapi.middleware.cors import CORSMiddleware from datetime import datetime import uvicorn +import os +from dotenv import load_dotenv + +# Cargar variables de entorno desde .env +load_dotenv() + import services -from cache_manager import start_background_refresh, cache_invalidate, cache_stats +from cache_manager import start_background_refresh, cache_stats -app = FastAPI(title="Dashboard API", version="1.0") +app = FastAPI(title="Dashboard Leads API", version="1.0") + + +# Orígenes CORS configurables +cors_env = os.getenv("CORS_ORIGINS", "*") +origins = [o.strip() for o in cors_env.split(",") if o.strip() and o.strip() != "*"] -# CORS — permite que el frontend React (otro puerto) consuma la API app.add_middleware( CORSMiddleware, - allow_origins=["*"], # en red local está bien; en producción restringir + allow_origins=origins if origins else ["*"], + allow_origin_regex=".*", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# ── Arranque: precarga + refresco en segundo plano ───────────────────────── @app.on_event("startup") def _startup(): - print("[startup] Precargando datos iniciales...") - try: - services.precargar_todo() - print("[startup] Precarga completa.") - except Exception as e: - print(f"[startup] Precarga falló: {e}") - # Refresco cada 4 min en segundo plano (TTL del caché es 5 min) + # Precarga en SEGUNDO PLANO (hilo aparte) para no bloquear el arranque del server. + import threading + def _precarga_bg(): + print("[startup] Precargando datos de Leads (2do plano)...") + try: + services.precargar_todo() + print("[startup] Precarga completa.") + except Exception as e: + print(f"[startup] Precarga falló: {e}") + threading.Thread(target=_precarga_bg, daemon=True).start() + # Otros General en un hilo aparte (sus consultas son mas pesadas) + def _precarga_otros_bg(): + try: + services.precargar_otros_general() + except Exception as e: + print(f"[startup] Precarga Otros falló: {e}") + threading.Thread(target=_precarga_otros_bg, daemon=True).start() start_background_refresh(services.refrescar_todo, interval=900) -# ── Salud / utilidades ───────────────────────────────────────────────────── @app.get("/api/health") def health(): return {"status": "ok", "hora": datetime.now().isoformat()} @app.get("/api/cache/stats") -def cache_estadisticas(): +def stats(): return cache_stats() @app.post("/api/cache/refresh") -def cache_refresh(): +def refresh(): services.refrescar_todo() return {"status": "refrescado"} -@app.post("/api/cache/refresh-manual") -def cache_refresh_manual(clave: str = None): - """Botón 'Actualizar': re-lee las bases para la vista actual (clave) al instante, - y recachea el resto en segundo plano.""" - return services.refrescar_manual(clave) +@app.get("/api/leads/filtros") +def get_filtros(): + try: + return services.opciones_filtros() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) -@app.get("/api/ultima-actualizacion") -def api_ultima_actualizacion(): - return services.ultima_actualizacion() - - -@app.post("/api/precarga/modulo") -def api_precarga_modulo(modulo: str, ano: int): - """Calienta en 2º plano la tabla principal del módulo para el año indicado.""" - return services.precargar_modulo(modulo, ano) - - -@app.get("/api/precarga/estado") -def api_precarga_estado(modulo: str): - """Progreso de la precarga (para el aviso 'ya cargó todo').""" - return services.precarga_estado(modulo) - - -@app.get("/api/periodo-actual") -def periodo_actual(): - dm = services.get_dm() - return {"ano": dm.get_current_year(), "mes": dm.get_current_month()} - - -# ── OCUPABILIDAD ─────────────────────────────────────────────────────────── -@app.get("/api/ocupabilidad") -def get_ocupabilidad( - ano: int = Query(...), - mes: int = Query(...), - sede: str = Query("TODOS"), +@app.get("/api/leads") +def get_leads( + ano: str = Query("TODOS"), + mes: str = Query("TODOS"), + dia: str = Query("TODOS"), programa: str = Query("TODOS"), -): - try: - return services.ocupabilidad(ano, mes, sede, programa) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/api/ocupabilidad/alumnos") -def get_ocupabilidad_alumnos(num_indice: str = Query(...)): - try: - return services.ocupabilidad_alumnos(num_indice) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -# ── VENTAS ───────────────────────────────────────────────────────────────── -@app.get("/api/ventas") -def get_ventas(ano: int = Query(...), mes: int = Query(...), - sede: str = Query("TODOS"), programa: str = Query("TODOS"), - con_overrides: bool = Query(False)): - try: - return services.ventas(ano, mes, sede, programa, con_overrides) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/api/ventas/detalle") -def get_ventas_detalle( - vendedor: str = Query(...), - ano: int = Query(...), - mes: int = Query(...), - tipo: str = Query("Venta Inscritos"), sede: str = Query("TODOS"), - programa: str = Query("TODOS"), ): try: - return services.ventas_detalle(vendedor, ano, mes, tipo, sede, programa) + return services.leads_dashboard(ano, mes, dia, programa, sede) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -# ── COBRANZA ─────────────────────────────────────────────────────────────── -@app.get("/api/cobranza") -def get_cobranza( - ano: int = Query(...), - mes: int = Query(...), - sectorista: str = Query("TODOS"), - agrupacion: str = Query("SEDE"), - sede_filtro: str = Query("TODOS"), - prog_filtro: str = Query("TODOS"), - venc_hasta: str = Query("TODOS"), +@app.get("/api/vendedores") +def get_vendedores( + ano: str = Query("TODOS"), + mes: str = Query("TODOS"), + dia: str = Query("TODOS"), ): try: - return services.cobranza(ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta) + return services.vendedores_dashboard(ano, mes, dia) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@app.get("/api/cobranza/detalle") -def get_cobranza_detalle( - grupo: str = Query(...), - ano: int = Query(...), - mes: int = Query(...), - sectorista: str = Query("TODOS"), - agrupacion: str = Query("PROGRAMA"), - prog_filtro: str = Query("TODOS"), - sede_filtro: str = Query("TODOS"), - venc_hasta: str = Query("TODOS"), +@app.get("/api/roas") +def get_roas( + ano: str = Query("TODOS"), + mes: str = Query("TODOS"), + dia: str = Query("TODOS"), ): try: - return services.cobranza_detalle(grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta) + return services.roas_dashboard(ano, mes, dia) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -# ── RENTABILIDAD ─────────────────────────────────────────────────────────── -@app.get("/api/rentabilidad") -def get_rentabilidad( - ano: int = Query(...), - mes: int = Query(...), - sede: str = Query("TODOS"), - programa: str = Query("TODOS"), +@app.get("/api/conjuntos-sin-sede") +def get_conjuntos_sin_sede(): + try: + return services.conjuntos_sin_sede() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/conjuntos-sin-sede/guardar") +def post_conjunto_sede(body: dict = Body(...)): + try: + cambios = body.get("cambios") or [] + return services.guardar_conjunto_sede(cambios) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/conjuntos-sin-sede/borrar") +def post_borrar_conjunto_sede(body: dict = Body(...)): + try: + return services.borrar_conjunto_sede(body.get("conjunto") or "") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/otros-general") +def get_otros_general( + ano: str = Query("TODOS"), + mes: str = Query("TODOS"), + dia: str = Query("TODOS"), ): try: - return services.rentabilidad(ano, mes, sede, programa) + return services.otros_general(ano, mes, dia) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@app.get("/api/rentabilidad/detalle") -def get_rentabilidad_detalle( - programa: str = Query(...), - ano: int = Query(...), - mes: int = Query(...), -): +@app.get("/api/conjuntos-sin-pauta") +def get_conjuntos_sin_pauta(): try: - return services.rentabilidad_detalle(programa, ano, mes) + return {"conjuntos": services.conjuntos_sin_pauta()} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@app.get("/api/rentabilidad/costos") -def get_rentabilidad_costos( - programa: str = Query(...), - ano: int = Query(...), - mes: int = Query(...), -): +@app.get("/api/programas-ocultos") +def get_programas_ocultos(): try: - return services.rentabilidad_costos(programa, ano, mes) + return services.programas_ocultos() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -# ── SALDO PENDIENTE ──────────────────────────────────────────────────────── -@app.get("/api/saldo-pendiente") -def get_saldo_pendiente(tipo_cuota: str = Query("1° Cuota")): +@app.post("/api/programas-ocultos/encender") +def post_encender_programas(body: dict = Body(...)): try: - return services.saldo_pendiente(tipo_cuota) + nis = body.get("num_indices") or [] + return services.encender_programas(nis) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -# ── GUARDAR COSTOS ───────────────────────────────────────────────────────── -from fastapi import Body - -@app.post("/api/rentabilidad/costos/guardar") -def post_guardar_costos(payload: dict = Body(...)): +@app.get("/api/programas-disponibles") +def get_programas_disponibles(): try: - num_indice = payload.get("num_indice", "") - costos_inicial = payload.get("inicial", {}) - costos_actual = payload.get("actual", {}) - ok = services.guardar_costos(num_indice, costos_inicial, costos_actual) - if ok: - return {"status": "ok"} - raise HTTPException(status_code=500, detail="No se pudo guardar (verifica Supabase)") + return services.programas_disponibles() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/leyenda-anuncios") +def get_leyenda(): + try: + return services.leyenda_anuncios() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/leyenda-anuncios/guardar") +def post_leyenda(body: dict = Body(...)): + try: + cambios = body.get("cambios") or [] + return services.guardar_leyenda(cambios) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ── +@app.get("/api/campanias") +def get_campanias(): + try: + return {"filas": services.campanias_listar()} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/campanias/agregar") +def post_campania(body: dict = Body(...)): + try: + return services.campania_agregar(body or {}) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/campanias/editar") +def post_editar_campania(body: dict = Body(...)): + try: + cid = body.get("id") + return services.campania_editar(cid, body or {}) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/campanias/borrar") +def post_borrar_campania(body: dict = Body(...)): + try: + return services.campania_borrar(body.get("id")) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/pauta/uso") +def get_uso_pauta(pauta: str = Query(...), excluir: str = Query("")): + try: + return services.uso_de_pauta(pauta, excluir) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/curso/guardar-pauta") +def post_guardar_pauta(body: dict = Body(...)): + try: + ni = str(body.get("num_indice", "")).strip() + pa = str(body.get("pauta", "")).strip() + cj = body.get("conjunto") or None + contar = body.get("contar") # "SI" / "NO" / None + if not ni: + raise HTTPException(status_code=400, detail="num_indice es obligatorio") + return services.guardar_edicion_curso(ni, pa, cj, contar) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@app.post("/api/cobranza/clasificar-sede") -def post_clasificar_sede(payload: dict = Body(...)): +@app.get("/api/ultima-actualizacion") +def get_ultima_actualizacion(): try: - nombres = payload.get("programas", []) - return {"mapa": services.clasificar_programas(nombres)} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/api/cobranza/clasificar-full") -def post_clasificar_full(payload: dict = Body(...)): - """Devuelve {programa: {sede, filtro}} usando el JSON REPLACE_META.""" - try: - nombres = payload.get("programas", []) - return {"mapa": services.clasificar_programas_full(nombres)} + return services.ultima_actualizacion() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@app.get("/api/debug/supabase-inicios") -def debug_supabase_inicios(ano: int, mes: int): - """Diagnóstico: muestra qué hay en Supabase y por qué (no) se inyecta el rojo.""" +@app.get("/api/leads/alertas") +def get_alertas(): + """Valores de programa/sede en cartera_junta que NO están en el diccionario.""" try: - dm = services.get_dm() - # 1) ¿Supabase conectado? - conectado = dm.supabase_client is not None - error_conexion = getattr(dm, "supabase_error", None) - # 2) Fechas guardadas en Supabase - fechas = dm.obtener_fechas_originales() - # 3) Índices que SQL trae este mes - crudos = dm.ejecutar_consulta_cursos(str(ano), str(mes)) - indices_sql = sorted({str(f.get("num_indice","")).strip() for f in (crudos or [])}) - # 4) Para cada fecha en Supabase, calcular su mes y si coincide con el filtrado - analisis = [] - for idx, info in fechas.items(): - fecha_orig = info.get("fecha","") if isinstance(info, dict) else str(info) - mes_orig = "" - if "/" in fecha_orig: - p = fecha_orig.split("/") - if len(p) >= 2: mes_orig = str(int(p[1])) - elif "-" in fecha_orig: - p = fecha_orig.split("-") - if len(p) >= 2: mes_orig = str(int(p[1])) - analisis.append({ - "num_indice": idx, - "fecha_supabase": fecha_orig, - "mes_detectado": mes_orig, - "mes_filtrado": str(int(mes)), - "coincide_mes": mes_orig == str(int(mes)), - "esta_en_sql_este_mes": idx in indices_sql, - "deberia_salir_rojo": (mes_orig == str(int(mes))) and (idx not in indices_sql), - }) - return { - "supabase_conectado": conectado, - "error_conexion": error_conexion, - "total_fechas_en_supabase": len(fechas), - "indices_en_sql_este_mes": indices_sql, - "analisis": analisis, - } + return services.alertas() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@app.get("/api/cobranza/detalle-todos") -def get_cobranza_detalle_todos(ano: int, mes: int, sectorista: str = "TODOS"): +@app.get("/api/debug/cursos") +def debug_cursos(ano: str = "2026", mes: str = "1"): + """Diagnóstico: lista cursos del periodo con inscritos, tipo y meta.""" try: - return services.cobranza_detalle_todos(ano, mes, sectorista) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -# ── USUARIOS ─────────────────────────────────────────────────────────────── -@app.get("/api/alumno/estado") -def get_alumno_estado(matricula: str): - try: - return services.alumno_estado_obtener(matricula) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.get("/api/alumnos/por-retirar") -def get_alumnos_por_retirar(): - try: - return services.alumnos_por_retirar() - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/api/alumno/estado/guardar") -def post_alumno_estado(payload: dict = Body(...)): - try: - return services.alumno_estado_guardar( - payload.get("matricula",""), - payload.get("estado","EN CURSO"), - payload.get("observaciones",[]), - payload.get("usuario")) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.get("/api/usuarios") -def get_usuarios(): - try: - return services.usuarios_listar() - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/api/usuarios/crear") -def post_usuario_crear(payload: dict = Body(...)): - try: - return services.usuarios_crear( - payload.get("email",""), payload.get("password",""), - payload.get("nombre",""), payload.get("rol","COBRANZA")) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/api/usuarios/rol") -def post_usuario_rol(payload: dict = Body(...)): - try: - return services.usuarios_actualizar_rol(payload.get("id",""), payload.get("rol","")) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/api/usuarios/eliminar") -def post_usuario_eliminar(payload: dict = Body(...)): - try: - return services.usuarios_eliminar(payload.get("id","")) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -# ── VENDEDORES MANUALES (Comisiones) ─────────────────────────────────────── -@app.get("/api/comisiones/vendedores") -def get_vend_manuales(ano: int, mes: int): - try: - return services.vendedores_manuales_listar(ano, mes) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/api/comisiones/vendedores/crear") -def post_vend_manual_crear(payload: dict = Body(...)): - try: - return services.vendedores_manuales_crear( - payload.get("nombre",""), payload.get("descripcion",""), - payload.get("fch_emision",""), payload.get("monto",0), - payload.get("ano"), payload.get("mes")) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/api/comisiones/vendedores/eliminar") -def post_vend_manual_eliminar(payload: dict = Body(...)): - try: - return services.vendedores_manuales_eliminar(payload.get("id","")) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/api/comisiones/override/guardar") -def post_com_override_guardar(payload: dict = Body(...)): - try: - return services.comisiones_override_guardar(payload.get("registros", [])) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/api/comisiones/override/restaurar") -def post_com_override_restaurar(payload: dict = Body(...)): - try: - return services.comisiones_override_restaurar(payload.get("num_matricula","")) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/api/comisiones/vendedores/actualizar") -def post_vend_manual_actualizar(payload: dict = Body(...)): - try: - return services.vendedores_manuales_actualizar( - payload.get("id",""), payload.get("descripcion",""), - payload.get("fch_emision",""), payload.get("monto",0)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/api/comisiones/vendedores/lote") -def post_vend_manual_lote(payload: dict = Body(...)): - try: - return services.vendedores_manuales_guardar_lote( - payload.get("nombre",""), payload.get("ano"), payload.get("mes"), - payload.get("filas", [])) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/api/comisiones/detalle-todos") -def get_comisiones_detalle_todos(ano: int, mes: int): - try: - return services.comisiones_detalle_todos(ano, mes) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/api/comisiones/config") -def get_comisiones_config(ano: int = Query(...), mes: int = Query(...)): - try: - return services.comisiones_config_listar(ano, mes) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/api/comisiones/config/guardar") -def post_comisiones_config(payload: dict = Body(...)): - try: - return services.comisiones_config_guardar(payload.get("ano"), payload.get("mes"), payload.get("filas", [])) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -# ── ASESORES (Chatwoot) ──────────────────────────────────────────────────── -CHATWOOT_URL = "https://gestor.escueladerefrigeracion.edu.pe" -ACCESS_TOKEN = "4anazHvZnvKLtup8biu5Zuoh" -ACCOUNT_ID = "1" -CHATWOOT_HEADERS = {"api_access_token": ACCESS_TOKEN, "Content-Type": "application/json"} - - -@app.get("/api/asesores") -def get_asesores(): - import requests - try: - url = f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents" - r = requests.get(url, headers=CHATWOOT_HEADERS, timeout=10) - r.raise_for_status() - return {"agentes": r.json()} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/api/asesores/estado") -def set_asesor_estado(agent_id: int = Query(...), online: bool = Query(...)): - import requests, time - url = f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents/{agent_id}" - estado = "online" if online else "offline" - try: - requests.patch(url, headers=CHATWOOT_HEADERS, json={"auto_offline": False}, timeout=10) - time.sleep(0.5) - r = requests.patch(url, headers=CHATWOOT_HEADERS, - json={"availability_status": estado, "availability": estado}, timeout=10) - r.raise_for_status() - return {"status": "ok", "estado": estado} + return services.debug_cursos(ano, mes) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) if __name__ == "__main__": - uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False) + uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False) diff --git a/backend/modules/__init__.py b/backend/modules/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/modules/asesores/__init__.py b/backend/modules/asesores/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/modules/asesores/processor.py b/backend/modules/asesores/processor.py deleted file mode 100644 index c793669..0000000 --- a/backend/modules/asesores/processor.py +++ /dev/null @@ -1,86 +0,0 @@ -# modules/asesores/processor.py -import requests -import time - -class AsesoresProcessor: - def __init__(self): - # Tus credenciales maestras de Chatwoot - self.chatwoot_url = "https://gestor.escueladerefrigeracion.edu.pe" - self.access_token = "4anazHvZnvKLtup8biu5Zuoh" - self.account_id = "1" - - def obtener_agentes_chatwoot(self): - url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents" - headers = { - "api_access_token": self.access_token, - "Content-Type": "application/json" - } - try: - response = requests.get(url, headers=headers, timeout=10) - response.raise_for_status() - agentes = response.json() - - # ================================================================= - # 🕵️‍♂️ ESCÁNER DEBUG (Se imprimirá en tu consola negra) - # ================================================================= - print("\n" + "="*60) - print("🕵️‍♂️ ESCÁNER: REVISANDO 'DESCONEXIÓN AUTOMÁTICA'") - print("="*60) - for ag in agentes: - nombre = ag.get('available_name') or ag.get('name', 'Desconocido') - estado = ag.get('availability_status', 'offline') - auto_offline = ag.get('auto_offline', 'Desconocido') - print(f"👤 {nombre} | Estado: {estado} | Radar: {auto_offline}") - print("="*60 + "\n") - - return agentes - except Exception: - return [] - - # ========================================================================= - # MOTOR DE ACTUALIZACIÓN (EL COMBO DE 2 GOLPES) - # ========================================================================= - def cambiar_estado_agente(self, agent_id, is_online): - url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents/{agent_id}" - headers = { - "api_access_token": self.access_token, - "Content-Type": "application/json" - } - - estado_str = "online" if is_online else "offline" - - try: - # ----------------------------------------------------------------- - # GOLPE 1: Desactivar el botón automático primero - # ----------------------------------------------------------------- - payload_radar = { - "auto_offline": False - } - requests.patch(url, headers=headers, json=payload_radar, timeout=10) - - # Le damos 0.5 segundos a la base de datos de Chatwoot para que - # asimile que el radar de este usuario acaba de ser destruido. - time.sleep(0.5) - - # ----------------------------------------------------------------- - # GOLPE 2: El código viejo y confiable para cambiar el estado - # ----------------------------------------------------------------- - payload_estado = { - "availability_status": estado_str, - "availability": estado_str - } - response = requests.patch(url, headers=headers, json=payload_estado, timeout=10) - - if response.status_code == 500: - return False, "Error interno del servidor Chatwoot." - - response.raise_for_status() - - # ⏳ Le damos 1.5 segundos para que le avise a la computadora - # del asesor que su estado acaba de cambiar en pantalla. - time.sleep(1.5) - - return True, "Orden ejecutada con el combo de 2 pasos" - - except Exception as e: - return False, f"Fallo de red: {str(e)}" \ No newline at end of file diff --git a/backend/modules/cobranza/__init__.py b/backend/modules/cobranza/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/modules/cobranza/logic.py b/backend/modules/cobranza/logic.py deleted file mode 100644 index 1e6487a..0000000 --- a/backend/modules/cobranza/logic.py +++ /dev/null @@ -1,231 +0,0 @@ -# modules/cobranza/logic.py -import pandas as pd -from .processor import CobranzaProcessor - -class CobranzaLogic: - def __init__(self, data_manager): - self.data_manager = data_manager - self.processor = CobranzaProcessor(data_manager) - - def obtener_lista_sectoristas(self, ano, mes): - return self.processor.obtener_lista_sectoristas(ano, mes) - - def obtener_datos_tabla(self, ano, mes, sectorista="TODOS", agrupacion="SEDE", - sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta=None): - datos_brutos = self.processor.obtener_datos_procesados(ano, mes, sectorista, agrupacion, - sede_filtro, prog_filtro, venc_hasta) - - sheet_data = [] - t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0 - - for d in datos_brutos: - grupo = d.get('GRUPO', '') - frecuencia = d.get('FRECUENCIA', '-') - num_cuota = d.get('NUM_CUOTA', '-') - fecha_venc = d.get('FCH_VENC_MODA', '-') - - cta_ant = float(d.get('CTA_COB_ANT', 0.0)) - cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0)) - cta_tot = float(d.get('TOTAL_CTA_COB', 0.0)) - cob_ant = float(d.get('COB_ANT', 0.0)) - r1 = float(d.get('RATIO_1', 0.0)) - cob_cur = float(d.get('COB_MES_CURSO', 0.0)) - r2 = float(d.get('RATIO_2', 0.0)) - cob_tot = float(d.get('TOTAL_COBRADO', 0.0)) - r3 = float(d.get('RATIO_3', 0.0)) - saldo = float(d.get('SALDO', 0.0)) - opciones = d.get('OPCIONES', ' ≡ ▼ ') - - t_cta_ant += cta_ant - t_cta_cur += cta_cur - t_cta_tot += cta_tot - t_cob_ant += cob_ant - t_cob_cur += cob_cur - t_cob_tot += cob_tot - t_saldo += saldo - - avance_ant = f"{r1:.0f}%" if cta_ant > 0 else "" - avance_cur = f"{r2:.0f}%" if cta_cur > 0 else "" - avance_tot = f"{r3:.0f}%" if cta_tot > 0 else "" - - if agrupacion == "PROGRAMA": - fila = [ - grupo, frecuencia, num_cuota, fecha_venc, - f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant, - f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur, - f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot, - f"S/ {saldo:,.0f}", opciones - ] - else: - fila = [ - grupo, - f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant, - f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur, - f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot, - f"S/ {saldo:,.0f}", opciones - ] - sheet_data.append(fila) - - r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0 - r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0 - r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0 - - if agrupacion == "PROGRAMA": - fila_total = [ - "TOTAL GENERAL", "-", "-", "-", - f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%", - f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%", - f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%", - f"S/ {t_saldo:,.0f}", "" - ] - else: - fila_total = [ - "TOTAL GENERAL", - f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%", - f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%", - f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%", - f"S/ {t_saldo:,.0f}", "" - ] - sheet_data.append(fila_total) - return sheet_data - - def obtener_detalle_programa_formateado(self, ano, mes, sectorista, programa, venc_hasta=None): - datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, programa, venc_hasta) - - sheet_data = [] - t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0 - - for d in datos_brutos: - mat = d.get('MATRICULA', '') - alumno = d.get('ALUMNO', '') - num_cuota = d.get('NUM_CUOTA', '-') - fch_venc = d.get('FCH_VENC', '-') - programa = d.get('PROGRAMA', '-') # programa detallado del alumno - - cta_ant = float(d.get('CTA_COB_ANT', 0.0)) - cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0)) - cta_tot = float(d.get('TOTAL_CTA_COB', 0.0)) - cob_ant = float(d.get('COB_ANT', 0.0)) - cob_cur = float(d.get('COB_MES_CURSO', 0.0)) - cob_tot = float(d.get('TOTAL_COBRADO', 0.0)) - saldo = float(d.get('SALDO', 0.0)) - - t_cta_ant += cta_ant; t_cta_cur += cta_cur; t_cta_tot += cta_tot - t_cob_ant += cob_ant; t_cob_cur += cob_cur; t_cob_tot += cob_tot; t_saldo += saldo - - r1 = (cob_ant / cta_ant) * 100 if cta_ant > 0 else 0.0 - r2 = (cob_cur / cta_cur) * 100 if cta_cur > 0 else 0.0 - r3 = (cob_tot / cta_tot) * 100 if cta_tot > 0 else 0.0 - - avance_ant = f"{r1:.0f}%" if cta_ant > 0 else "" - avance_cur = f"{r2:.0f}%" if cta_cur > 0 else "" - avance_tot = f"{r3:.0f}%" if cta_tot > 0 else "" - - fila = [ - mat, alumno, num_cuota, fch_venc, - f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant, - f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur, - f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot, - f"S/ {saldo:,.0f}", - programa # índice [14]: PROGRAMA (extra, al final; no altera columnas previas) - ] - sheet_data.append(fila) - - r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0 - r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0 - r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0 - - fila_total = [ - "TOTAL", "GENERAL", "-", "-", - f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%", - f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%", - f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%", - f"S/ {t_saldo:,.0f}", - "" # índice [14]: PROGRAMA vacío en la fila total - ] - sheet_data.append(fila_total) - return sheet_data - - def exportar_detalle_excel(self, programa, headers, datos): - if not datos: raise ValueError("No hay datos para exportar") - - prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:30] - archivo = f"Detalle_Cobranza_{prog_limpio}.xlsx" - - df = pd.DataFrame(datos, columns=[h.replace('\n', ' ') for h in headers]) - try: - with pd.ExcelWriter(archivo, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Detalle') - except: - df.to_excel(archivo, index=False) - return archivo - - def exportar_reporte_global(self, ano, mes, sectorista): - datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, "TODOS") - if not datos_brutos: raise ValueError("No hay datos para exportar en este mes.") - - filas_excel = [] - for d in datos_brutos: - cta_ant = float(d.get('CTA_COB_ANT', 0.0)) - cob_ant = float(d.get('COB_ANT', 0.0)) - cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0)) - cob_cur = float(d.get('COB_MES_CURSO', 0.0)) - cta_tot = float(d.get('TOTAL_CTA_COB', 0.0)) - cob_tot = float(d.get('TOTAL_COBRADO', 0.0)) - - filas_excel.append({ - "ALUMNO": d.get('ALUMNO', ''), - "PROGRAMA": d.get('PROGRAMA', '-'), - "FRECUENCIA": d.get('FRECUENCIA', '-'), - "NUM CUOTA": d.get('NUM_CUOTA', '-'), - "FCH VENCIMIENTO": d.get('FCH_VENC', '-'), - "CUENTA PENDIENTE": cta_ant, - "COBRADO PENDIENTE": cob_ant, - "AVANCE % ": (cob_ant / cta_ant) if cta_ant > 0 else 0.0, - "CUENTA EN CURSO": cta_cur, - "COBRADO EN CURSO": cob_cur, - "AVANCE % ": (cob_cur / cta_cur) if cta_cur > 0 else 0.0, - "TOTAL CUENTA": cta_tot, - "TOTAL COBRADO": cob_tot, - "AVANCE %": (cob_tot / cta_tot) if cta_tot > 0 else 0.0, - "SALDO": float(d.get('SALDO', 0.0)) - }) - - df = pd.DataFrame(filas_excel) - archivo = f"Reporte_Cobranza_Global_{ano}_{mes}.xlsx" - - try: - with pd.ExcelWriter(archivo, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Reporte Global') - worksheet = writer.sheets['Reporte Global'] - from openpyxl.styles import PatternFill, Font, Alignment - - fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid") - font_header = Font(color="FFFFFF", bold=True) - for cell in worksheet[1]: - cell.fill = fill_header - cell.font = font_header - cell.alignment = Alignment(horizontal="center", vertical="center") - - for row in range(2, len(filas_excel) + 2): - for col in [6, 7, 9, 10, 12, 13, 15]: - worksheet.cell(row=row, column=col).number_format = '"S/" #,##0.00' - for col in [8, 11, 14]: - worksheet.cell(row=row, column=col).number_format = '0%' - # FCH VENCIMIENTO (col 5): forzar TEXTO para que Excel no reinterprete - # la fecha ni le reste un día por zona horaria. - celda_fecha = worksheet.cell(row=row, column=5) - celda_fecha.value = str(celda_fecha.value) - celda_fecha.number_format = '@' - - for col in worksheet.columns: - max_len = 0 - col_letter = col[0].column_letter - for cell in col: - if cell.value: - max_len = max(max_len, len(str(cell.value))) - worksheet.column_dimensions[col_letter].width = min(max_len + 2, 40) - except Exception: - df.to_excel(archivo, index=False) - - return archivo \ No newline at end of file diff --git a/backend/modules/cobranza/processor.py b/backend/modules/cobranza/processor.py deleted file mode 100644 index ee7f814..0000000 --- a/backend/modules/cobranza/processor.py +++ /dev/null @@ -1,824 +0,0 @@ -# modules/cobranza/processor.py -from datetime import datetime -import requests -import unicodedata - -class CobranzaProcessor: - def __init__(self, data_manager): - self.data_manager = data_manager - self.config_sedes = self._cargar_json_sedes() - # ── Caché de consultas SQL ──────────────────────────────────────── - self._cache_cronograma = None - self._cache_facturas = None - self._cache_ano = None - - def _obtener_datos_cached(self, ano): - """Devuelve cronograma y facturas usando caché si el año coincide.""" - if self._cache_ano == ano and self._cache_cronograma is not None: - print(f"[CACHE] Reutilizando datos en caché para año {ano}") - return self._cache_cronograma, self._cache_facturas - print(f"[CACHE] Consultando BD para año {ano}...") - cronograma = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano) - facturas = self.data_manager.ejecutar_consulta_facturas_cobranza(ano) - self._cache_cronograma = cronograma - self._cache_facturas = facturas - self._cache_ano = ano - return cronograma, facturas - - def invalidar_cache(self): - """Fuerza recarga desde BD en la siguiente consulta.""" - self._cache_cronograma = None - self._cache_facturas = None - self._cache_ano = None - print("🧹 [CACHE] Memoria limpiada por actualización automática. La próxima consulta irá directo a la BD.") - - # ========================================================================= - # CARGA DE CONFIGURACIÓN (sede.json) - # ========================================================================= - def _cargar_json_sedes(self): - datos = None - try: - url = getattr(self.data_manager, 'github_sede_url', None) - if url: - response = requests.get(url) - response.raise_for_status() - datos = response.json() - except Exception as e: - pass - - if datos is None: - datos = { - "clasificacion_sedes": { - "AREQUIPA": {"patrones": ["AREQUIPA"]}, - "PIURA": {"patrones": ["PIURA"]}, - "TRUJILLO": {"patrones": ["TRUJILLO"]} - }, - "clasificacion_default": {"sede": "LIMA"} - } - - self._reemplazos_nombre = datos.get("reemplazos_nombre", []) - return datos - - # ========================================================================= - # REEMPLAZOS DE NOMBRE DE PROGRAMA - # ========================================================================= - @staticmethod - def _normalizar(texto): - texto = unicodedata.normalize('NFD', str(texto)) - return texto.encode('ascii', 'ignore').decode('utf-8') - - def _aplicar_reemplazos_nombre(self, texto): - if not texto or not self._reemplazos_nombre: - return texto - - resultado = texto - for entrada in self._reemplazos_nombre: - buscar = str(entrada.get("buscar", "")).strip() - reemplazar = str(entrada.get("reemplazar", "")).strip() - if not buscar: - continue - - buscar_norm = self._normalizar(buscar).upper() - idx = self._normalizar(resultado).upper().find(buscar_norm) - while idx != -1: - resultado = resultado[:idx] + reemplazar + resultado[idx + len(buscar):] - idx = self._normalizar(resultado).upper().find(buscar_norm, idx + len(reemplazar)) - - return resultado - - # ========================================================================= - # CLASIFICACIÓN DE SEDE - # ========================================================================= - def clasificar_sede(self, dsc_programa): - if not dsc_programa: - return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA") - - dsc_prog_upper = str(dsc_programa).upper() - sedes = self.config_sedes.get("clasificacion_sedes", {}) - - for nombre_sede, datos in sedes.items(): - for patron in datos.get("patrones", []): - if patron.upper() in dsc_prog_upper: - return nombre_sede - - return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA") - - # ========================================================================= - # UTILIDADES - # ========================================================================= - def parse_fecha(self, fecha_raw): - try: - if isinstance(fecha_raw, datetime): return fecha_raw - if not fecha_raw: return None - s_fecha = str(fecha_raw).strip() - if len(s_fecha) == 10 and s_fecha[2] == '-': - return datetime.strptime(s_fecha, '%d-%m-%Y') - if ' ' in s_fecha: - return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S') - return datetime.strptime(s_fecha, '%Y-%m-%d') - except: - return None - - def parse_corte_venc(self, venc_hasta): - """Convierte el corte del filtro (dd/mm/yyyy del calendario, o ISO) a datetime. - Devuelve None si no aplica (TODOS/vacío).""" - if venc_hasta in (None, "", "TODOS", "NONE"): return None - s = str(venc_hasta).strip() - try: - if '/' in s: return datetime.strptime(s, '%d/%m/%Y') - except: pass - return self.parse_fecha(s) - - def obtener_fechas_filtro(self, ano, mes_numero): - ano_int = int(ano) - mes_int = int(mes_numero) - fecha_filtro = datetime(ano_int, mes_int, 1) - if mes_int == 12: - fecha_filtro_siguiente = datetime(ano_int + 1, 1, 1) - else: - fecha_filtro_siguiente = datetime(ano_int, mes_int + 1, 1) - return fecha_filtro, fecha_filtro_siguiente - - def calcular_importe_final(self, fila): - try: imp_total = float(fila.get('imp_total', 0) or 0) - except: imp_total = 0.0 - try: imp_dscto = float(fila.get('imp_dscto', 0) or 0) - except: imp_dscto = 0.0 - return imp_total - imp_dscto - - def evaluar_ctaxcob_suma(self, fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats): - sectorista = str(fila.get('dsc_sectorista', '')) - if sectorista == " , " or sectorista.strip() == ",": - return 0.0, 0.0 - - try: num_cuota = int(fila.get('num_cuota', 0)) - except: num_cuota = 0 - if not (num_cuota > 1): - debug_stats['falla_cuota'] += 1 - return 0.0, 0.0 - - fch_vencimiento = self.parse_fecha(fila.get('fch_vencimiento')) - if not fch_vencimiento: - debug_stats['falla_vencimiento'] += 1 - return 0.0, 0.0 - - es_anterior = fch_vencimiento < fecha_filtro - es_curso = (fch_vencimiento >= fecha_filtro) and (fch_vencimiento < fecha_filtro_siguiente) - - if not (es_anterior or es_curso): - debug_stats['falla_vencimiento'] += 1 - return 0.0, 0.0 - - fch_inicio = self.parse_fecha(fila.get('fch_inicio')) - if not fch_inicio or not (fch_inicio <= hoy): - debug_stats['falla_inicio'] += 1 - return 0.0, 0.0 - - estado = str(fila.get('estado_matricula', '')).strip().upper() - if estado in ["ALU", "PRE"]: - pass - elif estado == "RET": - fch_retiro = self.parse_fecha(fila.get('fch_retiro')) - if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente): - debug_stats['falla_estado'] += 1 - return 0.0, 0.0 - else: - debug_stats['falla_estado'] += 1 - return 0.0, 0.0 - - importe_convertido = importe_final - moneda = str(fila.get('cod_moneda', '')).strip().upper() - if moneda == 'DOL': - try: tc = float(fila.get('imp_tc', 1.0) or 1.0) - except: tc = 1.0 - importe_convertido *= tc - - debug_stats['pasa_todo'] += 1 - - if es_anterior: return importe_convertido, 0.0 - if es_curso: return 0.0, importe_convertido - return 0.0, 0.0 - - def evaluar_ctaxcob_resta(self, fila_factura, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats, tc_correcto): - sectorista = str(fila_factura.get('dsc_sectorista', '')) - if sectorista == " , " or sectorista.strip() == ",": - return 0.0, 0.0, 0.0, 0.0 - - try: num_cuota = int(fila_factura.get('num_cuota', 0)) - except: num_cuota = 0 - if not (num_cuota > 1): - debug_stats['falla_cuota'] += 1 - return 0.0, 0.0, 0.0, 0.0 - - cod_estado = str(fila_factura.get('cod_estado', '')).strip().upper() - if cod_estado != "CAN": - debug_stats['falla_estado_can'] += 1 - return 0.0, 0.0, 0.0, 0.0 - - flg_nc = str(fila_factura.get('flg_nc', '')).strip().upper() - if flg_nc == "SI": - debug_stats['falla_nc'] += 1 - return 0.0, 0.0, 0.0, 0.0 - - fch_venc_cuota = self.parse_fecha(fila_factura.get('fch_vencimiento_cuota')) - if not fch_venc_cuota: - debug_stats['falla_venc_cuota'] += 1 - return 0.0, 0.0, 0.0, 0.0 - - es_anterior = fch_venc_cuota < fecha_filtro - es_curso = (fch_venc_cuota >= fecha_filtro) and (fch_venc_cuota < fecha_filtro_siguiente) - - if not (es_anterior or es_curso): - debug_stats['falla_venc_cuota'] += 1 - return 0.0, 0.0, 0.0, 0.0 - - fch_cancelacion = self.parse_fecha(fila_factura.get('fch_cancelacion')) - if not fch_cancelacion: - debug_stats['falla_cancelacion'] += 1 - return 0.0, 0.0, 0.0, 0.0 - - pasa_anterior = es_anterior and (fch_cancelacion < fecha_filtro) - pasa_curso = es_curso and (fch_cancelacion < fecha_filtro) - pasa_cobrado_ant = es_anterior and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente) - pasa_cobrado_curso = es_curso and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente) - - if not (pasa_anterior or pasa_curso or pasa_cobrado_ant or pasa_cobrado_curso): - debug_stats['falla_cancelacion'] += 1 - return 0.0, 0.0, 0.0, 0.0 - - fch_inicio = self.parse_fecha(fila_factura.get('fch_inicio')) - if not fch_inicio or not (fch_inicio <= hoy): - debug_stats['falla_inicio'] += 1 - return 0.0, 0.0, 0.0, 0.0 - - estado_mat = str(fila_factura.get('estado_matricula', '')).strip().upper() - if estado_mat in ["ALU", "PRE"]: pass - elif estado_mat == "RET": - fch_retiro = self.parse_fecha(fila_factura.get('fch_retiro')) - if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente): - debug_stats['falla_estado_mat'] += 1 - return 0.0, 0.0, 0.0, 0.0 - else: - debug_stats['falla_estado_mat'] += 1 - return 0.0, 0.0, 0.0, 0.0 - - try: imp_emitido = float(fila_factura.get('imp_emitido', 0) or 0) - except: imp_emitido = 0.0 - - imp_emitido_convertido = imp_emitido - moneda = str(fila_factura.get('cod_moneda', '')).strip().upper() - if moneda == 'DOL': - imp_emitido_convertido *= tc_correcto - - debug_stats['pasa_todo'] += 1 - - val_resta_ant = imp_emitido_convertido if pasa_anterior else 0.0 - val_resta_cur = imp_emitido_convertido if pasa_curso else 0.0 - val_cobrado_ant = imp_emitido_convertido if pasa_cobrado_ant else 0.0 - val_cobrado_curso = imp_emitido_convertido if pasa_cobrado_curso else 0.0 - - return val_resta_ant, val_resta_cur, val_cobrado_ant, val_cobrado_curso - - # ========================================================================= - # LISTA DE SECTORISTAS - # ========================================================================= - def obtener_lista_sectoristas(self, ano, mes): - fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes) - hoy = datetime.now() - - try: - cronograma_raw = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano) - facturas_raw = self.data_manager.ejecutar_consulta_facturas_cobranza(ano) - except Exception: return ["TODOS"] - - sectoristas_neto = {} - stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0} - stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0} - - indices_cronograma = set() - mapa_tc = {} - mapa_sectorista = {} - - for fila in cronograma_raw: - sec_str = str(fila.get('dsc_sectorista', '')) - sec_clean = sec_str.strip() - if sec_clean in ["", ",", "None"] or sec_str == " , ": continue - - idx = str(fila.get('num_indice', '')).strip() - if idx: - indices_cronograma.add(idx) - mapa_sectorista[idx] = sec_clean - - mat = str(fila.get('num_matricula', '')).strip() - if mat and mat not in mapa_tc: - try: tc = float(fila.get('imp_tc', 1.0) or 1.0) - except: tc = 1.0 - mapa_tc[mat] = tc - - for fila in cronograma_raw: - idx = str(fila.get('num_indice', '')).strip() - if idx not in indices_cronograma: continue - - sec_clean = mapa_sectorista[idx] - importe_final = self.calcular_importe_final(fila) - suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma) - - if sec_clean not in sectoristas_neto: sectoristas_neto[sec_clean] = 0.0 - sectoristas_neto[sec_clean] += (suma_ant + suma_cur) - - for fila in facturas_raw: - idx_factura = str(fila.get('num_indice', '')).strip() - if idx_factura not in indices_cronograma: continue - - sec_clean = mapa_sectorista.get(idx_factura, "") - if not sec_clean: continue - - mat_factura = str(fila.get('num_matricula', '')).strip() - tc_correcto = mapa_tc.get(mat_factura, 1.0) - - resta_ant, resta_cur, _, _ = self.evaluar_ctaxcob_resta( - fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto - ) - - if sec_clean in sectoristas_neto: sectoristas_neto[sec_clean] -= (resta_ant + resta_cur) - - lista_final = [] - for sec, total_cta_x_cob in sectoristas_neto.items(): - if round(total_cta_x_cob, 2) > 0: lista_final.append(sec) - - lista_final = sorted(lista_final) - lista_final.insert(0, "TODOS") - return lista_final - - # ========================================================================= - # DATOS PROCESADOS (TABLA PRINCIPAL CON VISTA DE ASESORES) - # ========================================================================= - - def _meta_full(self): - """JSON completo REPLACE_META (con clasificacion_filtro_programa).""" - md = getattr(self.data_manager, "meta_data", None) - if isinstance(md, dict) and md: - return md - return self.config_sedes or {} - - def clasificar_filtro_programa(self, dsc_programa): - """TEAC/TERC/SEMINARIOS/OTROS desde clasificacion_filtro_programa (REPLACE_META). - Respeta el orden del JSON (primer patrón que coincide gana).""" - up = str(dsc_programa or "").upper() - data = self._meta_full().get("clasificacion_filtro_programa", {}) - for cat, cfg in data.items(): - if cat == "DEFAULT": - continue - for pat in cfg.get("patrones", []): - if str(pat).upper() in up: - return cat - return data.get("DEFAULT", "OTROS") - - def clasificar_sede_meta(self, dsc_programa): - """Sede desde clasificacion_sedes del REPLACE_META (LIMA por defecto).""" - up = str(dsc_programa or "").upper() - data = self._meta_full().get("clasificacion_sedes", {}) - for sede, cfg in data.items(): - if sede == "DEFAULT": - continue - for pat in cfg.get("patrones", []): - if str(pat).upper() in up: - return sede - return data.get("DEFAULT", "LIMA") - - def obtener_datos_procesados(self, ano, mes, sectorista_filtro="TODOS", agrupacion="SEDE", - sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta=None): - fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes) - hoy = datetime.now() - # Filtro adicional por F. Vencimiento: solo cuotas con vencimiento <= venc_hasta. - # Las cuotas sin fecha de vencimiento se incluyen SIEMPRE. - venc_corte = self.parse_corte_venc(venc_hasta) - datos_agrupados = {} - - frecuencias_cuota = {} - frecuencias_fecha_venc = {} - mapa_frecuencia = {} - - alumnos_tracking = {} - - try: - cronograma_raw, facturas_raw = self._obtener_datos_cached(ano) - except Exception as e: - print(f"Error crítico: {e}") - return [] - - stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0} - stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0} - - indices_cronograma = set() - mapa_tc = {} - mapa_programa = {} - mapa_sectorista = {} # <-- NUEVO MAPA PARA CORREGIR ASESORES - - for fila in cronograma_raw: - sectorista_str = str(fila.get('dsc_sectorista', '')) - sectorista_clean = sectorista_str.strip() - - idx = str(fila.get('num_indice', '')).strip() - if idx: - # Guardamos el asesor real del cronograma para usarlo en las facturas - mapa_sectorista[idx] = sectorista_clean if sectorista_clean and sectorista_clean not in [",", "None"] else "SIN ASESOR" - - if sectorista_clean in [",", "", "None"] or sectorista_str == " , ": continue - if sectorista_filtro != "TODOS" and sectorista_clean != sectorista_filtro: continue - - if idx: indices_cronograma.add(idx) - - mat = str(fila.get('num_matricula', '')).strip() - if mat: - if mat not in mapa_tc: - try: tc = float(fila.get('imp_tc', 1.0) or 1.0) - except: tc = 1.0 - mapa_tc[mat] = tc - - if mat not in mapa_programa: - prog = str(fila.get('dsc_det_programa', '')).strip() - mapa_programa[mat] = prog if (prog and prog != "None") else "SIN PROGRAMA" - - for fila in cronograma_raw: - idx = str(fila.get('num_indice', '')).strip() - if idx not in indices_cronograma: continue - - mat = str(fila.get('num_matricula', '')).strip() - - # Filtro por SEDE y PROGRAMA (REPLACE_META) — se aplica en cualquier - # agrupación cuando se pide (usado sobre todo en ASESOR). - _prog = fila.get('dsc_programa', '') - if str(sede_filtro).upper() not in ("TODOS", "", "NONE"): - if str(self.clasificar_sede_meta(_prog)).upper() != str(sede_filtro).upper(): - continue - if str(prog_filtro).upper() not in ("TODOS", "", "NONE"): - if str(self.clasificar_filtro_programa(_prog)).upper() != str(prog_filtro).upper(): - continue - - # Filtro por F. Vencimiento (<=). Sin fecha => se incluye siempre. - if venc_corte is not None: - _fv = self.parse_fecha(fila.get('fch_vencimiento')) - if _fv is not None and _fv > venc_corte: - continue - - if agrupacion == "SEDE": - llave = self.clasificar_sede(fila.get('dsc_programa', '')) - elif agrupacion == "ASESOR": - llave = mapa_sectorista.get(idx, "SIN ASESOR") - else: - llave = str(fila.get('dsc_det_programa', '')).strip() - if not llave or llave == "None": llave = "SIN PROGRAMA" - - if llave not in datos_agrupados: - datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0} - - importe_final = self.calcular_importe_final(fila) - suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma) - - datos_agrupados[llave]["Suma_Ant"] += suma_ant - datos_agrupados[llave]["Suma_Cur"] += suma_cur - - clave_mat = (mat, llave) - if clave_mat not in alumnos_tracking: - alumnos_tracking[clave_mat] = {"suma": 0.0, "resta": 0.0, "cobrado": 0.0, "cuota_final": None, "fecha_final": None} - - alumnos_tracking[clave_mat]["suma"] += (suma_ant + suma_cur) - - if agrupacion == "PROGRAMA": - if llave not in mapa_frecuencia: - frec_val = str(fila.get('cod_frecuencia', '-')).strip() - mapa_frecuencia[llave] = frec_val if frec_val and frec_val != 'None' and frec_val != '' else '-' - - fch_venc_raw = fila.get('fch_vencimiento') - fch_venc_dt = self.parse_fecha(fch_venc_raw) - if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente): - try: - n_cuota = int(fila.get('num_cuota', -1)) - if n_cuota >= 0: alumnos_tracking[clave_mat]["cuota_final"] = n_cuota - except: pass - try: - alumnos_tracking[clave_mat]["fecha_final"] = fch_venc_dt.strftime('%d/%m/%Y') - except: pass - - for fila in facturas_raw: - idx_factura = str(fila.get('num_indice', '')).strip() - if idx_factura not in indices_cronograma: continue - - mat_factura = str(fila.get('num_matricula', '')).strip() - tc_correcto = mapa_tc.get(mat_factura, 1.0) - - # Mismo filtro SEDE/PROGRAMA (REPLACE_META) que en el cronograma. - _progF = fila.get('dsc_programa', '') - if str(sede_filtro).upper() not in ("TODOS", "", "NONE"): - if str(self.clasificar_sede_meta(_progF)).upper() != str(sede_filtro).upper(): - continue - if str(prog_filtro).upper() not in ("TODOS", "", "NONE"): - if str(self.clasificar_filtro_programa(_progF)).upper() != str(prog_filtro).upper(): - continue - - # Filtro por F. Vencimiento (<=). En facturas la fecha es fch_vencimiento_cuota. - # Sin fecha => se incluye siempre. - if venc_corte is not None: - _fvF = self.parse_fecha(fila.get('fch_vencimiento_cuota')) - if _fvF is not None and _fvF > venc_corte: - continue - - if agrupacion == "SEDE": - llave = self.clasificar_sede(fila.get('dsc_programa', '')) - elif agrupacion == "ASESOR": - # AQUÍ ESTÁ LA MAGIA: usa el asesor guardado desde el cronograma - llave = mapa_sectorista.get(idx_factura, "SIN ASESOR") - else: - llave = mapa_programa.get(mat_factura, "") - if not llave: - llave = str(fila.get('dsc_det_programa', '')).strip() - if not llave or llave == "None": llave = "SIN PROGRAMA" - - if llave not in datos_agrupados: - datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0} - - resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta( - fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto - ) - - datos_agrupados[llave]["Resta_Ant"] += resta_ant - datos_agrupados[llave]["Resta_Cur"] += resta_cur - datos_agrupados[llave]["Cob_Ant"] += cobrado_ant - datos_agrupados[llave]["Cob_Cur"] += cobrado_curso - - clave_mat = (mat_factura, llave) - if clave_mat in alumnos_tracking: - alumnos_tracking[clave_mat]["resta"] += (resta_ant + resta_cur) - alumnos_tracking[clave_mat]["cobrado"] += (cobrado_ant + cobrado_curso) - - for (mat, llave), track in alumnos_tracking.items(): - saldo_alumno = (track["suma"] - track["resta"]) - track["cobrado"] - if round(saldo_alumno, 2) > 0: - c = track["cuota_final"] - f = track["fecha_final"] - - if c is not None: - if llave not in frecuencias_cuota: frecuencias_cuota[llave] = {} - frecuencias_cuota[llave][c] = frecuencias_cuota[llave].get(c, 0) + 1 - if f is not None: - if llave not in frecuencias_fecha_venc: frecuencias_fecha_venc[llave] = {} - frecuencias_fecha_venc[llave][f] = frecuencias_fecha_venc[llave].get(f, 0) + 1 - - datos_limpios = [] - llaves_ordenadas = sorted(datos_agrupados.keys()) - - asesores_validos = [] - if agrupacion == "ASESOR": - asesores_validos = [s for s in self.obtener_lista_sectoristas(ano, mes) if s != "TODOS"] - - if agrupacion == "SEDE": - orden_sedes = ["LIMA", "AREQUIPA", "TRUJILLO", "PIURA"] - llaves_ordenadas = [s for s in orden_sedes if s in datos_agrupados] + [s for s in llaves_ordenadas if s not in orden_sedes] - - for llave in llaves_ordenadas: - if agrupacion == "ASESOR" and llave not in asesores_validos: - continue - - totales = datos_agrupados[llave] - cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"] - cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"] - total_cta = cta_cob_ant + cta_cob_curso - - cobrado_meses_ant = totales["Cob_Ant"] - cobrado_mes_curso = totales["Cob_Cur"] - total_cobrado_sede = cobrado_meses_ant + cobrado_mes_curso - - if round(total_cta, 2) <= 0 and round(total_cobrado_sede, 2) <= 0: continue - - ratio_1 = (cobrado_meses_ant / cta_cob_ant) * 100 if cta_cob_ant > 0 else 0.0 - ratio_2 = (cobrado_mes_curso / cta_cob_curso) * 100 if cta_cob_curso > 0 else 0.0 - ratio_3 = (total_cobrado_sede / total_cta) * 100 if total_cta > 0 else 0.0 - saldo_sede = total_cta - total_cobrado_sede - - frec_str = "-" - moda_cuota = "-" - moda_fecha = "-" - - if agrupacion == "PROGRAMA": - frec_str = mapa_frecuencia.get(llave, "-") - if llave in frecuencias_cuota and frecuencias_cuota[llave]: - moda_cuota = str(max(frecuencias_cuota[llave], key=frecuencias_cuota[llave].get)) - if llave in frecuencias_fecha_venc and frecuencias_fecha_venc[llave]: - moda_fecha = str(max(frecuencias_fecha_venc[llave], key=frecuencias_fecha_venc[llave].get)) - - if agrupacion == "PROGRAMA": - grupo_display = self._aplicar_reemplazos_nombre(llave) - else: - grupo_display = llave - - datos_limpios.append({ - 'GRUPO': grupo_display, - 'GRUPO_ORIGINAL': llave, - 'FRECUENCIA': frec_str, - 'NUM_CUOTA': moda_cuota, - 'FCH_VENC_MODA': moda_fecha, - 'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta, - 'COB_ANT': cobrado_meses_ant, 'RATIO_1': ratio_1, 'COB_MES_CURSO': cobrado_mes_curso, 'RATIO_2': ratio_2, - 'TOTAL_COBRADO': total_cobrado_sede, 'RATIO_3': ratio_3, 'SALDO': saldo_sede, 'OPCIONES': " ≡ ▼ " - }) - - if agrupacion == "PROGRAMA": - def orden_personalizado_programa(x): - fecha_str = x['FCH_VENC_MODA'] - if fecha_str == "-": - return (0, datetime.min, x['GRUPO']) - else: - try: - dt = datetime.strptime(fecha_str, '%d/%m/%Y') - return (1, dt, x['GRUPO']) - except: - return (1, datetime.max, x['GRUPO']) - - return sorted(datos_limpios, key=orden_personalizado_programa) - - return datos_limpios - - # ========================================================================= - # DESGLOSE POR ALUMNO/MATRÍCULA - # ========================================================================= - def obtener_detalle_programa(self, ano, mes, sectorista_filtro, programa_nombre, venc_hasta=None): - fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes) - hoy = datetime.now() - # Filtro por F. Vencimiento (<=). Sin fecha => se incluye siempre. - venc_corte = self.parse_corte_venc(venc_hasta) - - try: - cronograma_raw, facturas_raw = self._obtener_datos_cached(ano) - except Exception: return [] - - stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0} - stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0} - - indices_cronograma = set() - mat_validas = set() - mapa_tc = {} - mapa_programa_frec = {} - - for fila in cronograma_raw: - sectorista_str = str(fila.get('dsc_sectorista', '')).strip() - if sectorista_str in [",", ""] or fila.get('dsc_sectorista') == " , ": continue - if sectorista_filtro != "TODOS" and sectorista_str != sectorista_filtro: continue - - prog = str(fila.get('dsc_det_programa', '')).strip() - if not prog or prog == "None": prog = "SIN PROGRAMA" - - if programa_nombre != "TODOS": - if self._aplicar_reemplazos_nombre(prog) != programa_nombre: continue - - idx = str(fila.get('num_indice', '')).strip() - if idx: indices_cronograma.add(idx) - - mat = str(fila.get('num_matricula', '')).strip() - if mat: - mat_validas.add(mat) - if mat not in mapa_tc: - try: tc = float(fila.get('imp_tc', 1.0) or 1.0) - except: tc = 1.0 - mapa_tc[mat] = tc - if mat not in mapa_programa_frec: - prog_bd = str(fila.get('dsc_det_programa', '')).strip() - frec_bd = str(fila.get('cod_frecuencia', '')).strip() - mapa_programa_frec[mat] = { - "prog": prog_bd if prog_bd and prog_bd != "None" else "-", - "frec": frec_bd if frec_bd and frec_bd != "None" else "-" - } - - mapa_alumnos = {} - if mat_validas: - try: - conn = self.data_manager.get_connection() - cursor = conn.cursor() - lista_mats = list(mat_validas) - - for i in range(0, len(lista_mats), 1000): - chunk = lista_mats[i:i+1000] - chunk_str = ",".join([f"'{m}'" for m in chunk]) - - sql = f""" - SELECT sgeca_matricula.num_matricula, - sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno - FROM sgeca_matricula - INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno - WHERE sgeca_matricula.num_matricula IN ({chunk_str}) - """ - cursor.execute(sql) - for row in cursor.fetchall(): - num_mat_bd = str(row[0]).strip() - nombre_bd = str(row[1]).strip() - mapa_alumnos[num_mat_bd] = nombre_bd - conn.close() - except Exception as e: - print(f"Error consultando nombres de alumnos en BD: {e}") - - datos_agrupados = {} - - for fila in cronograma_raw: - idx = str(fila.get('num_indice', '')).strip() - if idx not in indices_cronograma: continue - - mat = str(fila.get('num_matricula', '')).strip() - if mat not in datos_agrupados: - prog_info = mapa_programa_frec.get(mat, {"prog": "-", "frec": "-"}) - datos_agrupados[mat] = { - "Alumno": mapa_alumnos.get(mat, "SIN NOMBRE"), - "Programa": prog_info["prog"], - "Frecuencia": prog_info["frec"], - "Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0, - "Cuota_Mes": "-", "Fch_Venc_Mes": "-" - } - - fch_venc_raw = fila.get('fch_vencimiento') - fch_venc_dt = self.parse_fecha(fch_venc_raw) - # Filtro por F. Vencimiento (<=). Sin fecha => se incluye siempre. - if venc_corte is not None and fch_venc_dt is not None and fch_venc_dt > venc_corte: - continue - if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente): - try: datos_agrupados[mat]["Cuota_Mes"] = str(int(fila.get('num_cuota', -1))) - except: pass - datos_agrupados[mat]["Fch_Venc_Mes"] = fch_venc_dt.strftime('%d/%m/%Y') - - importe_final = self.calcular_importe_final(fila) - suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma) - - datos_agrupados[mat]["Suma_Ant"] += suma_ant - datos_agrupados[mat]["Suma_Cur"] += suma_cur - - for fila in facturas_raw: - idx_factura = str(fila.get('num_indice', '')).strip() - if idx_factura not in indices_cronograma: continue - - mat_factura = str(fila.get('num_matricula', '')).strip() - tc_correcto = mapa_tc.get(mat_factura, 1.0) - - # Filtro por F. Vencimiento (<=). En facturas la fecha es fch_vencimiento_cuota. - # Sin fecha => se incluye siempre. - if venc_corte is not None: - _fvD = self.parse_fecha(fila.get('fch_vencimiento_cuota')) - if _fvD is not None and _fvD > venc_corte: - continue - - if mat_factura not in datos_agrupados: - prog_info = mapa_programa_frec.get(mat_factura, {"prog": "-", "frec": "-"}) - datos_agrupados[mat_factura] = { - "Alumno": mapa_alumnos.get(mat_factura, "SIN NOMBRE"), - "Programa": prog_info["prog"], - "Frecuencia": prog_info["frec"], - "Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0, - "Cuota_Mes": "-", "Fch_Venc_Mes": "-" - } - - resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta( - fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto - ) - - datos_agrupados[mat_factura]["Resta_Ant"] += resta_ant - datos_agrupados[mat_factura]["Resta_Cur"] += resta_cur - datos_agrupados[mat_factura]["Cob_Ant"] += cobrado_ant - datos_agrupados[mat_factura]["Cob_Cur"] += cobrado_curso - - datos_limpios = [] - for mat, totales in datos_agrupados.items(): - cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"] - cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"] - total_cta = cta_cob_ant + cta_cob_curso - - cob_ant = totales["Cob_Ant"] - cob_cur = totales["Cob_Cur"] - total_cob = cob_ant + cob_cur - saldo = total_cta - total_cob - - if round(total_cta, 2) <= 0 and round(total_cob, 2) <= 0: continue - - cuota_final = totales["Cuota_Mes"] - fecha_final = totales["Fch_Venc_Mes"] - if round(saldo, 2) <= 0: - cuota_final = "-" - fecha_final = "-" - - datos_limpios.append({ - 'MATRICULA': mat, 'ALUMNO': totales["Alumno"], - 'PROGRAMA': totales["Programa"], 'FRECUENCIA': totales["Frecuencia"], - 'NUM_CUOTA': cuota_final, - 'FCH_VENC': fecha_final, - 'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta, - 'COB_ANT': cob_ant, 'COB_MES_CURSO': cob_cur, 'TOTAL_COBRADO': total_cob, 'SALDO': saldo - }) - - def orden_personalizado(x): - fecha_str = x['FCH_VENC'] - if fecha_str == "-": - return (0, datetime.min, x['ALUMNO']) - else: - try: - dt = datetime.strptime(fecha_str, '%d/%m/%Y') - return (1, dt, x['ALUMNO']) - except: - return (1, datetime.max, x['ALUMNO']) - - return sorted(datos_limpios, key=orden_personalizado) \ No newline at end of file diff --git a/backend/modules/ocupabilidad/__init__.py b/backend/modules/ocupabilidad/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/modules/ocupabilidad/logic.py b/backend/modules/ocupabilidad/logic.py deleted file mode 100644 index 241a86b..0000000 --- a/backend/modules/ocupabilidad/logic.py +++ /dev/null @@ -1,234 +0,0 @@ -# modules/ocupabilidad/logic.py -import pandas as pd -from .processor import CursoProcessor - -class AnalizadorCursos: - """Analizador de cursos - Lógica de cálculo y estadísticas""" - - def __init__(self, data_manager): - self.data_manager = data_manager - self.curso_processor = CursoProcessor(self.data_manager) - self.columnas = self.curso_processor.columnas - - def obtener_sedes(self): - """Devuelve la lista de sedes para el nuevo filtro""" - return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"] - - def identificar_sede(self, dsc_programa): - """Identifica la sede basada en el nombre del programa y el JSON de GitHub""" - if not dsc_programa: return "LIMA" - dsc_prog_upper = str(dsc_programa).upper() - sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {}) - for sede, data in sedes_data.items(): - if sede == "DEFAULT": continue - for patron in data.get("patrones", []): - if patron.upper() in dsc_prog_upper: return sede - return sedes_data.get("DEFAULT", "LIMA") - - def obtener_filtros_programa(self): - """Devuelve la lista de programas para el nuevo filtro combinado""" - return ["SEMINARIOS", "OTROS", "TEAC", "TERC"] - - def identificar_filtro_programa(self, dsc_programa): - """Identifica la categoría del programa para el filtro de la UI""" - if not dsc_programa: return "OTROS" - dsc_prog_upper = str(dsc_programa).upper() - filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {}) - for categoria, data in filtro_data.items(): - if categoria == "DEFAULT": continue - for patron in data.get("patrones", []): - if patron.upper() in dsc_prog_upper: return categoria - return filtro_data.get("DEFAULT", "OTROS") - - def get_current_month(self): - return self.data_manager.get_current_month() - - def get_current_year(self): - return self.data_manager.get_current_year() - - def actualizar_configuracion(self): - print("🔄 Actualización automática de configuración...") - self.data_manager.cargar_toda_configuracion() - - # --- AGREGADO EL PARÁMETRO "TIPO" --- - def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"): - # Sincronizamos el estado del toggle UI con el procesador - self.curso_processor.mostrar_reprogramados = getattr(self, 'mostrar_reprogramados', True) - - datos = self.curso_processor.obtener_datos_procesados(ano, mes) - programas_disponibles = set() - - if datos: - for d in datos: - prog = str(d.get('programa_frecuencia', d.get('dsc_programa', ''))) - # Inyectamos variables para el filtro UI - d['Sede'] = self.identificar_sede(prog) - d['Filtro_Programa'] = self.identificar_filtro_programa(prog) - - if sede == "TODOS" or d['Sede'] == sede: - programas_disponibles.add(d['Filtro_Programa']) - - # Ordenar lista dinámica - orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"] - self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles] - - # NO auto-corregir: si el programa no existe en la sede/mes, se respeta el - # filtro y la tabla queda vacia (antes cambiaba a TODOS y mostraba todo). - self.filtro_corregido = None - - if datos and (sede != "TODOS" or filtro_prog != "TODOS"): - datos_filtrados = [] - for d in datos: - cumple_sede = (sede == "TODOS" or d.get('Sede') == sede) - cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog) - if cumple_sede and cumple_prog: - datos_filtrados.append(d) - return datos_filtrados - - return datos - - def formatear_datos_para_tabla(self, datos): - if not datos: - return [], {} - - sheet_data = [] - totales = { - 'mes': 0, 'retirados': 0, 'totales': 0, - 'activos': 0, 'pc': 0, 'continuidad': 0, 'meta': 0, - 'descuento': 0 - } - - for registro in datos: - fila = [] - for col in self.columnas: - if col != 'Avance_Inscritos': - valor = registro.get(col, '') - fila.append(str(valor)) - self._acumular_totales(totales, col, registro) - else: - total_insc = registro.get('Inscritos_Totales', 0) - meta = registro.get('Meta_Curso', 0) - - if total_insc == "-" or meta == "-": - fila.append("-") - else: - porcentaje = self.calcular_porcentaje_avance(total_insc, meta) - fila.append(self.crear_barra_texto_color(porcentaje)) - - sheet_data.append(fila) - - fila_totales = self.crear_fila_totales(totales) - sheet_data.append(fila_totales) - - return sheet_data, totales - - def _acumular_totales(self, totales, col, registro): - val = registro.get(col, 0) - if val == "-": return # Ignoramos los guiones para que no rompa la suma - - if col == 'Descuento': - totales['descuento'] += val - elif col == 'Inscritos_Mes': - totales['mes'] += val - elif col == 'Retirados': - totales['retirados'] += val - elif col == 'Inscritos_Totales': - totales['totales'] += val - elif col == 'Inscritos_Activos': - totales['activos'] += val - elif col == 'Inscritos_PC': - totales['pc'] += val - elif col == 'Inscritos_Continuidad': - totales['continuidad'] += val - elif col == 'Meta_Curso': - totales['meta'] += val - - def crear_fila_totales(self, totales): - porcentaje = self.calcular_porcentaje_avance(totales['totales'], totales['meta']) - # REORGANIZADO SEGÚN NUEVO ORDEN DE COLUMNAS SOLICITADO: - # [0]Prog, [1]Fch, [2]Dias, [3]Mes, [4]Total, [5]Retirados, [6]Activos, [7]PC, [8]Refriperu, [9]Cont, [10]Meta, [11]Avance - return [ - "TOTAL GENERAL", # PROGRAMA - "", # FECHA INICIO - "", # DIAS PARA INICIO - str(totales['mes']), # INSCRITOS MES - str(totales['totales']), # TOTAL INSCRITOS - str(totales['retirados']), # RETIRADOS - str(totales['activos']), # INSCRITOS EN CURSO - str(totales['pc']), # INSCRITOS P.C - str(totales['descuento']), # INSCRITOS REFRIPERU (Descuento) - str(totales['continuidad']), # INSCRITOS CONTINUIDAD - str(totales['meta']), # META - self.crear_barra_texto_color(porcentaje) # AVANCE INSCRITOS - ] - - def calcular_porcentaje_avance(self, activos, meta): - return (activos / meta * 100) if meta > 0 else 0 - - def crear_barra_texto_color(self, porcentaje): - longitud = 10 - llenas = int((min(porcentaje, 100) / 100) * longitud) - vacias = longitud - llenas - barra = '█' * llenas + '░' * vacias - porc_text = f"{porcentaje:.1f}%" - espacios = " " * (6 - len(porc_text)) - return f"{porc_text}{espacios}{barra}" - - def get_intervalo_actualizacion(self): - return self.data_manager.config_data.get( - 'config_general', {} - ).get('auto_update_minutos', 5) * 60000 - - def exportar_a_excel(self, datos, ano, mes): - df = pd.DataFrame(datos) - df_exportar = df[self.columnas] - archivo = f"cursos_{ano}_{mes}.xlsx" - df_exportar.to_excel(archivo, index=False) - return archivo - - def calcular_metricas_generales(self, datos): - if not datos: return {} - # Filtramos las filas fantasmas para no romper la matemática de Pandas - datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO'] - if not datos_validos: return {} - - df = pd.DataFrame(datos_validos) - metricas = { - 'total_cursos': len(datos), # Mostramos el total real de filas (incluyendo fantasmas) - 'total_inscritos': df['Inscritos_Activos'].sum(), - 'total_meta': df['Meta_Curso'].sum(), - 'porcentaje_avance_general': (df['Inscritos_Activos'].sum() / df['Meta_Curso'].sum() * 100) if df['Meta_Curso'].sum() > 0 else 0, - 'cursos_sobre_meta': len(df[df['Inscritos_Activos'] >= df['Meta_Curso']]), - 'cursos_bajo_meta': len(df[df['Inscritos_Activos'] < df['Meta_Curso']]), - 'total_inscritos_pc': df['Inscritos_PC'].sum() - } - return metricas - - def obtener_top_programas(self, datos, top_n=5): - if not datos: return [] - # Filtramos las filas fantasmas - datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO'] - if not datos_validos: return [] - - df = pd.DataFrame(datos_validos) - df['Porcentaje_Avance'] = df.apply( - lambda x: self.calcular_porcentaje_avance(x['Inscritos_Activos'], x['Meta_Curso']), - axis=1 - ) - top_programas = df.nlargest(top_n, 'Porcentaje_Avance')[ - ['programa_frecuencia', 'Inscritos_Activos', 'Meta_Curso', 'Porcentaje_Avance'] - ].to_dict('records') - return top_programas - - def calcular_tendencias_mensuales(self, ano): - tendencias = {} - for mes in range(1, 13): - try: - datos_mes = self.obtener_datos_procesados(str(ano), str(mes)) - if datos_mes: - metricas = self.calcular_metricas_generales(datos_mes) - tendencias[mes] = metricas - except Exception as e: - print(f"Error procesando mes {mes}: {e}") - continue - return tendencias \ No newline at end of file diff --git a/backend/modules/ocupabilidad/processor.py b/backend/modules/ocupabilidad/processor.py deleted file mode 100644 index 3cd34c3..0000000 --- a/backend/modules/ocupabilidad/processor.py +++ /dev/null @@ -1,677 +0,0 @@ -# modules/ocupabilidad/processor.py -from datetime import datetime -import unicodedata - -class CursoProcessor: - """Procesador de datos de cursos - Aplica transformaciones y cálculos""" - - def __init__(self, data_manager): - self.data_manager = data_manager - - # --- COLUMNAS REORDENADAS Y REORGANIZADAS --- - self.columnas = [ - 'programa_frecuencia', - 'fch_inicio', - 'dias_para_inicio', - 'Inscritos_Mes', - 'Inscritos_Totales', - 'Retirados', - 'Inscritos_Activos', - 'Inscritos_PC', - 'Descuento', # Esto es 'INSCRITOS REFRIPERU' - 'Inscritos_Continuidad', - 'Meta_Curso', - 'Avance_Inscritos' - ] - - def obtener_datos_procesados(self, ano, mes): - try: - # 1. Cargar datos básicos - datos_matriculas = self.data_manager.cargar_datos_matriculas(ano, mes) - datos_raw_matriculas = self.data_manager.ejecutar_consulta_matriculados_detalle(ano, mes) - datos_originales = self.data_manager.ejecutar_consulta_cursos(ano, mes) - - # --- NUEVO: EL GUARDIÁN DE FECHAS (SUPABASE) --- - fechas_historicas = self.data_manager.obtener_fechas_originales() - indices_en_sql = set() - - for fila in datos_originales: - indice = str(fila.get('num_indice', '')).strip() - fecha_actual = fila.get('fch_inicio') - programa = fila.get('dsc_programa', '') - - if indice and fecha_actual: - indices_en_sql.add(indice) # Registramos que sí vino en SQL este mes - if indice not in fechas_historicas: - fecha_str = fecha_actual.strftime('%d/%m/%Y') if isinstance(fecha_actual, datetime) else str(fecha_actual) - exito = self.data_manager.guardar_fecha_original(indice, programa, fecha_str) - if exito: - fechas_historicas[indice] = {'fecha': fecha_str, 'programa': programa} - # ----------------------------------------------- - - # 2. CONTINUIDAD - lista_dnis = [] - if datos_raw_matriculas: - for alumno in datos_raw_matriculas: - dni = alumno.get('dsc_documento') - if dni: lista_dnis.append(dni) - - datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis) - - # 3. Procesar todo - datos_procesados = self.aplicar_personalizaciones( - datos_originales, - datos_matriculas, - datos_raw_matriculas, - datos_historial, - ano, mes - ) - - # --- INYECTAR CURSOS REPROGRAMADOS (FILAS FANTASMAS) --- - if getattr(self, 'mostrar_reprogramados', True): - for idx, info in fechas_historicas.items(): - if idx not in indices_en_sql: # Si el curso NO vino este mes desde SQL... - # Aseguramos compatibilidad si guardaste data vieja como texto o la nueva como diccionario - if isinstance(info, dict): - fecha_orig = info.get('fecha', '') - prog_orig = info.get('programa', 'CURSO REPROGRAMADO') - else: - fecha_orig = str(info) - prog_orig = 'CURSO REPROGRAMADO' - - # Verificamos si la fecha original correspondía a ESTE mes Y AÑO filtrado - mes_orig = "" - ano_orig = "" - if "/" in fecha_orig: - partes = fecha_orig.split("/") - if len(partes) >= 2: mes_orig = str(int(partes[1])) - if len(partes) >= 3: ano_orig = str(int(partes[2])) - elif "-" in fecha_orig: - partes = fecha_orig.split("-") - if len(partes) >= 2: mes_orig = str(int(partes[1])) - if len(partes) >= 3: ano_orig = str(int(partes[2])) - - # Mes debe coincidir; y si la fecha tiene año, el año también debe coincidir. - # Si la fecha NO tiene año (formato viejo dd/mm), no se inyecta para evitar - # mostrarla en años equivocados. - coincide_mes = (mes_orig == str(int(mes))) - coincide_ano = (ano_orig == str(int(ano))) if ano_orig else False - if coincide_mes and coincide_ano: - # Inyectamos la fila falsa para alertar en pantalla - datos_procesados.append({ - 'num_indice': idx, - 'programa_frecuencia': prog_orig, - 'dsc_programa': prog_orig, - 'fch_inicio': fecha_orig, - 'dias_para_inicio': 'REPROGRAMADO', - 'Inscritos_Mes': "-", - 'Inscritos_Totales': "-", - 'Retirados': "-", - 'Inscritos_Activos': "-", - 'Inscritos_PC': "-", - 'Descuento': "-", - 'Inscritos_Continuidad': "-", - 'Meta_Curso': "-", - 'Avance_Inscritos': "-" - }) - # ----------------------------------------------- - - ## --- ORDENAR: NORMALES POR FECHA, REPROGRAMADOS POR SEDE --- - def parse_fecha_orden(fecha_str): - if not fecha_str or fecha_str == "-": - return datetime.max - s = str(fecha_str).replace("/", "-").strip() - try: - if len(s) == 5: - return datetime.strptime(f"{s}-{ano}", "%d-%m-%Y") - if len(s) >= 10: - return datetime.strptime(s[:10], "%d-%m-%Y") - except: - pass - return datetime.max - - def obtener_orden_sede(programa): - prog_upper = str(programa).upper() - if 'LIMA' in prog_upper: return 1 - if 'PIURA' in prog_upper: return 2 - if 'TRUJILLO' in prog_upper: return 3 - if 'AREQUIPA' in prog_upper: return 4 - return 5 - - def logica_ordenamiento(x): - if x.get('dias_para_inicio') == 'REPROGRAMADO': - # Reprogramados: Van al final (1), ordenados por sede, luego alfabéticamente - return (1, obtener_orden_sede(x.get('dsc_programa', '')), x.get('dsc_programa', '')) - else: - # Normales: Van al inicio (0), ordenados por fecha - return (0, 0, parse_fecha_orden(x.get('fch_inicio', ''))) - - # Aplicamos el ordenamiento inteligente - datos_procesados.sort(key=logica_ordenamiento) - - return datos_procesados - except Exception as e: - print(f"❌ Error obteniendo datos procesados: {e}") - return [] - - # ========================================================================= - # DETALLE DE ALUMNOS DE UN CURSO (para el popup "Ver") - # ========================================================================= - def obtener_alumnos_curso(self, num_indice): - """Alumnos matriculados de un curso (num_indice), TODOS menos ANU. - Devuelve nombre + cod_estado (ALU/PRE/RET) + curso anterior TEAC/TERC (si existe).""" - try: - idx = str(num_indice).strip() - conn = self.data_manager.get_connection() - cursor = conn.cursor() - - # 1) Alumnos del curso actual (nombre, estado, dni, fecha de inicio del curso) - sql = """ - SELECT - sgeca_matricula.num_matricula, - sgeca_matricula.cod_estado AS cod_estado, - sgema_alumno.dsc_documento AS dni, - sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno - + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno, - rp.fch_inicio AS fch_inicio_actual - FROM sgeca_matricula - INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno - LEFT JOIN sgede_RP_programa rp - ON sgeca_matricula.cod_periodo = rp.cod_detalle - AND sgeca_matricula.cod_programa = rp.cod_programa - AND sgeca_matricula.num_indice = rp.num_indice - WHERE sgeca_matricula.num_indice = ? - AND sgeca_matricula.cod_localidad LIKE 'SCENT' - AND sgeca_matricula.cod_estado NOT IN ('ANU') - ORDER BY dsc_alumno - """ - cursor.execute(sql, idx) - filas = [] - dnis = [] - for row in cursor.fetchall(): - mat = str(row[0]).strip() - if mat.endswith('.0'): mat = mat[:-2] - dni = str(row[2]).strip() if row[2] else "" - filas.append({ - "matricula": mat, - "estado": str(row[1]).strip().upper(), - "dni": dni, - "alumno": str(row[3]).strip(), - "fch_inicio_actual": self.parse_fecha(row[4]), - "curso_anterior": "-", - "estado_anterior": "-", - }) - if dni: - dnis.append(dni) - - # 2) Historial de esos alumnos (cursos TEAC/TERC, no ANU, no SUS) - historial = {} # dni -> lista de {programa, fecha, estado} - if dnis: - dnis_unicos = list(set(dnis)) - for i in range(0, len(dnis_unicos), 1000): - chunk = dnis_unicos[i:i+1000] - ph = ",".join(["?"] * len(chunk)) - sql_hist = f""" - SELECT a.dsc_documento, - rp.dsc_det_programa AS programa_detallado, - p.dsc_programa AS programa_general, - rp.fch_inicio, m.cod_estado - FROM sgeca_matricula m - INNER JOIN sgema_alumno a ON m.cod_alumno = a.cod_alumno - INNER JOIN sgeca_programa p ON m.cod_programa = p.cod_programa - INNER JOIN sgede_RP_programa rp - ON m.cod_periodo = rp.cod_detalle - AND m.cod_programa = rp.cod_programa - AND m.num_indice = rp.num_indice - WHERE a.dsc_documento IN ({ph}) - AND m.cod_localidad LIKE 'SCENT' - AND m.cod_estado IN ('ALU','PRE','RET') - AND rp.cod_estado <> 'SUS' - """ - cursor.execute(sql_hist, *chunk) - for r in cursor.fetchall(): - d = str(r[0]).strip() - det = str(r[1]).strip() if r[1] else "" - gen = str(r[2]).strip() if r[2] else "" - historial.setdefault(d, []).append({ - "programa": det if det and det != "None" else gen, # detallado (como Cobranza) - "programa_clasif": gen, # general, para clasificar TEAC/TERC - "fecha": self.parse_fecha(r[3]), - "estado": str(r[4]).strip().upper(), - }) - conn.close() - - # 3) Para cada alumno, buscar el ÚLTIMO curso anterior TEAC/TERC - for f in filas: - dni = f["dni"] - fch_act = f["fch_inicio_actual"] - if not dni or not fch_act or dni not in historial: - continue - candidatos = [] - for h in historial[dni]: - if not h["fecha"]: - continue - if not (h["fecha"] < fch_act): # debe ser anterior - continue - cat = self.obtener_categoria_programa(h.get("programa_clasif") or h["programa"]) - if cat not in ("TEAC", "TERC"): # solo TEAC/TERC - continue - candidatos.append(h) - if candidatos: - ultimo = max(candidatos, key=lambda x: x["fecha"]) - f["curso_anterior"] = ultimo["programa"] - f["estado_anterior"] = ultimo["estado"] - - # limpiar campos internos - for f in filas: - f.pop("dni", None) - f.pop("fch_inicio_actual", None) - return filas - except Exception as e: - print(f"❌ Error obtener_alumnos_curso: {e}") - return [] - - def normalizar_texto(self, texto): - if not texto: return "" - texto = str(texto).upper().strip() - texto = unicodedata.normalize('NFD', texto) - texto = texto.encode('ascii', 'ignore').decode("utf-8") - return texto - - def obtener_categoria_programa(self, nombre_programa): - try: - nombre_limpio = self.normalizar_texto(nombre_programa) - clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {}) - - for categoria, config in clasificaciones.items(): - patrones = config.get('patrones', []) - for patron in patrones: - patron_limpio = self.normalizar_texto(patron) - if patron_limpio in nombre_limpio: - return categoria - - return "OTROS" - except: - return "OTROS" - - def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro): - print("🎯 Aplicando personalizaciones...") - cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {}) - datos_filtrados = [] - - try: - ano_target = int(ano_filtro) - mes_target = int(mes_filtro) - except ValueError: - ano_target = 0 - mes_target = 0 - - # 1. FILTRADO - for curso in datos_originales: - num_indice = str(curso.get('num_indice', '')) - - if num_indice in cursos_personalizados: - personalizacion = cursos_personalizados[num_indice] - if 'fch_inicio' in personalizacion: - curso['fch_inicio'] = personalizacion['fch_inicio'] - if 'flg_activo' in personalizacion: - curso['flg_activo'] = personalizacion['flg_activo'] - if 'dsc_programa' in personalizacion: - curso['dsc_programa'] = personalizacion['dsc_programa'] - if 'dsc_det_programa' in personalizacion: - curso['dsc_det_programa'] = personalizacion['dsc_det_programa'] - - if curso.get('flg_activo', '') == 'NO': - continue - - fch_valida = True - try: - raw_fecha = curso.get('fch_inicio') - if raw_fecha: - f_obj = None - s_fecha = str(raw_fecha).strip() - if len(s_fecha) == 10 and s_fecha[2] == '-' and s_fecha[5] == '-': - f_obj = datetime.strptime(s_fecha, '%d-%m-%Y') - elif '-' in s_fecha: - if ' ' in s_fecha: - f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S') - else: - f_obj = datetime.strptime(s_fecha, '%Y-%m-%d') - - if f_obj: - if f_obj.year != ano_target or f_obj.month != mes_target: - fch_valida = False - except Exception: - pass - - if fch_valida: - datos_filtrados.append(curso) - - # 2. TRANSFORMACIONES - datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados) - datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados) - - for curso in datos_filtrados: - if 'fch_inicio' in curso: - curso['fch_inicio'] = self.formatear_fecha(curso['fch_inicio']) - - # 3. DATOS CALCULADOS CON PRIORIDAD (REFRIPERU > CONTINUIDAD) - datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas) - datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu) - - datos_filtrados = self.calcular_dias_para_inicio_cursos(datos_filtrados) - datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas) - datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas) - datos_filtrados = self.agregar_meta_curso(datos_filtrados) - datos_filtrados = self.agregar_avance_inscritos(datos_filtrados) - - return datos_filtrados - - def aplicar_reemplazos_programas(self, datos): - if 'reemplazos_programas' in self.data_manager.replace_data: - reemplazos = self.data_manager.replace_data['reemplazos_programas'] - for curso in datos: - programa_original = curso.get('dsc_programa', '') - if programa_original in reemplazos: - curso['dsc_programa'] = reemplazos[programa_original] - return datos - - def concatenar_programa_frecuencia(self, datos): - for curso in datos: - programa = curso.get('dsc_programa', '') - frecuencia = curso.get('cod_frecuencia', '') - if programa and frecuencia: - curso['programa_frecuencia'] = f"{programa} - {frecuencia}" - elif programa: - curso['programa_frecuencia'] = programa - elif frecuencia: - curso['programa_frecuencia'] = frecuencia - else: - curso['programa_frecuencia'] = "" - return datos - - def formatear_fecha(self, fecha_str): - try: - if not fecha_str: return "" - if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-': return fecha_str - if '.' in str(fecha_str): - return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y') - return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y') - except: return str(fecha_str) - - def identificar_linea_carrera(self, nombre_programa): - categoria = self.obtener_categoria_programa(nombre_programa) - - # --- AQUÍ ESTÁ EL CAMBIO --- - # Si es cualquiera de estas 5, las unimos bajo una misma "Línea Universal" de compatibilidad - if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]: - return "CARRERA_COMPATIBLE" - - # Si es GESTION, MASTERCLASS, SEMINARIOS, etc., retorna None (las sigue ignorando) - return None - - def parse_fecha(self, fecha_raw): - try: - if isinstance(fecha_raw, datetime): return fecha_raw - if not fecha_raw: return None - s_fecha = str(fecha_raw).strip() - if len(s_fecha) == 10 and s_fecha[2] == '-': - return datetime.strptime(s_fecha, '%d-%m-%Y') - if ' ' in s_fecha: - return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S') - return datetime.strptime(s_fecha, '%Y-%m-%d') - except: - return None - - # ========================================================================= - # ❄️ REFRIPERU / DESCUENTO (ALTA PRIORIDAD) - # ========================================================================= - def agregar_descuento(self, datos, datos_raw_matriculas): - print("\n💰 Calculando REFRIPERU (Descuentos)...") - print("="*80) - print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA':<20} | {'PAGO':<10} | {'ZONA/MOTIVO'}") - print("-" * 80) - - conteo_por_curso = {} - alumnos_refriperu = set() - - correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {}) - - if datos_raw_matriculas: - for alumno in datos_raw_matriculas: - try: - estado = str(alumno.get('estado_matricula', '')).strip().upper() - if estado not in ['ALU', 'PRE']: continue - - num_indice = str(alumno.get('num_indice', '')) - - dni = str(alumno.get('dsc_documento', 'SD')).strip() - nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip() - - mat_id = str(alumno.get('num_matricula', '')).strip() - if mat_id.endswith('.0'): mat_id = mat_id[:-2] - - if mat_id in correcciones_descuento: - accion = str(correcciones_descuento[mat_id]).upper().strip() - if accion == "SI": - conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1 - alumnos_refriperu.add(mat_id) - print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {'---':<20} | {'---':<10} | MANUAL (JSON)") - continue - elif accion == "NO": - continue - - nombre_programa = str(alumno.get('dsc_programa', '')) - - cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper() - try: - inv_neta = float(alumno.get('INV_NETA', 0) or 0) - imp_tc = float(alumno.get('imp_tc', 1) or 1) - if imp_tc <= 0: imp_tc = 1.0 - except: - inv_neta = 0.0; imp_tc = 1.0 - - if cod_moneda == 'DOL': - inv_neta_final = inv_neta * imp_tc - else: - inv_neta_final = inv_neta - - inv_neta_final = round(inv_neta_final, 2) - - categoria_json = self.obtener_categoria_programa(nombre_programa) - es_descuento = False - motivo_debug = "" - - if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"]: - if 1200 <= inv_neta_final <= 1700: - es_descuento = True - motivo_debug = f"{categoria_json} (Rango Prov)" - - elif categoria_json in ["TEAC", "TERC"]: - if 1400 <= inv_neta_final <= 1900: - es_descuento = True - motivo_debug = f"{categoria_json} (Rango Lima)" - - if es_descuento: - conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1 - alumnos_refriperu.add(mat_id) - print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_programa[:20]:<20} | S/{inv_neta_final:<8} | {motivo_debug}") - - except Exception: - continue - - for curso in datos: - idx = str(curso.get('num_indice', '')) - curso['Descuento'] = conteo_por_curso.get(idx, 0) - - return datos, alumnos_refriperu - - # ========================================================================= - # 🔄 CONTINUIDAD (BAJA PRIORIDAD) - # ========================================================================= - def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu=None): - if alumnos_refriperu is None: - alumnos_refriperu = set() - - print("🔄 Calculando CONTINUIDAD...") - print("="*80) - print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA ACTUAL':<30} | {'MOTIVO/ORIGEN'}") - print("-" * 80) - - conteo_por_curso = {} - - correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {}) - MIN_DIAS_DIFERENCIA = 60 - - if datos_raw_matriculas and datos_historial: - alumnos_por_curso = {} - for alu in datos_raw_matriculas: - idx = str(alu.get('num_indice', '')) - if idx not in alumnos_por_curso: alumnos_por_curso[idx] = [] - alumnos_por_curso[idx].append(alu) - - for curso in datos: - num_indice = str(curso.get('num_indice', '')) - nombre_curso_actual = str(curso.get('dsc_programa', '')) - fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio')) - - # Identifica si el curso actual pertenece a la "Línea Universal" de 5 categorías - linea_actual = self.identificar_linea_carrera(nombre_curso_actual) - - if not linea_actual or not fecha_inicio_actual: continue - - lista_alumnos = alumnos_por_curso.get(num_indice, []) - contador_fieles = 0 - - for alumno in lista_alumnos: - estado = str(alumno.get('estado_matricula', '')).strip().upper() - if estado not in ['ALU', 'PRE']: continue - - mat_id = str(alumno.get('num_matricula', '')).strip() - if mat_id.endswith('.0'): mat_id = mat_id[:-2] - - if mat_id in alumnos_refriperu: - continue - - dni = str(alumno.get('dsc_documento', 'SD')).strip() - nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip() - - es_manual_si = False - if mat_id in correcciones_continuidad: - accion = str(correcciones_continuidad[mat_id]).upper().strip() - if accion == "SI": es_manual_si = True - elif accion == "NO": continue - - es_fiel = False - motivo = "" - - if es_manual_si: - es_fiel = True - motivo = "MANUAL (JSON)" - elif dni and dni in datos_historial: - historial_alumno = datos_historial[dni] - for antecedente in historial_alumno: - nombre_pasado = str(antecedente.get('programa', '')) - fecha_pasada = antecedente.get('fecha') - if not fecha_pasada: continue - if not (fecha_pasada < fecha_inicio_actual): continue - dias_diff = (fecha_inicio_actual - fecha_pasada).days - if dias_diff < MIN_DIAS_DIFERENCIA: continue - - # Revisa si el curso pasado también es de esa misma línea universal - linea_pasada = self.identificar_linea_carrera(nombre_pasado) - - # Si ambos devuelven "CARRERA_COMPATIBLE", entonces hacen match - if linea_pasada == linea_actual: - es_fiel = True - motivo = f"Viene de: {nombre_pasado}" - break - - if es_fiel: - contador_fieles += 1 - print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_curso_actual[:30]:<30} | {motivo}") - - conteo_por_curso[num_indice] = contador_fieles - - for curso in datos: - idx = str(curso.get('num_indice', '')) - curso['Inscritos_Continuidad'] = conteo_por_curso.get(idx, 0) - - return datos - - def calcular_dias_para_inicio(self, fecha_str, cod_estado): - try: - if cod_estado == 'SUS': return "SUSPENDIDO" - if not fecha_str: return "" - if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-': - fecha_curso = datetime.strptime(fecha_str, '%d-%m-%Y') - else: - fecha_curso = datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S') - hoy = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - fecha_curso = fecha_curso.replace(hour=0, minute=0, second=0, microsecond=0) - diferencia = (fecha_curso - hoy).days - if diferencia < 0: return "INICIADO" - return str(diferencia) if diferencia != 0 else "0" - except: return "" - - def calcular_dias_para_inicio_cursos(self, datos): - for curso in datos: - curso['dias_para_inicio'] = self.calcular_dias_para_inicio(curso.get('fch_inicio'), curso.get('cod_estado')) - return datos - - def agregar_inscritos_mes(self, datos, datos_matriculas): - dict_norm = {str(k): v for k, v in datos_matriculas.items()} - for curso in datos: - num_indice = str(curso.get('num_indice', '')) - curso['Inscritos_Mes'] = dict_norm.get(num_indice, 0) - return datos - - def agregar_inscritos_pc(self, datos, datos_raw_matriculas): - conteo_pc = {} - if datos_raw_matriculas: - for m in datos_raw_matriculas: - try: - est = str(m.get('estado_matricula', '')).strip() - nid = str(m.get('num_indice', '')) - sm = float(m.get('imp_saldo_matricula', 0) or 0) - sc1 = float(m.get('imp_saldo_cuota1', 0) or 0) - if est in ['ALU', 'PRE'] and sm < 1 and sc1 < 1: - conteo_pc[nid] = conteo_pc.get(nid, 0) + 1 - except: continue - for curso in datos: - curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0) - curso['Retirados'] = int(curso.get('Inscritos_Retirados', 0)) # <--- NUEVA EXTRACCIÓN - return datos - - def calcular_meta_curso(self, nombre_programa, cod_estado): - try: - if cod_estado == 'SUS': return 0 - - categoria = self.obtener_categoria_programa(nombre_programa) - - clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {}) - if categoria in clasificaciones: - return clasificaciones[categoria].get('valor', 0) - - return self.data_manager.meta_data.get('clasificacion_default', {}).get('valor', 15) - except: return 0 - - def agregar_meta_curso(self, datos): - print("🎯 Calculando META_CURSO...") - for curso in datos: - curso['Meta_Curso'] = self.calcular_meta_curso(curso.get('dsc_programa', ''), curso.get('cod_estado', '')) - return datos - - def calcular_avance_inscritos(self, inscritos_activos, meta_curso): - try: - if meta_curso > 0: - porcentaje = (inscritos_activos / meta_curso) * 100 - return f"{porcentaje:.1f}%" - else: return "0%" - except: return "0%" - - def agregar_avance_inscritos(self, datos): - print("📊 Calculando AVANCE_INSCRITOS...") - for curso in datos: - curso['Avance_Inscritos'] = self.calcular_avance_inscritos(curso.get('Inscritos_Totales', 0), curso.get('Meta_Curso', 0)) - return datos \ No newline at end of file diff --git a/backend/modules/rentabilidad/__init__.py b/backend/modules/rentabilidad/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/modules/rentabilidad/logic.py b/backend/modules/rentabilidad/logic.py deleted file mode 100644 index 0202717..0000000 --- a/backend/modules/rentabilidad/logic.py +++ /dev/null @@ -1,946 +0,0 @@ -# modules/rentabilidad/logic.py -import pandas as pd -import requests -import re -from datetime import datetime -from .processor import RentabilidadProcessor - -class RentabilidadLogic: - """Controlador lógico de Rentabilidad - Estructura la tabla y gestiona variables de clasificación""" - - def __init__(self, data_manager): - self.data_manager = data_manager - self.processor = RentabilidadProcessor(self.data_manager) - - def obtener_categorias(self): - """Devuelve la lista simplificada y agrupada para el filtro del Dashboard""" - return ["AREQUIPA", "TRUJILLO", "PIURA", "PROGRAMAS", "SEMINARIOS", "OTROS"] - - def obtener_sedes(self): - """Devuelve la lista de sedes para el nuevo filtro""" - return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"] - - def identificar_sede(self, dsc_programa): - """Identifica la sede basada en el nombre del programa y el JSON de GitHub""" - if not dsc_programa: return "LIMA" - dsc_prog_upper = str(dsc_programa).upper() - sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {}) - - for sede, data in sedes_data.items(): - if sede == "DEFAULT": continue - for patron in data.get("patrones", []): - if patron.upper() in dsc_prog_upper: - return sede - return sedes_data.get("DEFAULT", "LIMA") - - def obtener_filtros_programa(self): - """Devuelve la lista de programas para el nuevo filtro combinado""" - return ["SEMINARIOS", "OTROS", "TEAC", "TERC"] - - def identificar_filtro_programa(self, dsc_programa): - """Identifica la categoría del programa para el filtro de la UI""" - if not dsc_programa: return "OTROS" - dsc_prog_upper = str(dsc_programa).upper() - filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {}) - - for categoria, data in filtro_data.items(): - if categoria == "DEFAULT": continue - for patron in data.get("patrones", []): - if patron.upper() in dsc_prog_upper: - return categoria - return filtro_data.get("DEFAULT", "OTROS") - - def clasificar_programa(self, dsc_programa): - if not dsc_programa: return "OTROS" - dsc_prog_upper = str(dsc_programa).upper() - meta_data = self.data_manager.meta_data - clasificaciones = meta_data.get("clasificacion_programas", {}) - - for clase, data in clasificaciones.items(): - for patron in data.get("patrones", []): - if patron.upper() in dsc_prog_upper: - return clase - return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS") - - def identificar_linea_carrera(self, nombre_programa): - """Identifica si el programa es de la familia de Carreras Técnicas""" - categoria = self.clasificar_programa(nombre_programa) - if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]: - return "CARRERA_COMPATIBLE" - return None - - def parse_fecha(self, fecha_raw): - """Convierte diferentes formatos de fecha a objeto datetime""" - try: - if isinstance(fecha_raw, datetime): return fecha_raw - if not fecha_raw: return None - s_fecha = str(fecha_raw).strip() - if len(s_fecha) == 10 and s_fecha[2] == '-': - return datetime.strptime(s_fecha, '%d-%m-%Y') - if ' ' in s_fecha: - return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S') - return datetime.strptime(s_fecha, '%Y-%m-%d') - except: - return None - - def calcular_descuento(self, clasificacion, inv_neta): - try: inv_neta = float(inv_neta) - except: inv_neta = 0.0 - - if clasificacion in ["TEAC", "TERC"]: return 3499.0 - inv_neta - elif clasificacion in ["AREQUIPA", "PIURA", "TRUJILLO"]: return 2839.0 - inv_neta - elif clasificacion == "CARRERA": return 4299.0 - inv_neta - else: return None - - def calcular_estado_descuento(self, clasificacion, descuento): - if descuento is None: return "" - es_provincia = clasificacion in ["TRUJILLO", "AREQUIPA", "PIURA"] - tope = 1000.0 if es_provincia else 800.0 - - if descuento > tope or descuento < 0: return "NO" - return "SI" - - # ======================================================================== - # MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE - # ======================================================================== - # ======================================================================== - # MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE - # ======================================================================== - def _obtener_costo_segun_categoria(self, diccionario_costo, categoria, dsc_programa): - """Busca el costo inteligente. Si es 'otros', busca similitud de palabras.""" - cat_lower = str(categoria).lower() - - # 1. Agrupamos los técnicos dentro de 'programas' - if cat_lower in ["teac", "terc"]: - cat_lower = "programas" - - # 2. EL EMBUDO: Si la categoría no existe en el diccionario (ej. seminarios, masterclass, etc.), - # la forzamos a que caiga siempre en la bolsa de "otros" - if cat_lower not in diccionario_costo: - cat_lower = "otros" - - # 3. Buscamos el valor - if cat_lower in diccionario_costo: - valor = diccionario_costo[cat_lower] - - # Si el valor es un bloque de similitudes (Como sucede ahora con "otros") - if isinstance(valor, dict): - dsc_upper = str(dsc_programa).upper() - - # Buscamos coincidencias con el nombre completo del curso - for patron, monto in valor.items(): - if patron != "DEFAULT" and patron.upper() in dsc_upper: - return float(monto) - - # Si lee todo el diccionario y no encuentra coincidencia, usamos el DEFAULT - return float(valor.get("DEFAULT", 0)) - else: - # Si es un número directo (ej. piura, trujillo, carrera) - return float(valor) - - return 0.0 - - # ======================================================================== - - def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"): - """Obtiene datos, inyecta Categoria_Programa, Sede, Filtro_Programa y calcula promedios.""" - datos = self.processor.obtener_datos_procesados(ano, mes) - programas_disponibles = set() - - if datos: - for d in datos: - prog = str(d.get('programa_frecuencia', d.get('dsc_programa', ''))) - d['Categoria_Programa'] = self.clasificar_programa(prog) - d['Sede'] = self.identificar_sede(prog) - d['Filtro_Programa'] = self.identificar_filtro_programa(prog) - - # Recolectar qué programas existen REALMENTE en la Sede seleccionada - if sede == "TODOS" or d['Sede'] == sede: - programas_disponibles.add(d['Filtro_Programa']) - - # Ordenar para el Dropdown en la UI - orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"] - self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles] - - # NO auto-corregir: se respeta el filtro y la tabla queda vacia si no hay. - self.filtro_corregido = None - - if datos and (sede != "TODOS" or filtro_prog != "TODOS"): - datos_filtrados = [] - for d in datos: - cumple_sede = (sede == "TODOS" or d.get('Sede') == sede) - cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog) - - if cumple_sede and cumple_prog: - datos_filtrados.append(d) - datos = datos_filtrados - - if not datos: return [] - - sql_matriculas = self._get_sql_matriculas_modificado() - sql_cuotas = self._get_sql_cuotas() - - matriculados_raw = [] - cuotas_raw = [] - - if sql_matriculas and sql_cuotas: - try: - conn = self.data_manager.get_connection() - cursor = conn.cursor() - try: - cursor.execute(sql_matriculas, ano, mes) - cols_mat = [c[0] for c in cursor.description] - matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()] - - cursor.execute(sql_cuotas, ano, mes) - cols_cuo = [c[0] for c in cursor.description] - cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()] - finally: - conn.close() - except Exception as e: - print(f"❌ Error en consultas DAX: {e}") - - promedios_dax = self._calcular_promedios_dax(matriculados_raw, cuotas_raw) - - for d in datos: - idx = str(d.get('num_indice', '')).strip() - if idx.endswith('.0'): idx = idx[:-2] - - if idx in promedios_dax: - d['Promedio_Cuota'] = promedios_dax[idx]['promedio'] - d['Suma_Dax_Real'] = promedios_dax[idx]['suma_real'] - d['Conteo_Dax_Real'] = promedios_dax[idx]['conteo_real'] - d['Promedio_Desc_E'] = promedios_dax[idx]['promedio_desc_e'] - d['Suma_Desc_E_Real'] = promedios_dax[idx]['suma_desc_e_real'] - d['Conteo_Desc_E_Real'] = promedios_dax[idx]['conteo_desc_e_real'] - d['Valor_Venta'] = promedios_dax[idx]['suma_valor_venta'] - d['Valor_Venta_Actual'] = promedios_dax[idx]['suma_valor_venta_actual'] - else: - d['Promedio_Cuota'] = 0.0 - d['Suma_Dax_Real'] = 0.0 - d['Conteo_Dax_Real'] = 0 - d['Promedio_Desc_E'] = 0.0 - d['Suma_Desc_E_Real'] = 0.0 - d['Conteo_Desc_E_Real'] = 0 - d['Valor_Venta'] = 0.0 - d['Valor_Venta_Actual'] = 0.0 - - return datos - - def _calcular_promedios_dax(self, matriculados, cuotas): - alumnos_dict = {} - agrupado_indices = {} - - lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"] - correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {}) - correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {}) - - for m in matriculados: - estado_mat = str(m.get('estado_matricula', '')).strip().upper() - if estado_mat not in ['ALU', 'PRE', 'RET']: continue - - num_matricula = str(m.get('num_matricula', '')).strip() - if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2] - - num_indice = str(m.get('num_indice', '')).strip() - if num_indice.endswith('.0'): num_indice = num_indice[:-2] - - prog = str(m.get('dsc_programa', '')) - try: inv_neta = float(m.get('INV_NETA', 0.0)) - except: inv_neta = 0.0 - - tc_override = None - if num_matricula in correcciones_mat: - if "imp_tc" in correcciones_mat[num_matricula]: - tc_override = float(correcciones_mat[num_matricula]["imp_tc"]) - - # TC del COMPROBANTE — único TC para todo (INV_NETA + cuotas) - if tc_override is not None: - imp_tc_comp = tc_override - else: - try: imp_tc_comp = float(m.get('imp_tc', 0) or 0) - except: imp_tc_comp = 0 - if imp_tc_comp < 2: imp_tc_comp = 3.45 # TC por defecto - - # CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar) - if num_matricula in correcciones_inv_neta: - try: inv_neta = float(correcciones_inv_neta[num_matricula]) - except: pass - else: - cod_moneda_mat = str(m.get('cod_moneda', '')).strip().upper() - if cod_moneda_mat == "DOL": - inv_neta = inv_neta * imp_tc_comp - - clase = self.clasificar_programa(prog) - descuento = self.calcular_descuento(clase, inv_neta) - estado_desc = self.calcular_estado_descuento(clase, descuento) - - alumnos_dict[num_matricula] = { - 'num_indice': num_indice, - 'clasificacion': clase, - 'programa': prog, - 'estado_descuento': estado_desc, - 'inv_neta': inv_neta, - 'estado_mat': estado_mat, - 'monto_real_cuotas': 0.0, - 'imp_tc_comprobante': imp_tc_comp - } - - if num_indice not in agrupado_indices: - agrupado_indices[num_indice] = { - 'suma': 0.0, 'conteo': 0, - 'suma_desc_e': 0.0, 'conteo_desc_e': 0, - 'suma_valor_venta': 0.0, - 'suma_valor_venta_actual': 0.0 - } - - agrupado_indices[num_indice]['suma_valor_venta'] += inv_neta - - # ALU y PRE: suma INV_NETA completa - if estado_mat in ['ALU', 'PRE']: - agrupado_indices[num_indice]['suma_valor_venta_actual'] += inv_neta - # RET: suma solo lo que ya pagó - elif estado_mat == 'RET': - try: imp_pagado = float(m.get('imp_total_pagado', 0) or 0) - except: imp_pagado = 0.0 - agrupado_indices[num_indice]['suma_valor_venta_actual'] += imp_pagado - - if clase not in lista_mirar_cuotas: - agrupado_indices[num_indice]['suma'] += inv_neta - agrupado_indices[num_indice]['conteo'] += 1 - - for c in cuotas: - num_matricula = str(c.get('num_matricula', '')).strip() - if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2] - - if num_matricula not in alumnos_dict: continue - - alumno = alumnos_dict[num_matricula] - num_idx = alumno['num_indice'] - - if alumno['clasificacion'] in lista_mirar_cuotas: - try: num_cuota = int(c.get('num_cuota', 0)) - except: num_cuota = 0 - - t_val = c.get('imp_total') - d_val = c.get('imp_dscto') - - try: t_monto = float(t_val) if t_val is not None else 0.0 - except: t_monto = 0.0 - try: d_monto = float(d_val) if d_val is not None else 0.0 - except: d_monto = 0.0 - - imp_cuota = t_monto - d_monto - - cod_moneda_cuo = str(c.get('cod_moneda', '')).strip().upper() - if cod_moneda_cuo == "DOL": - # USAR el TC del COMPROBANTE (ya validado con fallback 3.45) - tc_cuo = alumno['imp_tc_comprobante'] - imp_cuota = imp_cuota * tc_cuo - - cumple_cuota_normal = (alumno['estado_descuento'] == "SI" and num_cuota > 0 and imp_cuota < 1000) - cumple_desc_especial = (alumno['estado_descuento'] == "SI" and num_cuota > 0) - - if cumple_cuota_normal: - agrupado_indices[num_idx]['suma'] += imp_cuota - agrupado_indices[num_idx]['conteo'] += 1 - - if cumple_desc_especial: - alumno['monto_real_cuotas'] += imp_cuota - - for mat_id, alumno in alumnos_dict.items(): - idx = alumno['num_indice'] - clase = alumno['clasificacion'] - estado_desc = alumno['estado_descuento'] - - if clase in lista_mirar_cuotas: - monto_real = alumno['monto_real_cuotas'] - else: - monto_real = alumno['inv_neta'] - - valor_final = 0.0 - - if monto_real >= 1900 and estado_desc == "SI": - if clase in ["TEAC", "TERC"]: - desc_calc = 3400 - monto_real - elif clase in ["AREQUIPA", "PIURA", "TRUJILLO"]: - desc_calc = 2740 - monto_real - else: - desc_calc = 0 - - resta = desc_calc - 200 - valor_final = min(max(resta, 0.0), 200.0) - - agrupado_indices[idx]['suma_desc_e'] += valor_final - agrupado_indices[idx]['conteo_desc_e'] += 1 - - promedios = {} - for idx, totales in agrupado_indices.items(): - suma_total = totales['suma'] - conteo_total = totales['conteo'] - suma_desc_e = totales['suma_desc_e'] - conteo_desc_e = totales['conteo_desc_e'] - - promedios[idx] = { - 'promedio': suma_total / conteo_total if conteo_total > 0 else 0.0, - 'suma_real': suma_total, - 'conteo_real': conteo_total, - 'promedio_desc_e': suma_desc_e / conteo_desc_e if conteo_desc_e > 0 else 0.0, - 'suma_desc_e_real': suma_desc_e, - 'conteo_desc_e_real': conteo_desc_e, - 'suma_valor_venta': totales['suma_valor_venta'], - 'suma_valor_venta_actual': totales['suma_valor_venta_actual'] # <-- NUEVO - } - - return promedios - - def formatear_datos_para_tabla(self, datos): - """Estructura las filas finales para tksheet (14 columnas)""" - if not datos: return [] - - cfg_costos = self.data_manager.costos_data - if not cfg_costos: - cfg_costos = { - "epp": {"piura": 28, "trujillo": 28, "arequipa": 28, "programas": 28, "carrera": 28, "otros": 0}, - "certificado": {"piura": 7, "trujillo": 7, "arequipa": 7, "programas": 7, "carrera": 7, "otros": 0}, - "consumibles": {"piura": 676, "trujillo": 676, "arequipa": 676, "programas": 614, "carrera": 614, "otros": 0}, - "marketing": {"piura": 2839, "trujillo": 2839, "arequipa": 2839, "programas": 3499, "carrera": 3499, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 1000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 900, "DIPLOMADO INTERNACIONAL": 1300, "SEMINARIO VIRTUAL METRADO , COSTEO": 800, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 1000, "DEFAULT": 0}}, - "docente": {"piura": 5115, "trujillo": 5115, "arequipa": 6500, "programas": 4560, "carrera": 4560, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 4000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 3000, "DIPLOMADO INTERNACIONAL": 7000, "SEMINARIO VIRTUAL METRADO , COSTEO": 2000, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 4000, "DEFAULT": 0}} - } - - # CARGA OVERRIDES DESDE SUPABASE (una sola vez por render) - overrides_supabase = self.data_manager.cargar_overrides_costos() - - sheet_data = [] - tot_total = tot_retirados = tot_curso = 0 - tot_venta = 0.0 - tot_venta_actual = 0.0 - tot_costo = 0.0 - tot_costo_actual = 0.0 - - bolsa_plata_global = 0.0 - bolsa_recibos_global = 0 - bolsa_desc_e_plata_global = 0.0 - bolsa_desc_e_recibos_global = 0 - - for d in datos: - try: total_inscritos = int(d.get('Inscritos_Totales', 0)) - except: total_inscritos = 0 - try: retirados = int(d.get('Retirados', 0)) - except: retirados = 0 - try: en_curso = int(d.get('Inscritos_En_Curso', d.get('Inscritos_Activos', 0))) - except: en_curso = 0 - - p_cuota = float(d.get('Promedio_Cuota', 0.0)) - p_desc = float(d.get('Promedio_Desc_E', 0.0)) - venta = float(d.get('Valor_Venta', 0.0)) - venta_actual = float(d.get('Valor_Venta_Actual', 0.0)) - - conteo_cuota_real = int(d.get('Conteo_Dax_Real', 0)) - str_cuota_tabla = f"S/ {p_cuota:,.0f}" if conteo_cuota_real > 0 else "" - - conteo_desc_e = int(d.get('Conteo_Desc_E_Real', 0)) - str_desc_e_tabla = f"S/ {p_desc:,.0f}" if conteo_desc_e > 0 else "" - - cat = d.get('Categoria_Programa', 'OTROS') - nombre_prog_crudo = d.get('dsc_programa', '') - - # Identificador de curso para buscar override - num_idx_curso = str(d.get('num_indice', '')).strip() - if num_idx_curso.endswith('.0'): num_idx_curso = num_idx_curso[:-2] - ov_curso = overrides_supabase.get(num_idx_curso, {}) - ov_inicial = ov_curso.get('inicial', {}) - ov_actual = ov_curso.get('actual', {}) - - # ============= COSTO INICIAL (con override si existe) ============= - def _costo_inicial(key_supa, key_cfg, multiplicar): - val = ov_inicial.get(key_supa) - if val is not None: - return float(val) # Supabase manda directo, sin multiplicar - base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo) - return total_inscritos * base if multiplicar else base - - costo_epp = _costo_inicial('epp', 'epp', True) - costo_cert = _costo_inicial('certificado', 'certificado', True) - costo_cons = _costo_inicial('consumibles', 'consumibles', True) - costo_mkt = _costo_inicial('marketing', 'marketing', False) - costo_doc = _costo_inicial('docente', 'docente', False) - valor_costo_inicial = costo_epp + costo_cert + costo_cons + costo_mkt + costo_doc - - # ============= COSTO ACTUAL (con override si existe) ============= - def _costo_actual(key_supa, key_cfg, multiplicador): - val = ov_actual.get(key_supa) - if val is not None: - return float(val) # Supabase manda directo, sin multiplicar - base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo) - return multiplicador * base if multiplicador is not None else base - - costo_epp_actual = _costo_actual('epp', 'epp', total_inscritos) - costo_cert_actual = _costo_actual('certificado', 'certificado', en_curso) - costo_cons_actual = _costo_actual('consumibles', 'consumibles', total_inscritos) - costo_mkt_actual = _costo_actual('marketing', 'marketing', None) - costo_doc_actual = _costo_actual('docente', 'docente', None) - costo_actual = costo_epp_actual + costo_cert_actual + costo_cons_actual + costo_mkt_actual + costo_doc_actual - - # MARGEN BRUTO % - if venta > 0: - margen_bruto = 1 - (valor_costo_inicial / venta) - str_margen = f"{margen_bruto * 100:,.1f}%" - else: - str_margen = "" - - # MARGEN BRUTO ACTUAL % - if venta_actual > 0: - margen_bruto_actual = 1 - (costo_actual / venta_actual) - str_margen_actual = f"{margen_bruto_actual * 100:,.1f}%" - else: - str_margen_actual = "" - - tot_total += total_inscritos - tot_retirados += retirados - tot_curso += en_curso - tot_venta += venta - tot_venta_actual += venta_actual - tot_costo += valor_costo_inicial - tot_costo_actual += costo_actual - - bolsa_plata_global += float(d.get('Suma_Dax_Real', 0.0)) - bolsa_recibos_global += int(d.get('Conteo_Dax_Real', 0)) - bolsa_desc_e_plata_global += float(d.get('Suma_Desc_E_Real', 0.0)) - bolsa_desc_e_recibos_global += int(d.get('Conteo_Desc_E_Real', 0)) - - nombre_prog = d.get('programa_frecuencia', d.get('dsc_programa', '')) - - fila = [ - nombre_prog, - d.get('fch_inicio', ''), - total_inscritos, - retirados, - en_curso, - str_cuota_tabla, - str_desc_e_tabla, - f"S/ {venta:,.0f}", - f"S/ {valor_costo_inicial:,.0f}", - str_margen, - f"S/ {venta_actual:,.0f}", - f"S/ {costo_actual:,.0f}", - str_margen_actual, - " ≡ ▼ " - ] - sheet_data.append(fila) - - promedio_total_final = (bolsa_plata_global / bolsa_recibos_global) if bolsa_recibos_global > 0 else 0.0 - promedio_desc_e_final = (bolsa_desc_e_plata_global / bolsa_desc_e_recibos_global) if bolsa_desc_e_recibos_global > 0 else 0.0 - - str_total_cuota = f"S/ {promedio_total_final:,.0f}" if bolsa_recibos_global > 0 else "" - str_total_desc_e = f"S/ {promedio_desc_e_final:,.0f}" if bolsa_desc_e_recibos_global > 0 else "" - - margen_total = 1 - (tot_costo / tot_venta) if tot_venta > 0 else 0 - margen_total_actual = 1 - (tot_costo_actual / tot_venta_actual) if tot_venta_actual > 0 else 0 - fila_total = [ - "TOTAL GENERAL", "", - tot_total, - tot_retirados, - tot_curso, - str_total_cuota, - str_total_desc_e, - f"S/ {tot_venta:,.0f}", - f"S/ {tot_costo:,.0f}", - f"{margen_total * 100:,.1f}%", - f"S/ {tot_venta_actual:,.0f}", - f"S/ {tot_costo_actual:,.0f}", - f"{margen_total_actual * 100:,.1f}%", - "" - ] - sheet_data.append(fila_total) - - return sheet_data - - def exportar_detalle_alumnos_excel(self, programa, headers, datos): - if not datos: - raise ValueError("No hay datos para exportar") - - prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:40] - archivo = f"Detalle_Alumnos_{prog_limpio}.xlsx" - - df = pd.DataFrame(datos, columns=headers) - - try: - with pd.ExcelWriter(archivo, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Detalle_Alumnos') - worksheet = writer.sheets['Detalle_Alumnos'] - - try: - from openpyxl.styles import PatternFill, Font, Border, Side, Alignment - fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid") - font_header = Font(color="FFFFFF", bold=True) - thin_border = Border( - left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"), - top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD") - ) - - for cell in worksheet[1]: - cell.fill = fill_header - cell.font = font_header - cell.border = thin_border - cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) - - for row in worksheet.iter_rows(min_row=2): - for cell in row: - cell.border = thin_border - cell.alignment = Alignment(vertical="center") - - for col in worksheet.columns: - max_len = 0 - col_letter = col[0].column_letter - for cell in col: - if cell.value: - max_len = max(max_len, len(str(cell.value))) - worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50) - except Exception: pass - except Exception as e: - df.to_excel(archivo, index=False) - - return archivo - - def obtener_detalle_programa(self, programa_target, ano, mes_numero): - prog_t = str(programa_target).strip().upper() - - sql_matriculas = self._get_sql_matriculas_modificado() - sql_cuotas = self._get_sql_cuotas() - - matriculados_raw = [] - cuotas_raw = [] - - if sql_matriculas and sql_cuotas: - try: - conn = self.data_manager.get_connection() - cursor = conn.cursor() - try: - cursor.execute(sql_matriculas, ano, mes_numero) - cols_mat = [c[0] for c in cursor.description] - matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()] - - cursor.execute(sql_cuotas, ano, mes_numero) - cols_cuo = [c[0] for c in cursor.description] - cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()] - finally: - conn.close() - except Exception as e: - print(f"❌ Error en consultas detalle: {e}") - - cuotas_por_mat = {} - for c in cuotas_raw: - m_id = str(c.get('num_matricula', '')).strip() - if m_id.endswith('.0'): m_id = m_id[:-2] - if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = [] - cuotas_por_mat[m_id].append(c) - - cursos = self.processor.obtener_datos_procesados(ano, mes_numero) - indices_target = [] - fecha_inicio_actual = None - linea_actual = self.identificar_linea_carrera(prog_t) - - for c in cursos: - nombre = str(c.get('programa_frecuencia', c.get('dsc_programa', ''))).strip().upper() - if nombre == prog_t: - idx = str(c.get('num_indice', '')).strip() - if idx.endswith('.0'): idx = idx[:-2] - indices_target.append(idx) - if not fecha_inicio_actual: - fecha_inicio_actual = self.parse_fecha(c.get('fch_inicio')) - - lista_dnis = [] - for m in matriculados_raw: - dni = m.get('dsc_documento') - if dni: lista_dnis.append(str(dni).strip()) - - try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis) - except: datos_historial = {} - - correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {}) - correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {}) - correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {}) - correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {}) - - lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"] - - acum_inv_neta_global = 0.0 - acum_suma_cuotas_global = 0.0 - acum_cont_cuotas_global = 0 - acum_suma_desc_e_global = 0.0 - acum_cont_desc_e_global = 0 - - alumnos_lista = [] - - for m in matriculados_raw: - estado_mat = str(m.get('estado_matricula', '')).strip().upper() - if estado_mat not in ['ALU', 'PRE', 'RET']: continue - - idx_mat = str(m.get('num_indice', '')).strip() - if idx_mat.endswith('.0'): idx_mat = idx_mat[:-2] - - nombre_prog_crudo = str(m.get('dsc_programa', '')).strip().upper() - - if (idx_mat in indices_target) or (prog_t in nombre_prog_crudo) or (nombre_prog_crudo in prog_t): - - vendedor = str(m.get('dsc_vendedor', 'SIN VENDEDOR')).strip() - if vendedor == "None" or not vendedor: vendedor = "SIN VENDEDOR" - - alumno_nombre = str(m.get('nombre_alumno', m.get('dsc_alumno', 'SIN NOMBRE'))).strip() - if alumno_nombre == "None" or not alumno_nombre: alumno_nombre = "SIN NOMBRE" - - mat_id = str(m.get('num_matricula', '')).strip() - if mat_id.endswith('.0'): mat_id = mat_id[:-2] - dni = str(m.get('dsc_documento', '')).strip() - - try: inv_neta_raw = float(m.get('INV_NETA', 0.0)) - except: inv_neta_raw = 0.0 - cod_moneda = str(m.get('cod_moneda', 'SOL')).strip().upper() - try: tc = float(m.get('imp_tc', 0) or 0) - except: tc = 0 - if tc < 2: tc = 3.45 # TC por defecto si comprobante inválido - - fch_canc_raw = m.get('fch_cancelacion_cuota1', '') - - if mat_id in correcciones_mat: - datos_corregidos = correcciones_mat[mat_id] - if "imp_tc" in datos_corregidos: tc = float(datos_corregidos["imp_tc"]) - if "fch_cancelacion_cuota1" in datos_corregidos: fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"] - - # CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar) - if mat_id in correcciones_inv_neta: - try: inv_neta_soles = float(correcciones_inv_neta[mat_id]) - except: inv_neta_soles = inv_neta_raw - elif cod_moneda == "DOL": - inv_neta_soles = inv_neta_raw * tc - else: - inv_neta_soles = inv_neta_raw - inv_neta_soles = round(inv_neta_soles, 2) - - acum_inv_neta_global += inv_neta_soles - - fch_mat = m.get('fch_matricula', '') - fecha_mat_limpia = "" - if fch_mat: - try: - if hasattr(fch_mat, 'strftime'): fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y') - else: - tmp = str(fch_mat)[:10] - if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fecha_mat_limpia = f"{p[2]}/{p[1]}/{p[0]}" - else: fecha_mat_limpia = tmp - except: fecha_mat_limpia = str(fch_mat)[:10] - - fch_canc_limpia = "" - if fch_canc_raw and str(fch_canc_raw).strip() != "None": - try: - if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y') - else: - tmp = str(fch_canc_raw)[:10] - if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}" - else: fch_canc_limpia = tmp - except: fch_canc_limpia = str(fch_canc_raw)[:10] - - clase_alumno = self.clasificar_programa(nombre_prog_crudo) - - es_refriperu = False - if mat_id in correcciones_desc: - if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True - elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False - else: - if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True - elif clase_alumno in ["TEAC", "TERC"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True - - tipo_final = "NUEVO" - if es_refriperu: tipo_final = "REFRIPERU" - else: - es_cont = False - if mat_id in correcciones_cont: - if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True - elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False - else: - if fecha_inicio_actual and linea_actual and (dni in datos_historial): - for antecedente in datos_historial[dni]: - nombre_pasado = str(antecedente.get('programa', '')) - fecha_pasada = antecedente.get('fecha') - if not fecha_pasada: continue - if not (fecha_pasada < fecha_inicio_actual): continue - if (fecha_inicio_actual - fecha_pasada).days < 60: continue - if self.identificar_linea_carrera(nombre_pasado) == linea_actual: - es_cont = True; break - if es_cont: tipo_final = "CONTINUIDAD" - - descuento = self.calcular_descuento(clase_alumno, inv_neta_soles) - estado_desc = self.calcular_estado_descuento(clase_alumno, descuento) - - str_cuota_val = "" - monto_eval = 0.0 - - if clase_alumno in lista_mirar_cuotas: - suma_c = 0.0; cont_c = 0; monto_real_c = 0.0 - for c_dict in cuotas_por_mat.get(mat_id, []): - try: num_c = int(c_dict.get('num_cuota', 0)) - except: num_c = 0 - t_val = c_dict.get('imp_total'); d_val = c_dict.get('imp_dscto') - try: t_monto = float(t_val) if t_val is not None else 0.0 - except: t_monto = 0.0 - try: d_monto = float(d_val) if d_val is not None else 0.0 - except: d_monto = 0.0 - imp_c = t_monto - d_monto - tc_over = None - if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]: - tc_over = float(correcciones_mat[mat_id]["imp_tc"]) - cod_m_c = str(c_dict.get('cod_moneda', '')).strip().upper() - if cod_m_c == "DOL": - # USAR TC del COMPROBANTE (no del cronograma) - tc_c = tc # tc ya está validado arriba (línea 654) - imp_c *= tc_c - if estado_desc == "SI" and num_c > 0 and imp_c < 1000: - suma_c += imp_c; cont_c += 1 - if estado_desc == "SI" and num_c > 0: - monto_real_c += imp_c - if cont_c > 0: - str_cuota_val = f"S/ {suma_c / cont_c:,.0f}" - monto_eval = monto_real_c - acum_suma_cuotas_global += suma_c - acum_cont_cuotas_global += cont_c - else: - str_cuota_val = f"S/ {inv_neta_soles:,.0f}" - monto_eval = inv_neta_soles - acum_suma_cuotas_global += inv_neta_soles - acum_cont_cuotas_global += 1 - - str_desc_e_val = "" - if monto_eval >= 1900 and estado_desc == "SI": - if clase_alumno in ["TEAC", "TERC"]: d_calc = 3400 - monto_eval - elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - monto_eval - else: d_calc = 0 - desc_e_val = min(max(d_calc - 200, 0.0), 200.0) - str_desc_e_val = f"S/ {desc_e_val:,.0f}" - acum_suma_desc_e_global += desc_e_val - acum_cont_desc_e_global += 1 - - alumnos_lista.append([ - vendedor, alumno_nombre, fecha_mat_limpia, fch_canc_limpia, - tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}", - estado_mat # columna oculta para detectar RET - ]) - - alumnos_lista.sort(key=lambda x: x[1]) - - prom_gral_cuota = (acum_suma_cuotas_global / acum_cont_cuotas_global) if acum_cont_cuotas_global > 0 else 0.0 - prom_gral_desc = (acum_suma_desc_e_global / acum_cont_desc_e_global) if acum_cont_desc_e_global > 0 else 0.0 - - str_gral_cuota = f"S/ {prom_gral_cuota:,.0f}" if acum_cont_cuotas_global > 0 else "" - str_gral_desc = f"S/ {prom_gral_desc:,.0f}" if acum_cont_desc_e_global > 0 else "" - - alumnos_lista.append([ - "TOTAL GENERAL", "", "", "", "", - str_gral_cuota, str_gral_desc, f"S/ {acum_inv_neta_global:,.0f}", - "" # columna oculta extra para TOTAL - ]) - - return alumnos_lista - - def exportar_a_excel(self, datos, ano, mes): - if not datos: raise ValueError("No hay datos para exportar") - df = pd.DataFrame(datos) - archivo = f"reporte_rentabilidad_{ano}_{mes}.xlsx" - df.to_excel(archivo, index=False) - return archivo - - def _get_sql_matriculas_modificado(self): - if not hasattr(self, '_sql_matriculas_cache') or not self._sql_matriculas_cache: - sql = self.data_manager.query_matriculas_sql - if not sql: return "" - sql = re.sub( - r'YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)', - 'YEAR(sgede_RP_programa.fch_inicio)', - sql, flags=re.IGNORECASE - ) - sql = re.sub( - r'MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)', - 'MONTH(sgede_RP_programa.fch_inicio)', - sql, flags=re.IGNORECASE - ) - self._sql_matriculas_cache = sql - return self._sql_matriculas_cache - - def _get_sql_cuotas(self): - if not hasattr(self, '_sql_cuotas_cache') or not self._sql_cuotas_cache: - try: - res = requests.get(self.data_manager.github_cuota_url) - self._sql_cuotas_cache = res.text - except: - self._sql_cuotas_cache = "" - return self._sql_cuotas_cache - def obtener_datos_costos_programa(self, programa, ano, mes): - """Devuelve el desglose de costos e ingresos para el modal Editar""" - datos = self.obtener_datos_procesados(ano, mes) - if not datos: return None - - curso = None - for d in datos: - nombre = d.get('programa_frecuencia', d.get('dsc_programa', '')) - if str(nombre).strip() == str(programa).strip(): - curso = d; break - if not curso: return None - - cfg_costos = self.data_manager.costos_data - if not cfg_costos: return None - - # OVERRIDES desde Supabase - num_idx = str(curso.get('num_indice', '')).strip() - if num_idx.endswith('.0'): num_idx = num_idx[:-2] - overrides = self.data_manager.cargar_overrides_costos() - ov_curso = overrides.get(num_idx, {}) - ov_inicial = ov_curso.get('inicial', {}) - ov_actual = ov_curso.get('actual', {}) - - cat = curso.get('Categoria_Programa', 'OTROS') - prog_crudo = curso.get('dsc_programa', '') - try: total_inscritos = int(curso.get('Inscritos_Totales', 0)) - except: total_inscritos = 0 - try: en_curso = int(curso.get('Inscritos_En_Curso', curso.get('Inscritos_Activos', 0))) - except: en_curso = 0 - - epp_u = self._obtener_costo_segun_categoria(cfg_costos.get("epp", {}), cat, prog_crudo) - cert_u = self._obtener_costo_segun_categoria(cfg_costos.get("certificado", {}), cat, prog_crudo) - cons_u = self._obtener_costo_segun_categoria(cfg_costos.get("consumibles", {}), cat, prog_crudo) - mkt = self._obtener_costo_segun_categoria(cfg_costos.get("marketing", {}), cat, prog_crudo) - doc = self._obtener_costo_segun_categoria(cfg_costos.get("docente", {}), cat, prog_crudo) - - # INICIAL - epp_i = float(ov_inicial['epp']) if ov_inicial.get('epp') is not None else total_inscritos * epp_u - cert_i = float(ov_inicial['certificado']) if ov_inicial.get('certificado') is not None else total_inscritos * cert_u - cons_i = float(ov_inicial['consumibles']) if ov_inicial.get('consumibles') is not None else total_inscritos * cons_u - mkt_i = float(ov_inicial['marketing']) if ov_inicial.get('marketing') is not None else mkt - doc_i = float(ov_inicial['docente']) if ov_inicial.get('docente') is not None else doc - costo_i = epp_i + cert_i + cons_i + mkt_i + doc_i - - # ACTUAL - epp_a = float(ov_actual['epp']) if ov_actual.get('epp') is not None else total_inscritos * epp_u - cert_a = float(ov_actual['certificado']) if ov_actual.get('certificado') is not None else en_curso * cert_u - cons_a = float(ov_actual['consumibles']) if ov_actual.get('consumibles') is not None else total_inscritos * cons_u - mkt_a = float(ov_actual['marketing']) if ov_actual.get('marketing') is not None else mkt - doc_a = float(ov_actual['docente']) if ov_actual.get('docente') is not None else doc - costo_a = epp_a + cert_a + cons_a + mkt_a + doc_a - - vi = float(curso.get('Valor_Venta', 0.0)) - va = float(curso.get('Valor_Venta_Actual', 0.0)) - - mbi = (1 - costo_i / vi) * 100 if vi > 0 else 0.0 - mba = (1 - costo_a / va) * 100 if va > 0 else 0.0 - - return { - 'num_indice': num_idx, - 'venta_inicial': vi, 'costo_inicial': costo_i, 'mb_inicial': mbi, - 'venta_actual': va, 'costo_actual': costo_a, 'mb_actual': mba, - 'epp_inicial': epp_i, 'cert_inicial': cert_i, 'cons_inicial': cons_i, - 'mkt_inicial': mkt_i, 'doc_inicial': doc_i, - 'epp_actual': epp_a, 'cert_actual': cert_a, 'cons_actual': cons_a, - 'mkt_actual': mkt_a, 'doc_actual': doc_a, - # Lo que ya estaba en Supabase, para detectar qué fue editado - 'ov_inicial': ov_inicial, - 'ov_actual': ov_actual, - } \ No newline at end of file diff --git a/backend/modules/rentabilidad/processor.py b/backend/modules/rentabilidad/processor.py deleted file mode 100644 index f7e3c06..0000000 --- a/backend/modules/rentabilidad/processor.py +++ /dev/null @@ -1,290 +0,0 @@ -# modules/rentabilidad/processor.py -from datetime import datetime -import unicodedata - -class RentabilidadProcessor: - """Procesador de datos para Rentabilidad - Hereda la lógica estricta de Ocupabilidad""" - - def __init__(self, data_manager): - self.data_manager = data_manager - - # Columnas exclusivas de rentabilidad - self.columnas = [ - 'programa_frecuencia', - 'fch_inicio', - 'Descuento', # INSCRITOS REFRIPERU - 'Inscritos_Continuidad', # INSCRITOS CONTINUIDAD - 'Inscritos_Nuevos', # Calculado en Logic - 'Inscritos_Totales', # TOTAL INSCRITOS - 'Inscritos_PC', # INSCRITOS P.C - 'Retirados', # INSCRITOS RETIRADOS - 'Inscritos_En_Curso', # INSCRITOS ACTIVOS - 'Promedio_Cuota', # Pendiente (0) - 'Promedio_Desc_E', # Pendiente (0) - 'Valor_Venta', # Pendiente (0) - 'Opciones' # Fijo "en cu" - ] - - def obtener_datos_procesados(self, ano, mes): - try: - from concurrent.futures import ThreadPoolExecutor - - with ThreadPoolExecutor(max_workers=3) as executor: - fut_cursos = executor.submit(self.data_manager.ejecutar_consulta_cursos, ano, mes) - fut_raw = executor.submit(self.data_manager.ejecutar_consulta_matriculados_detalle, ano, mes) - fut_matriculas = executor.submit(self.data_manager.cargar_datos_matriculas, ano, mes) - - datos_originales = fut_cursos.result() - datos_raw_matriculas = fut_raw.result() - datos_matriculas = fut_matriculas.result() - - lista_dnis = [] - if datos_raw_matriculas: - for alumno in datos_raw_matriculas: - dni = alumno.get('dsc_documento') - if dni: lista_dnis.append(dni) - - datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis) - - datos_procesados = self.aplicar_personalizaciones( - datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano, mes - ) - return datos_procesados - except Exception as e: - print(f"❌ Error obteniendo datos rentabilidad: {e}") - return [] - - def normalizar_texto(self, texto): - if not texto: return "" - texto = str(texto).upper().strip() - texto = unicodedata.normalize('NFD', texto) - return texto.encode('ascii', 'ignore').decode("utf-8") - - def obtener_categoria_programa(self, nombre_programa): - try: - nombre_limpio = self.normalizar_texto(nombre_programa) - clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {}) - for categoria, config in clasificaciones.items(): - patrones = config.get('patrones', []) - for patron in patrones: - if self.normalizar_texto(patron) in nombre_limpio: - return categoria - return "OTROS" - except: return "OTROS" - - def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro): - print("💰 Procesando variables de Rentabilidad...") - cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {}) - datos_filtrados = [] - - try: ano_target, mes_target = int(ano_filtro), int(mes_filtro) - except: ano_target, mes_target = 0, 0 - - # FILTRADO Y ACTUALIZADOR EN VIVO - for curso in datos_originales: - num_indice = str(curso.get('num_indice', '')) - if num_indice in cursos_personalizados: - curso.update(cursos_personalizados[num_indice]) - - if curso.get('flg_activo', '') == 'NO': continue - - # Si el curso está suspendido, lo ignoramos y no se muestra en Rentabilidad - if str(curso.get('cod_estado', '')).strip().upper() == 'SUS': continue - - fch_valida = True - try: - raw_fecha = curso.get('fch_inicio') - if raw_fecha: - s_fecha = str(raw_fecha).strip() - if len(s_fecha) == 10 and s_fecha[2] == '-': f_obj = datetime.strptime(s_fecha, '%d-%m-%Y') - elif ' ' in s_fecha: f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S') - else: f_obj = datetime.strptime(s_fecha, '%Y-%m-%d') - - if f_obj and (f_obj.year != ano_target or f_obj.month != mes_target): - fch_valida = False - except: pass - - if fch_valida: datos_filtrados.append(curso) - - datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados) - datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados) - for c in datos_filtrados: c['fch_inicio'] = self.formatear_fecha(c.get('fch_inicio', '')) - - # CÁLCULOS CENTRALES - datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas) - datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu) - datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas) - datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas) - - # EXTRACCIÓN REAL DE BD Y VALORES PENDIENTES - for c in datos_filtrados: - c['Retirados'] = int(c.get('Inscritos_Retirados', 0)) # <--- AHORA JALA LA DATA REAL DEL SQL - - c['Promedio_Cuota'] = 0.0 - c['Promedio_Desc_E'] = 0.0 - c['Valor_Venta'] = 0.0 - c['Opciones'] = "en cu" - - return datos_filtrados - - def aplicar_reemplazos_programas(self, datos): - reemplazos = self.data_manager.replace_data.get('reemplazos_programas', {}) - for curso in datos: - prog = curso.get('dsc_programa', '') - if prog in reemplazos: curso['dsc_programa'] = reemplazos[prog] - return datos - - def concatenar_programa_frecuencia(self, datos): - for c in datos: - p, f = c.get('dsc_programa', ''), c.get('cod_frecuencia', '') - c['programa_frecuencia'] = f"{p} - {f}" if p and f else p or f or "" - return datos - - def formatear_fecha(self, fecha_str): - try: - if not fecha_str: return "" - if len(str(fecha_str)) == 10 and str(fecha_str)[2] == '-': return str(fecha_str) - if '.' in str(fecha_str): return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y') - return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y') - except: return str(fecha_str) - - def identificar_linea_carrera(self, nombre_programa): - categoria = self.obtener_categoria_programa(nombre_programa) - if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]: - return "CARRERA_COMPATIBLE" - return None - - def parse_fecha(self, fecha_raw): - try: - if isinstance(fecha_raw, datetime): return fecha_raw - if not fecha_raw: return None - s_fecha = str(fecha_raw).strip() - if len(s_fecha) == 10 and s_fecha[2] == '-': return datetime.strptime(s_fecha, '%d-%m-%Y') - if ' ' in s_fecha: return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S') - return datetime.strptime(s_fecha, '%Y-%m-%d') - except: return None - - # Lógica estricta de Refriperu - def agregar_descuento(self, datos, datos_raw_matriculas): - conteo_por_curso = {} - alumnos_refriperu = set() - correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {}) - - if datos_raw_matriculas: - for alumno in datos_raw_matriculas: - try: - if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue - num_indice = str(alumno.get('num_indice', '')) - mat_id = str(alumno.get('num_matricula', '')).strip() - if mat_id.endswith('.0'): mat_id = mat_id[:-2] - - if mat_id in correcciones_descuento: - if str(correcciones_descuento[mat_id]).upper().strip() == "SI": - conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1 - alumnos_refriperu.add(mat_id) - continue - elif str(correcciones_descuento[mat_id]).upper().strip() == "NO": continue - - nombre_programa = str(alumno.get('dsc_programa', '')) - cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper() - try: - inv_neta = float(alumno.get('INV_NETA', 0) or 0) - imp_tc = float(alumno.get('imp_tc', 0) or 0) - if imp_tc <= 0: imp_tc = 3.45 # TC por defecto si no hay comprobante - except: inv_neta, imp_tc = 0.0, 3.45 - - inv_neta_final = round(inv_neta * imp_tc if cod_moneda == 'DOL' else inv_neta, 2) - categoria_json = self.obtener_categoria_programa(nombre_programa) - - es_descuento = False - if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_final <= 1700: es_descuento = True - elif categoria_json in ["TEAC", "TERC"] and 1400 <= inv_neta_final <= 1900: es_descuento = True - - if es_descuento: - conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1 - alumnos_refriperu.add(mat_id) - except: continue - - for curso in datos: curso['Descuento'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0) - return datos, alumnos_refriperu - - # Lógica estricta de Continuidad - def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu): - conteo_por_curso = {} - correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {}) - - if datos_raw_matriculas and datos_historial: - - # PRE-ÍNDICE: {dni: [(linea_carrera, fecha), ...]} - # Calculamos identificar_linea_carrera UNA sola vez por programa del historial - historial_indexado = {} - for dni, antecedentes in datos_historial.items(): - lineas = [] - for ant in antecedentes: - linea = self.identificar_linea_carrera(str(ant.get('programa', ''))) - fecha = ant.get('fecha') - if linea and fecha: - lineas.append((linea, fecha)) - if lineas: - historial_indexado[dni] = lineas - - alumnos_por_curso = {} - for alu in datos_raw_matriculas: - idx = str(alu.get('num_indice', '')) - if idx not in alumnos_por_curso: alumnos_por_curso[idx] = [] - alumnos_por_curso[idx].append(alu) - - for curso in datos: - num_indice = str(curso.get('num_indice', '')) - fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio')) - linea_actual = self.identificar_linea_carrera(str(curso.get('dsc_programa', ''))) - - if not linea_actual or not fecha_inicio_actual: continue - - contador_fieles = 0 - for alumno in alumnos_por_curso.get(num_indice, []): - if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue - - mat_id = str(alumno.get('num_matricula', '')).strip() - if mat_id.endswith('.0'): mat_id = mat_id[:-2] - - if mat_id in alumnos_refriperu: continue - - dni = str(alumno.get('dsc_documento', 'SD')).strip() - es_fiel = False - - if mat_id in correcciones_continuidad: - accion = str(correcciones_continuidad[mat_id]).upper().strip() - if accion == "SI": es_fiel = True - elif accion == "NO": continue - - if not es_fiel and dni in historial_indexado: - for linea_pasada, fecha_pasada in historial_indexado[dni]: - if fecha_pasada >= fecha_inicio_actual: continue - if (fecha_inicio_actual - fecha_pasada).days < 60: continue - if linea_pasada == linea_actual: - es_fiel = True - break - - if es_fiel: contador_fieles += 1 - conteo_por_curso[num_indice] = contador_fieles - - for curso in datos: curso['Inscritos_Continuidad'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0) - return datos - - def agregar_inscritos_mes(self, datos, datos_matriculas): - dict_norm = {str(k): v for k, v in datos_matriculas.items()} - for curso in datos: curso['Inscritos_Mes'] = dict_norm.get(str(curso.get('num_indice', '')), 0) - return datos - - def agregar_inscritos_pc(self, datos, datos_raw_matriculas): - conteo_pc = {} - if datos_raw_matriculas: - for m in datos_raw_matriculas: - try: - if str(m.get('estado_matricula', '')).strip() in ['ALU', 'PRE'] and float(m.get('imp_saldo_matricula', 0) or 0) < 1 and float(m.get('imp_saldo_cuota1', 0) or 0) < 1: - nid = str(m.get('num_indice', '')) - conteo_pc[nid] = conteo_pc.get(nid, 0) + 1 - except: continue - for curso in datos: curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0) - return datos \ No newline at end of file diff --git a/backend/modules/saldo_pendiente/__init__.py b/backend/modules/saldo_pendiente/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/modules/saldo_pendiente/logic.py b/backend/modules/saldo_pendiente/logic.py deleted file mode 100644 index 6fb8573..0000000 --- a/backend/modules/saldo_pendiente/logic.py +++ /dev/null @@ -1,193 +0,0 @@ -# modules/saldo_pendiente/logic.py -from .processor import SaldoProcessor -from datetime import datetime - -class SaldoLogic: - """ - Controlador lógico optimizado. - 1. Filtro Global Manual (lista_saldo_pendiente). - 2. Filtro REFRIPERU (Precio Neto < 1900/1000 o Etiquetas). - 3. Filtros de Negocio (Vendedor, Fecha, Estado). - """ - - def __init__(self, data_manager): - self.data_manager = data_manager - self.processor = SaldoProcessor(self.data_manager) - - def obtener_saldos_consolidados(self, tipo_cuota): - # 1. Recargar Configuración (Para leer tu nueva lista en vivo) - try: self.data_manager.cargar_toda_configuracion() - except: pass - - all_debtors = [] - years_to_scan = [2025, 2026] - - print(f"⚡ [Logic] Iniciando carga para {tipo_cuota}...") - - for year in years_to_scan: - try: - # Usamos la consulta anual optimizada - raw_year_data = self.data_manager.ejecutar_consulta_saldos_anual(str(year)) - if raw_year_data: - datos_procesados = self._procesar_lote_local(raw_year_data, tipo_cuota) - all_debtors.extend(datos_procesados) - except AttributeError: - print("⚠️ Error: DataManager no actualizado.") - continue - - # Ordenar alfabéticamente - all_debtors.sort(key=lambda x: (x.get('VENDEDOR', '') or "ZZZ", x.get('ALUMNO', '') or "")) - print(f"✅ Carga Finalizada. Total en tabla: {len(all_debtors)}") - return all_debtors - - def _procesar_lote_local(self, raw_data, tipo_cuota): - datos_limpios = [] - - # --- Configuración --- - ahora = datetime.now() - ano_actual = ahora.year - mes_actual = ahora.month - - config = self.data_manager.config_data - - # 1. CARGAMOS TUS LISTAS DEL JSON - correcciones_refri = config.get("correcciones_descuento", {}) - exclusion_manual_global = config.get("lista_saldo_pendiente", {}) # <--- TU NUEVA LISTA - - # 2. Lista Vendedores Permitidos - lista_raw = config.get("lista_pendientes", []) - if not lista_raw: - lista_raw = ["AGUILAR U. JUAN CARLOS", "CHAVEZ P. DIANA", "HUAMAN C. ALONSO AGUSTIN", - "LA ROSA C. VERONICA ASTRID", "LAZARO Q. DIEGO ARTURO", - "MONTOYA D. CARMEN ISABEL", "PERALTA C. ALMENDRA LUCIA"] - lista_permitidos = set([str(v).strip().upper() for v in lista_raw]) - - # 3. Mapeo de columnas - mapa_cols = { - "1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"), - "2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"), - "3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"), - "4° Cuota": ("imp_saldo_cuota4", "fch_venc_cuota4"), - "5° Cuota": ("imp_saldo_cuota5", "fch_venc_cuota5"), - } - col_saldo, col_venc = mapa_cols.get(tipo_cuota, (None, None)) - if not col_saldo: return [] - - for row in raw_data: - # Identificadores - num_mat = str(row.get('num_matricula', '')).split('.')[0].strip() - - # ================================================================= - # 🛑 FILTRO 0: EXCLUSIÓN GLOBAL MANUAL (TU NUEVO PEDIDO) - # ================================================================= - # Si la matrícula está en "lista_saldo_pendiente" con valor "NO", ADIÓS. - if num_mat in exclusion_manual_global: - val_excl = str(exclusion_manual_global[num_mat]).strip().upper() - if val_excl == "NO": - continue # Se salta inmediatamente, no importa nada más. - - # ================================================================= - # 🛑 FILTRO 1: DETECCIÓN DE REFRIPERU (PRECIO NETO / ETIQUETAS) - # ================================================================= - es_refriperu = False - - # A) Manual (correcciones_descuento) - if num_mat in correcciones_refri: - val = str(correcciones_refri[num_mat]).strip().upper() - if val == "SI": es_refriperu = True - elif val == "NO": es_refriperu = False # Forzamos a cobrar - else: - # B) Automático (Etiquetas) - dsc_beca = str(row.get('dsc_beca', '')).strip().upper() - dsc_prog = str(row.get('dsc_programa', '')).strip().upper() - dsc_prom = str(row.get('dsc_promocion', '')).strip().upper() - - if 'REFRIPERU' in dsc_beca or '100%' in dsc_beca or 'BECA' in dsc_beca: - es_refriperu = True - elif 'REFRIPERU' in dsc_prog or 'REFRIPERU' in dsc_prom: - es_refriperu = True - - # C) Automático por PRECIO FINAL (INV_NETA) - if not es_refriperu: - try: - # Usamos INV_NETA (Precio Real) - inv_neta = float(row.get('INV_NETA', 0)) - except: - inv_neta = 0.0 - - if inv_neta > 0: - # Rango Técnicos/Especialistas - if ("TECNICO" in dsc_prog or "ESPECIALISTA" in dsc_prog or "TEAC" in dsc_prog): - if inv_neta < 1900: es_refriperu = True # ej: 1799 - - # Rango Gestión/Ventas - elif "GESTION" in dsc_prog or "VENTA" in dsc_prog: - if inv_neta < 1000: es_refriperu = True - - if es_refriperu: - continue - - # ================================================================= - # 🛑 FILTROS ESTÁNDAR (VENDEDOR, ESTADO, FECHA) - # ================================================================= - - # Vendedor - vendedor = str(row.get('dsc_vendedor', '')).strip().upper() - if lista_permitidos: - encontrado = False - if vendedor in lista_permitidos: encontrado = True - else: - for p in lista_permitidos: - if p.replace(" ","") in vendedor.replace(" ",""): - encontrado = True; break - if not encontrado: continue - - # Estado - estado = str(row.get('estado_matricula', '')).strip().upper() - if estado not in ('ALU', 'PRE'): continue - - # Fecha Futura (Solo 2da cuota en adelante) - if tipo_cuota != "1° Cuota": - fch_raw = row.get(col_venc) - if fch_raw: - try: - if isinstance(fch_raw, str): f_obj = datetime.strptime(fch_raw[:10], '%Y-%m-%d') - else: f_obj = fch_raw - if f_obj.year > ano_actual: continue - elif f_obj.year == ano_actual and f_obj.month > mes_actual: continue - except: pass - - # Saldo Positivo - try: val = float(row.get(col_saldo, 0)) - except: val = 0.0 - if val <= 0.1: continue - - # --- 4. Construcción de Fila --- - def fmt(v): - if not v: return "" - if isinstance(v, str): return v[:10] - if isinstance(v, datetime): return v.strftime('%d-%m-%Y') - return str(v) - - fila = { - 'MATRICULA': row.get('num_matricula'), - 'VENDEDOR': row.get('dsc_vendedor'), - 'ALUMNO': row.get('dsc_alumno'), - 'PROGRAMA': row.get('dsc_promocion'), - 'FECHA INICIO': fmt(row.get('fch_inicio')), - 'FECHA MATR.': fmt(row.get('fch_matricula')), - 'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)), - f'SALDO {tipo_cuota.upper()}': val, - 'VENCIMIENTO': fmt(row.get(col_venc)), - 'INV. NETA': float(row.get('INV_NETA', 0)), - 'DNI': row.get('dsc_documento', ''), - 'CELULAR': row.get('dsc_telefono_1', '') - } - datos_limpios.append(fila) - - return datos_limpios - - # Métodos legacy - def obtener_saldos(self, ano, mes, tipo_cuota): return self.processor.obtener_datos_procesados(ano, mes, tipo_cuota) - def get_current_year(self): return self.data_manager.get_current_year() - def get_current_month(self): return self.data_manager.get_current_month() \ No newline at end of file diff --git a/backend/modules/saldo_pendiente/processor.py b/backend/modules/saldo_pendiente/processor.py deleted file mode 100644 index ecce7f9..0000000 --- a/backend/modules/saldo_pendiente/processor.py +++ /dev/null @@ -1,162 +0,0 @@ -# modules/saldo_pendiente/processor.py -from datetime import datetime - -class SaldoProcessor: - """ - Procesador de lógica de negocio para Saldos Pendientes. - Filtra estrictamente por estado ALU/PRE, saldos positivos, - vendedores permitidos y FECHA DE VENCIMIENTO (No mostrar futuro). - """ - - def __init__(self, data_manager): - self.data_manager = data_manager - - def obtener_datos_procesados(self, ano, mes, tipo_cuota): - """ - Recupera datos crudos y aplica los filtros de negocio. - """ - # 1. Traer la data cruda desde DataManager - raw_data = self.data_manager.ejecutar_consulta_saldos_pendientes(ano, mes) - - datos_limpios = [] - - # 2. Obtener fecha actual para saber qué es "futuro" - ahora = datetime.now() - ano_actual = ahora.year - mes_actual = ahora.month - - # 3. Cargar la "Lista Blanca" de vendedores desde el JSON - config = self.data_manager.config_data - lista_raw = config.get("lista_pendientes", []) - - # RESPALDO DE EMERGENCIA - if not lista_raw: - lista_raw = [ - "AGUILAR U. JUAN CARLOS", - "CHAVEZ P. DIANA", - "HUAMAN C. ALONSO AGUSTIN", - "LA ROSA C. VERONICA ASTRID", - "LAZARO Q. DIEGO ARTURO", - "MONTOYA D. CARMEN ISABEL", - "PERALTA C. ALMENDRA LUCIA" - ] - - # Normalizamos la lista - lista_permitidos = set([str(v).strip().upper() for v in lista_raw]) - - # 4. Mapeo de columnas según la selección - mapa_columnas = { - "1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"), - "2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"), - "3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"), - } - - col_saldo_target, col_venc_target = mapa_columnas.get(tipo_cuota, (None, None)) - - if not col_saldo_target: - return [] - - for row in raw_data: - # ================================================================= - # 🛑 FILTRO 1: VENDEDOR PERMITIDO - # ================================================================= - vendedor_actual = str(row.get('dsc_vendedor', '')).strip().upper() - - if lista_permitidos: - if vendedor_actual not in lista_permitidos: - # Búsqueda parcial por si hay errores de espacios - encontrado = False - for permitido in lista_permitidos: - v_norm = vendedor_actual.replace(" ", "") - p_norm = permitido.replace(" ", "") - if p_norm in v_norm: - encontrado = True - break - if not encontrado: - continue - - # ================================================================= - # 🛑 FILTRO 2: ESTADO (ALU/PRE) - # ================================================================= - estado = str(row.get('estado_matricula', '')).strip().upper() - if estado not in ('ALU', 'PRE'): - continue - - # ================================================================= - # 🛑 FILTRO 3: FECHA DE VENCIMIENTO (NO MOSTRAR FUTURO) - # ================================================================= - # Este filtro aplica PRINCIPALMENTE para 2° Cuota en adelante. - # (Aunque la lógica es válida para todas, la 1° suele ser inmediata). - - if tipo_cuota != "1° Cuota": - fch_venc_raw = row.get(col_venc_target) - - es_futuro = False - if fch_venc_raw: - try: - # Convertir a objeto fecha si es texto - if isinstance(fch_venc_raw, str): - # Asumimos formato SQL YYYY-MM-DD - f_obj = datetime.strptime(fch_venc_raw[:10], '%Y-%m-%d') - else: - f_obj = fch_venc_raw # Ya es datetime - - venc_ano = f_obj.year - venc_mes = f_obj.month - - # LÓGICA DE TIEMPO: - # Si el año de vencimiento es mayor al actual -> ES FUTURO - if venc_ano > ano_actual: - es_futuro = True - # Si es el mismo año, pero el mes es mayor al actual -> ES FUTURO - elif venc_ano == ano_actual and venc_mes > mes_actual: - es_futuro = True - - except Exception as e: - # Si falla la fecha, asumimos que no es futuro para no ocultar por error - pass - - # Si la cuota vence en el futuro (ej: Marzo cuando estamos en Febrero), LA OCULTAMOS. - if es_futuro: - continue - - # ================================================================= - # 🛑 FILTRO 4: SALDO > 0 - # ================================================================= - try: - val = row.get(col_saldo_target, 0) - saldo_a_mostrar = float(val) if val is not None else 0.0 - except: - saldo_a_mostrar = 0.0 - - if saldo_a_mostrar <= 0.1: - continue - - # --- FORMATEO PARA VISUALIZACIÓN --- - def format_date(val): - if not val: return "" - if isinstance(val, str): return val[:10] - if isinstance(val, datetime): return val.strftime('%d-%m-%Y') - return str(val) - - f_inicio = format_date(row.get('fch_inicio')) - f_matr = format_date(row.get('fch_matricula')) - f_vencimiento = format_date(row.get(col_venc_target)) - - # --- CONSTRUCCIÓN DE LA FILA FINAL --- - fila = { - 'MATRICULA': row.get('num_matricula'), - 'VENDEDOR': row.get('dsc_vendedor'), - 'ALUMNO': row.get('dsc_alumno'), - 'PROGRAMA': row.get('dsc_promocion'), - 'FECHA INICIO': f_inicio, - 'FECHA MATR.': f_matr, - 'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)), - f'SALDO {tipo_cuota.upper()}': saldo_a_mostrar, - 'VENCIMIENTO': f_vencimiento, # Aquí se verá la fecha (ej: 14-02-2026) - 'INV. NETA': float(row.get('INV_NETA', 0)), - } - - datos_limpios.append(fila) - - return datos_limpios \ No newline at end of file diff --git a/backend/modules/ventas/__init__.py b/backend/modules/ventas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/modules/ventas/logic.py b/backend/modules/ventas/logic.py deleted file mode 100644 index 0a30bcb..0000000 --- a/backend/modules/ventas/logic.py +++ /dev/null @@ -1,767 +0,0 @@ -# modules/ventas/logic.py -import pandas as pd -from datetime import datetime -import requests -import re - -class VentasLogic: - """Clase que maneja la lógica de negocio y procesamiento de datos para Ventas""" - - def __init__(self, data_manager): - self.data_manager = data_manager - self._config_sedes = None - - def _clasificar_sede_comisiones(self, dsc_programa): - """Clasifica la sede usando EXACTAMENTE la misma lógica de Cobranza (sede.json).""" - try: - if not hasattr(self, '_cob_processor') or self._cob_processor is None: - from modules.cobranza.processor import CobranzaProcessor - self._cob_processor = CobranzaProcessor(self.data_manager) - return self._cob_processor.clasificar_sede(dsc_programa) - except Exception: - prog_up = str(dsc_programa or "").upper() - for s in ("AREQUIPA", "PIURA", "TRUJILLO"): - if s in prog_up: - return s - return "LIMA" - - def buscar_todos_matriculados(self): - """Devuelve TODOS los matriculados (todos los años/meses) para el buscador. - Ejecuta la query de matrículas quitando el filtro de YEAR/MONTH de fch_matricula. - Retorna filas con el mismo formato que el detalle (para reusar columnas).""" - import re as _re - sql_base = self.data_manager.query_matriculas_sql - if not sql_base: - return [] - # Quitar las condiciones de año y mes de fch_matricula - sql = _re.sub(r"AND\s+YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql_base, flags=_re.IGNORECASE) - sql = _re.sub(r"AND\s+MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql, flags=_re.IGNORECASE) - # Por si están sin AND (primera condición) - sql = _re.sub(r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE) - sql = _re.sub(r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE) - # El buscador debe traer TODOS los matriculados excepto ANU (incluye SUS, no pagados, etc.) - # El buscador muestra TODOS los matriculados, incluso ANU - sql = _re.sub(r"AND\s+sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\([^)]*\)", "", sql, flags=_re.IGNORECASE) - sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE) - sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*,\s*'RET'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE) - - conn = self.data_manager.get_connection() - cursor = conn.cursor() - cursor.execute(sql) # sin parámetros de fecha - columns = [c[0] for c in cursor.description] - datos = [dict(zip(columns, row)) for row in cursor.fetchall()] - - # Traer fecha de cancelación de la cuota 1 de TODAS las matrículas (cualquier refinanciamiento) - fechas_canc = {} - try: - cursor.execute(""" - SELECT num_matricula, MAX(fch_cancelacion) - FROM sgede_cronograma_matricula - WHERE num_cuota = 1 AND fch_cancelacion IS NOT NULL - GROUP BY num_matricula - """) - for r in cursor.fetchall(): - k = str(r[0]).strip() - if k.endswith('.0'): k = k[:-2] - fechas_canc[k] = r[1] - except Exception: - pass - conn.close() - - filas = [] - for d in datos: - raw_mat = d.get('num_matricula') - mat_id = "" - if raw_mat is not None: - v = str(raw_mat).strip() - if v.endswith('.0'): v = v[:-2] - mat_id = v - alumno = d.get('dsc_alumno', 'SIN NOMBRE') - # MOSTRAR: detallado (dsc_promocion = sgede_RP_programa.dsc_det_programa) - programa = (d.get('dsc_promocion') or d.get('dsc_det_programa') or d.get('dsc_programa') or '') - # CLASIFICAR sede: prioriza dsc_programa - prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa') or d.get('dsc_promocion') or '') - vendedor = d.get('dsc_vendedor', 'SIN VENDEDOR') or 'SIN VENDEDOR' - inv = d.get('INV_NETA', 0) or 0 - saldo_mat = d.get('imp_saldo_matricula', 0) or 0 - saldo_c1 = d.get('imp_saldo_cuota1', 0) or 0 - def _fdate(v): - if not v or str(v).strip() in ("","None"): return "" - if hasattr(v,'strftime'): return v.strftime('%d/%m/%Y') - s = str(v)[:10] - if len(s)==10 and s[4]=='-': p=s.split('-'); return f"{p[2]}/{p[1]}/{p[0]}" - return s - f_mat = _fdate(d.get('fch_matricula')) - f_ini = _fdate(d.get('fch_inicio')) - f_canc = _fdate(fechas_canc.get(mat_id)) - sede = self._clasificar_sede_comisiones(prog_clasificacion) - # mismo layout que el detalle: 16 columnas - fila = [ - vendedor, alumno, f_mat, f_canc, "", "", "", - f"S/ {float(inv):,.0f}", programa, f_ini, - f"S/ {float(saldo_mat):,.0f}", f"S/ {float(saldo_c1):,.0f}", - "", "-", sede, mat_id, - ] - filas.append(fila) - return filas - - def obtener_datos_brutos(self, ano, mes_numero): - datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero)) - return datos or [] - - def obtener_datos_brutos_filtrado(self, ano, mes_numero, sede="TODOS", programa="TODOS"): - datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero), sede, programa) - return datos or [] - - # ========================================================================= - # FUNCIONES AUXILIARES - # ========================================================================= - def clasificar_programa(self, dsc_programa): - if not dsc_programa: return "OTROS" - dsc_prog_upper = str(dsc_programa).upper() - meta_data = self.data_manager.meta_data - clasificaciones = meta_data.get("clasificacion_programas", {}) - - for clase, data in clasificaciones.items(): - for patron in data.get("patrones", []): - if patron.upper() in dsc_prog_upper: - return clase - return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS") - - def identificar_linea_carrera(self, nombre_programa): - categoria = self.clasificar_programa(nombre_programa) - if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC", "CARRERA"]: - return "CARRERA_COMPATIBLE" - return None - - def parse_fecha(self, fecha_raw): - try: - if isinstance(fecha_raw, datetime): return fecha_raw - if not fecha_raw: return None - s_fecha = str(fecha_raw).strip() - if len(s_fecha) == 10 and s_fecha[2] == '-': - return datetime.strptime(s_fecha, '%d-%m-%Y') - if ' ' in s_fecha: - return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S') - return datetime.strptime(s_fecha, '%Y-%m-%d') - except: - return None - - # ========================================================================= - # OBTENCIÓN DE DETALLE POR VENDEDOR (LÓGICA PURA) - # ========================================================================= - def obtener_detalle_vendedor(self, vendedor, ano, mes_numero): - try: - key_mes = f"{int(mes_numero):02d}-{ano}" - lista_raw = self.data_manager.historico_pendientes.get(key_mes, []) - set_vip_actual = set() - lista_vip_str = [] - - for item in lista_raw: - try: - val = int(item) - set_vip_actual.add(val) - lista_vip_str.append(str(val)) - except: pass - # Lista PURA de BASE_PENDIENTES (sin overrides). Venta Pendientes usa SOLO esto. - set_lista_pendientes = set(set_vip_actual) - # Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas) - - # Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas) - _fov_vip = getattr(self.data_manager, '_fecha_canc_overrides', None) or {} - for mk in _fov_vip.keys(): - try: - val = int(float(str(mk))) - set_vip_actual.add(val) - if str(val) not in lista_vip_str: - lista_vip_str.append(str(val)) - except: pass - - correcciones_mat = self.data_manager.config_data.get("correcciones_matriculas", {}) - correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {}) - correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {}) - - sql_base = self.data_manager.query_matriculas_sql - if not sql_base: - return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []} - - sql_in_clause = "(-1)" - if lista_vip_str: - sql_in_clause = "(" + ",".join(lista_vip_str) + ")" - - sql_modificado = re.sub( - r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", - r"( (YEAR(sgeca_matricula.fch_matricula) = ?", - sql_base, flags=re.IGNORECASE - ) - sql_modificado = re.sub( - r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", - rf"""MONTH(sgeca_matricula.fch_matricula) = ?) OR sgeca_matricula.num_matricula IN {sql_in_clause} - OR EXISTS (SELECT 1 FROM sgede_cronograma_matricula crono - WHERE crono.cod_localidad = sgeca_matricula.cod_localidad - AND crono.num_matricula = sgeca_matricula.num_matricula - AND crono.num_cuota = 1 AND crono.fch_cancelacion IS NOT NULL - AND YEAR(crono.fch_cancelacion) = ? AND MONTH(crono.fch_cancelacion) = ?) )""", - sql_modificado, flags=re.IGNORECASE - ) - - # Permitir matrículas VIP/override aunque estén ANU/SUS (igual que la tabla principal) - if lista_vip_str: - sql_modificado = re.sub( - r"sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)", - rf"(sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS') OR sgeca_matricula.num_matricula IN {sql_in_clause})", - sql_modificado, flags=re.IGNORECASE - ) - - conn = self.data_manager.get_connection() - cursor = conn.cursor() - - cursor.execute(sql_modificado, ano, mes_numero, ano, mes_numero) - columns = [column[0] for column in cursor.description] - datos_raw = [dict(zip(columns, row)) for row in cursor.fetchall()] - - # DEDUP por num_matricula: el LEFT JOIN con RP_programa puede duplicar filas - # (una con fch_cancelacion y otra sin). Nos quedamos con UNA sola por matrícula, - # priorizando la que TENGA fecha de cancelación (verdad del pago). - _dedup = {} - for _d in datos_raw: - _m = str(_d.get('num_matricula', '')).strip() - if _m.endswith('.0'): _m = _m[:-2] - _prev = _dedup.get(_m) - _tiene_f = bool(_d.get('fch_cancelacion_cuota1')) - if _prev is None: - _dedup[_m] = _d - elif _tiene_f and not bool(_prev.get('fch_cancelacion_cuota1')): - _dedup[_m] = _d - datos_raw = list(_dedup.values()) - - # 🔥 DESCARGA DEL CRONOGRAMA COMPLETO (SIN IMPORTAR EL MES) 🔥 - lista_mats = list(set([str(d.get('num_matricula')).replace('.0','').strip() for d in datos_raw if d.get('num_matricula')])) - cuotas_raw = [] - - if lista_mats: - mats_str_q = ",".join([f"'{m}'" for m in lista_mats]) - try: - res_cuo = requests.get(self.data_manager.github_cuota_url) - sql_cuotas_base = res_cuo.text - - # Convertimos las validaciones de Año y Mes en "1=1" para que no esconda los cronogramas de otros meses - sql_cuotas_base = re.sub(r"YEAR\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE) - sql_cuotas_base = re.sub(r"MONTH\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE) - sql_cuotas_base = re.sub(r"ORDER\s+BY\s+.*$", "", sql_cuotas_base, flags=re.IGNORECASE | re.DOTALL) - - sql_final_cuotas = f"SELECT * FROM ({sql_cuotas_base}) AS sub_cuotas WHERE num_matricula IN ({mats_str_q})" - - cursor.execute(sql_final_cuotas) - cols_cuo = [c[0] for c in cursor.description] - cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()] - except Exception as e: - print(f"Error DAX Cuotas: {e}") - - try: cursos_raw = self.data_manager.ejecutar_consulta_cursos(ano, mes_numero) - except: cursos_raw = [] - - conn.close() - - # Agrupar Cuotas - cuotas_por_mat = {} - for c in cuotas_raw: - m_id = str(c.get('num_matricula', '')).strip() - if m_id.endswith('.0'): m_id = m_id[:-2] - if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = [] - cuotas_por_mat[m_id].append(c) - - fecha_inicio_prog = {} - for c in cursos_raw: - prog = str(c.get('dsc_programa', '')).strip().upper() - if prog not in fecha_inicio_prog: - fecha_inicio_prog[prog] = self.parse_fecha(c.get('fch_inicio')) - - lista_dnis = [str(d.get('dsc_documento', '')).strip() for d in datos_raw if d.get('dsc_documento')] - try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis) - except: datos_historial = {} - - listas_datos = { - "Venta Inscritos": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0}, - "Venta P.C": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0}, - "Venta Pendientes":{"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0} - } - - vendedor_target = str(vendedor).upper().replace(".", "").replace(",", "") - vendedor_target = " ".join(vendedor_target.split()) - traer_todos = (vendedor_target == "__TODOS__") - - for d in datos_raw: - vend_name = str(d.get('dsc_vendedor', 'SIN VENDEDOR')).upper().replace(".", "").replace(",", "") - vend_name_clean = " ".join(vend_name.split()) - - if not traer_todos and vendedor_target not in vend_name_clean and vend_name_clean not in vendedor_target: - continue - - estado = str(d.get('estado_matricula', '')).strip().upper() - # Permitir ANU/otros si tiene override de fecha; si no, solo ALU/PRE/RET - _rm = d.get('num_matricula') - _mk = "" - if _rm is not None: - _mk = str(_rm).strip() - if _mk.endswith('.0'): _mk = _mk[:-2] - _fov_chk = getattr(self.data_manager, '_fecha_canc_overrides', None) or {} - if estado not in ['ALU', 'PRE', 'RET'] and _mk not in _fov_chk: continue - - raw_mat = d.get('num_matricula') - mat_id = "" - matricula_int = -1 - if raw_mat is not None: - val_str = str(raw_mat).strip() - if val_str.endswith('.0'): val_str = val_str[:-2] - mat_id = val_str - try: matricula_int = int(float(val_str)) - except: pass - - dni = str(d.get('dsc_documento', '')).strip() - alumno_nombre = d.get('dsc_alumno', 'SIN NOMBRE') - nombre_prog_crudo = str(d.get('dsc_programa', '')).strip().upper() - - inv_neta_raw = float(d.get('INV_NETA', 0.0)) - cod_moneda = str(d.get('cod_moneda', 'SOL')).strip().upper() - - # Buscar imp_tc desde el CRONOGRAMA (no del comprobante) - imp_tc = 0 - cuotas_alumno = cuotas_por_mat.get(mat_id, []) - for c_aux in cuotas_alumno: - try: - tc_aux = float(c_aux.get('imp_tc', 0) or 0) - if tc_aux >= 2: # Tomar el primer TC válido del cronograma - imp_tc = tc_aux - break - except: continue - if imp_tc < 2: imp_tc = 3.45 # TC por defecto si no hay cronograma o es inválido - - saldo_mat = float(d.get('imp_saldo_matricula', 0.0)) - saldo_c1 = float(d.get('imp_saldo_cuota1', 0.0)) - fch_canc_raw = d.get('fch_cancelacion_cuota1') - fch_canc_real_sql = d.get('fch_cancelacion_cuota1') # SQL puro, sin override - fch_mat = d.get('fch_matricula') - - # Override de fecha cancelación 1 (Supabase) → prioridad para clasificación + cuenta como pagado - _fov = getattr(self.data_manager, '_fecha_canc_overrides', None) or {} - _mat_key = str(mat_id).strip() - if _mat_key.endswith('.0'): _mat_key = _mat_key[:-2] - tiene_override_fecha = _mat_key in _fov - fecha_vaciada_override = False # override "borrar" → tiene prioridad sobre todo - if tiene_override_fecha: - s = str(_fov[_mat_key]).strip() - if s == "__VACIO__": - # Fecha borrada a propósito → anula la del SQL (no cuenta como pagado) - fch_canc_raw = None - tiene_override_fecha = False - fecha_vaciada_override = True - else: - # Normalizar dd/mm/yyyy o d/m/yyyy → yyyy-mm-dd - if '/' in s: - p = s.split('/') - if len(p) == 3: - s = f"{p[2]}-{int(p[1]):02d}-{int(p[0]):02d}" - fch_canc_raw = s - - if mat_id in correcciones_mat: - datos_corregidos = correcciones_mat[mat_id] - if "imp_tc" in datos_corregidos: imp_tc = float(datos_corregidos["imp_tc"]) - if "imp_saldo_cuota1" in datos_corregidos: saldo_c1 = float(datos_corregidos["imp_saldo_cuota1"]) - if "imp_saldo_matricula" in datos_corregidos: saldo_mat = float(datos_corregidos["imp_saldo_matricula"]) - # Supabase (vaciado) tiene prioridad: la corrección manual NO restaura la fecha - if "fch_cancelacion_cuota1" in datos_corregidos and not fecha_vaciada_override: - fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"] - - # Prioridad TC: Supabase (mes) > corrección > cronograma - _tc_ov = getattr(self.data_manager, '_tc_override_mes', None) - if _tc_ov: imp_tc = float(_tc_ov) - - if cod_moneda == 'DOL': inv_neta_soles = round(inv_neta_raw * imp_tc, 2) - else: inv_neta_soles = round(inv_neta_raw, 2) - - row_year, row_month = -1, -1 - fecha_mat_limpia = "" - if fch_mat: - try: - if hasattr(fch_mat, 'strftime'): - row_year, row_month = fch_mat.year, fch_mat.month - fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y') - else: - f_obj = datetime.strptime(str(fch_mat)[:10], '%Y-%m-%d') - row_year, row_month = f_obj.year, f_obj.month - fecha_mat_limpia = f_obj.strftime('%d/%m/%Y') - except: fecha_mat_limpia = str(fch_mat)[:10] - - fch_canc_limpia = "" - if fch_canc_raw and str(fch_canc_raw).strip() != "None": - try: - if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y') - else: - tmp = str(fch_canc_raw)[:10] - if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}" - else: fch_canc_limpia = tmp - except: fch_canc_limpia = str(fch_canc_raw)[:10] - - clase_alumno = self.clasificar_programa(nombre_prog_crudo) - linea_actual = self.identificar_linea_carrera(nombre_prog_crudo) - fecha_inicio_actual = fecha_inicio_prog.get(nombre_prog_crudo) - - es_refriperu = False - if mat_id in correcciones_desc: - if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True - elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False - else: - if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True - elif clase_alumno in ["TEAC", "TERC", "CARRERA"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True - - tipo_final = "NUEVO" - if es_refriperu: tipo_final = "REFRIPERU" - else: - es_cont = False - if mat_id in correcciones_cont: - if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True - elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False - else: - if fecha_inicio_actual and linea_actual and (dni in datos_historial): - for antecedente in datos_historial[dni]: - nombre_pasado = str(antecedente.get('programa', '')) - fecha_pasada = antecedente.get('fecha') - if not fecha_pasada: continue - if not (fecha_pasada < fecha_inicio_actual): continue - dias_diff = (fecha_inicio_actual - fecha_pasada).days - if dias_diff < 60: continue - linea_pasada = self.identificar_linea_carrera(nombre_pasado) - if linea_pasada == linea_actual: - es_cont = True - break - if es_cont: tipo_final = "CONTINUIDAD" - - # =============================================================== - # 🔥 MATEMÁTICA PURA EXACTAMENTE COMO LA PEDISTE 🔥 - # =============================================================== - suma_cuotas = 0.0 - mis_cuotas = cuotas_por_mat.get(mat_id, []) - - for c_dict in mis_cuotas: - try: num_c = int(c_dict.get('num_cuota', 0)) - except: num_c = 0 - - # REGLA: Si el número de cuota es mayor a 0, sumar (imp_total - imp_dscto) - if num_c > 0: - t_val = c_dict.get('imp_total', 0) - d_val = c_dict.get('imp_dscto', 0) - - try: t_monto = float(t_val) if t_val is not None else 0.0 - except: t_monto = 0.0 - try: d_monto = float(d_val) if d_val is not None else 0.0 - except: d_monto = 0.0 - - imp_c = t_monto - d_monto - - # Conversión si es Dólares - tc_over = None - if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]: - tc_over = float(correcciones_mat[mat_id]["imp_tc"]) - - cod_m_c = str(c_dict.get('cod_moneda', 'SOL')).strip().upper() - if cod_m_c == "DOL": - if tc_over is not None: tc_c = tc_over - else: - try: tc_c = float(c_dict.get('imp_tc', 0) or 0) - except: tc_c = 0 - if tc_c < 2: tc_c = 3.45 # TC por defecto si es inválido - imp_c *= tc_c - - suma_cuotas += imp_c - - # =============================================================== - # DIVISIÓN SEGÚN CLASIFICACIÓN - # =============================================================== - if suma_cuotas > 0: - if clase_alumno in ["TEAC", "TERC", "CARRERA"]: - valor_cuota_final = suma_cuotas / 5.0 - elif clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"]: - valor_cuota_final = suma_cuotas / 6.0 - else: - valor_cuota_final = suma_cuotas - - str_cuota_val = f"S/ {valor_cuota_final:,.0f}" - else: - valor_cuota_final = 0.0 - # Si no tiene cuotas mayores a 0, se queda en blanco limpio - str_cuota_val = "" - - # =============================================================== - # LÓGICA DESC. ESPECIAL (Sobre la suma pura) - # =============================================================== - desc_e_raw = 0.0 - if suma_cuotas >= 1900: - if clase_alumno in ["TEAC", "TERC", "CARRERA"]: d_calc = 3400 - suma_cuotas - elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - suma_cuotas - else: d_calc = 0 - - if d_calc > 0: - desc_e_raw = min(max(d_calc - 200, 0.0), 200.0) - - str_desc_e_val = f"S/ {desc_e_raw:,.0f}" if desc_e_raw > 0 else "" - - # ========================================== - # DISTRIBUCIÓN EN LAS 3 LISTAS - # ========================================== - vendedor_real = d.get('dsc_vendedor', 'SIN VENDEDOR') - if not vendedor_real: vendedor_real = 'SIN VENDEDOR' - - # Campos extra para el popup de Comisiones (mismo alumno / num_indice) - # NOTA: la query trae el programa DETALLADO con alias 'dsc_promocion' - # (sgede_RP_programa.dsc_det_programa AS dsc_promocion). - # MOSTRAR: prioriza el detallado (dsc_promocion) - det_programa = (d.get('dsc_promocion') or d.get('dsc_det_programa') - or d.get('dsc_programa') or '') - # CLASIFICAR (sede/filtro): prioriza dsc_programa - prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa') - or d.get('dsc_promocion') or '') - fch_ini_raw = d.get('fch_inicio', '') - fch_ini_limpia = "" - if fch_ini_raw and str(fch_ini_raw).strip() != "None": - try: - if hasattr(fch_ini_raw, 'strftime'): fch_ini_limpia = fch_ini_raw.strftime('%d/%m/%Y') - else: - t = str(fch_ini_raw)[:10] - if len(t) == 10 and t[4] == '-': - p = t.split('-'); fch_ini_limpia = f"{p[2]}/{p[1]}/{p[0]}" - else: fch_ini_limpia = t - except: fch_ini_limpia = str(fch_ini_raw)[:10] - str_saldo_mat = f"S/ {saldo_mat:,.0f}" if saldo_mat else "S/ 0" - str_saldo_c1 = f"S/ {saldo_c1:,.0f}" if saldo_c1 else "S/ 0" - - # ── Cálculos extra para Comisiones ────────────────────────── - # DÍAS ANTICIPACIÓN = Fecha Inicio - Fecha Cancelación 1 (en días) - dias_anticipacion = "-" - try: - def _to_date(v): - if not v or str(v).strip() in ("", "None"): return None - if hasattr(v, 'year'): return datetime(v.year, v.month, v.day) - s = str(v)[:10] - if len(s) == 10 and s[4] == '-': return datetime.strptime(s, '%Y-%m-%d') - if len(s) == 10 and s[2] == '/': - p = s.split('/'); return datetime(int(p[2]), int(p[1]), int(p[0])) - return None - di = _to_date(fch_ini_raw) - dc = _to_date(fch_canc_raw) - if di and dc: - dias_anticipacion = str((di - dc).days) - except Exception: - dias_anticipacion = "-" - - # TIPO PROGRAMA y sede (clasificación tipo Cobranza vía sede.json) - sede_alumno = self._clasificar_sede_comisiones(prog_clasificacion or nombre_prog_crudo) - tipo_programa = sede_alumno # según sede.json (LIMA/AREQUIPA/PIURA/TRUJILLO) - - # VALOR CUOTA ADICIONAL = Promedio cuota - 640 (solo si sede = LIMA, mínimo 0) - if sede_alumno == "LIMA" and valor_cuota_final > 0: - valor_adicional = valor_cuota_final - 640 - if valor_adicional < 0: - valor_adicional = 0 - str_valor_adicional = f"S/ {valor_adicional:,.0f}" - else: - str_valor_adicional = "-" - - fila_final = [ - vendedor_real, alumno_nombre, fecha_mat_limpia, fch_canc_limpia, - tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}", - # Extra para Comisiones (índices 8+): programa, fch inicio, saldos - det_programa, fch_ini_limpia, str_saldo_mat, str_saldo_c1, - # Nuevos (índices 12+): días anticipación, valor cuota adicional, tipo programa - dias_anticipacion, str_valor_adicional, tipo_programa, - # Índice [15]: num_matricula (clave para overrides de Comisiones) - mat_id, - # Índice [16]: nombre para CLASIFICAR (prioriza dsc_programa). Oculto en UI. - prog_clasificacion - ] - - es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes_numero)) - saldos_ok = (saldo_mat == 0 and saldo_c1 == 0) - if tiene_override_fecha: - saldos_ok = True - - def agregar_a_lista(nombre_lista): - listas_datos[nombre_lista]["filas"].append(fila_final) - listas_datos[nombre_lista]["s_neta"] += inv_neta_soles - - if valor_cuota_final > 0: - listas_datos[nombre_lista]["s_cuota"] += valor_cuota_final - listas_datos[nombre_lista]["c_cuota"] += 1 - - if desc_e_raw > 0: - listas_datos[nombre_lista]["s_desc"] += desc_e_raw - listas_datos[nombre_lista]["c_desc"] += 1 - - - - - - # Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas - # Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas - pago_en_fecha = False - if fch_canc_raw and str(fch_canc_raw).strip() != "None": - try: - if isinstance(fch_canc_raw, str): - f_obj = datetime.strptime(fch_canc_raw[:10], '%Y-%m-%d') - f_ano, f_mes = f_obj.year, f_obj.month - else: - f_ano, f_mes = fch_canc_raw.year, fch_canc_raw.month - - if str(f_ano) == str(ano) and str(f_mes) == str(mes_numero): - pago_en_fecha = True - except: pass - - # ¿Matrícula de un mes ANTERIOR al filtro? - matricula_mes_pasado = False - try: - ym_mat = int(row_year) * 100 + int(row_month) - ym_filtro = int(ano) * 100 + int(mes_numero) - matricula_mes_pasado = ym_mat < ym_filtro - except: pass - - if es_venta_del_mes: - agregar_a_lista("Venta Inscritos") - if saldos_ok and pago_en_fecha: - agregar_a_lista("Venta P.C") - - # VENTA PENDIENTES: SOLO de tu lista BASE_PENDIENTES y que pagaron su 1ª - # cuota EN EL MES/AÑO DEL FILTRO. Usa la FECHA REAL DEL SQL (sin override). - _pago_mes_real = False - if fch_canc_real_sql and str(fch_canc_real_sql).strip() != "None": - try: - if isinstance(fch_canc_real_sql, str): - _fo = datetime.strptime(fch_canc_real_sql[:10], '%Y-%m-%d') - _fa, _fm = _fo.year, _fo.month - else: - _fa, _fm = fch_canc_real_sql.year, fch_canc_real_sql.month - if int(_fa) == int(ano) and int(_fm) == int(mes_numero): - _pago_mes_real = True - except: pass - if matricula_int in set_lista_pendientes and _pago_mes_real: - agregar_a_lista("Venta Pendientes") - - - - - - - - # ========================================== - # ORDENAR Y ARMAR TOTAL GENERAL - # ========================================== - resultado_final = {} - for k, dict_data in listas_datos.items(): - dict_data["filas"].sort(key=lambda x: x[1]) - - p_cuota = (dict_data["s_cuota"] / dict_data["c_cuota"]) if dict_data["c_cuota"] > 0 else 0.0 - p_desc = (dict_data["s_desc"] / dict_data["c_desc"]) if dict_data["c_desc"] > 0 else 0.0 - - str_gral_cuota = f"S/ {p_cuota:,.0f}" if dict_data["c_cuota"] > 0 else "" - str_gral_desc = f"S/ {p_desc:,.0f}" if dict_data["c_desc"] > 0 else "" - - dict_data["filas"].append([ - "TOTAL GENERAL", "", "", "", "", - str_gral_cuota, str_gral_desc, f"S/ {dict_data['s_neta']:,.0f}" - ]) - resultado_final[k] = dict_data["filas"] - - return resultado_final - - except Exception as e: - print(f"Error procesando alumnos para modal Ventas: {e}") - return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []} - - def formatear_datos_para_tabla(self, datos): - if not datos: - return [] - - sheet_data = [] - total_monto = total_pc = total_pendientes = total_avance_pc_total = 0.0 - total_cantidad = total_cantidad_pc = total_cantidad_pendientes = 0 - - for registro in datos: - vendedor = registro.get('VENDEDOR', 'SIN VENDEDOR') - monto = float(registro.get('MONTO', 0.0)) - cantidad = int(registro.get('CANTIDAD', 0)) - ventas_pc = float(registro.get('VENTAS_PC', 0.0)) - cantidad_pc = int(registro.get('INSCRITOS_PC', 0)) - pendientes = float(registro.get('PENDIENTES', 0.0)) - cant_pendientes = int(registro.get('INSCRITOS_PENDIENTES', 0)) - - avance_pc_total = ventas_pc + pendientes - - total_monto += monto; total_cantidad += cantidad; total_pc += ventas_pc - total_cantidad_pc += cantidad_pc; total_pendientes += pendientes - total_cantidad_pendientes += cant_pendientes; total_avance_pc_total += avance_pc_total - - sheet_data.append([ - vendedor, cantidad, f"S/ {monto:,.0f}", cantidad_pc, f"S/ {ventas_pc:,.0f}", - cant_pendientes, f"S/ {pendientes:,.0f}", f"S/ {avance_pc_total:,.0f}", - " ≡ ▼ " - ]) - - sheet_data.append([ - "TOTAL GENERAL", total_cantidad, f"S/ {total_monto:,.0f}", - total_cantidad_pc, f"S/ {total_pc:,.0f}", total_cantidad_pendientes, - f"S/ {total_pendientes:,.0f}", f"S/ {total_avance_pc_total:,.0f}", - "" - ]) - - return sheet_data - - def exportar_detalle_alumnos_excel(self, vendedor, lista_nombre, headers, datos): - if not datos: raise ValueError("No hay datos para exportar") - vend_limpio = "".join([c if c.isalnum() else "_" for c in str(vendedor)])[:30] - lista_limpia = "".join([c if c.isalnum() else "_" for c in str(lista_nombre)]) - archivo = f"Ventas_{vend_limpio}_{lista_limpia}.xlsx" - - df = pd.DataFrame(datos, columns=headers) - try: - with pd.ExcelWriter(archivo, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Detalle_Ventas') - worksheet = writer.sheets['Detalle_Ventas'] - try: - from openpyxl.styles import PatternFill, Font, Border, Side, Alignment - fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid") - font_header = Font(color="FFFFFF", bold=True) - thin_border = Border(left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"), - top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD")) - - for cell in worksheet[1]: - cell.fill = fill_header - cell.font = font_header - cell.border = thin_border - cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) - - for row in worksheet.iter_rows(min_row=2): - for cell in row: - cell.border = thin_border - cell.alignment = Alignment(vertical="center") - - for col in worksheet.columns: - max_len = 0 - col_letter = col[0].column_letter - for cell in col: - if cell.value: max_len = max(max_len, len(str(cell.value))) - worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50) - except: pass - except: df.to_excel(archivo, index=False) - return archivo - - def exportar_a_excel(self, datos, ano, nombre_mes): - if not datos: raise ValueError("No hay datos para exportar") - datos_export = [] - for d in datos: - nuevo_d = dict(d) - nuevo_d['AVANCE_PC_TOTAL'] = float(d.get('VENTAS_PC', 0)) + float(d.get('PENDIENTES', 0)) - datos_export.append(nuevo_d) - - df = pd.DataFrame(datos_export) - archivo = f"ventas_{ano}_{nombre_mes}.xlsx" - df.to_excel(archivo, index=False) - return archivo \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 1c2d5da..0b80d1a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,10 +1,6 @@ -fastapi==0.115.0 -uvicorn[standard]==0.30.6 -pyodbc==5.1.0 -requests==2.32.3 -pandas==2.2.2 -openpyxl==3.1.5 -python-dotenv==1.0.1 -supabase==2.15.1 -httpx==0.28.1 -pytz==2024.1 +fastapi +uvicorn +python-dotenv +pyodbc +psycopg2-binary +requests diff --git a/backend/services.py b/backend/services.py index 8c56a99..c12d124 100644 --- a/backend/services.py +++ b/backend/services.py @@ -1,31 +1,11 @@ # backend/services.py -""" -Capa de servicios: envuelve la lógica existente (modules/) con caché. -NO modifica la lógica de negocio — solo la llama y serializa el resultado. -""" -from core.data_manager import DataManager -from modules.ocupabilidad.logic import AnalizadorCursos -from modules.ventas.logic import VentasLogic -from modules.cobranza.logic import CobranzaLogic -from modules.rentabilidad.logic import RentabilidadLogic -from modules.saldo_pendiente.logic import SaldoLogic -from cache_manager import cache_get_or_set +"""Servicios: traen datos crudos (cacheados) y arman la respuesta del módulo Leads.""" +from data_manager_v2 import DataManager +import leads_logic as L +from cache_manager import cache_get_or_set, cache_invalidate -# Instancia única del DataManager (como @st.cache_resource) _DM = None -# Hora de la última actualización de DATOS (se setea al precargar/refrescar) -_ultima_actualizacion = None - -def _marcar_actualizacion(): - global _ultima_actualizacion - from datetime import datetime - _ultima_actualizacion = datetime.now() - -def ultima_actualizacion(): - """Devuelve la hora (ISO) del último refresco de datos, o None.""" - return {"hora": _ultima_actualizacion.isoformat() if _ultima_actualizacion else None} - def get_dm() -> DataManager: global _DM @@ -34,875 +14,1755 @@ def get_dm() -> DataManager: return _DM -def _serializar_dicts(datos): - out = [] - for d in (datos or []): - fila = {} - for k, v in d.items(): - fila[k] = v if isinstance(v, (str, int, float, bool)) or v is None else str(v) - out.append(fila) - return out +# ── Prefijos de cache que dependen del mapa de campañas (leyendas). Al cambiar +# una leyenda se invalidan estos para que TODOS los apartados se reconstruyan +# en su proxima peticion (perezoso: solo lo que se pida, ~2-5s), sin esperar +# los 15 min ni recargar todo. NO se toca la logica, solo se refresca el cache. +_PREF_CAMPANIAS = ( + "campanias_map", "campanias_sc", "leads_raw", "leads_dash", "leads_filtros", + "leads_alertas", "vendedores_dash", "roas_dash", "otros_general", + "matriz_always", "matriz_webform", "matriz_asignados", "matriz_plantillas", + "tiempo_resp", "importe_pauta_periodo", "resultados_pauta_periodo", +) -def _serializar_filas(filas, limite=None): - out = [] - for f in (filas or []): - vals = f[:limite] if limite else f - out.append([str(v) if v is not None else "" for v in vals]) - return out +def _invalidar_por_campanias(): + """Invalida el cache de campañas (Supabase) y de todos los apartados que + dependen de las leyendas, para que el cambio se vea de inmediato.""" + from data_manager_v2 import invalidar_campanias_cache + invalidar_campanias_cache() + for p in _PREF_CAMPANIAS: + cache_invalidate(p) -# ── OCUPABILIDAD ─────────────────────────────────────────────────────────── -def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"): +# ── Datos crudos cacheados (consultas pesadas, una sola vez) ──── +def _leads_crudos(): + return cache_get_or_set("leads_raw", ("GLOBAL",), + lambda: L.procesar_leads(get_dm().traer_leads_chatwoot())) + + +def _cursos_crudos(): + return cache_get_or_set("cursos_raw", ("GLOBAL",), lambda: get_dm().traer_cursos()) + + +def _matriculas_crudas(): + return cache_get_or_set("mat_raw", ("GLOBAL",), lambda: get_dm().traer_matriculas()) + + +def _pauta_cruda(): + return cache_get_or_set("pauta_raw", ("GLOBAL",), lambda: get_dm().traer_pauta_cursos()) + + +def _programa_pautas(): + """{num_indice: [pautas]} desde la tabla programa_pautas (muchos-a-muchos).""" + return cache_get_or_set("programa_pautas", ("GLOBAL",), + lambda: get_dm().traer_programa_pautas()) + + +def _pautas_de_indice(): + """{num_indice: [pautas]} combinando: + - la pauta de basebi_programacion (1 por num_indice), y + - las pautas extra de programa_pautas (varias). + Sin duplicados. Un num_indice puede terminar con varias pautas que se SUMAN.""" def _load(): - dm = get_dm() - analizador = AnalizadorCursos(dm) - datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa) - filas = analizador.formatear_datos_para_tabla(datos) - return { - "datos": _serializar_dicts(datos), - "filas": _serializar_filas(filas), - } - return cache_get_or_set("ocupabilidad", (ano, mes, sede, programa), _load) - - -def ocupabilidad_alumnos(num_indice): - """Alumnos de un curso (num_indice) para el popup Ver: nombre + estado (sin ANU).""" - def _load(): - dm = get_dm() - analizador = AnalizadorCursos(dm) - alumnos = analizador.curso_processor.obtener_alumnos_curso(num_indice) - return {"alumnos": alumnos} - return cache_get_or_set("ocup_alumnos", (str(num_indice),), _load) - - -# ── VENTAS ───────────────────────────────────────────────────────────────── -def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False): - def _load(): - dm = get_dm() - # Los overrides de Supabase SOLO aplican en Comisiones (con_overrides=True). - # En Ventas se ignoran: la tabla muestra los datos reales del SQL. - if con_overrides: - dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) # TC de Supabase o None - dm._fecha_canc_overrides = comisiones_overrides_fecha() # {mat: fecha_canc override} - dm._inv_neta_overrides = comisiones_overrides_inv_neta() # {mat: inv_neta override} - else: - dm._tc_override_mes = None - dm._fecha_canc_overrides = {} - dm._inv_neta_overrides = {} - logic = VentasLogic(dm) - datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa) \ - if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes)) - datos = [d for d in datos if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR'] - - # PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que - # la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla). - try: - det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), str(mes)) - pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else [] - pend_por_vend = {} - for f in pend_filas: - if not f or str(f[0]).strip().upper() == "TOTAL GENERAL": - continue - vend = str(f[0]).strip() - # INV NETA está en la posición [7] como "S/ x" - try: - monto = float(str(f[7]).replace("S/", "").replace(",", "").strip()) - except Exception: - monto = 0.0 - acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0}) - acc["monto"] += monto - acc["cant"] += 1 - # Sobrescribir PENDIENTES en cada registro de la tabla principal - for d in datos: - v = str(d.get("VENDEDOR", "")).strip() - acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0}) - d["PENDIENTES"] = round(acc["monto"], 2) - d["INSCRITOS_PENDIENTES"] = acc["cant"] - except Exception as e: - print(f"[ventas] no se pudo alinear PENDIENTES con el Ver: {e}") - - filas = logic.formatear_datos_para_tabla(datos) - return { - "filas": _serializar_filas(filas), - "datos": _serializar_dicts(datos), - } - return cache_get_or_set("ventas", (ano, mes, sede, programa, con_overrides), _load) - - -def _clasif_filtro_programa(dm, dscp): - """Misma clasificación de programa que ejecutar_consulta_ventas (SEMINARIOS/TEAC/TERC/OTROS).""" - up = str(dscp or "").upper() - fdata = dm.meta_data.get("clasificacion_filtro_programa", {}) - for cat in ["OTROS", "SEMINARIOS", "TEAC", "TERC"]: - for pat in fdata.get(cat, {}).get("patrones", []): - if pat.upper() in up: - return cat - return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS" - - -def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS"): - def _load(): - dm = get_dm() - dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) - dm._fecha_canc_overrides = comisiones_overrides_fecha() - logic = VentasLogic(dm) - resultado = logic.obtener_detalle_vendedor(vendedor, str(ano), str(mes)) - filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else [] - filas = _aplicar_overrides_comisiones(filas) - # Filtro por SEDE [14] y PROGRAMA (clasificado desde el nombre [16]), - # mismas reglas que la tabla principal. Omite la fila TOTAL GENERAL. - _sede = str(sede or "TODOS").upper() - _prog = str(programa or "TODOS").upper() - if _sede != "TODOS" or _prog != "TODOS": - out = [] - for f in filas: - if str(f[0]).strip().upper() == "TOTAL GENERAL": - continue # el TOTAL se recalcula en el frontend - if len(f) < 15: - out.append(f); continue - if _sede != "TODOS" and str(f[14]).strip().upper() != _sede: - continue - # Clasificar programa por el nombre de CLASIFICACIÓN [16] (prioriza dsc_programa); - # si no existe, usar el mostrado [8]. - _prog_nombre = f[16] if len(f) > 16 else f[8] - if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog: - continue - out.append(f) - filas = out - return {"filas": _serializar_filas(filas)} - return cache_get_or_set("ventas_det", (vendedor, ano, mes, tipo_lista, sede, programa), _load) - - -def comisiones_detalle_todos(ano, mes): - """Devuelve TODOS los matriculados (todos los años/meses) para el buscador. - Aplica overrides de Supabase. Cacheado globalmente (no depende del mes).""" - def _load(): - dm = get_dm() - logic = VentasLogic(dm) - filas = logic.buscar_todos_matriculados() - filas = _aplicar_overrides_comisiones(filas) - return {"filas": _serializar_filas(filas)} - return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load) - - -def comisiones_overrides_fecha(): - """Devuelve dict {num_matricula: fecha_cancelacion1_override} para clasificación.""" - try: - sb = _sb() - res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute() + pm = _pauta_cruda() # {num_indice: {pauta,...}} + extra = _programa_pautas() # {num_indice: [pautas]} out = {} - for r in (res.data or []): - mat = str(r["num_matricula"]) - v = r.get("fecha_cancelacion1") - sv = "" if v is None else str(v).strip() - # Solo "__VACIO__" = fecha borrada a propósito → anula la del SQL. - # None/"" = la fila tiene override de OTROS campos pero no toco la fecha -> usar SQL. - if sv == "__VACIO__": - out[mat] = "__VACIO__" - elif sv != "": - out[mat] = sv + for ni, info in pm.items(): + p = info.get("pauta") + if p: + out.setdefault(str(ni).strip(), []).append(str(p).strip()) + for ni, pautas in extra.items(): + lst = out.setdefault(str(ni).strip(), []) + for p in pautas: + if str(p).strip() not in lst: + lst.append(str(p).strip()) return out - except Exception: - return {} + return cache_get_or_set("pautas_de_indice", ("GLOBAL",), _load) -def comisiones_overrides_inv_neta(): - """Devuelve dict {num_matricula: inversion_neta(float)} para la tabla principal. - El valor es el monto final en soles (no se re-convierte por TC).""" - try: - sb = _sb() - res = sb.table("comisiones_overrides").select("num_matricula,inversion_neta").execute() +def _campanias_map(): + return cache_get_or_set("campanias_map", ("GLOBAL",), + lambda: get_dm().traer_campanias_map()) + + +def _conjunto_pauta(): + return cache_get_or_set("conjunto_pauta", ("GLOBAL",), + lambda: get_dm().traer_conjunto_pauta()) + + +def _meta_filas(): + """Filas crudas del CSV de Meta: [{conjunto, importe, inicio}, ...].""" + return cache_get_or_set("meta_filas", ("GLOBAL",), + lambda: get_dm().traer_meta_importe()) + + +def _importe_por_pauta(): + """{pauta: importe_gastado_total} = suma del importe de TODAS las filas de los + conjuntos ligados a esa pauta (conjunto_pauta × Meta CSV). Sin filtro de fecha.""" + def _load(): + conj_pauta = _conjunto_pauta() # {pauta: [conjunto,...]} + imp_norm = {} + for f in _meta_filas(): + k = " ".join(str(f.get("conjunto")).split()).upper() + imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0) out = {} - for r in (res.data or []): - v = r.get("inversion_neta") - if v is None: + for pauta, conjuntos in conj_pauta.items(): + total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos) + out[str(pauta).strip()] = round(total, 2) + return out + return cache_get_or_set("importe_pauta", ("GLOBAL",), _load) + + +def _importe_por_pauta_periodo(ano, mes, dia): + """{pauta: importe} sumando solo las filas del CSV cuyo 'Inicio del informe' + cae dentro del periodo filtrado (año/mes/día). Respeta el filtro de arriba.""" + def _load(): + conj_pauta = _conjunto_pauta() + imp_norm = {} + sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None)) + for f in _meta_filas(): + fx = L._to_date(f.get("inicio")) + if sin_filtro: + pass # sin filtro: cuenta todo (incluye filas sin fecha) = total + elif not L._en_periodo(fx, ano, mes, dia): continue - s = str(v).replace("S/", "").replace(",", "").strip() - if s == "": + k = " ".join(str(f.get("conjunto")).split()).upper() + imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0) + out = {} + for pauta, conjuntos in conj_pauta.items(): + total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos) + out[str(pauta).strip()] = round(total, 2) + return out + return cache_get_or_set("importe_pauta_periodo", (ano, mes, dia), _load) + + +def _resultados_por_pauta_periodo(ano, mes, dia): + """{pauta: resultados} sumando solo las filas del CSV cuyo 'Inicio del informe' + cae en el periodo filtrado. Analogo a _importe_por_pauta_periodo pero con Resultados.""" + def _load(): + conj_pauta = _conjunto_pauta() + res_norm = {} + sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None)) + for f in _meta_filas(): + if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia): continue + k = " ".join(str(f.get("conjunto")).split()).upper() + res_norm[k] = res_norm.get(k, 0.0) + float(f.get("resultados") or 0.0) + out = {} + for pauta, conjuntos in conj_pauta.items(): + total = sum(res_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos) + out[str(pauta).strip()] = int(total) + return out + return cache_get_or_set("resultados_pauta_periodo", (ano, mes, dia), _load) + + +def _campanias_sede_cargo(): + return cache_get_or_set("campanias_sc", ("GLOBAL",), + lambda: get_dm().traer_campanias_sede_cargo()) + + +def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"): + """Matriz de gasto Meta Ads de pautas ALWAYS (con gasto pero SIN num_indice). + Agrupa Sede -> Programa (TEAC/TERC/SEMINARIOS) con Importe, Resultados, + Leads Nuevos (fecha_creada en el mes) y Leads Nuevos Asignados (con asesor). + Sede/programa salen de la tabla campanias. Pautas sin campanias NO se cuentan.""" + def _load(): + conj_pauta = _conjunto_pauta() # {pauta: [conjuntos]} + camp = _campanias_sede_cargo() # {pauta: {sede,cargo}} + pauta_map = _pauta_cruda() # {num_indice: {pauta,...}} + con_indice = {str(v.get("pauta")).strip() for v in pauta_map.values() if v.get("pauta")} + + # Leads nuevos (unicos) por pauta = codigo, cuya fecha_creada cae en el mes filtrado + leads = _leads_crudos() + leads_nuevos = {} # codigo -> nº leads unicos del mes + leads_nuevos_asig = {} # codigo -> nº leads unicos del mes con asesor + tel_lead_mes = {} # telefono -> (codigo, fecha_creada) del lead unico del mes + for l in leads: + if not l.get("es_unico"): + continue + if not L._en_periodo(l.get("fecha"), ano, mes, dia): + continue + cod = str(l.get("codigo") or "").strip() + if not cod: + continue + leads_nuevos[cod] = leads_nuevos.get(cod, 0) + 1 + if str(l.get("asesor") or "").strip(): + leads_nuevos_asig[cod] = leads_nuevos_asig.get(cod, 0) + 1 + tel = str(l.get("telefono") or "").strip() + if tel: + tel_lead_mes[tel] = (cod, l.get("fecha")) + + # MATRICULAS por pauta: matriculas cuyo telefono = un lead nuevo del mes, y con + # fch_matricula >= fecha_creada del lead. Cuenta CADA matricula (una vez c/u), + # atribuida a la pauta del lead. Cruce por telefono (tel_1, o tel_2 si vacio). + matriculas_pauta = {} # codigo -> nº matriculas + venta_pauta = {} # codigo -> suma INV_NETA en soles (DOL ×3.34) + _vistas = set() # (num_matricula) para no contar 2 veces + for m in _matriculas_crudas(): + t1 = str(m.get("dsc_telefono_1") or "").strip() + t2 = str(m.get("dsc_telefono_2") or "").strip() + ph = t1 if t1 else t2 + if ph not in tel_lead_mes: + continue + cod, f_lead = tel_lead_mes[ph] + fm = L._to_date(m.get("fch_matricula")) + if not fm or not f_lead or fm < f_lead: + continue # matricula anterior al ingreso del lead -> no cuenta + nm = str(m.get("num_matricula")) + if nm in _vistas: + continue + _vistas.add(nm) + matriculas_pauta[cod] = matriculas_pauta.get(cod, 0) + 1 + # INV_NETA en soles: si moneda DOL, ×3.34 (igual que tabla por Sede) try: - out[str(r["num_matricula"])] = float(s) - except Exception: - pass - return out - except Exception: - return {} + inv = float(m.get("INV_NETA", 0) or 0) + except (TypeError, ValueError): + inv = 0.0 + if str(m.get("cod_moneda", "")).strip().upper() == "DOL": + inv *= 3.34 + venta_pauta[cod] = venta_pauta.get(cod, 0.0) + inv - -def _aplicar_overrides_comisiones(filas): - """Sobreescribe valores de cada fila con los guardados en Supabase (por num_matricula). - Índices de fila: [2]F.MAT [7]INV.NETA [9]F.INI [10]SALDO MAT [11]SALDO C1 - [12]DÍAS [13]VALOR ADIC [14]TIPO PROG [15]num_matricula. (5=PROMEDIO CUOTA)""" - try: - dm = get_dm() - if not getattr(dm, "supabase_client", None): - return filas - res = dm.supabase_client.table("comisiones_overrides").select("*").execute() - ov = {str(r["num_matricula"]): r for r in (res.data or [])} - if not ov: - return filas - # columna override -> índice en la fila - mapa = { - "fch_matricula": 2, "promedio_cuota": 5, "inversion_neta": 7, - "fecha_inicio": 9, "saldo_matricula": 10, "saldo_cuota1": 11, - "dias_anticipacion": 12, "valor_cuota_adicional": 13, "tipo_programa": 14, - "fecha_cancelacion1": 3, - } - for f in filas: - if len(f) < 16: + # Importe y Resultados por conjunto, filtrando por el MES de arriba + imp = {}; res = {} + sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None)) + for f in _meta_filas(): + if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia): continue - mat = str(f[15]) - # [16] = nombre para clasificar (si no vino, usar el mostrado [8]) - # [17] = flag "tiene override" (0 por defecto) - if len(f) == 16: - f.append(f[8]) # buscador: no trae [16] → reutiliza el nombre mostrado - if len(f) == 17: - f.append("0") - if mat in ov: - r = ov[mat] - for col, idx in mapa.items(): - val = r.get(col) - if val is None: - continue - if str(val) == "__VACIO__": - f[idx] = "" # forzar vacío explícito (override "borrar") - elif str(val) != "": - f[idx] = val - f[17] = "1" # marcar fila como editada - return filas - except Exception as e: - print(f"[overrides comisiones] {e}") - return filas + k = " ".join(str(f.get("conjunto")).split()).upper() + imp[k] = imp.get(k, 0.0) + float(f.get("importe") or 0.0) + res[k] = res.get(k, 0.0) + float(f.get("resultados") or 0.0) + + # Acumular por Sede -> Programa -> Pauta (solo pautas ALWAYS que esten en campanias) + from collections import defaultdict + grupos = defaultdict(lambda: defaultdict(lambda: {"importe": 0.0, "resultados": 0.0, + "nuevos": 0, "nuevos_asig": 0, + "matriculas": 0, "venta": 0.0, "pautas": []})) + for pauta, conjuntos in conj_pauta.items(): + p = str(pauta).strip() + if p in con_indice: # tiene num_indice -> NO es always + continue + info = camp.get(p) + if not info: # no esta en campanias -> no se cuenta + continue + se = str(info.get("sede") or "").upper() + gr = L.grupo_programa_lead(info.get("cargo")) # TEAC/TERC/SEMINARIOS + ti = sum(imp.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos) + tr = sum(res.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos) + if ti == 0 and tr == 0: + continue # sin gasto en el mes -> no aparece + # filtros de arriba + if sede not in ("TODOS", None) and se != str(sede).upper(): + continue + if programa not in ("TODOS", None) and gr != str(programa).upper(): + continue + nv = leads_nuevos.get(p, 0) + na = leads_nuevos_asig.get(p, 0) + mt = matriculas_pauta.get(p, 0) + vv = venta_pauta.get(p, 0.0) + g = grupos[se][gr] + g["importe"] += ti + g["resultados"] += tr + g["nuevos"] += nv + g["nuevos_asig"] += na + g["matriculas"] += mt + g["venta"] += vv + g["pautas"].append({"pauta": p, "importe": round(ti, 2), "resultados": int(tr), + "nuevos": nv, "nuevos_asig": na, "matriculas": mt, + "venta": round(vv, 0)}) + + # Armar filas: Sede -> subfilas por programa -> pautas (solo con gasto) + filas = [] + for se in sorted(grupos.keys()): + subs = [] + s_imp = s_res = 0.0; s_nv = s_na = s_mt = 0; s_vv = 0.0 + for gr in ("TEAC", "TERC", "SEMINARIOS"): + if gr in grupos[se]: + d = grupos[se][gr] + pautas = sorted(d["pautas"], key=lambda x: -x["importe"]) + subs.append({"programa": gr, "importe": round(d["importe"], 2), + "resultados": int(d["resultados"]), + "nuevos": d["nuevos"], "nuevos_asig": d["nuevos_asig"], + "matriculas": d["matriculas"], "venta": round(d["venta"], 0), + "pautas": pautas}) + s_imp += d["importe"]; s_res += d["resultados"] + s_nv += d["nuevos"]; s_na += d["nuevos_asig"]; s_mt += d["matriculas"]; s_vv += d["venta"] + filas.append({"sede": se, "importe": round(s_imp, 2), "resultados": int(s_res), + "nuevos": s_nv, "nuevos_asig": s_na, "matriculas": s_mt, + "venta": round(s_vv, 0), "subfilas": subs}) + tot_i = round(sum(f["importe"] for f in filas), 2) + tot_r = int(sum(f["resultados"] for f in filas)) + tot_nv = sum(f["nuevos"] for f in filas) + tot_na = sum(f["nuevos_asig"] for f in filas) + tot_mt = sum(f["matriculas"] for f in filas) + tot_vv = round(sum(f["venta"] for f in filas), 0) + return {"filas": filas, "total": {"importe": tot_i, "resultados": tot_r, + "nuevos": tot_nv, "nuevos_asig": tot_na, + "matriculas": tot_mt, "venta": tot_vv}} + + return cache_get_or_set("matriz_always", (ano, mes, dia, sede, programa), _load) -# ── COBRANZA ─────────────────────────────────────────────────────────────── -def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta="TODOS"): +def matriz_web_formulario(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"): + """Matriz de leads con canal WEB_FORMULARIO (cartera_junta), agrupados Sede -> Programa. + Valores: Leads Recibidos (todos), Leads Procesados (con asesor), Matriculas y + Valor Venta (cruce por telefono, fch_matricula >= fecha_creada del lead).""" def _load(): - dm = get_dm() - logic = CobranzaLogic(dm) - filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta) - sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes)) - return { - "filas": _serializar_filas(filas), - "sectoristas": sectoristas or ["TODOS"], + rows = _cartera_rows_cache() + # 1) leads WEB_FORMULARIO cuya fila es el ORIGEN del telefono en TODA la cartera + # (es_origen=SI) y con fecha_creada en el periodo. Asi NO cuenta si el telefono + # ya vino antes por otro canal (igual criterio que las demas tablas). + vistos = {} # telefono -> {fecha, sede, prog, asesor} + for r in rows: + if str(r.get("canal") or "").strip().upper() != "WEB_FORMULARIO": + continue + if str(r.get("es_origen") or "").strip().upper() != "SI": + continue # solo el origen real del telefono + tel = str(r.get("telefono") or "").strip() + if not tel: + continue + f = L._to_date(r.get("fecha_creada")) + if not L._en_periodo(f, ano, mes, dia): + continue + # sede y programa YA vienen normalizados en cartera_junta. Reducir a 4 grupos. + se = " ".join(str(r.get("sede") or "").upper().split()) or "SIN SEDE" + pr = " ".join(str(r.get("programa") or "").upper().split()) + if pr == "TEAC": + gr = "TEAC" + elif pr == "TERC": + gr = "TERC" + elif pr in ("SEMINARIOS", "VRF", "CO2", "DIPLOMADO", "VENTILACIÓN", "VENTILACION"): + gr = "SEMINARIOS" + else: + gr = "OTROS" + vistos[tel] = {"fecha": f, "sede": se, "prog": gr, + "asesor": str(r.get("asesor") or "").strip()} + + # 2) matriculas por telefono (num_matricula, fecha, inv en soles) + from collections import defaultdict + mat_por_tel = defaultdict(list) + for m in _matriculas_crudas(): + t1 = str(m.get("dsc_telefono_1") or "").strip() + t2 = str(m.get("dsc_telefono_2") or "").strip() + ph = t1 if t1 else t2 + if not ph: + continue + fm = L._to_date(m.get("fch_matricula")) + try: + inv = float(m.get("INV_NETA", 0) or 0) + except (TypeError, ValueError): + inv = 0.0 + if str(m.get("cod_moneda", "")).strip().upper() == "DOL": + inv *= 3.34 + mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv)) + + # 3) agrupar Sede -> Programa + grupos = defaultdict(lambda: defaultdict(lambda: {"recibidos": 0, "procesados": 0, + "matriculas": 0, "venta": 0.0})) + for tel, info in vistos.items(): + se = info["sede"]; gr = info["prog"]; f_lead = info["fecha"] + if sede not in ("TODOS", None) and se != str(sede).upper(): + continue + if programa not in ("TODOS", None) and gr != str(programa).upper(): + continue + g = grupos[se][gr] + g["recibidos"] += 1 + if info["asesor"]: + g["procesados"] += 1 + for (nm, fm, inv) in mat_por_tel.get(tel, []): + if fm and f_lead and fm >= f_lead: + g["matriculas"] += 1 + g["venta"] += inv + + # 4) filas + filas = [] + for se in sorted(grupos.keys()): + subs = [] + s_rec = s_proc = s_mt = 0; s_vv = 0.0 + for gr in ("TEAC", "TERC", "SEMINARIOS", "OTROS"): + if gr in grupos[se]: + d = grupos[se][gr] + subs.append({"programa": gr, "recibidos": d["recibidos"], + "procesados": d["procesados"], "matriculas": d["matriculas"], + "venta": round(d["venta"], 0)}) + s_rec += d["recibidos"]; s_proc += d["procesados"] + s_mt += d["matriculas"]; s_vv += d["venta"] + filas.append({"sede": se, "recibidos": s_rec, "procesados": s_proc, + "matriculas": s_mt, "venta": round(s_vv, 0), "subfilas": subs}) + tot = {"recibidos": sum(f["recibidos"] for f in filas), + "procesados": sum(f["procesados"] for f in filas), + "matriculas": sum(f["matriculas"] for f in filas), + "venta": round(sum(f["venta"] for f in filas), 0)} + return {"filas": filas, "total": tot} + + return cache_get_or_set("matriz_webform", (ano, mes, dia, sede, programa), _load) + + +def _asignacion_respuesta_crudos(): + return cache_get_or_set("asig_resp", ("GLOBAL",), + lambda: get_dm().traer_asignacion_respuesta()) + + +def _leads_asignados_crudos(): + # UNIFICADO: usa la MISMA consulta/cache que el grafico (asignacion+respuesta+etiquetas), + # asi la tarjeta, la matriz de asignados y el grafico nunca se desfasan. + return _asignacion_respuesta_crudos() + + +def _actividad_asesores_crudos(): + return cache_get_or_set("act_asesores", ("GLOBAL",), + lambda: get_dm().traer_actividad_asesores()) + + +def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"): + """Matriz pivot: filas = asesor (user_name) con sus telefonos; columnas = dias del + mes; valor = cantidad de telefonos asignados a ese asesor ese dia. + Fecha = created_at del mensaje de asignacion (ya con -5h).""" + def _load(): + import calendar + from collections import defaultdict + rows = _leads_asignados_crudos() + # dias del mes filtrado (columnas) + if mes not in ("TODOS", None) and ano not in ("TODOS", None): + ndias = calendar.monthrange(int(ano), int(mes))[1] + dias_cols = list(range(1, ndias + 1)) + else: + dias_cols = list(range(1, 32)) + + # asesores excluidos de esta matriz + EXCLUIR = {"SIN ASESOR", "COPITO RIVERA"} + # asesor -> dia -> [telefonos] + por = defaultdict(lambda: defaultdict(list)) + for r in rows: + f = L._to_date(r.get("created_at")) + if not L._en_periodo(f, ano, mes, dia): + continue + ase = str(r.get("user_name") or "Sin Asesor").strip() or "Sin Asesor" + if ase.upper() in EXCLUIR: + continue + tel = str(r.get("telefono") or "").strip() + por[ase][f.day].append(tel) + + filas = [] + tot_por_dia = defaultdict(int) + for ase in sorted(por.keys()): + pordia = {d: len(por[ase].get(d, [])) for d in dias_cols} + total = sum(pordia.values()) + # telefonos por dia (para expandir con +) + tels_dia = {d: sorted(por[ase].get(d, [])) for d in dias_cols if por[ase].get(d)} + for d, n in pordia.items(): + tot_por_dia[d] += n + filas.append({"asesor": ase, "por_dia": pordia, "total": total, "tels_dia": tels_dia}) + filas.sort(key=lambda x: -x["total"]) + total_gral = sum(f["total"] for f in filas) + return {"dias": dias_cols, "filas": filas, + "total_por_dia": {d: tot_por_dia.get(d, 0) for d in dias_cols}, + "total": total_gral} + return cache_get_or_set("matriz_asignados", (ano, mes, dia), _load) + + +def _plantillas_crudas(): + return cache_get_or_set("plantillas_raw", ("GLOBAL",), + lambda: get_dm().traer_plantillas()) + + +def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"): + """Matriz de plantillas COBRADAS por plantilla. Columnas: + ENVIADAS = Mas_de_24h=SI, created_at_peru en el periodo filtrado + RESPONDIDAS= de esas, con respuesta del contacto (siguiente_mensaje_1) + ASIGNADAS = de esas, con asesor Y respondida por el contacto + MATRICULAS = matriculas de esos telefonos (fch_matricula >= envio mas antiguo). Sin filtro de mes. + VENTA = INV_NETA (DOL x3.34) de esas matriculas.""" + from datetime import timedelta, datetime + from collections import defaultdict + + def _load(): + rows = _plantillas_crudas() + UMBRAL = timedelta(hours=24) + + def _lleno(s): + return s is not None and str(s).strip() != "" + + enviadas = defaultdict(int) + respondidas = defaultdict(int) + asignadas = defaultdict(int) + prim_por_tel = {} # telefono -> (fecha_envio, plantilla) la cobrada mas antigua DEL PERIODO + + for r in rows: + plantilla = r.get("plantilla") + envio = r.get("created_at_peru") + f = L._to_date(envio) + # ENVIADAS/RESP/ASIG respetan el filtro de mes (por created_at_peru) + if not L._en_periodo(f, ano, mes, dia): + continue + anterior = r.get("hora_anterior_contacto") + sig = r.get("siguiente_mensaje_1") + es_si = False + if plantilla == "descuento_egresados_seminarios": + es_si = True + elif anterior is not None and (envio - anterior) > UMBRAL: + es_si = True + if not es_si: + continue + enviadas[plantilla] += 1 + if _lleno(sig): + respondidas[plantilla] += 1 + if _lleno(r.get("user_name")) and _lleno(sig): + asignadas[plantilla] += 1 + tel = str(r.get("telefono") or "").strip() + if tel and envio is not None: + if tel not in prim_por_tel or envio < prim_por_tel[tel][0]: + prim_por_tel[tel] = (envio, plantilla) + + # MATRICULAS + VENTA de los telefonos filtrados (sin filtro de mes en la matricula) + mat_por_tel = defaultdict(list) + for m in _matriculas_crudas(): + t1 = str(m.get("dsc_telefono_1") or "").strip() + t2 = str(m.get("dsc_telefono_2") or "").strip() + ph = t1 if t1 else t2 + if not ph: + continue + fm = m.get("fch_matricula") + if isinstance(fm, datetime): fm = fm.date() + try: + inv = float(m.get("INV_NETA", 0) or 0) + except (TypeError, ValueError): + inv = 0.0 + if str(m.get("cod_moneda", "")).strip().upper() == "DOL": + inv *= 3.34 + mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv)) + + matriculas = defaultdict(int) + venta = defaultdict(float) + _vistas = set() + for tel, (fenvio, plantilla) in prim_por_tel.items(): + fenvio_d = fenvio.date() if hasattr(fenvio, "date") else fenvio + for (nm, fm, inv) in mat_por_tel.get(tel, []): + if not fm or nm in _vistas: + continue + if fm >= fenvio_d: + _vistas.add(nm) + matriculas[plantilla] += 1 + venta[plantilla] += inv + + filas = [] + for p in sorted(enviadas.keys(), key=lambda x: -enviadas[x]): + filas.append({"plantilla": p, "enviadas": enviadas[p], + "respondidas": respondidas.get(p, 0), + "asignadas": asignadas.get(p, 0), + "matriculas": matriculas.get(p, 0), + "venta": round(venta.get(p, 0.0), 0)}) + tot = {"enviadas": sum(enviadas.values()), "respondidas": sum(respondidas.values()), + "asignadas": sum(asignadas.values()), "matriculas": sum(matriculas.values()), + "venta": round(sum(venta.values()), 0)} + return {"filas": filas, "total": tot} + + return cache_get_or_set("matriz_plantillas", (ano, mes, dia), _load) + + +def _norm_ni(x): + """Normaliza num_indice a texto (quita el '.0' si viniera como float).""" + s = str(x or "").strip() + return s[:-2] if s.endswith(".0") else s + + +def _mat_por_indice(): + """{num_indice: [(telefono_resuelto, fecha_matricula), ...]} desde las matrículas. + Teléfono resuelto = dsc_telefono_1 si existe; si no, dsc_telefono_2.""" + def _load(): + from collections import defaultdict + out = defaultdict(list) + for m in _matriculas_crudas(): + ni = _norm_ni(m.get("num_indice")) + t1 = str(m.get("dsc_telefono_1") or "").strip() + t2 = str(m.get("dsc_telefono_2") or "").strip() + fm = L._to_date(m.get("fch_matricula")) + out[ni].append((t1 if t1 else t2, fm)) + return out + return cache_get_or_set("mat_por_indice", ("GLOBAL",), _load) + + +def _cartera_rows_cache(): + """Filas crudas de cartera_junta cacheadas (se bajan de Supabase UNA sola vez). + Evita re-descargar las 86k filas en cada llamada/cambio de mes.""" + return cache_get_or_set("cartera_rows", ("GLOBAL",), + lambda: get_dm().traer_cartera_rows()) + + +def _cartera_idx(): + """Devuelve {'all':..., 'asig':...}, cada uno {(sede,programa):{telefono:fecha_min}}. + 'all' = todos los teléfonos; 'asig' = solo los que tienen asesor (no vacío). + Se baja la cartera UNA vez y queda cacheado.""" + def _load(): + from collections import defaultdict + CANAL_PAUTA = {"COPITO", "PAUTA_WSP", "PAUTA_WSP_FACE"} + CANAL_WEB = {"WEB_FORMULARIO", "WEB_WHATSAPP", "WHATSAPP WEB"} + + def _clase(canal): + if canal in CANAL_PAUTA: return "PAUTA" + if canal in CANAL_WEB: return "WEB" + return "OTROS" + + # PASO 1: DEDUP por (sede, prog, telefono) -> quedarse con la fila mas antigua. + # Se guarda la fecha, si tiene asesor, y el CANAL de esa fila ganadora. + # (Asi el canal de PAUTA/OTROS se decide DESPUES de deduplicar, como pide el usuario.) + ganador = {} # (sede, prog, tel) -> {"f":fecha, "asesor":bool, "canal":canal} + tels = set() + tels_canal = {} # telefono -> canal de su fila ganadora GLOBAL (para matriculas) + gan_global = {} # telefono -> fecha ganadora global (para decidir canal de matriculas) + origen = {} + for r in _cartera_rows_cache(): + tel = str(r.get("telefono") or "").strip() + if not tel: + continue + tels.add(tel) + sede = " ".join(str(r.get("sede") or "").upper().split()) + prog = " ".join(str(r.get("programa") or "").upper().split()) + canal = " ".join(str(r.get("canal") or "").upper().split()) + f = L._to_date(r.get("fecha_creada")) + if str(r.get("es_origen") or "").upper() == "SI" and f is not None: + origen[tel] = f + if f is None: + continue + con_asesor = bool(str(r.get("asesor") or "").strip()) + # ganador por sede+prog+tel (para la tabla) + k = (sede, prog, tel) + g = ganador.get(k) + if g is None or f < g["f"]: + ganador[k] = {"f": f, "asesor": con_asesor, "canal": canal} + # ganador global por tel (para clasificar matriculas por canal) + if tel not in gan_global or f < gan_global[tel]: + gan_global[tel] = f + tels_canal[tel] = canal + + # PASO 2: armar indices con la fila ganadora ya deduplicada + idx_all = defaultdict(dict); idx_asig = defaultdict(dict) + # por clase (PAUTA/WEB/OTROS): {clase: {"all":..,"asig":..}} + CLASES = ("PAUTA", "WEB", "OTROS") + idx_cl = {cl: {"all": defaultdict(dict), "asig": defaultdict(dict)} for cl in CLASES} + tels_cl = {cl: set() for cl in CLASES} + for (sede, prog, tel), g in ganador.items(): + f = g["f"]; cl = _clase(g["canal"]) + idx_all[(sede, prog)][tel] = f + if g["asesor"]: idx_asig[(sede, prog)][tel] = f + idx_cl[cl]["all"][(sede, prog)][tel] = f + if g["asesor"]: idx_cl[cl]["asig"][(sede, prog)][tel] = f + # telefonos por clase (segun ganador GLOBAL) para matriculas + for tel, canal in tels_canal.items(): + tels_cl[_clase(canal)].add(tel) + + out = {"all": idx_all, "asig": idx_asig, "tels": tels, "origen": origen} + for cl in CLASES: + out[f"all_{cl.lower()}"] = idx_cl[cl]["all"] + out[f"asig_{cl.lower()}"] = idx_cl[cl]["asig"] + out[f"tels_{cl.lower()}"] = tels_cl[cl] + return out + return cache_get_or_set("cartera_idx", ("GLOBAL",), _load) + + +# ── Respuesta del dashboard de Leads (con filtros) ────────────── +def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sede="TODOS"): + def _load(): + leads = _leads_crudos() + cursos = _cursos_crudos() + mats = _matriculas_crudas() + + # Filtro por PROGRAMA (4 grupos: TEAC / TERC / SEMINARIOS / OTROS). + # Recalcula toda la pantalla filtrando las listas base ANTES de calcular. + # - leads: grupo por 'cargo' del código de campaña + # - cursos y matrículas: grupo por dsc_programa + if programa not in ("TODOS", None): + g = str(programa).upper() + leads = [l for l in leads if L.grupo_programa_lead(l.get("cargo")) == g] + cursos = [c for c in cursos if L.grupo_programa_curso(c.get("dsc_programa")) == g] + mats = [m for m in mats if L.grupo_programa_curso(m.get("dsc_programa")) == g] + + # Filtro por SEDE (recalcula toda la pantalla). Se filtran las listas base + # ANTES de calcular, para no tocar las funciones de cálculo existentes. + # - leads: sede POR CÓDIGO de campaña (NO sede_act; coincide con conteo real) + # - cursos y matrículas: sede por clasificar_sede(dsc_programa) + if sede not in ("TODOS", None): + s = str(sede).upper() + sede_cod = L.sede_por_codigo_map() + leads = [l for l in leads + if sede_cod.get(str(l.get("codigo") or "").strip()) == s] + cursos = [c for c in cursos if L.clasificar_sede(c.get("dsc_programa")) == s] + mats = [m for m in mats if L.clasificar_sede(m.get("dsc_programa")) == s] + + kpis = L.kpis_leads(leads, cursos, mats, ano, mes, dia) + tabla = L.tabla_estado_objecion(leads, ano, mes, dia) + por_dia = L.matriculas_por_dia(mats, cursos, ano, mes, dia) + leads_dia = L.leads_por_dia(leads, ano, mes, dia) + pauta = L.tabla_pauta(leads, mats, ano, mes, dia, + _importe_por_pauta_periodo(ano, mes, dia), + _resultados_por_pauta_periodo(ano, mes, dia), + _campanias_sede_cargo()) + ci = _cartera_idx() + cartera_canal = { + "PAUTA": {"all": ci.get("all_pauta"), "asig": ci.get("asig_pauta"), "tels": ci.get("tels_pauta")}, + "WEB": {"all": ci.get("all_web"), "asig": ci.get("asig_web"), "tels": ci.get("tels_web")}, + "OTROS": {"all": ci.get("all_otros"), "asig": ci.get("asig_otros"), "tels": ci.get("tels_otros")}, } - return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta), _load) + matriz = L.matriz_cursos(cursos, leads, ano, mes, dia, _pauta_cruda(), + _campanias_map(), ci.get("all"), ci.get("asig"), + _mat_por_indice(), ci.get("tels"), ci.get("origen"), + _importe_por_pauta(), + _importe_por_pauta_periodo(ano, mes, dia), + cartera_canal, _conjunto_pauta(), _pautas_de_indice()) + return {"kpis": kpis, "estado_objecion": tabla, "matriculas_por_dia": por_dia, + "leads_por_dia": leads_dia, "tabla_pauta": pauta, "matriz_cursos": matriz} + + return cache_get_or_set("leads_dash", (ano, mes, dia, programa, sede), _load) -def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_filtro="TODOS", sede_filtro="TODOS", venc_hasta="TODOS"): +def _asesores(): + """{'map': {alias_norm: correcto}, 'lista': [los 8 vendedores]}.""" + return cache_get_or_set("asesores", ("GLOBAL",), lambda: get_dm().traer_asesores()) + + +def _norm_asesor(nombre, mapa): + """Normaliza un nombre de asesor a su 'correcto' via el diccionario.""" + k = " ".join(str(nombre or "").upper().split()) + return mapa.get(k) # None si no es uno de los vendedores + + +def _norm_tel_pe(v): + """Normaliza telefono igual que la cartera: quita simbolos y el '51' inicial + de celulares peruanos (rango 51900000000-51999999999). Asi cruzan con cartera_junta.""" + s = str(v or "") + for x in ("+51", "+", " ", "-", "(", ")"): + s = s.replace(x, "") + s = s.strip() + if s.isdigit() and 51900000000 <= int(s) <= 51999999999: + s = s[2:] + return s + + +def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): + """Tiempo de respuesta LABORAL (minutos) del asesor tras la asignacion. + - Turno detectado por dia/asesor segun donde hay MAS actividad: + tarde (13-18) -> Turno 1 (09-18, refrigerio 13-14 excluido). + noche (18-22) -> Turno 2 (09-13 + 18-22, sin refrigerio). + sabado: manana(09-13) o tarde(14-18), 4h, donde haya mas actividad. + sin actividad ese dia -> no trabajo (se salta). + - El tiempo cuenta SOLO minutos dentro del turno; si la asignacion cae fuera, + el reloj arranca al inicio del siguiente bloque laboral (saltando dias sin + actividad). Leads asignados en refrigerio (13-14) de turno1 se excluyen. + - Serie dia a dia por FECHA DE ASIGNACION: promedio de minutos de los leads + asignados ese dia (solo respondidos). Filtro ano/mes/dia por fecha asignacion. + - No respondidos -> se marcan aparte, NO entran en el promedio.""" + from datetime import datetime, timedelta, time as _time def _load(): - dm = get_dm() - logic = CobranzaLogic(dm) - proc = logic.processor - pf = str(prog_filtro).upper() - sf = str(sede_filtro).upper() - vh = venc_hasta - # Mapa MATRICULA -> programa, para filtrar por tipo de programa y/o sede (REPLACE_META) - mats_prog = None - if pf not in ("TODOS", "", "NONE") or sf not in ("TODOS", "", "NONE"): - crudos_pf = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") - mats_prog = set() - for d in (crudos_pf or []): - prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "") - ok_prog = pf in ("TODOS", "", "NONE") or str(proc.clasificar_filtro_programa(prog)).upper() == pf - ok_sede = sf in ("TODOS", "", "NONE") or str(proc.clasificar_sede_meta(prog)).upper() == sf - if ok_prog and ok_sede: - mats_prog.add(str(d.get("MATRICULA", ""))) - if agrupacion == "ASESOR": - datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS", vh) - elif agrupacion == "SEDE": - # Filtrar por sede usando el MISMO clasificador del processor (sede.json) - crudos = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") - grupo_up = str(grupo).upper() - mats_sede = set() - for d in (crudos or []): - prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "") - sede_det = str(proc.clasificar_sede(prog)).upper() - if sede_det == grupo_up: - mats_sede.add(str(d.get("MATRICULA", ""))) - todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS", vh) - # Solo alumnos de la sede (sin la fila TOTAL global; el frontend recalcula el total) - datos = [f for f in (todas or []) if str(f[0]) in mats_sede] - else: # PROGRAMA - datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo, vh) - # Filtro por Sede y/o tipo de programa (TEAC/TERC/...) si aplica - if mats_prog is not None: - datos = [f for f in (datos or []) if str(f[0]) in mats_prog or str(f[0]).upper() == "TOTAL"] - return {"filas": _serializar_filas(datos)} - return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta), _load) + from collections import defaultdict + ase = _asesores(); amap = ase["map"]; lista = ase["lista"] + + # ── 1) Detectar turno por (asesor_norm, fecha) segun actividad ── + # Mapa user_id -> nombre normalizado (de asignacion_respuesta). + uid2vend = {} + for r in _asignacion_respuesta_crudos(): + uid = r.get("user_id") + v = _norm_asesor(r.get("user_name"), amap) + if uid is not None and v: + uid2vend[uid] = v + + # Contar actividad por (vend, fecha, franja): tarde=13-18, noche=18-22, + # sab_manana=9-13, sab_tarde=14-18. + act = defaultdict(lambda: defaultdict(int)) # (vend,fecha) -> franja -> conteo + for a in _actividad_asesores_crudos(): + uid = a.get("user_id"); ts = a.get("created_at") + if uid not in uid2vend or not isinstance(ts, datetime): + continue + v = uid2vend[uid]; d = ts.date(); h = ts.hour + ts.minute / 60.0 + key = (v, d) + if d.weekday() == 6: # domingo: no trabaja + continue + if d.weekday() == 5: # sabado + if 9 <= h < 13: act[key]["sab_manana"] += 1 + elif 14 <= h < 18: act[key]["sab_tarde"] += 1 + else: # lun-vie + if 13 <= h < 18: act[key]["tarde"] += 1 + elif 18 <= h < 22: act[key]["noche"] += 1 + + def _turno_dia(v, d): + """Devuelve lista de bloques [(h_ini,h_fin), ...] del turno de ese dia, + o None si no trabajo. Refrigerio de turno1 (13-14) NO se incluye.""" + if d.weekday() == 6: + return None + fr = act.get((v, d)) + if not fr: + return None # sin actividad -> no trabajo + if d.weekday() == 5: # sabado (4h) + m = fr.get("sab_manana", 0); t = fr.get("sab_tarde", 0) + if m == 0 and t == 0: + return None + return [(9, 13)] if m >= t else [(14, 18)] + # lun-vie: comparar tarde vs noche + t = fr.get("tarde", 0); n = fr.get("noche", 0) + if t == 0 and n == 0: + return None + if n > t: + return [(9, 13), (18, 22)] # Turno 2 + return [(9, 13), (14, 18)] # Turno 1 (refrigerio 13-14 fuera) + + def _dt(d, hh): + return datetime(d.year, d.month, d.day) + timedelta(hours=hh) + + def _en_refrigerio_t1(v, ts): + """True si ts cae 13-15 y ese dia el asesor es turno 1 (lun-vie). + Estos leads NO se excluyen: cuentan como respondidos, pero su tiempo + (diferencia directa T2-T1) se reduce 45 min si supera 45.""" + d = ts.date() + if d.weekday() >= 5: + return False + fr = act.get((v, d)) + if not fr: + return False + t = fr.get("tarde", 0); n = fr.get("noche", 0) + es_t1 = not (n > t) and (t > 0 or n > 0) + h = ts.hour + ts.minute / 60.0 + return es_t1 and (13 <= h < 15) + + def _minutos_laborales(v, t1, t2): + """Minutos de turno entre t1 (asignacion) y t2 (respuesta), saltando + huecos y dias sin actividad. Devuelve None si no se puede (raro).""" + if not isinstance(t1, datetime) or not isinstance(t2, datetime) or t2 <= t1: + return 0 + total = 0.0 + cur = t1 + saltos = 0 + while cur < t2 and saltos < 60: # tope 60 dias por seguridad + d = cur.date() + bloques = _turno_dia(v, d) + if not bloques: + # dia sin trabajo -> saltar al dia siguiente 00:00 + cur = _dt(d, 24); saltos += 1; continue + avanzo = False + for (hi, hf) in bloques: + ini = _dt(d, hi); fin = _dt(d, hf) + if t2 <= ini: # la respuesta es antes de este bloque + break + if cur >= fin: # ya paso este bloque + continue + a = max(cur, ini) + b = min(t2, fin) + if b > a: + total += (b - a).total_seconds() / 60.0 + cur = fin # avanzar al fin del bloque + avanzo = True + if cur >= t2: + break + # pasar al dia siguiente + if cur.date() == d: + cur = _dt(d, 24); saltos += 1 + return round(total) + + # ── 2) Por lead: minutos laborales, agrupado por FECHA DE ASIGNACION ── + por_dia_sum = defaultdict(float) # (vend,dia) -> suma minutos + por_dia_cnt = defaultdict(int) # (vend,dia) -> nº respondidos + no_resp = defaultdict(int) # (vend,dia) -> nº sin responder + for r in _asignacion_respuesta_crudos(): + v = _norm_asesor(r.get("user_name"), amap) + if not v: + continue + t1 = r.get("created_at") + if not isinstance(t1, datetime): + continue + if not L._en_periodo(t1.date(), ano, mes, dia): + continue # filtro por fecha de asignacion + t2 = r.get("respuesta_fecha") + if not isinstance(t2, datetime): + no_resp[(v, t1.day)] += 1 # no respondio + continue + if _en_refrigerio_t1(v, t1): + # asignado 13-15 en turno1: tiempo = diferencia DIRECTA (T2-T1), + # y si supera 45 min se le restan 45 (nunca negativo). + dif = (t2 - t1).total_seconds() / 60.0 + mins = round(dif - 45) if dif > 45 else round(dif) + if mins < 0: + mins = 0 + else: + mins = _minutos_laborales(v, t1, t2) + por_dia_sum[(v, t1.day)] += mins + por_dia_cnt[(v, t1.day)] += 1 + + # ── 2b) Igual pero agrupado por FECHA DE RESPUESTA (dia en que respondio). + # Solo leads respondidos. Filtro por fecha de respuesta (t2). + # Si el tiempo laboral supera 960 min (2 dias laborales) -> NO entra en + # el promedio; se cuenta aparte como "respuesta tardia". + LIMITE_TARDIA = 960 # 2 dias laborales (8h/dia) + pr_sum = defaultdict(float) # (vend,dia_respuesta) -> suma minutos (validos) + pr_cnt = defaultdict(int) # (vend,dia_respuesta) -> nº validos (<=960) + pr_tardia = defaultdict(int) # (vend,dia_respuesta) -> nº tardios (>960) + for r in _asignacion_respuesta_crudos(): + v = _norm_asesor(r.get("user_name"), amap) + if not v: + continue + t1 = r.get("created_at"); t2 = r.get("respuesta_fecha") + if not isinstance(t1, datetime) or not isinstance(t2, datetime): + continue # sin respuesta -> no entra en este grafico + if not L._en_periodo(t2.date(), ano, mes, dia): + continue # filtro por FECHA DE RESPUESTA + if _en_refrigerio_t1(v, t1): + dif = (t2 - t1).total_seconds() / 60.0 + mins = round(dif - 45) if dif > 45 else round(dif) + if mins < 0: + mins = 0 + else: + mins = _minutos_laborales(v, t1, t2) + if mins > LIMITE_TARDIA: + pr_tardia[(v, t2.day)] += 1 # respuesta tardia -> fuera del promedio + continue + pr_sum[(v, t2.day)] += mins + pr_cnt[(v, t2.day)] += 1 + + # ── 3) Serie dia a dia (promedio min) por vendedor y total ── + def _serie(vends): + out = [] + for dnum in range(1, 32): + s = sum(por_dia_sum.get((v, dnum), 0) for v in vends) + c = sum(por_dia_cnt.get((v, dnum), 0) for v in vends) + nr = sum(no_resp.get((v, dnum), 0) for v in vends) + out.append({"dia": dnum, + "promedio_min": round(s / c) if c else 0, + "respondidos": c, "no_respondidos": nr}) + return out + + def _serie_resp(vends): + out = [] + for dnum in range(1, 32): + s = sum(pr_sum.get((v, dnum), 0) for v in vends) + c = sum(pr_cnt.get((v, dnum), 0) for v in vends) + td = sum(pr_tardia.get((v, dnum), 0) for v in vends) + out.append({"dia": dnum, + "promedio_min": round(s / c) if c else 0, + "respondidos": c, "tardios": td}) + return out + + serie_por_vend = {v: _serie([v]) for v in lista} + serie_total = _serie(lista) + serie_resp_por_vend = {v: _serie_resp([v]) for v in lista} + serie_resp_total = _serie_resp(lista) + return {"serie_por_vend": serie_por_vend, "serie_total": serie_total, + "serie_resp_por_vend": serie_resp_por_vend, + "serie_resp_total": serie_resp_total, + "vendedores": lista} + return cache_get_or_set("tiempo_resp", (ano, mes, dia), _load) -# ── RENTABILIDAD ─────────────────────────────────────────────────────────── -def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"): +def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): + """Por vendedor (los 8 de alias_normalizacion). Filtro Año/Mes/Día: + - Cartera (total/copito/otros): filtra por fecha_creada de cartera_junta. + - Matriculas: se cruzan por TELEFONO con los leads del mes (es_origen=SI, + fecha_creada en el periodo); la matricula cuenta en CUALQUIER fecha. + - Leads Procesados/Contactados: filtran por fecha del lead (mensaje campaña).""" def _load(): - dm = get_dm() - logic = RentabilidadLogic(dm) - datos = logic.obtener_datos_procesados(str(ano), str(mes), sede, filtro_prog) - filas = logic.formatear_datos_para_tabla(datos) - filas_s = _serializar_filas(filas, 13) - nombres = [str(f[0]) for f in filas_s if "TOTAL" not in str(f[0]).upper()] - return {"filas": filas_s, "programas": nombres} - return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load) + from collections import defaultdict + ase = _asesores() + amap = ase["map"]; lista = ase["lista"] + CANAL_COPITO = {"COPITO"} + + # 1) CARTERA por vendedor. Fila ORIGEN de cada telefono (es_origen=SI) cuya + # fecha_creada cae en el periodo filtrado. Cada telefono cuenta para UN solo asesor. + # Ademas guardamos {vendedor: set(telefonos del periodo)} para cruzar matriculas. + cart_total = defaultdict(int); cart_copito = defaultdict(int); cart_otros = defaultdict(int) + tels_por_vend = defaultdict(set) # vendedor -> telefonos (es_origen=SI del periodo) + cop_tels = defaultdict(set) # vendedor -> telefonos ya contados como Copito (sin repetir) + for r in _cartera_rows_cache(): + if str(r.get("es_origen") or "").strip().upper() != "SI": + continue # solo la fila origen de cada telefono + f = L._to_date(r.get("fecha_creada")) + if not L._en_periodo(f, ano, mes, dia): + continue # filtro por fecha_creada de la cartera + v = _norm_asesor(r.get("asesor"), amap) + if not v: # el asesor del origen no es uno de los 8 + continue + tel = str(r.get("telefono") or "").strip() + canal = " ".join(str(r.get("canal") or "").upper().split()) + ob = " ".join(str(r.get("origen_base") or "").upper().split()) + cart_total[v] += 1 + # Copito = union (origen_base=POSTGRE) OR (canal=COPITO), sin repetir telefono + es_copito = (ob == "POSTGRE") or (canal in CANAL_COPITO) + if es_copito: + if tel and tel in cop_tels[v]: + pass # ese telefono ya se conto como Copito -> no repetir + else: + cart_copito[v] += 1 + if tel: + cop_tels[v].add(tel) + else: + cart_otros[v] += 1 + if tel: + tels_por_vend[v].add(tel) + + # 2) MATRICULAS por vendedor. Se CRUZAN por TELEFONO con los leads del periodo + # (tels_por_vend). La matricula cuenta en CUALQUIER fecha (no se filtra por + # fch_matricula). Telefono de matricula: dsc_telefono_1, o _2 si el 1 esta vacio. + # Un telefono puede tener varias matriculas -> se cuentan todas (no unico). + # MATRICULAS: por dsc_vendedor + fch_matricula en el periodo (NO se cruza + # con la cartera por telefono). Filtro Año/Mes/Día sobre fch_matricula. + mat_total = defaultdict(int); mat_curso = defaultdict(int); mat_retir = defaultdict(int) + for m in _matriculas_crudas(): + vend = _norm_asesor(m.get("dsc_vendedor"), amap) + if not vend: + continue + estado = str(m.get("estado_matricula") or "").strip().upper() + if estado == "ANU": + continue # anuladas no cuentan + fm = L._to_date(m.get("fch_matricula")) + if not L._en_periodo(fm, ano, mes, dia): + continue # filtro por fch_matricula + mat_total[vend] += 1 + if estado in ("ALU", "PRE"): + mat_curso[vend] += 1 + elif estado == "RET": + mat_retir[vend] += 1 + + # 3) LEADS Procesados/Contactados por vendedor (leads de Chatwoot). Filtro por + # fecha del lead (mensaje de campaña). Procesados = lead unico con asesor; + # Contactados = de esos, ultima_etiqueta != "NO TRABAJADO". + lead_proc = defaultdict(int); lead_cont = defaultdict(int) + etiq = defaultdict(int) # clave: (vendedor, ultima_etiqueta) -> nº leads procesados + for l in _leads_crudos(): + if not l.get("es_unico"): + continue + if not L._en_periodo(l.get("fecha"), ano, mes, dia): + continue # filtro por fecha del lead + v = _norm_asesor(l.get("asesor"), amap) + if not v: + continue + lead_proc[v] += 1 # tiene asesor de los 8 => procesado + ult = l.get("ultima") or "NO TRABAJADO" + etiq[(v, ult)] += 1 # para la matriz Estado/Objeción + # NOTA: "Contactados" ya NO se cuenta aqui; ahora sale de los ASIGNADOS + # (bloque 3b), usando su ultima etiqueta. + + # 3b) LEADS ASIGNADOS por vendedor (mensajes "Asignado a..." de Chatwoot). + # Se cuenta por telefono (1 asignacion por telefono, la mas reciente). + # Filtro por fecha de asignacion (created_at). Asesor via user_name normalizado. + # CONTACTADOS = de los asignados, cuya ULTIMA ETIQUETA != "NO TRABAJADO". + lead_asig = defaultdict(int) + etiq_asig = defaultdict(int) # (vendedor, ultima_etiqueta) -> nº asignados (para matriz) + for a in _leads_asignados_crudos(): + f = L._to_date(a.get("created_at")) + if not L._en_periodo(f, ano, mes, dia): + continue # filtro por fecha de asignacion + v = _norm_asesor(a.get("user_name"), amap) + if not v: + continue # el asesor asignado no es uno de los 8 + lead_asig[v] += 1 + ult = L.ultima_etiqueta(a.get("etiquetas")) or "NO TRABAJADO" + etiq_asig[(v, ult)] += 1 # para la matriz Estado/Objeción (asignados) + if ult != "NO TRABAJADO": + lead_cont[v] += 1 # contactado = asignado con ultima etiqueta trabajada + + # SERIE DIARIA: Leads Asignados (por fecha de asignacion) y Matriculas (por fch_matricula), + # por vendedor y por dia del mes. clave: (vendedor, dia) -> conteo. + serie_proc = defaultdict(int) # (vendedor, dia) -> leads asignados + serie_mat = defaultdict(int) # (vendedor, dia) -> matriculas + for a in _leads_asignados_crudos(): + f = L._to_date(a.get("created_at")) + if not L._en_periodo(f, ano, mes, dia): + continue + v = _norm_asesor(a.get("user_name"), amap) + if v and f: + serie_proc[(v, f.day)] += 1 + for m in _matriculas_crudas(): + vend = _norm_asesor(m.get("dsc_vendedor"), amap) + if not vend: + continue + estado = str(m.get("estado_matricula") or "").strip().upper() + if estado == "ANU": + continue + fm = L._to_date(m.get("fch_matricula")) + if fm and L._en_periodo(fm, ano, mes, dia): + serie_mat[(vend, fm.day)] += 1 + + def _serie(vendedores): + """Lista 1..31 con asignados y matriculas del conjunto de vendedores.""" + out = [] + for d in range(1, 32): + p = sum(serie_proc.get((v, d), 0) for v in vendedores) + mm = sum(serie_mat.get((v, d), 0) for v in vendedores) + out.append({"dia": d, "asignados": p, "matriculas": mm}) + return out + + serie_por_vend = {v: _serie([v]) for v in lista} + serie_total = _serie(lista) + + # Matriz Estado/Objeción (Ultima_Etiqueta) por vendedor y total. + # Ahora se basa en los LEADS ASIGNADOS (etiq_asig), no en los de campaña. + etiquetas_all = sorted({e for (_, e) in etiq_asig.keys()}, key=lambda x: x.lower()) + + def _matriz_etiq(vendedores): + out = [] + for e in etiquetas_all: + c = sum(etiq_asig.get((v, e), 0) for v in vendedores) + if c > 0: + out.append({"estado": e, "cantidad": c}) + out.sort(key=lambda x: -x["cantidad"]) # mayor a menor por cantidad + return out + + matriz_etiq_por_vend = {v: _matriz_etiq([v]) for v in lista} + matriz_etiq_total = _matriz_etiq(lista) + + # 4) MATRIZ por TIPO DE PROGRAMA (TEAC/TERC/SEMINARIOS/OTROS). + # Filtro por fch_matricula (periodo) y dsc_vendedor. Total y Retiradas (RET). + # Se guarda por (vendedor, grupo) para poder filtrar por vendedor en el front. + GRUPOS_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS"] + prog_tot = defaultdict(int); prog_ret = defaultdict(int) # clave: (vendedor, grupo) + for m in _matriculas_crudas(): + vend = _norm_asesor(m.get("dsc_vendedor"), amap) + if not vend: + continue + estado = str(m.get("estado_matricula") or "").strip().upper() + if estado == "ANU": + continue + fm = L._to_date(m.get("fch_matricula")) + if not L._en_periodo(fm, ano, mes, dia): + continue # filtro por fch_matricula + grupo = L.grupo_programa_curso(m.get("dsc_programa")) + prog_tot[(vend, grupo)] += 1 + if estado == "RET": + prog_ret[(vend, grupo)] += 1 + + def _matriz_prog(vendedores): + """Suma total/retiradas por grupo para el conjunto de vendedores dado.""" + out = [] + for g in GRUPOS_PROG: + t = sum(prog_tot.get((v, g), 0) for v in vendedores) + r = sum(prog_ret.get((v, g), 0) for v in vendedores) + out.append({"programa": g, "total": t, "retiradas": r, + "pct_retiradas": round(r / t * 100, 1) if t else 0}) + return out + + # matriz por programa para CADA vendedor y para el TOTAL (todos) + matriz_prog_por_vend = {v: _matriz_prog([v]) for v in lista} + matriz_prog_total = _matriz_prog(lista) + + filas = [] + for v in lista: + proc = lead_proc.get(v, 0); cont = lead_cont.get(v, 0) + asig = lead_asig.get(v, 0) + filas.append({ + "vendedor": v, + "cartera_total": cart_total.get(v, 0), + "cartera_copito": cart_copito.get(v, 0), + "cartera_otros": cart_otros.get(v, 0), + "mat_total": mat_total.get(v, 0), + "mat_curso": mat_curso.get(v, 0), + "mat_retiradas": mat_retir.get(v, 0), + "pct_retiradas": round(mat_retir.get(v, 0) / mat_total.get(v, 0) * 100, 1) + if mat_total.get(v, 0) else 0, + "leads_procesados": proc, + "leads_contactados": cont, + # % Contactados = contactados / ASIGNADOS (ambos de la misma fuente) + "pct_contactados": round(cont / asig * 100, 1) if asig else 0, + "leads_asignados": asig, + }) + tot = {"cartera_total": sum(f["cartera_total"] for f in filas), + "cartera_copito": sum(f["cartera_copito"] for f in filas), + "cartera_otros": sum(f["cartera_otros"] for f in filas), + "mat_total": sum(f["mat_total"] for f in filas), + "mat_curso": sum(f["mat_curso"] for f in filas), + "mat_retiradas": sum(f["mat_retiradas"] for f in filas), + "leads_procesados": sum(f["leads_procesados"] for f in filas), + "leads_contactados": sum(f["leads_contactados"] for f in filas), + "leads_asignados": sum(f["leads_asignados"] for f in filas)} + tot["pct_retiradas"] = (round(tot["mat_retiradas"] / tot["mat_total"] * 100, 1) + if tot["mat_total"] else 0) + tot["pct_contactados"] = (round(tot["leads_contactados"] / tot["leads_asignados"] * 100, 1) + if tot["leads_asignados"] else 0) + # Tiempo de respuesta laboral (min) dia a dia por asesor (por fecha asignacion) + tr = tiempo_respuesta_dashboard(ano, mes, dia) + return {"filas": filas, "total": tot, "vendedores": lista, + "matriz_prog": matriz_prog_por_vend, "matriz_prog_total": matriz_prog_total, + "matriz_etiq": matriz_etiq_por_vend, "matriz_etiq_total": matriz_etiq_total, + "serie_dia": serie_por_vend, "serie_dia_total": serie_total, + "tiempo_resp": tr.get("serie_por_vend", {}), + "tiempo_resp_total": tr.get("serie_total", []), + "tiempo_resp_r": tr.get("serie_resp_por_vend", {}), + "tiempo_resp_r_total": tr.get("serie_resp_total", [])} + return cache_get_or_set("vendedores_dash", (ano, mes, dia), _load) -def rentabilidad_detalle(programa, ano, mes): +def _conjunto_sede_prog(): + """{conjunto_UPPER: {sede, programa}} asignaciones manuales (tabla Supabase).""" + return cache_get_or_set("conjunto_sede_prog", ("GLOBAL",), + lambda: get_dm().traer_conjunto_sede_programa()) + + +def conjuntos_sin_sede(): + """Lista los conjuntos del Meta CSV que NO estan ligados a ninguna sede + (ni por pauta->campania, ni por asignacion manual). Para el apartado de + asignacion. Sin filtro de mes. Incluye la sede/programa YA asignados (si tiene). + Devuelve [{conjunto, importe, resultados, sede, programa}].""" def _load(): - dm = get_dm() - logic = RentabilidadLogic(dm) - resultado = logic.obtener_detalle_programa(programa, str(ano), str(mes)) - return {"filas": _serializar_filas(resultado)} - return cache_get_or_set("rentabilidad_det", (programa, ano, mes), _load) + SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"} + conj_pauta = _conjunto_pauta() + camp = _campanias_sede_cargo() + manual = _conjunto_sede_prog() + # conjuntos que SÍ tienen sede via pauta -> campania + con_sede = set() + for pauta, conjuntos in conj_pauta.items(): + sp = camp.get(str(pauta).strip()) + sede = " ".join(str((sp or {}).get("sede") or "").upper().split()) + if sp and sede in SEDES: + for co in conjuntos: + con_sede.add(" ".join(str(co).split()).upper()) + agg = {} + for f in _meta_filas(): + co = " ".join(str(f.get("conjunto")).split()) + if not co: + continue + k = co.upper() + if k in con_sede: + continue # ya tiene sede por campania -> no aparece aqui + d = agg.setdefault(k, {"conjunto": co, "importe": 0.0, "resultados": 0, + "sede": manual.get(k, {}).get("sede", ""), + "programa": manual.get(k, {}).get("programa", "")}) + d["importe"] += float(f.get("importe") or 0.0) + d["resultados"] += int(float(f.get("resultados") or 0.0)) + out = sorted(agg.values(), key=lambda x: -x["importe"]) + for d in out: + d["importe"] = round(d["importe"], 2) + return out + return cache_get_or_set("conjuntos_sin_sede", ("GLOBAL",), _load) -def rentabilidad_costos(programa, ano, mes): +def guardar_conjunto_sede(cambios): + """Guarda asignaciones conjunto->sede/programa e invalida cachés dependientes.""" + r = get_dm().guardar_conjunto_sede_programa(cambios) + for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"): + cache_invalidate(prefix=pref) + return r + + +def borrar_conjunto_sede(conjunto): + """Borra la asignacion de un conjunto e invalida cachés dependientes.""" + r = get_dm().borrar_conjunto_sede_programa(conjunto) + for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"): + cache_invalidate(prefix=pref) + return r + + +def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): + """Matriz ROAS por SEDE (LIMA, PIURA, AREQUIPA, TRUJILLO): Importe Gastado y + Resultados del Google Sheet (Meta Ads), filtrado por Año/Mes (Inicio del informe). + Cadena: conjunto -> pauta (conjunto_pauta) -> sede (campanias). Conjuntos sin + pauta/sede se omiten por ahora.""" def _load(): - dm = get_dm() - logic = RentabilidadLogic(dm) - return {"costos": logic.obtener_datos_costos_programa(programa, str(ano), str(mes))} - return cache_get_or_set("rentabilidad_costos", (programa, ano, mes), _load) + SEDES = ["LIMA", "PIURA", "AREQUIPA", "TRUJILLO", "OTROS"] # OTROS = 5ta fila (manual) + CAMP_SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"} # sedes validas por campania + conj_pauta = _conjunto_pauta() # {pauta: [conjunto,...]} + camp = _campanias_sede_cargo() # {pauta: {sede, cargo}} + + PROGS_OK = {"TEAC", "TERC", "SEMINARIOS", "OTROS"} + + # conjunto (normalizado) -> (sede, programa) via su pauta -> campania + conj_sede = {} # conjunto -> sede + conj_prog = {} # conjunto -> programa (grupo) + for pauta, conjuntos in conj_pauta.items(): + sp = camp.get(str(pauta).strip()) + if not sp: + continue + sede = " ".join(str(sp.get("sede") or "").upper().split()) + if sede not in CAMP_SEDES: + continue + prog = L.grupo_programa_lead(sp.get("cargo")) # TEAC/TERC/SEMINARIOS + for co in conjuntos: + k = " ".join(str(co).split()).upper() + conj_sede[k] = sede + conj_prog[k] = prog + + # + asignaciones MANUALES (sede + programa). Prioridad sobre la campania. + for k, v in _conjunto_sede_prog().items(): + sede = v.get("sede") + if sede in SEDES: + conj_sede[k] = sede + prog = str(v.get("programa") or "").strip().upper() + if prog: # acepta CUALQUIER programa asignado manualmente (no solo los fijos) + conj_prog[k] = prog + + # sumar importe y resultados por sede y por (sede, programa), filtrando por Año/Mes + imp = {s: 0.0 for s in SEDES} + res = {s: 0 for s in SEDES} + imp_pg = {} # (sede, prog) -> importe + res_pg = {} # (sede, prog) -> resultados + # conjuntos por (sede, prog): nombre_conjunto -> {importe, resultados} + conj_det = {} # (sede, prog) -> { conjunto_original: {importe, resultados} } + sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None)) + for f in _meta_filas(): + if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia): + continue + co = " ".join(str(f.get("conjunto")).split()) + k = co.upper() + sede = conj_sede.get(k) + if not sede: + continue # conjunto sin pauta/sede -> se omite + i = float(f.get("importe") or 0.0) + rr = int(float(f.get("resultados") or 0.0)) + imp[sede] += i + res[sede] += rr + prog = conj_prog.get(k, "SIN PROGRAMA") + imp_pg[(sede, prog)] = imp_pg.get((sede, prog), 0.0) + i + res_pg[(sede, prog)] = res_pg.get((sede, prog), 0) + rr + dd = conj_det.setdefault((sede, prog), {}) + d = dd.setdefault(co, {"conjunto": co, "importe": 0.0, "resultados": 0}) + d["importe"] += i + d["resultados"] += rr + + ORDEN_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS", "SIN PROGRAMA"] + + def _conjuntos(sede, prog): + """Lista de conjuntos de anuncio de un (sede, programa), ordenada por importe.""" + det = list(conj_det.get((sede, prog), {}).values()) + det.sort(key=lambda x: -x["importe"]) + for d in det: + d["importe"] = round(d["importe"], 2) + return det + + def _sub(sede): + """Desglose por programa de una sede (solo los que tienen algo). + Primero los fijos en su orden, luego CUALQUIER otro programa asignado + (ej. BRANDING), y al final SIN PROGRAMA.""" + progs_de_sede = [p for (s, p) in imp_pg.keys() if s == sede] + extras = sorted(set(progs_de_sede) - set(ORDEN_PROG)) + orden = [p for p in ORDEN_PROG if p != "SIN PROGRAMA"] + extras + ["SIN PROGRAMA"] + out = [] + for p in orden: + i = imp_pg.get((sede, p), 0.0); r = res_pg.get((sede, p), 0) + if i or r: + out.append({"programa": p, "importe": round(i, 2), "resultados": r, + "conjuntos": _conjuntos(sede, p)}) + return out + + # Las 4 sedes reales siempre se muestran. OTROS solo si tiene algo (>0). + filas = [] + for s in SEDES: + if s == "OTROS" and imp[s] == 0 and res[s] == 0: + continue + filas.append({"sede": s, "importe": round(imp[s], 2), "resultados": res[s], + "programas": _sub(s)}) + tot = {"importe": round(sum(imp.values()), 2), "resultados": sum(res.values())} + return {"filas": filas, "total": tot} + return cache_get_or_set("roas_dash", (ano, mes, dia), _load) -# ── SALDO PENDIENTE ──────────────────────────────────────────────────────── -def saldo_pendiente(tipo_cuota): +def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"): + """Solo las 4 matrices de la pagina 'Otros General' (sin KPIs/graficos/tabla_pauta). + Mas liviano que leads_dashboard -> filtrar por mes es mas rapido.""" def _load(): - dm = get_dm() - logic = SaldoLogic(dm) - datos = logic.obtener_saldos_consolidados(tipo_cuota) - return {"datos": _serializar_dicts(datos)} - return cache_get_or_set("saldo", (tipo_cuota,), _load) + return { + "matriz_always": matriz_always(ano, mes, dia, "TODOS", "TODOS"), + "matriz_webform": matriz_web_formulario(ano, mes, dia, "TODOS", "TODOS"), + "matriz_asignados": matriz_asignados(ano, mes, dia), + "matriz_plantillas": matriz_plantillas(ano, mes, dia), + } + return cache_get_or_set("otros_general", (ano, mes, dia), _load) -# ── PRECARGA GLOBAL (calienta todo el caché) ─────────────────────────────── -def precargar_todo(): - from datetime import datetime - ahora = datetime.now() - ano, mes = ahora.year, ahora.month - tareas = [ - lambda: ocupabilidad(ano, mes), - lambda: ventas(ano, mes), - lambda: cobranza(ano, mes, "TODOS", "SEDE"), - lambda: cobranza(ano, mes, "TODOS", "PROGRAMA"), - lambda: cobranza(ano, mes, "TODOS", "ASESOR"), - lambda: rentabilidad(ano, mes), - lambda: saldo_pendiente("1° Cuota"), - ] - for t in tareas: - try: - t() - except Exception as e: - print(f"[precarga] error: {e}") - _marcar_actualizacion() - # Precargar mes actual + anterior de TODOS los módulos (sin entrar a ellos) - precargar_todos_modulos() +def conjuntos_sin_pauta(): + """Lista de conjuntos de anuncios sin pauta (para el desplegable del pop-up).""" + return get_dm().traer_conjuntos_sin_pauta() -_refresh_count = 0 +def programas_ocultos(): + """Programas con contar=NO en basebi_programacion (ocultos de la matriz). + Cada item: {num_indice, dsc_det_programa, fch_inicio, pauta}.""" + pm = _pauta_cruda() + ocultos_ni = {ni for ni, info in pm.items() if str(info.get("contar") or "").upper() == "NO"} + prog_de_ni = {} + fch_de_ni = {} + for c in _cursos_crudos(): + ni = _norm_ni(c.get("num_indice")) + prog_de_ni[ni] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() + f = L._to_date(c.get("fch_inicio")) + fch_de_ni[ni] = f.strftime("%d/%m/%Y") if f else "" + filas = [] + for ni in ocultos_ni: + filas.append({"num_indice": ni, + "dsc_det_programa": prog_de_ni.get(ni, "(sin nombre)"), + "fch_inicio": fch_de_ni.get(ni, ""), + "pauta": (pm.get(ni) or {}).get("pauta") or ""}) + filas.sort(key=lambda x: x["dsc_det_programa"]) + return {"filas": filas} -def _recalcular_clave(key): - """Dada una clave 'prefijo:arg1|arg2|...', recalcula su valor sin usar el caché viejo.""" + +def encender_programas(num_indices): + """Pone contar=SI a los num_indice dados en basebi_programacion (los reactiva).""" from cache_manager import cache_invalidate + dm = get_dm() + for ni in num_indices: + dm.guardar_pauta_basebi(ni, "", "SI") # pauta vacia -> no toca pauta, solo contar + for pref in ("pauta_raw", "leads_dash", "otros_general", "leads_filtros"): + cache_invalidate(pref) + return {"encendidos": len(num_indices)} + + +def programas_disponibles(): + """Lista de dsc_det_programa de cursos con fch_inicio >= 2026-01-01 (para el + desplegable de Programa en la Leyenda). Cada item: {num_indice, dsc_det_programa}.""" + from datetime import date + corte = date(2026, 1, 1) + vistos = {} + for c in _cursos_crudos(): + f = L._to_date(c.get("fch_inicio")) + if not f or f < corte: + continue + ni = _norm_ni(c.get("num_indice")) + nombre = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() + if not nombre: + continue + finicio = f.strftime("%d/%m/%Y") + label = f"{nombre} · {finicio}" + vistos[ni] = {"num_indice": ni, "dsc_det_programa": nombre, + "fch_inicio": finicio, "label": label} + filas = sorted(vistos.values(), key=lambda x: x["label"]) + return {"programas": filas} + + +# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ── +def campanias_listar(): + return get_dm().listar_campanias() + + +def campania_agregar(datos): + res = get_dm().agregar_campania(datos) + _invalidar_por_campanias() # la leyenda nueva se refleja de inmediato en todo el dashboard + return res + + +def campania_editar(cid, datos): + res = get_dm().editar_campania(cid, datos) + _invalidar_por_campanias() # el cambio se refleja de inmediato en todo el dashboard + return res + + +def campania_borrar(cid): + res = get_dm().borrar_campania(cid) + _invalidar_por_campanias() + return res + + +def leyenda_anuncios(): + """Tabla: todos los conjuntos del Sheet con su pauta (si tiene) y el programa + (dsc_det_programa) del num_indice que usa esa pauta. Cacheado (usa insumos ya + en memoria) para que abrir la Leyenda sea rapido.""" + def _load(): + conj_pauta = _conjunto_pauta() # cacheado + pauta_de_conj = {} + for pa, cjs in conj_pauta.items(): + for c in cjs: + pauta_de_conj[" ".join(str(c).split())] = str(pa).strip() + # pauta -> [num_indices] usando TODAS las vinculaciones (basebi + programa_pautas) + ni_de_pauta = {} + for ni, pautas in _pautas_de_indice().items(): + for p in pautas: + ni_de_pauta.setdefault(str(p).strip(), []).append(str(ni).strip()) + prog_de_ni = {} + for c in _cursos_crudos(): # cacheado + prog_de_ni[_norm_ni(c.get("num_indice"))] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() + def _programa(pauta): + if not pauta: + return "" + nis = ni_de_pauta.get(str(pauta).strip(), []) + nombres = [prog_de_ni.get(n, "") for n in nis if prog_de_ni.get(n)] + return " | ".join(sorted(set(nombres))) + filas = [] + vistos = set() + for f in _meta_filas(): # cacheado (CSV de Meta) + co = " ".join(str(f.get("conjunto")).split()) + if not co or co in vistos: + continue + vistos.add(co) + pa = pauta_de_conj.get(co, "") + filas.append({"conjunto": co, "pauta": pa, "programa": _programa(pa)}) + filas.sort(key=lambda x: x["conjunto"]) + return {"filas": filas} + return cache_get_or_set("leyenda_anuncios", ("GLOBAL",), _load) + + +def guardar_leyenda(cambios): + """cambios = [{conjunto, pauta, num_indice?}]. + - conjunto -> pauta: upsert en conjunto_pauta. + - si viene num_indice: conecta ese num_indice a la pauta en basebi_programacion. + Invalida solo cachés dependientes de pauta/conjunto (no la cartera).""" + from cache_manager import cache_invalidate + dm = get_dm() + for c in cambios: + conjunto = c.get("conjunto") + pauta = (c.get("pauta") or "").strip() + ni = c.get("num_indice") + if conjunto: + dm.upsert_conjunto_pauta(conjunto, pauta) + # si eligió un programa (num_indice), AGREGAR el vínculo num_indice<->pauta + # en programa_pautas (muchos-a-muchos, se SUMA, no reemplaza). + if ni and pauta: + dm.agregar_programa_pauta(str(ni).strip(), pauta) + for pref in ("conjunto_pauta", "pauta_raw", "programa_pautas", "pautas_de_indice", + "meta_filas", "importe_pauta", "importe_pauta_periodo", + "resultados_pauta_periodo", "leads_dash", "otros_general", + "matriz_always", "matriz_plantillas", "leyenda_anuncios"): + cache_invalidate(pref) + return {"guardados": len(cambios)} + + +def uso_de_pauta(pauta, excluir_num_indice=None): + """Verifica si una pauta ya esta en uso. Devuelve los cursos (dsc_det_programa) + y conjuntos que la usan (para la advertencia antes de guardar).""" + dm = get_dm() + nis = dm.num_indices_de_pauta(pauta) + excl = str(excluir_num_indice or "").strip() + nis = [n for n in nis if n != excl] + # dsc_det_programa de esos num_indice (desde cursos de SQL, ya cacheados) + cursos = _cursos_crudos() + detalle = [] + for c in cursos: + ni = _norm_ni(c.get("num_indice")) + if ni in nis: + detalle.append({"num_indice": ni, + "dsc_det_programa": str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()}) + conjuntos = _conjunto_pauta().get(str(pauta).strip(), []) + return {"en_uso": bool(nis) or bool(conjuntos), + "cursos": detalle, "num_indices": nis, "conjuntos": conjuntos} + + +def guardar_edicion_curso(num_indice, pauta, conjunto=None, contar=None): + """Guarda pauta y contar (SI/NO) en basebi_programacion (upsert) y, si se eligió, + vincula el conjunto a esa pauta en conjunto_pauta. Invalida SOLO los cachés que + dependen de pauta/conjunto (NO la cartera, para que la recarga sea rápida).""" + from cache_manager import cache_invalidate + dm = get_dm() + res = dm.guardar_pauta_basebi(num_indice, pauta, contar) + if conjunto: + dm.guardar_conjunto_pauta(conjunto, pauta) + # invalidar solo lo afectado por pauta/conjunto (la cartera NO se re-baja) + for pref in ("pauta_raw", "pautas_de_indice", "conjunto_pauta", "meta_filas", + "importe_pauta", "importe_pauta_periodo", "resultados_pauta_periodo", + "leads_dash", "otros_general", "matriz_always", "matriz_plantillas", + "leads_filtros", "roas_dash"): + cache_invalidate(pref) + return res + + +def _tipo_de_curso(c): + """Categoría de tipo de programa para el filtro (PROGRAMAS/PROVINCIA/SEMINARIOS/OTROS).""" + dscp = c.get("dsc_programa", "") + otros = L.clasificar_tipo_programa(dscp) + if otros == "OTROS": + return "OTROS" + up = str(dscp).upper() + cat = "TEAC" if ("AIRE ACONDICIONADO" in up or "TEAC" in up) else ( + "TERC" if ("REFRIGERACION COMERCIAL" in up or "REFRIGERACIÓN COMERCIAL" in up or "TERC" in up) else "SEM") + sede = L.clasificar_sede(dscp) + return L.tipo_programa_cohorte(sede, cat) + + +# ── Listas para los filtros (años, tipos de programa) ─────────── +def opciones_filtros(): + def _load(): + cursos = _cursos_crudos() + anos = sorted({L._to_date(c.get("fch_inicio")).year for c in cursos + if L._to_date(c.get("fch_inicio"))}, reverse=True) + # Grupos de programa fijos para el filtro PROGRAMA + tipos = ["TEAC", "TERC", "SEMINARIOS", "OTROS"] + return {"anos": anos or [2026], "tipos": tipos} + return cache_get_or_set("leads_filtros", ("GLOBAL",), _load) + + +# ── Alertas: valores de programa/sede no identificados en cartera_junta ── +def alertas(): + return cache_get_or_set("leads_alertas", ("GLOBAL",), + lambda: get_dm().traer_no_identificados()) + + +# ── Diagnóstico de cursos (para cuadrar ocupabilidad) ─────────── +def debug_cursos(ano, mes): + cursos = _cursos_crudos() + periodo = [c for c in cursos if L._en_periodo(L._to_date(c.get("fch_inicio")), ano, mes, "TODOS")] + detalle = [] + tot_insc = 0 + tot_meta = 0 + for c in periodo: + insc = int(c.get("Inscritos_Totales", 0) or 0) + meta = L.meta_curso(c.get("dsc_programa")) + tp = L.tipo_programa_curso(c.get("dsc_programa")) + tot_insc += insc + tot_meta += meta + detalle.append({ + "num_indice": c.get("num_indice"), + "dsc_programa": c.get("dsc_programa"), + "fch_inicio": str(c.get("fch_inicio"))[:10], + "cod_estado": c.get("cod_estado"), + "inscritos": insc, + "tipo": tp, + "meta": meta, + }) + return { + "num_cursos": len(periodo), + "suma_inscritos": tot_insc, + "suma_meta": tot_meta, + "ocupabilidad": round((tot_insc / tot_meta * 100), 2) if tot_meta else 0, + "cursos": detalle, + } + + +# ── Precarga / refresco ───────────────────────────────────────── +def _periodos_actual_anterior(): + """[(ano, mes), ...] = mes actual y mes anterior (segun fecha de hoy).""" + from datetime import date + hoy = date.today() + ano, mes = hoy.year, hoy.month + if mes == 1: + prev = (ano - 1, 12) + else: + prev = (ano, mes - 1) + return [(str(ano), str(mes)), (str(prev[0]), str(prev[1]))] + + +import os as _os +_LOG_CARGA = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "carga_tiempos.log") + + +def _log_carga(linea, reset=False): + """Escribe una linea en carga_tiempos.log (lo lee la ventana de tiempos).""" try: - prefijo, _, resto = key.partition(":") - args = resto.split("|") if resto else [] - # Invalidar SOLO esta entrada para forzar su recálculo - cache_invalidate(prefijo + ":" + resto if resto else prefijo) - if prefijo == "ocupabilidad" and len(args) == 4: - return ocupabilidad(args[0], args[1], args[2], args[3]) - if prefijo == "ventas" and len(args) == 2: - return ventas(args[0], args[1]) - if prefijo == "ventas" and len(args) == 5: - return ventas(args[0], args[1], args[2], args[3], args[4] == "True") - if prefijo == "cobranza" and len(args) == 4: - return cobranza(args[0], args[1], args[2], args[3]) - if prefijo == "rentabilidad" and len(args) == 4: - return rentabilidad(args[0], args[1], args[2], args[3]) - if prefijo == "saldo" and len(args) == 1: - return saldo_pendiente(args[0]) - if prefijo == "cobranza_det_todos" and len(args) == 3: - return cobranza_detalle_todos(args[0], args[1], args[2]) + with open(_LOG_CARGA, "w" if reset else "a", encoding="utf-8") as f: + f.write(linea + "\n") + except Exception: + pass + + +import threading as _thr +_TIEMPOS = {} # etiqueta -> segundos (para el resumen final) +_TIEMPOS_LOCK = _thr.Lock() +_HILOS_LISTOS = set() # {"leads","otros"} — cuando estan los 2, se imprime resumen + + +def _cron(etiqueta, base, fn, apartado=None): + """Ejecuta fn() midiendo el tiempo. Imprime y ademas guarda en el log: + paso, base de datos y segundos. Se ve en la ventana del BACKEND y en la + ventana de TIEMPOS al iniciar, para saber que carga cada cosa. + Si 'apartado' se indica, acumula el tiempo para el resumen final.""" + import time as _t + t0 = _t.perf_counter() + r = fn() + dt = _t.perf_counter() - t0 + n = "" + try: + if isinstance(r, (list, dict)): + n = f"({len(r)} filas)" + except Exception: + pass + linea = f" [carga] {etiqueta:32} {base:14} {dt:6.2f}s {n}" + print(linea, flush=True) + _log_carga(linea) + with _TIEMPOS_LOCK: + _TIEMPOS[apartado or etiqueta] = _TIEMPOS.get(apartado or etiqueta, 0.0) + dt + return r + + +def _hilo_termino(nombre): + """Marca un hilo como listo. Cuando ambos ("leads" y "otros") terminan, + imprime el resumen final por apartado en el log.""" + with _TIEMPOS_LOCK: + _HILOS_LISTOS.add(nombre) + listos = {"leads", "otros"}.issubset(_HILOS_LISTOS) + tiempos = dict(_TIEMPOS) + if not listos: + return + _log_carga("") + _log_carga("=" * 60) + _log_carga(" RESUMEN — tiempo listo para abrir cada apartado") + _log_carga("=" * 60) + orden = ["Leads", "Vendedores", "Otros General", "ROAS"] + for ap in orden: + seg = tiempos.get(ap) + if seg is not None: + estado = "instantaneo al abrir" if seg < 1 else f"listo tras {seg:.1f}s" + _log_carga(f" {ap:16} {seg:6.2f}s -> {estado}") + _log_carga("-" * 60) + _log_carga(" Ya precargados: al hacer clic se muestran AL INSTANTE (cache).") + _log_carga("=" * 60) + + +def precargar_todo(): + """Precarga en segundo plano: insumos crudos + el dashboard del mes actual y + anterior (con filtros en TODOS), para que el usuario no espere al abrir/filtrar.""" + try: + import time as _t + _log_carga("=" * 60, reset=True) + _log_carga(" CARGA DEL DASHBOARD LEADS — " + + _t.strftime("%d/%m/%Y %H:%M:%S")) + _log_carga("=" * 60) + print(">>> Precargando LEADS (hilo 1)...", flush=True) + _log_carga(">>> Hilo 1: LEADS (PostgreSQL + SQL Server)") + _cron("Leads (mensajes)", "PostgreSQL", _leads_crudos) + _cron("Cursos", "SQL Server", _cursos_crudos) + _cron("Matriculas", "SQL Server", _matriculas_crudas) + _cron("Leads asignados/respuesta", "PostgreSQL", _leads_asignados_crudos) + # Dashboard (Leads) del mes actual y anterior (lo mas usado). Otros General + # NO se precarga aqui (sus consultas son pesadas); se calcula al entrar. + p = _periodos_actual_anterior() + a0, m0 = p[0] + for (a, m) in p: + _cron(f"Apartado Leads {a}-{m}", "cache", + lambda a=a, m=m: leads_dashboard(a, m, "TODOS", "TODOS", "TODOS"), apartado="Leads") + # Vendedores y ROAS del periodo actual: se precargan para abrir al instante. + _cron("Apartado Vendedores", "cache", + lambda: vendedores_dashboard(a0, m0, "TODOS"), apartado="Vendedores") + _cron("Apartado ROAS", "cache", + lambda: roas_dashboard(a0, m0, "TODOS"), apartado="ROAS") + _marcar_actualizacion() + print(">>> Hilo 1 (Leads) LISTO.", flush=True) + _log_carga(">>> Hilo 1 (Leads) LISTO.") + _hilo_termino("leads") except Exception as e: - print(f"[recalcular] {key}: {e}") - return None # los detalles puntuales se recalculan al pedirse + print(f"[precarga] {e}", flush=True) + _log_carga(f"[precarga] ERROR: {e}") + _hilo_termino("leads") + + +def precargar_otros_general(): + """Precarga (aparte, mas pesada) las 4 matrices de Otros General del mes actual + y anterior. Se llama en su propio hilo para no bloquear.""" + try: + print(">>> Precargando CARTERA + OTROS GENERAL (hilo 2)...", flush=True) + _log_carga(">>> Hilo 2: CARTERA (Supabase) + OTROS GENERAL") + _cron("Plantillas", "Google Sheet", _plantillas_crudas) + _cron("Cartera", "Supabase", _cartera_rows_cache) # la mas pesada + for (a, m) in _periodos_actual_anterior(): + _cron(f"Otros General {a}-{m}", "cache", + lambda a=a, m=m: otros_general(a, m, "TODOS"), apartado="Otros General") + print(">>> Hilo 2 (Cartera/Otros) LISTO.", flush=True) + _log_carga(">>> Hilo 2 (Cartera/Otros) LISTO.") + _hilo_termino("otros") + except Exception as e: + print(f"[precarga-otros] {e}", flush=True) + _log_carga(f"[precarga-otros] ERROR: {e}") + _hilo_termino("otros") + + +# Hora de la última actualización (hora Perú) +_ULTIMA_ACTUALIZACION = None + + +def _marcar_actualizacion(): + global _ULTIMA_ACTUALIZACION + from datetime import datetime, timezone, timedelta + peru = timezone(timedelta(hours=-5)) + _ULTIMA_ACTUALIZACION = datetime.now(peru).strftime("%d/%m/%Y %H:%M") + + +def ultima_actualizacion(): + return {"hora": _ULTIMA_ACTUALIZACION} def refrescar_todo(): - """Refresca SOLO las entradas ya cacheadas (no vacía el caché), para que los - meses ya visitados se mantengan rápidos. Cada ~1h recarga config de GitHub.""" - global _refresh_count - from cache_manager import cache_refresh_existing, cache_keys - _refresh_count += 1 - # El hilo corre cada 900s (15 min); 4 ciclos ≈ 60 min → recargar config GitHub - if _refresh_count % 4 == 0: - try: - get_dm().cargar_toda_configuracion() - print("[config] Recarga de queries/JSON de GitHub completada") - except Exception as e: - print(f"[config] error al recargar GitHub: {e}") - - # Si el caché está vacío (primer arranque), hacer precarga normal - if not cache_keys(): - precargar_todo() - else: - # Limpiar el caché anual de cobranza para que el recálculo lea SQL fresco - try: - get_dm().limpiar_cache_cobranza() - except Exception: - pass - cache_refresh_existing(_recalcular_clave) - _marcar_actualizacion() - # Reprecargar mes actual + anterior de todos los módulos (incluye combos nuevos) - precargar_todos_modulos() - - -def refrescar_manual(clave_actual=None): - """Refresco a demanda (botón 'Actualizar'): - 1) VACÍA el caché y RE-LEE las bases (SQL/Postgre/Supabase) para la vista que el - usuario está viendo AHORA (clave_actual) → se devuelve al instante. - 2) El resto de vistas cacheadas se recalculan en SEGUNDO PLANO (hilo aparte), - sin bloquear la respuesta. - NO afecta al ciclo automático de 15 min.""" - import threading - from cache_manager import cache_keys, cache_invalidate - - claves_previas = cache_keys() - # Vaciar TODO el caché para forzar re-lectura real de las bases - cache_invalidate() - # También el caché anual de cobranza (si no, el recálculo usaría datos viejos) - try: - get_dm().limpiar_cache_cobranza() - except Exception: - pass - - # 1) Vista actual primero (bloqueante, rápido) - if clave_actual: - try: - _recalcular_clave(clave_actual) - except Exception as e: - print(f"[refresh manual] vista actual {clave_actual}: {e}") - - # 2) El resto en segundo plano - resto = [k for k in claves_previas if k != clave_actual] - - def _fondo(): - for k in resto: - try: - _recalcular_clave(k) - except Exception as e: - print(f"[refresh manual bg] {k}: {e}") - _marcar_actualizacion() - print(f"[refresh manual] segundo plano completado ({len(resto)} vistas)") - - threading.Thread(target=_fondo, daemon=True).start() - # Reprecargar mes actual + anterior de todos los módulos con datos frescos - precargar_todos_modulos() + from cache_manager import cache_invalidate + from data_manager_v2 import invalidar_campanias_cache + cache_invalidate() # vaciar y recargar TODO + invalidar_campanias_cache() # tambien las campañas (cache aparte) + precargar_todo() # leads (mes actual/anterior) + precargar_otros_general() # cartera + Otros General (para que no quede vacia) _marcar_actualizacion() - return ultima_actualizacion() - - -# ── PRECARGA EN SEGUNDO PLANO (cache warming por módulo) ──────────────────── -# Calienta en 2º plano TODAS las combinaciones de la TABLA PRINCIPAL del año -# indicado, de a una y con micro-pausas para NO saturar las bases. Solo lo que -# consulta al backend; los filtros que se resuelven en el cliente (sede/frecuencia -# en Cobranza) quedan instantáneos sin precargar nada. Los detalles (Ver/buscar) -# NO se precargan. Cada llamada nueva reemplaza (supersede) la precarga anterior. -import threading as _threading -_precarga_gen = {} # modulo -> id de generación (para cancelar la anterior) -_precarga_estado = {} # modulo -> {"total","hechas","completo","ano"} -_precarga_lock = _threading.Lock() -_PRECARGA_PAUSA = 0.15 # seg entre vistas, para no golpear las bases - - -def precarga_estado(modulo): - """Progreso de la precarga de un módulo (para el aviso en el frontend).""" - modulo = (modulo or "").lower() - with _precarga_lock: - e = _precarga_estado.get(modulo) - return dict(e) if e else {"total": 0, "hechas": 0, "fallidas": 0, "completo": False} - - -def _periodos_act_ant(): - """(año, mes) del mes actual y el anterior (maneja el cambio de año en enero).""" - from datetime import datetime - hoy = datetime.now() - prev = (hoy.year - 1, 12) if hoy.month == 1 else (hoy.year, hoy.month - 1) - return [(hoy.year, hoy.month), prev] - - -def _combos_cobranza(ano): - """Lista de (ano, mes, sectorista, agrupacion) a calentar para Cobranza. - SOLO el mes actual y el mes anterior (no todo el año).""" - periodos = _periodos_act_ant() - agrupaciones = ["SEDE", "PROGRAMA", "ASESOR"] - combos = [] - for (yy, mm) in periodos: - # sectoristas reales de ese mes (+ TODOS). Si falla, solo TODOS. - try: - logic = CobranzaLogic(get_dm()) - secs = logic.obtener_lista_sectoristas(str(yy), str(mm)) or ["TODOS"] - except Exception: - secs = ["TODOS"] - secs = list(dict.fromkeys(["TODOS"] + [s for s in secs if s and s != "TODOS"])) - for ag in agrupaciones: - for sec in secs: - combos.append((yy, mm, sec, ag)) - return combos - - -def precargar_modulo(modulo, ano, en_hilo=True): - """Precarga la tabla principal del módulo para el año (mes actual + anterior). - Si en_hilo=True lanza un hilo y devuelve al instante; si False corre sincrónico - (para encadenar varios módulos en un solo hilo, sin ráfagas de conexiones).""" - modulo = (modulo or "").lower() - with _precarga_lock: - gen = _precarga_gen.get(modulo, 0) + 1 - _precarga_gen[modulo] = gen - _precarga_estado[modulo] = {"total": 0, "hechas": 0, "fallidas": 0, "completo": False, "ano": ano} - - def _vigente(): - with _precarga_lock: - return _precarga_gen.get(modulo) == gen - - def _sumar(campo): - with _precarga_lock: - if _precarga_gen.get(modulo) == gen: - _precarga_estado[modulo][campo] += 1 - - def _set_total(n): - with _precarga_lock: - if _precarga_gen.get(modulo) == gen: - _precarga_estado[modulo]["total"] = n - - def _marcar_completo(): - with _precarga_lock: - if _precarga_gen.get(modulo) == gen: - _precarga_estado[modulo]["completo"] = True - - def _fondo(): - import time - if modulo == "cobranza": - combos = _combos_cobranza(ano) - _set_total(len(combos)) - for (yy, mm, sec, ag) in combos: - if not _vigente(): - return # una precarga más nueva la reemplazó - try: - cobranza(str(yy), str(mm), sec, ag) # cache_get_or_set: si ya está, no reconsulta - _sumar("hechas") - except Exception as e: - print(f"[precarga cobranza] {yy}-{mm} {ag}/{sec}: {e}") - _sumar("fallidas") - time.sleep(_PRECARGA_PAUSA) - _marcar_completo() - print(f"[precarga cobranza] {ano} completado ({len(combos)} vistas)") - elif modulo in ("ocupabilidad", "ventas", "rentabilidad"): - # Solo mes actual y anterior, con filtros por defecto (TODOS/TODOS) - periodos = _periodos_act_ant() - _set_total(len(periodos)) - for (yy, mm) in periodos: - if not _vigente(): - return - try: - if modulo == "ocupabilidad": - ocupabilidad(str(yy), str(mm), "TODOS", "TODOS") - elif modulo == "ventas": - ventas(str(yy), str(mm), "TODOS", "TODOS") - else: - rentabilidad(str(yy), str(mm), "TODOS", "TODOS") - _sumar("hechas") - except Exception as e: - print(f"[precarga {modulo}] {yy}-{mm}: {e}") - _sumar("fallidas") - time.sleep(_PRECARGA_PAUSA) - _marcar_completo() - print(f"[precarga {modulo}] {ano} completado ({len(periodos)} vistas)") - else: - print(f"[precarga] módulo no soportado aún: {modulo}") - - if en_hilo: - _threading.Thread(target=_fondo, daemon=True).start() - else: - _fondo() - return {"status": "precarga iniciada", "modulo": modulo, "ano": ano} - - -def precargar_todos_modulos(): - """Precarga (en 2º plano) el mes actual y anterior de TODOS los módulos, de forma - SECUENCIAL en un solo hilo para no saturar el SQL. Se llama al arrancar, en cada - ciclo de 15 min y tras el refresco manual. No requiere entrar a cada módulo.""" - from datetime import datetime - ano = datetime.now().year - - def _todo(): - for m in ("cobranza", "ocupabilidad", "ventas", "rentabilidad"): - try: - precargar_modulo(m, ano, en_hilo=False) - except Exception as e: - print(f"[precarga todos] {m}: {e}") - print("[precarga todos] completado (todos los módulos)") - - _threading.Thread(target=_todo, daemon=True).start() - - -# ── GUARDAR COSTOS (override Supabase) ───────────────────────────────────── -def guardar_costos(num_indice, costos_inicial, costos_actual): - dm = get_dm() - ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial) - ok2 = dm.guardar_override_costo(num_indice, "actual", costos_actual) - from cache_manager import cache_invalidate - cache_invalidate("rentabilidad") - cache_invalidate("rentabilidad_costos") - return bool(ok1 and ok2) - - -# ── CLASIFICACIÓN DE SEDE (sede.json) ────────────────────────────────────── -def clasificar_programas(nombres): - """Devuelve {programa: sede} usando el clasificador del processor (sede.json).""" - dm = get_dm() - logic = CobranzaLogic(dm) - return {n: str(logic.processor.clasificar_sede(n)) for n in nombres} - - -def clasificar_programas_full(nombres): - """Devuelve {programa: {sede, filtro}} usando el JSON REPLACE_META (meta_data): - - sede: clasificacion_sedes (LIMA/PIURA/AREQUIPA/TRUJILLO) - - filtro: clasificacion_filtro_programa (TEAC/TERC/SEMINARIOS/OTROS).""" - dm = get_dm() - logic = RentabilidadLogic(dm) # su identificar_sede/identificar_filtro_programa usan meta_data - out = {} - for n in nombres: - out[n] = {"sede": str(logic.identificar_sede(n)), - "filtro": str(logic.identificar_filtro_programa(n))} - return out - - -def cobranza_detalle_todos(ano, mes, sectorista="TODOS"): - """Trae TODOS los alumnos en UNA sola consulta (para export y buscador).""" - def _load(): - dm = get_dm() - logic = CobranzaLogic(dm) - brutos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") - alumnos = [] - for d in (brutos or []): - prog = d.get("Programa", d.get("PROGRAMA", "-")) or "-" - cta_ant = float(d.get("CTA_COB_ANT", 0.0)); cob_ant = float(d.get("COB_ANT", 0.0)) - cta_cur = float(d.get("CTA_COB_MES_CURSO", 0.0)); cob_cur = float(d.get("COB_MES_CURSO", 0.0)) - cta_tot = float(d.get("TOTAL_CTA_COB", 0.0)); cob_tot = float(d.get("TOTAL_COBRADO", 0.0)) - saldo = float(d.get("SALDO", 0.0)) - alumnos.append({ - "matricula": str(d.get("MATRICULA", "")), - "alumno": d.get("ALUMNO", d.get("Alumno", "")), - "programa": prog, - "sede": str(logic.processor.clasificar_sede(prog)), - "frecuencia": d.get("FRECUENCIA", d.get("Frecuencia", "-")), - "num_cuota": d.get("NUM_CUOTA", "-"), - "fch_venc": d.get("FCH_VENC", "-"), - "cta_ant": cta_ant, "cob_ant": cob_ant, - "cta_cur": cta_cur, "cob_cur": cob_cur, - "cta_tot": cta_tot, "cob_tot": cob_tot, - "saldo": saldo, - }) - return {"alumnos": alumnos} - return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load) - - -# ── GESTIÓN DE USUARIOS (Supabase Auth + tabla perfiles) ─────────────────── -def _sb(): - dm = get_dm() - if not dm.supabase_client: - raise RuntimeError("Supabase no está conectado") - return dm.supabase_client - -# ── Estado/observaciones por alumno (tabla cobranza_estado_alumno) ── -def alumno_estado_obtener(matricula): - """Devuelve {estado, observaciones} de un alumno. Si no existe, valores por defecto.""" - sb = _sb() - res = sb.table("cobranza_estado_alumno").select("*").eq("matricula", str(matricula)).execute() - fila = (res.data or [None])[0] - if not fila: - return {"estado": "EN CURSO", "observaciones": []} - return {"estado": fila.get("estado") or "EN CURSO", - "observaciones": fila.get("observaciones") or []} - -def alumno_estado_guardar(matricula, estado, observaciones, usuario=None): - """Guarda (upsert) el estado y la lista de observaciones del alumno.""" - sb = _sb() - from datetime import datetime, timezone - payload = { - "matricula": str(matricula), - "estado": estado or "EN CURSO", - "observaciones": observaciones or [], - "actualizado_en": datetime.now(timezone.utc).isoformat(), - "actualizado_por": usuario or None, - } - sb.table("cobranza_estado_alumno").upsert(payload).execute() - return {"status": "ok"} - -def alumnos_por_retirar(): - """Lista de matrículas marcadas como POR RETIRAR (para tachar la fila en la tabla).""" - sb = _sb() - res = sb.table("cobranza_estado_alumno").select("matricula").eq("estado", "POR RETIRAR").execute() - return {"matriculas": [str(r["matricula"]) for r in (res.data or [])]} - -def usuarios_listar(): - sb = _sb() - perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or [] - try: - users = sb.auth.admin.list_users() - lista = users if isinstance(users, list) else getattr(users, "users", []) - correo_por_id = {str(u.id): u.email for u in lista} - except Exception: - correo_por_id = {} - out = [] - for p in perfiles: - out.append({ - "id": p["id"], - "email": correo_por_id.get(str(p["id"]), "-"), - "nombre": p.get("nombre"), - "rol": p.get("rol"), - "activo": p.get("activo", True), - }) - return {"usuarios": out} - -def usuarios_crear(email, password, nombre, rol): - sb = _sb() - res = sb.auth.admin.create_user({ - "email": email, - "password": password, - "email_confirm": True, - }) - user = getattr(res, "user", None) or res - uid = str(user.id) - sb.table("perfiles").upsert({"id": uid, "nombre": nombre or email, "rol": rol, "activo": True}).execute() - return {"status": "ok", "id": uid} - -def usuarios_actualizar_rol(user_id, rol): - sb = _sb() - sb.table("perfiles").update({"rol": rol}).eq("id", user_id).execute() - return {"status": "ok"} - -def usuarios_eliminar(user_id): - sb = _sb() - sb.auth.admin.delete_user(user_id) - return {"status": "ok"} - - -# ── VENDEDORES MANUALES (Comisiones, en Supabase) ────────────────────────── -# ── CONFIG MENSUAL COMISIONES (IMP_TC, META, COMISIÓN por tipo de programa) ── -def comisiones_config_listar(ano, mes): - """Lista la config de un mes (todas las filas por tipo_programa).""" - sb = _sb() - res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute() - return {"config": res.data or []} - -def comisiones_config_guardar(ano, mes, filas): - """Reemplaza la config del mes. filas = [{tipo_programa, imp_tc, meta, comision}].""" - sb = _sb() - sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute() - payload = [] - for f in filas: - payload.append({ - "ano": int(ano), "mes": int(mes), - "tipo_programa": f.get("tipo_programa", ""), - "imp_tc": float(f.get("imp_tc", 0) or 0), - "meta": float(f.get("meta", 0) or 0), - "comision": float(f.get("comision", 0) or 0), - }) - if payload: - sb.table("comisiones_config_mensual").insert(payload).execute() - from cache_manager import cache_invalidate - cache_invalidate("ventas") - cache_invalidate("ventas_det") - return {"status": "ok"} - -def comisiones_tc_del_mes(ano, mes): - """Devuelve el IMP_TC configurado para el mes (toma el primero que tenga TC > 0), o None.""" - try: - sb = _sb() - res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute() - for r in (res.data or []): - tc = float(r.get("imp_tc", 0) or 0) - if tc > 0: return tc - except Exception: - pass - return None - - -def vendedores_manuales_listar(ano, mes): - sb = _sb() - query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano)) - if int(mes) != 0: # mes=0 → traer todo el año (para cache en frontend) - query = query.eq("mes", int(mes)) - res = query.execute() - return {"vendedores": res.data or []} - -def vendedores_manuales_crear(nombre, descripcion, fch_emision, monto, ano, mes): - sb = _sb() - payload = { - "nombre": nombre, "descripcion": descripcion or "", - "fch_emision": fch_emision or "", "monto": float(monto or 0), - "ano": int(ano), "mes": int(mes), - } - res = sb.table("vendedores_manuales").insert(payload).execute() - return {"status": "ok", "data": res.data} - -def vendedores_manuales_eliminar(id): - sb = _sb() - sb.table("vendedores_manuales").delete().eq("id", id).execute() - return {"status": "ok"} - - -def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto): - sb = _sb() - sb.table("vendedores_manuales").update({ - "descripcion": descripcion or "", "fch_emision": fch_emision or "", - "monto": float(monto or 0), - }).eq("id", id).execute() - return {"status": "ok"} - - -def vendedores_manuales_guardar_lote(nombre, ano, mes, filas): - """Reemplaza TODAS las filas de un vendedor manual en una sola operación. - filas = lista de dicts {descripcion, fch_emision, monto}. Si filas vacía → elimina el vendedor.""" - sb = _sb() - # Borrar las existentes de ese vendedor/mes - sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute() - # Insertar las nuevas en un solo batch - payload = [] - for f in filas: - payload.append({ - "nombre": nombre, - "descripcion": f.get("descripcion","") or "", - "fch_emision": f.get("fch_emision","") or "", - "monto": float(f.get("monto",0) or 0), - "ano": int(ano), "mes": int(mes), - }) - if payload: - sb.table("vendedores_manuales").insert(payload).execute() - return {"status": "ok"} - - - - -# ── OVERRIDES DE COMISIONES (editar valores por num_matricula) ───────────── -def comisiones_override_guardar(registros): - """Guarda/actualiza overrides en UNA sola operación (batch upsert).""" - sb = _sb() - from cache_manager import cache_invalidate - validos = [r for r in registros if r.get("num_matricula")] - if validos: - sb.table("comisiones_overrides").upsert(validos).execute() - cache_invalidate("ventas_det") - cache_invalidate("ventas") - cache_invalidate("comisiones_det_todos") - return {"status": "ok"} - -def comisiones_override_restaurar(num_matricula): - """Borra el override de un alumno (vuelve a los valores de SQL).""" - sb = _sb() - from cache_manager import cache_invalidate - sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute() - cache_invalidate("ventas_det") - cache_invalidate("ventas") - cache_invalidate("comisiones_det_todos") - return {"status": "ok"} diff --git a/docker-compose.yml b/docker-compose.yml index 2ecfd15..cdd192d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,29 +1,38 @@ +# ============================================================ +# Dashboard Leads - despliegue con Docker Compose +# +# IP Pública: 161.132.96.129 | IP Privada: 192.168.1.107 +# +# Mapeo de puertos en el Host / Nginx Reverse Proxy: +# dashboardv1.escueladerefrigeracion.edu.pe -> host:3002 (frontend nginx) +# dashboardapi.escueladerefrigeracion.edu.pe -> host:8001 (backend uvicorn) +# ============================================================ + services: - # 1. EL BACKEND (FastAPI - Python) backend: build: context: ./backend - environment: - - SQL_SERVER=191.98.134.80 - - SQL_DATABASE=BDUS_CK000040_0001 - - SQL_USERNAME=ASEBASTIAN - - SQL_PASSWORD=24MY36$$z>&Uf # Escapado correctamente para Docker ($$) - - PG_HOST=191.98.134.81 - - PG_DATABASE=chatwoot_production - - PG_USER=postgres - - PG_PASSWORD=2165$$%sd3%DFG # <--- ¡CORREGIDO AQUÍ! Añadido el doble $$ para Docker - - PG_PORT=5432 - - SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co - - SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y - - GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "8001:8001" + expose: + - "8001" + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8001/api/health')\" || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s - # 2. EL FRONTEND (Vite - React) frontend: build: context: ./frontend - args: - - VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co - - VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY2MzQ5ODMsImV4cCI6MjA5MjIxMDk4M30.OEMjCJOXHBgAHPxtncQUOVC8iDjW40CnT5pczU-gz0U - - VITE_API_URL=https://dashboardapi.escueladerefrigeracion.edu.pe + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "3002:80" + expose: + - "80" depends_on: - - backend \ No newline at end of file + - backend diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..2d2cd85 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.env.local +npm-debug.log* +Dockerfile +.dockerignore diff --git a/frontend/.env.example b/frontend/.env.example index b9e8804..34fbc8c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,4 @@ -# Credenciales públicas de Supabase para el login (frontend) -# La URL es la misma de tu proyecto. -# La ANON KEY es la PÚBLICA (anon/public), NO la service_role. -VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co -VITE_SUPABASE_ANON_KEY=pega_aqui_tu_anon_public_key +# URL base del backend FastAPI que consume el frontend. +# Local: http://localhost:8001 +# Despliegue: https://dashboardapi.escueladerefrigeracion.edu.pe +VITE_API_URL=http://localhost:8001 diff --git a/frontend/.env.production b/frontend/.env.production deleted file mode 100644 index 8f08bae..0000000 --- a/frontend/.env.production +++ /dev/null @@ -1 +0,0 @@ -VITE_API_URL=https://dashboardapi.escueladerefrigeracion.edu.pe \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index dd8fe26..0000000 --- a/frontend/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -dist/ -*.log -.env diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9411a15..3e0ed07 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,27 +1,20 @@ -# Usamos Node.js para compilar el proyecto -FROM node:18-alpine +# ── Etapa 1: build del frontend con Vite ── +FROM node:20-alpine AS build -# Creamos la carpeta de trabajo WORKDIR /app -# Declaramos que recibiremos estos argumentos durante el build -ARG VITE_SUPABASE_URL -ARG VITE_SUPABASE_ANON_KEY -ARG VITE_API_URL +COPY package.json package-lock.json* ./ +RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund -# Los convertimos en variables de entorno para que Vite los vea al compilar -ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL -ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY -ENV VITE_API_URL=$VITE_API_URL - -# Copiamos los archivos de configuración e instalamos dependencias -COPY package*.json ./ -RUN npm install - -# Copiamos el código y construimos la web (ahora Vite sí verá las variables) COPY . . RUN npm run build -# Instalamos el servidor estático y lo ejecutamos -RUN npm install -g serve -CMD ["serve", "-s", "dist", "-l", "5173"] \ No newline at end of file +# ── Etapa 2: servir el build con nginx ── +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 0c04ae2..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Frontend — Dashboard React - -## Requisitos -- Node.js 18+ (descarga: https://nodejs.org) - -## Instalación (una sola vez) -``` -npm install -``` - -## Ejecutar -``` -npm run dev -``` -Abre: http://localhost:3000 - -⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000 -(en otra terminal: `py -3.12 main.py`) - -## Estado -- ✅ Sidebar + navegación -- ✅ Ocupabilidad (conectado al backend) -- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones) - -## Cambiar IP del backend -Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3: -```js -const BASE_URL = "http://IP_DEL_SERVIDOR:8000"; -``` diff --git a/frontend/index.html b/frontend/index.html index 4f9f9a2..a467813 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,9 +1,9 @@ - + - Dashboard — Escuela de Refrigeración + Dashboard Leads — Escuela Refrigeración
diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f47310c --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Compresion + gzip on; + gzip_types text/plain text/css application/json application/javascript + text/xml application/xml application/xml+rss text/javascript + image/svg+xml; + gzip_min_length 1024; + + # Cache larga para los assets con hash en el nombre + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # SPA: cualquier ruta desconocida devuelve index.html + location / { + try_files $uri $uri/ /index.html; + } + + # index.html nunca se cachea, para que los deploys se vean al instante + location = /index.html { + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + internal; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb250a2..e9d6f3a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,22 +1,20 @@ { - "name": "dashboard-frontend", + "name": "dashboard-leads", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dashboard-frontend", + "name": "dashboard-leads", "version": "1.0.0", "dependencies": { - "@supabase/supabase-js": "^2.107.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "xlsx": "^0.18.5", - "xlsx-js-style": "^1.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.12.0" }, "devDependencies": { - "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.0" + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.0" } }, "node_modules/@babel/code-frame": { @@ -253,6 +251,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -750,9 +757,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "cpu": [ "arm" ], @@ -764,9 +771,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -778,9 +785,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -792,9 +799,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -806,9 +813,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "cpu": [ "arm64" ], @@ -820,9 +827,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -834,13 +841,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -848,13 +858,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -862,13 +875,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -876,13 +892,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -890,13 +909,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -904,13 +926,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -918,13 +943,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -932,13 +960,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -946,13 +977,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -960,13 +994,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -974,13 +1011,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -988,13 +1028,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1002,13 +1045,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1016,9 +1062,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", "cpu": [ "x64" ], @@ -1030,9 +1076,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -1044,9 +1090,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -1058,9 +1104,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "cpu": [ "ia32" ], @@ -1072,9 +1118,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -1086,9 +1132,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "cpu": [ "x64" ], @@ -1099,90 +1145,6 @@ "win32" ] }, - "node_modules/@supabase/auth-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz", - "integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz", - "integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/phoenix": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", - "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", - "license": "MIT" - }, - "node_modules/@supabase/postgrest-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz", - "integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz", - "integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==", - "license": "MIT", - "dependencies": { - "@supabase/phoenix": "^0.4.2", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz", - "integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==", - "license": "MIT", - "dependencies": { - "iceberg-js": "^0.8.1", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.107.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz", - "integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.107.0", - "@supabase/functions-js": "2.107.0", - "@supabase/postgrest-js": "2.107.0", - "@supabase/realtime-js": "2.107.0", - "@supabase/storage-js": "2.107.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1228,10 +1190,73 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1256,19 +1281,10 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/adler-32": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", - "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/baseline-browser-mapping": { - "version": "2.10.33", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", - "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1279,9 +1295,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -1299,10 +1315,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -1313,9 +1329,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -1333,34 +1349,15 @@ ], "license": "CC-BY-4.0" }, - "node_modules/cfb": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", - "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" - }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", "engines": { - "node": ">=0.8" + "node": ">=6" } }, - "node_modules/codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/commander": { - "version": "2.17.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", - "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1368,16 +1365,131 @@ "dev": true, "license": "MIT" }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" }, "engines": { - "node": ">=0.8" + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" } }, "node_modules/debug": { @@ -1398,10 +1510,26 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", "dev": true, "license": "ISC" }, @@ -1454,28 +1582,19 @@ "node": ">=6" } }, - "node_modules/exit-on-epipe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", - "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/fflate": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", - "integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==", + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, - "node_modules/frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "license": "Apache-2.0", + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", "engines": { - "node": ">=0.8" + "node": ">=6.0.0" } }, "node_modules/fsevents": { @@ -1503,13 +1622,13 @@ "node": ">=6.9.0" } }, - "node_modules/iceberg-js": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", - "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", - "license": "MIT", + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", "engines": { - "node": ">=20.0.0" + "node": ">=12" } }, "node_modules/js-tokens": { @@ -1544,6 +1663,12 @@ "node": ">=6" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1574,9 +1699,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -1593,15 +1718,24 @@ } }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1638,18 +1772,23 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/printj": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", - "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==", - "license": "Apache-2.0", - "bin": { - "printj": "bin/printj.njs" - }, - "engines": { - "node": ">=0.8" + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1675,6 +1814,12 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1685,14 +1830,78 @@ "node": ">=0.10.0" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -1702,31 +1911,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" } }, @@ -1759,23 +1968,11 @@ "node": ">=0.10.0" } }, - "node_modules/ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "license": "Apache-2.0", - "dependencies": { - "frac": "~1.1.2" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.2.3", @@ -1808,6 +2005,28 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1868,107 +2087,6 @@ } } }, - "node_modules/wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/xlsx": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", - "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "cfb": "~1.2.1", - "codepage": "~1.15.0", - "crc-32": "~1.2.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/xlsx-js-style": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz", - "integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.2.0", - "cfb": "^1.1.4", - "codepage": "~1.14.0", - "commander": "~2.17.1", - "crc-32": "~1.2.0", - "exit-on-epipe": "~1.0.1", - "fflate": "^0.3.8", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/xlsx-js-style/node_modules/adler-32": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz", - "integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==", - "license": "Apache-2.0", - "dependencies": { - "exit-on-epipe": "~1.0.1", - "printj": "~1.1.0" - }, - "bin": { - "adler32": "bin/adler32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/xlsx-js-style/node_modules/codepage": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz", - "integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==", - "license": "Apache-2.0", - "dependencies": { - "commander": "~2.14.1", - "exit-on-epipe": "~1.0.1" - }, - "bin": { - "codepage": "bin/codepage.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", - "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==", - "license": "MIT" - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index dd4567a..b7ffe74 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,22 +1,20 @@ { - "name": "dashboard-frontend", + "name": "dashboard-leads", "private": true, "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", - "build": "vite build --mode production", + "build": "vite build", "preview": "vite preview" }, "dependencies": { - "@supabase/supabase-js": "^2.107.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "xlsx": "^0.18.5", - "xlsx-js-style": "^1.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.12.0" }, "devDependencies": { - "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.0" + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.0" } } diff --git a/frontend/public/50x.html b/frontend/public/50x.html new file mode 100644 index 0000000..a57c2f9 --- /dev/null +++ b/frontend/public/50x.html @@ -0,0 +1,19 @@ + + + +Error + + + +

An error occurred.

+

Sorry, the page you are looking for is currently unavailable.
+Please try again later.

+

If you are the system administrator of this resource then you should check +the error log for details.

+

Faithfully yours, nginx.

+ + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fd6a360..ff52521 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,67 +1,35 @@ -// src/App.jsx -import { useState, useEffect } from "react"; -import { useAuth } from "./lib/auth"; +import { useState } from "react"; import Sidebar from "./components/Sidebar"; -import IndicadorActualizacion from "./components/IndicadorActualizacion"; -import Login from "./pages/Login"; -import Ocupabilidad from "./pages/Ocupabilidad"; -import Ventas from "./pages/Ventas"; -import Comisiones from "./pages/Comisiones"; -import Cobranza from "./pages/Cobranza"; -import Rentabilidad from "./pages/Rentabilidad"; -import Asesores from "./pages/Asesores"; -import Usuarios from "./pages/Usuarios"; +import Leads from "./pages/Leads"; +import OtrosGeneral from "./pages/OtrosGeneral"; +import Vendedores from "./pages/Vendedores"; +import Roas from "./pages/Roas"; +import CampanitaAlertas from "./components/CampanitaAlertas"; +import ConfigModal from "./components/ConfigModal"; +import ErrorBoundary from "./components/ErrorBoundary"; export default function App() { - const { autenticado, cargando, permisos, puedeVer } = useAuth(); - const [pagina, setPagina] = useState(null); - - // Al entrar, seleccionar el primer módulo permitido - useEffect(() => { - if (autenticado && permisos.length > 0 && !pagina) { - setPagina(permisos[0]); - } - if (!autenticado) setPagina(null); - }, [autenticado, permisos, pagina]); - - if (cargando) { - return ( -
- Cargando... -
- ); - } - - if (!autenticado) return ; + const [pagina, setPagina] = useState("leads"); + const [config, setConfig] = useState(false); function render() { - // Seguridad: si la página actual no está permitida, no renderizar - if (pagina && !puedeVer(pagina)) return ; switch (pagina) { - case "ocupabilidad": return ; - case "ventas": return ; - case "comisiones": return ; - case "cobranza": return ; - case "rentabilidad": return ; - case "asesores": return ; - case "usuarios": return ; - default: return null; + case "leads": return ; + case "otros": return ; + case "vendedores": return ; + case "roas": return ; + default: return null; } } return (
- -
{render()}
- -
- ); -} - -function SinAcceso() { - return ( -
- 🔒 No tienes acceso a este módulo. + setConfig(true)} /> +
+ {render()} +
+ + {config && setConfig(false)} />}
); } diff --git a/frontend/src/components/CampanitaAlertas.jsx b/frontend/src/components/CampanitaAlertas.jsx new file mode 100644 index 0000000..ead6532 --- /dev/null +++ b/frontend/src/components/CampanitaAlertas.jsx @@ -0,0 +1,92 @@ +// Campanita de alertas: avisa de programas/sedes NO identificados en cartera_junta +// (valores que no están en el diccionario alias_normalizacion). +import { useState, useEffect } from "react"; +import { api } from "../lib/api"; + +export default function CampanitaAlertas() { + const [data, setData] = useState({ programa: [], sede: [] }); + const [abierto, setAbierto] = useState(false); + + useEffect(() => { + let activo = true; + api.alertas() + .then((r) => { if (activo) setData({ programa: r.programa || [], sede: r.sede || [] }); }) + .catch(() => { if (activo) setData({ programa: [], sede: [] }); }); + return () => { activo = false; }; + }, []); + + const total = (data.programa?.length || 0) + (data.sede?.length || 0); + const hay = total > 0; + + return ( +
+ + + {abierto && ( +
+
🔔 Alertas de normalización
+ {!hay ? ( +
✓ Todo identificado, nada por revisar.
+ ) : ( + <> + {(data.programa?.length > 0) && ( +
+
+ Nuevo programa no identificado: +
+ {data.programa.map((p, i) => ( +
+ {p.valor || "(vacío)"} · {p.veces} +
+ ))} +
+ )} + {(data.sede?.length > 0) && ( +
+
+ Nueva sede no identificada: +
+ {data.sede.map((s, i) => ( +
+ {s.valor || "(vacío)"} · {s.veces} +
+ ))} +
+ )} +
+ Agrégalos al diccionario (alias_normalizacion) para clasificarlos. +
+ + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ConfigModal.jsx b/frontend/src/components/ConfigModal.jsx new file mode 100644 index 0000000..6033862 --- /dev/null +++ b/frontend/src/components/ConfigModal.jsx @@ -0,0 +1,969 @@ +import { useState, useEffect, useRef } from "react"; +import { api } from "../lib/api"; + +// Desplegable con búsqueda (estilo Claude). +function ProgramaSelect({ valor, opciones = [], onSelect }) { + const [abierto, setAbierto] = useState(false); + const [q, setQ] = useState(""); + const ref = useRef(null); + + useEffect(() => { + function fuera(e) { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); } + document.addEventListener("mousedown", fuera); + return () => document.removeEventListener("mousedown", fuera); + }, []); + + const filtradas = opciones.filter((o) => o.label.toLowerCase().includes(q.toLowerCase())); + + return ( +
+ + {abierto && ( +
+
+ setQ(e.target.value)} placeholder="Buscar..." + style={{ width: "100%", padding: "8px 11px", border: "1px solid #cbd5e1", borderRadius: 8, + fontSize: 13, fontFamily: "inherit", outline: "none" }} /> +
+
+ {filtradas.length === 0 ? ( +
Sin opciones
+ ) : filtradas.map((o) => ( +
e.currentTarget.style.background = "#f1f5f9"} + onMouseLeave={(e) => e.currentTarget.style.background = "transparent"} + onMouseDown={(e) => e.preventDefault()} + onClick={() => { onSelect && onSelect(o); setAbierto(false); setQ(""); }}> + {o.label} +
+ ))} +
+
+ )} +
+ ); +} + +// Apartado "Sedes de Anuncios": conjuntos del CSV sin sede -> asignar sede manual. +// FASE 1: solo vista (dropdown de sede por fila). El guardar se agrega despues. +function SedesAnuncios() { + const [filas, setFilas] = useState([]); + const [cargando, setCargando] = useState(true); + const [sel, setSel] = useState({}); // conjunto -> sede elegida (decorativo por ahora) + const [selProg, setSelProg] = useState({}); // conjunto -> programa elegido (decorativo) + const [busca, setBusca] = useState(""); + // Opciones fijas (no se pueden borrar) y opciones ampliables con "Agregar". + const SEDES_FIJAS = ["LIMA", "PIURA", "AREQUIPA", "TRUJILLO"]; + const PROGS_FIJOS = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]; + const [sedes, setSedes] = useState(SEDES_FIJAS); + const [programas, setProgramas] = useState(PROGS_FIJOS); + const SEDES = sedes; + const PROGRAMAS = programas; + + // Quita una opción creada (no fija): la saca del dropdown y de cualquier + // conjunto que la tenga asignada (se aplica al Guardar). + function quitarOpcion(tipo, val) { + if (tipo === "TIPO") { + if (SEDES_FIJAS.includes(val)) return; + setSedes((s) => s.filter((x) => x !== val)); + setSel((cur) => { + const n = { ...cur }; + Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; }); + return n; + }); + } else { + if (PROGS_FIJOS.includes(val)) return; + setProgramas((p) => p.filter((x) => x !== val)); + setSelProg((cur) => { + const n = { ...cur }; + Object.keys(n).forEach((k) => { if (n[k] === val) n[k] = ""; }); + return n; + }); + } + } + // Pop-up "Agregar" + const [popup, setPopup] = useState(false); + const [nvTipo, setNvTipo] = useState("TIPO"); // "TIPO" o "PROGRAMA" + const [nvValor, setNvValor] = useState(""); + + function agregar() { + const val = nvValor.trim().toUpperCase(); + if (!val) return; + if (nvTipo === "TIPO") { + if (!sedes.includes(val)) setSedes((s) => [...s, val]); + } else { + if (!programas.includes(val)) setProgramas((p) => [...p, val]); + } + setNvValor(""); setPopup(false); + } + + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(""); + + useEffect(() => { + api.conjuntosSinSede() + .then((r) => { + const rows = r || []; + setFilas(rows); + // precargar sede/programa ya asignados + ampliar opciones si hay valores nuevos + const s0 = {}, p0 = {}; const sExtra = new Set(), pExtra = new Set(); + rows.forEach((f) => { + if (f.sede) { s0[f.conjunto] = f.sede; sExtra.add(f.sede); } + if (f.programa) { p0[f.conjunto] = f.programa; pExtra.add(f.programa); } + }); + setSel(s0); setSelProg(p0); + setSedes((cur) => [...cur, ...[...sExtra].filter((x) => !cur.includes(x))]); + // Carga los programas ya asignados (incluye los nuevos que crees, ej. + // BRANDING). El dashboard ahora SÍ reconoce cualquier programa asignado. + setProgramas((cur) => [...cur, ...[...pExtra].filter((x) => !cur.includes(x))]); + setCargando(false); + }) + .catch(() => setCargando(false)); + }, []); + + async function guardar() { + setGuardando(true); setMsg(""); + try { + const cambios = filas + .filter((f) => sel[f.conjunto] || selProg[f.conjunto]) + .map((f) => ({ conjunto: f.conjunto, sede: sel[f.conjunto] || "", programa: selProg[f.conjunto] || "" })); + await api.guardarConjuntoSede(cambios); + setMsg("✓ Guardado. Se reflejará en ROAS."); + window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue + window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados + } catch (e) { + setMsg("Error al guardar"); + } + setGuardando(false); + } + + async function borrar(conjunto) { + // limpia la seleccion en pantalla y borra la asignacion en Supabase + setSel((s) => { const n = { ...s }; delete n[conjunto]; return n; }); + setSelProg((s) => { const n = { ...s }; delete n[conjunto]; return n; }); + try { + await api.borrarConjuntoSede(conjunto); + setMsg("✓ Asignación borrada."); + window.dispatchEvent(new Event("roas-actualizar")); // avisa al ROAS que recargue + window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados + } catch (e) { + setMsg("Error al borrar"); + } + } + + const vis = filas.filter((f) => f.conjunto.toLowerCase().includes(busca.toLowerCase())); + + return ( + <> +
+
+ setBusca(e.target.value)} + placeholder="Buscar conjunto de anuncio..." + style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1", + borderRadius: 9, fontSize: 13 }} /> + +
+
+ Conjuntos de anuncios sin sede asignada. Elige la sede y el programa de cada uno. +
+
+ + {/* Pop-up Agregar nueva opción (Tipo o Programa) */} + {popup && ( +
setPopup(false)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.4)", zIndex: 60, + display: "flex", alignItems: "center", justifyContent: "center" }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, width: 360, padding: 22, + boxShadow: "0 20px 50px rgba(0,0,0,0.3)" }}> +
+ Agregar nueva opción +
+ + + + setNvValor(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && agregar()} + placeholder="Ej: CHICLAYO" + style={{ width: "100%", padding: "9px 10px", border: "1px solid #cbd5e1", + borderRadius: 8, fontSize: 14, marginTop: 5, marginBottom: 14 }} /> + + {/* Lista de opciones CREADAS (no fijas) para poder borrarlas */} + {(() => { + const esSede = nvTipo === "TIPO"; + const lista = (esSede ? sedes : programas) + .filter((x) => !(esSede ? SEDES_FIJAS : PROGS_FIJOS).includes(x)); + if (lista.length === 0) return null; + return ( +
+
+ {esSede ? "Sedes creadas" : "Programas creados"} (clic en × para borrar) +
+
+ {lista.map((x) => ( + + {x} + + + ))} +
+
+ ); + })()} + +
+ + +
+
+
+ )} +
+ {cargando ? ( +
Cargando...
+ ) : ( +
+ + + + + + + + + + + {vis.map((f, i) => ( + + + + + + + ))} + {vis.length === 0 && ( + + )} + +
Conjunto de AnuncioTipoPrograma
{f.conjunto} + + + + + {(sel[f.conjunto] || selProg[f.conjunto]) && ( + + )} +
+ No hay conjuntos sin sede.
+
+ )} +
+
+ + {msg || `${vis.length} conjuntos sin sede`} + + +
+ + ); +} + +// Apartado "Programas": lista de programas con contar=NO (ocultos), con switch para reactivar. +function ProgramasOcultos() { + const [filas, setFilas] = useState([]); + const [cargando, setCargando] = useState(true); + const [enc, setEnc] = useState({}); // num_indice -> true si se va a encender + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(""); + + useEffect(() => { + api.programasOcultos() + .then((r) => { setFilas(r.filas || []); setCargando(false); }) + .catch(() => setCargando(false)); + }, []); + + async function guardar() { + const nis = Object.entries(enc).filter(([, v]) => v).map(([ni]) => ni); + if (nis.length === 0) { setMsg("No hay programas para activar."); return; } + setGuardando(true); setMsg(""); + try { + await api.encenderProgramas(nis); + setMsg("✓ Activados. Ya aparecen en la matriz."); + const r = await api.programasOcultos(); + setFilas(r.filas || []); setEnc({}); + window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados + } catch (e) { setMsg("Error: " + e.message); } + finally { setGuardando(false); } + } + + return ( + <> +
+
+ Aquí están los programas ocultos del Detalle por Curso. Enciende el interruptor + y guarda para que vuelvan a aparecer en la tabla del dashboard. +
+
+
+ {cargando ? ( +
Cargando...
+ ) : filas.length === 0 ? ( +
+ No hay programas ocultos. +
+ ) : ( +
+ {filas.map((f) => { + const on = !!enc[f.num_indice]; + return ( +
+
+
{f.dsc_det_programa}
+
+ Índice {f.num_indice}{f.fch_inicio ? ` · ${f.fch_inicio}` : ""}{f.pauta ? ` · Pauta ${f.pauta}` : ""} +
+
+
setEnc((p) => ({ ...p, [f.num_indice]: !p[f.num_indice] }))} + style={{ width: 46, height: 26, borderRadius: 999, cursor: "pointer", flexShrink: 0, + background: on ? "#2563eb" : "#cbd5e1", position: "relative", transition: "background .15s" }}> +
+
+
+ ); + })} +
+ )} +
+
+ {msg} + +
+ + ); +} + +// ── Mini-apartado LEYENDA: tabla de campanias + Agregar (popup) + eliminar (visual) ── +function LeyendaCampanias() { + const CARGOS = ["TEAC", "TERC", "VRF", "CO2", "Seminarios", "Diseño de Chillers", "Diplomado REF", "CAD", "Amoniaco", "Cámaras", "Diseño de Sistemas", "Metrado y Costeo"]; + const SEDES = ["Lima", "Piura", "Arequipa", "Trujillo"]; + const ORIGENES = ["Pauta_wsp", "Web_Whatsapp"]; + const DIAS = ["Domingo", "Sábado", "Semipresencial", "-"]; + + const [filas, setFilas] = useState([]); + const [cargando, setCargando] = useState(true); + const [busca, setBusca] = useState(""); + const [popup, setPopup] = useState(false); + const [editandoId, setEditandoId] = useState(null); // id de la fila en edicion, o null = agregar + const [confirmar, setConfirmar] = useState(null); // fila a eliminar, o null + const [choques, setChoques] = useState(null); // [filas que chocan] o null (popup de aviso) + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(""); + const nuevaVacia = { frase_busqueda: "", cargo: "TEAC", codigo: "", sede: "Lima", dia: "-", origen: "Pauta_wsp", fecha_inicio: "", fecha_fin: "" }; + const [nueva, setNueva] = useState(nuevaVacia); + + function recargar() { + setCargando(true); + api.campanias().then((r) => { setFilas(r.filas || []); setCargando(false); }) + .catch(() => setCargando(false)); + } + useEffect(() => { recargar(); }, []); + + const vis = filas.filter((f) => + !busca || ((f.frase_busqueda || "") + (f.cargo || "") + (f.codigo || "") + (f.sede || "")).toLowerCase().includes(busca.toLowerCase())); + + // Extrae los emojis de un texto (pictográficos). Devuelve un Set de emojis. + function emojisDe(txt) { + const s = new Set(); + for (const ch of String(txt || "")) { + const cp = ch.codePointAt(0); + // rangos de emojis/pictogramas comunes + if ((cp >= 0x1F000 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) || + (cp >= 0x2B00 && cp <= 0x2BFF) || cp === 0x2764 || (cp >= 0x2190 && cp <= 0x21FF)) { + s.add(ch); + } + } + return s; + } + // ¿los rangos [i1,f1] y [i2,f2] se solapan? (fechas en texto YYYY-MM-DD) + function fechasSolapan(i1, f1, i2, f2) { + if (!i1 || !f1 || !i2 || !f2) return false; + return i1 <= f2 && i2 <= f1; + } + // Devuelve las campañas existentes que chocan con "nueva": + // mismo emoji compartido Y fechas solapadas. + function detectarChoques() { + const emN = emojisDe(nueva.frase_busqueda); + if (emN.size === 0) return []; + const iN = (nueva.fecha_inicio || "").slice(0, 10); + const fN = (nueva.fecha_fin || "").slice(0, 10); + return filas.filter((f) => { + if (editandoId != null && f.id === editandoId) return false; // no chocar contra si misma + const emE = emojisDe(f.frase_busqueda); + const comparte = [...emN].some((e) => emE.has(e)); // ¿comparten algún emoji? + if (!comparte) return false; + return fechasSolapan(iN, fN, (f.fecha_inicio || "").slice(0, 10), (f.fecha_fin || "").slice(0, 10)); + }); + } + + function abrirAgregar() { + setEditandoId(null); setNueva(nuevaVacia); setMsg(""); setPopup(true); + } + function abrirEditar(f) { + setEditandoId(f.id); + setNueva({ + frase_busqueda: f.frase_busqueda || "", cargo: f.cargo || "TEAC", + codigo: f.codigo || "", sede: f.sede || "Lima", dia: f.dia || "-", + origen: f.origen || "Pauta_wsp", + fecha_inicio: (f.fecha_inicio || "").slice(0, 10), + fecha_fin: (f.fecha_fin || "").slice(0, 10), + }); + setMsg(""); setPopup(true); + } + + async function agregar() { + if (!nueva.frase_busqueda.trim()) { setMsg("Escribe la frase de búsqueda."); return; } + // Aviso de choque: mismo emoji + fechas solapadas. Si las fechas NO chocan, no avisa. + const ch = detectarChoques(); + if (ch.length > 0) { setChoques(ch); return; } + await guardarCampania(); + } + + async function guardarCampania() { + setChoques(null); + setGuardando(true); setMsg(""); + try { + if (editandoId != null) await api.editarCampania(editandoId, nueva); + else await api.agregarCampania(nueva); + setPopup(false); setNueva(nuevaVacia); setEditandoId(null); + recargar(); + window.dispatchEvent(new Event("datos-actualizar")); + } catch (e) { setMsg("Error al guardar: " + e.message); } + finally { setGuardando(false); } + } + async function borrarConfirmado() { + const f = confirmar; + setConfirmar(null); + try { + await api.borrarCampania(f.id); + recargar(); + window.dispatchEvent(new Event("datos-actualizar")); + } catch (e) { setMsg("Error al borrar: " + e.message); } + } + + const inp = { width: "100%", height: 38, border: "1px solid #e2e8f0", borderRadius: 8, + padding: "0 11px", fontSize: 13, boxSizing: "border-box" }; + const lab = { fontSize: 12.5, fontWeight: 600, color: "#475569", display: "block", marginBottom: 5 }; + + return ( + <> +
+ setBusca(e.target.value)} + placeholder="Buscar frase, cargo, código o sede..." + style={{ flex: 1, padding: "9px 12px", border: "1px solid #cbd5e1", borderRadius: 9, fontSize: 13 }} /> + +
+ +
+
+ + + + {["Frase de búsqueda", "Cargo", "Código", "Sede", "Día", "Origen", "Inicio", "Fin", ""].map((h, i, arr) => ( + + ))} + + + + {cargando && ( + + )} + {!cargando && vis.length === 0 && ( + + )} + {!cargando && vis.map((f) => ( + + + + + + + + + + + + ))} + +
{h}
Cargando...
Sin leyendas.
{f.frase_busqueda}{f.cargo}{f.codigo}{f.sede}{f.dia}{f.origen}{f.fecha_inicio}{f.fecha_fin} +
+ + +
+
+
+
+ + {/* Popup Agregar leyenda */} + {popup && ( +
setPopup(false)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)", + display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1300 }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, width: 520, maxWidth: "94vw", + boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}> +
+
{editandoId != null ? "Editar leyenda" : "Agregar leyenda"}
+ +
+
+
+ + setNueva({ ...nueva, frase_busqueda: e.target.value })} /> +
+
+
+ + +
+
+ + setNueva({ ...nueva, codigo: e.target.value })} /> +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + setNueva({ ...nueva, fecha_inicio: e.target.value })} /> +
+
+ + setNueva({ ...nueva, fecha_fin: e.target.value })} /> +
+
+
+
+ + +
+ {msg &&
{msg}
} +
+
+ )} + + {/* Popup de confirmación de eliminación */} + {confirmar !== null && ( +
setConfirmar(null)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)", + display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1400 }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, width: 400, maxWidth: "92vw", + boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden", textAlign: "center" }}> +
+
🗑️
+
+ ¿Eliminar esta leyenda? +
+
+ {confirmar && ( + <>Se eliminará la pauta {confirmar.codigo} + {" "}({confirmar.cargo} — {confirmar.sede}). Esta acción no se puede deshacer. + )} +
+
+
+ + +
+
+
+ )} + + {/* Popup de ADVERTENCIA de choque: mismo emoji + fechas solapadas */} + {choques !== null && ( +
setChoques(null)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.45)", + display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1500 }}> +
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 14, width: 460, maxWidth: "94vw", + boxShadow: "0 20px 50px rgba(0,0,0,.25)", overflow: "hidden" }}> +
+
⚠️
+
+ Esta leyenda podría duplicar leads +
+
+ La frase que vas a crear comparte emoji y fechas con {choques.length === 1 ? "esta pauta existente" : `estas ${choques.length} pautas existentes`}. + Un mismo mensaje podría contarse dos veces. +
+
+
+ {choques.map((c) => ( +
+
+ Pauta {c.codigo} ({c.cargo} — {c.sede}) +
+
“{c.frase_busqueda}”
+
+ Vigente: {c.fecha_inicio} → {c.fecha_fin} +
+
+ ))} +
+
+ + +
+
+
+ )} + + ); +} + +export default function ConfigModal({ onClose }) { + const [seccion, setSeccion] = useState("leyenda"); + const [filas, setFilas] = useState([]); + const [cargando, setCargando] = useState(true); + const [guardando, setGuardando] = useState(false); + const [msg, setMsg] = useState(""); + const [busca, setBusca] = useState(""); + const [edit, setEdit] = useState({}); // conjunto -> pauta editada + const [editProg, setEditProg] = useState({}); // conjunto -> {num_indice, label} programa elegido + const [progOpc, setProgOpc] = useState([]); // opciones del desplegable de Programa + + useEffect(() => { + api.leyendaAnuncios() + .then((r) => { setFilas(r.filas || []); setCargando(false); }) + .catch(() => setCargando(false)); + api.programasDisponibles() + .then((r) => setProgOpc(r.programas || [])) + .catch(() => setProgOpc([])); + }, []); + + const setPauta = (conjunto, val) => setEdit((p) => ({ ...p, [conjunto]: val })); + const valorPauta = (f) => (edit[f.conjunto] !== undefined ? edit[f.conjunto] : (f.pauta || "")); + // Programa mostrado: el elegido si hay, si no el que ya trae la fila + const valorPrograma = (f) => (editProg[f.conjunto] !== undefined ? editProg[f.conjunto].label : (f.programa || "")); + + async function guardar() { + // Un cambio por conjunto que tenga pauta editada o programa elegido + const conjuntos = new Set([...Object.keys(edit), ...Object.keys(editProg)]); + const cambios = []; + for (const conjunto of conjuntos) { + const f = filas.find((x) => x.conjunto === conjunto) || {}; + const pauta = (edit[conjunto] !== undefined ? edit[conjunto] : (f.pauta || "")).trim(); + const prog = editProg[conjunto]; // {num_indice, label} o undefined + cambios.push({ conjunto, pauta, num_indice: prog ? prog.num_indice : null }); + } + if (cambios.length === 0) { setMsg("No hay cambios."); return; } + setGuardando(true); setMsg(""); + try { + await api.guardarLeyenda(cambios); + setMsg("✓ Guardado correctamente."); + const r = await api.leyendaAnuncios(); + setFilas(r.filas || []); setEdit({}); setEditProg({}); + window.dispatchEvent(new Event("datos-actualizar")); // avisa a TODOS los apartados + } catch (e) { setMsg("Error: " + e.message); } + finally { setGuardando(false); } + } + + // Orden: 1) pauta Y programa, 2) pauta sin programa, 3) programa sin pauta, 4) nada + const _rango = (f) => { + const p = valorPauta(f).trim() !== ""; + const g = (f.programa || "").trim() !== ""; + if (p && g) return 0; + if (p && !g) return 1; + if (!p && g) return 2; + return 3; + }; + const filtradas = filas + .filter((f) => + !busca || f.conjunto.toLowerCase().includes(busca.toLowerCase()) + || String(f.pauta).includes(busca) + || (f.programa || "").toLowerCase().includes(busca.toLowerCase())) + .sort((a, b) => (_rango(a) - _rango(b)) || a.conjunto.localeCompare(b.conjunto)); + + return ( +
+
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 16, width: 1320, maxWidth: "96vw", + height: 680, maxHeight: "92vh", display: "flex", overflow: "hidden", + boxShadow: "0 24px 70px rgba(0,0,0,0.4)", + fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif" }}> + {/* Sidebar del modal */} +
+
Configuración
+ + + + +
+ {/* Contenido */} +
+
+
+ {seccion === "leyenda" ? "Leyenda de Anuncios" + : seccion === "programas" ? "Programas" + : seccion === "leyendas" ? "Leyenda" + : "Sedes de Anuncios"} +
+ +
+ + {seccion === "leyendas" ? ( + + ) : seccion === "sedes" ? ( + + ) : seccion === "programas" ? ( + + ) : ( + <> +
+ setBusca(e.target.value)} + placeholder="Buscar conjunto, pauta o programa..." + style={{ width: "100%", padding: "9px 12px", border: "1px solid #cbd5e1", + borderRadius: 9, fontSize: 13 }} /> +
+ +
+ {cargando ? ( +
Cargando...
+ ) : ( +
+ + + + + + + + + + + + + + + {filtradas.map((f) => ( + + + + + + ))} + +
Conjunto de AnunciosPautaPrograma
{f.conjunto} + setPauta(f.conjunto, e.target.value)} + style={{ width: 90, padding: "6px 8px", border: "1px solid #cbd5e1", + borderRadius: 6, fontSize: 13, textAlign: "left", fontFamily: "inherit", + background: edit[f.conjunto] !== undefined ? "#fffbeb" : "#fff" }} /> + + setEditProg((p) => ({ ...p, [f.conjunto]: o }))} /> +
+
+ )} +
+ +
+ {msg} + +
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..48b9efb --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,41 @@ +import { Component } from "react"; + +// Captura errores de render de sus hijos y muestra un mensaje en vez de +// dejar la pantalla en blanco. Botón para reintentar (re-monta el contenido). +export default class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + static getDerivedStateFromError(error) { + return { error }; + } + componentDidCatch(error, info) { + // Log para diagnóstico en consola del navegador + console.error("[ErrorBoundary]", error, info); + } + reintentar = () => this.setState({ error: null }); + render() { + if (this.state.error) { + return ( +
+
⚠️
+
+ Ocurrió un problema al mostrar esta sección +
+
+ El resto del dashboard sigue funcionando. Puedes reintentar o cambiar de apartado. +
+ +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/IndicadorActualizacion.jsx b/frontend/src/components/IndicadorActualizacion.jsx deleted file mode 100644 index 6da6483..0000000 --- a/frontend/src/components/IndicadorActualizacion.jsx +++ /dev/null @@ -1,97 +0,0 @@ -// src/components/IndicadorActualizacion.jsx -// Indicador (arriba-derecha) con la hora de la última actualización + botón de -// recarga. Al pulsar: re-lee las bases para la vista actual y recachea el resto. -import { useState, useEffect, useCallback } from "react"; -import { api } from "../lib/api"; -import { getVistaActual, emitirRefresco } from "../lib/vistaActual"; - -function formatearHora(iso) { - if (!iso) return "—"; - try { - const d = new Date(iso); - return d.toLocaleString("es-PE", { - day: "2-digit", month: "2-digit", - hour: "2-digit", minute: "2-digit", - }); - } catch { - return "—"; - } -} - -export default function IndicadorActualizacion() { - const [hora, setHora] = useState(null); - const [cargando, setCargando] = useState(false); - - const consultar = useCallback(async () => { - try { - const r = await api.ultimaActualizacion(); - setHora(r?.hora || null); - } catch { /* silencioso */ } - }, []); - - useEffect(() => { - consultar(); - const id = setInterval(consultar, 60000); - return () => clearInterval(id); - }, [consultar]); - - async function recargar() { - if (cargando) return; - setCargando(true); - try { - const r = await api.cacheRefreshManual(getVistaActual()); - setHora(r?.hora || null); - emitirRefresco(); - } catch { /* silencioso */ } - finally { setCargando(false); } - } - - return ( - <> - -
- - {cargando ? "Actualizando…" : formatearHora(hora)} - - -
- - ); -} \ No newline at end of file diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx deleted file mode 100644 index 2ad4c3e..0000000 --- a/frontend/src/components/Modal.jsx +++ /dev/null @@ -1,34 +0,0 @@ -// src/components/Modal.jsx -export default function Modal({ title, onClose, children, width = 900 }) { - return ( -
-
e.stopPropagation()} - style={{ - background: "#fff", borderRadius: 16, width: "100%", maxWidth: width, - maxHeight: "90vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)", - }} - > -
-

{title}

- -
-
{children}
-
-
- ); -} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 39170b9..1cc752e 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,26 +1,48 @@ -// src/components/Sidebar.jsx -import { useAuth } from "../lib/auth"; +import { useState, useEffect } from "react"; +import { api } from "../lib/api"; const MENU = [ - { id: "ocupabilidad", ico: "📊", label: "Ocupabilidad" }, - { id: "ventas", ico: "💰", label: "Ventas" }, - { id: "comisiones", ico: "🧮", label: "Comisiones" }, - { id: "cobranza", ico: "📋", label: "Cobranza" }, - { id: "rentabilidad", ico: "📈", label: "Rentabilidad" }, - { id: "asesores", ico: "👥", label: "Asesores" }, - { id: "usuarios", ico: "🔐", label: "Usuarios" }, + { id: "leads", ico: "🎯", label: "Leads" }, + { id: "otros", ico: "📊", label: "Otros General" }, + { id: "vendedores", ico: "🧑‍💼", label: "Vendedores" }, + { id: "roas", ico: "📈", label: "ROAS" }, ]; -export default function Sidebar({ active, onChange }) { - const { perfil, permisos, logout } = useAuth(); - const visibles = MENU.filter((m) => permisos.includes(m.id)); +export default function Sidebar({ active, onChange, onConfig }) { + const [menuUser, setMenuUser] = useState(false); + const [ultima, setUltima] = useState(""); + const [actualizando, setActualizando] = useState(false); + + const cargarHora = () => api.ultimaActualizacion() + .then((r) => setUltima(r.hora || "")).catch(() => {}); + + useEffect(() => { + cargarHora(); + const id = setInterval(cargarHora, 60 * 1000); // refresca la hora cada minuto + return () => clearInterval(id); + }, []); + + // Actualiza TODO manualmente (como si pasaran los 15 min, pero al instante). + async function actualizarTodo() { + if (actualizando) return; + setActualizando(true); + try { + const r = await api.refrescarTodo(); + if (r && r.hora) setUltima(r.hora); // hora del refresco manual recien hecho + else await cargarHora(); + window.dispatchEvent(new Event("datos-actualizar")); // recarga el apartado abierto + } catch (e) { + // silencioso: no romper la UI si falla + } finally { + setActualizando(false); + } + } return (
+
+ {menuUser && ( +
+ + +
+ )} +
diff --git a/frontend/src/components/UI.jsx b/frontend/src/components/UI.jsx index d7c47ba..a4d5d81 100644 --- a/frontend/src/components/UI.jsx +++ b/frontend/src/components/UI.jsx @@ -1,4 +1,3 @@ -// src/components/UI.jsx export function Loader({ text = "Cargando..." }) { return (
@@ -12,34 +11,15 @@ export function ErrorBox({ msg }) { return
⚠️ {msg}
; } -export function colorSemaforo(pct) { - if (pct >= 80) return { bg: "#d1fae5", fg: "#065f46", fill: "#10b981" }; - if (pct >= 60) return { bg: "#fef9c3", fg: "#854d0e", fill: "#f59e0b" }; - return { bg: "#fee2e2", fg: "#991b1b", fill: "#ef4444" }; -} - -export function ProgressBar({ pct }) { - const c = colorSemaforo(pct); - const w = Math.min(Math.max(pct, 0), 100); - return ( -
- {pct.toFixed(1)}% -
-
-
-
- ); -} - export function Filters({ children }) { return
{children}
; } -export function Select({ label, value, options, onChange, width }) { +export function Select({ label, value, options, onChange }) { return ( -
+
{label && } - onChange(e.target.value)}> {options.map((o) => { const val = typeof o === "object" ? o.value : o; const txt = typeof o === "object" ? o.label : o; diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 95ae9f5..b337e52 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -1,86 +1,150 @@ -// src/lib/api.js -// Cliente para el backend FastAPI. Cambia BASE_URL si el backend corre en otra IP. -const BASE_URL = "http://localhost:8000"; +// Cliente del backend FastAPI de Leads. +// 1) Si existe VITE_API_URL (archivo .env del frontend), se usa esa. +// 2) Si no, se decide por el host desde el que se abrió la página: +// localhost -> backend local http://localhost:8001 +// cualquier otro -> API publicada del servidor +// Así funciona en desarrollo y en el servidor sin necesitar ningún .env. +const API_LOCAL = "http://localhost:8001"; +const API_SERVIDOR = "https://dashboardapi.escueladerefrigeracion.edu.pe"; -async function get(path, params = {}) { +function _baseUrlPorDefecto() { + if (typeof window === "undefined") return API_LOCAL; + const h = window.location.hostname; + if (h === "localhost" || h === "127.0.0.1" || h === "::1") { + return API_LOCAL; + } + const proto = window.location.protocol || "http:"; + return `${proto}//dashboardapi.escueladerefrigeracion.edu.pe`; +} + +const BASE_URL = ( + import.meta.env.VITE_API_URL || _baseUrlPorDefecto() +).replace(/\/+$/, ""); + +// ── Cache en memoria (frontend) con TTL 15 min y "stale-while-revalidate" ── +// Guarda la respuesta de cada GET (por path+params). Al volver a pedir: +// - si hay dato vigente (< 15 min) -> lo devuelve al instante, SIN fetch. +// - si venció -> devuelve el viejo al instante Y refresca en 2º plano. +// Así, al cambiar de apartado y volver, NO se recarga; y a los 15 min se +// actualiza solo sin mostrar loader. Los POST invalidan el cache. +const TTL = 15 * 60 * 1000; // 15 minutos +const _cache = new Map(); // key -> { ts, data } +const _enVuelo = new Map(); // key -> Promise (evita fetch duplicado) + +function _key(path, params) { + return path + "?" + new URLSearchParams(params).toString(); +} + +async function _fetch(path, params) { const qs = new URLSearchParams(params).toString(); - const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; - const r = await fetch(url); + const r = await fetch(`${BASE_URL}${path}${qs ? "?" + qs : ""}`); if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); return r.json(); } -async function postJson(path, body) { +// GET con cache. Devuelve SIEMPRE una promesa que resuelve rápido si hay cache. +function get(path, params = {}) { + const key = _key(path, params); + const hit = _cache.get(key); + const now = Date.now(); + + if (hit) { + // Hay algo cacheado -> devolverlo YA. + if (now - hit.ts >= TTL && !_enVuelo.has(key)) { + // Venció: refrescar en 2º plano (no bloquea, no muestra loader). + const p = _fetch(path, params) + .then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; }) + .finally(() => _enVuelo.delete(key)); + _enVuelo.set(key, p); + } + return Promise.resolve(hit.data); + } + + // No hay cache: si ya hay una petición en vuelo para esta key, reusarla. + if (_enVuelo.has(key)) return _enVuelo.get(key); + + const p = _fetch(path, params) + .then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; }) + .finally(() => _enVuelo.delete(key)); + _enVuelo.set(key, p); + return p; +} + +// Invalida entradas del cache cuyo path empiece con alguno de los prefijos dados. +function invalidar(...prefijos) { + for (const k of _cache.keys()) { + if (prefijos.some((p) => k.startsWith(p))) _cache.delete(k); + } +} + +// POST helper: hace el POST y luego invalida los caches afectados. +async function post(path, body, invalidarPrefijos = []) { const r = await fetch(`${BASE_URL}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); - return r.json(); -} - -async function post(path, params = {}) { - const qs = new URLSearchParams(params).toString(); - const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; - const r = await fetch(url, { method: "POST" }); - if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); - return r.json(); + if (!r.ok) throw new Error(`Error al guardar en ${path}`); + const data = await r.json(); + if (invalidarPrefijos.length) invalidar(...invalidarPrefijos); + return data; } export const api = { - periodoActual: () => get("/api/periodo-actual"), + leadsFiltros: () => get("/api/leads/filtros"), + leads: (ano = "TODOS", mes = "TODOS", dia = "TODOS", programa = "TODOS", sede = "TODOS") => + get("/api/leads", { ano, mes, dia, programa, sede }), + otrosGeneral: (ano = "TODOS", mes = "TODOS", dia = "TODOS") => + get("/api/otros-general", { ano, mes, dia }), + vendedores: (ano = "TODOS", mes = "TODOS", dia = "TODOS") => + get("/api/vendedores", { ano, mes, dia }), + roas: (ano = "TODOS", mes = "TODOS", dia = "TODOS") => + get("/api/roas", { ano, mes, dia }), + conjuntosSinSede: () => get("/api/conjuntos-sin-sede"), + guardarConjuntoSede: (cambios) => + post("/api/conjuntos-sin-sede/guardar", { cambios }, ["/api/conjuntos-sin-sede", "/api/roas"]), + borrarConjuntoSede: (conjunto) => + post("/api/conjuntos-sin-sede/borrar", { conjunto }, ["/api/conjuntos-sin-sede", "/api/roas"]), ultimaActualizacion: () => get("/api/ultima-actualizacion"), - cacheRefresh: () => post("/api/cache/refresh"), - cacheRefreshManual: (clave) => post("/api/cache/refresh-manual", clave ? { clave } : {}), - precargaModulo: (modulo, ano) => post("/api/precarga/modulo", { modulo, ano }), - precargaEstado: (modulo) => get("/api/precarga/estado", { modulo }), - ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => - get("/api/ocupabilidad", { ano, mes, sede, programa }), - ocupabilidadAlumnos: (num_indice) => - get("/api/ocupabilidad/alumnos", { num_indice }), - ventas: (ano, mes, sede = "TODOS", programa = "TODOS", con_overrides = false) => get("/api/ventas", { ano, mes, sede, programa, con_overrides }), - ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") => - get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }), - cobranza: (ano, mes, sectorista = "TODOS", agrupacion = "SEDE", sede_filtro = "TODOS", prog_filtro = "TODOS", venc_hasta = "TODOS") => - get("/api/cobranza", { ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta }), - cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA", prog_filtro = "TODOS", sede_filtro = "TODOS", venc_hasta = "TODOS") => - get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta }), - alumnoEstado: (matricula) => get("/api/alumno/estado", { matricula }), - alumnosPorRetirar: () => get("/api/alumnos/por-retirar"), - alumnoEstadoGuardar: (matricula, estado, observaciones, usuario) => - postJson("/api/alumno/estado/guardar", { matricula, estado, observaciones, usuario }), - rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => - get("/api/rentabilidad", { ano, mes, sede, programa }), - rentabilidadDetalle: (programa, ano, mes) => - get("/api/rentabilidad/detalle", { programa, ano, mes }), - rentabilidadCostos: (programa, ano, mes) => - get("/api/rentabilidad/costos", { programa, ano, mes }), - saldoPendiente: (tipo_cuota) => get("/api/saldo-pendiente", { tipo_cuota }), - asesores: () => get("/api/asesores"), - asesorEstado: (agent_id, online) => - post("/api/asesores/estado", { agent_id, online }), - guardarCostos: (num_indice, inicial, actual) => - postJson("/api/rentabilidad/costos/guardar", { num_indice, inicial, actual }), - clasificarSede: (programas) => - postJson("/api/cobranza/clasificar-sede", { programas }), - clasificarFull: (programas) => - postJson("/api/cobranza/clasificar-full", { programas }), - cobranzaDetalleTodos: (ano, mes, sectorista = "TODOS") => - get("/api/cobranza/detalle-todos", { ano, mes, sectorista }), - usuariosListar: () => get("/api/usuarios"), - usuariosCrear: (email, password, nombre, rol) => - postJson("/api/usuarios/crear", { email, password, nombre, rol }), - usuariosActualizarRol: (id, rol) => postJson("/api/usuarios/rol", { id, rol }), - usuariosEliminar: (id) => postJson("/api/usuarios/eliminar", { id }), - vendedoresManualesListar: (ano, mes) => get("/api/comisiones/vendedores", { ano, mes }), - vendedoresManualesCrear: (datos) => postJson("/api/comisiones/vendedores/crear", datos), - vendedoresManualesEliminar: (id) => postJson("/api/comisiones/vendedores/eliminar", { id }), - vendedoresManualesActualizar: (datos) => postJson("/api/comisiones/vendedores/actualizar", datos), - vendedoresManualesLote: (datos) => postJson("/api/comisiones/vendedores/lote", datos), - comisionesOverrideGuardar: (registros) => postJson("/api/comisiones/override/guardar", { registros }), - comisionesOverrideRestaurar: (num_matricula) => postJson("/api/comisiones/override/restaurar", { num_matricula }), - comisionesDetalleTodos: (ano, mes) => get("/api/comisiones/detalle-todos", { ano, mes }), - comisionesConfig: (ano, mes) => get("/api/comisiones/config", { ano, mes }), - comisionesConfigGuardar: (datos) => postJson("/api/comisiones/config/guardar", datos), + // Actualiza TODO manualmente (como si pasaran los 15 min), pero EN SEGUNDO + // PLANO: NO se toca el cache del cliente mientras el backend trabaja, así el + // usuario sigue viendo los datos viejos sin pantallas de carga durante los + // ~29s. Recien cuando el backend responde OK se limpia el cache del cliente, + // para que a partir de ahi todo muestre los datos nuevos de inmediato. + refrescarTodo: async () => { + const r = await fetch(`${BASE_URL}/api/cache/refresh`, { method: "POST" }); + if (!r.ok) throw new Error("Error al actualizar"); + await r.json(); // espera a que el backend TERMINE de rehacer todo + _cache.clear(); // solo AHORA se invalida el cache del cliente + // Trae la hora de actualizacion FRESCA (sin cache) y la deja cacheada, para + // que el sidebar muestre la hora del refresco manual recien hecho. + const hr = await _fetch("/api/ultima-actualizacion", {}); + _cache.set(_key("/api/ultima-actualizacion", {}), { ts: Date.now(), data: hr }); + return hr; // { hora: "..." } + }, + conjuntosSinPauta: () => get("/api/conjuntos-sin-pauta"), + usoPauta: (pauta, excluir) => get("/api/pauta/uso", { pauta, excluir: excluir || "" }), + leyendaAnuncios: () => get("/api/leyenda-anuncios"), + programasDisponibles: () => get("/api/programas-disponibles"), + programasOcultos: () => get("/api/programas-ocultos"), + encenderProgramas: (num_indices) => + post("/api/programas-ocultos/encender", { num_indices }, + ["/api/programas-ocultos", "/api/programas-disponibles", "/api/leads"]), + guardarLeyenda: (cambios) => + post("/api/leyenda-anuncios/guardar", { cambios }, ["/api/leyenda-anuncios", "/api/roas"]), + guardarPauta: (num_indice, pauta, conjunto, contar) => + post("/api/curso/guardar-pauta", { num_indice, pauta, conjunto, contar }, + ["/api/leads", "/api/conjuntos-sin-pauta", "/api/roas"]), + alertas: () => get("/api/leads/alertas"), + // Leyenda (campanias de Supabase). Al agregar/borrar, invalida TODOS los + // apartados que dependen de las leyendas para que el cambio se vea al instante. + campanias: () => get("/api/campanias"), + agregarCampania: (datos) => + post("/api/campanias/agregar", datos, + ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]), + editarCampania: (id, datos) => + post("/api/campanias/editar", { id, ...datos }, + ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]), + borrarCampania: (id) => + post("/api/campanias/borrar", { id }, + ["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]), }; diff --git a/frontend/src/lib/auth.jsx b/frontend/src/lib/auth.jsx deleted file mode 100644 index 81b7a79..0000000 --- a/frontend/src/lib/auth.jsx +++ /dev/null @@ -1,144 +0,0 @@ -// src/lib/auth.jsx -import { createContext, useContext, useEffect, useState, useCallback } from "react"; -import { supabase } from "./supabase"; - -const AuthContext = createContext(null); - -export function AuthProvider({ children }) { - const [session, setSession] = useState(null); - const [perfil, setPerfil] = useState(null); - const [permisos, setPermisos] = useState([]); - const [cargando, setCargando] = useState(true); - - const cargarPerfil = useCallback(async (userId) => { - try { - const { data: perf, error: e1 } = await supabase - .from("perfiles") - .select("nombre, rol, activo") - .eq("id", userId) - .single(); - if (e1 || !perf || perf.activo === false) { - setPerfil(null); - setPermisos([]); - return false; - } - const { data: perms } = await supabase - .from("permisos") - .select("modulo, puede_ver") - .eq("rol", perf.rol); - const modulos = (perms || []) - .filter((p) => p.puede_ver) - .map((p) => p.modulo); - setPerfil({ nombre: perf.nombre, rol: perf.rol }); - setPermisos(modulos); - return true; - } catch (e) { - console.error("[auth] cargarPerfil falló:", e); - setPerfil(null); - setPermisos([]); - return false; - } - }, []); - - useEffect(() => { - let activo = true; - let resuelto = false; - - const terminar = () => { - if (activo) { setCargando(false); resuelto = true; sessionStorage.removeItem("auth_reload"); } - }; - - // Fuente principal: onAuthStateChange dispara INITIAL_SESSION al arrancar - // con la sesión persistida (renueva token solo). Maneja login/logout en vivo. - const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => { - try { - setSession(s); - if (s?.user) await cargarPerfil(s.user.id); - else { setPerfil(null); setPermisos([]); } - } catch (e) { - console.error("[auth] onAuthStateChange falló:", e); - } finally { - terminar(); - } - }); - - // Respaldo: si el listener no disparó en 6s, intenta getSession una vez. - // Si trae sesión la usa; si no, NO fuerza login (deja que el listener resuelva). - const tRespaldo = setTimeout(async () => { - if (resuelto || !activo) return; - try { - const { data } = await supabase.auth.getSession(); - const s = data?.session || null; - if (s?.user && activo) { - setSession(s); - await cargarPerfil(s.user.id); - } - } catch (e) { - console.error("[auth] getSession (respaldo) falló:", e); - } finally { - terminar(); - } - }, 6000); - - // Si a los 8s sigue sin resolver, recarga la página UNA vez (F5 automático) - // en vez de mostrar el login pegado. sessionStorage evita bucle. - const tSeguridad = setTimeout(() => { - if (!activo || resuelto) return; - if (!sessionStorage.getItem("auth_reload")) { - sessionStorage.setItem("auth_reload", "1"); - window.location.reload(); - } else { - setCargando(false); - } - }, 8000); - - return () => { - activo = false; - clearTimeout(tRespaldo); - clearTimeout(tSeguridad); - sub?.subscription?.unsubscribe(); - }; - }, [cargarPerfil]); - - const login = async (email, password) => { - const { data, error } = await supabase.auth.signInWithPassword({ email, password }); - if (error) return { ok: false, msg: traducirError(error.message) }; - const ok = await cargarPerfil(data.user.id); - if (!ok) { - await supabase.auth.signOut(); - return { ok: false, msg: "Tu usuario no tiene un perfil asignado. Contacta al administrador." }; - } - return { ok: true }; - }; - - const logout = async () => { - await supabase.auth.signOut(); - setPerfil(null); - setPermisos([]); - }; - - const value = { - session, - perfil, - permisos, - cargando, - login, - logout, - autenticado: !!session && !!perfil, - puedeVer: (modulo) => permisos.includes(modulo), - }; - - return {children}; -} - -export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider"); - return ctx; -} - -function traducirError(msg) { - if (/invalid login credentials/i.test(msg)) return "Correo o contraseña incorrectos."; - if (/email not confirmed/i.test(msg)) return "El correo no está confirmado."; - return msg || "Error al iniciar sesión."; -} diff --git a/frontend/src/lib/supabase.js b/frontend/src/lib/supabase.js deleted file mode 100644 index dc68737..0000000 --- a/frontend/src/lib/supabase.js +++ /dev/null @@ -1,15 +0,0 @@ -// src/lib/supabase.js -// Cliente de Supabase para autenticación (login). -// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role). -import { createClient } from "@supabase/supabase-js"; - -const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || ""; -const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || ""; - -export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - auth: { - persistSession: true, - autoRefreshToken: true, - detectSessionInUrl: true, - }, -}); \ No newline at end of file diff --git a/frontend/src/lib/useColumnasAjustables.jsx b/frontend/src/lib/useColumnasAjustables.jsx index 88732ee..e224f7c 100644 --- a/frontend/src/lib/useColumnasAjustables.jsx +++ b/frontend/src/lib/useColumnasAjustables.jsx @@ -1,11 +1,10 @@ // src/lib/useColumnasAjustables.jsx -// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla. +// Hook para columnas redimensionables (arrastrar el borde, estilo Excel). import { useState, useEffect } from "react"; export function useColumnasAjustables(anchosIniciales) { const [anchos, setAnchos] = useState(anchosIniciales); - // Si cambia el número de columnas (ej. Cobranza Sede↔Programa), reiniciar anchos useEffect(() => { if (anchos.length !== anchosIniciales.length) { setAnchos(anchosIniciales); @@ -29,7 +28,6 @@ export function useColumnasAjustables(anchosIniciales) { document.addEventListener("mouseup", onUp); } - // Devuelve los elementos para el de la tabla function ColGroup() { return ( @@ -38,7 +36,6 @@ export function useColumnasAjustables(anchosIniciales) { ); } - // Devuelve el divisor arrastrable para poner dentro de cada function Resizer({ index }) { return ( a + b, 0); - - // width:100% → llena el contenedor desde el inicio (sin huecos a la derecha). - // minWidth:anchoTotal → si las columnas crecen más que el contenedor, aparece scroll. - // tableLayout:fixed + colgroup → encabezados y celdas alineados. const tableProps = { style: { tableLayout: "fixed", width: "100%", minWidth: anchoTotal }, }; diff --git a/frontend/src/lib/vistaActual.js b/frontend/src/lib/vistaActual.js deleted file mode 100644 index ca9f75e..0000000 --- a/frontend/src/lib/vistaActual.js +++ /dev/null @@ -1,23 +0,0 @@ -// src/lib/vistaActual.js -// Registro de la "vista actual" (clave de caché del backend) + bus de eventos -// para que la página visible se recargue tras el refresco manual. -let _clave = null; - -export function setVistaActual(clave) { - _clave = clave || null; -} - -export function getVistaActual() { - return _clave; -} - -const EVENTO = "datos-refrescados"; - -export function emitirRefresco() { - window.dispatchEvent(new Event(EVENTO)); -} - -export function onRefrescar(cb) { - window.addEventListener(EVENTO, cb); - return () => window.removeEventListener(EVENTO, cb); -} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 8fd46bf..5733886 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,13 +1,10 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; -import { AuthProvider } from "./lib/auth"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root")).render( - - - + ); diff --git a/frontend/src/pages/Asesores.jsx b/frontend/src/pages/Asesores.jsx deleted file mode 100644 index d71ec72..0000000 --- a/frontend/src/pages/Asesores.jsx +++ /dev/null @@ -1,105 +0,0 @@ -// src/pages/Asesores.jsx -import { useState, useEffect, useCallback } from "react"; -import { api } from "../lib/api"; -import { Loader, ErrorBox } from "../components/UI"; - -const ESTADO_INFO = { - online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" }, - busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" }, - offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" }, -}; - -export default function Asesores() { - const [agentes, setAgentes] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [cambiando, setCambiando] = useState(null); - - const cargar = useCallback(() => { - setLoading(true); setError(null); - api.asesores() - .then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); }) - .catch((e)=>{ setError(e.message); setLoading(false); }); - }, []); - - useEffect(() => { cargar(); }, [cargar]); - - async function toggle(ag) { - const online = ag.availability_status !== "online"; - setCambiando(ag.id); - try { - await api.asesorEstado(ag.id, online); - setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a)); - } catch (e) { /* noop */ } - setCambiando(null); - } - - async function todos(online) { - setCambiando("all"); - for (const ag of agentes) { - try { await api.asesorEstado(ag.id, online); } catch (e) {} - } - setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"}))); - setCambiando(null); - } - - const total = agentes.length; - const enLinea = agentes.filter((a)=>a.availability_status==="online").length; - const ocupados = agentes.filter((a)=>a.availability_status==="busy").length; - const offline = total - enLinea - ocupados; - - return ( -
-

👥 Asesores

- - {loading ? : - error ? : - <> -
-
👥
Total Asesores
{total}
-
🟢
En Línea
{enLinea}
-
🟡
Ocupados
{ocupados}
-
🔴
Fuera de Línea
{offline}
-
- -
- - - -
- -
- - - - {agentes.map((ag)=>{ - const est = ESTADO_INFO[ag.availability_status] || ESTADO_INFO.offline; - const isOnline = ag.availability_status==="online"; - return ( - - - - - - - - ); - })} - -
ASESOREMAILROLESTADOACCIÓN
{ag.name || ag.available_name || "—"}{ag.email || "—"}{ag.role || "agent"} - ● {est.txt} - - -
-
- } -
- ); -} diff --git a/frontend/src/pages/Cobranza.jsx b/frontend/src/pages/Cobranza.jsx deleted file mode 100644 index 62ee409..0000000 --- a/frontend/src/pages/Cobranza.jsx +++ /dev/null @@ -1,1313 +0,0 @@ -// src/pages/Cobranza.jsx -import { useState, useEffect, useMemo, useRef, Fragment } from "react"; -import { api } from "../lib/api"; -import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI"; -import Modal from "../components/Modal"; -import { useColumnasAjustables } from "../lib/useColumnasAjustables"; -import { setVistaActual, onRefrescar } from "../lib/vistaActual"; - -const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; -const ANOS = [2024, 2025, 2026]; -const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"ASESOR",label:"Por Asesor"}]; -const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"]; -const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"]; -const PROGRAMAS_FILTRO = ["TODOS","TEAC","TERC","SEMINARIOS","OTROS"]; - -// Colores por cartera — diferenciados y legibles (sin azul, reservado para TOTAL) -// ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave -const BG_ANT = "#fef3c7", BG_MES = "#d1fae5", BG_TOT = "#ede9fe"; -// Versiones más oscuras para hover de cada cartera -const HV_ANT = "#fde68a", HV_MES = "#a7f3d0", HV_TOT = "#ddd6fe"; - -function toMonto(s){ const n = parseFloat(String(s).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } -function toPct(s){ const n = parseFloat(String(s).replace("%","").trim()); return isNaN(n)?0:n; } -// Formato moneda SIN decimales (solo visualización) -function fmtMoneda(s){ - if (s == null) return ""; - if (!String(s).includes("S/")) return s; // no es monto, devolver tal cual - const n = toMonto(s); - return "S/ " + n.toLocaleString("es-PE", { maximumFractionDigits: 0 }); -} - -function MiniBar({ valStr }) { - if (!valStr || !String(valStr).includes("%")) return —; - const pct = toPct(valStr); - const c = colorSemaforo(pct); - const w = Math.min(Math.max(pct,0),100); - return ( -
- {pct.toFixed(0)}% -
-
-
-
- ); -} - -export default function Cobranza() { - const hoy = new Date(); - const [ano, setAno] = useState(hoy.getFullYear()); - const [mes, setMes] = useState(hoy.getMonth() + 1); - const [agrupacion, setAgrupacion] = useState("SEDE"); - const [sectorista, setSectorista] = useState("TODOS"); - const [sede, setSede] = useState("TODOS"); - const [frec, setFrec] = useState("TODOS"); - // Filtros extra para la vista "Por Sede" (usan JSON REPLACE_META) - const [sedeFiltro, setSedeFiltro] = useState("TODOS"); - const [progFiltro, setProgFiltro] = useState("TODOS"); - const [filas, setFilas] = useState([]); - const [sectoristas, setSectoristas] = useState(["TODOS"]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [modal, setModal] = useState(null); - const [sedeMap, setSedeMap] = useState({}); // programa -> sede (sede.json) - const [exportando, setExportando] = useState(false); - const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta) - const [buscadorAbierto, setBuscadorAbierto] = useState(false); - const [calAbierto, setCalAbierto] = useState(false); - const [vencHasta, setVencHasta] = useState("TODOS"); // filtro F. Vencimiento (dd/mm/yyyy) - // Expansión sede -> programas (solo en agrupación SEDE) - const [filasPrograma, setFilasPrograma] = useState([]); // vista "Por Programa" (para expandir) - const [sedeMapPrograma, setSedeMapPrograma] = useState({}); // programa -> sede - const [expandidas, setExpandidas] = useState(new Set()); // sedes abiertas - - const esPrograma = agrupacion === "PROGRAMA"; - // índices según agrupación - const IDX = esPrograma - ? { saldo:13, ant:[4,5,6], mes:[7,8,9], tot:[10,11,12] } - : { saldo:10, ant:[1,2,3], mes:[4,5,6], tot:[7,8,9] }; - - useEffect(() => { - let activo = true; - setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}|${sedeFiltro}|${progFiltro}|${vencHasta}`); - const cargar = () => { - setLoading(true); setError(null); - api.cobranza(ano, mes, sectorista, agrupacion, sedeFiltro, progFiltro, vencHasta) - .then((res) => { - if (!activo) return; - setFilas(res.filas || []); - setSectoristas(res.sectoristas || ["TODOS"]); - setLoading(false); - }) - .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); - }; - cargar(); - const off = onRefrescar(cargar); - return () => { activo = false; off(); }; - }, [ano, mes, agrupacion, sectorista, sedeFiltro, progFiltro, vencHasta]); - - // Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada - useEffect(() => { - let activo = true; - api.cobranzaDetalleTodos(ano, mes, sectorista) - .then((res) => { if (activo) setAlumnos(res.alumnos || []); }) - .catch(() => { if (activo) setAlumnos([]); }); - return () => { activo = false; }; - }, [ano, mes, sectorista]); - - // Clasificación (sede/filtro) de los programas de los alumnos, para filtrar - // las tarjetas "Saldo por Retirar" y el buscador por Sede/Programa (REPLACE_META). - const [progClasif, setProgClasif] = useState({}); - useEffect(() => { - const nombres = [...new Set(alumnos.map((a)=>String(a.programa)).filter(Boolean))]; - if (!nombres.length) { setProgClasif({}); return; } - let activo = true; - api.clasificarFull(nombres) - .then((r)=>{ if(activo) setProgClasif(r.mapa || {}); }) - .catch(()=>{ if(activo) setProgClasif({}); }); - return () => { activo = false; }; - }, [alumnos]); - - // Cargar matrículas POR RETIRAR (para el "Saldo por Retirar" de las tarjetas) - const [porRetirar, setPorRetirar] = useState(new Set()); - useEffect(() => { - api.alumnosPorRetirar() - .then((r) => setPorRetirar(new Set((r.matriculas || []).map(String)))) - .catch(() => {}); - }, [ano, mes]); - - // En agrupación SEDE: cargar la vista "Por Programa" (para poder expandir cada sede - // y ver sus programas con FREC/N° CUOTA/VENCIMIENTO). Se filtra por sede al expandir. - useEffect(() => { - if (agrupacion !== "SEDE") { setFilasPrograma([]); setExpandidas(new Set()); return; } - let activo = true; - api.cobranza(ano, mes, sectorista, "PROGRAMA", sedeFiltro, progFiltro, vencHasta) - .then((res) => { - if (!activo) return; - const fp = (res.filas || []).filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"); - setFilasPrograma(fp); - // clasificar cada programa a su SEDE y FILTRO (TEAC/TERC/...) via REPLACE_META - const nombres = fp.map((f)=>String(f[0])); - if (nombres.length) { - api.clasificarFull(nombres) - .then((r)=>{ if(activo) setSedeMapPrograma(r.mapa || {}); }) - .catch(()=>{ if(activo) setSedeMapPrograma({}); }); - } - }) - .catch(() => { if (activo) setFilasPrograma([]); }); - return () => { activo = false; }; - }, [agrupacion, ano, mes, sectorista, sedeFiltro, progFiltro, vencHasta]); - - // Cargar clasificación de sede (sede.json) para los programas visibles - useEffect(() => { - if (!esPrograma) return; - const nombres = filas - .filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL") - .map((f) => String(f[0])); - if (nombres.length === 0) return; - let activo = true; - api.clasificarSede(nombres) - .then((res) => { if (activo) setSedeMap(res.mapa || {}); }) - .catch(() => { if (activo) setSedeMap({}); }); - return () => { activo = false; }; - }, [filas, esPrograma]); - - // Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json - const datosCalc = useMemo(() => { - let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); - if (esPrograma) { - if (sede !== "TODOS") { - arr = arr.filter((f) => { - const s = (sedeMap[String(f[0])] || "LIMA").toUpperCase(); - return s === sede.toUpperCase(); - }); - } - if (frec !== "TODOS") arr = arr.filter((f) => String(f[1]).toUpperCase() === frec); - return arr; - } - - // Vista POR SEDE / POR ASESOR: el backend ya devuelve las filas filtradas por - // Sede y Programa (JSON REPLACE_META), así que se muestran tal cual. - return arr; - }, [filas, esPrograma, sede, frec, sedeMap]); - - // Mientras carga, mostrar la ÚLTIMA vista renderizada (congelada) hasta que llegue - // la respuesta nueva. Así al filtrar solo se opaca lo anterior, sin recalcular a 0. - const datosViewRef = useRef([]); - if (!loading) datosViewRef.current = datosCalc; - const datos = loading ? datosViewRef.current : datosCalc; - - // KPIs: 3 tarjetas (recalculadas con datos filtrados) - const kpis = useMemo(() => { - let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0; - datos.forEach((f) => { - cAnt += toMonto(f[IDX.ant[0]]); obAnt += toMonto(f[IDX.ant[1]]); - cMes += toMonto(f[IDX.mes[0]]); obMes += toMonto(f[IDX.mes[1]]); - cTot += toMonto(f[IDX.tot[0]]); obTot += toMonto(f[IDX.tot[1]]); - }); - return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot }; - }, [datos, IDX]); - - const esAsesor = agrupacion === "ASESOR"; - // Las 3 columnas extra (FREC/N°CUOTA/VENC) SOLO se muestran en SEDE cuando hay - // al menos una sede expandida, o siempre en PROGRAMA. En ASESOR nunca. - const conColsExtra = esPrograma || (!esAsesor && expandidas.size > 0); - const headers = !conColsExtra - ? ["GRUPO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."] - : [(esPrograma?"PROGRAMA":"GRUPO"),"FREC.","N° CUOTA","VENCIMIENTO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."]; - - // Anchos para columnas redimensionables (1ª columna más ancha, resto estándar) - const anchosCob = headers.map((h, i) => i === 0 ? (conColsExtra ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120)); - const cols = useColumnasAjustables(anchosCob); - - // Índices de cartera según el layout de columnas de la tabla actual. - const LAY = conColsExtra - ? { saldo:13, ant:[4,5,6], mes:[7,8,9], tot:[10,11,12] } - : { saldo:10, ant:[1,2,3], mes:[4,5,6], tot:[7,8,9] }; - - function cartClase(ci) { - if (LAY.ant.includes(ci)) return "cart-ant"; - if (LAY.mes.includes(ci)) return "cart-mes"; - if (LAY.tot.includes(ci)) return "cart-tot"; - return ""; - } - - // Columnas fijas (sticky) de la tabla principal: - // - sin expandir: solo GRUPO (1) - // - expandido: GRUPO, FREC., N° CUOTA, VENCIMIENTO (4) - const FIJAS_MAIN = conColsExtra ? 4 : 1; - const stickyMain = (ci, { esHeader = false, bg = "#fff" } = {}) => { - if (ci >= FIJAS_MAIN) return {}; - let left = 0; - for (let k = 0; k < ci; k++) left += (cols.anchos[k] || 0); - const ultima = ci === FIJAS_MAIN - 1; - const lineaColor = esHeader ? "#475569" : "#e2e8f0"; - return { - position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg, - borderRight: "none", - boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""), - }; - }; - - // Normaliza una fila de SEDE al layout activo. Si conColsExtra (hay expansión), - // inserta FREC/N°CUOTA/VENC vacías tras el grupo. Si no, la deja tal cual. - const filaLayout = (f) => { - if (esPrograma) return f; // ya viene con las 15 columnas - if (esAsesor || !conColsExtra) return f; // layout simple (12 cols) - // SEDE con expansión: [grupo, "","","", cta_ant,cob_ant,%ant, cta_mes,... saldo] - return [f[0], "", "", "", f[1],f[2],f[3], f[4],f[5],f[6], f[7],f[8],f[9], f[10]]; - }; - - // Expandir/contraer una sede (agrupación SEDE) - const toggleSede = (s) => { - const key = String(s).toUpperCase(); - setExpandidas((prev) => { - const n = new Set(prev); - n.has(key) ? n.delete(key) : n.add(key); - return n; - }); - }; - - // sede/filtro de un programa (desde REPLACE_META). sedeMapPrograma[nombre]={sede,filtro} - const infoProg = (nombre) => sedeMapPrograma[String(nombre)] || {sede:"LIMA", filtro:"OTROS"}; - - // Programas (filas de la vista Por Programa) que pertenecen a una sede, - // aplicando el filtro Programa (TEAC/TERC/...) si está activo. - const programasDeSede = (s) => { - const key = String(s).toUpperCase(); - return filasPrograma.filter((fp) => { - const ip = infoProg(fp[0]); - if (String(ip.sede).toUpperCase() !== key) return false; - if (progFiltro !== "TODOS" && String(ip.filtro).toUpperCase() !== progFiltro) return false; - return true; - }); - }; - - // Alumnos visibles según filtros activos (mismo dataset que Ver / Estado de Cuenta) - const alumnosFiltrados = useMemo(() => { - let arr = alumnos; - if (esPrograma) { - if (sede !== "TODOS") arr = arr.filter((a) => String(a.sede).toUpperCase() === sede.toUpperCase()); - if (frec !== "TODOS") arr = arr.filter((a) => String(a.frecuencia).toUpperCase() === frec); - } - // Filtros Sede / Programa (REPLACE_META) — aplican en Por Sede y Por Asesor - if (sedeFiltro !== "TODOS") { - arr = arr.filter((a) => { - const s = (progClasif[String(a.programa)]?.sede || a.sede || "LIMA"); - return String(s).toUpperCase() === sedeFiltro.toUpperCase(); - }); - } - if (progFiltro !== "TODOS") { - arr = arr.filter((a) => String(progClasif[String(a.programa)]?.filtro || "").toUpperCase() === progFiltro); - } - // Filtro por F. Vencimiento (<=). Cuotas sin fecha se incluyen siempre. - if (vencHasta !== "TODOS") { - const toTs = (s) => { - const [dd,mm,yy] = String(s).split("/").map(Number); - return (dd&&mm&&yy) ? new Date(yy,mm-1,dd).getTime() : null; - }; - const corte = toTs(vencHasta); - if (corte != null) arr = arr.filter((a) => { - const t = toTs(a.fch_venc); - return t == null || t <= corte; // sin fecha => siempre - }); - } - return arr; - }, [alumnos, esPrograma, sede, frec, sedeFiltro, progFiltro, progClasif, vencHasta]); - - function grupoDe(a) { - // El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa). - return a.programa; - } - - // Saldo por retirar (ANT/MES/TOTAL) de los alumnos marcados POR RETIRAR (según filtros) - const retiroKpi = useMemo(() => { - let sAnt=0, sMes=0; - alumnosFiltrados.forEach((a) => { - if (!porRetirar.has(String(a.matricula))) return; - sAnt += ((Number(a.cta_ant)||0) - (Number(a.cob_ant)||0)); - sMes += ((Number(a.cta_cur)||0) - (Number(a.cob_cur)||0)); - }); - return { sAnt, sMes, sTot: sAnt + sMes }; - }, [alumnosFiltrados, porRetirar]); - - // Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas) - async function exportarDetalle() { - setExportando(true); - try { - const base = alumnosFiltrados; - if (base.length === 0) { alert("No hay alumnos para exportar con los filtros actuales."); return; } - - // Fecha como TEXTO tal cual (dd/mm/yyyy). No convertir a Date: al escribir - // un Date en Excel se corre un día por zona horaria (UTC-5). - const toDate = (v) => { - if (!v || v === "-") return ""; - return String(v).trim(); - }; - const pct = (cob, cta) => cta > 0 ? cob/cta : null; - - const XLSX = await import("xlsx-js-style"); - const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; - - // Colores suaves por cartera (igual que el dashboard): ANT ámbar, MES verde, TOT lila - const CART = { ant:"FEF3C7", mes:"D1FAE5", tot:"EDE9FE" }; - const cartFill = (c) => { - if ([5,6,7].includes(c)) return CART.ant; - if ([8,9,10].includes(c)) return CART.mes; - if ([11,12,13].includes(c)) return CART.tot; - return null; - }; - const headerStyle = { font:{bold:true,color:{rgb:"FFFFFF"},sz:11}, fill:{fgColor:{rgb:"1E40AF"}}, alignment:{horizontal:"center",vertical:"center"} }; - const moneyFmt = '"S/" #,##0'; - const pctFmt = '0%'; - const dateFmt = "dd/mm/yyyy"; - const cellStyle = (c) => { const f = cartFill(c); return f ? { fill:{fgColor:{rgb:f}} } : undefined; }; - - const ws = {}; - COLS.forEach((h,c)=>{ ws[XLSX.utils.encode_cell({r:0,c})] = {v:h,t:"s",s:headerStyle}; }); - - base.forEach((a, ri) => { - const r = ri + 1; - const vals = [ - grupoDe(a), a.matricula, a.alumno, a.num_cuota, toDate(a.fch_venc), - a.cta_ant, a.cob_ant, pct(a.cob_ant,a.cta_ant), - a.cta_cur, a.cob_cur, pct(a.cob_cur,a.cta_cur), - a.cta_tot, a.cob_tot, pct(a.cob_tot,a.cta_tot), - a.saldo, - ]; - vals.forEach((v, c) => { - const ref = XLSX.utils.encode_cell({r,c}); - const st = cellStyle(c); - if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) }; - else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) }; - else ws[ref] = { v: v ?? "", t:"s" }; // F. VENC. (col 4) va como texto tal cual - }); - }); - - // TOTAL GENERAL (suma de montos) - const rTot = base.length + 1; - const totalStyle = { font:{bold:true}, fill:{fgColor:{rgb:"EFF6FF"}} }; - const sum = (k) => base.reduce((s,a)=>s+(Number(a[k])||0),0); - const totVals = { 5:sum("cta_ant"),6:sum("cob_ant"),8:sum("cta_cur"),9:sum("cob_cur"),11:sum("cta_tot"),12:sum("cob_tot"),14:sum("saldo") }; - COLS.forEach((col,c)=>{ - const ref = XLSX.utils.encode_cell({r:rTot,c}); - if (c===0) ws[ref] = {v:"TOTAL GENERAL",t:"s",s:totalStyle}; - else if (totVals[c]!==undefined) ws[ref] = {v:totVals[c],t:"n",z:moneyFmt,s:totalStyle}; - else ws[ref] = {v:"",t:"s",s:totalStyle}; - }); - - ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rTot,c:COLS.length-1} }); - ws["!cols"] = [{wch:26},{wch:12},{wch:28},{wch:9},{wch:12},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:13}]; - - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, "Cobranza"); - const sufijo = agrupacion + (esPrograma && sede!=="TODOS" ? `_${sede}` : "") + (sectorista!=="TODOS" ? `_${sectorista}` : ""); - XLSX.writeFile(wb, `cobranza_${sufijo}_${ano}_${mes}.xlsx`); - } finally { - setExportando(false); - } - } - - return ( -
-

📋 Cobranza

- - - ({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} /> - - {esPrograma && } - {(agrupacion==="SEDE"||agrupacion==="ASESOR") && {setProgFiltro(v);setExpandidas(new Set());}} />} -
-
- - {calAbierto && { setVencHasta(f); setCalAbierto(false); }} - onClose={()=>setCalAbierto(false)} />} -
- - -
-
- - {loading && !datos.length ? : - error ? : -
- - -
- - - {headers.map((h, i)=>{ - const st = stickyMain(i, {esHeader:true, bg:"#334155"}); - const fija = i < FIJAS_MAIN; - // Header siempre pegado arriba (top:0); columnas fijas además a la izquierda. - const stH = fija - ? {...st, top:0, zIndex:30} - : {position:"sticky", top:0, zIndex:22, backgroundColor:"#334155"}; - return ; - })} - - {datos.map((fRaw, ri) => { - const f = filaLayout(fRaw); // layout normalizado (15 o 12 cols) - const nCols = headers.length - 1; // sin OPC - const sedeKey = String(f[0]).toUpperCase(); - const abiertaSede = !esPrograma && !esAsesor && expandidas.has(sedeKey); - - const renderCeldas = (fila, {indent=false, bg="#fff"}={}) => { - const av = !String(fila[LAY.ant[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.ant[2]]).trim()); - const mv = !String(fila[LAY.mes[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.mes[2]]).trim()); - const tv = !String(fila[LAY.tot[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.tot[2]]).trim()); - return fila.slice(0, nCols).map((v, ci) => { - const isPct = LAY.ant[2]===ci || LAY.mes[2]===ci || LAY.tot[2]===ci; - let val = v; - if ((ci===LAY.ant[0]||ci===LAY.ant[1]||ci===LAY.ant[2]) && av) val = "—"; - if ((ci===LAY.mes[0]||ci===LAY.mes[1]||ci===LAY.mes[2]) && mv) val = "—"; - if ((ci===LAY.tot[0]||ci===LAY.tot[1]||ci===LAY.tot[2]) && tv) val = "—"; - const cartCls = cartClase(ci); - const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:""); - const puedeExpandir = (!esPrograma && !esAsesor && ci===0 && !indent); - const abierta = puedeExpandir && expandidas.has(String(fila[0]).toUpperCase()); - return ( - - ); - }); - }; - - return ( - - - {renderCeldas(f)} - - - {abiertaSede && programasDeSede(fRaw[0]).map((fp, pi) => ( - - {renderCeldas(fp, {indent:true, bg:"#f8fafc"})} - - - ))} - - ); - })} - {datos.length > 0 && (() => { - // Fila TOTAL GENERAL — suma de los datos CRUDOS (IDX), render en layout LAY. - const suma = (idx)=> datos.reduce((s,f)=>s+toMonto(f[idx]),0); - const T = { cAnt:suma(IDX.ant[0]), oAnt:suma(IDX.ant[1]), - cMes:suma(IDX.mes[0]), oMes:suma(IDX.mes[1]), - cTot:suma(IDX.tot[0]), oTot:suma(IDX.tot[1]), - saldo:suma(IDX.saldo) }; - const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : ""; - const money = (n)=> "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); - // valor por columna del layout LAY - const cell = (ci) => { - if (ci===0) return "TOTAL GENERAL"; - if (ci===LAY.ant[0]) return money(T.cAnt); - if (ci===LAY.ant[1]) return money(T.oAnt); - if (ci===LAY.ant[2]) return ratio(T.oAnt,T.cAnt); - if (ci===LAY.mes[0]) return money(T.cMes); - if (ci===LAY.mes[1]) return money(T.oMes); - if (ci===LAY.mes[2]) return ratio(T.oMes,T.cMes); - if (ci===LAY.tot[0]) return money(T.cTot); - if (ci===LAY.tot[1]) return money(T.oTot); - if (ci===LAY.tot[2]) return ratio(T.oTot,T.cTot); - if (ci===LAY.saldo) return money(T.saldo); - return ""; - }; - return ( - - {Array.from({length: headers.length-1}).map((_,ci)=>{ - const isPct = LAY.ant[2]===ci || LAY.mes[2]===ci || LAY.tot[2]===ci; - const val = cell(ci); - const fija = ci < FIJAS_MAIN; - let left = 0; for (let k=0;k - {isPct ? : val} - - ); - })} - - - ); - })()} - -
{h}
- {puedeExpandir && ( - toggleSede(String(fila[0]))} - style={{cursor:"pointer",color:"#1e40af",fontWeight:800, - marginRight:8,userSelect:"none",fontSize:14, - display:"inline-block",width:12,textAlign:"center", - transition:"transform .15s ease"}}> - {abierta ? "−" : "+"} - - )} - {isPct ? (val==="—" ? — : ) : fmtMoneda(val)} -
- -
- -
—
-
-
} - - {modal && ( - setModal(null)} /> - )} - - {buscadorAbierto && ( - setBuscadorAbierto(false)} - /> - )} -
- ); -} - -// Mini-calendario. Muestra el mes/año del filtro, fijo. Al elegir un día filtra -// por F. Vencimiento (<= ese día). Para ver todo, elegir el último día del mes. -function MiniCalendario({ ano, mes, sel, onSelect, onClose }) { - const hoy = new Date(); - const mIdx = (mes - 1); // el filtro usa mes 1..12; Date usa 0..11 - const ref = useRef(null); - - useEffect(() => { - const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); }; - document.addEventListener("mousedown", fuera); - return () => document.removeEventListener("mousedown", fuera); - }, [onClose]); - - const MESN = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"]; - const DIAS = ["Lu","Ma","Mi","Ju","Vi","Sá","Do"]; - const primerDia = new Date(ano, mIdx, 1).getDay(); // 0=Dom - const offset = (primerDia + 6) % 7; // lunes primero - const diasMes = new Date(ano, mIdx + 1, 0).getDate(); - const celdas = [...Array(offset).fill(null), ...Array.from({length:diasMes},(_,i)=>i+1)]; - // Día seleccionado (si sel es dd/mm/yyyy de este mes/año) - const selDia = (() => { - if (!sel || sel === "TODOS") return null; - const [dd,mm,yy] = String(sel).split("/").map(Number); - return (mm===mes && yy===ano) ? dd : null; - })(); - const fmt = (d) => `${String(d).padStart(2,"0")}/${String(mes).padStart(2,"0")}/${ano}`; - const mismaFecha = (d) => d && selDia===d; - const esHoy = (d) => d && d===hoy.getDate() && mIdx===hoy.getMonth() && ano===hoy.getFullYear(); - - return ( -
- {/* Encabezado (fijo, sin navegación) */} -
- {MESN[mIdx]} {ano} -
- {/* Días de la semana */} -
- {DIAS.map((d)=>
{d}
)} -
- {/* Celdas */} -
- {celdas.map((d,i)=>( -
d && onSelect(fmt(d))} - style={{textAlign:"center",padding:"7px 0",fontSize:13,borderRadius:8, - cursor:d?"pointer":"default",fontWeight:mismaFecha(d)?800:500, - color:mismaFecha(d)?"#fff":(esHoy(d)?"#1e40af":"#334155"), - background:mismaFecha(d)?"#1e40af":(esHoy(d)?"#eff6ff":"transparent"), - border:esHoy(d)&&!mismaFecha(d)?"1px solid #bfdbfe":"1px solid transparent", - transition:"background .12s"}} - onMouseEnter={(e)=>{ if(d&&!mismaFecha(d)) e.currentTarget.style.background="#f1f5f9"; }} - onMouseLeave={(e)=>{ if(d&&!mismaFecha(d)) e.currentTarget.style.background=esHoy(d)?"#eff6ff":"transparent"; }}> - {d||""} -
- ))} -
- {/* Pie: limpiar filtro */} - {sel && sel!=="TODOS" && ( -
- -
- )} -
- ); -} - -function Tarjetas({ kpis, retiro, loading }) { - const rt = retiro || { sAnt:0, sMes:0, sTot:0 }; - const alDia = !loading && kpis.saldo >= -0.01 && kpis.saldo <= 0.01; - if (alDia) { - return ( -
-
✅
-
AL DÍA
-
Sin deuda pendiente
-
- ); - } - const cards = [ - ["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt, rt.sAnt], - ["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes, rt.sMes], - ["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot, rt.sTot], - ]; - return ( -
- {cards.map(([titulo,color,cta,cob,saldoRetiro]) => { - const ratio = cta>0 ? (cob/cta*100) : 0; - const c = colorSemaforo(ratio); - const saldo = cta - cob; - return ( -
-
{titulo}
-
- - - 0?"#dc2626":"#10b981"} /> - - {cta>0 && ( -
-
- % de Pago - {ratio.toFixed(0)}% -
-
-
-
-
- )} -
-
- ); - })} -
- ); -} - -function Row({ k, v, bold, color }) { - return ( -
- {k} - {v} -
- ); -} - -function TarjetasResumen({ r, retiro }) { - const rt = retiro || { sAnt:0, sMes:0, sTot:0 }; - const cards = [ - ["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt, rt.sAnt], - ["📅 MES EN CURSO","#059669",r.cMes,r.obMes, rt.sMes], - ["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot, rt.sTot], - ]; - return ( -
- {cards.map(([titulo,color,cta,cob,saldoRetiro]) => { - const ratio = cta>0 ? (cob/cta*100) : 0; - const c = colorSemaforo(ratio); - const saldo = cta - cob; - return ( -
-
{titulo}
-
- - - 0?"#dc2626":"#10b981"} /> - - {cta>0 && ( -
-
- % de Pago - {ratio.toFixed(0)}% -
-
-
-
-
- )} -
-
- ); - })} -
- ); -} - -function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) { - const [texto, setTexto] = useState(""); - const [query, setQuery] = useState(""); - const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR - const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO - - const cargarPorRetirar = () => { - api.alumnosPorRetirar() - .then((r)=>setPorRetirar(new Set((r.matriculas||[]).map(String)))) - .catch(()=>{}); - }; - useEffect(() => { cargarPorRetirar(); }, []); - - const resultados = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return []; - return alumnos.filter((a) => - String(a.alumno).toLowerCase().includes(q) || - String(a.programa).toLowerCase().includes(q) || - String(a.matricula).toLowerCase().includes(q) - ); - }, [alumnos, query]); - - const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.", - "CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"]; - - // Anchos iniciales — columnas redimensionables manualmente. - const anchosBusc = COLS.map((h,i) => { - if (i===0) return 250; // PROGRAMA - if (i===1) return 250; // ALUMNO - if (h==="N° CUOTA" || h==="F. VENC.") return 100; - if (h==="INFO") return 70; - if (h.startsWith("%")) return 80; - return 110; - }); - const cols = useColumnasAjustables(anchosBusc); - - // Congelar las 4 primeras columnas (PROGRAMA, ALUMNO, N° CUOTA, F. VENC.). - const FIJAS = 4; - const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => { - if (j >= FIJAS) return {}; - let left = 0; - for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0); - const ultima = j === FIJAS - 1; - const lineaColor = esHeader ? "#475569" : "#e2e8f0"; - return { - position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg, - borderRight: "none", - boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""), - }; - }; - - const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : ""; - - return ( - -
- setTexto(e.target.value)} - onKeyDown={(e)=>{ if(e.key==="Enter") setQuery(texto); }} - placeholder="Escribe nombre, programa o matrícula (ej: Aro) y presiona Enter" - style={{flex:1,padding:"10px 14px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:14}} - /> - - {query && } -
- - {!query ? ( -
- Escribe un texto y presiona Enter para buscar. -
- ) : resultados.length === 0 ? ( -
- Sin coincidencias para "{query}". -
- ) : ( - <> -
- {resultados.length} alumno(s) encontrado(s) -
-
- - - {COLS.map((c,i)=>{ - const fija = i < FIJAS; - const stH = fija ? stickyCol(i,{esHeader:true,bg:"#334155"}) - : { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" }; - return ; - })} - - {resultados.map((a,i)=>{ - const deuda = a.saldo > 0.01; - const retira = porRetirar.has(String(a.matricula)); // POR RETIRAR - const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff"; - const celdas = [ - {v:a.programa, cls:"col-name", al:"left", texto:true}, - {v:a.alumno, cls:"col-name", al:"left", texto:true}, - {v:a.num_cuota}, - {v:a.fch_venc}, - {v:fmtMoneda("S/ "+a.cta_ant), cart:"cart-ant"}, - {v:fmtMoneda("S/ "+a.cob_ant), cart:"cart-ant"}, - {pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"}, - {v:fmtMoneda("S/ "+a.cta_cur), cart:"cart-mes"}, - {v:fmtMoneda("S/ "+a.cob_cur), cart:"cart-mes"}, - {pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"}, - {v:fmtMoneda("S/ "+a.cta_tot), cart:"cart-tot"}, - {v:fmtMoneda("S/ "+a.cob_tot), cart:"cart-tot"}, - {pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"}, - {v:fmtMoneda("S/ "+a.saldo), saldo:true}, - ]; - return ( - - {celdas.map((c,j)=>( - - ))} - - - ); - })} - -
{c}
- {c.pct!==undefined ? (c.pct ? : "—") : c.v} - - -
-
- - )} - {infoAlumno && ( - setInfoAlumno(null)} - onGuardado={cargarPorRetirar} /> - )} -
- ); -} - -function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, progFiltro = "TODOS", sedeFiltro = "TODOS", vencHasta = "TODOS", onClose }) { - const [filas, setFilas] = useState([]); - const [loading, setLoading] = useState(true); - const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR - const [soloRetirar, setSoloRetirar] = useState(false); // filtro: ver solo POR RETIRAR - - useEffect(() => { - let activo = true; - setLoading(true); - api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion, progFiltro, sedeFiltro, vencHasta) - .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) - .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); - return () => { activo = false; }; - }, [grupo, ano, mes, sectorista, agrupacion, progFiltro, sedeFiltro, vencHasta]); - - // Cargar (y recargar) las matrículas marcadas POR RETIRAR para tachar sus filas - const cargarPorRetirar = () => { - api.alumnosPorRetirar() - .then((r) => setPorRetirar(new Set((r.matriculas || []).map(String)))) - .catch(() => {}); - }; - useEffect(() => { cargarPorRetirar(); }, []); - - const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"]; - - // Alumno seleccionado para el pop-up de INFO (estado + observaciones) - const [infoAlumno, setInfoAlumno] = useState(null); - - // Columnas redimensionables (igual que la tabla principal): PROGRAMA y ALUMNO más anchas - const anchosDet = COLS.map((h, i) => i === 0 ? 240 : (i === 1 ? 220 : (h==="INFO" ? 70 : h.startsWith("%") ? 80 : 110))); - const cols = useColumnasAjustables(anchosDet); - - // Congelar las 4 primeras columnas (PROGRAMA, ALUMNO, N° CUOTA, F. VENC.). - const FIJAS = 4; - const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => { - if (j >= FIJAS) return {}; - let left = 0; - for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0); - const ultima = j === FIJAS - 1; - const lineaColor = esHeader ? "#475569" : "#e2e8f0"; - // Usamos box-shadow (no border) para la línea divisoria: el box-shadow SÍ tapa - // por completo lo que pasa por detrás al hacer scroll (el border dejaba ver el color). - const sombraLinea = `inset -1px 0 0 0 ${lineaColor}`; - const sombraCorte = ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""; - return { - position: "sticky", left, zIndex: esHeader ? 20 : 10, - backgroundColor: bg, - borderRight: "none", - boxShadow: sombraLinea + sombraCorte, - }; - }; - - // Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos - const resumen = useMemo(() => { - let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0; - filas.forEach((f) => { - if (String(f[0]).toUpperCase() === "TOTAL") return; - cAnt += toMonto(f[4]); obAnt += toMonto(f[5]); - cMes += toMonto(f[7]); obMes += toMonto(f[8]); - cTot += toMonto(f[10]); obTot += toMonto(f[11]); - }); - return { cAnt,obAnt,cMes,obMes,cTot,obTot }; - }, [filas]); - - // Conteo de alumnos: total, ya pagaron (saldo 0) y por pagar (saldo > 0) - const conteo = useMemo(() => { - let total=0, pagaron=0, deben=0; - filas.forEach((f) => { - if (String(f[0]).toUpperCase() === "TOTAL") return; - total++; - if (toMonto(f[13]) > 0.01) deben++; else pagaron++; - }); - return { total, pagaron, deben }; - }, [filas]); - - // POR RETIRAR: cantidad de alumnos marcados y saldos por cartera (ANT, MES, TOTAL) - const retiro = useMemo(() => { - let cant=0, saldo=0, sAnt=0, sMes=0; - filas.forEach((f) => { - if (String(f[0]).toUpperCase() === "TOTAL") return; - if (porRetirar.has(String(f[0]))) { - cant++; - saldo += toMonto(f[13]); - sAnt += (toMonto(f[4]) - toMonto(f[5])); // CTA ANT - COB ANT - sMes += (toMonto(f[7]) - toMonto(f[8])); // CTA MES - COB MES - } - }); - return { cant, saldo, sAnt, sMes, sTot: sAnt + sMes }; - }, [filas, porRetirar]); - - return ( - - {loading ? : - filas.length === 0 ?
Sin datos.
: - <> -
- -
- {/* Etiquetas de conteo de alumnos */} -
- {/* Grupo 1: conteo general */} - - 👥 Total: {conteo.total} - - - ✅ Pagaron: {conteo.pagaron} - - - ⏳ Por pagar: {conteo.deben} - - - {/* Divisor */} - - - {/* Grupo 2: POR RETIRAR */} - - 🚫 Por retirar: {retiro.cant} - - - 💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})} - - - {/* Checkbox alineado a la derecha */} - -
-
- - - {COLS.map((c,i)=>{ - const fija = i < FIJAS; - const stH = fija - ? stickyCol(i,{esHeader:true,bg:"#334155"}) - : { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" }; - return ; - })} - - {filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL") - .filter((f)=> !soloRetirar || porRetirar.has(String(f[0]))) - .map((f, i) => { - const saldo = toMonto(f[13]); - const deuda = saldo > 0.01; - // Regla "-": si %ANT vacío → CTA/COB ANT a "—" (igual MES y TOT) - const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim()); - const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim()); - const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim()); - const programa = f[14] || "-"; // programa del alumno (extra del backend) - const retira = porRetirar.has(String(f[0])); // alumno POR RETIRAR - return ( - - {f.slice(0, 14).map((v, j) => { - // Columna 0: mostrar PROGRAMA en vez de la matrícula - let val = (j===0) ? programa : v; - if ((j===4||j===5||j===6) && antVacio) val = "—"; - if ((j===7||j===8||j===9) && mesVacio) val = "—"; - if ((j===10||j===11||j===12) && totVacio) val = "—"; - const isPct = [6,9,12].includes(j); - const isMonto = String(val).includes("S/"); - let cart = ""; - if ([4,5,6].includes(j)) cart = "cart-ant"; - else if ([7,8,9].includes(j)) cart = "cart-mes"; - else if ([10,11,12].includes(j)) cart = "cart-tot"; - const clss = (((j===0||j===1)?"col-name":"") + (cart?` ${cart}`:"")).trim(); - const esTexto = (j===0||j===1); - const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff"; // par/impar (i base 0) - return ( - - ); - })} - {/* Botón INFO por alumno (estado + observaciones) */} - - - ); - })} - - - {(() => { - // TOTAL GENERAL del modal — solo alumnos visibles (respeta sede y filtro POR RETIRAR) - const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL") - .filter((f)=> !soloRetirar || porRetirar.has(String(f[0]))); - if (alumnos.length===0) return null; - const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0); - const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)}; - const r = (cob,cta)=>cta>0?`${Math.round(cob/cta*100)}%`:""; - const celda = (j)=>{ - if (j===0) return "TOTAL GENERAL"; - if (j===1||j===2||j===3) return ""; - if (j===6) return r(t[5],t[4]); - if (j===9) return r(t[8],t[7]); - if (j===12) return r(t[11],t[10]); - if (t[j]!==undefined) return "S/ "+t[j].toLocaleString("es-PE",{maximumFractionDigits:0}); - return ""; - }; - return ( - - {Array.from({length:14}).map((_,j)=>{ - const isPct=[6,9,12].includes(j); - const val=celda(j); - const fija = j < FIJAS; - let left = 0; for (let k=0;k - {isPct?:val} - - ); - })} - - - ); - })()} - -
{c}
- {isPct ? (val==="—"?—:) : fmtMoneda(val)} - - -
-
- } - {infoAlumno && ( - setInfoAlumno(null)} - onGuardado={cargarPorRetirar} /> - )} -
- ); -} - -// Pop-up de INFO por alumno: cambiar estado (EN CURSO / POR RETIRAR) y observaciones. -// Carga y guarda en Supabase (tabla cobranza_alumno_estado). -function ModalInfoAlumno({ alumno, onClose, onGuardado }) { - const [estado, setEstado] = useState("EN CURSO"); // EN CURSO | POR RETIRAR - const [nuevaObs, setNuevaObs] = useState(""); - const [observaciones, setObservaciones] = useState([]); - const [cargando, setCargando] = useState(true); - const [guardando, setGuardando] = useState(false); - const [guardado, setGuardado] = useState(false); - - // Cargar estado/observaciones actuales del alumno al abrir - useEffect(() => { - let activo = true; - api.alumnoEstado(alumno.matricula) - .then((r)=>{ if(activo){ setEstado(r.estado||"EN CURSO"); setObservaciones(r.observaciones||[]); } }) - .catch(()=>{}) - .finally(()=>{ if(activo) setCargando(false); }); - return ()=>{ activo=false; }; - }, [alumno.matricula]); - - const agregarObs = () => { - const t = nuevaObs.trim(); - if (!t) return; - const fecha = new Date().toLocaleString("es-PE", { dateStyle:"short", timeStyle:"short" }); - setObservaciones((prev)=>[...prev, { texto:t, fecha }]); - setNuevaObs(""); - setGuardado(false); - }; - - const guardar = async () => { - setGuardando(true); setGuardado(false); - try { - await api.alumnoEstadoGuardar(alumno.matricula, estado, observaciones); - setGuardado(true); - if (onGuardado) onGuardado(); // refresca el tachado en la tabla - } catch { - alert("No se pudo guardar. Intenta de nuevo."); - } finally { - setGuardando(false); - } - }; - - return ( - - {/* Datos del alumno */} -
-
{alumno.alumno}
-
- Matrícula: {alumno.matricula} · {alumno.programa} -
-
- - {/* Estado */} -
- - -
- - {/* Observaciones */} -
- -
- setNuevaObs(e.target.value)} - onKeyDown={(e)=>{ if(e.key==="Enter") agregarObs(); }} - placeholder="Escribe una observación…" - style={{flex:1,padding:"8px 10px",fontSize:13,borderRadius:8,border:"1px solid #cbd5e1"}} /> - -
- {/* Lista de observaciones agregadas (local) */} -
- {observaciones.length===0 ? ( -
Sin observaciones.
- ) : observaciones.map((o,i)=>( -
-
-
{o.texto}
-
{o.fecha}
-
- -
- ))} -
-
- - {/* Acciones */} -
- - -
- - {/* Mensaje de confirmación (se mantiene visible) */} - {guardado && ( -
- ✅ Guardado correctamente. -
- )} -
- ); -} diff --git a/frontend/src/pages/Comisiones.jsx b/frontend/src/pages/Comisiones.jsx deleted file mode 100644 index e519e99..0000000 --- a/frontend/src/pages/Comisiones.jsx +++ /dev/null @@ -1,1004 +0,0 @@ -// src/pages/Comisiones.jsx -import { useState, useEffect, useMemo } from "react"; -import { api } from "../lib/api"; -import { Loader, ErrorBox, Filters, Select } from "../components/UI"; -import Modal from "../components/Modal"; -import { useColumnasAjustables } from "../lib/useColumnasAjustables"; - -const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; -const ANOS = [2024, 2025, 2026]; -const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; -const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; -const COLS = ["VENDEDOR","INSCRITOS PAGADOS MES EN CURSO","MONTO PAGADO MES EN CURSO","INSCRITOS PAGADOS MES PASADO","MONTO PAGADO MES PASADO","AVANCE TOTAL","OPCIONES"]; -// Mostaza transparente: resalta filas de alumnos con edición (override en Supabase) -const BG_EDITADO = "rgba(217,160,28,0.14)"; - -// Filtro del popup → tipo de lista que ya maneja ventasDetalle -const OPCIONES_POPUP = [ - { value: "Venta P.C", label: "INSCRITOS PAGADOS MES EN CURSO" }, - { value: "Venta Pendientes", label: "INSCRITOS PAGADOS MES PASADO" }, -]; - -function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } -// Formatea cualquier monto a "S/ 1,234" (sin decimales) -function fmt(v){ return "S/ " + toNum(v).toLocaleString("es-PE",{maximumFractionDigits:0}); } -// True si la fecha de matrícula (dd/mm/yyyy) es >= 01/01/2025 -function fechaMatDesde2025(fch){ - const s = String(fch || "").trim(); - const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); - if (!m) return false; // sin fecha válida → excluir - const d = new Date(+m[3], +m[2]-1, +m[1]); - return d >= new Date(2025, 0, 1); -} - -export default function Comisiones() { - const hoy = new Date(); - const [ano, setAno] = useState(hoy.getFullYear()); - const [mes, setMes] = useState(hoy.getMonth() + 1); - const [sede, setSede] = useState("TODOS"); - const [programa, setPrograma] = useState("TODOS"); - const [filas, setFilas] = useState([]); - const [manualesAno, setManualesAno] = useState([]); // todos los del año (cache) - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [modal, setModal] = useState(null); // { vendedor, manual? } - const [modalEdit, setModalEdit] = useState(null); // { vendedor } - const [modalEditManual, setModalEditManual] = useState(null); // { nombre } - const [modalAdd, setModalAdd] = useState(false); - const [modalBuscar, setModalBuscar] = useState(false); - const [modalConfig, setModalConfig] = useState(false); - const cols = useColumnasAjustables([200, 160, 150, 160, 150, 130, 110]); - - // Carga TODOS los manuales del año (una sola vez por año) → cambio de mes instantáneo - function cargarManuales() { - api.vendedoresManualesListar(ano, 0) - .then((res) => setManualesAno(res.vendedores || [])) - .catch(() => setManualesAno([])); - } - - // Manuales del mes actual: filtrado en memoria (instantáneo, sin esperar red) - const manuales = useMemo( - () => manualesAno.filter((m) => Number(m.mes) === Number(mes)), - [manualesAno, mes] - ); - - // Tabla de ventas (depende del mes, sede y programa) - function cargarTabla() { - setLoading(true); setError(null); - api.ventas(ano, mes, sede, programa) - .then((res) => { setFilas(res.filas || []); setLoading(false); }) - .catch((e) => { setError(e.message); setLoading(false); }); - } - - useEffect(() => { - let activo = true; - setLoading(true); setError(null); - api.ventas(ano, mes, sede, programa) - .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) - .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); - return () => { activo = false; }; - }, [ano, mes, sede, programa]); - - // Cargar manuales del año cuando cambia el año (o al inicio) - useEffect(() => { cargarManuales(); }, [ano]); - - const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]); - const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]); - - // Agrupar vendedores manuales por nombre (cada nombre = 1 fila, suma de montos) - const manualesAgrupados = useMemo(() => { - const map = {}; - manuales.forEach((m) => { - if (!map[m.nombre]) map[m.nombre] = { nombre: m.nombre, totalMonto: 0, filas: [] }; - map[m.nombre].totalMonto += toNum(m.monto); - map[m.nombre].filas.push(m); - }); - return Object.values(map); - }, [manuales]); - - // Total general de AVANCE TOTAL = ventas + montos manuales - // (los manuales solo cuentan cuando NO hay filtro de sede/programa) - const totalAvance = useMemo(() => { - let s = total ? toNum(total[7]) : 0; - if (sede==="TODOS" && programa==="TODOS") manuales.forEach((m) => { s += toNum(m.monto); }); - return s; - }, [total, manuales, sede, programa]); - - const C = { textAlign: "center" }; // todas las columnas centradas - - return ( -
-

🧮 Comisiones

- - - ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> - -
- - - -
-
- - {loading ? : - error ? : -
- - - {COLS.map((c, i) => )} - - {datos.map((f, i) => ( - - - - - - - - ))} - - {/* Asesores manuales: solo cuando NO hay filtro de sede/programa */} - {sede==="TODOS" && programa==="TODOS" && manualesAgrupados.map((g) => ( - - - - - - - - ))} - - {total && ( - - - - - - - )} - -
{c}
{f[0]}{f[3]}{fmt(f[4])}{f[5]}{fmt(f[6])}{fmt(f[7])} - - -
{g.nombre}————{fmt(g.totalMonto)} - - -
TOTAL GENERAL{total[3]}{fmt(total[4])}{total[5]}{fmt(total[6])}{fmt(totalAvance)}—
-
} - - {modal && ( - setModal(null)} - onEliminar={() => { setModal(null); cargarManuales(); }} /> - )} - - {modalEdit && ( - setModalEdit(null)} onGuardado={cargarTabla} /> - )} - - {modalEditManual && ( - m.nombre===modalEditManual.nombre)} - onClose={() => setModalEditManual(null)} - onCambio={cargarManuales} /> - )} - - {modalConfig && ( - setModalConfig(false)} onGuardado={cargarTabla} /> - )} - - {modalBuscar && ( - setModalBuscar(false)} onGuardado={cargarTabla} /> - )} - - {modalAdd && ( - setModalAdd(false)} - onGuardado={() => { setModalAdd(false); cargarManuales(); }} /> - )} -
- ); -} - -// Celda editable con validación según tipo (fecha / dinero / numero / texto) -function CeldaEdit({ tipo, valor, onChange }) { - const baseStyle = { width:"100%", minWidth:90, padding:"5px 6px", border:"1px solid #cbd5e1", - borderRadius:6, fontSize:12, textAlign:"center", boxSizing:"border-box" }; - - // Convierte dd/mm/yyyy → yyyy-mm-dd (para input date) - function aISO(v) { - const s = String(v || "").trim(); - if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0,10); - const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); - if (m) return `${m[3]}-${m[2].padStart(2,"0")}-${m[1].padStart(2,"0")}`; - return ""; - } - // Convierte yyyy-mm-dd → dd/mm/yyyy (para guardar) - function aDMY(iso) { - const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/); - return m ? `${m[3]}/${m[2]}/${m[1]}` : iso; - } - - if (tipo === "fecha") { - return ( - onChange(e.target.value ? aDMY(e.target.value) : "")} - style={baseStyle} /> - ); - } - - if (tipo === "dinero") { - const num = toNum(valor); - const display = (valor === "" || valor === "-" || valor == null) ? "" : "S/ " + num.toLocaleString("es-PE",{maximumFractionDigits:0}); - return ( - { - // Solo dígitos: "5500" → guarda "S/ 5500" - const limpio = e.target.value.replace(/[^\d]/g, ""); - onChange(limpio === "" ? "" : "S/ " + parseInt(limpio,10).toLocaleString("es-PE")); - }} - placeholder="S/ 0" style={baseStyle} /> - ); - } - - if (tipo === "numero") { - return ( - onChange(e.target.value.replace(/[^\d-]/g, ""))} - style={baseStyle} /> - ); - } - - // texto - return onChange(e.target.value)} style={baseStyle} />; -} - -function ModalEditar({ vendedor, ano, mes, sede = "TODOS", programa = "TODOS", onClose, onGuardado }) { - const [tipo, setTipo] = useState(OPCIONES_POPUP[0].value); - const [filas, setFilas] = useState([]); - const [edits, setEdits] = useState({}); // { num_matricula: { idx: valor } } - const [loading, setLoading] = useState(true); - const [guardando, setGuardando] = useState(false); - const [msg, setMsg] = useState(null); - const [confirmar, setConfirmar] = useState(null); // matrícula a restaurar - - function cargar() { - setLoading(true); - api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa) - .then((res) => { - const soloAlumnos = (res.filas || []).filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); - setFilas(soloAlumnos); - setEdits({}); - setLoading(false); - }) - .catch(() => { setFilas([]); setLoading(false); }); - } - useEffect(() => { cargar(); }, [vendedor, ano, mes, tipo, sede, programa]); - - // Columnas editables: etiqueta + índice en la fila + clave en tabla override - const CAMPOS = [ - { label: "F. MATRÍCULA", idx: 2, col: "fch_matricula", tipo: "fecha" }, - { label: "INVERSIÓN NETA", idx: 7, col: "inversion_neta", tipo: "dinero" }, - { label: "SALDO MATRÍCULA", idx: 10, col: "saldo_matricula", tipo: "dinero" }, - { label: "SALDO 1° CUOTA", idx: 11, col: "saldo_cuota1", tipo: "dinero" }, - { label: "FECHA INICIO", idx: 9, col: "fecha_inicio", tipo: "fecha" }, - { label: "FECHA CANCELACIÓN 1", idx: 3, col: "fecha_cancelacion1", tipo: "fecha" }, - { label: "PROMEDIO CUOTA", idx: 5, col: "promedio_cuota", tipo: "dinero" }, - { label: "DÍAS ANTICIPACIÓN", idx: 12, col: "dias_anticipacion", tipo: "numero" }, - { label: "VALOR CUOTA ADICIONAL", idx: 13, col: "valor_cuota_adicional", tipo: "dinero" }, - { label: "SEDE", idx: 14, col: "tipo_programa", tipo: "texto" }, - ]; - - function valorActual(f, idx) { - const mat = String(f[15]); - if (edits[mat] && edits[mat][idx] !== undefined) return edits[mat][idx]; - return f[idx] ?? ""; - } - function cambiar(mat, idx, valor) { - setEdits((prev) => ({ ...prev, [mat]: { ...(prev[mat]||{}), [idx]: valor } })); - } - - async function guardar() { - setGuardando(true); setMsg(null); - try { - const registros = Object.keys(edits).map((mat) => { - const r = { num_matricula: mat }; - CAMPOS.forEach((c) => { - const e = edits[mat][c.idx]; - // Campo borrado a propósito → marcador "__VACIO__" (fuerza vacío en el override) - if (e !== undefined) r[c.col] = (String(e).trim() === "") ? "__VACIO__" : String(e); - }); - return r; - }); - if (registros.length === 0) { setMsg({ok:false,txt:"No hay cambios para guardar."}); setGuardando(false); return; } - await api.comisionesOverrideGuardar(registros); - setMsg({ ok:true, txt:"✅ Cambios guardados." }); - cargar(); - if (onGuardado) onGuardado(); // recarga la tabla principal - } catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); } - setGuardando(false); - } - - async function restaurar(mat) { - try { await api.comisionesOverrideRestaurar(mat); cargar(); if (onGuardado) onGuardado(); } - catch (e) { setMsg({ ok:false, txt:"No se pudo restaurar: " + e.message }); } - } - - const th = { background:"#334155", color:"#f1f5f9", padding:8, fontSize:10, fontWeight:600, whiteSpace:"nowrap", textAlign:"center", position:"sticky", top:0, borderRight:"1px solid #475569" }; - - // Columnas redimensionables: ALUMNO + PROGRAMA + CAMPOS + ACCIÓN - const colsEdit = useColumnasAjustables([200, 220, ...CAMPOS.map(()=>130), 80]); - - return ( - -
- cambiarRow(i,"descripcion",e.target.value)} style={inp} /> - cambiarRow(i,"fch_emision",e.target.value)} style={inp} /> - cambiarRow(i,"monto",e.target.value)} style={inp} /> - - - - - ))} - - -
- - - - {msg && ( -
{msg.txt}
- )} -
- - -
-
- ); -} - -function ModalConfig({ ano, mes, onClose, onGuardado }) { - const TIPOS = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]; - const [tc, setTc] = useState(""); // IMP_TC único del mes - const [filas, setFilas] = useState([]); // META y COMISIÓN por tipo - const [loading, setLoading] = useState(true); - const [guardando, setGuardando] = useState(false); - const [msg, setMsg] = useState(null); - - useEffect(() => { - setLoading(true); - api.comisionesConfig(ano, mes) - .then((res) => { - const guardadas = res.config || []; - // TC: tomar el primero que tenga valor (es único por mes) - const tcGuardado = guardadas.find((x) => +x.imp_tc > 0); - setTc(tcGuardado ? String(tcGuardado.imp_tc) : ""); - const arr = TIPOS.map((t) => { - const g = guardadas.find((x) => x.tipo_programa === t) || {}; - return { tipo_programa: t, meta: g.meta ?? "", comision: g.comision ?? "" }; - }); - setFilas(arr); setLoading(false); - }) - .catch(() => { setTc(""); setFilas(TIPOS.map((t)=>({tipo_programa:t,meta:"",comision:""}))); setLoading(false); }); - }, [ano, mes]); - - function cambiar(i, campo, val) { - setFilas((prev) => prev.map((f, idx) => idx === i ? { ...f, [campo]: val } : f)); - } - - async function guardar() { - setGuardando(true); setMsg(null); - try { - // El mismo TC del mes se guarda en todas las filas (es único por mes) - const payload = filas.map((f) => ({ - tipo_programa: f.tipo_programa, - imp_tc: toNum(tc), meta: toNum(f.meta), comision: toNum(f.comision), - })); - await api.comisionesConfigGuardar({ ano, mes, filas: payload }); - if (onGuardado) onGuardado(); // recarga la tabla principal - onClose(); - } catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); } - } - - const inp = { width:"100%", padding:"7px 9px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:13, textAlign:"center", boxSizing:"border-box" }; - const MESES_N = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; - - return ( - -
- Configuración para {MESES_N[mes-1]} {ano}. -
- {loading ? : <> - {/* IMP_TC único del mes */} -
- - setTc(e.target.value)} - style={{...inp,maxWidth:160}} /> - Se usará para convertir dólares este mes. Si lo dejas vacío, usa el TC de la base. -
- - {/* META y COMISIÓN por tipo de programa */} -
- - - - - - - - {filas.map((f, i) => ( - - - - - - ))} - -
TIPO PROGRAMAMETA (S/)COMISIÓN (%)
{f.tipo_programa}cambiar(i,"meta",e.target.value)} style={inp} />cambiar(i,"comision",e.target.value)} style={inp} />
-
- } - {msg && ( -
{msg.txt}
- )} -
- - -
-
- ); -} - -function ModalBuscar({ ano, mes, onClose, onGuardado }) { - const [todos, setTodos] = useState([]); - const [loading, setLoading] = useState(true); - const [q, setQ] = useState(""); // lo que se escribe - const [qAplicado, setQAplicado] = useState(""); // lo que se busca (con debounce) - const [modo, setModo] = useState("VER"); // VER | EDITAR - const [edits, setEdits] = useState({}); // { num_matricula: { idx: valor } } - const [guardando, setGuardando] = useState(false); - const [msg, setMsg] = useState(null); - const [confirmar, setConfirmar] = useState(null); // matrícula a restaurar - - function cargar() { - setLoading(true); - api.comisionesDetalleTodos(ano, mes) - .then((res) => { setTodos(res.filas || []); setLoading(false); }) - .catch(() => { setTodos([]); setLoading(false); }); - } - useEffect(() => { cargar(); }, [ano, mes]); - - async function restaurar(mat) { - try { - await api.comisionesOverrideRestaurar(mat); - setEdits((p) => { const c = {...p}; delete c[mat]; return c; }); - cargar(); - if (onGuardado) onGuardado(); // recarga la tabla principal - setMsg({ ok:true, txt:"✅ Alumno restaurado a sus valores originales." }); - } catch (e) { setMsg({ ok:false, txt:"No se pudo restaurar: " + e.message }); } - } - - // Debounce: aplica la búsqueda 250ms después de dejar de escribir (evita lag) - useEffect(() => { - const t = setTimeout(() => setQAplicado(q.trim().toLowerCase()), 250); - return () => clearTimeout(t); - }, [q]); - - // Filtrado en memoria, limitado a 100 resultados para no congelar el render - const resultados = useMemo(() => { - if (!qAplicado) return []; - const out = []; - for (let i = 0; i < todos.length && out.length < 100; i++) { - const f = todos[i]; - if (String(f[1]).toLowerCase().includes(qAplicado) || - String(f[8]).toLowerCase().includes(qAplicado) || - String(f[0]).toLowerCase().includes(qAplicado)) { - out.push(f); - } - } - return out; - }, [qAplicado, todos]); - - const HEADERS = ["VENDEDOR","ALUMNO","PROGRAMA","F. MATRÍCULA","INVERSIÓN NETA","SALDO MATRÍCULA","SALDO 1° CUOTA","FECHA INICIO","FECHA CANCELACIÓN 1","PROMEDIO CUOTA","DÍAS ANTICIPACIÓN","VALOR CUOTA ADICIONAL","SEDE"]; - const ORDEN = [0, 1, 8, 2, 7, 10, 11, 9, 3, 5, 12, 13, 14]; - const COL_MONTO = new Set([4, 5, 6, 9, 11]); - // Campos editables (mismos que el Editar normal): idx de fila → tipo - const EDITABLES = { 2:"fecha", 7:"dinero", 10:"dinero", 11:"dinero", 9:"fecha", 3:"fecha", 5:"dinero", 12:"numero", 13:"dinero", 14:"texto" }; - - // Anchos iniciales por columna (VENDEDOR/ALUMNO/PROGRAMA más anchas); +ACCIÓN en modo EDITAR - const anchosBuscar = [180, 220, 240, ...Array(HEADERS.length-3).fill(130), ...(modo==="EDITAR" ? [80] : [])]; - const colsBuscar = useColumnasAjustables(anchosBuscar); - - function setEdit(mat, idx, val) { - setEdits((p) => ({ ...p, [mat]: { ...(p[mat]||{}), [idx]: val } })); - } - - async function guardar() { - setGuardando(true); setMsg(null); - try { - const registros = Object.entries(edits).map(([mat, campos]) => { - const r = { num_matricula: mat }; - Object.entries(campos).forEach(([idx, val]) => { - const map = {2:"fch_matricula",7:"inversion_neta",10:"saldo_matricula",11:"saldo_cuota1",9:"fecha_inicio",3:"fecha_cancelacion1",5:"promedio_cuota",12:"dias_anticipacion",13:"valor_cuota_adicional",14:"tipo_programa"}; - // Campo borrado a propósito → marcador "__VACIO__" - if (map[idx]) r[map[idx]] = (String(val).trim() === "") ? "__VACIO__" : val; - }); - return r; - }); - await api.comisionesOverrideGuardar(registros); - setEdits({}); - if (onGuardado) onGuardado(); - setMsg({ ok:true, txt:"✅ Cambios guardados." }); - setGuardando(false); - } catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); } - } - - return ( - -
- setQ(e.target.value)} - placeholder="Escribe nombre de alumno, programa o vendedor..." - style={{flex:1,padding:"11px 14px",border:"1px solid #cbd5e1",borderRadius:10,fontSize:14,boxSizing:"border-box"}} /> - {/* Switch VER / EDITAR */} -
- {["VER","EDITAR"].map((m) => ( - - ))} -
-
- - {loading ? : - !qAplicado ?
Escribe para buscar.
: - resultados.length === 0 ?
Sin coincidencias.
: -
-
- {resultados.length}{resultados.length===100?"+":""} resultado(s) {modo==="EDITAR" && "— modo edición"} -
- - - - {HEADERS.map((h,hi)=>)} - {modo==="EDITAR" && } - - - {resultados.map((f, i) => { - const mat = f[15]; - return ( - - {ORDEN.map((idx, pos) => { - const esTexto = pos===0||pos===1||pos===2; - const editable = modo==="EDITAR" && EDITABLES[idx] && idx!==0 && idx!==1 && idx!==8; - const valActual = (edits[mat] && edits[mat][idx] !== undefined) ? edits[mat][idx] : (f[idx] ?? ""); - return ( - - ); - })} - {modo==="EDITAR" && ( - - )} - - );})} - -
{h}ACCIÓN
- {editable - ? setEdit(mat, idx, v)} /> - : (COL_MONTO.has(pos) ? fmt(f[idx]) : (f[idx] ?? ""))} - - -
-
} - - {msg && ( -
{msg.txt}
- )} - {modo==="EDITAR" && ( -
- -
- )} - - {confirmar && ( - setConfirmar(null)} - onConfirmar={async () => { await restaurar(confirmar); setConfirmar(null); }} - /> - )} -
- ); -} - -function ModalAgregar({ ano, mes, onClose, onGuardado }) { - const [nombre, setNombre] = useState(""); - const [descripcion, setDescripcion] = useState(""); - const [fchEmision, setFchEmision] = useState(""); - const [monto, setMonto] = useState(""); - const [guardando, setGuardando] = useState(false); - const [msg, setMsg] = useState(null); - - async function guardar() { - if (!nombre.trim()) { setMsg("El nombre es obligatorio."); return; } - setGuardando(true); setMsg(null); - try { - await api.vendedoresManualesCrear({ - nombre: nombre.trim(), descripcion: descripcion.trim(), - fch_emision: fchEmision, monto: toNum(monto), ano, mes, - }); - onGuardado(); - } catch (e) { - setMsg("Error al guardar: " + e.message); - setGuardando(false); - } - } - - const inp = { width:"100%", padding:"9px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, boxSizing:"border-box" }; - - return ( - -
-
- - setNombre(e.target.value)} style={inp} placeholder="Nombre del vendedor" autoFocus /> -
-
- - setDescripcion(e.target.value)} style={inp} placeholder="Descripción" /> -
-
- - setFchEmision(e.target.value)} style={inp} /> -
-
- - setMonto(e.target.value)} style={inp} placeholder="0" /> -
- {msg &&
{msg}
} -
- - -
-
-
- ); -} - -const lbl = { fontSize:12, fontWeight:600, color:"#475569", display:"block", marginBottom:5 }; - -function ModalDetalle({ vendedor, manual, ano, mes, sede = "TODOS", programa = "TODOS", onClose, onEliminar }) { - const [tipo, setTipo] = useState(OPCIONES_POPUP[0].value); - const [filas, setFilas] = useState([]); - const [loading, setLoading] = useState(true); - const [eliminando, setEliminando] = useState(false); - - useEffect(() => { - if (manual) { setLoading(false); return; } // manual no consulta detalle - let activo = true; - setLoading(true); - api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa) - .then((res) => { - if (!activo) return; - const soloAlumnos = (res.filas || []).filter( - (f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL" && fechaMatDesde2025(f[2]) - ); - setFilas(soloAlumnos); - setLoading(false); - }) - .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); - return () => { activo = false; }; - }, [vendedor, ano, mes, tipo, manual, sede, programa]); - - async function eliminar() { - if (!confirm(`¿Eliminar al vendedor "${manual.nombre}"?`)) return; - setEliminando(true); - try { await api.vendedoresManualesEliminar(manual.id); onEliminar(); } - catch (e) { alert("No se pudo eliminar: " + e.message); setEliminando(false); } - } - - // ── Vista para vendedor MANUAL ── - if (manual) { - const filasM = manual.filas || [manual]; - const totalM = filasM.reduce((s,r)=>s+toNum(r.monto),0); - return ( - -
- - - - - - - - - {filasM.map((r) => ( - - - - - - - ))} - - - - - - -
VENDEDORDESCRIPCIÓNFECHA EMISIÓNMONTO
{r.nombre}{r.descripcion || "—"}{r.fch_emision || "—"}{fmt(r.monto)}
TOTAL GENERAL{fmt(totalM)}
-
-
- -
-
- ); - } - - // Orden solicitado de columnas - const HEADERS = ["VENDEDOR","ALUMNO","PROGRAMA","F. MATRÍCULA","INVERSIÓN NETA","SALDO MATRÍCULA","SALDO 1° CUOTA","FECHA INICIO","FECHA CANCELACIÓN 1","PROMEDIO CUOTA","DÍAS ANTICIPACIÓN","VALOR CUOTA ADICIONAL","SEDE"]; - const ORDEN = [0, 1, 8, 2, 7, 10, 11, 9, 3, 5, 12, 13, 14]; - const COL_MONTO = new Set([4, 5, 6, 9, 11]); - - // Anchos iniciales por columna (px) — el usuario los puede arrastrar - const ANCHOS_INI = [180, 240, 280, 110, 130, 130, 130, 110, 140, 120, 120, 150, 130]; - const [anchos, setAnchos] = useState(ANCHOS_INI); - - function iniciarResize(e, i) { - e.preventDefault(); - const xInicial = e.clientX; - const anchoInicial = anchos[i]; - function onMove(ev) { - const nuevo = Math.max(60, anchoInicial + (ev.clientX - xInicial)); - setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; }); - } - function onUp() { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - } - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - } - - const totalInv = useMemo(() => filas.reduce((s,f)=>s+toNum(f[7]),0), [filas]); - - return ( - -
- + + + + + {loading ? : + error ? : + !k ? : + <> + {/* ── FILA DE KPIs (4 tarjetas compuestas, como el PBI) ── */} +
+ {/* Tarjeta 1: Leads Recibidos / Procesados / % */} +
+ + + +
+ + {/* Tarjeta 2: Procesados / Contactados / % */} +
+ + + +
+ + {/* Tarjeta 3: Matriculados / Matrículas mes / Ocupabilidad */} +
+ + + +
+ + {/* Tarjeta 4: Cursos programados / reprog / susp / iniciados */} +
+ + + + +
+
+ + {/* ── FILA INFERIOR: gráfico + tabla ── */} +
+
+
Matrículas por Día
+ + + + + + + + + + + + } cursor={{ stroke: "#cbd5e1", strokeDasharray: "4 4" }} /> + + + (v > 0 ? v : "")} /> + + + +
+ +
+
+ + + + + + + + + {(data.estado_objecion?.filas || []).map((f) => { + const abierto = !!expandido[f.estado]; + const tieneAsesores = (f.asesores || []).length > 0; + return ( + + + + + + {abierto && (f.asesores || []).map((a) => { + const keyA = f.estado + "|" + a.asesor; + const abiertoA = !!expAsesor[keyA]; + const tieneTel = (a.telefonos || []).length > 0; + return ( + + + + + + {abiertoA && (a.telefonos || []).map((t, i) => ( + + + + + ))} + + ); + })} + + ); + })} + + + + + +
Estado/ObjeciónLeads Procesados
+ {tieneAsesores && ( + + )} + {f.estado} + {f.cantidad}
+ {tieneTel && ( + + )} + {a.asesor} + {a.cantidad}
📞 {t}
Total{data.estado_objecion.total}
+
+
+
+ + {/* ── FILA 2: Leads por Programa (área) + Tabla por Sede ── */} +
+
+
Leads por Programa
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + + + + + + + + + {(data.tabla_pauta?.filas || []).map((f) => { + const abierto = !!expSede[f.sede]; + return ( + + + + + + + + + + + {abierto && (f.subfilas || []).map((s) => { + const keyT = f.sede + "|" + s.tipo; + const abiertoT = !!expTipo[keyT]; + const tieneCod = (s.codigos || []).length > 0; + return ( + + + + + + + + + + + {abiertoT && (s.codigos || []).map((cd) => ( + + + + + + + + + + ))} + + ); + })} + + ); + })} + + + + + + + + + + +
SedeImporte GastadoResultadosRecibidosProcesadosMatr.Valor Venta
+ + {f.sede} + {f.importe ? `$${Number(f.importe).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"}{f.resultados}{f.recibidos}{f.procesados}{f.matriculas}S/ {Number(f.inversion || 0).toLocaleString("es-PE")}
+ {tieneCod && ( + + )} + {s.tipo} + {s.importe ? `$${Number(s.importe).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"}{s.resultados}{s.recibidos}{s.procesados}{s.matriculas}S/ {Number(s.inversion || 0).toLocaleString("es-PE")}
cód. {cd.codigo}{cd.importe ? `$${Number(cd.importe).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—"}{cd.resultados ? cd.resultados : "—"}{cd.recibidos}{cd.procesados}{cd.matriculas}S/ {Number(cd.inversion || 0).toLocaleString("es-PE")}
Total{`$${Number(data.tabla_pauta.total.importe || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}{data.tabla_pauta.total.resultados}{data.tabla_pauta.total.recibidos}{data.tabla_pauta.total.procesados}{data.tabla_pauta.total.matriculas}S/ {Number(data.tabla_pauta.total.inversion).toLocaleString("es-PE")}
+
+
+
+ + {/* ── FILA 3: Matriz por Curso (num_indice) ── */} +
+
+
Detalle por Curso
+
+ + Ver + + {[["TOTAL", verTotal, setVerTotal], ["ASIGNADO", verAsig, setVerAsig]].map(([txt, val, set]) => ( + + ))} +
+
+
+ + + + + {colsVisibles.map((col, i) => ( + + ))} + + + + {(data.matriz_cursos?.filas || []).map((c) => { + const abierto = !!expMat[c.num_indice]; + return ( + + + {colsVisibles.map((col) => col.prog ? ( + + ) : col.ver ? ( + + ) : ( + + ))} + + {abierto && (c.subfilas_canal || []).map((sf) => ( + + {colsVisibles.map((col) => { + if (col.prog) return ( + + ); + if (col.ver) return ; + // Estas columnas -> "-" en subfilas (Importe y Leads Recibidos/Procesados) + const GUION = ["importe_pauta", "importe_pauta_mes", + "leads_nuevos", "leads_nuevos_asesor", + "leads_nuevos_mes", "leads_nuevos_mes_asesor"]; + if (GUION.includes(col.key)) return ( + + ); + const val = sf[col.key]; + return ( + + ); + })} + + ))} + + ); + })} + +
{col.label}
+ {(c.subfilas_canal && c.subfilas_canal.length > 0) && ( + setExpMat((p) => ({ ...p, [c.num_indice]: !p[c.num_indice] }))} + style={{ cursor: "pointer", fontWeight: 700, color: "#2563eb", + marginRight: 6, userSelect: "none", display: "inline-block", width: 14 }} + title="Ver por canal (Pauta / Web / Otros)"> + {abierto ? "−" : "+"} + + )} + {c.personalizado} + + + + {col.render ? col.render(c) : c[col.key]} +
{sf.canal}—{val === undefined ? "—" : val}
+
+
+ + } + + {/* ── Pop-up "Ver": num_indice, pauta y conjuntos de anuncios ── */} + {modalCurso && ( +
+
e.stopPropagation()} + style={{ background: "#fff", borderRadius: 16, width: 560, maxWidth: "94vw", + maxHeight: "86vh", overflow: "auto", boxShadow: "0 24px 70px rgba(0,0,0,0.4)", + fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif" }}> +
+
{modalCurso.personalizado}
+ +
+
+
+
+
Num Índice
+
{modalCurso.num_indice || "—"}
+
+
+
Pauta
+ {editando ? ( + setEditPauta(e.target.value)} + placeholder="Código de pauta" + style={{ marginTop: 4, padding: "7px 10px", border: "1px solid #cbd5e1", + borderRadius: 8, fontSize: 14, width: "100%" }} /> + ) : ( +
{modalCurso.pauta || "—"}
+ )} +
+
+ + {editando ? ( + <> +
+ Vincular Conjunto de Anuncio (opcional) +
+ + + {/* Switch: contar SI/NO */} +
+
+
Mostrar en la lista
+
+ {editContar === "SI" ? "Este programa aparece en el Detalle por Curso" : "Este programa quedará oculto"} +
+
+
{ + const nuevo = editContar === "SI" ? "NO" : "SI"; + setEditContar(nuevo); + if (nuevo === "NO") setAvisoContar(true); + }} + style={{ width: 46, height: 26, borderRadius: 999, cursor: "pointer", + background: editContar === "SI" ? "#2563eb" : "#cbd5e1", + position: "relative", transition: "background .15s", flexShrink: 0 }}> +
+
+
+ {avisoContar && editContar === "NO" && ( +
+ ⚠️ Al poner NO y guardar, este programa ya no aparecerá en la lista. + Solo podrás volver a añadirlo desde Configuración. +
+ )} + + {msgGuardar &&
{msgGuardar}
} +
+ + {(() => { + const cambioContar = editContar !== (modalCurso.contar || "SI"); + const puedeGuardar = editPauta.trim() || cambioContar; + return ( + + ); + })()} +
+ + ) : ( + <> +
+ Conjuntos de Anuncios ({(modalCurso.conjuntos || []).length}) +
+ {(modalCurso.conjuntos || []).length === 0 ? ( +
Sin conjuntos vinculados a esta pauta.
+ ) : ( +
+ {modalCurso.conjuntos.map((cj, i) => ( +
+ {cj} +
+ ))} +
+ )} +
+ +
+ + )} +
+
+
+ )} + + {/* ── Advertencia: la pauta ya está en uso ── */} + {avisoUso && ( +
+
+
+ ⚠️ Esta pauta ya está en uso +
+
+
+ La pauta {editPauta} ya está asignada a: +
+ {(avisoUso.cursos || []).length > 0 && ( +
+
Programas
+ {avisoUso.cursos.map((cu, i) => ( +
+ {cu.dsc_det_programa || "(sin nombre)"} · índice {cu.num_indice} +
+ ))} +
+ )} + {(avisoUso.conjuntos || []).length > 0 && ( +
+
Conjuntos de anuncios
+ {avisoUso.conjuntos.map((cj, i) => ( +
+ {cj} +
+ ))} +
+ )} +
+ ¿Estás seguro de continuar? +
+
+ + +
+
+
+
+ )} +
+ ); +} + +// Tooltip personalizado: mini-gráfico de barras horizontales por tipo de programa +function TooltipMatriculas({ active, payload, label }) { + if (!active || !payload || !payload.length) return null; + const p = payload[0].payload; + const detalle = p.detalle || []; + const max = Math.max(1, ...detalle.map((d) => d.cantidad)); + return ( +
+
+ Día {label} — {p.cantidad} matrícula(s) +
+ {detalle.length === 0 ? ( +
Sin matrículas
+ ) : detalle.map((d) => ( +
+ {d.tipo} +
+
+
+ {d.cantidad} +
+ ))} +
+ ); +} + +function KpiRow({ ico, label, value }) { + return ( +
+ {ico} +
+
{label}
+
{value}
+
+
+ ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx deleted file mode 100644 index 83cc836..0000000 --- a/frontend/src/pages/Login.jsx +++ /dev/null @@ -1,82 +0,0 @@ -// src/pages/Login.jsx -import { useState } from "react"; -import { useAuth } from "../lib/auth"; - -export default function Login() { - const { login } = useAuth(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [cargando, setCargando] = useState(false); - - async function onSubmit(e) { - e.preventDefault(); - setError(null); - setCargando(true); - const res = await login(email.trim(), password); - if (!res.ok) setError(res.msg); - setCargando(false); - } - - return ( -
-
-
-
❄️
-

Escuela de Refrigeración

-

Dashboard — Inicia sesión

-
- -
-
- - setEmail(e.target.value)} - placeholder="usuario@escuela.com" required autoFocus - style={inputStyle} - /> -
- -
- - setPassword(e.target.value)} - placeholder="••••••••" required - style={inputStyle} - /> -
- - {error && ( -
⚠️ {error}
- )} - - -
-
-
- ); -} - -const inputStyle = { - width: "100%", padding: "11px 14px", border: "1px solid #cbd5e1", borderRadius: 10, - fontSize: 14, outline: "none", boxSizing: "border-box", -}; diff --git a/frontend/src/pages/Ocupabilidad.jsx b/frontend/src/pages/Ocupabilidad.jsx deleted file mode 100644 index 1fbe444..0000000 --- a/frontend/src/pages/Ocupabilidad.jsx +++ /dev/null @@ -1,307 +0,0 @@ -// src/pages/Ocupabilidad.jsx -import { useState, useEffect, useMemo } from "react"; -import { api } from "../lib/api"; -import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI"; -import { useColumnasAjustables } from "../lib/useColumnasAjustables"; -import Modal from "../components/Modal"; -import { setVistaActual, onRefrescar } from "../lib/vistaActual"; - -const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; -const ANOS = [2024, 2025, 2026]; -const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; -const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; - -const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD","OPC."]; -function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; } - -export default function Ocupabilidad() { - const hoy = new Date(); - const [ano, setAno] = useState(hoy.getFullYear()); - const [mes, setMes] = useState(hoy.getMonth() + 1); - const [sede, setSede] = useState("TODOS"); - const [programa, setPrograma] = useState("TODOS"); - const [mostrarRepro, setMostrarRepro] = useState(true); - const [datos, setDatos] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [modalCurso, setModalCurso] = useState(null); - const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140, 80]); - - useEffect(() => { - let activo = true; - setVistaActual(`ocupabilidad:${ano}|${mes}|${sede}|${programa}`); - const cargar = () => { - setLoading(true); setError(null); - api.ocupabilidad(ano, mes, sede, programa) - .then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } }) - .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); - }; - cargar(); - const off = onRefrescar(cargar); - return () => { activo = false; off(); }; - }, [ano, mes, sede, programa]); - - // Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio - const datosVisibles = useMemo(() => { - if (mostrarRepro) return datos; - return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO"); - }, [datos, mostrarRepro]); - - const kpis = useMemo(() => { - if (!datosVisibles.length) return null; - const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO"; - // Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio) - const validos = datosVisibles.filter((d) => !esRepro(d)); - const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0); - const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0); - const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0); - const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0); - return { - cursos: validos.length, // solo programados (sin reprogramados) - reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO) - inscritos: totalInsc, // columna TOTAL INSCRITOS - meta, - avance: meta > 0 ? (totalInsc / meta * 100) : 0, - desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0, - sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length, - pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0), - }; - }, [datosVisibles]); - - const totales = useMemo(() => { - const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 }; - datosVisibles.forEach((d) => { - t.mes += parseInt(d.Inscritos_Mes)||0; - t.total += parseInt(d.Inscritos_Totales)||0; - t.pc += parseInt(d.Inscritos_PC)||0; - t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0; - t.activos += parseInt(d.Inscritos_Activos)||0; - t.refri += parseInt(d.Descuento)||0; - t.cont += parseInt(d.Inscritos_Continuidad)||0; - t.meta += parseInt(d.Meta_Curso)||0; - }); - return t; - }, [datosVisibles]); - - const KPI_CARDS = kpis ? [ - ["📚","Cursos Activos", kpis.cursos, "programas este mes"], - ["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"], - ["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"], - ["🎯","Meta Total", kpis.meta, "inscritos objetivo"], - ["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"], - ["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"], - ] : []; - - async function exportarExcel() { - if (!datosVisibles.length) return; - // Cargar SheetJS desde CDN solo cuando se necesita - const XLSX = await import("xlsx"); - const filas = datosVisibles.map((d) => ({ - "PROGRAMA": d.programa_frecuencia || d.dsc_programa || "", - "FECHA INICIO": d.fch_inicio || "", - "DÍAS PARA INICIO": d.dias_para_inicio ?? "", - "TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0, - "RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0, - "INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0, - "META INSCRITOS": parseInt(d.Meta_Curso)||0, - "AVANCE INSCRITOS": d.Avance_Inscritos || "", - "INSCRITOS MES": parseInt(d.Inscritos_Mes)||0, - "INSCRITOS P.C": parseInt(d.Inscritos_PC)||0, - "INSCRITOS REFRIPERU": parseInt(d.Descuento)||0, - "INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0, - })); - const ws = XLSX.utils.json_to_sheet(filas); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad"); - XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`); - } - - return ( -
-

📊 Ocupabilidad

- - - ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> - -
- - -
- -
- - {loading ? : - error ? : - <> -
- {KPI_CARDS.map(([ico,label,val,sub]) => ( -
-
{ico}
-
{label}
-
{val}
-
{sub}
-
- ))} -
- -
- - - {COLS.map((c, i) => )} - - {datosVisibles.map((d, i) => { - const prog = d.programa_frecuencia || d.dsc_programa || ""; - const dias = parseInt(d.dias_para_inicio); - const pct = toNum(d.Avance_Inscritos); - // Inicio reprogramado (existe en Supabase pero no en SQL este mes) → fila roja - const esInicio = String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO"; - const cel = (v) => esInicio ? "-" : v; - return ( - - - - - - - - - - - - - - - - ); - })} - {datosVisibles.length > 0 && ( - - - - - - - - - - - - )} - -
{c}
{prog}{d.fch_inicio || ""}{d.dias_para_inicio ?? ""}{cel(parseInt(d.Inscritos_Totales)||0)}{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}{cel(parseInt(d.Inscritos_Activos)||0)}{cel(parseInt(d.Meta_Curso)||0)}{esInicio ? "-" : }{cel(parseInt(d.Inscritos_Mes)||0)}{cel(parseInt(d.Inscritos_PC)||0)}{cel(parseInt(d.Descuento)||0)}{cel(parseInt(d.Inscritos_Continuidad)||0)} - {esInicio ? "-" : ( - - )} -
TOTAL GENERAL——{totales.total}{totales.retirados}{totales.activos}{totales.meta}0 ? totales.total/totales.meta*100 : 0} />{totales.mes}{totales.pc}{totales.refri}{totales.cont}—
-
- } - - {modalCurso && ( - setModalCurso(null)} - /> - )} -
- ); -} - -function ModalAlumnosCurso({ numIndice, programa, onClose }) { - const [alumnos, setAlumnos] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let activo = true; - setLoading(true); - api.ocupabilidadAlumnos(numIndice) - .then((res) => { if (activo) { setAlumnos(res.alumnos || []); setLoading(false); } }) - .catch(() => { if (activo) { setAlumnos([]); setLoading(false); } }); - return () => { activo = false; }; - }, [numIndice]); - - const colorEstado = (e) => { - if (e === "ALU") return { bg:"#ecfdf5", fg:"#047857", bd:"#a7f3d0" }; - if (e === "PRE") return { bg:"#eff6ff", fg:"#1e40af", bd:"#bfdbfe" }; - if (e === "RET") return { bg:"#fef2f2", fg:"#b91c1c", bd:"#fecaca" }; - return { bg:"#f1f5f9", fg:"#475569", bd:"#e2e8f0" }; - }; - - const conteo = useMemo(() => { - const c = {}; - alumnos.forEach((a) => { c[a.estado] = (c[a.estado] || 0) + 1; }); - return c; - }, [alumnos]); - - return ( - - {loading ? : - alumnos.length === 0 ?
Sin alumnos.
: - <> -
- - 👥 Total: {alumnos.length} alumno{alumnos.length!==1?"s":""} - - {["ALU","PRE","RET"].map((e)=> conteo[e] ? ( - - {e}: {conteo[e]} - - ) : null)} -
-
- - - - - - - - - {alumnos.map((a, i) => { - const c = colorEstado(a.estado); - const tieneAnt = a.curso_anterior && a.curso_anterior !== "-"; - const cAnt = colorEstado(a.estado_anterior); - return ( - - - - - - - ); - })} - -
ALUMNOESTADOCURSO ANTERIOR (TEAC/TERC)ESTADO ANTERIOR
{a.alumno} - - {a.estado} - - - {a.curso_anterior || "-"} - - {tieneAnt ? ( - - {a.estado_anterior} - - ) : -} -
-
- } -
- ); -} diff --git a/frontend/src/pages/OtrosGeneral.jsx b/frontend/src/pages/OtrosGeneral.jsx new file mode 100644 index 0000000..5b034a5 --- /dev/null +++ b/frontend/src/pages/OtrosGeneral.jsx @@ -0,0 +1,334 @@ +import { useState, useEffect, useMemo, Fragment } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; + +export default function OtrosGeneral() { + const hoy = new Date(); + const [ano, setAno] = useState(String(hoy.getFullYear())); + const [mes, setMes] = useState(String(hoy.getMonth() + 1)); + const [dia, setDia] = useState("TODOS"); + + const [filtros, setFiltros] = useState({ anos: [2026] }); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expAlways, setExpAlways] = useState({}); + const [expAlwaysProg, setExpAlwaysProg] = useState({}); + const [expWeb, setExpWeb] = useState({}); + const [expAsig, setExpAsig] = useState({}); + const [reload, setReload] = useState(0); + + useEffect(() => { + api.leadsFiltros().then(setFiltros).catch(() => {}); + }, []); + + // Auto-refresco cada 15 min (sin F5) + recarga al guardar en Config + useEffect(() => { + const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000); + const onActualizar = () => setReload((n) => n + 1); + window.addEventListener("datos-actualizar", onActualizar); + return () => { clearInterval(id); window.removeEventListener("datos-actualizar", onActualizar); }; + }, []); + + useEffect(() => { + let activo = true; + if (!data) setLoading(true); // spinner solo en la primera carga + setError(null); + api.otrosGeneral(ano, mes, dia) + .then((res) => { if (activo) { setData(res); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, dia, reload]); + + const optAnos = useMemo(() => (filtros.anos || []).map(String), [filtros]); + const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" }, + ...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []); + const optDia = useMemo(() => ["TODOS", ...Array.from({length:31}, (_,i)=>String(i+1))], []); + + const money = (v) => `$${Number(v || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + const sol = (v) => `S/ ${Number(v || 0).toLocaleString("en-US", { maximumFractionDigits: 0 })}`; + + return ( +
+

📊 Otros General

+ + + + setAno(+v)} /> - - setMostrarInscritos(e.target.checked)} /> - Total / Retirados / En curso - -
- - - {loading ? : - error ? : - <> - {kpis && ( -
- {[["📚","Programas",kpis.programas,"este mes"], - ["🎓","En Curso",kpis.enCurso,"alumnos activos"], - ["💵","Venta Actual",kpis.ventaAct,"ingreso total"], - ["💸","Costo Actual",kpis.costoAct,"gasto total"], - ["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>( -
-
{ico}
{label}
-
{val}
{sub}
-
- ))} -
- )} - -
- - - {COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=>)} - - {datos.map((f, ri) => ( - - {f.slice(0,13).map((v,ci)=>{ - if (ocultar(ci)) return null; - const isPct = ci===9||ci===12; - return ( - - ); - })} - - - ))} - {total && ( - - {total.slice(0,13).map((v,ci)=>( - ocultar(ci) ? null : - - ))} - - - )} - -
{c}
{v} - - -
{v}—
-
- } - - {modalVer && setModalVer(null)} />} - {modalCostos && setModalCostos(null)} onGuardado={cargar} />} -
- ); -} - -function ModalVerDetalle({ programa, ano, mes, onClose }) { - const [filas, setFilas] = useState([]); - const [loading, setLoading] = useState(true); - useEffect(() => { - let activo = true; setLoading(true); - api.rentabilidadDetalle(programa, ano, mes) - .then((res)=>{ if(activo){setFilas(res.filas||[]);setLoading(false);} }) - .catch(()=>{ if(activo){setFilas([]);setLoading(false);} }); - return ()=>{activo=false;}; - }, [programa, ano, mes]); - - const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA"]; - - return ( - - {loading ? : - filas.length===0 ?
Sin alumnos.
: -
- - {COLS_DET.map((c)=>)} - - {filas.map((f,i)=>( - - {f.map((v,j)=>( - - ))} - - ))} - -
{c}
=5?"right":(j===1?"left":"center")}}>{v}
-
} -
- ); -} - -function ModalCostos({ programa, ano, mes, onClose, onGuardado }) { - const [costos, setCostos] = useState(null); - const [loading, setLoading] = useState(true); - const [guardando, setGuardando] = useState(false); - const [msg, setMsg] = useState(null); - // [keyGuardar, etiqueta, prefijoBackend] - const CAMPOS = [["epp","COSTO EPP","epp"],["certificado","COSTO CERTIFICADO","cert"],["docente","COSTO DOCENTE","doc"],["marketing","COSTO MARKETING","mkt"],["consumibles","COSTO CONSUMIBLES","cons"]]; - - const [ini, setIni] = useState({}); - const [act, setAct] = useState({}); - - useEffect(() => { - let activo = true; setLoading(true); - api.rentabilidadCostos(programa, ano, mes) - .then((res)=>{ - if(!activo) return; - const c = res.costos || {}; - setCostos(c); - const gi={}, ga={}; - CAMPOS.forEach(([k,,bp])=>{ gi[k]=+(c[`${bp}_inicial`]||0); ga[k]=+(c[`${bp}_actual`]||0); }); - setIni(gi); setAct(ga); setLoading(false); - }) - .catch(()=>{ if(activo){setCostos(null);setLoading(false);} }); - return ()=>{activo=false;}; - }, [programa, ano, mes]); - - const totIni = Object.values(ini).reduce((a,b)=>a+(+b||0),0); - const totAct = Object.values(act).reduce((a,b)=>a+(+b||0),0); - - async function guardar() { - setGuardando(true); setMsg(null); - try { - await api.guardarCostos(costos.num_indice, ini, act); - if (onGuardado) onGuardado(); // recarga la tabla principal - onClose(); // cierra el modal - } catch (e) { - setMsg({ ok:false, txt:"❌ Error al guardar. Verifica Supabase." }); - setGuardando(false); - } - } - - return ( - - {loading ? : - !costos ?
Sin datos de costos.
: - <> -
- - -
- {msg &&
{msg.txt}
} -
- - -
- } -
- ); -} - -function Columna({ titulo, color, campos, valores, setVal, total }) { - return ( -
-
{titulo}
- {campos.map(([k,label])=>( -
- - setVal({...valores,[k]:+e.target.value})} - style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} /> -
- ))} -
- TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})} -
-
- ); -} diff --git a/frontend/src/pages/Roas.jsx b/frontend/src/pages/Roas.jsx new file mode 100644 index 0000000..46671f3 --- /dev/null +++ b/frontend/src/pages/Roas.jsx @@ -0,0 +1,138 @@ +import { useState, useEffect, useMemo, Fragment } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; + +export default function Roas() { + const hoy = new Date(); + const [ano, setAno] = useState(String(hoy.getFullYear())); + const [mes, setMes] = useState(String(hoy.getMonth() + 1)); + + const [filtros, setFiltros] = useState({ anos: [2026] }); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expand, setExpand] = useState({}); // sede -> abierto/cerrado + const [expandP, setExpandP] = useState({}); // "sede|prog" -> abierto/cerrado + const [reload, setReload] = useState(0); + + useEffect(() => { api.leadsFiltros().then(setFiltros).catch(() => {}); }, []); + useEffect(() => { + const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000); + const onActualizar = () => setReload((n) => n + 1); // recarga al guardar en Config + window.addEventListener("roas-actualizar", onActualizar); + window.addEventListener("datos-actualizar", onActualizar); + return () => { clearInterval(id); + window.removeEventListener("roas-actualizar", onActualizar); + window.removeEventListener("datos-actualizar", onActualizar); }; + }, []); + + useEffect(() => { + let activo = true; + if (!data) setLoading(true); + setError(null); + api.roas(ano, mes, "TODOS") + .then((res) => { if (activo) { setData(res); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, reload]); + + const optAnos = useMemo(() => (filtros.anos || []).map(String), [filtros]); + const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" }, + ...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []); + + const soles = (v) => "$" + Number(v || 0).toLocaleString("es-PE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const num = (v) => Number(v || 0).toLocaleString("es-PE"); + + return ( +
+

📈 ROAS

+ + + + + + {loading ? : + error ? : + !data ? : +
+
Inversión por Sede
+
+ + + + + + + + + + {(data.filas || []).map((f) => { + const subs = f.programas || []; + const abierto = !!expand[f.sede]; + return ( + + + + + + + {abierto && subs.map((p) => { + const claveP = f.sede + "|" + p.programa; + const abiertoP = !!expandP[claveP]; + const conjs = p.conjuntos || []; + return ( + + + + + + + {abiertoP && conjs.map((c) => ( + + + + + + ))} + + ); + })} + + ); + })} + {data.total && ( + + + + + + )} + +
SedeImporte GastadoResultados
+ {subs.length > 0 && ( + + )} + {f.sede} + {soles(f.importe)}{num(f.resultados)}
+ {conjs.length > 0 && ( + + )} + {p.programa} + {soles(p.importe)}{num(p.resultados)}
{c.conjunto}{soles(c.importe)}{num(c.resultados)}
Total{soles(data.total.importe)}{num(data.total.resultados)}
+
+
} +
+ ); +} diff --git a/frontend/src/pages/SaldoPendiente.jsx b/frontend/src/pages/SaldoPendiente.jsx deleted file mode 100644 index 62ac521..0000000 --- a/frontend/src/pages/SaldoPendiente.jsx +++ /dev/null @@ -1,96 +0,0 @@ -// src/pages/SaldoPendiente.jsx -import { useState, useEffect, useMemo } from "react"; -import { api } from "../lib/api"; -import { Loader, ErrorBox, Filters, Select } from "../components/UI"; - -const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"]; - -function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } -function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); } - -export default function SaldoPendiente() { - const [tipoCuota, setTipoCuota] = useState("1° Cuota"); - const [datos, setDatos] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [vendedor, setVendedor] = useState("TODOS"); - - useEffect(() => { - let activo = true; - setLoading(true); setError(null); - api.saldoPendiente(tipoCuota) - .then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} }) - .catch((e)=>{ if(activo){setError(e.message);setLoading(false);} }); - return ()=>{activo=false;}; - }, [tipoCuota]); - - const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`; - const vendedores = useMemo(() => { - const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean)); - return ["TODOS", ...Array.from(set).sort()]; - }, [datos]); - - const filtrados = useMemo(() => { - let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01); - if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase()); - return arr; - }, [datos, vendedor, colSaldo]); - - const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]); - const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]); - - const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; - const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; - - return ( -
-

⏳ Saldo Pendiente

- - - - - - {loading ? : - error ? : - <> -
-
👥
Alumnos con Saldo
{filtrados.length}
-
⏳
Saldo {tipoCuota}
{fmt(totalSaldo)}
-
📋
Saldo Matrícula
{fmt(totalMat)}
-
- -
- - {COLS.map((c)=>)} - - {filtrados.map((d,i)=>( - - {KEYS.map((k,j)=>{ - const isMonto = k.includes("SALDO")||k.includes("INV"); - return ( - - ); - })} - - ))} - {filtrados.length>0 && ( - - - - - - - )} - -
{c}
- {isMonto ? fmt(d[k]) : (d[k] ?? "")} -
TOTAL GENERAL ({filtrados.length}){fmt(totalMat)}{fmt(totalSaldo)}——
-
- } -
- ); -} diff --git a/frontend/src/pages/Usuarios.jsx b/frontend/src/pages/Usuarios.jsx deleted file mode 100644 index e4da63d..0000000 --- a/frontend/src/pages/Usuarios.jsx +++ /dev/null @@ -1,130 +0,0 @@ -// src/pages/Usuarios.jsx -import { useState, useEffect, useCallback } from "react"; -import { api } from "../lib/api"; -import { Loader, ErrorBox, Filters, Select } from "../components/UI"; - -const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"]; - -export default function Usuarios() { - const [usuarios, setUsuarios] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // form nuevo usuario - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [nombre, setNombre] = useState(""); - const [rol, setRol] = useState("COBRANZA"); - const [creando, setCreando] = useState(false); - const [msg, setMsg] = useState(null); - - const cargar = useCallback(() => { - setLoading(true); setError(null); - api.usuariosListar() - .then((res) => { setUsuarios(res.usuarios || []); setLoading(false); }) - .catch((e) => { setError(e.message); setLoading(false); }); - }, []); - - useEffect(() => { cargar(); }, [cargar]); - - async function crear() { - setMsg(null); - if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; } - setCreando(true); - try { - await api.usuariosCrear(email.trim(), password, nombre.trim(), rol); - setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` }); - setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA"); - cargar(); - } catch (e) { - setMsg({ ok:false, txt:"Error: " + e.message }); - } - setCreando(false); - } - - async function cambiarRol(id, nuevoRol) { - try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); } - catch (e) { alert("No se pudo cambiar el rol: " + e.message); } - } - - async function eliminar(id, correo) { - if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return; - try { await api.usuariosEliminar(id); cargar(); } - catch (e) { alert("No se pudo eliminar: " + e.message); } - } - - return ( -
-

🔐 Usuarios

- - {/* Crear nuevo usuario */} -
-

➕ Crear nuevo usuario

-
- - setEmail(e.target.value)} placeholder="usuario@escuela.com" style={inp} /> - - - setPassword(e.target.value)} placeholder="mín. 6 caracteres" style={inp} /> - - - setNombre(e.target.value)} placeholder="Nombre completo" style={inp} /> - - - cambiarRol(u.id, e.target.value)} - style={{ padding:"5px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12 }}> - {ROLES.map((r)=>)} - - - - - - - ))} - {usuarios.length === 0 && ( - Sin usuarios. - )} - - -
} -
- ); -} - -function Campo({ label, children }) { - return ( -
- - {children} -
- ); -} - -const inp = { padding:"8px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, minWidth:180 }; diff --git a/frontend/src/pages/Vendedores.jsx b/frontend/src/pages/Vendedores.jsx new file mode 100644 index 0000000..0151d0e --- /dev/null +++ b/frontend/src/pages/Vendedores.jsx @@ -0,0 +1,423 @@ +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import { + AreaChart, Area, BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, +} from "recharts"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; + +export default function Vendedores() { + const _hoy = new Date(); + const [vend, setVend] = useState("TODOS"); + const [ano, setAno] = useState(String(_hoy.getFullYear())); // año actual + const [mes, setMes] = useState(String(_hoy.getMonth() + 1)); // mes actual + const [dia, setDia] = useState("TODOS"); + const [modoTR, setModoTR] = useState("asig"); // "asig" | "resp" para el grafico de tiempo + + const [filtros, setFiltros] = useState({ anos: [2026] }); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [recargando, setRecargando] = useState(false); + const [error, setError] = useState(null); + const [reload, setReload] = useState(0); + + useEffect(() => { api.leadsFiltros().then(setFiltros).catch(() => {}); }, []); + useEffect(() => { + const id = setInterval(() => setReload((n) => n + 1), 15 * 60 * 1000); + const onActualizar = () => setReload((n) => n + 1); // recarga al guardar en Config + window.addEventListener("datos-actualizar", onActualizar); + return () => { clearInterval(id); window.removeEventListener("datos-actualizar", onActualizar); }; + }, []); + + useEffect(() => { + let activo = true; + if (!data) setLoading(true); + setRecargando(true); // atenuar contenido mientras se recalcula + setError(null); + api.vendedores(ano, mes, dia) + .then((res) => { if (activo) { setData(res); setLoading(false); setRecargando(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); setRecargando(false); } }); + return () => { activo = false; }; + }, [ano, mes, dia, reload]); + + const optVend = useMemo(() => ["TODOS", ...((data && data.vendedores) || [])], [data]); + const optAnos = useMemo(() => { + const anos = (filtros.anos || []).map(String); + const actual = String(_hoy.getFullYear()); + if (!anos.includes(actual)) anos.push(actual); // asegura que el año actual sea opcion + return ["TODOS", ...anos]; + }, [filtros]); + const optMes = useMemo(() => [{ value: "TODOS", label: "TODOS" }, + ...MESES.map((m,i)=>({ value: String(i+1), label: m }))], []); + const optDia = useMemo(() => ["TODOS", ...Array.from({length:31}, (_,i)=>String(i+1))], []); + + // Filas visibles: si hay vendedor filtrado, solo esa; si no, todas + const filas = useMemo(() => { + if (!data) return []; + if (vend === "TODOS") return data.filas; + return data.filas.filter((f) => f.vendedor === vend); + }, [data, vend]); + + const num = (v) => Number(v || 0).toLocaleString("es-PE"); + + // Resumen para las tarjetas: si hay 1 vendedor filtrado -> esa fila; si TODOS -> el total + const resumen = useMemo(() => { + if (!data) return null; + if (vend !== "TODOS") return data.filas.find((f) => f.vendedor === vend) || null; + return data.total || null; + }, [data, vend]); + + // Matriz por programa: si hay 1 vendedor -> la suya; si TODOS -> el total + const matrizProg = useMemo(() => { + if (!data) return []; + if (vend !== "TODOS") return (data.matriz_prog && data.matriz_prog[vend]) || []; + return data.matriz_prog_total || []; + }, [data, vend]); + + // Matriz Estado/Objeción (Ultima_Etiqueta): por vendedor o total + const matrizEtiq = useMemo(() => { + if (!data) return []; + if (vend !== "TODOS") return (data.matriz_etiq && data.matriz_etiq[vend]) || []; + return data.matriz_etiq_total || []; + }, [data, vend]); + + // Serie diaria (asignados + matriculas): por vendedor o total + const serieDia = useMemo(() => { + if (!data) return []; + if (vend !== "TODOS") return (data.serie_dia && data.serie_dia[vend]) || []; + return data.serie_dia_total || []; + }, [data, vend]); + + // Serie tiempo de respuesta (promedio min por dia de asignacion) + const serieTR = useMemo(() => { + if (!data) return []; + if (vend !== "TODOS") return (data.tiempo_resp && data.tiempo_resp[vend]) || []; + return data.tiempo_resp_total || []; + }, [data, vend]); + + // Serie tiempo de respuesta (promedio min por dia de RESPUESTA) + const serieTRr = useMemo(() => { + if (!data) return []; + if (vend !== "TODOS") return (data.tiempo_resp_r && data.tiempo_resp_r[vend]) || []; + return data.tiempo_resp_r_total || []; + }, [data, vend]); + + return ( +
+

🧑‍💼 Vendedores

+ + + + + + + {loading ? : + error ? : + !data ? : +
+ {resumen && } + + {/* Gráfico día a día + Estado/Objeción lado a lado */} +
+ + {/* Gráfico día a día: Leads Procesados vs Matrículas */} +
+
+ Leads Asignados y Matrículas por Día{vend !== "TODOS" ? ` — ${vend}` : ""} +
+ + + + + + + + + + + + + + + + + + + + + +
+ + {/* Matriz Estado/Objeción (Ultima_Etiqueta) — al costado del gráfico */} +
+
+ Estado/Objeción{vend !== "TODOS" ? ` — ${vend}` : ""} +
+
+ + + + + + + + + {matrizEtiq.map((e) => ( + + + + + ))} + {matrizEtiq.length > 0 && ( + + + + + )} + +
Estado/ObjeciónLeads Asignados
{e.estado}{num(e.cantidad)}
Total{num(matrizEtiq.reduce((s, e) => s + (e.cantidad || 0), 0))}
+
+
+
+ + + {/* Tiempo de respuesta + Sin responder, lado a lado */} +
+ {(() => { + const esResp = modoTR === "resp"; + const serie = esResp ? serieTRr : serieTR; + const color = esResp ? "#2563eb" : "#f59e0b"; + const colorSoft = esResp ? "#3b82f6" : "#fbbf24"; + return ( +
+
+
+
+ Tiempo de Respuesta por Día (min){vend !== "TODOS" ? ` — ${vend}` : ""} +
+ +
+
+ + + + + + { + if (!active || !payload || !payload.length) return null; + const d = payload[0].payload || {}; + return ( +
+
Día {label}
+
Promedio: {d.promedio_min || 0} min
+
Respondidos{esResp ? " ese día" : ""}: {d.respondidos || 0}
+ {esResp + ?
⚠ Respuesta tardía (+2 días): {d.tardios || 0}
+ :
Sin responder: {d.no_respondidos || 0}
} +
+ ); + }} /> + +
+
+
+
+
+ ); + })()} + + {/* Leads SIN responder por día (fecha de asignación) — barras */} +
+
+
+ + Leads Sin Responder por Día{vend !== "TODOS" ? ` — ${vend}` : ""} +
+
+ {(() => { + const datosNR = serieTR.filter((d) => (d.no_respondidos || 0) >= 1); + if (datosNR.length === 0) { + return ( +
+
✅
+
Sin leads pendientes
+
Todos los leads del periodo fueron respondidos.
+
+ ); + } + return ( + + + + + + + + + + + + { + if (!active || !payload || !payload.length) return null; + const d = payload[0].payload || {}; + return ( +
+
Día {label}
+
Sin responder: {d.no_respondidos || 0}
+
Respondidos: {d.respondidos || 0}
+
+ ); + }} /> + +
+
+ ); + })()} +
+
+
+
+ + {/* Matriz por tipo de programa (al final) */} +
+
+
+ Matrículas por Tipo de Programa{vend !== "TODOS" ? ` — ${vend}` : ""} +
+
+ + + + + + + + + + + {matrizProg.map((p) => ( + + + + + + + ))} + {matrizProg.length > 0 && ( + + + + + + + )} + +
Tipo de ProgramaTotal MatrículasRetiradas% Retirados
{p.programa}{num(p.total)}{num(p.retiradas)}{Number(p.pct_retiradas || 0).toLocaleString("es-PE")}%
Total{num(matrizProg.reduce((s, p) => s + (p.total || 0), 0))}{num(matrizProg.reduce((s, p) => s + (p.retiradas || 0), 0))}{(() => { + const t = matrizProg.reduce((s, p) => s + (p.total || 0), 0); + const r = matrizProg.reduce((s, p) => s + (p.retiradas || 0), 0); + return `${(t ? Math.round(r / t * 1000) / 10 : 0).toLocaleString("es-PE")}%`; + })()}
+
+
+
+ +
} +
+ ); +} + +// ── Tarjetas de resumen: 3 grupos (Cartera / Matrículas / Leads) en lista ───── +function ResumenTarjetas({ r, num }) { + const pct = Number(r.pct_retiradas || 0); + const pctCont = Number(r.pct_contactados || 0); + const grupos = [ + { + titulo: "Cartera", acento: "#2563eb", + filas: [ + { label: "Total", valor: num(r.cartera_total) }, + { label: "Copito", valor: num(r.cartera_copito) }, + { label: "Otros", valor: num(r.cartera_otros) }, + ], + }, + { + titulo: "Matrículas", acento: "#0f766e", + filas: [ + { label: "Total", valor: num(r.mat_total) }, + { label: "Retiradas", valor: num(r.mat_retiradas) }, + { label: "% Retirados", valor: `${pct.toLocaleString("es-PE")}%`, alerta: true }, + ], + }, + { + titulo: "Leads", acento: "#7c3aed", + filas: [ + { label: "Asignados", valor: num(r.leads_asignados) }, + { label: "Contactados", valor: num(r.leads_contactados) }, + { label: "% Contactados", valor: `${pctCont.toLocaleString("es-PE")}%`, resalta: "#7c3aed" }, + ], + }, + ]; + + return ( +
+ {grupos.map((g) => ( +
+
+ {g.titulo} +
+ {g.filas.map((f, i) => ( +
+ {f.label} + + {f.valor} + +
+ ))} +
+ ))} +
+ ); +} diff --git a/frontend/src/pages/Ventas.jsx b/frontend/src/pages/Ventas.jsx deleted file mode 100644 index 108347c..0000000 --- a/frontend/src/pages/Ventas.jsx +++ /dev/null @@ -1,272 +0,0 @@ -// src/pages/Ventas.jsx -import { useState, useEffect, useMemo } from "react"; -import { api } from "../lib/api"; -import { Loader, ErrorBox, Filters, Select } from "../components/UI"; -import Modal from "../components/Modal"; -import { useColumnasAjustables } from "../lib/useColumnasAjustables"; -import { setVistaActual, onRefrescar } from "../lib/vistaActual"; - -const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; -const ANOS = [2024, 2025, 2026]; -const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; -const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; -const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."]; -const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"]; -const HEADERS_DETALLE = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"]; - -export default function Ventas() { - const hoy = new Date(); - const [ano, setAno] = useState(hoy.getFullYear()); - const [mes, setMes] = useState(hoy.getMonth() + 1); - const [sede, setSede] = useState("TODOS"); - const [programa, setPrograma] = useState("TODOS"); - const [filas, setFilas] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [modal, setModal] = useState(null); // { vendedor } - const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]); - - useEffect(() => { - let activo = true; - setVistaActual(`ventas:${ano}|${mes}|${sede}|${programa}|False`); - const cargar = () => { - setLoading(true); setError(null); - api.ventas(ano, mes, sede, programa) - .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) - .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); - }; - cargar(); - const off = onRefrescar(cargar); - return () => { activo = false; off(); }; - }, [ano, mes, sede, programa]); - - const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]); - const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]); - - const KPIS = total ? [ - ["👤","Vendedores", datos.length, "activos este mes"], - ["🎓","Total Inscritos", total[1], "matriculados"], - ["💵","Venta Total", total[2], "inversión neta"], - ["✅","Venta P.C del Mes", total[4], "pagos completos"], - ["🚀","Avance P.C Total", total[7], "P.C + Pendientes"], - ] : []; - - // Exportar saldos por cuota — Excel con formato (anchos, encabezado de color, - // fechas reales, montos numéricos en soles y fila TOTAL GENERAL). - async function exportarCuota(tipoCuota) { - const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }; - // Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original - const toDate = (v) => { - if (!v) return ""; - const s = String(v).trim().replace(/\//g,"-"); - const p = s.split("-"); - if (p.length === 3) { - let [d,m,y] = p; - if (y.length === 4) return new Date(+y, +m-1, +d); - } - return String(v); - }; - - let res; - try { - res = await api.saldoPendiente(tipoCuota); - } catch (e) { - alert("No se pudo obtener los datos de saldos: " + e.message); - return; - } - const datosS = res.datos || []; - const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`; - const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01); - if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; } - - const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; - const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; - const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"]; - const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"]; - - const XLSX = await import("xlsx-js-style"); - - // Construir matriz de celdas (AOA) con tipos correctos - const headerStyle = { - font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 }, - fill: { fgColor: { rgb: "1E40AF" } }, - alignment: { horizontal: "center", vertical: "center" }, - }; - const moneyFmt = '"S/" #,##0'; - const dateFmt = "dd/mm/yyyy"; - - const ws = {}; - const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } }; - - // Encabezado - COLS.forEach((h, c) => { - const ref = XLSX.utils.encode_cell({ r: 0, c }); - ws[ref] = { v: h, t: "s", s: headerStyle }; - }); - - // Filas de datos - filtrados.forEach((d, ri) => { - KEYS.forEach((k, c) => { - const ref = XLSX.utils.encode_cell({ r: ri + 1, c }); - const col = COLS[c]; - if (COL_MONTO.includes(col)) { - ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt }; - } else if (COL_FECHA.includes(col)) { - const dt = toDate(d[k]); - if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt }; - else ws[ref] = { v: dt, t: "s" }; - } else { - ws[ref] = { v: d[k] ?? "", t: "s" }; - } - }); - }); - - // Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA - const rTot = filtrados.length + 1; - const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0); - const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0; - const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } }; - COLS.forEach((col, c) => { - const ref = XLSX.utils.encode_cell({ r: rTot, c }); - if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle }; - else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle }; - else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle }; - else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle }; - else ws[ref] = { v: "", t: "s", s: totalStyle }; - }); - - ws["!ref"] = XLSX.utils.encode_range(range); - ws["!cols"] = [ - { wch: 12 }, // MATRÍCULA - { wch: 22 }, // VENDEDOR - { wch: 28 }, // ALUMNO - { wch: 32 }, // PROGRAMA - { wch: 12 }, // F. INICIO - { wch: 12 }, // F. MATR. - { wch: 13 }, // SALDO MAT. - { wch: 15 }, // SALDO CUOTA - { wch: 13 }, // VENCIMIENTO - { wch: 13 }, // INV. NETA - ]; - - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, "Saldos"); - const nombre = tipoCuota.replace("°","").replace(/\s/g,"_"); - XLSX.writeFile(wb, `saldos_${nombre}.xlsx`); - } - - return ( -
-

🛒 Ventas

- - - ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> - -
- - -
-
- - {loading ? : - error ? : - <> -
- {KPIS.map(([ico,label,val,sub]) => ( -
-
{ico}
-
{label}
-
{val}
-
{sub}
-
- ))} -
- -
- - - {COLS.map((c, i) => )} - - {datos.map((f, i) => ( - - - - - - - - - ))} - {total && ( - - - - - - - - )} - -
{c}
{f[0]}{f[1]}{f[2]}{f[3]}{f[4]}{f[5]}{f[6]}{f[7]} - -
TOTAL GENERAL{total[1]}{total[2]}{total[3]}{total[4]}{total[5]}{total[6]}{total[7]}—
-
- } - - {modal && ( - setModal(null)} /> - )} -
- ); -} - -function ModalDetalle({ vendedor, ano, mes, onClose }) { - const [tipo, setTipo] = useState(TIPOS[0]); - const [filas, setFilas] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let activo = true; - setLoading(true); - api.ventasDetalle(vendedor, ano, mes, tipo) - .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) - .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); - return () => { activo = false; }; - }, [vendedor, ano, mes, tipo]); - - // Columnas redimensionables (igual que las tablas principales) - const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]); - - return ( - -
-