Compare commits

...

61 Commits

Author SHA1 Message Date
9ee3cc1e3b Actualizar frontend/src/pages/Cobranza.jsx 2026-08-26 13:23:39 -05:00
d444c8051b Actualizar frontend/src/pages/Cobranza.jsx 2026-08-26 11:18:39 -05:00
5b96e4cb59 Actualizar frontend/src/pages/Cobranza.jsx 2026-08-26 11:01:29 -05:00
d711146674 Actualizar frontend/src/lib/auth.jsx 2026-08-26 09:50:43 -05:00
e5f0754493 Actualizar frontend/src/pages/Cobranza.jsx 2026-08-26 09:47:21 -05:00
c50ee402f4 Actualizar frontend/src/lib/api.js 2026-08-26 09:41:35 -05:00
b1d2dff29c Actualizar backend/main.py 2026-08-26 09:40:21 -05:00
a0dbffd848 Actualizar backend/services.py 2026-08-26 09:38:11 -05:00
51d6de32dd Actualizar frontend/src/lib/auth.jsx 2026-08-22 11:23:25 -05:00
de1af4e8fe Actualizar frontend/src/lib/supabase.js 2026-08-22 11:22:02 -05:00
9e8e592bdd Actualizar backend/modules/rentabilidad/logic.py 2026-08-22 11:09:43 -05:00
359e78f666 Actualizar backend/modules/ocupabilidad/logic.py 2026-08-22 11:08:19 -05:00
0e7c6989d4 Actualizar backend/core/data_manager.py 2026-08-17 11:17:54 -05:00
abaaa1c131 Actualizar backend/core/data_manager.py 2026-08-17 11:17:15 -05:00
09f4a05609 Actualizar frontend/src/pages/Rentabilidad.jsx 2026-07-10 14:42:16 -05:00
3aec610cd0 Actualizar frontend/src/pages/Ocupabilidad.jsx 2026-07-10 14:40:09 -05:00
4da3a5ae6e Actualizar frontend/src/pages/Ventas.jsx 2026-07-10 14:39:01 -05:00
5a7b55e793 Actualizar frontend/src/pages/Cobranza.jsx 2026-07-10 14:37:34 -05:00
4b5f0f8d77 Añadir frontend/src/components/IndicadorActualizacion.jsx 2026-07-10 14:34:59 -05:00
c4b6be1810 Añadir frontend/src/lib/vistaActual.js 2026-07-10 14:34:20 -05:00
16449d23cd Actualizar frontend/src/App.jsx 2026-07-10 14:32:53 -05:00
9266dd82b9 Actualizar frontend/src/lib/auth.jsx 2026-07-10 14:30:32 -05:00
329294fc13 Actualizar frontend/src/lib/api.js 2026-07-10 14:27:26 -05:00
8f743715af Actualizar backend/main.py 2026-07-10 14:21:47 -05:00
1a051760d4 Actualizar backend/services.py 2026-07-10 14:14:34 -05:00
0e0909f230 Actualizar backend/core/data_manager.py 2026-07-10 14:10:55 -05:00
f21c37c162 Actualizar frontend/src/pages/Ocupabilidad.jsx 2026-07-03 17:00:13 -05:00
e686ae9f1a Actualizar frontend/src/lib/api.js 2026-07-03 16:55:35 -05:00
3a029bc443 Actualizar backend/main.py 2026-07-03 16:53:40 -05:00
a5df935e4f Actualizar backend/services.py 2026-07-03 16:50:46 -05:00
c91026e4da Actualizar backend/modules/ocupabilidad/processor.py 2026-07-03 16:48:26 -05:00
2f8fae01ca Actualizar backend/modules/cobranza/logic.py 2026-07-03 15:28:29 -05:00
27787b97be Actualizar frontend/src/pages/Cobranza.jsx 2026-07-03 15:26:07 -05:00
b3eaed0863 Actualizar backend/services.py 2026-07-01 11:31:55 -05:00
71c4d1376e Actualizar backend/services.py 2026-06-30 17:24:09 -05:00
99762151da Actualizar backend/modules/ventas/logic.py 2026-06-30 17:03:56 -05:00
fe14e2117b Actualizar backend/core/data_manager.py 2026-06-30 16:59:36 -05:00
807a907ee8 Actualizar backend/Dockerfile 2026-06-29 00:07:59 -05:00
b66bfc4803 Actualizar backend/Dockerfile 2026-06-29 00:02:10 -05:00
7f2f5134cb Actualizar backend/core/data_manager.py 2026-06-29 00:01:47 -05:00
f886370d5c Actualizar backend/services.py 2026-06-28 23:03:21 -05:00
725cc6f818 Actualizar docker-compose.yml 2026-06-28 04:34:51 -05:00
89d048035c Actualizar docker-compose.yml 2026-06-28 04:04:01 -05:00
8532eb12f2 Actualizar docker-compose.yml 2026-06-28 04:02:07 -05:00
6c06501bee Actualizar docker-compose.yml 2026-06-28 03:19:10 -05:00
3e95a1467c Actualizar frontend/src/lib/api.js 2026-06-28 03:14:06 -05:00
0f9cec175e Actualizar docker-compose.yml 2026-06-28 02:55:12 -05:00
92d578fcad Actualizar frontend/package.json 2026-06-28 02:50:22 -05:00
bbc2cd4958 Actualizar docker-compose.yml 2026-06-28 02:17:52 -05:00
cd22ea94cc Añadir frontend/.env.production 2026-06-28 00:15:18 -05:00
3a34921dc7 Actualizar backend/Dockerfile 2026-06-27 17:47:10 -05:00
9f73b3ca76 Actualizar backend/Dockerfile 2026-06-27 17:40:10 -05:00
d0fbade0fe Actualizar backend/Dockerfile 2026-06-27 12:53:57 -05:00
86dcef0a96 Actualizar docker-compose.yml 2026-06-27 12:47:41 -05:00
b016961858 Actualizar frontend/Dockerfile 2026-06-27 12:46:47 -05:00
f53ee398e0 Actualizar docker-compose.yml 2026-06-27 12:17:11 -05:00
132c01425b Añadir frontend/Dockerfile 2026-06-27 12:16:21 -05:00
daa40368e1 Añadir backend/Dockerfile 2026-06-27 12:15:58 -05:00
Panchito
6ed8fa1bb7 Agregando docker-compose para EasyPanel 2026-06-27 12:09:51 -05:00
Panchito
c2a5e025e1 Solucionado submodulos y subiendo codigo real 2026-06-27 12:09:01 -05:00
Panchito
15921af4f1 Solucionado submodulos y subiendo codigo real 2026-06-27 11:58:50 -05:00
80 changed files with 1459 additions and 12298 deletions

34
backend/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Usamos una versión estable y probada de Debian 12 (Bookworm)
FROM python:3.10-slim-bookworm
# Evitamos que Python guarde caché o bloquee los logs
ENV PYTHONUNBUFFERED=1
# 1. Instalar las herramientas base
RUN apt-get update && apt-get install -y \
curl apt-transport-https gnupg2 unixodbc-dev \
# 2. Descargar la llave de Microsoft usando el método moderno (sin apt-key)
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
# 3. Agregar el repositorio oficial de Microsoft para Debian 12
&& curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list | tee /etc/apt/sources.list.d/mssql-release.list \
&& apt-get update \
# 4. Instalar el ODBC Driver 17 aceptando los términos automáticamente
&& ACCEPT_EULA=Y apt-get install -y msodbcsql17 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Crear la carpeta de trabajo
WORKDIR /app
# Copiar las librerías e instalarlas
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copiar todo el código de tu backend
COPY . .
# Exponer el puerto
EXPOSE 8000
# Arrancar FastAPI
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,19 +0,0 @@
# === 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

View File

@@ -1,6 +0,0 @@
.env
__pycache__/
*.pyc
*.xlsx
venv/
.venv/

View File

@@ -1,74 +0,0 @@
# Backend — Dashboard API (FastAPI)
API REST que reutiliza toda la lógica de negocio del dashboard original.
El frontend (React) consume estos endpoints.
## Requisitos
- Python 3.10+
- Driver ODBC para SQL Server (ODBC Driver 17 o 18)
## Instalación
```bash
cd backend
pip install -r requirements.txt
```
En Windows, si falta el driver ODBC de SQL Server, descárgalo de:
https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server
## Configuración
El archivo `.env` ya contiene las credenciales (copiado del proyecto original).
Si necesitas cambiarlas, edita `.env` (usa `.env.example` como referencia).
## Ejecutar
```bash
python main.py
```
El servidor arranca en: **http://localhost:8000**
- Documentación interactiva: http://localhost:8000/docs
- Health check: http://localhost:8000/api/health
## Cómo funciona el rendimiento
1. **Al arrancar** → precarga todos los módulos (datos en memoria)
2. **Refresco en segundo plano** → cada 4 min recarga el caché silenciosamente
3. **Respuestas** → instantáneas porque vienen de memoria
4. El frontend solo pide datos (JSON), nunca recarga toda la página
## Endpoints principales
| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/api/periodo-actual` | Año y mes actuales |
| GET | `/api/ocupabilidad?ano=&mes=&sede=&programa=` | Datos ocupabilidad |
| GET | `/api/ventas?ano=&mes=` | Datos ventas |
| GET | `/api/ventas/detalle?vendedor=&ano=&mes=&tipo=` | Detalle vendedor |
| GET | `/api/cobranza?ano=&mes=&sectorista=&agrupacion=` | Datos cobranza |
| GET | `/api/cobranza/detalle?grupo=&ano=&mes=&sectorista=&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
```

View File

@@ -1,59 +0,0 @@
SELECT
-- Cronograma de Matrícula (T1)
T1.num_matricula,
T1.num_cuota,
T1.fch_vencimiento,
T1.fch_cancelacion,
T1.cod_estado,
T1.imp_total,
T1.imp_total_pagado,
T1.imp_saldo,
T1.imp_dscto,
-- Matrícula y Programa
M.fch_matricula,
M.fch_retiro,
M.cod_estado AS estado_matricula,
P.dsc_programa,
RP.dsc_det_programa,
M.num_indice,
RP.fch_inicio,
M.cod_moneda,
M.imp_tc,
-- Sectorista
(ISNULL(EMP.dsc_apellido_paterno, '') + ' ' +
ISNULL(EMP.dsc_apellido_materno, '') + ', ' +
ISNULL(EMP.dsc_nombres, '')) AS dsc_sectorista,
RP.cod_frecuencia
FROM
[BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1
INNER JOIN
sgeca_matricula M
ON T1.cod_localidad = M.cod_localidad
AND T1.num_matricula = M.num_matricula
LEFT JOIN
sgeca_programa P
ON M.cod_programa = P.cod_programa
LEFT JOIN
sgede_RP_programa RP
ON M.cod_programa = RP.cod_programa
AND M.cod_periodo = RP.cod_detalle
AND M.num_indice = RP.num_indice
LEFT JOIN
vtama_cuota C
ON M.cod_cuota = C.cod_cuota
LEFT JOIN
[BDUS_CK000040_0001].[dbo].[sgede_RP_programa_sectorista] SEC_ASIG
ON M.cod_programa = SEC_ASIG.cod_programa
AND M.cod_periodo = SEC_ASIG.cod_detalle
AND M.num_indice = SEC_ASIG.num_indice
LEFT JOIN
[BDUS_CK000040_0001].[dbo].[rhuma_trabajador] EMP
ON SEC_ASIG.cod_trabajador = EMP.cod_trabajador
WHERE
YEAR(RP.fch_inicio) IN (2025, 2026)
ORDER BY
T1.fch_vencimiento ASC;

View File

@@ -1,46 +0,0 @@
SELECT
-- Cronograma de Matrícula (T1)
T1.cod_localidad,
T1.num_matricula,
T1.num_refinanciamiento,
T1.num_cuota,
T1.fch_vencimiento,
T1.fch_cancelacion,
T1.cod_estado,
T1.imp_total,
T1.imp_total_pagado,
T1.imp_saldo,
T1.imp_dscto,
-- Matrícula y Programa
M.fch_matricula,
M.cod_estado AS estado_matricula,
P.dsc_programa,
RP.dsc_det_programa,
RP.fch_inicio,
M.cod_moneda,
C.dsc_cuota,
M.imp_tc
FROM
[BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1
INNER JOIN
sgeca_matricula M
ON T1.cod_localidad = M.cod_localidad
AND T1.num_matricula = M.num_matricula
LEFT JOIN
sgeca_programa P
ON M.cod_programa = P.cod_programa
LEFT JOIN
sgede_RP_programa RP
ON M.cod_programa = RP.cod_programa
AND M.cod_periodo = RP.cod_detalle
AND M.num_indice = RP.num_indice
LEFT JOIN
vtama_cuota C
ON M.cod_cuota = C.cod_cuota
WHERE
YEAR(RP.fch_inicio) = ?
AND MONTH(RP.fch_inicio) = ?
AND T1.fch_vencimiento > '2020-01-01 00:00:00:000'
ORDER BY
T1.fch_vencimiento ASC;

View File

@@ -1,38 +0,0 @@
SELECT
rp.[num_indice],
rp.[dsc_det_programa],
rp.[flg_activo],
rp.[fch_inicio],
rp.[cod_frecuencia],
rp.[cod_estado],
p.[dsc_programa],
(
SELECT COUNT(*)
FROM sgeca_matricula m
WHERE m.cod_periodo = rp.cod_detalle
AND m.num_indice = rp.num_indice
AND m.cod_estado NOT IN ('ANU', 'SUS')
) AS 'Inscritos_Totales',
(
SELECT COUNT(*)
FROM sgeca_matricula m
WHERE m.cod_periodo = rp.cod_detalle
AND m.num_indice = rp.num_indice
AND m.cod_estado = 'RET'
) AS 'Inscritos_Retirados',
(
SELECT COUNT(*)
FROM sgeca_matricula m
WHERE m.cod_periodo = rp.cod_detalle
AND m.num_indice = rp.num_indice
AND m.cod_estado NOT IN ('ANU', 'RET', 'SUS')
) AS 'Inscritos_Activos'
FROM
[BDUS_CK000040_0001].[dbo].[sgede_RP_programa] rp
INNER JOIN [BDUS_CK000040_0001].[dbo].[sgeca_programa] p
ON rp.cod_programa = p.cod_programa
WHERE
YEAR(rp.fch_inicio) = ?
AND MONTH(rp.fch_inicio) = ?
ORDER BY
rp.fch_inicio ASC

View File

@@ -1,51 +0,0 @@
SELECT
comp.[num_correlativo],
comp.[fch_emision],
comp.[fch_cancelacion],
comp.[cod_estado],
comp.[cod_moneda],
comp.[imp_total],
comp.[imp_saldo],
comp.[imp_tc],
comp.[flg_nc],
-- Centro de Responsabilidad
cr_prog.dsc_centroresp AS dsc_cr,
-- Programa académico
rp.dsc_det_programa,
rp.fch_inicio,
prog.dsc_programa,
mat.num_indice,
mat.cod_estado AS estado_matricula,
mat.fch_retiro,
-- Cuotas y matrícula
cxc.num_matricula,
cxc.num_cuota,
COALESCE(cxc.imp_emitido, comp.imp_total) as imp_emitido,
-- Cuota
cron.fch_vencimiento as fch_vencimiento_cuota
FROM [BDUS_CK000040_0001].[dbo].[vtaca_comprobante] comp
LEFT JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
AND comp.num_correlativo = cxc.num_correlativo
LEFT JOIN sgeca_matricula mat ON cxc.cod_localidad_c = mat.cod_localidad
AND cxc.num_matricula = mat.num_matricula
LEFT JOIN sgede_RP_programa rp ON mat.cod_programa = rp.cod_programa
AND mat.cod_periodo = rp.cod_detalle
AND mat.num_indice = rp.num_indice
LEFT JOIN sgeca_programa prog ON mat.cod_programa = prog.cod_programa
LEFT JOIN rhuma_centroresponsabilidad cr_prog ON prog.cod_centroresp = cr_prog.cod_centroresp
LEFT JOIN sgede_cronograma_matricula cron ON cxc.cod_localidad_c = cron.cod_localidad
AND cxc.num_matricula = cron.num_matricula
AND cxc.num_refinanciamiento = cron.num_refinanciamiento
AND cxc.num_cuota = cron.num_cuota
WHERE comp.fch_emision BETWEEN '01-01-2024 00:00:00.000' AND '31-12-2028 00:00:00.000'
ORDER BY comp.fch_emision ASC;

View File

@@ -1,211 +0,0 @@
SELECT
sgede_RP_programa.num_indice,
vtama_localidad.dsc_localidad,
sgeca_matricula.num_matricula,
sgeca_matricula.fch_matricula,
sgeca_matricula.cod_estado AS estado_matricula,
sgema_alumno.dsc_telefono_1,
(
SELECT
rhuma_trabajador.dsc_apellido_paterno + ' ' +
SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' +
rhuma_trabajador.dsc_nombres
FROM rhuma_trabajador
WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor
) AS dsc_vendedor,
sgeca_matricula.cod_moneda,
ISNULL((
SELECT
SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
), 0) AS INV_NETA,
(
SELECT vtama_cuota.dsc_cuota
FROM vtama_cuota
WHERE vtama_cuota.cod_cuota = sgeca_matricula.cod_cuota
) AS dsc_cuota,
(
SELECT sgema_beca.dsc_beca
FROM sgema_beca
WHERE sgema_beca.cod_beca = sgeca_matricula.cod_beca
) AS dsc_beca,
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno,
vtama_tipo_documento.dsc_tipo_documento,
sgema_alumno.dsc_documento,
sgeca_programa.dsc_programa,
sgede_RP_programa.dsc_det_programa AS dsc_promocion,
sgede_RP_programa.fch_inicio,
-- ==========================================
-- SALDOS Y VENCIMIENTOS: MATRICULA Y CUOTAS 1-5
-- ==========================================
ISNULL((
SELECT sgede_cronograma_matricula.imp_saldo
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 0
), 0) AS imp_saldo_matricula,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 0
) AS fch_venc_matricula,
ISNULL((
SELECT sgede_cronograma_matricula.imp_saldo
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 1
), 0) AS imp_saldo_cuota1,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 1
) AS fch_venc_cuota1,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 1
) AS fch_cancelacion_cuota1,
ISNULL((
SELECT sgede_cronograma_matricula.imp_saldo
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 2
), 0) AS imp_saldo_cuota2,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 2
) AS fch_venc_cuota2,
ISNULL((
SELECT sgede_cronograma_matricula.imp_saldo
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 3
), 0) AS imp_saldo_cuota3,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 3
) AS fch_venc_cuota3,
ISNULL((
SELECT sgede_cronograma_matricula.imp_saldo
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 4
), 0) AS imp_saldo_cuota4,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 4
) AS fch_venc_cuota4,
ISNULL((
SELECT sgede_cronograma_matricula.imp_saldo
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 5
), 0) AS imp_saldo_cuota5,
(
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 5
) AS fch_venc_cuota5,
-- ==========================================
-- TIPO DE CAMBIO
-- ==========================================
ISNULL((
SELECT TOP 1 comp.imp_tc
FROM vtaca_comprobante comp
INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
AND comp.num_correlativo = cxc.num_correlativo
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
AND comp.cod_estado NOT IN ('ANU', 'ELI')
ORDER BY comp.fch_emision DESC
), 0) AS imp_tc,
-- ==========================================
-- IMPORTE TOTAL PAGADO
-- ==========================================
(SELECT SUM(sgede_cronograma_matricula.imp_total_pagado)
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = sgeca_matricula.num_refinanciamiento) AS imp_total_pagado
FROM sgeca_matricula
INNER JOIN sgeca_programa
ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
LEFT JOIN sgede_RP_programa
ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
INNER JOIN sgema_alumno
ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
INNER JOIN vtama_localidad
ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad
INNER JOIN vtama_tipo_documento
ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento
WHERE
sgeca_matricula.cod_localidad LIKE 'SCENT'
AND YEAR(sgeca_matricula.fch_matricula) = ?
AND MONTH(sgeca_matricula.fch_matricula) = ?
AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS')
ORDER BY
num_indice ASC

View File

@@ -1,98 +0,0 @@
# 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")

View File

@@ -1,794 +0,0 @@
# core/data_manager.py
import os
import requests
import json
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
class DataManager:
def __init__(self):
# --- SQL SERVER ---
self.server = os.getenv("SQL_SERVER", "191.98.134.80")
self.database = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
self.username = os.getenv("SQL_USERNAME", "ASEBASTIAN")
self.password = os.getenv("SQL_PASSWORD", "")
# --- POSTGRESQL ---
self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
self.pg_database = os.getenv("PG_DATABASE", "chatwoot_production")
self.pg_user = os.getenv("PG_USER", "postgres")
self.pg_password = os.getenv("PG_PASSWORD", "")
self.pg_port = os.getenv("PG_PORT", "5432")
# --- SUPABASE ---
self.supabase_url = os.getenv("SUPABASE_URL", "")
self.supabase_key = os.getenv("SUPABASE_KEY", "")
self.supabase_client = None
self._init_supabase()
# --- GITHUB ---
base = os.getenv("GITHUB_BASE", "https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD")
self.github_json_url = f"{base}/actualizador.json"
self.github_replace_url = f"{base}/REPLACE_CURSO.json"
self.github_meta_url = f"{base}/REPLACE_META.json"
self.github_costos_url = f"{base}/REPLACE_COSTOS.json"
self.github_pronostico_url = f"{base}/BASE_PRONOSTICO.json"
self.github_pendientes_url = f"{base}/BASE_PENDIENTES.json"
self.github_sede_url = f"{base}/sede.json"
self.github_horario_url = f"{base}/HORARIO_ASESOR.json"
self.github_query_url = f"{base}/SQL_QUERY_2/BASE_CURSO_2.query"
self.github_cuota_url = f"{base}/SQL_QUERY_2/BASE_CUOTAS_2.query"
self.github_cronograma_url = f"{base}/SQL_QUERY_2/BASE_CRONOGRAMA_2.query"
self.github_facturas_url = f"{base}/SQL_QUERY_2/BASE_FACTURAS_2.query"
self.github_matriculas_url = f"{base}/SQL_QUERY_2/BASE_MATRICULADOS_2.query"
# --- Variables de estado ---
self.config_data = {}
self.replace_data = {}
self.meta_data = {}
self.sede_data = {}
self.pronostico_data = {}
self.costos_data = {}
self.historico_pendientes = {}
self.pendientes_data = set()
self.query_sql = ""
self.query_matriculas_sql = ""
self.query_cronograma_sql = ""
self.query_facturas_sql = ""
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

View File

@@ -1,410 +0,0 @@
# 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)

View File

@@ -1,86 +0,0 @@
# modules/asesores/processor.py
import requests
import time
class AsesoresProcessor:
def __init__(self):
# Tus credenciales maestras de Chatwoot
self.chatwoot_url = "https://gestor.escueladerefrigeracion.edu.pe"
self.access_token = "4anazHvZnvKLtup8biu5Zuoh"
self.account_id = "1"
def obtener_agentes_chatwoot(self):
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents"
headers = {
"api_access_token": self.access_token,
"Content-Type": "application/json"
}
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
agentes = response.json()
# =================================================================
# 🕵️‍♂️ ESCÁNER DEBUG (Se imprimirá en tu consola negra)
# =================================================================
print("\n" + "="*60)
print("🕵️‍♂️ ESCÁNER: REVISANDO 'DESCONEXIÓN AUTOMÁTICA'")
print("="*60)
for ag in agentes:
nombre = ag.get('available_name') or ag.get('name', 'Desconocido')
estado = ag.get('availability_status', 'offline')
auto_offline = ag.get('auto_offline', 'Desconocido')
print(f"👤 {nombre} | Estado: {estado} | Radar: {auto_offline}")
print("="*60 + "\n")
return agentes
except Exception:
return []
# =========================================================================
# MOTOR DE ACTUALIZACIÓN (EL COMBO DE 2 GOLPES)
# =========================================================================
def cambiar_estado_agente(self, agent_id, is_online):
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents/{agent_id}"
headers = {
"api_access_token": self.access_token,
"Content-Type": "application/json"
}
estado_str = "online" if is_online else "offline"
try:
# -----------------------------------------------------------------
# GOLPE 1: Desactivar el botón automático primero
# -----------------------------------------------------------------
payload_radar = {
"auto_offline": False
}
requests.patch(url, headers=headers, json=payload_radar, timeout=10)
# Le damos 0.5 segundos a la base de datos de Chatwoot para que
# asimile que el radar de este usuario acaba de ser destruido.
time.sleep(0.5)
# -----------------------------------------------------------------
# GOLPE 2: El código viejo y confiable para cambiar el estado
# -----------------------------------------------------------------
payload_estado = {
"availability_status": estado_str,
"availability": estado_str
}
response = requests.patch(url, headers=headers, json=payload_estado, timeout=10)
if response.status_code == 500:
return False, "Error interno del servidor Chatwoot."
response.raise_for_status()
# ⏳ Le damos 1.5 segundos para que le avise a la computadora
# del asesor que su estado acaba de cambiar en pantalla.
time.sleep(1.5)
return True, "Orden ejecutada con el combo de 2 pasos"
except Exception as e:
return False, f"Fallo de red: {str(e)}"

View File

@@ -1,221 +0,0 @@
# modules/cobranza/logic.py
import pandas as pd
from .processor import CobranzaProcessor
class CobranzaLogic:
def __init__(self, data_manager):
self.data_manager = data_manager
self.processor = CobranzaProcessor(data_manager)
def obtener_lista_sectoristas(self, ano, mes):
return self.processor.obtener_lista_sectoristas(ano, mes)
def obtener_datos_tabla(self, ano, mes, sectorista="TODOS", agrupacion="SEDE"):
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

View File

@@ -1,734 +0,0 @@
# modules/cobranza/processor.py
from datetime import datetime
import requests
import unicodedata
class CobranzaProcessor:
def __init__(self, data_manager):
self.data_manager = data_manager
self.config_sedes = self._cargar_json_sedes()
# ── Caché de consultas SQL ────────────────────────────────────────
self._cache_cronograma = None
self._cache_facturas = None
self._cache_ano = None
def _obtener_datos_cached(self, ano):
"""Devuelve cronograma y facturas usando caché si el año coincide."""
if self._cache_ano == ano and self._cache_cronograma is not None:
print(f"[CACHE] Reutilizando datos en caché para año {ano}")
return self._cache_cronograma, self._cache_facturas
print(f"[CACHE] Consultando BD para año {ano}...")
cronograma = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano)
facturas = self.data_manager.ejecutar_consulta_facturas_cobranza(ano)
self._cache_cronograma = cronograma
self._cache_facturas = facturas
self._cache_ano = ano
return cronograma, facturas
def invalidar_cache(self):
"""Fuerza recarga desde BD en la siguiente consulta."""
self._cache_cronograma = None
self._cache_facturas = None
self._cache_ano = None
print("🧹 [CACHE] Memoria limpiada por actualización automática. La próxima consulta irá directo a la BD.")
# =========================================================================
# CARGA DE CONFIGURACIÓN (sede.json)
# =========================================================================
def _cargar_json_sedes(self):
datos = None
try:
url = getattr(self.data_manager, 'github_sede_url', None)
if url:
response = requests.get(url)
response.raise_for_status()
datos = response.json()
except Exception as e:
pass
if datos is None:
datos = {
"clasificacion_sedes": {
"AREQUIPA": {"patrones": ["AREQUIPA"]},
"PIURA": {"patrones": ["PIURA"]},
"TRUJILLO": {"patrones": ["TRUJILLO"]}
},
"clasificacion_default": {"sede": "LIMA"}
}
self._reemplazos_nombre = datos.get("reemplazos_nombre", [])
return datos
# =========================================================================
# REEMPLAZOS DE NOMBRE DE PROGRAMA
# =========================================================================
@staticmethod
def _normalizar(texto):
texto = unicodedata.normalize('NFD', str(texto))
return texto.encode('ascii', 'ignore').decode('utf-8')
def _aplicar_reemplazos_nombre(self, texto):
if not texto or not self._reemplazos_nombre:
return texto
resultado = texto
for entrada in self._reemplazos_nombre:
buscar = str(entrada.get("buscar", "")).strip()
reemplazar = str(entrada.get("reemplazar", "")).strip()
if not buscar:
continue
buscar_norm = self._normalizar(buscar).upper()
idx = self._normalizar(resultado).upper().find(buscar_norm)
while idx != -1:
resultado = resultado[:idx] + reemplazar + resultado[idx + len(buscar):]
idx = self._normalizar(resultado).upper().find(buscar_norm, idx + len(reemplazar))
return resultado
# =========================================================================
# CLASIFICACIÓN DE SEDE
# =========================================================================
def clasificar_sede(self, dsc_programa):
if not dsc_programa:
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
dsc_prog_upper = str(dsc_programa).upper()
sedes = self.config_sedes.get("clasificacion_sedes", {})
for nombre_sede, datos in sedes.items():
for patron in datos.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return nombre_sede
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
# =========================================================================
# UTILIDADES
# =========================================================================
def parse_fecha(self, fecha_raw):
try:
if isinstance(fecha_raw, datetime): return fecha_raw
if not fecha_raw: return None
s_fecha = str(fecha_raw).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-':
return datetime.strptime(s_fecha, '%d-%m-%Y')
if ' ' in s_fecha:
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
return datetime.strptime(s_fecha, '%Y-%m-%d')
except:
return None
def 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)

View File

@@ -1,237 +0,0 @@
# modules/ocupabilidad/logic.py
import pandas as pd
from .processor import CursoProcessor
class AnalizadorCursos:
"""Analizador de cursos - Lógica de cálculo y estadísticas"""
def __init__(self, data_manager):
self.data_manager = data_manager
self.curso_processor = CursoProcessor(self.data_manager)
self.columnas = self.curso_processor.columnas
def obtener_sedes(self):
"""Devuelve la lista de sedes para el nuevo filtro"""
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
def identificar_sede(self, dsc_programa):
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
if not dsc_programa: return "LIMA"
dsc_prog_upper = str(dsc_programa).upper()
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
for sede, data in sedes_data.items():
if sede == "DEFAULT": continue
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper: return sede
return sedes_data.get("DEFAULT", "LIMA")
def obtener_filtros_programa(self):
"""Devuelve la lista de programas para el nuevo filtro combinado"""
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
def identificar_filtro_programa(self, dsc_programa):
"""Identifica la categoría del programa para el filtro de la UI"""
if not dsc_programa: return "OTROS"
dsc_prog_upper = str(dsc_programa).upper()
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
for categoria, data in filtro_data.items():
if categoria == "DEFAULT": continue
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper: return categoria
return filtro_data.get("DEFAULT", "OTROS")
def get_current_month(self):
return self.data_manager.get_current_month()
def get_current_year(self):
return self.data_manager.get_current_year()
def actualizar_configuracion(self):
print("🔄 Actualización automática de configuración...")
self.data_manager.cargar_toda_configuracion()
# --- AGREGADO EL PARÁMETRO "TIPO" ---
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
# Sincronizamos el estado del toggle UI con el procesador
self.curso_processor.mostrar_reprogramados = getattr(self, 'mostrar_reprogramados', True)
datos = self.curso_processor.obtener_datos_procesados(ano, mes)
programas_disponibles = set()
if datos:
for d in datos:
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
# Inyectamos variables para el filtro UI
d['Sede'] = self.identificar_sede(prog)
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
if sede == "TODOS" or d['Sede'] == sede:
programas_disponibles.add(d['Filtro_Programa'])
# Ordenar lista dinámica
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
# 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

View File

@@ -1,560 +0,0 @@
# modules/ocupabilidad/processor.py
from datetime import datetime
import unicodedata
class CursoProcessor:
"""Procesador de datos de cursos - Aplica transformaciones y cálculos"""
def __init__(self, data_manager):
self.data_manager = data_manager
# --- COLUMNAS REORDENADAS Y REORGANIZADAS ---
self.columnas = [
'programa_frecuencia',
'fch_inicio',
'dias_para_inicio',
'Inscritos_Mes',
'Inscritos_Totales',
'Retirados',
'Inscritos_Activos',
'Inscritos_PC',
'Descuento', # Esto es 'INSCRITOS REFRIPERU'
'Inscritos_Continuidad',
'Meta_Curso',
'Avance_Inscritos'
]
def obtener_datos_procesados(self, ano, mes):
try:
# 1. Cargar datos básicos
datos_matriculas = self.data_manager.cargar_datos_matriculas(ano, mes)
datos_raw_matriculas = self.data_manager.ejecutar_consulta_matriculados_detalle(ano, mes)
datos_originales = self.data_manager.ejecutar_consulta_cursos(ano, mes)
# --- NUEVO: EL GUARDIÁN DE FECHAS (SUPABASE) ---
fechas_historicas = self.data_manager.obtener_fechas_originales()
indices_en_sql = set()
for fila in datos_originales:
indice = str(fila.get('num_indice', '')).strip()
fecha_actual = fila.get('fch_inicio')
programa = fila.get('dsc_programa', '')
if indice and fecha_actual:
indices_en_sql.add(indice) # Registramos que sí vino en SQL este mes
if indice not in fechas_historicas:
fecha_str = fecha_actual.strftime('%d/%m/%Y') if isinstance(fecha_actual, datetime) else str(fecha_actual)
exito = self.data_manager.guardar_fecha_original(indice, programa, fecha_str)
if exito:
fechas_historicas[indice] = {'fecha': fecha_str, 'programa': programa}
# -----------------------------------------------
# 2. CONTINUIDAD
lista_dnis = []
if datos_raw_matriculas:
for alumno in datos_raw_matriculas:
dni = alumno.get('dsc_documento')
if dni: lista_dnis.append(dni)
datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
# 3. Procesar todo
datos_procesados = self.aplicar_personalizaciones(
datos_originales,
datos_matriculas,
datos_raw_matriculas,
datos_historial,
ano, mes
)
# --- INYECTAR CURSOS REPROGRAMADOS (FILAS FANTASMAS) ---
if getattr(self, 'mostrar_reprogramados', True):
for idx, info in fechas_historicas.items():
if idx not in indices_en_sql: # Si el curso NO vino este mes desde SQL...
# Aseguramos compatibilidad si guardaste data vieja como texto o la nueva como diccionario
if isinstance(info, dict):
fecha_orig = info.get('fecha', '')
prog_orig = info.get('programa', 'CURSO REPROGRAMADO')
else:
fecha_orig = str(info)
prog_orig = 'CURSO REPROGRAMADO'
# Verificamos si la fecha original correspondía a ESTE mes Y AÑO filtrado
mes_orig = ""
ano_orig = ""
if "/" in fecha_orig:
partes = fecha_orig.split("/")
if len(partes) >= 2: mes_orig = str(int(partes[1]))
if len(partes) >= 3: ano_orig = str(int(partes[2]))
elif "-" in fecha_orig:
partes = fecha_orig.split("-")
if len(partes) >= 2: mes_orig = str(int(partes[1]))
if len(partes) >= 3: ano_orig = str(int(partes[2]))
# Mes debe coincidir; y si la fecha tiene año, el año también debe coincidir.
# Si la fecha NO tiene año (formato viejo dd/mm), no se inyecta para evitar
# mostrarla en años equivocados.
coincide_mes = (mes_orig == str(int(mes)))
coincide_ano = (ano_orig == str(int(ano))) if ano_orig else False
if coincide_mes and coincide_ano:
# Inyectamos la fila falsa para alertar en pantalla
datos_procesados.append({
'num_indice': idx,
'programa_frecuencia': prog_orig,
'dsc_programa': prog_orig,
'fch_inicio': fecha_orig,
'dias_para_inicio': 'REPROGRAMADO',
'Inscritos_Mes': "-",
'Inscritos_Totales': "-",
'Retirados': "-",
'Inscritos_Activos': "-",
'Inscritos_PC': "-",
'Descuento': "-",
'Inscritos_Continuidad': "-",
'Meta_Curso': "-",
'Avance_Inscritos': "-"
})
# -----------------------------------------------
## --- ORDENAR: NORMALES POR FECHA, REPROGRAMADOS POR SEDE ---
def parse_fecha_orden(fecha_str):
if not fecha_str or fecha_str == "-":
return datetime.max
s = str(fecha_str).replace("/", "-").strip()
try:
if len(s) == 5:
return datetime.strptime(f"{s}-{ano}", "%d-%m-%Y")
if len(s) >= 10:
return datetime.strptime(s[:10], "%d-%m-%Y")
except:
pass
return datetime.max
def obtener_orden_sede(programa):
prog_upper = str(programa).upper()
if 'LIMA' in prog_upper: return 1
if 'PIURA' in prog_upper: return 2
if 'TRUJILLO' in prog_upper: return 3
if 'AREQUIPA' in prog_upper: return 4
return 5
def logica_ordenamiento(x):
if x.get('dias_para_inicio') == 'REPROGRAMADO':
# Reprogramados: Van al final (1), ordenados por sede, luego alfabéticamente
return (1, obtener_orden_sede(x.get('dsc_programa', '')), x.get('dsc_programa', ''))
else:
# Normales: Van al inicio (0), ordenados por fecha
return (0, 0, parse_fecha_orden(x.get('fch_inicio', '')))
# Aplicamos el ordenamiento inteligente
datos_procesados.sort(key=logica_ordenamiento)
return datos_procesados
except Exception as e:
print(f"❌ Error obteniendo datos procesados: {e}")
return []
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

View File

@@ -1,950 +0,0 @@
# modules/rentabilidad/logic.py
import pandas as pd
import requests
import re
from datetime import datetime
from .processor import RentabilidadProcessor
class RentabilidadLogic:
"""Controlador lógico de Rentabilidad - Estructura la tabla y gestiona variables de clasificación"""
def __init__(self, data_manager):
self.data_manager = data_manager
self.processor = RentabilidadProcessor(self.data_manager)
def obtener_categorias(self):
"""Devuelve la lista simplificada y agrupada para el filtro del Dashboard"""
return ["AREQUIPA", "TRUJILLO", "PIURA", "PROGRAMAS", "SEMINARIOS", "OTROS"]
def obtener_sedes(self):
"""Devuelve la lista de sedes para el nuevo filtro"""
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
def identificar_sede(self, dsc_programa):
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
if not dsc_programa: return "LIMA"
dsc_prog_upper = str(dsc_programa).upper()
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
for sede, data in sedes_data.items():
if sede == "DEFAULT": continue
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return sede
return sedes_data.get("DEFAULT", "LIMA")
def obtener_filtros_programa(self):
"""Devuelve la lista de programas para el nuevo filtro combinado"""
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
def identificar_filtro_programa(self, dsc_programa):
"""Identifica la categoría del programa para el filtro de la UI"""
if not dsc_programa: return "OTROS"
dsc_prog_upper = str(dsc_programa).upper()
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
for categoria, data in filtro_data.items():
if categoria == "DEFAULT": continue
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return categoria
return filtro_data.get("DEFAULT", "OTROS")
def clasificar_programa(self, dsc_programa):
if not dsc_programa: return "OTROS"
dsc_prog_upper = str(dsc_programa).upper()
meta_data = self.data_manager.meta_data
clasificaciones = meta_data.get("clasificacion_programas", {})
for clase, data in clasificaciones.items():
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return clase
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
def identificar_linea_carrera(self, nombre_programa):
"""Identifica si el programa es de la familia de Carreras Técnicas"""
categoria = self.clasificar_programa(nombre_programa)
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
return "CARRERA_COMPATIBLE"
return None
def parse_fecha(self, fecha_raw):
"""Convierte diferentes formatos de fecha a objeto datetime"""
try:
if isinstance(fecha_raw, datetime): return fecha_raw
if not fecha_raw: return None
s_fecha = str(fecha_raw).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-':
return datetime.strptime(s_fecha, '%d-%m-%Y')
if ' ' in s_fecha:
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
return datetime.strptime(s_fecha, '%Y-%m-%d')
except:
return None
def calcular_descuento(self, clasificacion, inv_neta):
try: inv_neta = float(inv_neta)
except: inv_neta = 0.0
if clasificacion in ["TEAC", "TERC"]: return 3499.0 - inv_neta
elif clasificacion in ["AREQUIPA", "PIURA", "TRUJILLO"]: return 2839.0 - inv_neta
elif clasificacion == "CARRERA": return 4299.0 - inv_neta
else: return None
def calcular_estado_descuento(self, clasificacion, descuento):
if descuento is None: return ""
es_provincia = clasificacion in ["TRUJILLO", "AREQUIPA", "PIURA"]
tope = 1000.0 if es_provincia else 800.0
if descuento > tope or descuento < 0: return "NO"
return "SI"
# ========================================================================
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
# ========================================================================
# ========================================================================
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
# ========================================================================
def _obtener_costo_segun_categoria(self, diccionario_costo, categoria, dsc_programa):
"""Busca el costo inteligente. Si es 'otros', busca similitud de palabras."""
cat_lower = str(categoria).lower()
# 1. Agrupamos los técnicos dentro de 'programas'
if cat_lower in ["teac", "terc"]:
cat_lower = "programas"
# 2. EL EMBUDO: Si la categoría no existe en el diccionario (ej. seminarios, masterclass, etc.),
# la forzamos a que caiga siempre en la bolsa de "otros"
if cat_lower not in diccionario_costo:
cat_lower = "otros"
# 3. Buscamos el valor
if cat_lower in diccionario_costo:
valor = diccionario_costo[cat_lower]
# Si el valor es un bloque de similitudes (Como sucede ahora con "otros")
if isinstance(valor, dict):
dsc_upper = str(dsc_programa).upper()
# Buscamos coincidencias con el nombre completo del curso
for patron, monto in valor.items():
if patron != "DEFAULT" and patron.upper() in dsc_upper:
return float(monto)
# Si lee todo el diccionario y no encuentra coincidencia, usamos el DEFAULT
return float(valor.get("DEFAULT", 0))
else:
# Si es un número directo (ej. piura, trujillo, carrera)
return float(valor)
return 0.0
# ========================================================================
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
"""Obtiene datos, inyecta Categoria_Programa, Sede, Filtro_Programa y calcula promedios."""
datos = self.processor.obtener_datos_procesados(ano, mes)
programas_disponibles = set()
if datos:
for d in datos:
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
d['Categoria_Programa'] = self.clasificar_programa(prog)
d['Sede'] = self.identificar_sede(prog)
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
# Recolectar qué programas existen REALMENTE en la Sede seleccionada
if sede == "TODOS" or d['Sede'] == sede:
programas_disponibles.add(d['Filtro_Programa'])
# Ordenar para el Dropdown en la UI
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
# 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,
}

View File

@@ -1,290 +0,0 @@
# modules/rentabilidad/processor.py
from datetime import datetime
import unicodedata
class RentabilidadProcessor:
"""Procesador de datos para Rentabilidad - Hereda la lógica estricta de Ocupabilidad"""
def __init__(self, data_manager):
self.data_manager = data_manager
# Columnas exclusivas de rentabilidad
self.columnas = [
'programa_frecuencia',
'fch_inicio',
'Descuento', # INSCRITOS REFRIPERU
'Inscritos_Continuidad', # INSCRITOS CONTINUIDAD
'Inscritos_Nuevos', # Calculado en Logic
'Inscritos_Totales', # TOTAL INSCRITOS
'Inscritos_PC', # INSCRITOS P.C
'Retirados', # INSCRITOS RETIRADOS
'Inscritos_En_Curso', # INSCRITOS ACTIVOS
'Promedio_Cuota', # Pendiente (0)
'Promedio_Desc_E', # Pendiente (0)
'Valor_Venta', # Pendiente (0)
'Opciones' # Fijo "en cu"
]
def obtener_datos_procesados(self, ano, mes):
try:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=3) as executor:
fut_cursos = executor.submit(self.data_manager.ejecutar_consulta_cursos, ano, mes)
fut_raw = executor.submit(self.data_manager.ejecutar_consulta_matriculados_detalle, ano, mes)
fut_matriculas = executor.submit(self.data_manager.cargar_datos_matriculas, ano, mes)
datos_originales = fut_cursos.result()
datos_raw_matriculas = fut_raw.result()
datos_matriculas = fut_matriculas.result()
lista_dnis = []
if datos_raw_matriculas:
for alumno in datos_raw_matriculas:
dni = alumno.get('dsc_documento')
if dni: lista_dnis.append(dni)
datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
datos_procesados = self.aplicar_personalizaciones(
datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano, mes
)
return datos_procesados
except Exception as e:
print(f"❌ Error obteniendo datos rentabilidad: {e}")
return []
def normalizar_texto(self, texto):
if not texto: return ""
texto = str(texto).upper().strip()
texto = unicodedata.normalize('NFD', texto)
return texto.encode('ascii', 'ignore').decode("utf-8")
def obtener_categoria_programa(self, nombre_programa):
try:
nombre_limpio = self.normalizar_texto(nombre_programa)
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
for categoria, config in clasificaciones.items():
patrones = config.get('patrones', [])
for patron in patrones:
if self.normalizar_texto(patron) in nombre_limpio:
return categoria
return "OTROS"
except: return "OTROS"
def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro):
print("💰 Procesando variables de Rentabilidad...")
cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {})
datos_filtrados = []
try: ano_target, mes_target = int(ano_filtro), int(mes_filtro)
except: ano_target, mes_target = 0, 0
# FILTRADO Y ACTUALIZADOR EN VIVO
for curso in datos_originales:
num_indice = str(curso.get('num_indice', ''))
if num_indice in cursos_personalizados:
curso.update(cursos_personalizados[num_indice])
if curso.get('flg_activo', '') == 'NO': continue
# Si el curso está suspendido, lo ignoramos y no se muestra en Rentabilidad
if str(curso.get('cod_estado', '')).strip().upper() == 'SUS': continue
fch_valida = True
try:
raw_fecha = curso.get('fch_inicio')
if raw_fecha:
s_fecha = str(raw_fecha).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-': f_obj = datetime.strptime(s_fecha, '%d-%m-%Y')
elif ' ' in s_fecha: f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
else: f_obj = datetime.strptime(s_fecha, '%Y-%m-%d')
if f_obj and (f_obj.year != ano_target or f_obj.month != mes_target):
fch_valida = False
except: pass
if fch_valida: datos_filtrados.append(curso)
datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados)
datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados)
for c in datos_filtrados: c['fch_inicio'] = self.formatear_fecha(c.get('fch_inicio', ''))
# CÁLCULOS CENTRALES
datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas)
datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu)
datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas)
datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas)
# EXTRACCIÓN REAL DE BD Y VALORES PENDIENTES
for c in datos_filtrados:
c['Retirados'] = int(c.get('Inscritos_Retirados', 0)) # <--- AHORA JALA LA DATA REAL DEL SQL
c['Promedio_Cuota'] = 0.0
c['Promedio_Desc_E'] = 0.0
c['Valor_Venta'] = 0.0
c['Opciones'] = "en cu"
return datos_filtrados
def aplicar_reemplazos_programas(self, datos):
reemplazos = self.data_manager.replace_data.get('reemplazos_programas', {})
for curso in datos:
prog = curso.get('dsc_programa', '')
if prog in reemplazos: curso['dsc_programa'] = reemplazos[prog]
return datos
def concatenar_programa_frecuencia(self, datos):
for c in datos:
p, f = c.get('dsc_programa', ''), c.get('cod_frecuencia', '')
c['programa_frecuencia'] = f"{p} - {f}" if p and f else p or f or ""
return datos
def formatear_fecha(self, fecha_str):
try:
if not fecha_str: return ""
if len(str(fecha_str)) == 10 and str(fecha_str)[2] == '-': return str(fecha_str)
if '.' in str(fecha_str): return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y')
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y')
except: return str(fecha_str)
def identificar_linea_carrera(self, nombre_programa):
categoria = self.obtener_categoria_programa(nombre_programa)
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
return "CARRERA_COMPATIBLE"
return None
def parse_fecha(self, fecha_raw):
try:
if isinstance(fecha_raw, datetime): return fecha_raw
if not fecha_raw: return None
s_fecha = str(fecha_raw).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-': return datetime.strptime(s_fecha, '%d-%m-%Y')
if ' ' in s_fecha: return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
return datetime.strptime(s_fecha, '%Y-%m-%d')
except: return None
# Lógica estricta de Refriperu
def agregar_descuento(self, datos, datos_raw_matriculas):
conteo_por_curso = {}
alumnos_refriperu = set()
correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {})
if datos_raw_matriculas:
for alumno in datos_raw_matriculas:
try:
if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue
num_indice = str(alumno.get('num_indice', ''))
mat_id = str(alumno.get('num_matricula', '')).strip()
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
if mat_id in correcciones_descuento:
if str(correcciones_descuento[mat_id]).upper().strip() == "SI":
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
alumnos_refriperu.add(mat_id)
continue
elif str(correcciones_descuento[mat_id]).upper().strip() == "NO": continue
nombre_programa = str(alumno.get('dsc_programa', ''))
cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper()
try:
inv_neta = float(alumno.get('INV_NETA', 0) or 0)
imp_tc = float(alumno.get('imp_tc', 0) or 0)
if imp_tc <= 0: imp_tc = 3.45 # TC por defecto si no hay comprobante
except: inv_neta, imp_tc = 0.0, 3.45
inv_neta_final = round(inv_neta * imp_tc if cod_moneda == 'DOL' else inv_neta, 2)
categoria_json = self.obtener_categoria_programa(nombre_programa)
es_descuento = False
if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_final <= 1700: es_descuento = True
elif categoria_json in ["TEAC", "TERC"] and 1400 <= inv_neta_final <= 1900: es_descuento = True
if es_descuento:
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
alumnos_refriperu.add(mat_id)
except: continue
for curso in datos: curso['Descuento'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0)
return datos, alumnos_refriperu
# Lógica estricta de Continuidad
def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu):
conteo_por_curso = {}
correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {})
if datos_raw_matriculas and datos_historial:
# PRE-ÍNDICE: {dni: [(linea_carrera, fecha), ...]}
# Calculamos identificar_linea_carrera UNA sola vez por programa del historial
historial_indexado = {}
for dni, antecedentes in datos_historial.items():
lineas = []
for ant in antecedentes:
linea = self.identificar_linea_carrera(str(ant.get('programa', '')))
fecha = ant.get('fecha')
if linea and fecha:
lineas.append((linea, fecha))
if lineas:
historial_indexado[dni] = lineas
alumnos_por_curso = {}
for alu in datos_raw_matriculas:
idx = str(alu.get('num_indice', ''))
if idx not in alumnos_por_curso: alumnos_por_curso[idx] = []
alumnos_por_curso[idx].append(alu)
for curso in datos:
num_indice = str(curso.get('num_indice', ''))
fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio'))
linea_actual = self.identificar_linea_carrera(str(curso.get('dsc_programa', '')))
if not linea_actual or not fecha_inicio_actual: continue
contador_fieles = 0
for alumno in alumnos_por_curso.get(num_indice, []):
if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue
mat_id = str(alumno.get('num_matricula', '')).strip()
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
if mat_id in alumnos_refriperu: continue
dni = str(alumno.get('dsc_documento', 'SD')).strip()
es_fiel = False
if mat_id in correcciones_continuidad:
accion = str(correcciones_continuidad[mat_id]).upper().strip()
if accion == "SI": es_fiel = True
elif accion == "NO": continue
if not es_fiel and dni in historial_indexado:
for linea_pasada, fecha_pasada in historial_indexado[dni]:
if fecha_pasada >= fecha_inicio_actual: continue
if (fecha_inicio_actual - fecha_pasada).days < 60: continue
if linea_pasada == linea_actual:
es_fiel = True
break
if es_fiel: contador_fieles += 1
conteo_por_curso[num_indice] = contador_fieles
for curso in datos: curso['Inscritos_Continuidad'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0)
return datos
def agregar_inscritos_mes(self, datos, datos_matriculas):
dict_norm = {str(k): v for k, v in datos_matriculas.items()}
for curso in datos: curso['Inscritos_Mes'] = dict_norm.get(str(curso.get('num_indice', '')), 0)
return datos
def agregar_inscritos_pc(self, datos, datos_raw_matriculas):
conteo_pc = {}
if datos_raw_matriculas:
for m in datos_raw_matriculas:
try:
if str(m.get('estado_matricula', '')).strip() in ['ALU', 'PRE'] and float(m.get('imp_saldo_matricula', 0) or 0) < 1 and float(m.get('imp_saldo_cuota1', 0) or 0) < 1:
nid = str(m.get('num_indice', ''))
conteo_pc[nid] = conteo_pc.get(nid, 0) + 1
except: continue
for curso in datos: curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0)
return datos

View File

@@ -1,193 +0,0 @@
# modules/saldo_pendiente/logic.py
from .processor import SaldoProcessor
from datetime import datetime
class SaldoLogic:
"""
Controlador lógico optimizado.
1. Filtro Global Manual (lista_saldo_pendiente).
2. Filtro REFRIPERU (Precio Neto < 1900/1000 o Etiquetas).
3. Filtros de Negocio (Vendedor, Fecha, Estado).
"""
def __init__(self, data_manager):
self.data_manager = data_manager
self.processor = SaldoProcessor(self.data_manager)
def obtener_saldos_consolidados(self, tipo_cuota):
# 1. Recargar Configuración (Para leer tu nueva lista en vivo)
try: self.data_manager.cargar_toda_configuracion()
except: pass
all_debtors = []
years_to_scan = [2025, 2026]
print(f"⚡ [Logic] Iniciando carga para {tipo_cuota}...")
for year in years_to_scan:
try:
# Usamos la consulta anual optimizada
raw_year_data = self.data_manager.ejecutar_consulta_saldos_anual(str(year))
if raw_year_data:
datos_procesados = self._procesar_lote_local(raw_year_data, tipo_cuota)
all_debtors.extend(datos_procesados)
except AttributeError:
print("⚠️ Error: DataManager no actualizado.")
continue
# Ordenar alfabéticamente
all_debtors.sort(key=lambda x: (x.get('VENDEDOR', '') or "ZZZ", x.get('ALUMNO', '') or ""))
print(f"✅ Carga Finalizada. Total en tabla: {len(all_debtors)}")
return all_debtors
def _procesar_lote_local(self, raw_data, tipo_cuota):
datos_limpios = []
# --- Configuración ---
ahora = datetime.now()
ano_actual = ahora.year
mes_actual = ahora.month
config = self.data_manager.config_data
# 1. CARGAMOS TUS LISTAS DEL JSON
correcciones_refri = config.get("correcciones_descuento", {})
exclusion_manual_global = config.get("lista_saldo_pendiente", {}) # <--- TU NUEVA LISTA
# 2. Lista Vendedores Permitidos
lista_raw = config.get("lista_pendientes", [])
if not lista_raw:
lista_raw = ["AGUILAR U. JUAN CARLOS", "CHAVEZ P. DIANA", "HUAMAN C. ALONSO AGUSTIN",
"LA ROSA C. VERONICA ASTRID", "LAZARO Q. DIEGO ARTURO",
"MONTOYA D. CARMEN ISABEL", "PERALTA C. ALMENDRA LUCIA"]
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
# 3. Mapeo de columnas
mapa_cols = {
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
"4° Cuota": ("imp_saldo_cuota4", "fch_venc_cuota4"),
"5° Cuota": ("imp_saldo_cuota5", "fch_venc_cuota5"),
}
col_saldo, col_venc = mapa_cols.get(tipo_cuota, (None, None))
if not col_saldo: return []
for row in raw_data:
# Identificadores
num_mat = str(row.get('num_matricula', '')).split('.')[0].strip()
# =================================================================
# 🛑 FILTRO 0: EXCLUSIÓN GLOBAL MANUAL (TU NUEVO PEDIDO)
# =================================================================
# Si la matrícula está en "lista_saldo_pendiente" con valor "NO", ADIÓS.
if num_mat in exclusion_manual_global:
val_excl = str(exclusion_manual_global[num_mat]).strip().upper()
if val_excl == "NO":
continue # Se salta inmediatamente, no importa nada más.
# =================================================================
# 🛑 FILTRO 1: DETECCIÓN DE REFRIPERU (PRECIO NETO / ETIQUETAS)
# =================================================================
es_refriperu = False
# A) Manual (correcciones_descuento)
if num_mat in correcciones_refri:
val = str(correcciones_refri[num_mat]).strip().upper()
if val == "SI": es_refriperu = True
elif val == "NO": es_refriperu = False # Forzamos a cobrar
else:
# B) Automático (Etiquetas)
dsc_beca = str(row.get('dsc_beca', '')).strip().upper()
dsc_prog = str(row.get('dsc_programa', '')).strip().upper()
dsc_prom = str(row.get('dsc_promocion', '')).strip().upper()
if 'REFRIPERU' in dsc_beca or '100%' in dsc_beca or 'BECA' in dsc_beca:
es_refriperu = True
elif 'REFRIPERU' in dsc_prog or 'REFRIPERU' in dsc_prom:
es_refriperu = True
# C) Automático por PRECIO FINAL (INV_NETA)
if not es_refriperu:
try:
# Usamos INV_NETA (Precio Real)
inv_neta = float(row.get('INV_NETA', 0))
except:
inv_neta = 0.0
if inv_neta > 0:
# Rango Técnicos/Especialistas
if ("TECNICO" in dsc_prog or "ESPECIALISTA" in dsc_prog or "TEAC" in dsc_prog):
if inv_neta < 1900: es_refriperu = True # ej: 1799
# Rango Gestión/Ventas
elif "GESTION" in dsc_prog or "VENTA" in dsc_prog:
if inv_neta < 1000: es_refriperu = True
if es_refriperu:
continue
# =================================================================
# 🛑 FILTROS ESTÁNDAR (VENDEDOR, ESTADO, FECHA)
# =================================================================
# Vendedor
vendedor = str(row.get('dsc_vendedor', '')).strip().upper()
if lista_permitidos:
encontrado = False
if vendedor in lista_permitidos: encontrado = True
else:
for p in lista_permitidos:
if p.replace(" ","") in vendedor.replace(" ",""):
encontrado = True; break
if not encontrado: continue
# Estado
estado = str(row.get('estado_matricula', '')).strip().upper()
if estado not in ('ALU', 'PRE'): continue
# Fecha Futura (Solo 2da cuota en adelante)
if tipo_cuota != "1° Cuota":
fch_raw = row.get(col_venc)
if fch_raw:
try:
if isinstance(fch_raw, str): f_obj = datetime.strptime(fch_raw[:10], '%Y-%m-%d')
else: f_obj = fch_raw
if f_obj.year > ano_actual: continue
elif f_obj.year == ano_actual and f_obj.month > mes_actual: continue
except: pass
# Saldo Positivo
try: val = float(row.get(col_saldo, 0))
except: val = 0.0
if val <= 0.1: continue
# --- 4. Construcción de Fila ---
def fmt(v):
if not v: return ""
if isinstance(v, str): return v[:10]
if isinstance(v, datetime): return v.strftime('%d-%m-%Y')
return str(v)
fila = {
'MATRICULA': row.get('num_matricula'),
'VENDEDOR': row.get('dsc_vendedor'),
'ALUMNO': row.get('dsc_alumno'),
'PROGRAMA': row.get('dsc_promocion'),
'FECHA INICIO': fmt(row.get('fch_inicio')),
'FECHA MATR.': fmt(row.get('fch_matricula')),
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
f'SALDO {tipo_cuota.upper()}': val,
'VENCIMIENTO': fmt(row.get(col_venc)),
'INV. NETA': float(row.get('INV_NETA', 0)),
'DNI': row.get('dsc_documento', ''),
'CELULAR': row.get('dsc_telefono_1', '')
}
datos_limpios.append(fila)
return datos_limpios
# Métodos legacy
def obtener_saldos(self, ano, mes, tipo_cuota): return self.processor.obtener_datos_procesados(ano, mes, tipo_cuota)
def get_current_year(self): return self.data_manager.get_current_year()
def get_current_month(self): return self.data_manager.get_current_month()

View File

@@ -1,162 +0,0 @@
# modules/saldo_pendiente/processor.py
from datetime import datetime
class SaldoProcessor:
"""
Procesador de lógica de negocio para Saldos Pendientes.
Filtra estrictamente por estado ALU/PRE, saldos positivos,
vendedores permitidos y FECHA DE VENCIMIENTO (No mostrar futuro).
"""
def __init__(self, data_manager):
self.data_manager = data_manager
def obtener_datos_procesados(self, ano, mes, tipo_cuota):
"""
Recupera datos crudos y aplica los filtros de negocio.
"""
# 1. Traer la data cruda desde DataManager
raw_data = self.data_manager.ejecutar_consulta_saldos_pendientes(ano, mes)
datos_limpios = []
# 2. Obtener fecha actual para saber qué es "futuro"
ahora = datetime.now()
ano_actual = ahora.year
mes_actual = ahora.month
# 3. Cargar la "Lista Blanca" de vendedores desde el JSON
config = self.data_manager.config_data
lista_raw = config.get("lista_pendientes", [])
# RESPALDO DE EMERGENCIA
if not lista_raw:
lista_raw = [
"AGUILAR U. JUAN CARLOS",
"CHAVEZ P. DIANA",
"HUAMAN C. ALONSO AGUSTIN",
"LA ROSA C. VERONICA ASTRID",
"LAZARO Q. DIEGO ARTURO",
"MONTOYA D. CARMEN ISABEL",
"PERALTA C. ALMENDRA LUCIA"
]
# Normalizamos la lista
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
# 4. Mapeo de columnas según la selección
mapa_columnas = {
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
}
col_saldo_target, col_venc_target = mapa_columnas.get(tipo_cuota, (None, None))
if not col_saldo_target:
return []
for row in raw_data:
# =================================================================
# 🛑 FILTRO 1: VENDEDOR PERMITIDO
# =================================================================
vendedor_actual = str(row.get('dsc_vendedor', '')).strip().upper()
if lista_permitidos:
if vendedor_actual not in lista_permitidos:
# Búsqueda parcial por si hay errores de espacios
encontrado = False
for permitido in lista_permitidos:
v_norm = vendedor_actual.replace(" ", "")
p_norm = permitido.replace(" ", "")
if p_norm in v_norm:
encontrado = True
break
if not encontrado:
continue
# =================================================================
# 🛑 FILTRO 2: ESTADO (ALU/PRE)
# =================================================================
estado = str(row.get('estado_matricula', '')).strip().upper()
if estado not in ('ALU', 'PRE'):
continue
# =================================================================
# 🛑 FILTRO 3: FECHA DE VENCIMIENTO (NO MOSTRAR FUTURO)
# =================================================================
# Este filtro aplica PRINCIPALMENTE para 2° Cuota en adelante.
# (Aunque la lógica es válida para todas, la 1° suele ser inmediata).
if tipo_cuota != "1° Cuota":
fch_venc_raw = row.get(col_venc_target)
es_futuro = False
if fch_venc_raw:
try:
# Convertir a objeto fecha si es texto
if isinstance(fch_venc_raw, str):
# Asumimos formato SQL YYYY-MM-DD
f_obj = datetime.strptime(fch_venc_raw[:10], '%Y-%m-%d')
else:
f_obj = fch_venc_raw # Ya es datetime
venc_ano = f_obj.year
venc_mes = f_obj.month
# LÓGICA DE TIEMPO:
# Si el año de vencimiento es mayor al actual -> ES FUTURO
if venc_ano > ano_actual:
es_futuro = True
# Si es el mismo año, pero el mes es mayor al actual -> ES FUTURO
elif venc_ano == ano_actual and venc_mes > mes_actual:
es_futuro = True
except Exception as e:
# Si falla la fecha, asumimos que no es futuro para no ocultar por error
pass
# Si la cuota vence en el futuro (ej: Marzo cuando estamos en Febrero), LA OCULTAMOS.
if es_futuro:
continue
# =================================================================
# 🛑 FILTRO 4: SALDO > 0
# =================================================================
try:
val = row.get(col_saldo_target, 0)
saldo_a_mostrar = float(val) if val is not None else 0.0
except:
saldo_a_mostrar = 0.0
if saldo_a_mostrar <= 0.1:
continue
# --- FORMATEO PARA VISUALIZACIÓN ---
def format_date(val):
if not val: return ""
if isinstance(val, str): return val[:10]
if isinstance(val, datetime): return val.strftime('%d-%m-%Y')
return str(val)
f_inicio = format_date(row.get('fch_inicio'))
f_matr = format_date(row.get('fch_matricula'))
f_vencimiento = format_date(row.get(col_venc_target))
# --- CONSTRUCCIÓN DE LA FILA FINAL ---
fila = {
'MATRICULA': row.get('num_matricula'),
'VENDEDOR': row.get('dsc_vendedor'),
'ALUMNO': row.get('dsc_alumno'),
'PROGRAMA': row.get('dsc_promocion'),
'FECHA INICIO': f_inicio,
'FECHA MATR.': f_matr,
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
f'SALDO {tipo_cuota.upper()}': saldo_a_mostrar,
'VENCIMIENTO': f_vencimiento, # Aquí se verá la fecha (ej: 14-02-2026)
'INV. NETA': float(row.get('INV_NETA', 0)),
}
datos_limpios.append(fila)
return datos_limpios

View File

@@ -1,736 +0,0 @@
# modules/ventas/logic.py
import pandas as pd
from datetime import datetime
import requests
import re
class VentasLogic:
"""Clase que maneja la lógica de negocio y procesamiento de datos para Ventas"""
def __init__(self, data_manager):
self.data_manager = data_manager
self._config_sedes = None
def _clasificar_sede_comisiones(self, dsc_programa):
"""Clasifica la sede usando EXACTAMENTE la misma lógica de Cobranza (sede.json)."""
try:
if not hasattr(self, '_cob_processor') or self._cob_processor is None:
from modules.cobranza.processor import CobranzaProcessor
self._cob_processor = CobranzaProcessor(self.data_manager)
return self._cob_processor.clasificar_sede(dsc_programa)
except Exception:
prog_up = str(dsc_programa or "").upper()
for s in ("AREQUIPA", "PIURA", "TRUJILLO"):
if s in prog_up:
return s
return "LIMA"
def buscar_todos_matriculados(self):
"""Devuelve TODOS los matriculados (todos los años/meses) para el buscador.
Ejecuta la query de matrículas quitando el filtro de YEAR/MONTH de fch_matricula.
Retorna filas con el mismo formato que el detalle (para reusar columnas)."""
import re as _re
sql_base = self.data_manager.query_matriculas_sql
if not sql_base:
return []
# Quitar las condiciones de año y mes de fch_matricula
sql = _re.sub(r"AND\s+YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql_base, flags=_re.IGNORECASE)
sql = _re.sub(r"AND\s+MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql, flags=_re.IGNORECASE)
# Por si están sin AND (primera condición)
sql = _re.sub(r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
sql = _re.sub(r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
# El buscador debe traer TODOS los matriculados excepto ANU (incluye SUS, no pagados, etc.)
# El buscador muestra TODOS los matriculados, incluso ANU
sql = _re.sub(r"AND\s+sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\([^)]*\)", "", sql, flags=_re.IGNORECASE)
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*,\s*'RET'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
conn = self.data_manager.get_connection()
cursor = conn.cursor()
cursor.execute(sql) # sin parámetros de fecha
columns = [c[0] for c in cursor.description]
datos = [dict(zip(columns, row)) for row in cursor.fetchall()]
# Traer fecha de cancelación de la cuota 1 de TODAS las matrículas (cualquier refinanciamiento)
fechas_canc = {}
try:
cursor.execute("""
SELECT num_matricula, MAX(fch_cancelacion)
FROM sgede_cronograma_matricula
WHERE num_cuota = 1 AND fch_cancelacion IS NOT NULL
GROUP BY num_matricula
""")
for r in cursor.fetchall():
k = str(r[0]).strip()
if k.endswith('.0'): k = k[:-2]
fechas_canc[k] = r[1]
except Exception:
pass
conn.close()
filas = []
for d in datos:
raw_mat = d.get('num_matricula')
mat_id = ""
if raw_mat is not None:
v = str(raw_mat).strip()
if v.endswith('.0'): v = v[:-2]
mat_id = v
alumno = d.get('dsc_alumno', 'SIN NOMBRE')
# MOSTRAR: detallado (dsc_promocion = sgede_RP_programa.dsc_det_programa)
programa = (d.get('dsc_promocion') or d.get('dsc_det_programa') or d.get('dsc_programa') or '')
# CLASIFICAR sede: prioriza dsc_programa
prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa') or d.get('dsc_promocion') or '')
vendedor = d.get('dsc_vendedor', 'SIN VENDEDOR') or 'SIN VENDEDOR'
inv = d.get('INV_NETA', 0) or 0
saldo_mat = d.get('imp_saldo_matricula', 0) or 0
saldo_c1 = d.get('imp_saldo_cuota1', 0) or 0
def _fdate(v):
if not v or str(v).strip() in ("","None"): return ""
if hasattr(v,'strftime'): return v.strftime('%d/%m/%Y')
s = str(v)[:10]
if len(s)==10 and s[4]=='-': p=s.split('-'); return f"{p[2]}/{p[1]}/{p[0]}"
return s
f_mat = _fdate(d.get('fch_matricula'))
f_ini = _fdate(d.get('fch_inicio'))
f_canc = _fdate(fechas_canc.get(mat_id))
sede = self._clasificar_sede_comisiones(prog_clasificacion)
# mismo layout que el detalle: 16 columnas
fila = [
vendedor, alumno, f_mat, f_canc, "", "", "",
f"S/ {float(inv):,.0f}", programa, f_ini,
f"S/ {float(saldo_mat):,.0f}", f"S/ {float(saldo_c1):,.0f}",
"", "-", sede, mat_id,
]
filas.append(fila)
return filas
def obtener_datos_brutos(self, ano, mes_numero):
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero))
return datos or []
def obtener_datos_brutos_filtrado(self, ano, mes_numero, sede="TODOS", programa="TODOS"):
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero), sede, programa)
return datos or []
# =========================================================================
# FUNCIONES AUXILIARES
# =========================================================================
def clasificar_programa(self, dsc_programa):
if not dsc_programa: return "OTROS"
dsc_prog_upper = str(dsc_programa).upper()
meta_data = self.data_manager.meta_data
clasificaciones = meta_data.get("clasificacion_programas", {})
for clase, data in clasificaciones.items():
for patron in data.get("patrones", []):
if patron.upper() in dsc_prog_upper:
return clase
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
def identificar_linea_carrera(self, nombre_programa):
categoria = self.clasificar_programa(nombre_programa)
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC", "CARRERA"]:
return "CARRERA_COMPATIBLE"
return None
def parse_fecha(self, fecha_raw):
try:
if isinstance(fecha_raw, datetime): return fecha_raw
if not fecha_raw: return None
s_fecha = str(fecha_raw).strip()
if len(s_fecha) == 10 and s_fecha[2] == '-':
return datetime.strptime(s_fecha, '%d-%m-%Y')
if ' ' in s_fecha:
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
return datetime.strptime(s_fecha, '%Y-%m-%d')
except:
return None
# =========================================================================
# OBTENCIÓN DE DETALLE POR VENDEDOR (LÓGICA PURA)
# =========================================================================
def obtener_detalle_vendedor(self, vendedor, ano, mes_numero):
try:
key_mes = f"{int(mes_numero):02d}-{ano}"
lista_raw = self.data_manager.historico_pendientes.get(key_mes, [])
set_vip_actual = set()
lista_vip_str = []
for item in lista_raw:
try:
val = int(item)
set_vip_actual.add(val)
lista_vip_str.append(str(val))
except: pass
# 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

View File

@@ -1,10 +0,0 @@
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

View File

@@ -1,587 +0,0 @@
# 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"}

View File

@@ -58,6 +58,11 @@ class DataManager:
self.query_cronograma_sql = ""
self.query_facturas_sql = ""
# Caché en memoria de las consultas ANUALES de cobranza (evita re-ejecutar
# la consulta del año completo en cada combinación de filtros).
self._cache_cronograma = {} # str(ano) -> filas
self._cache_facturas = {} # str(ano) -> filas
self.cargar_toda_configuracion()
# =========================================================================
@@ -94,19 +99,27 @@ class DataManager:
self.pendientes_data = set()
def _get_json(self, url, default=None):
# 3 intentos, timeout 15s. Si falla, avisa en consola y devuelve el default.
for intento in range(1, 4):
try:
r = requests.get(url, timeout=4)
r = requests.get(url, timeout=15)
r.raise_for_status()
return r.json()
except:
except Exception as e:
print(f"⚠️ [GitHub] intento {intento}/3 fallo JSON {url}: {e}")
print(f"❌ [GitHub] NO se pudo descargar (JSON): {url}")
return default or {}
def _get_text(self, url):
# 3 intentos, timeout 15s. Si falla, avisa en consola y devuelve texto vacio.
for intento in range(1, 4):
try:
r = requests.get(url, timeout=4)
r = requests.get(url, timeout=15)
r.raise_for_status()
return r.text
except:
except Exception as e:
print(f"⚠️ [GitHub] intento {intento}/3 fallo TEXT {url}: {e}")
print(f"❌ [GitHub] NO se pudo descargar (TEXT): {url}")
return ""
def _cargar_pendientes(self):
@@ -122,7 +135,7 @@ class DataManager:
def get_connection(self):
import pyodbc
conn_str = (
f"DRIVER={{SQL Server}};"
f"DRIVER={{ODBC Driver 17 for SQL Server}};" # <--- ¡ESTE ES EL CAMBIO MÁGICO!
f"SERVER={self.server};"
f"DATABASE={self.database};"
f"UID={self.username};"
@@ -447,6 +460,9 @@ class DataManager:
try: lista_vip_list.append(int(item))
except: pass
# Lista PURA de BASE_PENDIENTES (sin overrides). Venta Pendientes usa SOLO esto.
set_lista_pendientes = set(lista_vip_list)
# Incluir matrículas con override de fecha (para que la query las traiga, incluso ANU)
_fov = getattr(self, '_fecha_canc_overrides', None) or {}
for mk in _fov.keys():
@@ -619,7 +635,7 @@ class DataManager:
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:
if matricula_int in set_lista_pendientes and pago_en_fecha_correcta:
ventas_por_vendedor[vendedor]['monto_pendientes'] += inv_neta
ventas_por_vendedor[vendedor]['cantidad_pendientes'] += 1
@@ -642,6 +658,10 @@ class DataManager:
return []
def ejecutar_consulta_cronograma_cobranza(self, ano):
clave = str(ano)
en_cache = self._cache_cronograma.get(clave)
if en_cache is not None:
return en_cache
try:
if not self.query_cronograma_sql:
return []
@@ -651,12 +671,17 @@ class DataManager:
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
self._cache_cronograma[clave] = results # cachear SOLO si tuvo éxito
return results
except Exception as e:
print(f"❌ Error cronograma cobranza: {e}")
return []
def ejecutar_consulta_facturas_cobranza(self, ano):
clave = str(ano)
en_cache = self._cache_facturas.get(clave)
if en_cache is not None:
return en_cache
try:
if not self.query_facturas_sql:
return []
@@ -666,11 +691,22 @@ class DataManager:
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
self._cache_facturas[clave] = results # cachear SOLO si tuvo éxito
return results
except Exception as e:
print(f"❌ Error facturas cobranza: {e}")
return []
def limpiar_cache_cobranza(self, ano=None):
"""Vacía el caché anual de cobranza para forzar re-lectura desde SQL.
Se llama en cada refresco (manual o ciclo de 15 min)."""
if ano is None:
self._cache_cronograma.clear()
self._cache_facturas.clear()
else:
self._cache_cronograma.pop(str(ano), None)
self._cache_facturas.pop(str(ano), None)
def ejecutar_consulta_saldos_anual(self, ano):
try:
if not self.query_matriculas_sql:

View File

@@ -52,8 +52,22 @@ def cache_estadisticas():
def cache_refresh():
services.refrescar_todo()
return {"status": "refrescado"}
@app.post("/api/cache/refresh-manual")
def cache_refresh_manual(clave: str = None):
"""Botón 'Actualizar': re-lee las bases para la vista actual (clave) al instante,
y recachea el resto en segundo plano."""
return services.refrescar_manual(clave)
@app.get("/api/ultima-actualizacion")
def api_ultima_actualizacion():
return services.ultima_actualizacion()
@app.post("/api/precarga/modulo")
def api_precarga_modulo(modulo: str, ano: int):
"""Calienta en 2º plano la tabla principal del módulo para el año indicado."""
return services.precargar_modulo(modulo, ano)
@app.get("/api/precarga/estado")
def api_precarga_estado(modulo: str):
"""Progreso de la precarga."""
return services.precarga_estado(modulo)
@app.get("/api/periodo-actual")
def periodo_actual():
dm = services.get_dm()
@@ -74,6 +88,14 @@ def get_ocupabilidad(
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/ocupabilidad/alumnos")
def get_ocupabilidad_alumnos(num_indice: str = Query(...)):
try:
return services.ocupabilidad_alumnos(num_indice)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── VENTAS ─────────────────────────────────────────────────────────────────
@app.get("/api/ventas")
def get_ventas(ano: int = Query(...), mes: int = Query(...),
@@ -254,6 +276,32 @@ def get_cobranza_detalle_todos(ano: int, mes: int, sectorista: str = "TODOS"):
raise HTTPException(status_code=500, detail=str(e))
# ── ESTADO / OBSERVACIONES POR ALUMNO (Cobranza) ───────────────────────────
@app.get("/api/alumno/estado")
def get_alumno_estado(matricula: str):
try:
return services.alumno_estado_obtener(matricula)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/alumno/estado/guardar")
def post_alumno_estado(payload: dict = Body(...)):
try:
return services.alumno_estado_guardar(
payload.get("matricula",""),
payload.get("estado","EN CURSO"),
payload.get("observaciones",[]),
payload.get("usuario"))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/alumnos/por-retirar")
def get_alumnos_por_retirar():
try:
return services.alumnos_por_retirar()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── USUARIOS ───────────────────────────────────────────────────────────────
@app.get("/api/usuarios")
def get_usuarios():

View File

@@ -98,6 +98,7 @@ class CobranzaLogic:
alumno = d.get('ALUMNO', '')
num_cuota = d.get('NUM_CUOTA', '-')
fch_venc = d.get('FCH_VENC', '-')
programa = d.get('PROGRAMA', '-') # programa detallado del alumno
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
@@ -113,7 +114,6 @@ class CobranzaLogic:
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 ""
@@ -123,7 +123,8 @@ class CobranzaLogic:
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}"
f"S/ {saldo:,.0f}",
programa # índice [14]: PROGRAMA (extra, al final; no altera columnas previas)
]
sheet_data.append(fila)
@@ -136,7 +137,8 @@ class CobranzaLogic:
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}"
f"S/ {t_saldo:,.0f}",
"" # índice [14]: PROGRAMA vacío en la fila total
]
sheet_data.append(fila_total)
return sheet_data

View File

@@ -72,11 +72,8 @@ class AnalizadorCursos:
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:
# NO auto-corregir: si el programa no existe en la sede/mes, se respeta el
# filtro y la tabla queda vacia (antes cambiaba a TODOS y mostraba todo).
self.filtro_corregido = None
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):

View File

@@ -154,6 +154,123 @@ class CursoProcessor:
print(f"❌ Error obteniendo datos procesados: {e}")
return []
# =========================================================================
# DETALLE DE ALUMNOS DE UN CURSO (para el popup "Ver")
# =========================================================================
def obtener_alumnos_curso(self, num_indice):
"""Alumnos matriculados de un curso (num_indice), TODOS menos ANU.
Devuelve nombre + cod_estado (ALU/PRE/RET) + curso anterior TEAC/TERC (si existe)."""
try:
idx = str(num_indice).strip()
conn = self.data_manager.get_connection()
cursor = conn.cursor()
# 1) Alumnos del curso actual (nombre, estado, dni, fecha de inicio del curso)
sql = """
SELECT
sgeca_matricula.num_matricula,
sgeca_matricula.cod_estado AS cod_estado,
sgema_alumno.dsc_documento AS dni,
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno
+ ', ' + sgema_alumno.dsc_nombres AS dsc_alumno,
rp.fch_inicio AS fch_inicio_actual
FROM sgeca_matricula
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
LEFT JOIN sgede_RP_programa rp
ON sgeca_matricula.cod_periodo = rp.cod_detalle
AND sgeca_matricula.cod_programa = rp.cod_programa
AND sgeca_matricula.num_indice = rp.num_indice
WHERE sgeca_matricula.num_indice = ?
AND sgeca_matricula.cod_localidad LIKE 'SCENT'
AND sgeca_matricula.cod_estado NOT IN ('ANU')
ORDER BY dsc_alumno
"""
cursor.execute(sql, idx)
filas = []
dnis = []
for row in cursor.fetchall():
mat = str(row[0]).strip()
if mat.endswith('.0'): mat = mat[:-2]
dni = str(row[2]).strip() if row[2] else ""
filas.append({
"matricula": mat,
"estado": str(row[1]).strip().upper(),
"dni": dni,
"alumno": str(row[3]).strip(),
"fch_inicio_actual": self.parse_fecha(row[4]),
"curso_anterior": "-",
"estado_anterior": "-",
})
if dni:
dnis.append(dni)
# 2) Historial de esos alumnos (cursos TEAC/TERC, no ANU, no SUS)
historial = {} # dni -> lista de {programa, fecha, estado}
if dnis:
dnis_unicos = list(set(dnis))
for i in range(0, len(dnis_unicos), 1000):
chunk = dnis_unicos[i:i+1000]
ph = ",".join(["?"] * len(chunk))
sql_hist = f"""
SELECT a.dsc_documento,
rp.dsc_det_programa AS programa_detallado,
p.dsc_programa AS programa_general,
rp.fch_inicio, m.cod_estado
FROM sgeca_matricula m
INNER JOIN sgema_alumno a ON m.cod_alumno = a.cod_alumno
INNER JOIN sgeca_programa p ON m.cod_programa = p.cod_programa
INNER JOIN sgede_RP_programa rp
ON m.cod_periodo = rp.cod_detalle
AND m.cod_programa = rp.cod_programa
AND m.num_indice = rp.num_indice
WHERE a.dsc_documento IN ({ph})
AND m.cod_localidad LIKE 'SCENT'
AND m.cod_estado IN ('ALU','PRE','RET')
AND rp.cod_estado <> 'SUS'
"""
cursor.execute(sql_hist, *chunk)
for r in cursor.fetchall():
d = str(r[0]).strip()
det = str(r[1]).strip() if r[1] else ""
gen = str(r[2]).strip() if r[2] else ""
historial.setdefault(d, []).append({
"programa": det if det and det != "None" else gen, # detallado (como Cobranza)
"programa_clasif": gen, # general, para clasificar TEAC/TERC
"fecha": self.parse_fecha(r[3]),
"estado": str(r[4]).strip().upper(),
})
conn.close()
# 3) Para cada alumno, buscar el ÚLTIMO curso anterior TEAC/TERC
for f in filas:
dni = f["dni"]
fch_act = f["fch_inicio_actual"]
if not dni or not fch_act or dni not in historial:
continue
candidatos = []
for h in historial[dni]:
if not h["fecha"]:
continue
if not (h["fecha"] < fch_act): # debe ser anterior
continue
cat = self.obtener_categoria_programa(h.get("programa_clasif") or h["programa"])
if cat not in ("TEAC", "TERC"): # solo TEAC/TERC
continue
candidatos.append(h)
if candidatos:
ultimo = max(candidatos, key=lambda x: x["fecha"])
f["curso_anterior"] = ultimo["programa"]
f["estado_anterior"] = ultimo["estado"]
# limpiar campos internos
for f in filas:
f.pop("dni", None)
f.pop("fch_inicio_actual", None)
return filas
except Exception as e:
print(f"❌ Error obtener_alumnos_curso: {e}")
return []
def normalizar_texto(self, texto):
if not texto: return ""
texto = str(texto).upper().strip()

View File

@@ -162,11 +162,7 @@ class RentabilidadLogic:
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:
# NO auto-corregir: se respeta el filtro y la tabla queda vacia si no hay.
self.filtro_corregido = None
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):

View File

@@ -163,6 +163,9 @@ class VentasLogic:
set_vip_actual.add(val)
lista_vip_str.append(str(val))
except: pass
# Lista PURA de BASE_PENDIENTES (sin overrides). Venta Pendientes usa SOLO esto.
set_lista_pendientes = set(set_vip_actual)
# Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas)
# Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas)
_fov_vip = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
@@ -217,6 +220,21 @@ class VentasLogic:
columns = [column[0] for column in cursor.description]
datos_raw = [dict(zip(columns, row)) for row in cursor.fetchall()]
# DEDUP por num_matricula: el LEFT JOIN con RP_programa puede duplicar filas
# (una con fch_cancelacion y otra sin). Nos quedamos con UNA sola por matrícula,
# priorizando la que TENGA fecha de cancelación (verdad del pago).
_dedup = {}
for _d in datos_raw:
_m = str(_d.get('num_matricula', '')).strip()
if _m.endswith('.0'): _m = _m[:-2]
_prev = _dedup.get(_m)
_tiene_f = bool(_d.get('fch_cancelacion_cuota1'))
if _prev is None:
_dedup[_m] = _d
elif _tiene_f and not bool(_prev.get('fch_cancelacion_cuota1')):
_dedup[_m] = _d
datos_raw = list(_dedup.values())
# 🔥 DESCARGA DEL CRONOGRAMA COMPLETO (SIN IMPORTAR EL MES) 🔥
lista_mats = list(set([str(d.get('num_matricula')).replace('.0','').strip() for d in datos_raw if d.get('num_matricula')]))
cuotas_raw = []
@@ -322,6 +340,7 @@ class VentasLogic:
saldo_mat = float(d.get('imp_saldo_matricula', 0.0))
saldo_c1 = float(d.get('imp_saldo_cuota1', 0.0))
fch_canc_raw = d.get('fch_cancelacion_cuota1')
fch_canc_real_sql = d.get('fch_cancelacion_cuota1') # SQL puro, sin override
fch_mat = d.get('fch_matricula')
# Override de fecha cancelación 1 (Supabase) → prioridad para clasificación + cuenta como pagado
@@ -610,8 +629,20 @@ class VentasLogic:
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:
# VENTA PENDIENTES: SOLO de tu lista BASE_PENDIENTES y que pagaron su 1ª
# cuota EN EL MES/AÑO DEL FILTRO. Usa la FECHA REAL DEL SQL (sin override).
_pago_mes_real = False
if fch_canc_real_sql and str(fch_canc_real_sql).strip() != "None":
try:
if isinstance(fch_canc_real_sql, str):
_fo = datetime.strptime(fch_canc_real_sql[:10], '%Y-%m-%d')
_fa, _fm = _fo.year, _fo.month
else:
_fa, _fm = fch_canc_real_sql.year, fch_canc_real_sql.month
if int(_fa) == int(ano) and int(_fm) == int(mes_numero):
_pago_mes_real = True
except: pass
if matricula_int in set_lista_pendientes and _pago_mes_real:
agregar_a_lista("Venta Pendientes")

View File

@@ -1,4 +1,3 @@
# 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.
@@ -14,6 +13,17 @@ from cache_manager import cache_get_or_set
# Instancia única del DataManager (como @st.cache_resource)
_DM = None
# Hora de la última actualización de DATOS (se setea al precargar/refrescar)
_ultima_actualizacion = None
def _marcar_actualizacion():
global _ultima_actualizacion
from datetime import datetime
_ultima_actualizacion = datetime.now()
def ultima_actualizacion():
"""Devuelve la hora (ISO) del último refresco de datos, o None."""
return {"hora": _ultima_actualizacion.isoformat() if _ultima_actualizacion else None}
def get_dm() -> DataManager:
global _DM
@@ -21,7 +31,6 @@ def get_dm() -> DataManager:
_DM = DataManager()
return _DM
def _serializar_dicts(datos):
out = []
for d in (datos or []):
@@ -31,7 +40,6 @@ def _serializar_dicts(datos):
out.append(fila)
return out
def _serializar_filas(filas, limite=None):
out = []
for f in (filas or []):
@@ -39,14 +47,24 @@ def _serializar_filas(filas, limite=None):
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)
print(f"[DEBUG Ocupabilidad] Buscando ano={ano}, mes={mes}, sede={sede}, programa={programa}")
# Intento 1: Formato original (ej: "6")
datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa)
# Intento 2: Si viene vacío, probar con formato de dos dígitos (ej: "06")
if not datos and len(str(mes)) == 1:
mes_padded = str(mes).zfill(2)
print(f"[DEBUG Ocupabilidad] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'")
datos = analizador.obtener_datos_procesados(str(ano), mes_padded, sede, programa)
filas = analizador.formatear_datos_para_tabla(datos)
print(f"[DEBUG Ocupabilidad] Proceso completado. Registros encontrados: {len(datos) if datos else 0}")
return {
"datos": _serializar_dicts(datos),
"filas": _serializar_filas(filas),
@@ -54,25 +72,79 @@ def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"):
return cache_get_or_set("ocupabilidad", (ano, mes, sede, programa), _load)
# ── VENTAS ─────────────────────────────────────────────────────────────────
def ventas(ano, mes, sede="TODOS", programa="TODOS"):
def ocupabilidad_alumnos(num_indice):
"""Alumnos de un curso (num_indice) para el popup Ver: nombre + estado (sin ANU)."""
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}
analizador = AnalizadorCursos(dm)
alumnos = analizador.curso_processor.obtener_alumnos_curso(num_indice)
return {"alumnos": alumnos}
return cache_get_or_set("ocup_alumnos", (str(num_indice),), _load)
# ── VENTAS ─────────────────────────────────────────────────────────────────
def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
def _load():
dm = get_dm()
# Los overrides de Supabase SOLO aplican en Comisiones (con_overrides=True).
# En Ventas se ignoran: la tabla muestra los datos reales del SQL.
if con_overrides:
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes)
dm._fecha_canc_overrides = comisiones_overrides_fecha()
dm._inv_neta_overrides = comisiones_overrides_inv_neta()
else:
dm._tc_override_mes = None
dm._fecha_canc_overrides = {}
dm._inv_neta_overrides = {}
logic = VentasLogic(dm)
datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes))
datos = [d for d in datos if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR']
print(f"[DEBUG Ventas] Buscando ano={ano}, mes={mes}, sede={sede}, programa={programa}")
mes_str = str(mes)
datos = logic.obtener_datos_brutos_filtrado(str(ano), mes_str, sede, programa) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), mes_str)
# Intento 2: Mes de dos dígitos si el original no trajo nada
if not datos and len(mes_str) == 1:
mes_padded = mes_str.zfill(2)
print(f"[DEBUG Ventas] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'")
datos = logic.obtener_datos_brutos_filtrado(str(ano), mes_padded, sede, programa) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), mes_padded)
datos = [d for d in (datos or []) if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR']
# PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que
# la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla).
try:
det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), mes_str)
pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else []
pend_por_vend = {}
for f in pend_filas:
if not f or str(f[0]).strip().upper() == "TOTAL GENERAL":
continue
vend = str(f[0]).strip()
try:
monto = float(str(f[7]).replace("S/", "").replace(",", "").strip())
except Exception:
monto = 0.0
acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0})
acc["monto"] += monto
acc["cant"] += 1
for d in datos:
v = str(d.get("VENDEDOR", "")).strip()
acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0})
d["PENDIENTES"] = round(acc["monto"], 2)
d["INSCRITOS_PENDIENTES"] = acc["cant"]
except Exception as e:
print(f"[ventas] no se pudo alinear PENDIENTES con el Ver: {e}")
filas = logic.formatear_datos_para_tabla(datos)
print(f"[DEBUG Ventas] Proceso completado. Registros encontrados: {len(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()
@@ -83,7 +155,6 @@ def _clasif_filtro_programa(dm, dscp):
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()
@@ -93,8 +164,7 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS
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":
@@ -106,8 +176,6 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS
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
@@ -116,10 +184,7 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS
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)
@@ -128,9 +193,7 @@ def comisiones_detalle_todos(ano, mes):
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()
@@ -139,8 +202,6 @@ def comisiones_overrides_fecha():
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 != "":
@@ -149,10 +210,7 @@ def comisiones_overrides_fecha():
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()
@@ -164,19 +222,14 @@ def comisiones_overrides_inv_neta():
s = str(v).replace("S/", "").replace(",", "").strip()
if s == "":
continue
try:
out[str(r["num_matricula"])] = float(s)
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):
@@ -185,7 +238,6 @@ def _aplicar_overrides_comisiones(filas):
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,
@@ -196,10 +248,8 @@ def _aplicar_overrides_comisiones(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
f.append(f[8])
if len(f) == 17:
f.append("0")
if mat in ov:
@@ -209,30 +259,38 @@ def _aplicar_overrides_comisiones(filas):
if val is None:
continue
if str(val) == "__VACIO__":
f[idx] = "" # forzar vacío explícito (override "borrar")
f[idx] = ""
elif str(val) != "":
f[idx] = val
f[17] = "1" # marcar fila como editada
f[17] = "1"
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))
print(f"[DEBUG Cobranza] Buscando ano={ano}, mes={mes}, sectorista={sectorista}, agrupacion={agrupacion}")
mes_str = str(mes)
filas = logic.obtener_datos_tabla(str(ano), mes_str, sectorista, agrupacion)
if not filas and len(mes_str) == 1:
mes_padded = mes_str.zfill(2)
print(f"[DEBUG Cobranza] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'")
filas = logic.obtener_datos_tabla(str(ano), mes_padded, sectorista, agrupacion)
sectoristas = logic.obtener_lista_sectoristas(str(ano), mes_str)
print(f"[DEBUG Cobranza] Proceso completado. Filas encontradas: {len(filas) if filas else 0}")
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()
@@ -240,7 +298,6 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"):
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()
@@ -250,14 +307,12 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"):
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
else:
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():
@@ -270,7 +325,6 @@ def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"):
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()
@@ -279,7 +333,6 @@ def rentabilidad_detalle(programa, ano, 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()
@@ -287,7 +340,6 @@ def rentabilidad_costos(programa, ano, mes):
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():
@@ -297,8 +349,7 @@ def saldo_pendiente(tipo_cuota):
return {"datos": _serializar_dicts(datos)}
return cache_get_or_set("saldo", (tipo_cuota,), _load)
# ── PRECARGA GLOBAL (calienta todo el caché) ───────────────────────────────
# ── PRECARGA GLOBAL ────────────────────────────────────────────────────────
def precargar_todo():
from datetime import datetime
ahora = datetime.now()
@@ -317,22 +368,24 @@ def precargar_todo():
t()
except Exception as e:
print(f"[precarga] error: {e}")
_marcar_actualizacion()
# Precargar mes actual + anterior de TODOS los módulos (sin entrar a ellos)
precargar_todos_modulos()
_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 == "ventas" and len(args) == 5:
return ventas(args[0], args[1], args[2], args[3], args[4] == "True")
if prefijo == "cobranza" and len(args) == 4:
return cobranza(args[0], args[1], args[2], args[3])
if prefijo == "rentabilidad" and len(args) == 4:
@@ -343,31 +396,204 @@ def _recalcular_clave(key):
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
return None
def refrescar_todo():
"""Refresca SOLO las entradas ya cacheadas (no vacía el caché), para que los
meses ya visitados se mantengan rápidos. Cada ~1h recarga config de GitHub."""
global _refresh_count
from cache_manager import cache_refresh_existing, cache_keys
_refresh_count += 1
# El hilo corre cada 900s (15 min); 4 ciclos ≈ 60 min → recargar config GitHub
if _refresh_count % 4 == 0:
try:
get_dm().cargar_toda_configuracion()
print("[config] Recarga de queries/JSON de GitHub completada")
except Exception as e:
print(f"[config] error al recargar GitHub: {e}")
# Si el caché está vacío (primer arranque), hacer precarga normal
if not cache_keys():
precargar_todo()
else:
# Limpiar el caché anual de cobranza para que el recálculo lea SQL fresco
try:
get_dm().limpiar_cache_cobranza()
except Exception:
pass
cache_refresh_existing(_recalcular_clave)
_marcar_actualizacion()
# Reprecargar mes actual + anterior de todos los módulos (incluye combos nuevos)
precargar_todos_modulos()
# ── GUARDAR COSTOS (override Supabase) ─────────────────────────────────────
def refrescar_manual(clave_actual=None):
"""Refresco a demanda (botón 'Actualizar'):
1) VACÍA el caché y RE-LEE las bases para la vista que el usuario ve AHORA.
2) El resto de vistas cacheadas se recalculan en SEGUNDO PLANO.
NO afecta al ciclo automático de 15 min."""
import threading
from cache_manager import cache_keys, cache_invalidate
claves_previas = cache_keys()
cache_invalidate()
try:
get_dm().limpiar_cache_cobranza()
except Exception:
pass
if clave_actual:
try:
_recalcular_clave(clave_actual)
except Exception as e:
print(f"[refresh manual] vista actual {clave_actual}: {e}")
resto = [k for k in claves_previas if k != clave_actual]
def _fondo():
for k in resto:
try:
_recalcular_clave(k)
except Exception as e:
print(f"[refresh manual bg] {k}: {e}")
_marcar_actualizacion()
print(f"[refresh manual] segundo plano completado ({len(resto)} vistas)")
threading.Thread(target=_fondo, daemon=True).start()
precargar_todos_modulos()
_marcar_actualizacion()
return ultima_actualizacion()
# ── PRECARGA EN SEGUNDO PLANO (cache warming por módulo) ────────────────────
import threading as _threading
_precarga_gen = {}
_precarga_estado = {}
_precarga_lock = _threading.Lock()
_PRECARGA_PAUSA = 0.15
def precarga_estado(modulo):
"""Progreso de la precarga de un módulo."""
modulo = (modulo or "").lower()
with _precarga_lock:
e = _precarga_estado.get(modulo)
return dict(e) if e else {"total": 0, "hechas": 0, "fallidas": 0, "completo": False}
def _periodos_act_ant():
"""(año, mes) del mes actual y el anterior (maneja el cambio de año en enero)."""
from datetime import datetime
hoy = datetime.now()
prev = (hoy.year - 1, 12) if hoy.month == 1 else (hoy.year, hoy.month - 1)
return [(hoy.year, hoy.month), prev]
def _combos_cobranza(ano):
"""(ano, mes, sectorista, agrupacion) a calentar: SOLO mes actual y anterior."""
periodos = _periodos_act_ant()
agrupaciones = ["SEDE", "PROGRAMA", "ASESOR"]
combos = []
for (yy, mm) in periodos:
try:
logic = CobranzaLogic(get_dm())
secs = logic.obtener_lista_sectoristas(str(yy), str(mm)) or ["TODOS"]
except Exception:
secs = ["TODOS"]
secs = list(dict.fromkeys(["TODOS"] + [s for s in secs if s and s != "TODOS"]))
for ag in agrupaciones:
for sec in secs:
combos.append((yy, mm, sec, ag))
return combos
def precargar_modulo(modulo, ano, en_hilo=True):
"""Precarga la tabla principal del módulo (mes actual + anterior)."""
modulo = (modulo or "").lower()
with _precarga_lock:
gen = _precarga_gen.get(modulo, 0) + 1
_precarga_gen[modulo] = gen
_precarga_estado[modulo] = {"total": 0, "hechas": 0, "fallidas": 0, "completo": False, "ano": ano}
def _vigente():
with _precarga_lock:
return _precarga_gen.get(modulo) == gen
def _sumar(campo):
with _precarga_lock:
if _precarga_gen.get(modulo) == gen:
_precarga_estado[modulo][campo] += 1
def _set_total(n):
with _precarga_lock:
if _precarga_gen.get(modulo) == gen:
_precarga_estado[modulo]["total"] = n
def _marcar_completo():
with _precarga_lock:
if _precarga_gen.get(modulo) == gen:
_precarga_estado[modulo]["completo"] = True
def _fondo():
import time
if modulo == "cobranza":
combos = _combos_cobranza(ano)
_set_total(len(combos))
for (yy, mm, sec, ag) in combos:
if not _vigente():
return
try:
cobranza(str(yy), str(mm), sec, ag)
_sumar("hechas")
except Exception as e:
print(f"[precarga cobranza] {yy}-{mm} {ag}/{sec}: {e}")
_sumar("fallidas")
time.sleep(_PRECARGA_PAUSA)
_marcar_completo()
print(f"[precarga cobranza] {ano} completado ({len(combos)} vistas)")
elif modulo in ("ocupabilidad", "ventas", "rentabilidad"):
periodos = _periodos_act_ant()
_set_total(len(periodos))
for (yy, mm) in periodos:
if not _vigente():
return
try:
if modulo == "ocupabilidad":
ocupabilidad(str(yy), str(mm), "TODOS", "TODOS")
elif modulo == "ventas":
ventas(str(yy), str(mm), "TODOS", "TODOS")
else:
rentabilidad(str(yy), str(mm), "TODOS", "TODOS")
_sumar("hechas")
except Exception as e:
print(f"[precarga {modulo}] {yy}-{mm}: {e}")
_sumar("fallidas")
time.sleep(_PRECARGA_PAUSA)
_marcar_completo()
print(f"[precarga {modulo}] {ano} completado ({len(periodos)} vistas)")
else:
print(f"[precarga] módulo no soportado aún: {modulo}")
if en_hilo:
_threading.Thread(target=_fondo, daemon=True).start()
else:
_fondo()
return {"status": "precarga iniciada", "modulo": modulo, "ano": ano}
def precargar_todos_modulos():
"""Precarga (en 2º plano) el mes actual y anterior de TODOS los módulos, de forma
SECUENCIAL en un solo hilo. Se llama al arrancar, cada 15 min y tras el refresco manual."""
from datetime import datetime
ano = datetime.now().year
def _todo():
for m in ("cobranza", "ocupabilidad", "ventas", "rentabilidad"):
try:
precargar_modulo(m, ano, en_hilo=False)
except Exception as e:
print(f"[precarga todos] {m}: {e}")
print("[precarga todos] completado (todos los módulos)")
_threading.Thread(target=_todo, daemon=True).start()
# ── OPERACIONES EXTRA ──────────────────────────────────────────────────────
def guardar_costos(num_indice, costos_inicial, costos_actual):
dm = get_dm()
ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial)
@@ -377,17 +603,12 @@ def guardar_costos(num_indice, costos_inicial, costos_actual):
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)
@@ -415,14 +636,44 @@ def cobranza_detalle_todos(ano, mes, sectorista="TODOS"):
return {"alumnos": alumnos}
return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load)
# ── GESTIÓN DE USUARIOS (Supabase Auth + tabla perfiles) ───────────────────
# ── GESTIÓN DE USUARIOS ────────────────────────────────────────────────────
def _sb():
dm = get_dm()
if not dm.supabase_client:
raise RuntimeError("Supabase no está conectado")
return dm.supabase_client
# ── Estado/observaciones por alumno (tabla cobranza_estado_alumno) ──
def alumno_estado_obtener(matricula):
"""Devuelve {estado, observaciones} de un alumno. Si no existe, valores por defecto."""
sb = _sb()
res = sb.table("cobranza_estado_alumno").select("*").eq("matricula", str(matricula)).execute()
fila = (res.data or [None])[0]
if not fila:
return {"estado": "EN CURSO", "observaciones": []}
return {"estado": fila.get("estado") or "EN CURSO",
"observaciones": fila.get("observaciones") or []}
def alumno_estado_guardar(matricula, estado, observaciones, usuario=None):
"""Guarda (upsert) el estado y la lista de observaciones del alumno."""
sb = _sb()
from datetime import datetime, timezone
payload = {
"matricula": str(matricula),
"estado": estado or "EN CURSO",
"observaciones": observaciones or [],
"actualizado_en": datetime.now(timezone.utc).isoformat(),
"actualizado_por": usuario or None,
}
sb.table("cobranza_estado_alumno").upsert(payload).execute()
return {"status": "ok"}
def alumnos_por_retirar():
"""Lista de matrículas marcadas como POR RETIRAR (para tachar la fila en la tabla)."""
sb = _sb()
res = sb.table("cobranza_estado_alumno").select("matricula").eq("estado", "POR RETIRAR").execute()
return {"matriculas": [str(r["matricula"]) for r in (res.data or [])]}
def usuarios_listar():
sb = _sb()
perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or []
@@ -465,17 +716,13 @@ def usuarios_eliminar(user_id):
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) ──
# ── CONFIG MENSUAL COMISIONES ──────────────────────────────────────────────
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 = []
@@ -495,7 +742,6 @@ def comisiones_config_guardar(ano, mes, filas):
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()
@@ -506,11 +752,10 @@ def comisiones_tc_del_mes(ano, mes):
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)
if int(mes) != 0:
query = query.eq("mes", int(mes))
res = query.execute()
return {"vendedores": res.data or []}
@@ -530,7 +775,6 @@ def vendedores_manuales_eliminar(id):
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({
@@ -539,14 +783,9 @@ def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto):
}).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({
@@ -560,12 +799,8 @@ def vendedores_manuales_guardar_lote(nombre, ano, mes, filas):
sb.table("vendedores_manuales").insert(payload).execute()
return {"status": "ok"}
# ── OVERRIDES DE COMISIONES (editar valores por num_matricula) ─────────────
# ── OVERRIDES DE COMISIONES ────────────────────────────────────────────────
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")]
@@ -577,7 +812,6 @@ def comisiones_override_guardar(registros):
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()

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
services:
# 1. EL BACKEND (FastAPI - Python)
backend:
build:
context: ./backend
environment:
- SQL_SERVER=191.98.134.80
- SQL_DATABASE=BDUS_CK000040_0001
- SQL_USERNAME=ASEBASTIAN
- SQL_PASSWORD=24MY36$$z>&Uf # Escapado correctamente para Docker ($$)
- PG_HOST=191.98.134.81
- PG_DATABASE=chatwoot_production
- PG_USER=postgres
- PG_PASSWORD=2165$$%sd3%DFG # <--- ¡CORREGIDO AQUÍ! Añadido el doble $$ para Docker
- PG_PORT=5432
- SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
- SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y
- GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD
# 2. EL FRONTEND (Vite - React)
frontend:
build:
context: ./frontend
args:
- VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
- VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY2MzQ5ODMsImV4cCI6MjA5MjIxMDk4M30.OEMjCJOXHBgAHPxtncQUOVC8iDjW40CnT5pczU-gz0U
- VITE_API_URL=https://api.escueladerefrigeracion.lat
depends_on:
- backend

1
frontend/.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=https://api.escueladerefrigeracion.lat

27
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# Usamos Node.js para compilar el proyecto
FROM node:18-alpine
# Creamos la carpeta de trabajo
WORKDIR /app
# Declaramos que recibiremos estos argumentos durante el build
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_API_URL
# Los convertimos en variables de entorno para que Vite los vea al compilar
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
ENV VITE_API_URL=$VITE_API_URL
# Copiamos los archivos de configuración e instalamos dependencias
COPY package*.json ./
RUN npm install
# Copiamos el código y construimos la web (ahora Vite sí verá las variables)
COPY . .
RUN npm run build
# Instalamos el servidor estático y lo ejecutamos
RUN npm install -g serve
CMD ["serve", "-s", "dist", "-l", "5173"]

View File

@@ -1,5 +0,0 @@
# 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

View File

@@ -1,4 +0,0 @@
node_modules/
dist/
*.log
.env

View File

@@ -1,29 +0,0 @@
# Frontend — Dashboard React
## Requisitos
- Node.js 18+ (descarga: https://nodejs.org)
## Instalación (una sola vez)
```
npm install
```
## Ejecutar
```
npm run dev
```
Abre: http://localhost:3000
⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000
(en otra terminal: `py -3.12 main.py`)
## Estado
- ✅ Sidebar + navegación
- ✅ Ocupabilidad (conectado al backend)
- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones)
## Cambiar IP del backend
Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3:
```js
const BASE_URL = "http://IP_DEL_SERVIDOR:8000";
```

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard — Escuela de Refrigeración</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
{
"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"
}
}

View File

@@ -1,65 +0,0 @@
// 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 (
<div style={{ minHeight:"100vh", display:"flex", alignItems:"center", justifyContent:"center", background:"#0f172a", color:"#fff" }}>
Cargando...
</div>
);
}
if (!autenticado) return <Login />;
function render() {
// Seguridad: si la página actual no está permitida, no renderizar
if (pagina && !puedeVer(pagina)) return <SinAcceso />;
switch (pagina) {
case "ocupabilidad": return <Ocupabilidad />;
case "ventas": return <Ventas />;
case "comisiones": return <Comisiones />;
case "cobranza": return <Cobranza />;
case "rentabilidad": return <Rentabilidad />;
case "asesores": return <Asesores />;
case "usuarios": return <Usuarios />;
default: return null;
}
}
return (
<div className="app">
<Sidebar active={pagina} onChange={setPagina} />
<main className="main">{render()}</main>
</div>
);
}
function SinAcceso() {
return (
<div style={{ padding:40, textAlign:"center", color:"#94a3b8" }}>
🔒 No tienes acceso a este módulo.
</div>
);
}

View File

@@ -1,34 +0,0 @@
// src/components/Modal.jsx
export default function Modal({ title, onClose, children, width = 900 }) {
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
display: "flex", alignItems: "center", justifyContent: "center",
zIndex: 1000, padding: 20,
}}
>
<div
onClick={(e) => 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)",
}}
>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 22px", borderBottom: "1px solid #e2e8f0", position: "sticky",
top: 0, background: "#fff", zIndex: 2,
}}>
<h3 style={{ fontSize: 16, fontWeight: 700, color: "#1e40af" }}>{title}</h3>
<button onClick={onClose} style={{
border: "none", background: "#f1f5f9", borderRadius: 8, width: 32, height: 32,
cursor: "pointer", fontSize: 18, color: "#475569",
}}>×</button>
</div>
<div style={{ padding: 22 }}>{children}</div>
</div>
</div>
);
}

View File

@@ -1,54 +0,0 @@
// 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 (
<aside className="sidebar">
<div className="sidebar-logo">Escuela <span>Refrigeración</span></div>
<nav className="nav" style={{ flex: 1 }}>
{visibles.map((m) => (
<button
key={m.id}
className={`nav-item ${active === m.id ? "active" : ""}`}
onClick={() => onChange(m.id)}
>
<span className="ico">{m.ico}</span> {m.label}
</button>
))}
</nav>
<div style={{ padding: 14, borderTop: "1px solid rgba(255,255,255,0.08)" }}>
<div style={{ fontSize: 12, color: "#94a3b8", marginBottom: 2 }}>
{perfil?.nombre || "Usuario"}
</div>
<div style={{ fontSize: 11, color: "#60a5fa", fontWeight: 600, marginBottom: 10 }}>
{perfil?.rol || ""}
</div>
<button
onClick={logout}
style={{
width: "100%", padding: "8px", border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 8, background: "transparent", color: "#cbd5e1", fontSize: 12,
cursor: "pointer", fontWeight: 500,
}}
>
🚪 Cerrar sesión
</button>
</div>
</aside>
);
}

View File

@@ -1,51 +0,0 @@
// src/components/UI.jsx
export function Loader({ text = "Cargando..." }) {
return (
<div className="loader-wrap">
<div className="spinner" />
<div className="loader-text">{text}</div>
</div>
);
}
export function ErrorBox({ msg }) {
return <div className="error-box"> {msg}</div>;
}
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 (
<div className="pbar">
<span className="pct" style={{ color: c.fg }}>{pct.toFixed(1)}%</span>
<div className="track">
<div className="fill" style={{ width: `${w}%`, background: c.fill }} />
</div>
</div>
);
}
export function Filters({ children }) {
return <div className="filters">{children}</div>;
}
export function Select({ label, value, options, onChange }) {
return (
<div className="filter-group">
{label && <label>{label}</label>}
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const val = typeof o === "object" ? o.value : o;
const txt = typeof o === "object" ? o.label : o;
return <option key={val} value={val}>{txt}</option>;
})}
</select>
</div>
);
}

View File

@@ -1,73 +0,0 @@
// 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),
};

View File

@@ -1,106 +0,0 @@
// src/lib/auth.jsx
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import { supabase } from "./supabase";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [session, setSession] = useState(null);
const [perfil, setPerfil] = useState(null); // { 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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
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.";
}

View File

@@ -1,10 +0,0 @@
// src/lib/supabase.js
// Cliente de Supabase para autenticación (login).
// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role).
import { createClient } from "@supabase/supabase-js";
// 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);

View File

@@ -1,63 +0,0 @@
// 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 <col> para el <colgroup> de la tabla
function ColGroup() {
return (
<colgroup>
{anchos.map((w, i) => <col key={i} style={{ width: w, minWidth: w }} />)}
</colgroup>
);
}
// Devuelve el divisor arrastrable para poner dentro de cada <th>
function Resizer({ index }) {
return (
<span
onMouseDown={(e) => 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 };
}

View File

@@ -1,13 +0,0 @@
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(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);

View File

@@ -1,105 +0,0 @@
// src/pages/Asesores.jsx
import { useState, useEffect, useCallback } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox } from "../components/UI";
const ESTADO_INFO = {
online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" },
busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" },
offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" },
};
export default function Asesores() {
const [agentes, setAgentes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [cambiando, setCambiando] = useState(null);
const cargar = useCallback(() => {
setLoading(true); setError(null);
api.asesores()
.then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); })
.catch((e)=>{ setError(e.message); setLoading(false); });
}, []);
useEffect(() => { cargar(); }, [cargar]);
async function toggle(ag) {
const online = ag.availability_status !== "online";
setCambiando(ag.id);
try {
await api.asesorEstado(ag.id, online);
setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a));
} catch (e) { /* noop */ }
setCambiando(null);
}
async function todos(online) {
setCambiando("all");
for (const ag of agentes) {
try { await api.asesorEstado(ag.id, online); } catch (e) {}
}
setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"})));
setCambiando(null);
}
const total = agentes.length;
const enLinea = agentes.filter((a)=>a.availability_status==="online").length;
const ocupados = agentes.filter((a)=>a.availability_status==="busy").length;
const offline = total - enLinea - ocupados;
return (
<div>
<h1 className="page-title">👥 Asesores</h1>
{loading ? <Loader text="Cargando asesores de Chatwoot..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div className="kpis" style={{gridTemplateColumns:"repeat(4,1fr)"}}>
<div className="kpi"><div className="ico">👥</div><div className="label">Total Asesores</div><div className="value">{total}</div></div>
<div className="kpi" style={{background:"#d1fae5"}}><div className="ico">🟢</div><div className="label">En Línea</div><div className="value" style={{color:"#065f46"}}>{enLinea}</div></div>
<div className="kpi" style={{background:"#fef9c3"}}><div className="ico">🟡</div><div className="label">Ocupados</div><div className="value" style={{color:"#854d0e"}}>{ocupados}</div></div>
<div className="kpi" style={{background:"#fee2e2"}}><div className="ico">🔴</div><div className="label">Fuera de Línea</div><div className="value" style={{color:"#991b1b"}}>{offline}</div></div>
</div>
<div style={{display:"flex",gap:10,marginBottom:16}}>
<button className="btn btn-primary" disabled={cambiando==="all"} onClick={()=>todos(true)}>🟢 Encender todos</button>
<button className="btn btn-ghost" disabled={cambiando==="all"} onClick={()=>todos(false)}>🔴 Apagar todos</button>
<button className="btn btn-ghost" title="Refrescar" style={{fontSize:18}} onClick={cargar}>🔄</button>
</div>
<div className="table-wrap">
<table>
<thead><tr><th>ASESOR</th><th>EMAIL</th><th>ROL</th><th>ESTADO</th><th>ACCIÓN</th></tr></thead>
<tbody>
{agentes.map((ag)=>{
const est = ESTADO_INFO[ag.availability_status] || ESTADO_INFO.offline;
const isOnline = ag.availability_status==="online";
return (
<tr key={ag.id}>
<td className="col-name">{ag.name || ag.available_name || "—"}</td>
<td>{ag.email || "—"}</td>
<td>{ag.role || "agent"}</td>
<td>
<span style={{background:est.bg,color:est.fg,padding:"3px 10px",borderRadius:20,
fontSize:11,fontWeight:700}}> {est.txt}</span>
</td>
<td>
<button className="btn" disabled={cambiando===ag.id}
style={{padding:"5px 12px",fontSize:11,
background:isOnline?"#fee2e2":"#d1fae5",
color:isOnline?"#991b1b":"#065f46"}}
onClick={()=>toggle(ag)}>
{cambiando===ag.id ? "..." : (isOnline?"Apagar":"Encender")}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>}
</div>
);
}

View File

@@ -1,660 +0,0 @@
// 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 <span style={{color:"#cbd5e1"}}></span>;
const pct = toPct(valStr);
const c = colorSemaforo(pct);
const w = Math.min(Math.max(pct,0),100);
return (
<div style={{display:"flex",alignItems:"center",gap:5}}>
<span style={{fontSize:10,fontWeight:700,color:c.fg,minWidth:30,textAlign:"right"}}>{pct.toFixed(0)}%</span>
<div style={{flex:1,background:"#e2e8f0",borderRadius:4,height:5,overflow:"hidden",minWidth:30}}>
<div style={{width:`${w}%`,background:c.fill,height:"100%"}} />
</div>
</div>
);
}
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 (
<div>
<h1 className="page-title">📋 Cobranza</h1>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
<Select label="Agrupación" value={agrupacion} options={AGRUPACIONES} onChange={(v)=>{setAgrupacion(v);setSede("TODOS");setFrec("TODOS");}} />
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} />
{esPrograma && <Select label="Sede" value={sede} options={SEDES} onChange={setSede} />}
{esPrograma && <Select label="Frecuencia" value={frec} options={FRECS} onChange={setFrec} />}
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
<button className="btn btn-ghost" style={{height:38}} onClick={()=>setBuscadorAbierto(true)}>🔍 Buscar</button>
<button className="btn btn-primary" style={{height:38}} disabled={exportando || !alumnosFiltrados.length} onClick={exportarDetalle}>
{exportando ? "Exportando..." : "⬇️ Exportar"}
</button>
</div>
</Filters>
{loading ? <Loader text="Calculando cobranza..." /> :
error ? <ErrorBox msg={error} /> :
<>
<Tarjetas kpis={kpis} />
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{headers.map((h, i)=><th key={h} style={{position:"relative"}}>{h}<cols.Resizer index={i} /></th>)}</tr></thead>
<tbody>
{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 (
<tr key={ri}>
{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 (
<td key={ci} className={clss.trim()}
style={{textAlign: ci===0?"left":"center",
minWidth: ci===0?(esPrograma?300:200):undefined,
maxWidth: ci===0?500:undefined,
whiteSpace: "nowrap",
overflow: ci===0?"hidden":undefined,
textOverflow: ci===0?"ellipsis":undefined}}>
{isPct ? (val==="—" ? <span style={{color:"#cbd5e1"}}></span> : <MiniBar valStr={val} />) : fmtMoneda(val)}
</td>
);
})}
<td>
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModal({ grupo: f[0] })}>👁 Ver</button>
</td>
</tr>
);
})}
{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 (
<tr className="total-row">
{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 (
<td key={ci} className={ci===0?"col-name":""}
style={{textAlign: ci===0?"left":"center",
whiteSpace:"nowrap"}}>
{isPct ? <MiniBar valStr={val} /> : val}
</td>
);
})}
<td></td>
</tr>
);
})()}
</tbody>
</table>
</div>
</>}
{modal && (
<ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes}
sectorista={sectorista} agrupacion={agrupacion} onClose={() => setModal(null)} />
)}
{buscadorAbierto && (
<ModalBuscador
alumnos={alumnosFiltrados}
agrupacion={agrupacion}
sectorista={sectorista}
onClose={() => setBuscadorAbierto(false)}
/>
)}
</div>
);
}
function Tarjetas({ kpis }) {
const alDia = kpis.saldo <= 0.01;
if (alDia) {
return (
<div style={{background:"linear-gradient(135deg,#d1fae5,#a7f3d0)",border:"2px solid #10b981",
borderRadius:14,padding:18,textAlign:"center"}}>
<div style={{fontSize:32}}></div>
<div style={{fontSize:18,fontWeight:800,color:"#065f46",letterSpacing:1}}>AL DÍA</div>
<div style={{fontSize:12,color:"#047857"}}>Sin deuda pendiente</div>
</div>
);
}
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 (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob]) => {
const ratio = cta>0 ? (cob/cta*100) : 0;
const c = colorSemaforo(ratio);
const saldo = cta - cob;
return (
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
<div style={{background:color,color:"#fff",padding:"10px 14px",fontWeight:700,fontSize:13,textAlign:"center"}}>{titulo}</div>
<div style={{padding:14}}>
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
{cta>0 && (
<div style={{marginTop:10}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
<span style={{fontSize:14,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
</div>
<div style={{background:"#e2e8f0",borderRadius:6,height:8,overflow:"hidden"}}>
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
</div>
</div>
)}
</div>
</div>
);
})}
</div>
);
}
function Row({ k, v, bold, color }) {
return (
<div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:"#64748b",marginBottom:4,
...(bold?{paddingTop:6,borderTop:"1px solid #f1f5f9"}:{})}}>
<span>{k}</span>
<span style={{fontWeight:bold?700:600,color:color||"#0f172a"}}>{v}</span>
</div>
);
}
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 (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob]) => {
const ratio = cta>0 ? (cob/cta*100) : 0;
const c = colorSemaforo(ratio);
const saldo = cta - cob;
return (
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
<div style={{background:color,color:"#fff",padding:"9px 12px",fontWeight:700,fontSize:12,textAlign:"center"}}>{titulo}</div>
<div style={{padding:12}}>
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
{cta>0 && (
<div style={{marginTop:8}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
<span style={{fontSize:13,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
</div>
<div style={{background:"#e2e8f0",borderRadius:6,height:7,overflow:"hidden"}}>
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
</div>
</div>
)}
</div>
</div>
);
})}
</div>
);
}
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 (
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1200}>
<div style={{display:"flex",gap:8,marginBottom:16}}>
<input
autoFocus
value={texto}
onChange={(e)=>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}}
/>
<button className="btn btn-primary" onClick={()=>setQuery(texto)}>🔍 Buscar</button>
{query && <button className="btn btn-ghost" onClick={()=>{setTexto("");setQuery("");}}>Limpiar</button>}
</div>
{!query ? (
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
Escribe un texto y presiona Enter para buscar.
</div>
) : resultados.length === 0 ? (
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
Sin coincidencias para "{query}".
</div>
) : (
<>
<div style={{fontSize:13,color:"#1e40af",fontWeight:600,marginBottom:8}}>
{resultados.length} alumno(s) encontrado(s)
</div>
<div className="table-wrap">
<table className="cob-table" style={{minWidth:1100}}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
<tbody>
{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 (
<tr key={i} className={deuda?"row-deuda":""}>
{celdas.map((c,j)=>(
<td key={j} className={((c.cls||"")+" "+(c.cart||"")).trim()}
style={{textAlign:c.al||"center",
fontWeight:c.saldo?700:undefined,
color:c.saldo&&deuda?"#dc2626":undefined}}>
{c.pct!==undefined ? (c.pct ? <MiniBar valStr={c.pct} /> : "—") : c.v}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</Modal>
);
}
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 (
<Modal title={`📊 Estado de Cuenta — ${grupo}`} onClose={onClose} width={1500}>
{loading ? <Loader text="Cargando estado de cuenta..." /> :
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos.</div> :
<>
<div style={{marginBottom:16}}>
<TarjetasResumen r={resumen} />
</div>
<div className="table-wrap">
<table className="cob-table" style={{minWidth:1000}}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
<tbody>
{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 (
<tr key={i} className={deuda?"row-deuda":""}>
{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 (
<td key={j} className={clss}
style={{textAlign:j===1?"left":(isMonto?"right":"center"),
whiteSpace:"nowrap",
color:j===13&&deuda?"#dc2626":undefined,
fontWeight:j===13?700:undefined}}>
{isPct ? (val==="—"?<span style={{color:"#cbd5e1"}}></span>:<MiniBar valStr={val} />) : fmtMoneda(val)}
</td>
);
})}
</tr>
);
})}
{(() => {
// 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 (
<tr className="total-row">
{Array.from({length:14}).map((_,j)=>{
const isPct=[6,9,12].includes(j);
const val=celda(j);
return (
<td key={j} className={j===0?"col-name":""}
style={{textAlign:j===0?"left":(String(val).includes("S/")?"right":"center"),whiteSpace:"nowrap"}}>
{isPct?<MiniBar valStr={val} />:val}
</td>
);
})}
</tr>
);
})()}
</tbody>
</table>
</div>
</>}
</Modal>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +0,0 @@
// src/pages/Login.jsx
import { useState } from "react";
import { useAuth } from "../lib/auth";
export default function Login() {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [cargando, setCargando] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setError(null);
setCargando(true);
const res = await login(email.trim(), password);
if (!res.ok) setError(res.msg);
setCargando(false);
}
return (
<div style={{
minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center",
background: "linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)", padding: 20,
}}>
<div style={{
background: "#fff", borderRadius: 16, padding: "40px 36px", width: "100%", maxWidth: 400,
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}>
<div style={{ textAlign: "center", marginBottom: 28 }}>
<div style={{ fontSize: 38, marginBottom: 8 }}></div>
<h1 style={{ fontSize: 20, fontWeight: 800, color: "#0f172a" }}>Escuela de Refrigeración</h1>
<p style={{ fontSize: 13, color: "#64748b", marginTop: 4 }}>Dashboard Inicia sesión</p>
</div>
<form onSubmit={onSubmit}>
<div style={{ marginBottom: 16 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
Correo
</label>
<input
type="email" value={email} onChange={(e) => setEmail(e.target.value)}
placeholder="usuario@escuela.com" required autoFocus
style={inputStyle}
/>
</div>
<div style={{ marginBottom: 20 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
Contraseña
</label>
<input
type="password" value={password} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" required
style={inputStyle}
/>
</div>
{error && (
<div style={{
background: "#fee2e2", border: "1px solid #fca5a5", color: "#991b1b",
padding: "10px 12px", borderRadius: 8, fontSize: 13, marginBottom: 16,
}}> {error}</div>
)}
<button type="submit" disabled={cargando} style={{
width: "100%", padding: "12px", border: "none", borderRadius: 10,
background: cargando ? "#94a3b8" : "#2563eb", color: "#fff", fontSize: 15, fontWeight: 600,
cursor: cargando ? "default" : "pointer",
}}>
{cargando ? "Ingresando..." : "Iniciar sesión"}
</button>
</form>
</div>
</div>
);
}
const inputStyle = {
width: "100%", padding: "11px 14px", border: "1px solid #cbd5e1", borderRadius: 10,
fontSize: 14, outline: "none", boxSizing: "border-box",
};

View File

@@ -1,194 +0,0 @@
// src/pages/Ocupabilidad.jsx
import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
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 (
<div>
<h1 className="page-title">📊 Ocupabilidad</h1>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
<div className="filter-group">
<label>Reprogramados</label>
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
<input type="checkbox" checked={mostrarRepro} onChange={(e)=>setMostrarRepro(e.target.checked)} />
Mostrar
</label>
</div>
<button className="btn btn-primary" style={{height:38,marginLeft:"auto"}} onClick={exportarExcel} disabled={!datosVisibles.length}>
Exportar Excel
</button>
</Filters>
{loading ? <Loader text="Calculando ocupabilidad..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div className="kpis">
{KPI_CARDS.map(([ico,label,val,sub]) => (
<div className="kpi" key={label}>
<div className="ico">{ico}</div>
<div className="label" style={{fontSize:15}}>{label}</div>
<div className="value">{val}</div>
<div className="sub">{sub}</div>
</div>
))}
</div>
<div className="table-wrap" style={{overflowX:"auto"}}>
<table {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
<tbody>
{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 (
<tr key={i} className={esInicio ? "row-inicio" : ""}>
<td className="col-name" style={{minWidth:300,maxWidth:480,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{prog}</td>
<td>{d.fch_inicio || ""}</td>
<td style={!esInicio && dias < 0 ? {color:"#dc2626",fontWeight:700} : {}}>{d.dias_para_inicio ?? ""}</td>
<td>{cel(parseInt(d.Inscritos_Totales)||0)}</td>
<td>{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}</td>
<td>{cel(parseInt(d.Inscritos_Activos)||0)}</td>
<td>{cel(parseInt(d.Meta_Curso)||0)}</td>
<td>{esInicio ? "-" : <ProgressBar pct={pct} />}</td>
<td>{cel(parseInt(d.Inscritos_Mes)||0)}</td>
<td>{cel(parseInt(d.Inscritos_PC)||0)}</td>
<td>{cel(parseInt(d.Descuento)||0)}</td>
<td>{cel(parseInt(d.Inscritos_Continuidad)||0)}</td>
</tr>
);
})}
{datosVisibles.length > 0 && (
<tr className="total-row">
<td className="col-name">TOTAL GENERAL</td>
<td></td><td></td>
<td>{totales.total}</td>
<td>{totales.retirados}</td>
<td>{totales.activos}</td>
<td>{totales.meta}</td>
<td><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></td>
<td>{totales.mes}</td><td>{totales.pc}</td><td>{totales.refri}</td><td>{totales.cont}</td>
</tr>
)}
</tbody>
</table>
</div>
</>}
</div>
);
}

View File

@@ -1,270 +0,0 @@
// 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 (
<div>
<h1 className="page-title">📈 Rentabilidad</h1>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
<div className="filter-group" style={{marginLeft:"auto"}}>
<label>Inscritos</label>
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
<input type="checkbox" checked={mostrarInscritos} onChange={(e)=>setMostrarInscritos(e.target.checked)} />
Total / Retirados / En curso
</label>
</div>
</Filters>
{loading ? <Loader text="Calculando rentabilidad..." /> :
error ? <ErrorBox msg={error} /> :
<>
{kpis && (
<div className="kpis" style={{gridTemplateColumns:"repeat(5,1fr)"}}>
{[["📚","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])=>(
<div className="kpi" key={label}>
<div className="ico">{ico}</div><div className="label">{label}</div>
<div className="value" style={{fontSize:18}}>{val}</div><div className="sub">{sub}</div>
</div>
))}
</div>
)}
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
<table {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
<tbody>
{datos.map((f, ri) => (
<tr key={ri}>
{f.slice(0,13).map((v,ci)=>{
if (ocultar(ci)) return null;
const isPct = ci===9||ci===12;
return (
<td key={ci} className={ci===0?"col-name":""}
style={{background:bgCol(ci)||undefined,
textAlign: ci===0?"left":"center",
fontWeight:isPct?700:undefined,
minWidth:ci===0?260:undefined}}>{v}</td>
);
})}
<td style={{whiteSpace:"nowrap"}}>
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11,marginRight:4}}
onClick={()=>setModalVer({programa:f[0]})}>👁</button>
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11}}
onClick={()=>setModalCostos({programa:f[0]})}></button>
</td>
</tr>
))}
{total && (
<tr className="total-row">
{total.slice(0,13).map((v,ci)=>(
ocultar(ci) ? null :
<td key={ci} className={ci===0?"col-name":""}
style={{textAlign:ci===0?"left":"center"}}>{v}</td>
))}
<td></td>
</tr>
)}
</tbody>
</table>
</div>
</>}
{modalVer && <ModalVerDetalle programa={modalVer.programa} ano={ano} mes={mes} onClose={()=>setModalVer(null)} />}
{modalCostos && <ModalCostos programa={modalCostos.programa} ano={ano} mes={mes} onClose={()=>setModalCostos(null)} onGuardado={cargar} />}
</div>
);
}
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 (
<Modal title={`👁️ Detalle — ${programa}`} onClose={onClose} width={1000}>
{loading ? <Loader text="Cargando detalle..." /> :
filas.length===0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
<div className="table-wrap">
<table>
<thead><tr>{COLS_DET.map((c)=><th key={c}>{c}</th>)}</tr></thead>
<tbody>
{filas.map((f,i)=>(
<tr key={i}>
{f.map((v,j)=>(
<td key={j} className={j===1?"col-name":""} style={{textAlign:j>=5?"right":(j===1?"left":"center")}}>{v}</td>
))}
</tr>
))}
</tbody>
</table>
</div>}
</Modal>
);
}
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 (
<Modal title={`✏️ Editar Costos — ${programa}`} onClose={onClose} width={760}>
{loading ? <Loader text="Cargando costos..." /> :
!costos ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos de costos.</div> :
<>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
<Columna titulo="INICIAL" color="#1e3a5f" campos={CAMPOS} valores={ini} setVal={setIni} total={totIni} />
<Columna titulo="ACTUAL" color="#1a5f4a" campos={CAMPOS} valores={act} setVal={setAct} total={totAct} />
</div>
{msg && <div style={{marginTop:14,padding:10,borderRadius:8,fontSize:13,
background:msg.ok?"#d1fae5":"#fee2e2",color:msg.ok?"#065f46":"#991b1b"}}>{msg.txt}</div>}
<div style={{display:"flex",gap:10,marginTop:16}}>
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
{guardando ? "Guardando..." : "💾 Guardar"}
</button>
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
</div>
</>}
</Modal>
);
}
function Columna({ titulo, color, campos, valores, setVal, total }) {
return (
<div>
<div style={{background:color,color:"#fff",borderRadius:8,padding:"8px 12px",
marginBottom:10,fontWeight:600,fontSize:12,textAlign:"center"}}>{titulo}</div>
{campos.map(([k,label])=>(
<div key={k} style={{marginBottom:8}}>
<label style={{fontSize:11,fontWeight:600,color:"#64748b",display:"block",marginBottom:3}}>{label}</label>
<input type="number" value={valores[k]??0} min="0" step="100"
onChange={(e)=>setVal({...valores,[k]:+e.target.value})}
style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} />
</div>
))}
<div style={{background:"#f1f5f9",borderRadius:8,padding:10,textAlign:"center",
fontWeight:700,color,marginTop:8}}>
TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})}
</div>
</div>
);
}

View File

@@ -1,96 +0,0 @@
// src/pages/SaldoPendiente.jsx
import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"];
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); }
export default function SaldoPendiente() {
const [tipoCuota, setTipoCuota] = useState("1° Cuota");
const [datos, setDatos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [vendedor, setVendedor] = useState("TODOS");
useEffect(() => {
let activo = true;
setLoading(true); setError(null);
api.saldoPendiente(tipoCuota)
.then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} })
.catch((e)=>{ if(activo){setError(e.message);setLoading(false);} });
return ()=>{activo=false;};
}, [tipoCuota]);
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
const vendedores = useMemo(() => {
const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean));
return ["TODOS", ...Array.from(set).sort()];
}, [datos]);
const filtrados = useMemo(() => {
let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01);
if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase());
return arr;
}, [datos, vendedor, colSaldo]);
const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]);
const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]);
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
return (
<div>
<h1 className="page-title"> Saldo Pendiente</h1>
<Filters>
<Select label="Tipo de cuota" value={tipoCuota} options={CUOTAS} onChange={setTipoCuota} />
<Select label="Vendedor" value={vendedor} options={vendedores} onChange={setVendedor} />
</Filters>
{loading ? <Loader text="Calculando saldos..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div className="kpis" style={{gridTemplateColumns:"repeat(3,1fr)"}}>
<div className="kpi"><div className="ico">👥</div><div className="label">Alumnos con Saldo</div><div className="value">{filtrados.length}</div></div>
<div className="kpi"><div className="ico"></div><div className="label">Saldo {tipoCuota}</div><div className="value" style={{fontSize:20}}>{fmt(totalSaldo)}</div></div>
<div className="kpi"><div className="ico">📋</div><div className="label">Saldo Matrícula</div><div className="value" style={{fontSize:20}}>{fmt(totalMat)}</div></div>
</div>
<div className="table-wrap" style={{marginTop:16}}>
<table style={{minWidth:1100}}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
<tbody>
{filtrados.map((d,i)=>(
<tr key={i}>
{KEYS.map((k,j)=>{
const isMonto = k.includes("SALDO")||k.includes("INV");
return (
<td key={j} className={k==="ALUMNO"||k==="PROGRAMA"?"col-name":""}
style={{textAlign:isMonto?"right":(k==="ALUMNO"||k==="PROGRAMA"?"left":"center"),
fontWeight:k===colSaldo?700:undefined,
color:k===colSaldo?"#dc2626":undefined}}>
{isMonto ? fmt(d[k]) : (d[k] ?? "")}
</td>
);
})}
</tr>
))}
{filtrados.length>0 && (
<tr className="total-row">
<td colSpan={6} style={{textAlign:"left"}}>TOTAL GENERAL ({filtrados.length})</td>
<td style={{textAlign:"right"}}>{fmt(totalMat)}</td>
<td style={{textAlign:"right"}}>{fmt(totalSaldo)}</td>
<td></td><td></td>
</tr>
)}
</tbody>
</table>
</div>
</>}
</div>
);
}

View File

@@ -1,130 +0,0 @@
// src/pages/Usuarios.jsx
import { useState, useEffect, useCallback } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"];
export default function Usuarios() {
const [usuarios, setUsuarios] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// form nuevo usuario
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [nombre, setNombre] = useState("");
const [rol, setRol] = useState("COBRANZA");
const [creando, setCreando] = useState(false);
const [msg, setMsg] = useState(null);
const cargar = useCallback(() => {
setLoading(true); setError(null);
api.usuariosListar()
.then((res) => { setUsuarios(res.usuarios || []); setLoading(false); })
.catch((e) => { setError(e.message); setLoading(false); });
}, []);
useEffect(() => { cargar(); }, [cargar]);
async function crear() {
setMsg(null);
if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; }
setCreando(true);
try {
await api.usuariosCrear(email.trim(), password, nombre.trim(), rol);
setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` });
setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA");
cargar();
} catch (e) {
setMsg({ ok:false, txt:"Error: " + e.message });
}
setCreando(false);
}
async function cambiarRol(id, nuevoRol) {
try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); }
catch (e) { alert("No se pudo cambiar el rol: " + e.message); }
}
async function eliminar(id, correo) {
if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return;
try { await api.usuariosEliminar(id); cargar(); }
catch (e) { alert("No se pudo eliminar: " + e.message); }
}
return (
<div>
<h1 className="page-title">🔐 Usuarios</h1>
{/* Crear nuevo usuario */}
<div style={{ background:"#fff", border:"1px solid #e2e8f0", borderRadius:12, padding:18, marginBottom:20 }}>
<h3 style={{ fontSize:15, fontWeight:700, color:"#0f172a", marginBottom:14 }}> Crear nuevo usuario</h3>
<div style={{ display:"flex", gap:12, flexWrap:"wrap", alignItems:"flex-end" }}>
<Campo label="Correo">
<input value={email} onChange={(e)=>setEmail(e.target.value)} placeholder="usuario@escuela.com" style={inp} />
</Campo>
<Campo label="Contraseña">
<input type="text" value={password} onChange={(e)=>setPassword(e.target.value)} placeholder="mín. 6 caracteres" style={inp} />
</Campo>
<Campo label="Nombre">
<input value={nombre} onChange={(e)=>setNombre(e.target.value)} placeholder="Nombre completo" style={inp} />
</Campo>
<Campo label="Rol">
<Select value={rol} options={ROLES} onChange={setRol} />
</Campo>
<button className="btn btn-primary" style={{height:38}} disabled={creando} onClick={crear}>
{creando ? "Creando..." : "Crear usuario"}
</button>
</div>
{msg && (
<div style={{ marginTop:12, padding:10, borderRadius:8, fontSize:13,
background: msg.ok ? "#d1fae5" : "#fee2e2", color: msg.ok ? "#065f46" : "#991b1b" }}>
{msg.ok ? "✅" : "⚠️"} {msg.txt}
</div>
)}
</div>
{/* Lista de usuarios */}
{loading ? <Loader text="Cargando usuarios..." /> :
error ? <ErrorBox msg={error} /> :
<div className="table-wrap">
<table>
<thead><tr><th>CORREO</th><th>NOMBRE</th><th>ROL</th><th>ACCIONES</th></tr></thead>
<tbody>
{usuarios.map((u) => (
<tr key={u.id}>
<td className="col-name">{u.email}</td>
<td>{u.nombre || "—"}</td>
<td>
<select value={u.rol} onChange={(e)=>cambiarRol(u.id, e.target.value)}
style={{ padding:"5px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12 }}>
{ROLES.map((r)=><option key={r} value={r}>{r}</option>)}
</select>
</td>
<td>
<button className="btn" style={{ padding:"4px 10px", fontSize:11, background:"#fee2e2", color:"#991b1b" }}
onClick={()=>eliminar(u.id, u.email)}>🗑 Eliminar</button>
</td>
</tr>
))}
{usuarios.length === 0 && (
<tr><td colSpan={4} style={{ padding:20, textAlign:"center", color:"#94a3b8" }}>Sin usuarios.</td></tr>
)}
</tbody>
</table>
</div>}
</div>
);
}
function Campo({ label, children }) {
return (
<div className="filter-group">
<label>{label}</label>
{children}
</div>
);
}
const inp = { padding:"8px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, minWidth:180 };

View File

@@ -1,266 +0,0 @@
// src/pages/Ventas.jsx
import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import Modal from "../components/Modal";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
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 (
<div>
<h1 className="page-title">🛒 Ventas</h1>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("1° Cuota")}>
Exportar 1° Cuota
</button>
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("2° Cuota")}>
Exportar 2° Cuota
</button>
</div>
</Filters>
{loading ? <Loader text="Calculando ventas..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div className="kpis" style={{ gridTemplateColumns: "repeat(5,1fr)" }}>
{KPIS.map(([ico,label,val,sub]) => (
<div className="kpi" key={label}>
<div className="ico">{ico}</div>
<div className="label" style={{fontSize:15}}>{label}</div>
<div className="value" style={{fontSize:20}}>{val}</div>
<div className="sub">{sub}</div>
</div>
))}
</div>
<div className="table-wrap" style={{overflowX:"auto"}}>
<table {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
<tbody>
{datos.map((f, i) => (
<tr key={i}>
<td className="col-name">{f[0]}</td>
<td>{f[1]}</td><td>{f[2]}</td>
<td>{f[3]}</td><td>{f[4]}</td>
<td>{f[5]}</td><td>{f[6]}</td>
<td style={{fontWeight:600}}>{f[7]}</td>
<td>
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModal({ vendedor: f[0] })}>👁 Ver</button>
</td>
</tr>
))}
{total && (
<tr className="total-row">
<td className="col-name">TOTAL GENERAL</td>
<td>{total[1]}</td><td>{total[2]}</td>
<td>{total[3]}</td><td>{total[4]}</td>
<td>{total[5]}</td><td>{total[6]}</td>
<td>{total[7]}</td><td></td>
</tr>
)}
</tbody>
</table>
</div>
</>}
{modal && (
<ModalDetalle vendedor={modal.vendedor} ano={ano} mes={mes} onClose={() => setModal(null)} />
)}
</div>
);
}
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 (
<Modal title={`👤 ${vendedor}`} onClose={onClose} width={1500}>
<div style={{ marginBottom: 14 }}>
<Select label="Tipo de venta" value={tipo} options={TIPOS} onChange={setTipo} />
</div>
{loading ? <Loader text="Cargando detalle..." /> :
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin registros para esta categoría.</div> :
<div className="table-wrap" style={{overflowX:"auto"}}>
<table {...colsDet.tableProps}>
<colsDet.ColGroup />
<thead><tr>{HEADERS_DETALLE.map((h, i) => <th key={h} style={{position:"relative", whiteSpace:"nowrap"}}>{h}<colsDet.Resizer index={i} /></th>)}</tr></thead>
<tbody>
{filas.map((f, i) => (
<tr key={i}>
{f.slice(0, HEADERS_DETALLE.length).map((v, j) => (
<td key={j} className={j === 1 ? "col-name" : ""}
style={{textAlign:"center", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}>{v}</td>
))}
</tr>
))}
</tbody>
</table>
</div>}
</Modal>
);
}

View File

@@ -1,127 +0,0 @@
/* src/styles.css */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', -apple-system, Arial, sans-serif;
background: #f1f5f9;
color: #0f172a;
}
/* ── Layout ── */
.app { display: flex; min-height: 100vh; }
/* ── Sidebar ── */
.sidebar {
width: 240px; background: #0f172a; color: #e2e8f0;
display: flex; flex-direction: column; padding: 0;
position: fixed; height: 100vh; left: 0; top: 0;
}
.sidebar-logo {
padding: 22px 20px; font-size: 16px; font-weight: 700; color: #fff;
border-bottom: 1px solid rgba(255,255,255,0.08); letter-spacing: 0.3px;
}
.sidebar-logo span { color: #60a5fa; }
.nav { padding: 12px 10px; flex: 1; }
.nav-item {
display: flex; align-items: center; gap: 11px; padding: 11px 14px;
border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 500;
color: #cbd5e1; margin-bottom: 3px; transition: all 0.15s; border: none;
background: transparent; width: 100%; text-align: left;
}
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
.nav-item.active { background: #2563eb; color: #fff; }
.nav-item .ico { font-size: 16px; }
/* ── Main content ── */
.main { margin-left: 240px; flex: 1; padding: 22px 28px; min-width: 0; overflow-x: hidden; }
.page-title { font-size: 22px; font-weight: 700; color: #0f172a; margin-bottom: 14px; }
/* ── Filters bar ── */
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; align-items: flex-end; }
.filter-group { display: flex; flex-direction: column; gap: 4px; }
.filter-group label {
font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.4px;
}
.filter-group select {
padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px;
font-size: 13px; background: #fff; color: #0f172a; cursor: pointer; min-width: 130px;
}
.btn {
padding: 9px 16px; border: none; border-radius: 8px; font-size: 13px; font-weight: 600;
cursor: pointer; transition: all 0.15s;
}
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-ghost { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; }
.btn-ghost:hover { background: #e2e8f0; }
/* ── KPI cards ── */
.kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 18px; }
.kpi {
background: #fff; border-radius: 12px; padding: 16px; border: 1px solid #e2e8f0;
}
.kpi .ico { font-size: 18px; margin-bottom: 4px; }
.kpi .label { font-size: 10px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.6px; }
.kpi .value { font-size: 24px; font-weight: 700; color: #0f172a; line-height: 1.3; }
.kpi .sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
/* ── Table ── */
.table-wrap {
background: #fff; border-radius: 12px; border: 1px solid #e2e8f0;
overflow: hidden; overflow-x: auto; max-width: 100%;
}
table { width: 100%; border-collapse: collapse; font-size: 12px; }
thead th {
background: #334155; color: #f1f5f9; padding: 11px 10px; font-size: 11px;
font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px;
white-space: nowrap; text-align: center; position: sticky; top: 0;
border-right: 1px solid #475569;
}
thead th:last-child { border-right: none; }
tbody td { padding: 9px 8px; text-align: center; font-size: 12px; color: #374151; border-bottom: 1px solid #eef2f7; border-right: 1px solid #f1f5f9; }
tbody td:last-child { border-right: none; }
tbody tr:nth-child(even) { background: #f8fafc; }
tbody tr:hover td { background: #eff6ff; }
tr.total-row td { background: #eff6ff !important; font-weight: 700; color: #1e40af; }
.col-name { text-align: left !important; font-weight: 500; color: #111827; }
/* ── Progress bar ── */
.pbar { display: flex; align-items: center; gap: 6px; }
.pbar .pct { font-size: 11px; font-weight: 700; min-width: 42px; text-align: right; }
.pbar .track { flex: 1; background: #e2e8f0; border-radius: 5px; height: 6px; overflow: hidden; min-width: 50px; }
.pbar .fill { height: 100%; border-radius: 5px; }
/* ── Loader ── */
.loader-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px; }
.spinner {
width: 44px; height: 44px; border: 4px solid #e2e8f0; border-top-color: #2563eb;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loader-text { margin-top: 14px; font-size: 13px; color: #64748b; }
/* ── Error ── */
.error-box {
background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b;
padding: 16px; border-radius: 10px; font-size: 13px;
}
/* ── Cobranza: colores por cartera + hover por cartera ── */
.cob-table td.cart-ant { background: #fef3c7; }
.cob-table td.cart-mes { background: #d1fae5; }
.cob-table td.cart-tot { background: #ede9fe; }
.cob-table tbody tr:hover td.cart-ant { background: #fde68a !important; }
.cob-table tbody tr:hover td.cart-mes { background: #a7f3d0 !important; }
.cob-table tbody tr:hover td.cart-tot { background: #ddd6fe !important; }
/* Filas con deuda: rojo y su hover, prevalece sobre cartera */
.cob-table tbody tr.row-deuda td { background: #fee2e2 !important; }
.cob-table tbody tr.row-deuda:hover td { background: #fecaca !important; }
/* Hover normal de celdas sin cartera */
.cob-table tbody tr:hover td:not(.cart-ant):not(.cart-mes):not(.cart-tot) { background: #f1f5f9; }
/* ── Ocupabilidad: fila de Inicio reprogramado (desde Supabase) en rojo ── */
tbody tr.row-inicio td { background: #fecaca !important; color: #7f1d1d; font-weight: 600; }
tbody tr.row-inicio:hover td { background: #fca5a5 !important; }
/* Zona de redimensionado de columnas (popups con columnas ajustables) */
.col-resizer { transition: background 0.15s; }
.col-resizer:hover { background: #93c5fd; }

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { port: 3000, host: true }
})

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build --mode production",
"preview": "vite preview"
},
"dependencies": {

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { useAuth } from "./lib/auth";
import Sidebar from "./components/Sidebar";
import IndicadorActualizacion from "./components/IndicadorActualizacion";
import Login from "./pages/Login";
import Ocupabilidad from "./pages/Ocupabilidad";
import Ventas from "./pages/Ventas";
@@ -52,6 +53,7 @@ export default function App() {
<div className="app">
<Sidebar active={pagina} onChange={setPagina} />
<main className="main">{render()}</main>
<IndicadorActualizacion />
</div>
);
}

View File

@@ -0,0 +1,97 @@
// src/components/IndicadorActualizacion.jsx
// Indicador (arriba-derecha) con la hora de la última actualización + botón de
// recarga. Al pulsar: re-lee las bases para la vista actual y recachea el resto.
import { useState, useEffect, useCallback } from "react";
import { api } from "../lib/api";
import { getVistaActual, emitirRefresco } from "../lib/vistaActual";
function formatearHora(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleString("es-PE", {
day: "2-digit", month: "2-digit",
hour: "2-digit", minute: "2-digit",
});
} catch {
return "—";
}
}
export default function IndicadorActualizacion() {
const [hora, setHora] = useState(null);
const [cargando, setCargando] = useState(false);
const consultar = useCallback(async () => {
try {
const r = await api.ultimaActualizacion();
setHora(r?.hora || null);
} catch { /* silencioso */ }
}, []);
useEffect(() => {
consultar();
const id = setInterval(consultar, 60000);
return () => clearInterval(id);
}, [consultar]);
async function recargar() {
if (cargando) return;
setCargando(true);
try {
const r = await api.cacheRefreshManual(getVistaActual());
setHora(r?.hora || null);
emitirRefresco();
} catch { /* silencioso */ }
finally { setCargando(false); }
}
return (
<>
<style>{`
@keyframes spin-actualiza { to { transform: rotate(360deg); } }
.ind-actualiza-btn { transition: color .15s, transform .15s; }
.ind-actualiza-btn:hover:not(:disabled) { color:#7dd3fc; transform: scale(1.15); }
.ind-actualiza-spin { animation: spin-actualiza .9s linear infinite; }
`}</style>
<div
style={{
position: "fixed", right: 16, top: 14, zIndex: 60,
display: "flex", alignItems: "center", gap: 8,
background: "rgba(15,23,42,0.9)",
color: cargando ? "#7dd3fc" : "#cbd5e1",
border: "1px solid " + (cargando ? "#0ea5e9" : "#334155"),
borderRadius: 20, padding: "6px 12px",
fontSize: 12, lineHeight: 1, fontWeight: 500,
boxShadow: "0 2px 8px rgba(0,0,0,0.28)",
backdropFilter: "blur(4px)",
userSelect: "none",
}}
title="Última actualización de las bases de datos"
>
<span style={{ opacity: cargando ? 1 : 0.85 }}>
{cargando ? "Actualizando…" : formatearHora(hora)}
</span>
<button
onClick={recargar}
disabled={cargando}
className={"ind-actualiza-btn" + (cargando ? " ind-actualiza-spin" : "")}
title={cargando ? "Actualizando datos…" : "Actualizar ahora"}
style={{
background: "transparent", border: "none",
cursor: cargando ? "default" : "pointer",
color: "#38bdf8", fontSize: 15, padding: 0, lineHeight: 1,
display: "flex", alignItems: "center",
}}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.4"
strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
</button>
</div>
</>
);
}

View File

@@ -1,7 +1,5 @@
// 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";
// ✅ DESPUÉS — Vite inyecta el valor real durante npm run build
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
async function get(path, params = {}) {
const qs = new URLSearchParams(params).toString();
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
@@ -30,8 +28,15 @@ async function post(path, params = {}) {
export const api = {
periodoActual: () => get("/api/periodo-actual"),
ultimaActualizacion: () => get("/api/ultima-actualizacion"),
cacheRefresh: () => post("/api/cache/refresh"),
cacheRefreshManual: (clave) => post("/api/cache/refresh-manual", clave ? { clave } : {}),
precargaModulo: (modulo, ano) => post("/api/precarga/modulo", { modulo, ano }),
precargaEstado: (modulo) => get("/api/precarga/estado", { modulo }),
ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
get("/api/ocupabilidad", { ano, mes, sede, programa }),
ocupabilidadAlumnos: (num_indice) =>
get("/api/ocupabilidad/alumnos", { num_indice }),
ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => 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 }),
@@ -39,6 +44,10 @@ export const api = {
get("/api/cobranza", { ano, mes, sectorista, agrupacion }),
cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") =>
get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }),
alumnoEstado: (matricula) => get("/api/alumno/estado", { matricula }),
alumnoEstadoGuardar: (matricula, estado, observaciones, usuario) =>
postJson("/api/alumno/estado/guardar", { matricula, estado, observaciones, usuario }),
alumnosPorRetirar: () => get("/api/alumnos/por-retirar"),
rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
get("/api/rentabilidad", { ano, mes, sede, programa }),
rentabilidadDetalle: (programa, ano, mes) =>

View File

@@ -6,65 +6,103 @@ 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 [perfil, setPerfil] = useState(null);
const [permisos, setPermisos] = useState([]);
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"
try {
const { data: perf, error: e1 } = await supabase
.from("perfiles")
.select("nombre, rol, activo")
.eq("id", userId)
.single();
if (e1 || !perf || perf.activo === false) {
setPerfil(null);
setPermisos([]);
return false;
}
// 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;
} catch (e) {
console.error("[auth] cargarPerfil falló:", e);
setPerfil(null);
setPermisos([]);
return false;
}
}, []);
// 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);
});
let resuelto = false;
// Escuchar cambios de sesión (login/logout)
const terminar = () => {
if (activo) { setCargando(false); resuelto = true; sessionStorage.removeItem("auth_reload"); }
};
// Fuente principal: onAuthStateChange dispara INITIAL_SESSION al arrancar
// con la sesión persistida (renueva token solo). Maneja login/logout en vivo.
const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => {
try {
setSession(s);
if (s?.user) await cargarPerfil(s.user.id);
else { setPerfil(null); setPermisos([]); }
} catch (e) {
console.error("[auth] onAuthStateChange falló:", e);
} finally {
terminar();
}
});
return () => { activo = false; sub?.subscription?.unsubscribe(); };
// Respaldo: si el listener no disparó en 6s, intenta getSession una vez.
// Si trae sesión la usa; si no, NO fuerza login (deja que el listener resuelva).
const tRespaldo = setTimeout(async () => {
if (resuelto || !activo) return;
try {
const { data } = await supabase.auth.getSession();
const s = data?.session || null;
if (s?.user && activo) {
setSession(s);
await cargarPerfil(s.user.id);
}
} catch (e) {
console.error("[auth] getSession (respaldo) falló:", e);
} finally {
terminar();
}
}, 6000);
// Si a los 8s sigue sin resolver, recarga la página UNA vez (F5 automático)
// en vez de mostrar el login pegado. sessionStorage evita bucle.
const tSeguridad = setTimeout(() => {
if (!activo || resuelto) return;
if (!sessionStorage.getItem("auth_reload")) {
sessionStorage.setItem("auth_reload", "1");
window.location.reload();
} else {
setCargando(false);
}
}, 8000);
return () => {
activo = false;
clearTimeout(tRespaldo);
clearTimeout(tSeguridad);
sub?.subscription?.unsubscribe();
};
}, [cargarPerfil]);
const login = async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { ok: false, msg: traducirError(error.message) };
// Verificar que tenga perfil/rol válido
const ok = await cargarPerfil(data.user.id);
if (!ok) {
await supabase.auth.signOut();

View File

@@ -3,8 +3,13 @@
// 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);
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
});

View File

@@ -0,0 +1,23 @@
// src/lib/vistaActual.js
// Registro de la "vista actual" (clave de caché del backend) + bus de eventos
// para que la página visible se recargue tras el refresco manual.
let _clave = null;
export function setVistaActual(clave) {
_clave = clave || null;
}
export function getVistaActual() {
return _clave;
}
const EVENTO = "datos-refrescados";
export function emitirRefresco() {
window.dispatchEvent(new Event(EVENTO));
}
export function onRefrescar(cb) {
window.addEventListener(EVENTO, cb);
return () => window.removeEventListener(EVENTO, cb);
}

View File

@@ -4,6 +4,7 @@ import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI";
import Modal from "../components/Modal";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026];
@@ -68,6 +69,8 @@ export default function Cobranza() {
useEffect(() => {
let activo = true;
setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}`);
const cargar = () => {
setLoading(true); setError(null);
api.cobranza(ano, mes, sectorista, agrupacion)
.then((res) => {
@@ -77,7 +80,10 @@ export default function Cobranza() {
setLoading(false);
})
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
return () => { activo = false; };
};
cargar();
const off = onRefrescar(cargar);
return () => { activo = false; off(); };
}, [ano, mes, agrupacion, sectorista]);
// Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada
@@ -89,6 +95,14 @@ export default function Cobranza() {
return () => { activo = false; };
}, [ano, mes, sectorista]);
// Cargar matrículas POR RETIRAR (para el "Saldo por Retirar" de las tarjetas)
const [porRetirar, setPorRetirar] = useState(new Set());
useEffect(() => {
api.alumnosPorRetirar()
.then((r) => setPorRetirar(new Set((r.matriculas || []).map(String))))
.catch(() => {});
}, [ano, mes]);
// Cargar clasificación de sede (sede.json) para los programas visibles
useEffect(() => {
if (!esPrograma) return;
@@ -155,11 +169,21 @@ export default function Cobranza() {
}, [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
// El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa).
return a.programa;
}
// Saldo por retirar (ANT/MES/TOTAL) de los alumnos marcados POR RETIRAR (según filtros)
const retiroKpi = useMemo(() => {
let sAnt=0, sMes=0;
alumnosFiltrados.forEach((a) => {
if (!porRetirar.has(String(a.matricula))) return;
sAnt += ((Number(a.cta_ant)||0) - (Number(a.cob_ant)||0));
sMes += ((Number(a.cta_cur)||0) - (Number(a.cob_cur)||0));
});
return { sAnt, sMes, sTot: sAnt + sMes };
}, [alumnosFiltrados, porRetirar]);
// Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas)
async function exportarDetalle() {
setExportando(true);
@@ -169,10 +193,7 @@ export default function Cobranza() {
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);
return String(v).trim();
};
const pct = (cob, cta) => cta > 0 ? cob/cta : null;
@@ -210,7 +231,6 @@ export default function Cobranza() {
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" };
});
});
@@ -261,7 +281,7 @@ export default function Cobranza() {
{loading ? <Loader text="Calculando cobranza..." /> :
error ? <ErrorBox msg={error} /> :
<>
<Tarjetas kpis={kpis} />
<Tarjetas kpis={kpis} retiro={retiroKpi} />
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
<table className="cob-table" {...cols.tableProps}>
@@ -357,7 +377,8 @@ export default function Cobranza() {
);
}
function Tarjetas({ kpis }) {
function Tarjetas({ kpis, retiro }) {
const rt = retiro || { sAnt:0, sMes:0, sTot:0 };
const alDia = kpis.saldo <= 0.01;
if (alDia) {
return (
@@ -370,13 +391,13 @@ function Tarjetas({ kpis }) {
);
}
const cards = [
["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt],
["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes],
["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot],
["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt, rt.sAnt],
["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes, rt.sMes],
["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot, rt.sTot],
];
return (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob]) => {
{cards.map(([titulo,color,cta,cob,saldoRetiro]) => {
const ratio = cta>0 ? (cob/cta*100) : 0;
const c = colorSemaforo(ratio);
const saldo = cta - cob;
@@ -387,6 +408,7 @@ function Tarjetas({ kpis }) {
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
<Row k="Saldo por Retirar" v={`S/ ${(saldoRetiro||0).toLocaleString("es-PE",{maximumFractionDigits:0})}`} color="#dc2626" />
{cta>0 && (
<div style={{marginTop:10}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
@@ -416,15 +438,16 @@ function Row({ k, v, bold, color }) {
);
}
function TarjetasResumen({ r }) {
function TarjetasResumen({ r, retiro }) {
const rt = retiro || { sAnt:0, sMes:0, sTot:0 };
const cards = [
["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt],
["📅 MES EN CURSO","#059669",r.cMes,r.obMes],
["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot],
["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt, rt.sAnt],
["📅 MES EN CURSO","#059669",r.cMes,r.obMes, rt.sMes],
["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot, rt.sTot],
];
return (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob]) => {
{cards.map(([titulo,color,cta,cob,saldoRetiro]) => {
const ratio = cta>0 ? (cob/cta*100) : 0;
const c = colorSemaforo(ratio);
const saldo = cta - cob;
@@ -435,6 +458,7 @@ function TarjetasResumen({ r }) {
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
<Row k="Saldo por Retirar" v={`S/ ${(saldoRetiro||0).toLocaleString("es-PE",{maximumFractionDigits:0})}`} color="#dc2626" />
{cta>0 && (
<div style={{marginTop:8}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
@@ -457,6 +481,15 @@ function TarjetasResumen({ r }) {
function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
const [texto, setTexto] = useState("");
const [query, setQuery] = useState("");
const [porRetirar, setPorRetirar] = useState(new Set());
const [infoAlumno, setInfoAlumno] = useState(null);
const cargarPorRetirar = () => {
api.alumnosPorRetirar()
.then((r)=>setPorRetirar(new Set((r.matriculas||[]).map(String))))
.catch(()=>{});
};
useEffect(() => { cargarPorRetirar(); }, []);
const resultados = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -468,13 +501,37 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
);
}, [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 COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.",
"CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"];
const anchosBusc = COLS.map((h,i) => {
if (i===0) return 250;
if (i===1) return 250;
if (h==="N° CUOTA" || h==="F. VENC.") return 100;
if (h==="INFO") return 70;
if (h.startsWith("%")) return 80;
return 110;
});
const cols = useColumnasAjustables(anchosBusc);
const FIJAS = 4;
const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => {
if (j >= FIJAS) return {};
let left = 0;
for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0);
const ultima = j === FIJAS - 1;
const lineaColor = esHeader ? "#475569" : "#e2e8f0";
return {
position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg,
borderRight: "none",
boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""),
};
};
const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : "";
return (
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1200}>
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1360}>
<div style={{display:"flex",gap:8,marginBottom:16}}>
<input
autoFocus
@@ -501,40 +558,62 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
<div style={{fontSize:13,color:"#1e40af",fontWeight:600,marginBottom:8}}>
{resultados.length} alumno(s) encontrado(s)
</div>
<div className="table-wrap">
<table className="cob-table" style={{minWidth:1100}}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
<div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.map((c,i)=>{
const fija = i < FIJAS;
const stH = fija ? stickyCol(i,{esHeader:true,bg:"#334155"})
: { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" };
return <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
})}</tr></thead>
<tbody>
{resultados.map((a,i)=>{
const deuda = a.saldo > 0.01;
const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa);
const retira = porRetirar.has(String(a.matricula));
const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff";
const celdas = [
{v:grp, cls:"col-name", al:"left"},
{v:a.matricula},
{v:a.alumno, cls:"col-name", al:"left"},
{v:a.programa, cls:"col-name", al:"left", texto:true},
{v:a.alumno, cls:"col-name", al:"left", texto:true},
{v:a.num_cuota},
{v:a.fch_venc},
{v:fmtMoneda("S/ "+a.cta_ant), al:"right", cart:"cart-ant"},
{v:fmtMoneda("S/ "+a.cob_ant), al:"right", cart:"cart-ant"},
{v:fmtMoneda("S/ "+a.cta_ant), cart:"cart-ant"},
{v:fmtMoneda("S/ "+a.cob_ant), cart:"cart-ant"},
{pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"},
{v:fmtMoneda("S/ "+a.cta_cur), al:"right", cart:"cart-mes"},
{v:fmtMoneda("S/ "+a.cob_cur), al:"right", cart:"cart-mes"},
{v:fmtMoneda("S/ "+a.cta_cur), cart:"cart-mes"},
{v:fmtMoneda("S/ "+a.cob_cur), cart:"cart-mes"},
{pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"},
{v:fmtMoneda("S/ "+a.cta_tot), al:"right", cart:"cart-tot"},
{v:fmtMoneda("S/ "+a.cob_tot), al:"right", cart:"cart-tot"},
{v:fmtMoneda("S/ "+a.cta_tot), cart:"cart-tot"},
{v:fmtMoneda("S/ "+a.cob_tot), cart:"cart-tot"},
{pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"},
{v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true},
{v:fmtMoneda("S/ "+a.saldo), saldo:true},
];
return (
<tr key={i} className={deuda?"row-deuda":""}>
<tr key={i} className={deuda?"row-deuda":""}
style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626",
textDecorationThickness:"2px" } : undefined}>
{celdas.map((c,j)=>(
<td key={j} className={((c.cls||"")+" "+(c.cart||"")).trim()}
title={c.texto ? String(c.v) : undefined}
style={{textAlign:c.al||"center",
fontWeight:c.saldo?700:undefined,
color:c.saldo&&deuda?"#dc2626":undefined}}>
color:c.saldo&&deuda?"#dc2626":undefined,
whiteSpace:"nowrap",
overflow:c.texto?"hidden":undefined,
textOverflow:c.texto?"ellipsis":undefined,
...stickyCol(j,{bg:bgFila})}}>
{c.pct!==undefined ? (c.pct ? <MiniBar valStr={c.pct} /> : "—") : c.v}
</td>
))}
<td style={{textAlign:"center"}}>
<button
onClick={()=>setInfoAlumno({ matricula:a.matricula, alumno:a.alumno, programa:a.programa })}
style={{padding:"3px 10px",fontSize:11,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#334155"}}
title="Ver/editar estado y observaciones">
INFO
</button>
</td>
</tr>
);
})}
@@ -543,6 +622,10 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
</div>
</>
)}
{infoAlumno && (
<ModalInfoAlumno alumno={infoAlumno} onClose={()=>setInfoAlumno(null)}
onGuardado={cargarPorRetirar} />
)}
</Modal>
);
}
@@ -550,6 +633,9 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) {
const [filas, setFilas] = useState([]);
const [loading, setLoading] = useState(true);
const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR
const [soloRetirar, setSoloRetirar] = useState(false); // filtro: ver solo POR RETIRAR
const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO
useEffect(() => {
let activo = true;
@@ -560,7 +646,34 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
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"];
// Cargar (y recargar) las matrículas marcadas POR RETIRAR para tachar sus filas
const cargarPorRetirar = () => {
api.alumnosPorRetirar()
.then((r) => setPorRetirar(new Set((r.matriculas || []).map(String))))
.catch(() => {});
};
useEffect(() => { cargarPorRetirar(); }, []);
const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"];
// Columnas redimensionables (igual que la tabla principal): PROGRAMA y ALUMNO más anchas
const anchosDet = COLS.map((h, i) => i === 0 ? 240 : (i === 1 ? 220 : (h==="INFO" ? 70 : h.startsWith("%") ? 80 : 110)));
const cols = useColumnasAjustables(anchosDet);
// Congelar las 4 primeras columnas (PROGRAMA, ALUMNO, N° CUOTA, F. VENC.).
const FIJAS = 4;
const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => {
if (j >= FIJAS) return {};
let left = 0;
for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0);
const ultima = j === FIJAS - 1;
const lineaColor = esHeader ? "#475569" : "#e2e8f0";
return {
position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg,
borderRight: "none",
boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""),
};
};
// Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos
const resumen = useMemo(() => {
@@ -574,55 +687,146 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
return { cAnt,obAnt,cMes,obMes,cTot,obTot };
}, [filas]);
// Conteo de alumnos: total, ya pagaron (saldo 0) y por pagar (saldo > 0)
const conteo = useMemo(() => {
let total=0, pagaron=0, deben=0;
filas.forEach((f) => {
if (String(f[0]).toUpperCase() === "TOTAL") return;
total++;
if (toMonto(f[13]) > 0.01) deben++; else pagaron++;
});
return { total, pagaron, deben };
}, [filas]);
// POR RETIRAR: cantidad de alumnos marcados y saldos por cartera (ANT, MES, TOTAL)
const retiro = useMemo(() => {
let cant=0, saldo=0, sAnt=0, sMes=0;
filas.forEach((f) => {
if (String(f[0]).toUpperCase() === "TOTAL") return;
if (porRetirar.has(String(f[0]))) {
cant++;
saldo += toMonto(f[13]);
sAnt += (toMonto(f[4]) - toMonto(f[5])); // CTA ANT - COB ANT
sMes += (toMonto(f[7]) - toMonto(f[8])); // CTA MES - COB MES
}
});
return { cant, saldo, sAnt, sMes, sTot: sAnt + sMes };
}, [filas, porRetirar]);
return (
<Modal title={`📊 Estado de Cuenta — ${grupo}`} onClose={onClose} width={1500}>
{loading ? <Loader text="Cargando estado de cuenta..." /> :
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos.</div> :
<>
<div style={{marginBottom:16}}>
<TarjetasResumen r={resumen} />
<TarjetasResumen r={resumen} retiro={retiro} />
</div>
<div className="table-wrap">
<table className="cob-table" style={{minWidth:1000}}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
{/* Etiquetas de conteo de alumnos */}
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap",alignItems:"center"}}>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
border:"1px solid #bfdbfe"}}>
👥 Total: {conteo.total}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#ecfdf5",color:"#047857",fontSize:13,fontWeight:700,
border:"1px solid #a7f3d0"}}>
Pagaron: {conteo.pagaron}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fffbeb",color:"#b45309",fontSize:13,fontWeight:700,
border:"1px solid #fde68a"}}>
Por pagar: {conteo.deben}
</span>
<span style={{width:1,height:26,background:"#e2e8f0",margin:"0 4px"}} />
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}>
🚫 Por retirar: {retiro.cant}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}>
💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}
</span>
<label style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:soloRetirar?"#fee2e2":"#f8fafc",marginLeft:"auto",
color:soloRetirar?"#b91c1c":"#64748b",fontSize:13,fontWeight:700,cursor:"pointer",
border:`1px solid ${soloRetirar?"#fca5a5":"#e2e8f0"}`}}>
<input type="checkbox" checked={soloRetirar}
onChange={(e)=>setSoloRetirar(e.target.checked)} style={{cursor:"pointer"}} />
Ver solo por retirar
</label>
</div>
<div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.map((c,i)=>{
const fija = i < FIJAS;
const stH = fija
? stickyCol(i,{esHeader:true,bg:"#334155"})
: { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" };
return <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
})}</tr></thead>
<tbody>
{filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").map((f, i) => {
{filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL")
.filter((f)=> !soloRetirar || porRetirar.has(String(f[0])))
.map((f, i) => {
const saldo = toMonto(f[13]);
const deuda = saldo > 0.01;
// Regla "-": si %ANT vacío → CTA/COB ANT a "—" (igual MES y TOT)
const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim());
const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim());
const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim());
const programa = f[14] || "-";
const retira = porRetirar.has(String(f[0]));
return (
<tr key={i} className={deuda?"row-deuda":""}>
{f.map((v, j) => {
let val = v;
<tr key={i} className={deuda?"row-deuda":""}
style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626",
textDecorationThickness:"2px" } : undefined}>
{f.slice(0, 14).map((v, j) => {
let val = (j===0) ? programa : v;
if ((j===4||j===5||j===6) && antVacio) val = "—";
if ((j===7||j===8||j===9) && mesVacio) val = "—";
if ((j===10||j===11||j===12) && totVacio) val = "—";
const isPct = [6,9,12].includes(j);
const isMonto = String(val).includes("S/");
let cart = "";
if ([4,5,6].includes(j)) cart = "cart-ant";
else if ([7,8,9].includes(j)) cart = "cart-mes";
else if ([10,11,12].includes(j)) cart = "cart-tot";
const clss = ((j===1?"col-name":"") + (cart?` ${cart}`:"")).trim();
const clss = (((j===0||j===1)?"col-name":"") + (cart?` ${cart}`:"")).trim();
const esTexto = (j===0||j===1);
const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff";
return (
<td key={j} className={clss}
style={{textAlign:j===1?"left":(isMonto?"right":"center"),
title={esTexto ? String(val) : undefined}
style={{textAlign:esTexto?"left":"center",
whiteSpace:"nowrap",
overflow:esTexto?"hidden":undefined,
textOverflow:esTexto?"ellipsis":undefined,
color:j===13&&deuda?"#dc2626":undefined,
fontWeight:j===13?700:undefined}}>
fontWeight:j===13?700:undefined,
...stickyCol(j,{bg:bgFila})}}>
{isPct ? (val==="—"?<span style={{color:"#cbd5e1"}}></span>:<MiniBar valStr={val} />) : fmtMoneda(val)}
</td>
);
})}
<td style={{textAlign:"center"}}>
<button
onClick={()=>setInfoAlumno({ matricula:f[0], alumno:f[1], programa:programa })}
style={{padding:"3px 10px",fontSize:11,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#334155"}}
title="Ver/editar estado y observaciones">
INFO
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
{(() => {
// TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada)
const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL");
const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL")
.filter((f)=> !soloRetirar || porRetirar.has(String(f[0])));
if (alumnos.length===0) return null;
const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0);
const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)};
@@ -641,20 +845,160 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
{Array.from({length:14}).map((_,j)=>{
const isPct=[6,9,12].includes(j);
const val=celda(j);
const fija = j < FIJAS;
let left = 0; for (let k=0;k<j;k++) left += (cols.anchos[k]||0);
const st = {
position:"sticky", bottom:0,
zIndex: fija ? 15 : 8,
backgroundColor:"#eff6ff",
padding:"11px 8px",
...(fija ? { left, boxShadow:`inset -1px 0 0 0 #cbd5e1${j===FIJAS-1?", 3px 0 5px -2px rgba(0,0,0,0.18)":""}, inset 0 1px 0 0 #cbd5e1` } : { boxShadow:"inset 0 1px 0 0 #cbd5e1" }),
};
return (
<td key={j} className={j===0?"col-name":""}
style={{textAlign:j===0?"left":(String(val).includes("S/")?"right":"center"),whiteSpace:"nowrap"}}>
style={{textAlign:j===0?"left":"center",whiteSpace:"nowrap",...st}}>
{isPct?<MiniBar valStr={val} />:val}
</td>
);
})}
<td style={{position:"sticky",bottom:0,zIndex:8,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}></td>
</tr>
);
})()}
</tbody>
</tfoot>
</table>
</div>
</>}
{infoAlumno && (
<ModalInfoAlumno alumno={infoAlumno} onClose={()=>setInfoAlumno(null)}
onGuardado={cargarPorRetirar} />
)}
</Modal>
);
}
// Pop-up de INFO por alumno: cambiar estado (EN CURSO / POR RETIRAR) y observaciones.
// Carga y guarda en Supabase (tabla cobranza_estado_alumno).
function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
const [estado, setEstado] = useState("EN CURSO");
const [nuevaObs, setNuevaObs] = useState("");
const [observaciones, setObservaciones] = useState([]);
const [cargando, setCargando] = useState(true);
const [guardando, setGuardando] = useState(false);
const [guardado, setGuardado] = useState(false);
useEffect(() => {
let activo = true;
api.alumnoEstado(alumno.matricula)
.then((r)=>{ if(activo){ setEstado(r.estado||"EN CURSO"); setObservaciones(r.observaciones||[]); } })
.catch(()=>{})
.finally(()=>{ if(activo) setCargando(false); });
return ()=>{ activo=false; };
}, [alumno.matricula]);
const agregarObs = () => {
const t = nuevaObs.trim();
if (!t) return;
const fecha = new Date().toLocaleString("es-PE", { dateStyle:"short", timeStyle:"short" });
setObservaciones((prev)=>[...prev, { texto:t, fecha }]);
setNuevaObs("");
setGuardado(false);
};
const guardar = async () => {
setGuardando(true); setGuardado(false);
try {
await api.alumnoEstadoGuardar(alumno.matricula, estado, observaciones);
setGuardado(true);
if (onGuardado) onGuardado();
} catch {
alert("No se pudo guardar. Intenta de nuevo.");
} finally {
setGuardando(false);
}
};
return (
<Modal title={` Información del alumno`} onClose={onClose} width={560}>
<div style={{marginBottom:16}}>
<div style={{fontSize:15,fontWeight:800,color:"#0f172a"}}>{alumno.alumno}</div>
<div style={{fontSize:12,color:"#64748b",marginTop:2}}>
Matrícula: {alumno.matricula} · {alumno.programa}
</div>
</div>
<div style={{marginBottom:18}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Estado del alumno
</label>
<select value={estado} onChange={(e)=>{setEstado(e.target.value); setGuardado(false);}}
style={{width:"100%",padding:"8px 10px",fontSize:14,borderRadius:8,
border:"1px solid #cbd5e1",background:"#fff",
color: estado==="POR RETIRAR" ? "#b91c1c" : "#047857", fontWeight:700}}>
<option value="EN CURSO">EN CURSO</option>
<option value="POR RETIRAR">POR RETIRAR</option>
</select>
</div>
<div style={{marginBottom:14}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Observaciones
</label>
<div style={{display:"flex",gap:8}}>
<input value={nuevaObs} onChange={(e)=>setNuevaObs(e.target.value)}
onKeyDown={(e)=>{ if(e.key==="Enter") agregarObs(); }}
placeholder="Escribe una observación…"
style={{flex:1,padding:"8px 10px",fontSize:13,borderRadius:8,border:"1px solid #cbd5e1"}} />
<button onClick={agregarObs}
style={{padding:"8px 14px",fontSize:13,fontWeight:700,cursor:"pointer",
border:"none",borderRadius:8,background:"#1e40af",color:"#fff"}}>
Agregar
</button>
</div>
<div style={{marginTop:10,display:"flex",flexDirection:"column",gap:6,maxHeight:180,overflowY:"auto"}}>
{observaciones.length===0 ? (
<div style={{fontSize:12,color:"#94a3b8",padding:"6px 2px"}}>Sin observaciones.</div>
) : observaciones.map((o,i)=>(
<div key={i} style={{background:"#f8fafc",border:"1px solid #eef2f7",borderRadius:8,
padding:"8px 10px",display:"flex",alignItems:"flex-start",gap:8}}>
<div style={{flex:1}}>
<div style={{fontSize:13,color:"#334155"}}>{o.texto}</div>
<div style={{fontSize:10,color:"#94a3b8",marginTop:2}}>{o.fecha}</div>
</div>
<button onClick={()=>{ setObservaciones(prev=>prev.filter((_,k)=>k!==i)); setGuardado(false); }}
title="Eliminar observación (se aplica al guardar)"
style={{border:"none",background:"transparent",color:"#dc2626",cursor:"pointer",
fontSize:16,fontWeight:800,lineHeight:1,padding:"0 2px"}}>
×
</button>
</div>
))}
</div>
</div>
<div style={{display:"flex",justifyContent:"flex-end",alignItems:"center",gap:10,marginTop:8,
borderTop:"1px solid #f1f5f9",paddingTop:12}}>
<button onClick={onClose}
style={{padding:"8px 16px",fontSize:13,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:8,background:"#fff",color:"#334155"}}>
Cerrar
</button>
<button onClick={guardar} disabled={guardando||cargando}
style={{padding:"8px 18px",fontSize:13,fontWeight:700,
cursor:(guardando||cargando)?"not-allowed":"pointer",
border:"none",borderRadius:8,background:"#1e40af",color:"#fff",
opacity:(guardando||cargando)?.7:1}}>
{guardando ? "Guardando…" : "Guardar"}
</button>
</div>
{guardado && (
<div style={{marginTop:12,display:"flex",alignItems:"center",gap:8,
background:"#ecfdf5",border:"1px solid #a7f3d0",color:"#047857",
borderRadius:8,padding:"10px 14px",fontSize:13,fontWeight:700}}>
<span style={{fontSize:16}}></span> Guardado correctamente.
</div>
)}
</Modal>
);
}

View File

@@ -3,14 +3,15 @@ import { useState, useEffect, useMemo } from "react";
import { api } from "../lib/api";
import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import Modal from "../components/Modal";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026];
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD"];
const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD","OPC."];
function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; }
export default function Ocupabilidad() {
@@ -23,15 +24,21 @@ export default function Ocupabilidad() {
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]);
const [modalCurso, setModalCurso] = useState(null);
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140, 80]);
useEffect(() => {
let activo = true;
setVistaActual(`ocupabilidad:${ano}|${mes}|${sede}|${programa}`);
const cargar = () => {
setLoading(true); setError(null);
api.ocupabilidad(ano, mes, sede, programa)
.then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
return () => { activo = false; };
};
cargar();
const off = onRefrescar(cargar);
return () => { activo = false; off(); };
}, [ano, mes, sede, programa]);
// Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio
@@ -170,6 +177,14 @@ export default function Ocupabilidad() {
<td>{cel(parseInt(d.Inscritos_PC)||0)}</td>
<td>{cel(parseInt(d.Descuento)||0)}</td>
<td>{cel(parseInt(d.Inscritos_Continuidad)||0)}</td>
<td>
{esInicio ? "-" : (
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModalCurso({ num_indice: d.num_indice, programa: prog })}>
👁 Ver
</button>
)}
</td>
</tr>
);
})}
@@ -183,12 +198,110 @@ export default function Ocupabilidad() {
<td>{totales.meta}</td>
<td><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></td>
<td>{totales.mes}</td><td>{totales.pc}</td><td>{totales.refri}</td><td>{totales.cont}</td>
<td></td>
</tr>
)}
</tbody>
</table>
</div>
</>}
{modalCurso && (
<ModalAlumnosCurso
numIndice={modalCurso.num_indice}
programa={modalCurso.programa}
onClose={() => setModalCurso(null)}
/>
)}
</div>
);
}
function ModalAlumnosCurso({ numIndice, programa, onClose }) {
const [alumnos, setAlumnos] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let activo = true;
setLoading(true);
api.ocupabilidadAlumnos(numIndice)
.then((res) => { if (activo) { setAlumnos(res.alumnos || []); setLoading(false); } })
.catch(() => { if (activo) { setAlumnos([]); setLoading(false); } });
return () => { activo = false; };
}, [numIndice]);
const colorEstado = (e) => {
if (e === "ALU") return { bg:"#ecfdf5", fg:"#047857", bd:"#a7f3d0" };
if (e === "PRE") return { bg:"#eff6ff", fg:"#1e40af", bd:"#bfdbfe" };
if (e === "RET") return { bg:"#fef2f2", fg:"#b91c1c", bd:"#fecaca" };
return { bg:"#f1f5f9", fg:"#475569", bd:"#e2e8f0" };
};
const conteo = useMemo(() => {
const c = {};
alumnos.forEach((a) => { c[a.estado] = (c[a.estado] || 0) + 1; });
return c;
}, [alumnos]);
return (
<Modal title={`👥 Alumnos Inscritos — ${programa}`} onClose={onClose} width={1050}>
{loading ? <Loader text="Cargando alumnos..." /> :
alumnos.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
<>
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
border:"1px solid #bfdbfe"}}>
👥 Total: {alumnos.length} alumno{alumnos.length!==1?"s":""}
</span>
{["ALU","PRE","RET"].map((e)=> conteo[e] ? (
<span key={e} style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:colorEstado(e).bg,color:colorEstado(e).fg,fontSize:13,fontWeight:700,
border:`1px solid ${colorEstado(e).bd}`}}>
{e}: {conteo[e]}
</span>
) : null)}
</div>
<div className="table-wrap" style={{overflowX:"auto"}}>
<table className="cob-table" style={{minWidth:900}}>
<thead><tr>
<th style={{textAlign:"left"}}>ALUMNO</th>
<th>ESTADO</th>
<th style={{textAlign:"left"}}>CURSO ANTERIOR (TEAC/TERC)</th>
<th>ESTADO ANTERIOR</th>
</tr></thead>
<tbody>
{alumnos.map((a, i) => {
const c = colorEstado(a.estado);
const tieneAnt = a.curso_anterior && a.curso_anterior !== "-";
const cAnt = colorEstado(a.estado_anterior);
return (
<tr key={i}>
<td className="col-name" style={{textAlign:"left"}}>{a.alumno}</td>
<td style={{textAlign:"center"}}>
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
background:c.bg,color:c.fg,fontWeight:700,fontSize:12,border:`1px solid ${c.bd}`}}>
{a.estado}
</span>
</td>
<td className="col-name" style={{textAlign:"left",color:tieneAnt?undefined:"#cbd5e1"}}>
{a.curso_anterior || "-"}
</td>
<td style={{textAlign:"center"}}>
{tieneAnt ? (
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
background:cAnt.bg,color:cAnt.fg,fontWeight:700,fontSize:12,border:`1px solid ${cAnt.bd}`}}>
{a.estado_anterior}
</span>
) : <span style={{color:"#cbd5e1"}}>-</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>}
</Modal>
);
}

View File

@@ -4,6 +4,7 @@ import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import Modal from "../components/Modal";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026];
@@ -44,11 +45,16 @@ export default function Rentabilidad() {
useEffect(() => {
let activo = true;
setVistaActual(`rentabilidad:${ano}|${mes}|${sede}|${programa}`);
const cargarSeguro = () => {
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; };
};
cargarSeguro();
const off = onRefrescar(cargarSeguro);
return () => { activo = false; off(); };
}, [ano, mes, sede, programa]);
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);

View File

@@ -4,6 +4,7 @@ import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import Modal from "../components/Modal";
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026];
@@ -27,11 +28,16 @@ export default function Ventas() {
useEffect(() => {
let activo = true;
setVistaActual(`ventas:${ano}|${mes}|${sede}|${programa}|False`);
const cargar = () => {
setLoading(true); setError(null);
api.ventas(ano, mes, sede, programa)
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
return () => { activo = false; };
};
cargar();
const off = onRefrescar(cargar);
return () => { activo = false; off(); };
}, [ano, mes, sede, programa]);
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);