diff --git a/backend b/backend deleted file mode 160000 index c0ee88b..0000000 --- a/backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c0ee88b15338a16dd77b7299713864f4948d07e5 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..f97f94a --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,19 @@ +# === SQL SERVER === +SQL_SERVER=191.98.134.80 +SQL_DATABASE=BDUS_CK000040_0001 +SQL_USERNAME=ASEBASTIAN +SQL_PASSWORD=tu_password_aqui + +# === POSTGRESQL (Chatwoot) === +PG_HOST=191.98.134.81 +PG_DATABASE=chatwoot_production +PG_USER=postgres +PG_PASSWORD=tu_password_aqui +PG_PORT=5432 + +# === SUPABASE === +SUPABASE_URL=tu_url_supabase +SUPABASE_KEY=tu_key_supabase + +# === GITHUB === +GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..bc7d7e6 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +*.pyc +*.xlsx +venv/ +.venv/ diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..b0e975d --- /dev/null +++ b/backend/README.md @@ -0,0 +1,74 @@ +# 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 new file mode 100644 index 0000000..b4ccbec --- /dev/null +++ b/backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000..a28f7da --- /dev/null +++ b/backend/SQL_QUERY_2/BASE_CUOTAS_2.query @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..6df2253 --- /dev/null +++ b/backend/SQL_QUERY_2/BASE_CURSO_2.query @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..a1ded1c --- /dev/null +++ b/backend/SQL_QUERY_2/BASE_FACTURAS_2.query @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..82aada5 --- /dev/null +++ b/backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query @@ -0,0 +1,211 @@ +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/backend/.env.example b/backend/backend/.env.example new file mode 100644 index 0000000..f97f94a --- /dev/null +++ b/backend/backend/.env.example @@ -0,0 +1,19 @@ +# === SQL SERVER === +SQL_SERVER=191.98.134.80 +SQL_DATABASE=BDUS_CK000040_0001 +SQL_USERNAME=ASEBASTIAN +SQL_PASSWORD=tu_password_aqui + +# === POSTGRESQL (Chatwoot) === +PG_HOST=191.98.134.81 +PG_DATABASE=chatwoot_production +PG_USER=postgres +PG_PASSWORD=tu_password_aqui +PG_PORT=5432 + +# === SUPABASE === +SUPABASE_URL=tu_url_supabase +SUPABASE_KEY=tu_key_supabase + +# === GITHUB === +GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD diff --git a/backend/backend/.gitignore b/backend/backend/.gitignore new file mode 100644 index 0000000..bc7d7e6 --- /dev/null +++ b/backend/backend/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +*.pyc +*.xlsx +venv/ +.venv/ diff --git a/backend/backend/README.md b/backend/backend/README.md new file mode 100644 index 0000000..b0e975d --- /dev/null +++ b/backend/backend/README.md @@ -0,0 +1,74 @@ +# 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/backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query b/backend/backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query new file mode 100644 index 0000000..b4ccbec --- /dev/null +++ b/backend/backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query @@ -0,0 +1,59 @@ +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/backend/SQL_QUERY_2/BASE_CUOTAS_2.query b/backend/backend/SQL_QUERY_2/BASE_CUOTAS_2.query new file mode 100644 index 0000000..a28f7da --- /dev/null +++ b/backend/backend/SQL_QUERY_2/BASE_CUOTAS_2.query @@ -0,0 +1,46 @@ +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/backend/SQL_QUERY_2/BASE_CURSO_2.query b/backend/backend/SQL_QUERY_2/BASE_CURSO_2.query new file mode 100644 index 0000000..6df2253 --- /dev/null +++ b/backend/backend/SQL_QUERY_2/BASE_CURSO_2.query @@ -0,0 +1,38 @@ +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/backend/SQL_QUERY_2/BASE_FACTURAS_2.query b/backend/backend/SQL_QUERY_2/BASE_FACTURAS_2.query new file mode 100644 index 0000000..a1ded1c --- /dev/null +++ b/backend/backend/SQL_QUERY_2/BASE_FACTURAS_2.query @@ -0,0 +1,51 @@ +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/backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query b/backend/backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query new file mode 100644 index 0000000..82aada5 --- /dev/null +++ b/backend/backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query @@ -0,0 +1,211 @@ +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/backend/cache_manager.py b/backend/backend/cache_manager.py new file mode 100644 index 0000000..e267bea --- /dev/null +++ b/backend/backend/cache_manager.py @@ -0,0 +1,98 @@ +# backend/cache_manager.py +""" +Caché global en memoria con refresco en segundo plano. +Independiente de Streamlit. Mantiene los datos calientes para respuestas instantáneas. +""" +import time +import threading +from typing import Any, Callable, Dict, Tuple + +_CACHE: Dict[str, Tuple[float, Any]] = {} +_LOCK = threading.Lock() +_DEFAULT_TTL = 900 # 15 min + + +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.""" + 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) + if nuevo is not None: + with _LOCK: + _CACHE[key] = (time.time(), nuevo) + except Exception as e: + print(f"[cache refresh] {key}: {e}") + + +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() + else: + for k in list(_CACHE.keys()): + if k.startswith(prefix + ":"): + del _CACHE[k] + + +def cache_stats() -> dict: + with _LOCK: + 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).""" + global _background_started + if _background_started: + return + _background_started = True + + def _loop(): + while True: + time.sleep(interval) + try: + refresh_fn() + except Exception as e: + print(f"[background refresh] error: {e}") + + t = threading.Thread(target=_loop, daemon=True) + t.start() + print(f"[cache] refresco en segundo plano cada {interval}s iniciado") diff --git a/backend/backend/core/__init__.py b/backend/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/backend/core/data_manager.py b/backend/backend/core/data_manager.py new file mode 100644 index 0000000..8703115 --- /dev/null +++ b/backend/backend/core/data_manager.py @@ -0,0 +1,794 @@ +# 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 = "" + + 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): + try: + r = requests.get(url, timeout=4) + r.raise_for_status() + return r.json() + except: + return default or {} + + def _get_text(self, url): + try: + r = requests.get(url, timeout=4) + r.raise_for_status() + return r.text + except: + 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={{SQL Server}};" + 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 + + # 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_mes_pasado and saldos_ok 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): + 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() + return results + except Exception as e: + print(f"❌ Error cronograma cobranza: {e}") + return [] + + def ejecutar_consulta_facturas_cobranza(self, ano): + 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() + return results + except Exception as e: + print(f"❌ Error facturas cobranza: {e}") + return [] + + 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/backend/main.py b/backend/backend/main.py new file mode 100644 index 0000000..aadf7dc --- /dev/null +++ b/backend/backend/main.py @@ -0,0 +1,410 @@ +# 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 +from fastapi.middleware.cors import CORSMiddleware +from datetime import datetime +import uvicorn + +import services +from cache_manager import start_background_refresh, cache_invalidate, cache_stats + +app = FastAPI(title="Dashboard API", version="1.0") + +# 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_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) + 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(): + return cache_stats() + + +@app.post("/api/cache/refresh") +def cache_refresh(): + services.refrescar_todo() + return {"status": "refrescado"} + + +@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"), + programa: str = Query("TODOS"), +): + try: + return services.ocupabilidad(ano, mes, sede, programa) + 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")): + try: + return services.ventas(ano, mes, sede, programa) + 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) + 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"), +): + try: + return services.cobranza(ano, mes, sectorista, agrupacion) + 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"), +): + try: + return services.cobranza_detalle(grupo, ano, mes, sectorista, agrupacion) + 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"), +): + try: + return services.rentabilidad(ano, mes, sede, programa) + 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(...), +): + try: + return services.rentabilidad_detalle(programa, ano, mes) + 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(...), +): + try: + return services.rentabilidad_costos(programa, ano, mes) + 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")): + try: + return services.saldo_pendiente(tipo_cuota) + 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(...)): + 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)") + 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(...)): + try: + nombres = payload.get("programas", []) + return {"mapa": services.clasificar_programas(nombres)} + 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.""" + 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, + } + 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"): + 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/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} + 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) diff --git a/backend/backend/modules/__init__.py b/backend/backend/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/backend/modules/asesores/__init__.py b/backend/backend/modules/asesores/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/backend/modules/asesores/processor.py b/backend/backend/modules/asesores/processor.py new file mode 100644 index 0000000..c793669 --- /dev/null +++ b/backend/backend/modules/asesores/processor.py @@ -0,0 +1,86 @@ +# 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/backend/modules/cobranza/__init__.py b/backend/backend/modules/cobranza/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/backend/modules/cobranza/logic.py b/backend/backend/modules/cobranza/logic.py new file mode 100644 index 0000000..fbd9177 --- /dev/null +++ b/backend/backend/modules/cobranza/logic.py @@ -0,0 +1,221 @@ +# 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"): + datos_brutos = self.processor.obtener_datos_procesados(ano, mes, sectorista, agrupacion) + + 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): + datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, programa) + + 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', '-') + + 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}" + ] + 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}" + ] + 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%' + + 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/backend/modules/cobranza/processor.py b/backend/backend/modules/cobranza/processor.py new file mode 100644 index 0000000..5747134 --- /dev/null +++ b/backend/backend/modules/cobranza/processor.py @@ -0,0 +1,734 @@ +# 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 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 obtener_datos_procesados(self, ano, mes, sectorista_filtro="TODOS", agrupacion="SEDE"): + fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes) + hoy = datetime.now() + 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() + + 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) + + 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): + fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes) + hoy = datetime.now() + + 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) + 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) + + 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/backend/modules/ocupabilidad/__init__.py b/backend/backend/modules/ocupabilidad/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/backend/modules/ocupabilidad/logic.py b/backend/backend/modules/ocupabilidad/logic.py new file mode 100644 index 0000000..7605995 --- /dev/null +++ b/backend/backend/modules/ocupabilidad/logic.py @@ -0,0 +1,237 @@ +# 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] + + # Auto-corrección si el programa no existe en la sede actual + if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles: + filtro_prog = "TODOS" + self.filtro_corregido = "TODOS" + else: + 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/backend/modules/ocupabilidad/processor.py b/backend/backend/modules/ocupabilidad/processor.py new file mode 100644 index 0000000..eb8ebdd --- /dev/null +++ b/backend/backend/modules/ocupabilidad/processor.py @@ -0,0 +1,560 @@ +# 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 [] + + 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/backend/modules/rentabilidad/__init__.py b/backend/backend/modules/rentabilidad/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/backend/modules/rentabilidad/logic.py b/backend/backend/modules/rentabilidad/logic.py new file mode 100644 index 0000000..bd34710 --- /dev/null +++ b/backend/backend/modules/rentabilidad/logic.py @@ -0,0 +1,950 @@ +# 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] + + # AUTO-CORRECCIÓN: Si el programa elegido ya no existe en la nueva sede, forzamos a TODOS + if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles: + filtro_prog = "TODOS" + self.filtro_corregido = "TODOS" + else: + 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/backend/modules/rentabilidad/processor.py b/backend/backend/modules/rentabilidad/processor.py new file mode 100644 index 0000000..f7e3c06 --- /dev/null +++ b/backend/backend/modules/rentabilidad/processor.py @@ -0,0 +1,290 @@ +# 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/backend/modules/saldo_pendiente/__init__.py b/backend/backend/modules/saldo_pendiente/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/backend/modules/saldo_pendiente/logic.py b/backend/backend/modules/saldo_pendiente/logic.py new file mode 100644 index 0000000..6fb8573 --- /dev/null +++ b/backend/backend/modules/saldo_pendiente/logic.py @@ -0,0 +1,193 @@ +# 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/backend/modules/saldo_pendiente/processor.py b/backend/backend/modules/saldo_pendiente/processor.py new file mode 100644 index 0000000..ecce7f9 --- /dev/null +++ b/backend/backend/modules/saldo_pendiente/processor.py @@ -0,0 +1,162 @@ +# 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/backend/modules/ventas/__init__.py b/backend/backend/modules/ventas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/backend/modules/ventas/logic.py b/backend/backend/modules/ventas/logic.py new file mode 100644 index 0000000..80983e9 --- /dev/null +++ b/backend/backend/modules/ventas/logic.py @@ -0,0 +1,736 @@ +# 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 + + # 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()] + + # 🔥 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_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") + + # MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro + if matricula_mes_pasado and saldos_ok and pago_en_fecha: + 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/backend/requirements.txt b/backend/backend/requirements.txt new file mode 100644 index 0000000..1c2d5da --- /dev/null +++ b/backend/backend/requirements.txt @@ -0,0 +1,10 @@ +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 diff --git a/backend/backend/services.py b/backend/backend/services.py new file mode 100644 index 0000000..11702b5 --- /dev/null +++ b/backend/backend/services.py @@ -0,0 +1,587 @@ +# 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 + +# Instancia única del DataManager (como @st.cache_resource) +_DM = None + + +def get_dm() -> DataManager: + global _DM + if _DM is None: + _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 + + +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 + + +# ── OCUPABILIDAD ─────────────────────────────────────────────────────────── +def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"): + 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) + + +# ── VENTAS ───────────────────────────────────────────────────────────────── +def ventas(ano, mes, sede="TODOS", programa="TODOS"): + def _load(): + dm = get_dm() + 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: inversion_neta override} + 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'] + 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), _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() + 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 + return out + except Exception: + return {} + + +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() + out = {} + for r in (res.data or []): + v = r.get("inversion_neta") + if v is None: + continue + s = str(v).replace("S/", "").replace(",", "").strip() + if s == "": + continue + try: + out[str(r["num_matricula"])] = float(s) + except Exception: + pass + return out + except Exception: + return {} + + +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: + 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 + + +# ── COBRANZA ─────────────────────────────────────────────────────────────── +def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE"): + def _load(): + dm = get_dm() + logic = CobranzaLogic(dm) + filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion) + sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes)) + return { + "filas": _serializar_filas(filas), + "sectoristas": sectoristas or ["TODOS"], + } + return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion), _load) + + +def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"): + def _load(): + dm = get_dm() + logic = CobranzaLogic(dm) + if agrupacion == "ASESOR": + datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS") + elif agrupacion == "SEDE": + # Filtrar por sede usando el MISMO clasificador del processor (sede.json) + crudos = logic.processor.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(logic.processor.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") + # 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) + return {"filas": _serializar_filas(datos)} + return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion), _load) + + +# ── RENTABILIDAD ─────────────────────────────────────────────────────────── +def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"): + 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) + + +def rentabilidad_detalle(programa, ano, mes): + 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) + + +def rentabilidad_costos(programa, ano, mes): + 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) + + +# ── SALDO PENDIENTE ──────────────────────────────────────────────────────── +def saldo_pendiente(tipo_cuota): + 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) + + +# ── 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}") + + +_refresh_count = 0 + +def _recalcular_clave(key): + """Dada una clave 'prefijo:arg1|arg2|...', recalcula su valor sin usar el caché viejo.""" + from cache_manager import cache_invalidate + 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 == "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]) + except Exception as e: + print(f"[recalcular] {key}: {e}") + return None # los detalles puntuales se recalculan al pedirse + + +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: + cache_refresh_existing(_recalcular_clave) + + +# ── 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 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 + +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/backend/cache_manager.py b/backend/cache_manager.py new file mode 100644 index 0000000..e267bea --- /dev/null +++ b/backend/cache_manager.py @@ -0,0 +1,98 @@ +# backend/cache_manager.py +""" +Caché global en memoria con refresco en segundo plano. +Independiente de Streamlit. Mantiene los datos calientes para respuestas instantáneas. +""" +import time +import threading +from typing import Any, Callable, Dict, Tuple + +_CACHE: Dict[str, Tuple[float, Any]] = {} +_LOCK = threading.Lock() +_DEFAULT_TTL = 900 # 15 min + + +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.""" + 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) + if nuevo is not None: + with _LOCK: + _CACHE[key] = (time.time(), nuevo) + except Exception as e: + print(f"[cache refresh] {key}: {e}") + + +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() + else: + for k in list(_CACHE.keys()): + if k.startswith(prefix + ":"): + del _CACHE[k] + + +def cache_stats() -> dict: + with _LOCK: + 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).""" + global _background_started + if _background_started: + return + _background_started = True + + def _loop(): + while True: + time.sleep(interval) + try: + refresh_fn() + except Exception as e: + print(f"[background refresh] error: {e}") + + t = threading.Thread(target=_loop, daemon=True) + t.start() + print(f"[cache] refresco en segundo plano cada {interval}s iniciado") diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/data_manager.py b/backend/core/data_manager.py new file mode 100644 index 0000000..8703115 --- /dev/null +++ b/backend/core/data_manager.py @@ -0,0 +1,794 @@ +# 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 = "" + + 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): + try: + r = requests.get(url, timeout=4) + r.raise_for_status() + return r.json() + except: + return default or {} + + def _get_text(self, url): + try: + r = requests.get(url, timeout=4) + r.raise_for_status() + return r.text + except: + 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={{SQL Server}};" + 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 + + # 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_mes_pasado and saldos_ok 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): + 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() + return results + except Exception as e: + print(f"❌ Error cronograma cobranza: {e}") + return [] + + def ejecutar_consulta_facturas_cobranza(self, ano): + 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() + return results + except Exception as e: + print(f"❌ Error facturas cobranza: {e}") + return [] + + 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/main.py b/backend/main.py new file mode 100644 index 0000000..aadf7dc --- /dev/null +++ b/backend/main.py @@ -0,0 +1,410 @@ +# 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 +from fastapi.middleware.cors import CORSMiddleware +from datetime import datetime +import uvicorn + +import services +from cache_manager import start_background_refresh, cache_invalidate, cache_stats + +app = FastAPI(title="Dashboard API", version="1.0") + +# 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_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) + 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(): + return cache_stats() + + +@app.post("/api/cache/refresh") +def cache_refresh(): + services.refrescar_todo() + return {"status": "refrescado"} + + +@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"), + programa: str = Query("TODOS"), +): + try: + return services.ocupabilidad(ano, mes, sede, programa) + 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")): + try: + return services.ventas(ano, mes, sede, programa) + 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) + 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"), +): + try: + return services.cobranza(ano, mes, sectorista, agrupacion) + 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"), +): + try: + return services.cobranza_detalle(grupo, ano, mes, sectorista, agrupacion) + 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"), +): + try: + return services.rentabilidad(ano, mes, sede, programa) + 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(...), +): + try: + return services.rentabilidad_detalle(programa, ano, mes) + 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(...), +): + try: + return services.rentabilidad_costos(programa, ano, mes) + 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")): + try: + return services.saldo_pendiente(tipo_cuota) + 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(...)): + 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)") + 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(...)): + try: + nombres = payload.get("programas", []) + return {"mapa": services.clasificar_programas(nombres)} + 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.""" + 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, + } + 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"): + 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/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} + 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) diff --git a/backend/modules/__init__.py b/backend/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/asesores/__init__.py b/backend/modules/asesores/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/asesores/processor.py b/backend/modules/asesores/processor.py new file mode 100644 index 0000000..c793669 --- /dev/null +++ b/backend/modules/asesores/processor.py @@ -0,0 +1,86 @@ +# 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 new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/cobranza/logic.py b/backend/modules/cobranza/logic.py new file mode 100644 index 0000000..fbd9177 --- /dev/null +++ b/backend/modules/cobranza/logic.py @@ -0,0 +1,221 @@ +# 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"): + datos_brutos = self.processor.obtener_datos_procesados(ano, mes, sectorista, agrupacion) + + 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): + datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, programa) + + 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', '-') + + 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}" + ] + 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}" + ] + 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%' + + 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 new file mode 100644 index 0000000..5747134 --- /dev/null +++ b/backend/modules/cobranza/processor.py @@ -0,0 +1,734 @@ +# 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 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 obtener_datos_procesados(self, ano, mes, sectorista_filtro="TODOS", agrupacion="SEDE"): + fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes) + hoy = datetime.now() + 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() + + 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) + + 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): + fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes) + hoy = datetime.now() + + 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) + 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) + + 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 new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/ocupabilidad/logic.py b/backend/modules/ocupabilidad/logic.py new file mode 100644 index 0000000..7605995 --- /dev/null +++ b/backend/modules/ocupabilidad/logic.py @@ -0,0 +1,237 @@ +# 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] + + # Auto-corrección si el programa no existe en la sede actual + if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles: + filtro_prog = "TODOS" + self.filtro_corregido = "TODOS" + else: + 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 new file mode 100644 index 0000000..eb8ebdd --- /dev/null +++ b/backend/modules/ocupabilidad/processor.py @@ -0,0 +1,560 @@ +# 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 [] + + 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 new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/rentabilidad/logic.py b/backend/modules/rentabilidad/logic.py new file mode 100644 index 0000000..bd34710 --- /dev/null +++ b/backend/modules/rentabilidad/logic.py @@ -0,0 +1,950 @@ +# 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] + + # AUTO-CORRECCIÓN: Si el programa elegido ya no existe en la nueva sede, forzamos a TODOS + if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles: + filtro_prog = "TODOS" + self.filtro_corregido = "TODOS" + else: + 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 new file mode 100644 index 0000000..f7e3c06 --- /dev/null +++ b/backend/modules/rentabilidad/processor.py @@ -0,0 +1,290 @@ +# 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 new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/saldo_pendiente/logic.py b/backend/modules/saldo_pendiente/logic.py new file mode 100644 index 0000000..6fb8573 --- /dev/null +++ b/backend/modules/saldo_pendiente/logic.py @@ -0,0 +1,193 @@ +# 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 new file mode 100644 index 0000000..ecce7f9 --- /dev/null +++ b/backend/modules/saldo_pendiente/processor.py @@ -0,0 +1,162 @@ +# 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 new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/ventas/logic.py b/backend/modules/ventas/logic.py new file mode 100644 index 0000000..80983e9 --- /dev/null +++ b/backend/modules/ventas/logic.py @@ -0,0 +1,736 @@ +# 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 + + # 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()] + + # 🔥 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_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") + + # MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro + if matricula_mes_pasado and saldos_ok and pago_en_fecha: + 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 new file mode 100644 index 0000000..1c2d5da --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +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 diff --git a/backend/services.py b/backend/services.py new file mode 100644 index 0000000..11702b5 --- /dev/null +++ b/backend/services.py @@ -0,0 +1,587 @@ +# 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 + +# Instancia única del DataManager (como @st.cache_resource) +_DM = None + + +def get_dm() -> DataManager: + global _DM + if _DM is None: + _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 + + +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 + + +# ── OCUPABILIDAD ─────────────────────────────────────────────────────────── +def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"): + 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) + + +# ── VENTAS ───────────────────────────────────────────────────────────────── +def ventas(ano, mes, sede="TODOS", programa="TODOS"): + def _load(): + dm = get_dm() + 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: inversion_neta override} + 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'] + 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), _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() + 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 + return out + except Exception: + return {} + + +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() + out = {} + for r in (res.data or []): + v = r.get("inversion_neta") + if v is None: + continue + s = str(v).replace("S/", "").replace(",", "").strip() + if s == "": + continue + try: + out[str(r["num_matricula"])] = float(s) + except Exception: + pass + return out + except Exception: + return {} + + +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: + 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 + + +# ── COBRANZA ─────────────────────────────────────────────────────────────── +def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE"): + def _load(): + dm = get_dm() + logic = CobranzaLogic(dm) + filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion) + sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes)) + return { + "filas": _serializar_filas(filas), + "sectoristas": sectoristas or ["TODOS"], + } + return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion), _load) + + +def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"): + def _load(): + dm = get_dm() + logic = CobranzaLogic(dm) + if agrupacion == "ASESOR": + datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS") + elif agrupacion == "SEDE": + # Filtrar por sede usando el MISMO clasificador del processor (sede.json) + crudos = logic.processor.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(logic.processor.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") + # 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) + return {"filas": _serializar_filas(datos)} + return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion), _load) + + +# ── RENTABILIDAD ─────────────────────────────────────────────────────────── +def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"): + 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) + + +def rentabilidad_detalle(programa, ano, mes): + 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) + + +def rentabilidad_costos(programa, ano, mes): + 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) + + +# ── SALDO PENDIENTE ──────────────────────────────────────────────────────── +def saldo_pendiente(tipo_cuota): + 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) + + +# ── 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}") + + +_refresh_count = 0 + +def _recalcular_clave(key): + """Dada una clave 'prefijo:arg1|arg2|...', recalcula su valor sin usar el caché viejo.""" + from cache_manager import cache_invalidate + 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 == "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]) + except Exception as e: + print(f"[recalcular] {key}: {e}") + return None # los detalles puntuales se recalculan al pedirse + + +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: + cache_refresh_existing(_recalcular_clave) + + +# ── 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 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 + +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/frontend b/frontend deleted file mode 160000 index d6c65c4..0000000 --- a/frontend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d6c65c44b1d48ad9a33e2fe0a37e26c12713777d diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..b9e8804 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# 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 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..dd8fe26 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.env diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..0c04ae2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,29 @@ +# 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/frontend/.env.example b/frontend/frontend/.env.example new file mode 100644 index 0000000..b9e8804 --- /dev/null +++ b/frontend/frontend/.env.example @@ -0,0 +1,5 @@ +# 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 diff --git a/frontend/frontend/.gitignore b/frontend/frontend/.gitignore new file mode 100644 index 0000000..dd8fe26 --- /dev/null +++ b/frontend/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.env diff --git a/frontend/frontend/README.md b/frontend/frontend/README.md new file mode 100644 index 0000000..0c04ae2 --- /dev/null +++ b/frontend/frontend/README.md @@ -0,0 +1,29 @@ +# 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/frontend/index.html b/frontend/frontend/index.html new file mode 100644 index 0000000..4f9f9a2 --- /dev/null +++ b/frontend/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Dashboard — Escuela de Refrigeración + + +
+ + + diff --git a/frontend/frontend/package-lock.json b/frontend/frontend/package-lock.json new file mode 100644 index 0000000..fb250a2 --- /dev/null +++ b/frontend/frontend/package-lock.json @@ -0,0 +1,1980 @@ +{ + "name": "dashboard-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard-frontend", + "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" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "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==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "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==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "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", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "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==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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" + }, + "engines": { + "node": ">=0.8" + } + }, + "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", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "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" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "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==", + "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", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "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/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "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", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/frontend/package.json b/frontend/frontend/package.json new file mode 100644 index 0000000..1000243 --- /dev/null +++ b/frontend/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "dashboard-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "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" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } +} diff --git a/frontend/frontend/src/App.jsx b/frontend/frontend/src/App.jsx new file mode 100644 index 0000000..a6ccd5d --- /dev/null +++ b/frontend/frontend/src/App.jsx @@ -0,0 +1,65 @@ +// src/App.jsx +import { useState, useEffect } from "react"; +import { useAuth } from "./lib/auth"; +import Sidebar from "./components/Sidebar"; +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"; + +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 ; + + 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; + } + } + + return ( +
+ +
{render()}
+
+ ); +} + +function SinAcceso() { + return ( +
+ 🔒 No tienes acceso a este módulo. +
+ ); +} diff --git a/frontend/frontend/src/components/Modal.jsx b/frontend/frontend/src/components/Modal.jsx new file mode 100644 index 0000000..2ad4c3e --- /dev/null +++ b/frontend/frontend/src/components/Modal.jsx @@ -0,0 +1,34 @@ +// 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/frontend/src/components/Sidebar.jsx b/frontend/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..39170b9 --- /dev/null +++ b/frontend/frontend/src/components/Sidebar.jsx @@ -0,0 +1,54 @@ +// src/components/Sidebar.jsx +import { useAuth } from "../lib/auth"; + +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" }, +]; + +export default function Sidebar({ active, onChange }) { + const { perfil, permisos, logout } = useAuth(); + const visibles = MENU.filter((m) => permisos.includes(m.id)); + + return ( + + ); +} diff --git a/frontend/frontend/src/components/UI.jsx b/frontend/frontend/src/components/UI.jsx new file mode 100644 index 0000000..3ab9ea6 --- /dev/null +++ b/frontend/frontend/src/components/UI.jsx @@ -0,0 +1,51 @@ +// src/components/UI.jsx +export function Loader({ text = "Cargando..." }) { + return ( +
+
+
{text}
+
+ ); +} + +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 }) { + return ( +
+ {label && } + +
+ ); +} diff --git a/frontend/frontend/src/lib/api.js b/frontend/frontend/src/lib/api.js new file mode 100644 index 0000000..958d367 --- /dev/null +++ b/frontend/frontend/src/lib/api.js @@ -0,0 +1,73 @@ +// 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"; + +async function get(path, params = {}) { + const qs = new URLSearchParams(params).toString(); + const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; + const r = await fetch(url); + if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); + return r.json(); +} + +async function postJson(path, body) { + const r = await fetch(`${BASE_URL}${path}`, { + 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(); +} + +export const api = { + periodoActual: () => get("/api/periodo-actual"), + ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => + get("/api/ocupabilidad", { ano, mes, sede, programa }), + ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => get("/api/ventas", { ano, mes, sede, programa }), + 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") => + get("/api/cobranza", { ano, mes, sectorista, agrupacion }), + cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") => + get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }), + 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 }), + 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), +}; diff --git a/frontend/frontend/src/lib/auth.jsx b/frontend/frontend/src/lib/auth.jsx new file mode 100644 index 0000000..e218452 --- /dev/null +++ b/frontend/frontend/src/lib/auth.jsx @@ -0,0 +1,106 @@ +// 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); // { nombre, rol } + const [permisos, setPermisos] = useState([]); // ["ocupabilidad", "cobranza", ...] + const [cargando, setCargando] = useState(true); + + // Cargar perfil (rol) y permisos del usuario logueado + const cargarPerfil = useCallback(async (userId) => { + // 1) Traer el rol del usuario desde la tabla "perfiles" + 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; + } + + // 2) Traer los módulos permitidos para ese rol desde "permisos" + 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; + }, []); + + // Al iniciar: revisar si ya hay sesión activa + useEffect(() => { + let activo = true; + supabase.auth.getSession().then(async ({ data }) => { + if (!activo) return; + const s = data?.session || null; + setSession(s); + if (s?.user) await cargarPerfil(s.user.id); + setCargando(false); + }); + + // Escuchar cambios de sesión (login/logout) + const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => { + setSession(s); + if (s?.user) await cargarPerfil(s.user.id); + else { setPerfil(null); setPermisos([]); } + }); + + return () => { activo = false; 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) }; + // Verificar que tenga perfil/rol válido + 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/frontend/src/lib/supabase.js b/frontend/frontend/src/lib/supabase.js new file mode 100644 index 0000000..a3de4b6 --- /dev/null +++ b/frontend/frontend/src/lib/supabase.js @@ -0,0 +1,10 @@ +// 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"; + +// Estos valores se leen de frontend/.env (ver .env.example) +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); diff --git a/frontend/frontend/src/lib/useColumnasAjustables.jsx b/frontend/frontend/src/lib/useColumnasAjustables.jsx new file mode 100644 index 0000000..88732ee --- /dev/null +++ b/frontend/frontend/src/lib/useColumnasAjustables.jsx @@ -0,0 +1,63 @@ +// src/lib/useColumnasAjustables.jsx +// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla. +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); + } + }, [anchosIniciales.length]); + + function iniciarResize(e, i) { + e.preventDefault(); + e.stopPropagation(); + const xInicial = e.clientX; + const anchoInicial = anchos[i]; + function onMove(ev) { + const nuevo = Math.max(50, 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); + } + + // Devuelve los elementos para el de la tabla + function ColGroup() { + return ( + + {anchos.map((w, i) => )} + + ); + } + + // Devuelve el divisor arrastrable para poner dentro de cada + function Resizer({ index }) { + return ( + iniciarResize(e, index)} + className="col-resizer" + style={{ position: "absolute", top: 0, right: 0, width: 8, height: "100%", + cursor: "col-resize", userSelect: "none", zIndex: 5 }} + /> + ); + } + + const anchoTotal = anchos.reduce((a, b) => 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 }, + }; + + return { anchos, ColGroup, Resizer, anchoTotal, tableProps }; +} diff --git a/frontend/frontend/src/main.jsx b/frontend/frontend/src/main.jsx new file mode 100644 index 0000000..8fd46bf --- /dev/null +++ b/frontend/frontend/src/main.jsx @@ -0,0 +1,13 @@ +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/frontend/src/pages/Asesores.jsx b/frontend/frontend/src/pages/Asesores.jsx new file mode 100644 index 0000000..d71ec72 --- /dev/null +++ b/frontend/frontend/src/pages/Asesores.jsx @@ -0,0 +1,105 @@ +// 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/frontend/src/pages/Cobranza.jsx b/frontend/frontend/src/pages/Cobranza.jsx new file mode 100644 index 0000000..742498a --- /dev/null +++ b/frontend/frontend/src/pages/Cobranza.jsx @@ -0,0 +1,660 @@ +// src/pages/Cobranza.jsx +import { useState, useEffect, useMemo } 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"; + +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:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}]; +const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"]; +const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"]; + +// 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"); + 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 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; + setLoading(true); setError(null); + api.cobranza(ano, mes, sectorista, agrupacion) + .then((res) => { + if (!activo) return; + setFilas(res.filas || []); + setSectoristas(res.sectoristas || ["TODOS"]); + setLoading(false); + }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, agrupacion, sectorista]); + + // 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]); + + // 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 datos = 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; + }, [filas, esPrograma, sede, frec, sedeMap]); + + // 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 headers = esPrograma + ? ["PROGRAMA","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."] + : ["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."]; + + // Anchos para columnas redimensionables (1ª columna más ancha, resto estándar) + const anchosCob = headers.map((h, i) => i === 0 ? (esPrograma ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120)); + const cols = useColumnasAjustables(anchosCob); + + function cartClase(ci) { + if (IDX.ant.includes(ci)) return "cart-ant"; + if (IDX.mes.includes(ci)) return "cart-mes"; + if (IDX.tot.includes(ci)) return "cart-tot"; + return ""; + } + + // 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); + } + return arr; + }, [alumnos, esPrograma, sede, frec]); + + function grupoDe(a) { + if (agrupacion === "SEDE") return a.sede; + if (agrupacion === "ASESOR") return ""; // el sectorista ya filtra; no hay sub-grupo por alumno + return a.programa; // PROGRAMA + } + + // 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; } + + const toDate = (v) => { + if (!v || v === "-") return ""; + const s = String(v).trim().replace(/\//g,"-"); + const p = s.split("-"); + if (p.length === 3 && p[2].length === 4) return new Date(+p[2], +p[1]-1, +p[0]); + return String(v); + }; + 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 if (c===4 && v instanceof Date) ws[ref] = { v, t:"d", z:dateFmt }; + else ws[ref] = { v: v ?? "", t:"s" }; + }); + }); + + // 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 && } +
+ + +
+
+ + {loading ? : + error ? : + <> + + +
+ + + {headers.map((h, i)=>)} + + {datos.map((f, ri) => { + // En la tabla principal SIEMPRE son agrupaciones (Sede/Programa/Asesor), + // nunca alumnos individuales → NO aplicar rojo por deuda aquí. + // Regla "-": si % del grupo está vacío, CTA y COB del grupo → "-" + const antVacio = !String(f[IDX.ant[2]]||"").trim() || ["-","—"].includes(String(f[IDX.ant[2]]).trim()); + const mesVacio = !String(f[IDX.mes[2]]||"").trim() || ["-","—"].includes(String(f[IDX.mes[2]]).trim()); + const totVacio = !String(f[IDX.tot[2]]||"").trim() || ["-","—"].includes(String(f[IDX.tot[2]]).trim()); + return ( + + {f.slice(0, headers.length-1).map((v, ci) => { + const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; + let val = v; + if ((ci===IDX.ant[0]||ci===IDX.ant[1]||ci===IDX.ant[2]) && antVacio) val = "—"; + if ((ci===IDX.mes[0]||ci===IDX.mes[1]||ci===IDX.mes[2]) && mesVacio) val = "—"; + if ((ci===IDX.tot[0]||ci===IDX.tot[1]||ci===IDX.tot[2]) && totVacio) val = "—"; + const cartCls = cartClase(ci); + const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:""); + return ( + + ); + })} + + + ); + })} + {datos.length > 0 && (() => { + // Fila TOTAL GENERAL — suma de los datos visibles (respeta filtros) + const t = {}; + [IDX.ant[0],IDX.ant[1],IDX.mes[0],IDX.mes[1],IDX.tot[0],IDX.tot[1],IDX.saldo] + .forEach((ci)=>{ t[ci] = datos.reduce((s,f)=>s+toMonto(f[ci]),0); }); + const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : ""; + const cell = (ci) => { + if (ci===0) return "TOTAL GENERAL"; + if (ci===IDX.ant[2]) return ratio(t[IDX.ant[1]],t[IDX.ant[0]]); + if (ci===IDX.mes[2]) return ratio(t[IDX.mes[1]],t[IDX.mes[0]]); + if (ci===IDX.tot[2]) return ratio(t[IDX.tot[1]],t[IDX.tot[0]]); + if (t[ci]!==undefined) return "S/ " + t[ci].toLocaleString("es-PE",{maximumFractionDigits:0}); + return ""; + }; + return ( + + {Array.from({length: headers.length-1}).map((_,ci)=>{ + const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; + const val = cell(ci); + return ( + + ); + })} + + + ); + })()} + +
{h}
+ {isPct ? (val==="—" ? : ) : fmtMoneda(val)} + + +
+ {isPct ? : val} +
+
+ } + + {modal && ( + setModal(null)} /> + )} + + {buscadorAbierto && ( + setBuscadorAbierto(false)} + /> + )} +
+ ); +} + +function Tarjetas({ kpis }) { + const alDia = kpis.saldo <= 0.01; + if (alDia) { + return ( +
+
+
AL DÍA
+
Sin deuda pendiente
+
+ ); + } + const cards = [ + ["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt], + ["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes], + ["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot], + ]; + return ( +
+ {cards.map(([titulo,color,cta,cob]) => { + 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 }) { + const cards = [ + ["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt], + ["📅 MES EN CURSO","#059669",r.cMes,r.obMes], + ["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot], + ]; + return ( +
+ {cards.map(([titulo,color,cta,cob]) => { + 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 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 = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.", + "CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + 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)=>)} + + {resultados.map((a,i)=>{ + const deuda = a.saldo > 0.01; + const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa); + const celdas = [ + {v:grp, cls:"col-name", al:"left"}, + {v:a.matricula}, + {v:a.alumno, cls:"col-name", al:"left"}, + {v:a.num_cuota}, + {v:a.fch_venc}, + {v:fmtMoneda("S/ "+a.cta_ant), al:"right", cart:"cart-ant"}, + {v:fmtMoneda("S/ "+a.cob_ant), al:"right", cart:"cart-ant"}, + {pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"}, + {v:fmtMoneda("S/ "+a.cta_cur), al:"right", cart:"cart-mes"}, + {v:fmtMoneda("S/ "+a.cob_cur), al:"right", cart:"cart-mes"}, + {pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"}, + {v:fmtMoneda("S/ "+a.cta_tot), al:"right", cart:"cart-tot"}, + {v:fmtMoneda("S/ "+a.cob_tot), al:"right", cart:"cart-tot"}, + {pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"}, + {v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true}, + ]; + return ( + + {celdas.map((c,j)=>( + + ))} + + ); + })} + +
{c}
+ {c.pct!==undefined ? (c.pct ? : "—") : c.v} +
+
+ + )} +
+ ); +} + +function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) { + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let activo = true; + setLoading(true); + api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); + return () => { activo = false; }; + }, [grupo, ano, mes, sectorista, agrupacion]); + + const COLS = ["MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + // 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]); + + return ( + + {loading ? : + filas.length === 0 ?
Sin datos.
: + <> +
+ +
+
+ + {COLS.map((c)=>)} + + {filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").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()); + return ( + + {f.map((v, j) => { + let val = 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===1?"col-name":"") + (cart?` ${cart}`:"")).trim(); + return ( + + ); + })} + + ); + })} + {(() => { + // TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada) + const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL"); + 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); + return ( + + ); + })} + + ); + })()} + +
{c}
+ {isPct ? (val==="—"?:) : fmtMoneda(val)} +
+ {isPct?:val} +
+
+ } +
+ ); +} diff --git a/frontend/frontend/src/pages/Comisiones.jsx b/frontend/frontend/src/pages/Comisiones.jsx new file mode 100644 index 0000000..e519e99 --- /dev/null +++ b/frontend/frontend/src/pages/Comisiones.jsx @@ -0,0 +1,1004 @@ +// 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 ( + +
+ 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/frontend/src/pages/Ocupabilidad.jsx b/frontend/frontend/src/pages/Ocupabilidad.jsx new file mode 100644 index 0000000..ff3d98a --- /dev/null +++ b/frontend/frontend/src/pages/Ocupabilidad.jsx @@ -0,0 +1,194 @@ +// 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"; + +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"]; + +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 cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140]); + + useEffect(() => { + let activo = true; + 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); } }); + return () => { activo = false; }; + }, [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)}
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}
+
+ } +
+ ); +} diff --git a/frontend/frontend/src/pages/Rentabilidad.jsx b/frontend/frontend/src/pages/Rentabilidad.jsx new file mode 100644 index 0000000..da70a05 --- /dev/null +++ b/frontend/frontend/src/pages/Rentabilidad.jsx @@ -0,0 +1,270 @@ +// src/pages/Rentabilidad.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 = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."]; +// índices: inicial = 7,8,9 | actual = 10,11,12 +const BG_INI = "#eef4ff", BG_ACT = "#edfff6"; + +function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; } + +export default function Rentabilidad() { + 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 [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalVer, setModalVer] = useState(null); + // Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado + const IDX_INSCRITOS = [2, 3, 4]; + const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i); + const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80]; + const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i)); + const cols = useColumnasAjustables(anchosVisibles); + const [modalCostos, setModalCostos] = useState(null); + + function cargar() { + setLoading(true); setError(null); + api.rentabilidad(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.rentabilidad(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]); + + const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]); + const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]); + + const kpis = useMemo(() => { + if (!total) return null; + return { + programas: datos.length, + enCurso: total[4], + ventaAct: total[10], + costoAct: total[11], + mbAct: total[12], + }; + }, [datos, total]); + + function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; } + + return ( +
+

📈 Rentabilidad

+ + + ({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} /> + +
+ + +
+
+ + {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/frontend/src/pages/SaldoPendiente.jsx b/frontend/frontend/src/pages/SaldoPendiente.jsx new file mode 100644 index 0000000..62ac521 --- /dev/null +++ b/frontend/frontend/src/pages/SaldoPendiente.jsx @@ -0,0 +1,96 @@ +// 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/frontend/src/pages/Usuarios.jsx b/frontend/frontend/src/pages/Usuarios.jsx new file mode 100644 index 0000000..e4da63d --- /dev/null +++ b/frontend/frontend/src/pages/Usuarios.jsx @@ -0,0 +1,130 @@ +// 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/frontend/src/pages/Ventas.jsx b/frontend/frontend/src/pages/Ventas.jsx new file mode 100644 index 0000000..4f7696a --- /dev/null +++ b/frontend/frontend/src/pages/Ventas.jsx @@ -0,0 +1,266 @@ +// 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"; + +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; + 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]); + + 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 ( + +
+ onChange(e.target.value)}> + {options.map((o) => { + const val = typeof o === "object" ? o.value : o; + const txt = typeof o === "object" ? o.label : o; + return ; + })} + +
+ ); +} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..958d367 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,73 @@ +// 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"; + +async function get(path, params = {}) { + const qs = new URLSearchParams(params).toString(); + const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`; + const r = await fetch(url); + if (!r.ok) throw new Error(`Error ${r.status} en ${path}`); + return r.json(); +} + +async function postJson(path, body) { + const r = await fetch(`${BASE_URL}${path}`, { + 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(); +} + +export const api = { + periodoActual: () => get("/api/periodo-actual"), + ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") => + get("/api/ocupabilidad", { ano, mes, sede, programa }), + ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => get("/api/ventas", { ano, mes, sede, programa }), + 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") => + get("/api/cobranza", { ano, mes, sectorista, agrupacion }), + cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") => + get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }), + 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 }), + 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), +}; diff --git a/frontend/src/lib/auth.jsx b/frontend/src/lib/auth.jsx new file mode 100644 index 0000000..e218452 --- /dev/null +++ b/frontend/src/lib/auth.jsx @@ -0,0 +1,106 @@ +// 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); // { nombre, rol } + const [permisos, setPermisos] = useState([]); // ["ocupabilidad", "cobranza", ...] + const [cargando, setCargando] = useState(true); + + // Cargar perfil (rol) y permisos del usuario logueado + const cargarPerfil = useCallback(async (userId) => { + // 1) Traer el rol del usuario desde la tabla "perfiles" + 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; + } + + // 2) Traer los módulos permitidos para ese rol desde "permisos" + 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; + }, []); + + // Al iniciar: revisar si ya hay sesión activa + useEffect(() => { + let activo = true; + supabase.auth.getSession().then(async ({ data }) => { + if (!activo) return; + const s = data?.session || null; + setSession(s); + if (s?.user) await cargarPerfil(s.user.id); + setCargando(false); + }); + + // Escuchar cambios de sesión (login/logout) + const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => { + setSession(s); + if (s?.user) await cargarPerfil(s.user.id); + else { setPerfil(null); setPermisos([]); } + }); + + return () => { activo = false; 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) }; + // Verificar que tenga perfil/rol válido + 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 new file mode 100644 index 0000000..a3de4b6 --- /dev/null +++ b/frontend/src/lib/supabase.js @@ -0,0 +1,10 @@ +// 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"; + +// Estos valores se leen de frontend/.env (ver .env.example) +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); diff --git a/frontend/src/lib/useColumnasAjustables.jsx b/frontend/src/lib/useColumnasAjustables.jsx new file mode 100644 index 0000000..88732ee --- /dev/null +++ b/frontend/src/lib/useColumnasAjustables.jsx @@ -0,0 +1,63 @@ +// src/lib/useColumnasAjustables.jsx +// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla. +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); + } + }, [anchosIniciales.length]); + + function iniciarResize(e, i) { + e.preventDefault(); + e.stopPropagation(); + const xInicial = e.clientX; + const anchoInicial = anchos[i]; + function onMove(ev) { + const nuevo = Math.max(50, 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); + } + + // Devuelve los elementos para el de la tabla + function ColGroup() { + return ( + + {anchos.map((w, i) => )} + + ); + } + + // Devuelve el divisor arrastrable para poner dentro de cada + function Resizer({ index }) { + return ( + iniciarResize(e, index)} + className="col-resizer" + style={{ position: "absolute", top: 0, right: 0, width: 8, height: "100%", + cursor: "col-resize", userSelect: "none", zIndex: 5 }} + /> + ); + } + + const anchoTotal = anchos.reduce((a, b) => 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 }, + }; + + return { anchos, ColGroup, Resizer, anchoTotal, tableProps }; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..8fd46bf --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..d71ec72 --- /dev/null +++ b/frontend/src/pages/Asesores.jsx @@ -0,0 +1,105 @@ +// 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 new file mode 100644 index 0000000..742498a --- /dev/null +++ b/frontend/src/pages/Cobranza.jsx @@ -0,0 +1,660 @@ +// src/pages/Cobranza.jsx +import { useState, useEffect, useMemo } 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"; + +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:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}]; +const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"]; +const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"]; + +// 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"); + 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 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; + setLoading(true); setError(null); + api.cobranza(ano, mes, sectorista, agrupacion) + .then((res) => { + if (!activo) return; + setFilas(res.filas || []); + setSectoristas(res.sectoristas || ["TODOS"]); + setLoading(false); + }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes, agrupacion, sectorista]); + + // 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]); + + // 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 datos = 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; + }, [filas, esPrograma, sede, frec, sedeMap]); + + // 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 headers = esPrograma + ? ["PROGRAMA","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."] + : ["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."]; + + // Anchos para columnas redimensionables (1ª columna más ancha, resto estándar) + const anchosCob = headers.map((h, i) => i === 0 ? (esPrograma ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120)); + const cols = useColumnasAjustables(anchosCob); + + function cartClase(ci) { + if (IDX.ant.includes(ci)) return "cart-ant"; + if (IDX.mes.includes(ci)) return "cart-mes"; + if (IDX.tot.includes(ci)) return "cart-tot"; + return ""; + } + + // 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); + } + return arr; + }, [alumnos, esPrograma, sede, frec]); + + function grupoDe(a) { + if (agrupacion === "SEDE") return a.sede; + if (agrupacion === "ASESOR") return ""; // el sectorista ya filtra; no hay sub-grupo por alumno + return a.programa; // PROGRAMA + } + + // 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; } + + const toDate = (v) => { + if (!v || v === "-") return ""; + const s = String(v).trim().replace(/\//g,"-"); + const p = s.split("-"); + if (p.length === 3 && p[2].length === 4) return new Date(+p[2], +p[1]-1, +p[0]); + return String(v); + }; + 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 if (c===4 && v instanceof Date) ws[ref] = { v, t:"d", z:dateFmt }; + else ws[ref] = { v: v ?? "", t:"s" }; + }); + }); + + // 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 && } +
+ + +
+
+ + {loading ? : + error ? : + <> + + +
+ + + {headers.map((h, i)=>)} + + {datos.map((f, ri) => { + // En la tabla principal SIEMPRE son agrupaciones (Sede/Programa/Asesor), + // nunca alumnos individuales → NO aplicar rojo por deuda aquí. + // Regla "-": si % del grupo está vacío, CTA y COB del grupo → "-" + const antVacio = !String(f[IDX.ant[2]]||"").trim() || ["-","—"].includes(String(f[IDX.ant[2]]).trim()); + const mesVacio = !String(f[IDX.mes[2]]||"").trim() || ["-","—"].includes(String(f[IDX.mes[2]]).trim()); + const totVacio = !String(f[IDX.tot[2]]||"").trim() || ["-","—"].includes(String(f[IDX.tot[2]]).trim()); + return ( + + {f.slice(0, headers.length-1).map((v, ci) => { + const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; + let val = v; + if ((ci===IDX.ant[0]||ci===IDX.ant[1]||ci===IDX.ant[2]) && antVacio) val = "—"; + if ((ci===IDX.mes[0]||ci===IDX.mes[1]||ci===IDX.mes[2]) && mesVacio) val = "—"; + if ((ci===IDX.tot[0]||ci===IDX.tot[1]||ci===IDX.tot[2]) && totVacio) val = "—"; + const cartCls = cartClase(ci); + const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:""); + return ( + + ); + })} + + + ); + })} + {datos.length > 0 && (() => { + // Fila TOTAL GENERAL — suma de los datos visibles (respeta filtros) + const t = {}; + [IDX.ant[0],IDX.ant[1],IDX.mes[0],IDX.mes[1],IDX.tot[0],IDX.tot[1],IDX.saldo] + .forEach((ci)=>{ t[ci] = datos.reduce((s,f)=>s+toMonto(f[ci]),0); }); + const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : ""; + const cell = (ci) => { + if (ci===0) return "TOTAL GENERAL"; + if (ci===IDX.ant[2]) return ratio(t[IDX.ant[1]],t[IDX.ant[0]]); + if (ci===IDX.mes[2]) return ratio(t[IDX.mes[1]],t[IDX.mes[0]]); + if (ci===IDX.tot[2]) return ratio(t[IDX.tot[1]],t[IDX.tot[0]]); + if (t[ci]!==undefined) return "S/ " + t[ci].toLocaleString("es-PE",{maximumFractionDigits:0}); + return ""; + }; + return ( + + {Array.from({length: headers.length-1}).map((_,ci)=>{ + const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; + const val = cell(ci); + return ( + + ); + })} + + + ); + })()} + +
{h}
+ {isPct ? (val==="—" ? : ) : fmtMoneda(val)} + + +
+ {isPct ? : val} +
+
+ } + + {modal && ( + setModal(null)} /> + )} + + {buscadorAbierto && ( + setBuscadorAbierto(false)} + /> + )} +
+ ); +} + +function Tarjetas({ kpis }) { + const alDia = kpis.saldo <= 0.01; + if (alDia) { + return ( +
+
+
AL DÍA
+
Sin deuda pendiente
+
+ ); + } + const cards = [ + ["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt], + ["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes], + ["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot], + ]; + return ( +
+ {cards.map(([titulo,color,cta,cob]) => { + 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 }) { + const cards = [ + ["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt], + ["📅 MES EN CURSO","#059669",r.cMes,r.obMes], + ["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot], + ]; + return ( +
+ {cards.map(([titulo,color,cta,cob]) => { + 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 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 = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.", + "CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + 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)=>)} + + {resultados.map((a,i)=>{ + const deuda = a.saldo > 0.01; + const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa); + const celdas = [ + {v:grp, cls:"col-name", al:"left"}, + {v:a.matricula}, + {v:a.alumno, cls:"col-name", al:"left"}, + {v:a.num_cuota}, + {v:a.fch_venc}, + {v:fmtMoneda("S/ "+a.cta_ant), al:"right", cart:"cart-ant"}, + {v:fmtMoneda("S/ "+a.cob_ant), al:"right", cart:"cart-ant"}, + {pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"}, + {v:fmtMoneda("S/ "+a.cta_cur), al:"right", cart:"cart-mes"}, + {v:fmtMoneda("S/ "+a.cob_cur), al:"right", cart:"cart-mes"}, + {pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"}, + {v:fmtMoneda("S/ "+a.cta_tot), al:"right", cart:"cart-tot"}, + {v:fmtMoneda("S/ "+a.cob_tot), al:"right", cart:"cart-tot"}, + {pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"}, + {v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true}, + ]; + return ( + + {celdas.map((c,j)=>( + + ))} + + ); + })} + +
{c}
+ {c.pct!==undefined ? (c.pct ? : "—") : c.v} +
+
+ + )} +
+ ); +} + +function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) { + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let activo = true; + setLoading(true); + api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion) + .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); + return () => { activo = false; }; + }, [grupo, ano, mes, sectorista, agrupacion]); + + const COLS = ["MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; + + // 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]); + + return ( + + {loading ? : + filas.length === 0 ?
Sin datos.
: + <> +
+ +
+
+ + {COLS.map((c)=>)} + + {filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").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()); + return ( + + {f.map((v, j) => { + let val = 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===1?"col-name":"") + (cart?` ${cart}`:"")).trim(); + return ( + + ); + })} + + ); + })} + {(() => { + // TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada) + const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL"); + 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); + return ( + + ); + })} + + ); + })()} + +
{c}
+ {isPct ? (val==="—"?:) : fmtMoneda(val)} +
+ {isPct?:val} +
+
+ } +
+ ); +} diff --git a/frontend/src/pages/Comisiones.jsx b/frontend/src/pages/Comisiones.jsx new file mode 100644 index 0000000..e519e99 --- /dev/null +++ b/frontend/src/pages/Comisiones.jsx @@ -0,0 +1,1004 @@ +// 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 ( + +
+ 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 new file mode 100644 index 0000000..ff3d98a --- /dev/null +++ b/frontend/src/pages/Ocupabilidad.jsx @@ -0,0 +1,194 @@ +// 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"; + +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"]; + +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 cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140]); + + useEffect(() => { + let activo = true; + 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); } }); + return () => { activo = false; }; + }, [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)}
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}
+
+ } +
+ ); +} diff --git a/frontend/src/pages/Rentabilidad.jsx b/frontend/src/pages/Rentabilidad.jsx new file mode 100644 index 0000000..da70a05 --- /dev/null +++ b/frontend/src/pages/Rentabilidad.jsx @@ -0,0 +1,270 @@ +// src/pages/Rentabilidad.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 = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."]; +// índices: inicial = 7,8,9 | actual = 10,11,12 +const BG_INI = "#eef4ff", BG_ACT = "#edfff6"; + +function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; } + +export default function Rentabilidad() { + 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 [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalVer, setModalVer] = useState(null); + // Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado + const IDX_INSCRITOS = [2, 3, 4]; + const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i); + const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80]; + const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i)); + const cols = useColumnasAjustables(anchosVisibles); + const [modalCostos, setModalCostos] = useState(null); + + function cargar() { + setLoading(true); setError(null); + api.rentabilidad(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.rentabilidad(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]); + + const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]); + const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]); + + const kpis = useMemo(() => { + if (!total) return null; + return { + programas: datos.length, + enCurso: total[4], + ventaAct: total[10], + costoAct: total[11], + mbAct: total[12], + }; + }, [datos, total]); + + function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; } + + return ( +
+

📈 Rentabilidad

+ + + ({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} /> + +
+ + +
+
+ + {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/SaldoPendiente.jsx b/frontend/src/pages/SaldoPendiente.jsx new file mode 100644 index 0000000..62ac521 --- /dev/null +++ b/frontend/src/pages/SaldoPendiente.jsx @@ -0,0 +1,96 @@ +// 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 new file mode 100644 index 0000000..e4da63d --- /dev/null +++ b/frontend/src/pages/Usuarios.jsx @@ -0,0 +1,130 @@ +// 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/Ventas.jsx b/frontend/src/pages/Ventas.jsx new file mode 100644 index 0000000..4f7696a --- /dev/null +++ b/frontend/src/pages/Ventas.jsx @@ -0,0 +1,266 @@ +// 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"; + +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; + 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]); + + 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 ( + +
+