Solucionado submodulos y subiendo codigo real
This commit is contained in:
1
backend
1
backend
Submodule backend deleted from c0ee88b153
19
backend/.env.example
Normal file
19
backend/.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# === SQL SERVER ===
|
||||||
|
SQL_SERVER=191.98.134.80
|
||||||
|
SQL_DATABASE=BDUS_CK000040_0001
|
||||||
|
SQL_USERNAME=ASEBASTIAN
|
||||||
|
SQL_PASSWORD=tu_password_aqui
|
||||||
|
|
||||||
|
# === POSTGRESQL (Chatwoot) ===
|
||||||
|
PG_HOST=191.98.134.81
|
||||||
|
PG_DATABASE=chatwoot_production
|
||||||
|
PG_USER=postgres
|
||||||
|
PG_PASSWORD=tu_password_aqui
|
||||||
|
PG_PORT=5432
|
||||||
|
|
||||||
|
# === SUPABASE ===
|
||||||
|
SUPABASE_URL=tu_url_supabase
|
||||||
|
SUPABASE_KEY=tu_key_supabase
|
||||||
|
|
||||||
|
# === GITHUB ===
|
||||||
|
GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD
|
||||||
6
backend/.gitignore
vendored
Normal file
6
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.xlsx
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
74
backend/README.md
Normal file
74
backend/README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Backend — Dashboard API (FastAPI)
|
||||||
|
|
||||||
|
API REST que reutiliza toda la lógica de negocio del dashboard original.
|
||||||
|
El frontend (React) consume estos endpoints.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- Driver ODBC para SQL Server (ODBC Driver 17 o 18)
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
En Windows, si falta el driver ODBC de SQL Server, descárgalo de:
|
||||||
|
https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
El archivo `.env` ya contiene las credenciales (copiado del proyecto original).
|
||||||
|
Si necesitas cambiarlas, edita `.env` (usa `.env.example` como referencia).
|
||||||
|
|
||||||
|
## Ejecutar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
El servidor arranca en: **http://localhost:8000**
|
||||||
|
|
||||||
|
- Documentación interactiva: http://localhost:8000/docs
|
||||||
|
- Health check: http://localhost:8000/api/health
|
||||||
|
|
||||||
|
## Cómo funciona el rendimiento
|
||||||
|
|
||||||
|
1. **Al arrancar** → precarga todos los módulos (datos en memoria)
|
||||||
|
2. **Refresco en segundo plano** → cada 4 min recarga el caché silenciosamente
|
||||||
|
3. **Respuestas** → instantáneas porque vienen de memoria
|
||||||
|
4. El frontend solo pide datos (JSON), nunca recarga toda la página
|
||||||
|
|
||||||
|
## Endpoints principales
|
||||||
|
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/periodo-actual` | Año y mes actuales |
|
||||||
|
| GET | `/api/ocupabilidad?ano=&mes=&sede=&programa=` | Datos ocupabilidad |
|
||||||
|
| GET | `/api/ventas?ano=&mes=` | Datos ventas |
|
||||||
|
| GET | `/api/ventas/detalle?vendedor=&ano=&mes=&tipo=` | Detalle vendedor |
|
||||||
|
| GET | `/api/cobranza?ano=&mes=§orista=&agrupacion=` | Datos cobranza |
|
||||||
|
| GET | `/api/cobranza/detalle?grupo=&ano=&mes=§orista=&agrupacion=` | Estado de cuenta |
|
||||||
|
| GET | `/api/rentabilidad?ano=&mes=&sede=&programa=` | Datos rentabilidad |
|
||||||
|
| GET | `/api/rentabilidad/detalle?programa=&ano=&mes=` | Detalle programa |
|
||||||
|
| GET | `/api/rentabilidad/costos?programa=&ano=&mes=` | Costos programa |
|
||||||
|
| GET | `/api/saldo-pendiente?tipo_cuota=` | Saldos pendientes |
|
||||||
|
| GET | `/api/asesores` | Lista de asesores (Chatwoot) |
|
||||||
|
| POST | `/api/asesores/estado?agent_id=&online=` | Cambiar estado asesor |
|
||||||
|
| POST | `/api/cache/refresh` | Forzar refresco de caché |
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── main.py # FastAPI + endpoints
|
||||||
|
├── services.py # Capa que envuelve la lógica con caché
|
||||||
|
├── cache_manager.py # Caché en memoria + refresco background
|
||||||
|
├── requirements.txt
|
||||||
|
├── .env # Credenciales (no subir a git)
|
||||||
|
├── core/ # DataManager (reutilizado del original)
|
||||||
|
├── modules/ # Lógica de negocio (reutilizada del original)
|
||||||
|
└── SQL_QUERY_2/ # Queries SQL optimizadas
|
||||||
|
```
|
||||||
59
backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query
Normal file
59
backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
SELECT
|
||||||
|
-- Cronograma de Matrícula (T1)
|
||||||
|
T1.num_matricula,
|
||||||
|
T1.num_cuota,
|
||||||
|
T1.fch_vencimiento,
|
||||||
|
T1.fch_cancelacion,
|
||||||
|
T1.cod_estado,
|
||||||
|
T1.imp_total,
|
||||||
|
T1.imp_total_pagado,
|
||||||
|
T1.imp_saldo,
|
||||||
|
T1.imp_dscto,
|
||||||
|
|
||||||
|
-- Matrícula y Programa
|
||||||
|
M.fch_matricula,
|
||||||
|
M.fch_retiro,
|
||||||
|
M.cod_estado AS estado_matricula,
|
||||||
|
P.dsc_programa,
|
||||||
|
RP.dsc_det_programa,
|
||||||
|
M.num_indice,
|
||||||
|
RP.fch_inicio,
|
||||||
|
M.cod_moneda,
|
||||||
|
M.imp_tc,
|
||||||
|
|
||||||
|
-- Sectorista
|
||||||
|
(ISNULL(EMP.dsc_apellido_paterno, '') + ' ' +
|
||||||
|
ISNULL(EMP.dsc_apellido_materno, '') + ', ' +
|
||||||
|
ISNULL(EMP.dsc_nombres, '')) AS dsc_sectorista,
|
||||||
|
|
||||||
|
RP.cod_frecuencia
|
||||||
|
|
||||||
|
FROM
|
||||||
|
[BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1
|
||||||
|
INNER JOIN
|
||||||
|
sgeca_matricula M
|
||||||
|
ON T1.cod_localidad = M.cod_localidad
|
||||||
|
AND T1.num_matricula = M.num_matricula
|
||||||
|
LEFT JOIN
|
||||||
|
sgeca_programa P
|
||||||
|
ON M.cod_programa = P.cod_programa
|
||||||
|
LEFT JOIN
|
||||||
|
sgede_RP_programa RP
|
||||||
|
ON M.cod_programa = RP.cod_programa
|
||||||
|
AND M.cod_periodo = RP.cod_detalle
|
||||||
|
AND M.num_indice = RP.num_indice
|
||||||
|
LEFT JOIN
|
||||||
|
vtama_cuota C
|
||||||
|
ON M.cod_cuota = C.cod_cuota
|
||||||
|
LEFT JOIN
|
||||||
|
[BDUS_CK000040_0001].[dbo].[sgede_RP_programa_sectorista] SEC_ASIG
|
||||||
|
ON M.cod_programa = SEC_ASIG.cod_programa
|
||||||
|
AND M.cod_periodo = SEC_ASIG.cod_detalle
|
||||||
|
AND M.num_indice = SEC_ASIG.num_indice
|
||||||
|
LEFT JOIN
|
||||||
|
[BDUS_CK000040_0001].[dbo].[rhuma_trabajador] EMP
|
||||||
|
ON SEC_ASIG.cod_trabajador = EMP.cod_trabajador
|
||||||
|
WHERE
|
||||||
|
YEAR(RP.fch_inicio) IN (2025, 2026)
|
||||||
|
ORDER BY
|
||||||
|
T1.fch_vencimiento ASC;
|
||||||
46
backend/SQL_QUERY_2/BASE_CUOTAS_2.query
Normal file
46
backend/SQL_QUERY_2/BASE_CUOTAS_2.query
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
SELECT
|
||||||
|
-- Cronograma de Matrícula (T1)
|
||||||
|
T1.cod_localidad,
|
||||||
|
T1.num_matricula,
|
||||||
|
T1.num_refinanciamiento,
|
||||||
|
T1.num_cuota,
|
||||||
|
T1.fch_vencimiento,
|
||||||
|
T1.fch_cancelacion,
|
||||||
|
T1.cod_estado,
|
||||||
|
T1.imp_total,
|
||||||
|
T1.imp_total_pagado,
|
||||||
|
T1.imp_saldo,
|
||||||
|
T1.imp_dscto,
|
||||||
|
|
||||||
|
-- Matrícula y Programa
|
||||||
|
M.fch_matricula,
|
||||||
|
M.cod_estado AS estado_matricula,
|
||||||
|
P.dsc_programa,
|
||||||
|
RP.dsc_det_programa,
|
||||||
|
RP.fch_inicio,
|
||||||
|
M.cod_moneda,
|
||||||
|
C.dsc_cuota,
|
||||||
|
M.imp_tc
|
||||||
|
FROM
|
||||||
|
[BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1
|
||||||
|
INNER JOIN
|
||||||
|
sgeca_matricula M
|
||||||
|
ON T1.cod_localidad = M.cod_localidad
|
||||||
|
AND T1.num_matricula = M.num_matricula
|
||||||
|
LEFT JOIN
|
||||||
|
sgeca_programa P
|
||||||
|
ON M.cod_programa = P.cod_programa
|
||||||
|
LEFT JOIN
|
||||||
|
sgede_RP_programa RP
|
||||||
|
ON M.cod_programa = RP.cod_programa
|
||||||
|
AND M.cod_periodo = RP.cod_detalle
|
||||||
|
AND M.num_indice = RP.num_indice
|
||||||
|
LEFT JOIN
|
||||||
|
vtama_cuota C
|
||||||
|
ON M.cod_cuota = C.cod_cuota
|
||||||
|
WHERE
|
||||||
|
YEAR(RP.fch_inicio) = ?
|
||||||
|
AND MONTH(RP.fch_inicio) = ?
|
||||||
|
AND T1.fch_vencimiento > '2020-01-01 00:00:00:000'
|
||||||
|
ORDER BY
|
||||||
|
T1.fch_vencimiento ASC;
|
||||||
38
backend/SQL_QUERY_2/BASE_CURSO_2.query
Normal file
38
backend/SQL_QUERY_2/BASE_CURSO_2.query
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
SELECT
|
||||||
|
rp.[num_indice],
|
||||||
|
rp.[dsc_det_programa],
|
||||||
|
rp.[flg_activo],
|
||||||
|
rp.[fch_inicio],
|
||||||
|
rp.[cod_frecuencia],
|
||||||
|
rp.[cod_estado],
|
||||||
|
p.[dsc_programa],
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sgeca_matricula m
|
||||||
|
WHERE m.cod_periodo = rp.cod_detalle
|
||||||
|
AND m.num_indice = rp.num_indice
|
||||||
|
AND m.cod_estado NOT IN ('ANU', 'SUS')
|
||||||
|
) AS 'Inscritos_Totales',
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sgeca_matricula m
|
||||||
|
WHERE m.cod_periodo = rp.cod_detalle
|
||||||
|
AND m.num_indice = rp.num_indice
|
||||||
|
AND m.cod_estado = 'RET'
|
||||||
|
) AS 'Inscritos_Retirados',
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sgeca_matricula m
|
||||||
|
WHERE m.cod_periodo = rp.cod_detalle
|
||||||
|
AND m.num_indice = rp.num_indice
|
||||||
|
AND m.cod_estado NOT IN ('ANU', 'RET', 'SUS')
|
||||||
|
) AS 'Inscritos_Activos'
|
||||||
|
FROM
|
||||||
|
[BDUS_CK000040_0001].[dbo].[sgede_RP_programa] rp
|
||||||
|
INNER JOIN [BDUS_CK000040_0001].[dbo].[sgeca_programa] p
|
||||||
|
ON rp.cod_programa = p.cod_programa
|
||||||
|
WHERE
|
||||||
|
YEAR(rp.fch_inicio) = ?
|
||||||
|
AND MONTH(rp.fch_inicio) = ?
|
||||||
|
ORDER BY
|
||||||
|
rp.fch_inicio ASC
|
||||||
51
backend/SQL_QUERY_2/BASE_FACTURAS_2.query
Normal file
51
backend/SQL_QUERY_2/BASE_FACTURAS_2.query
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
SELECT
|
||||||
|
comp.[num_correlativo],
|
||||||
|
comp.[fch_emision],
|
||||||
|
comp.[fch_cancelacion],
|
||||||
|
comp.[cod_estado],
|
||||||
|
comp.[cod_moneda],
|
||||||
|
comp.[imp_total],
|
||||||
|
comp.[imp_saldo],
|
||||||
|
comp.[imp_tc],
|
||||||
|
comp.[flg_nc],
|
||||||
|
|
||||||
|
-- Centro de Responsabilidad
|
||||||
|
cr_prog.dsc_centroresp AS dsc_cr,
|
||||||
|
|
||||||
|
-- Programa académico
|
||||||
|
rp.dsc_det_programa,
|
||||||
|
rp.fch_inicio,
|
||||||
|
prog.dsc_programa,
|
||||||
|
mat.num_indice,
|
||||||
|
mat.cod_estado AS estado_matricula,
|
||||||
|
mat.fch_retiro,
|
||||||
|
|
||||||
|
-- Cuotas y matrícula
|
||||||
|
cxc.num_matricula,
|
||||||
|
cxc.num_cuota,
|
||||||
|
COALESCE(cxc.imp_emitido, comp.imp_total) as imp_emitido,
|
||||||
|
|
||||||
|
-- Cuota
|
||||||
|
cron.fch_vencimiento as fch_vencimiento_cuota
|
||||||
|
FROM [BDUS_CK000040_0001].[dbo].[vtaca_comprobante] comp
|
||||||
|
|
||||||
|
LEFT JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
|
||||||
|
AND comp.num_correlativo = cxc.num_correlativo
|
||||||
|
|
||||||
|
LEFT JOIN sgeca_matricula mat ON cxc.cod_localidad_c = mat.cod_localidad
|
||||||
|
AND cxc.num_matricula = mat.num_matricula
|
||||||
|
|
||||||
|
LEFT JOIN sgede_RP_programa rp ON mat.cod_programa = rp.cod_programa
|
||||||
|
AND mat.cod_periodo = rp.cod_detalle
|
||||||
|
AND mat.num_indice = rp.num_indice
|
||||||
|
|
||||||
|
LEFT JOIN sgeca_programa prog ON mat.cod_programa = prog.cod_programa
|
||||||
|
LEFT JOIN rhuma_centroresponsabilidad cr_prog ON prog.cod_centroresp = cr_prog.cod_centroresp
|
||||||
|
|
||||||
|
LEFT JOIN sgede_cronograma_matricula cron ON cxc.cod_localidad_c = cron.cod_localidad
|
||||||
|
AND cxc.num_matricula = cron.num_matricula
|
||||||
|
AND cxc.num_refinanciamiento = cron.num_refinanciamiento
|
||||||
|
AND cxc.num_cuota = cron.num_cuota
|
||||||
|
|
||||||
|
WHERE comp.fch_emision BETWEEN '01-01-2024 00:00:00.000' AND '31-12-2028 00:00:00.000'
|
||||||
|
ORDER BY comp.fch_emision ASC;
|
||||||
211
backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query
Normal file
211
backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
SELECT
|
||||||
|
sgede_RP_programa.num_indice,
|
||||||
|
vtama_localidad.dsc_localidad,
|
||||||
|
sgeca_matricula.num_matricula,
|
||||||
|
sgeca_matricula.fch_matricula,
|
||||||
|
sgeca_matricula.cod_estado AS estado_matricula,
|
||||||
|
sgema_alumno.dsc_telefono_1,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
rhuma_trabajador.dsc_apellido_paterno + ' ' +
|
||||||
|
SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' +
|
||||||
|
rhuma_trabajador.dsc_nombres
|
||||||
|
FROM rhuma_trabajador
|
||||||
|
WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor
|
||||||
|
) AS dsc_vendedor,
|
||||||
|
|
||||||
|
sgeca_matricula.cod_moneda,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT
|
||||||
|
SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
), 0) AS INV_NETA,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT vtama_cuota.dsc_cuota
|
||||||
|
FROM vtama_cuota
|
||||||
|
WHERE vtama_cuota.cod_cuota = sgeca_matricula.cod_cuota
|
||||||
|
) AS dsc_cuota,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT sgema_beca.dsc_beca
|
||||||
|
FROM sgema_beca
|
||||||
|
WHERE sgema_beca.cod_beca = sgeca_matricula.cod_beca
|
||||||
|
) AS dsc_beca,
|
||||||
|
|
||||||
|
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno,
|
||||||
|
vtama_tipo_documento.dsc_tipo_documento,
|
||||||
|
sgema_alumno.dsc_documento,
|
||||||
|
sgeca_programa.dsc_programa,
|
||||||
|
sgede_RP_programa.dsc_det_programa AS dsc_promocion,
|
||||||
|
sgede_RP_programa.fch_inicio,
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- SALDOS Y VENCIMIENTOS: MATRICULA Y CUOTAS 1-5
|
||||||
|
-- ==========================================
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 0
|
||||||
|
), 0) AS imp_saldo_matricula,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 0
|
||||||
|
) AS fch_venc_matricula,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
), 0) AS imp_saldo_cuota1,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
) AS fch_venc_cuota1,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
) AS fch_cancelacion_cuota1,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 2
|
||||||
|
), 0) AS imp_saldo_cuota2,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 2
|
||||||
|
) AS fch_venc_cuota2,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 3
|
||||||
|
), 0) AS imp_saldo_cuota3,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 3
|
||||||
|
) AS fch_venc_cuota3,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 4
|
||||||
|
), 0) AS imp_saldo_cuota4,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 4
|
||||||
|
) AS fch_venc_cuota4,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 5
|
||||||
|
), 0) AS imp_saldo_cuota5,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 5
|
||||||
|
) AS fch_venc_cuota5,
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- TIPO DE CAMBIO
|
||||||
|
-- ==========================================
|
||||||
|
ISNULL((
|
||||||
|
SELECT TOP 1 comp.imp_tc
|
||||||
|
FROM vtaca_comprobante comp
|
||||||
|
INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
|
||||||
|
AND comp.num_correlativo = cxc.num_correlativo
|
||||||
|
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
|
||||||
|
AND comp.cod_estado NOT IN ('ANU', 'ELI')
|
||||||
|
ORDER BY comp.fch_emision DESC
|
||||||
|
), 0) AS imp_tc,
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- IMPORTE TOTAL PAGADO
|
||||||
|
-- ==========================================
|
||||||
|
(SELECT SUM(sgede_cronograma_matricula.imp_total_pagado)
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = sgeca_matricula.num_refinanciamiento) AS imp_total_pagado
|
||||||
|
|
||||||
|
FROM sgeca_matricula
|
||||||
|
INNER JOIN sgeca_programa
|
||||||
|
ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
|
||||||
|
LEFT JOIN sgede_RP_programa
|
||||||
|
ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
|
||||||
|
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
|
||||||
|
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
|
||||||
|
INNER JOIN sgema_alumno
|
||||||
|
ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||||
|
INNER JOIN vtama_localidad
|
||||||
|
ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad
|
||||||
|
INNER JOIN vtama_tipo_documento
|
||||||
|
ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento
|
||||||
|
|
||||||
|
WHERE
|
||||||
|
sgeca_matricula.cod_localidad LIKE 'SCENT'
|
||||||
|
AND YEAR(sgeca_matricula.fch_matricula) = ?
|
||||||
|
AND MONTH(sgeca_matricula.fch_matricula) = ?
|
||||||
|
AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS')
|
||||||
|
ORDER BY
|
||||||
|
num_indice ASC
|
||||||
19
backend/backend/.env.example
Normal file
19
backend/backend/.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# === SQL SERVER ===
|
||||||
|
SQL_SERVER=191.98.134.80
|
||||||
|
SQL_DATABASE=BDUS_CK000040_0001
|
||||||
|
SQL_USERNAME=ASEBASTIAN
|
||||||
|
SQL_PASSWORD=tu_password_aqui
|
||||||
|
|
||||||
|
# === POSTGRESQL (Chatwoot) ===
|
||||||
|
PG_HOST=191.98.134.81
|
||||||
|
PG_DATABASE=chatwoot_production
|
||||||
|
PG_USER=postgres
|
||||||
|
PG_PASSWORD=tu_password_aqui
|
||||||
|
PG_PORT=5432
|
||||||
|
|
||||||
|
# === SUPABASE ===
|
||||||
|
SUPABASE_URL=tu_url_supabase
|
||||||
|
SUPABASE_KEY=tu_key_supabase
|
||||||
|
|
||||||
|
# === GITHUB ===
|
||||||
|
GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD
|
||||||
6
backend/backend/.gitignore
vendored
Normal file
6
backend/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.xlsx
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
74
backend/backend/README.md
Normal file
74
backend/backend/README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Backend — Dashboard API (FastAPI)
|
||||||
|
|
||||||
|
API REST que reutiliza toda la lógica de negocio del dashboard original.
|
||||||
|
El frontend (React) consume estos endpoints.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- Driver ODBC para SQL Server (ODBC Driver 17 o 18)
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
En Windows, si falta el driver ODBC de SQL Server, descárgalo de:
|
||||||
|
https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
El archivo `.env` ya contiene las credenciales (copiado del proyecto original).
|
||||||
|
Si necesitas cambiarlas, edita `.env` (usa `.env.example` como referencia).
|
||||||
|
|
||||||
|
## Ejecutar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
El servidor arranca en: **http://localhost:8000**
|
||||||
|
|
||||||
|
- Documentación interactiva: http://localhost:8000/docs
|
||||||
|
- Health check: http://localhost:8000/api/health
|
||||||
|
|
||||||
|
## Cómo funciona el rendimiento
|
||||||
|
|
||||||
|
1. **Al arrancar** → precarga todos los módulos (datos en memoria)
|
||||||
|
2. **Refresco en segundo plano** → cada 4 min recarga el caché silenciosamente
|
||||||
|
3. **Respuestas** → instantáneas porque vienen de memoria
|
||||||
|
4. El frontend solo pide datos (JSON), nunca recarga toda la página
|
||||||
|
|
||||||
|
## Endpoints principales
|
||||||
|
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/periodo-actual` | Año y mes actuales |
|
||||||
|
| GET | `/api/ocupabilidad?ano=&mes=&sede=&programa=` | Datos ocupabilidad |
|
||||||
|
| GET | `/api/ventas?ano=&mes=` | Datos ventas |
|
||||||
|
| GET | `/api/ventas/detalle?vendedor=&ano=&mes=&tipo=` | Detalle vendedor |
|
||||||
|
| GET | `/api/cobranza?ano=&mes=§orista=&agrupacion=` | Datos cobranza |
|
||||||
|
| GET | `/api/cobranza/detalle?grupo=&ano=&mes=§orista=&agrupacion=` | Estado de cuenta |
|
||||||
|
| GET | `/api/rentabilidad?ano=&mes=&sede=&programa=` | Datos rentabilidad |
|
||||||
|
| GET | `/api/rentabilidad/detalle?programa=&ano=&mes=` | Detalle programa |
|
||||||
|
| GET | `/api/rentabilidad/costos?programa=&ano=&mes=` | Costos programa |
|
||||||
|
| GET | `/api/saldo-pendiente?tipo_cuota=` | Saldos pendientes |
|
||||||
|
| GET | `/api/asesores` | Lista de asesores (Chatwoot) |
|
||||||
|
| POST | `/api/asesores/estado?agent_id=&online=` | Cambiar estado asesor |
|
||||||
|
| POST | `/api/cache/refresh` | Forzar refresco de caché |
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── main.py # FastAPI + endpoints
|
||||||
|
├── services.py # Capa que envuelve la lógica con caché
|
||||||
|
├── cache_manager.py # Caché en memoria + refresco background
|
||||||
|
├── requirements.txt
|
||||||
|
├── .env # Credenciales (no subir a git)
|
||||||
|
├── core/ # DataManager (reutilizado del original)
|
||||||
|
├── modules/ # Lógica de negocio (reutilizada del original)
|
||||||
|
└── SQL_QUERY_2/ # Queries SQL optimizadas
|
||||||
|
```
|
||||||
59
backend/backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query
Normal file
59
backend/backend/SQL_QUERY_2/BASE_CRONOGRAMA_2.query
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
SELECT
|
||||||
|
-- Cronograma de Matrícula (T1)
|
||||||
|
T1.num_matricula,
|
||||||
|
T1.num_cuota,
|
||||||
|
T1.fch_vencimiento,
|
||||||
|
T1.fch_cancelacion,
|
||||||
|
T1.cod_estado,
|
||||||
|
T1.imp_total,
|
||||||
|
T1.imp_total_pagado,
|
||||||
|
T1.imp_saldo,
|
||||||
|
T1.imp_dscto,
|
||||||
|
|
||||||
|
-- Matrícula y Programa
|
||||||
|
M.fch_matricula,
|
||||||
|
M.fch_retiro,
|
||||||
|
M.cod_estado AS estado_matricula,
|
||||||
|
P.dsc_programa,
|
||||||
|
RP.dsc_det_programa,
|
||||||
|
M.num_indice,
|
||||||
|
RP.fch_inicio,
|
||||||
|
M.cod_moneda,
|
||||||
|
M.imp_tc,
|
||||||
|
|
||||||
|
-- Sectorista
|
||||||
|
(ISNULL(EMP.dsc_apellido_paterno, '') + ' ' +
|
||||||
|
ISNULL(EMP.dsc_apellido_materno, '') + ', ' +
|
||||||
|
ISNULL(EMP.dsc_nombres, '')) AS dsc_sectorista,
|
||||||
|
|
||||||
|
RP.cod_frecuencia
|
||||||
|
|
||||||
|
FROM
|
||||||
|
[BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1
|
||||||
|
INNER JOIN
|
||||||
|
sgeca_matricula M
|
||||||
|
ON T1.cod_localidad = M.cod_localidad
|
||||||
|
AND T1.num_matricula = M.num_matricula
|
||||||
|
LEFT JOIN
|
||||||
|
sgeca_programa P
|
||||||
|
ON M.cod_programa = P.cod_programa
|
||||||
|
LEFT JOIN
|
||||||
|
sgede_RP_programa RP
|
||||||
|
ON M.cod_programa = RP.cod_programa
|
||||||
|
AND M.cod_periodo = RP.cod_detalle
|
||||||
|
AND M.num_indice = RP.num_indice
|
||||||
|
LEFT JOIN
|
||||||
|
vtama_cuota C
|
||||||
|
ON M.cod_cuota = C.cod_cuota
|
||||||
|
LEFT JOIN
|
||||||
|
[BDUS_CK000040_0001].[dbo].[sgede_RP_programa_sectorista] SEC_ASIG
|
||||||
|
ON M.cod_programa = SEC_ASIG.cod_programa
|
||||||
|
AND M.cod_periodo = SEC_ASIG.cod_detalle
|
||||||
|
AND M.num_indice = SEC_ASIG.num_indice
|
||||||
|
LEFT JOIN
|
||||||
|
[BDUS_CK000040_0001].[dbo].[rhuma_trabajador] EMP
|
||||||
|
ON SEC_ASIG.cod_trabajador = EMP.cod_trabajador
|
||||||
|
WHERE
|
||||||
|
YEAR(RP.fch_inicio) IN (2025, 2026)
|
||||||
|
ORDER BY
|
||||||
|
T1.fch_vencimiento ASC;
|
||||||
46
backend/backend/SQL_QUERY_2/BASE_CUOTAS_2.query
Normal file
46
backend/backend/SQL_QUERY_2/BASE_CUOTAS_2.query
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
SELECT
|
||||||
|
-- Cronograma de Matrícula (T1)
|
||||||
|
T1.cod_localidad,
|
||||||
|
T1.num_matricula,
|
||||||
|
T1.num_refinanciamiento,
|
||||||
|
T1.num_cuota,
|
||||||
|
T1.fch_vencimiento,
|
||||||
|
T1.fch_cancelacion,
|
||||||
|
T1.cod_estado,
|
||||||
|
T1.imp_total,
|
||||||
|
T1.imp_total_pagado,
|
||||||
|
T1.imp_saldo,
|
||||||
|
T1.imp_dscto,
|
||||||
|
|
||||||
|
-- Matrícula y Programa
|
||||||
|
M.fch_matricula,
|
||||||
|
M.cod_estado AS estado_matricula,
|
||||||
|
P.dsc_programa,
|
||||||
|
RP.dsc_det_programa,
|
||||||
|
RP.fch_inicio,
|
||||||
|
M.cod_moneda,
|
||||||
|
C.dsc_cuota,
|
||||||
|
M.imp_tc
|
||||||
|
FROM
|
||||||
|
[BDUS_CK000040_0001].[dbo].[sgede_cronograma_matricula] T1
|
||||||
|
INNER JOIN
|
||||||
|
sgeca_matricula M
|
||||||
|
ON T1.cod_localidad = M.cod_localidad
|
||||||
|
AND T1.num_matricula = M.num_matricula
|
||||||
|
LEFT JOIN
|
||||||
|
sgeca_programa P
|
||||||
|
ON M.cod_programa = P.cod_programa
|
||||||
|
LEFT JOIN
|
||||||
|
sgede_RP_programa RP
|
||||||
|
ON M.cod_programa = RP.cod_programa
|
||||||
|
AND M.cod_periodo = RP.cod_detalle
|
||||||
|
AND M.num_indice = RP.num_indice
|
||||||
|
LEFT JOIN
|
||||||
|
vtama_cuota C
|
||||||
|
ON M.cod_cuota = C.cod_cuota
|
||||||
|
WHERE
|
||||||
|
YEAR(RP.fch_inicio) = ?
|
||||||
|
AND MONTH(RP.fch_inicio) = ?
|
||||||
|
AND T1.fch_vencimiento > '2020-01-01 00:00:00:000'
|
||||||
|
ORDER BY
|
||||||
|
T1.fch_vencimiento ASC;
|
||||||
38
backend/backend/SQL_QUERY_2/BASE_CURSO_2.query
Normal file
38
backend/backend/SQL_QUERY_2/BASE_CURSO_2.query
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
SELECT
|
||||||
|
rp.[num_indice],
|
||||||
|
rp.[dsc_det_programa],
|
||||||
|
rp.[flg_activo],
|
||||||
|
rp.[fch_inicio],
|
||||||
|
rp.[cod_frecuencia],
|
||||||
|
rp.[cod_estado],
|
||||||
|
p.[dsc_programa],
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sgeca_matricula m
|
||||||
|
WHERE m.cod_periodo = rp.cod_detalle
|
||||||
|
AND m.num_indice = rp.num_indice
|
||||||
|
AND m.cod_estado NOT IN ('ANU', 'SUS')
|
||||||
|
) AS 'Inscritos_Totales',
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sgeca_matricula m
|
||||||
|
WHERE m.cod_periodo = rp.cod_detalle
|
||||||
|
AND m.num_indice = rp.num_indice
|
||||||
|
AND m.cod_estado = 'RET'
|
||||||
|
) AS 'Inscritos_Retirados',
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sgeca_matricula m
|
||||||
|
WHERE m.cod_periodo = rp.cod_detalle
|
||||||
|
AND m.num_indice = rp.num_indice
|
||||||
|
AND m.cod_estado NOT IN ('ANU', 'RET', 'SUS')
|
||||||
|
) AS 'Inscritos_Activos'
|
||||||
|
FROM
|
||||||
|
[BDUS_CK000040_0001].[dbo].[sgede_RP_programa] rp
|
||||||
|
INNER JOIN [BDUS_CK000040_0001].[dbo].[sgeca_programa] p
|
||||||
|
ON rp.cod_programa = p.cod_programa
|
||||||
|
WHERE
|
||||||
|
YEAR(rp.fch_inicio) = ?
|
||||||
|
AND MONTH(rp.fch_inicio) = ?
|
||||||
|
ORDER BY
|
||||||
|
rp.fch_inicio ASC
|
||||||
51
backend/backend/SQL_QUERY_2/BASE_FACTURAS_2.query
Normal file
51
backend/backend/SQL_QUERY_2/BASE_FACTURAS_2.query
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
SELECT
|
||||||
|
comp.[num_correlativo],
|
||||||
|
comp.[fch_emision],
|
||||||
|
comp.[fch_cancelacion],
|
||||||
|
comp.[cod_estado],
|
||||||
|
comp.[cod_moneda],
|
||||||
|
comp.[imp_total],
|
||||||
|
comp.[imp_saldo],
|
||||||
|
comp.[imp_tc],
|
||||||
|
comp.[flg_nc],
|
||||||
|
|
||||||
|
-- Centro de Responsabilidad
|
||||||
|
cr_prog.dsc_centroresp AS dsc_cr,
|
||||||
|
|
||||||
|
-- Programa académico
|
||||||
|
rp.dsc_det_programa,
|
||||||
|
rp.fch_inicio,
|
||||||
|
prog.dsc_programa,
|
||||||
|
mat.num_indice,
|
||||||
|
mat.cod_estado AS estado_matricula,
|
||||||
|
mat.fch_retiro,
|
||||||
|
|
||||||
|
-- Cuotas y matrícula
|
||||||
|
cxc.num_matricula,
|
||||||
|
cxc.num_cuota,
|
||||||
|
COALESCE(cxc.imp_emitido, comp.imp_total) as imp_emitido,
|
||||||
|
|
||||||
|
-- Cuota
|
||||||
|
cron.fch_vencimiento as fch_vencimiento_cuota
|
||||||
|
FROM [BDUS_CK000040_0001].[dbo].[vtaca_comprobante] comp
|
||||||
|
|
||||||
|
LEFT JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
|
||||||
|
AND comp.num_correlativo = cxc.num_correlativo
|
||||||
|
|
||||||
|
LEFT JOIN sgeca_matricula mat ON cxc.cod_localidad_c = mat.cod_localidad
|
||||||
|
AND cxc.num_matricula = mat.num_matricula
|
||||||
|
|
||||||
|
LEFT JOIN sgede_RP_programa rp ON mat.cod_programa = rp.cod_programa
|
||||||
|
AND mat.cod_periodo = rp.cod_detalle
|
||||||
|
AND mat.num_indice = rp.num_indice
|
||||||
|
|
||||||
|
LEFT JOIN sgeca_programa prog ON mat.cod_programa = prog.cod_programa
|
||||||
|
LEFT JOIN rhuma_centroresponsabilidad cr_prog ON prog.cod_centroresp = cr_prog.cod_centroresp
|
||||||
|
|
||||||
|
LEFT JOIN sgede_cronograma_matricula cron ON cxc.cod_localidad_c = cron.cod_localidad
|
||||||
|
AND cxc.num_matricula = cron.num_matricula
|
||||||
|
AND cxc.num_refinanciamiento = cron.num_refinanciamiento
|
||||||
|
AND cxc.num_cuota = cron.num_cuota
|
||||||
|
|
||||||
|
WHERE comp.fch_emision BETWEEN '01-01-2024 00:00:00.000' AND '31-12-2028 00:00:00.000'
|
||||||
|
ORDER BY comp.fch_emision ASC;
|
||||||
211
backend/backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query
Normal file
211
backend/backend/SQL_QUERY_2/BASE_MATRICULADOS_2.query
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
SELECT
|
||||||
|
sgede_RP_programa.num_indice,
|
||||||
|
vtama_localidad.dsc_localidad,
|
||||||
|
sgeca_matricula.num_matricula,
|
||||||
|
sgeca_matricula.fch_matricula,
|
||||||
|
sgeca_matricula.cod_estado AS estado_matricula,
|
||||||
|
sgema_alumno.dsc_telefono_1,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
rhuma_trabajador.dsc_apellido_paterno + ' ' +
|
||||||
|
SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' +
|
||||||
|
rhuma_trabajador.dsc_nombres
|
||||||
|
FROM rhuma_trabajador
|
||||||
|
WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor
|
||||||
|
) AS dsc_vendedor,
|
||||||
|
|
||||||
|
sgeca_matricula.cod_moneda,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT
|
||||||
|
SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
), 0) AS INV_NETA,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT vtama_cuota.dsc_cuota
|
||||||
|
FROM vtama_cuota
|
||||||
|
WHERE vtama_cuota.cod_cuota = sgeca_matricula.cod_cuota
|
||||||
|
) AS dsc_cuota,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT sgema_beca.dsc_beca
|
||||||
|
FROM sgema_beca
|
||||||
|
WHERE sgema_beca.cod_beca = sgeca_matricula.cod_beca
|
||||||
|
) AS dsc_beca,
|
||||||
|
|
||||||
|
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno,
|
||||||
|
vtama_tipo_documento.dsc_tipo_documento,
|
||||||
|
sgema_alumno.dsc_documento,
|
||||||
|
sgeca_programa.dsc_programa,
|
||||||
|
sgede_RP_programa.dsc_det_programa AS dsc_promocion,
|
||||||
|
sgede_RP_programa.fch_inicio,
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- SALDOS Y VENCIMIENTOS: MATRICULA Y CUOTAS 1-5
|
||||||
|
-- ==========================================
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 0
|
||||||
|
), 0) AS imp_saldo_matricula,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 0
|
||||||
|
) AS fch_venc_matricula,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
), 0) AS imp_saldo_cuota1,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
) AS fch_venc_cuota1,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
) AS fch_cancelacion_cuota1,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 2
|
||||||
|
), 0) AS imp_saldo_cuota2,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 2
|
||||||
|
) AS fch_venc_cuota2,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 3
|
||||||
|
), 0) AS imp_saldo_cuota3,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 3
|
||||||
|
) AS fch_venc_cuota3,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 4
|
||||||
|
), 0) AS imp_saldo_cuota4,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 4
|
||||||
|
) AS fch_venc_cuota4,
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 5
|
||||||
|
), 0) AS imp_saldo_cuota5,
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_vencimiento
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 5
|
||||||
|
) AS fch_venc_cuota5,
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- TIPO DE CAMBIO
|
||||||
|
-- ==========================================
|
||||||
|
ISNULL((
|
||||||
|
SELECT TOP 1 comp.imp_tc
|
||||||
|
FROM vtaca_comprobante comp
|
||||||
|
INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
|
||||||
|
AND comp.num_correlativo = cxc.num_correlativo
|
||||||
|
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
|
||||||
|
AND comp.cod_estado NOT IN ('ANU', 'ELI')
|
||||||
|
ORDER BY comp.fch_emision DESC
|
||||||
|
), 0) AS imp_tc,
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- IMPORTE TOTAL PAGADO
|
||||||
|
-- ==========================================
|
||||||
|
(SELECT SUM(sgede_cronograma_matricula.imp_total_pagado)
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = sgeca_matricula.num_refinanciamiento) AS imp_total_pagado
|
||||||
|
|
||||||
|
FROM sgeca_matricula
|
||||||
|
INNER JOIN sgeca_programa
|
||||||
|
ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
|
||||||
|
LEFT JOIN sgede_RP_programa
|
||||||
|
ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
|
||||||
|
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
|
||||||
|
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
|
||||||
|
INNER JOIN sgema_alumno
|
||||||
|
ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||||
|
INNER JOIN vtama_localidad
|
||||||
|
ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad
|
||||||
|
INNER JOIN vtama_tipo_documento
|
||||||
|
ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento
|
||||||
|
|
||||||
|
WHERE
|
||||||
|
sgeca_matricula.cod_localidad LIKE 'SCENT'
|
||||||
|
AND YEAR(sgeca_matricula.fch_matricula) = ?
|
||||||
|
AND MONTH(sgeca_matricula.fch_matricula) = ?
|
||||||
|
AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS')
|
||||||
|
ORDER BY
|
||||||
|
num_indice ASC
|
||||||
98
backend/backend/cache_manager.py
Normal file
98
backend/backend/cache_manager.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# backend/cache_manager.py
|
||||||
|
"""
|
||||||
|
Caché global en memoria con refresco en segundo plano.
|
||||||
|
Independiente de Streamlit. Mantiene los datos calientes para respuestas instantáneas.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from typing import Any, Callable, Dict, Tuple
|
||||||
|
|
||||||
|
_CACHE: Dict[str, Tuple[float, Any]] = {}
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_DEFAULT_TTL = 900 # 15 min
|
||||||
|
|
||||||
|
|
||||||
|
def _make_key(prefix: str, args: tuple) -> str:
|
||||||
|
return prefix + ":" + "|".join(str(a) for a in args)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get_or_set(prefix: str, args: tuple, loader: Callable[[], Any],
|
||||||
|
ttl: int = _DEFAULT_TTL) -> Any:
|
||||||
|
"""Devuelve el valor cacheado si está fresco; si no, lo calcula y guarda."""
|
||||||
|
key = _make_key(prefix, args)
|
||||||
|
now = time.time()
|
||||||
|
with _LOCK:
|
||||||
|
hit = _CACHE.get(key)
|
||||||
|
if hit and (now - hit[0] < ttl):
|
||||||
|
return hit[1]
|
||||||
|
# Calcular fuera del lock (puede tardar)
|
||||||
|
value = loader()
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE[key] = (now, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set(prefix: str, args: tuple, value: Any) -> None:
|
||||||
|
key = _make_key(prefix, args)
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE[key] = (time.time(), value)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_keys():
|
||||||
|
"""Lista las claves actualmente cacheadas (para refrescarlas sin vaciar)."""
|
||||||
|
with _LOCK:
|
||||||
|
return list(_CACHE.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def cache_refresh_existing(loader_for_key):
|
||||||
|
"""Recalcula SOLO las entradas que ya existen, sin vaciar el caché.
|
||||||
|
Así los meses ya visitados se mantienen calientes y nunca quedan 'fríos'."""
|
||||||
|
for key in cache_keys():
|
||||||
|
try:
|
||||||
|
nuevo = loader_for_key(key)
|
||||||
|
if nuevo is not None:
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE[key] = (time.time(), nuevo)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[cache refresh] {key}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_invalidate(prefix: str = None) -> None:
|
||||||
|
"""Invalida todo el caché, o solo las claves de un prefijo."""
|
||||||
|
with _LOCK:
|
||||||
|
if prefix is None:
|
||||||
|
_CACHE.clear()
|
||||||
|
else:
|
||||||
|
for k in list(_CACHE.keys()):
|
||||||
|
if k.startswith(prefix + ":"):
|
||||||
|
del _CACHE[k]
|
||||||
|
|
||||||
|
|
||||||
|
def cache_stats() -> dict:
|
||||||
|
with _LOCK:
|
||||||
|
return {"entradas": len(_CACHE), "claves": list(_CACHE.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Refresco en segundo plano ──────────────────────────────────────────────
|
||||||
|
_background_started = False
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 240):
|
||||||
|
"""Lanza un hilo que ejecuta refresh_fn cada `interval` segundos (4 min por defecto,
|
||||||
|
antes de que expire el TTL de 5 min, para mantener el caché siempre caliente)."""
|
||||||
|
global _background_started
|
||||||
|
if _background_started:
|
||||||
|
return
|
||||||
|
_background_started = True
|
||||||
|
|
||||||
|
def _loop():
|
||||||
|
while True:
|
||||||
|
time.sleep(interval)
|
||||||
|
try:
|
||||||
|
refresh_fn()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[background refresh] error: {e}")
|
||||||
|
|
||||||
|
t = threading.Thread(target=_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
print(f"[cache] refresco en segundo plano cada {interval}s iniciado")
|
||||||
0
backend/backend/core/__init__.py
Normal file
0
backend/backend/core/__init__.py
Normal file
794
backend/backend/core/data_manager.py
Normal file
794
backend/backend/core/data_manager.py
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
# core/data_manager.py
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
class DataManager:
|
||||||
|
def __init__(self):
|
||||||
|
# --- SQL SERVER ---
|
||||||
|
self.server = os.getenv("SQL_SERVER", "191.98.134.80")
|
||||||
|
self.database = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
|
||||||
|
self.username = os.getenv("SQL_USERNAME", "ASEBASTIAN")
|
||||||
|
self.password = os.getenv("SQL_PASSWORD", "")
|
||||||
|
|
||||||
|
# --- POSTGRESQL ---
|
||||||
|
self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
|
||||||
|
self.pg_database = os.getenv("PG_DATABASE", "chatwoot_production")
|
||||||
|
self.pg_user = os.getenv("PG_USER", "postgres")
|
||||||
|
self.pg_password = os.getenv("PG_PASSWORD", "")
|
||||||
|
self.pg_port = os.getenv("PG_PORT", "5432")
|
||||||
|
|
||||||
|
# --- SUPABASE ---
|
||||||
|
self.supabase_url = os.getenv("SUPABASE_URL", "")
|
||||||
|
self.supabase_key = os.getenv("SUPABASE_KEY", "")
|
||||||
|
self.supabase_client = None
|
||||||
|
self._init_supabase()
|
||||||
|
|
||||||
|
# --- GITHUB ---
|
||||||
|
base = os.getenv("GITHUB_BASE", "https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD")
|
||||||
|
self.github_json_url = f"{base}/actualizador.json"
|
||||||
|
self.github_replace_url = f"{base}/REPLACE_CURSO.json"
|
||||||
|
self.github_meta_url = f"{base}/REPLACE_META.json"
|
||||||
|
self.github_costos_url = f"{base}/REPLACE_COSTOS.json"
|
||||||
|
self.github_pronostico_url = f"{base}/BASE_PRONOSTICO.json"
|
||||||
|
self.github_pendientes_url = f"{base}/BASE_PENDIENTES.json"
|
||||||
|
self.github_sede_url = f"{base}/sede.json"
|
||||||
|
self.github_horario_url = f"{base}/HORARIO_ASESOR.json"
|
||||||
|
self.github_query_url = f"{base}/SQL_QUERY_2/BASE_CURSO_2.query"
|
||||||
|
self.github_cuota_url = f"{base}/SQL_QUERY_2/BASE_CUOTAS_2.query"
|
||||||
|
self.github_cronograma_url = f"{base}/SQL_QUERY_2/BASE_CRONOGRAMA_2.query"
|
||||||
|
self.github_facturas_url = f"{base}/SQL_QUERY_2/BASE_FACTURAS_2.query"
|
||||||
|
self.github_matriculas_url = f"{base}/SQL_QUERY_2/BASE_MATRICULADOS_2.query"
|
||||||
|
|
||||||
|
# --- Variables de estado ---
|
||||||
|
self.config_data = {}
|
||||||
|
self.replace_data = {}
|
||||||
|
self.meta_data = {}
|
||||||
|
self.sede_data = {}
|
||||||
|
self.pronostico_data = {}
|
||||||
|
self.costos_data = {}
|
||||||
|
self.historico_pendientes = {}
|
||||||
|
self.pendientes_data = set()
|
||||||
|
self.query_sql = ""
|
||||||
|
self.query_matriculas_sql = ""
|
||||||
|
self.query_cronograma_sql = ""
|
||||||
|
self.query_facturas_sql = ""
|
||||||
|
|
||||||
|
self.cargar_toda_configuracion()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CARGA DE CONFIGURACIÓN
|
||||||
|
# =========================================================================
|
||||||
|
def cargar_toda_configuracion(self):
|
||||||
|
# OPTIMIZACIÓN: descargar todas las URLs en paralelo
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
json_tasks = {
|
||||||
|
"config_data": (self.github_json_url, {"config_general": {"auto_update_minutos": 5}}),
|
||||||
|
"replace_data": (self.github_replace_url, {}),
|
||||||
|
"meta_data": (self.github_meta_url, {}),
|
||||||
|
"sede_data": (self.github_sede_url, {}),
|
||||||
|
"pronostico_data": (self.github_pronostico_url, {"pronosticos": []}),
|
||||||
|
"costos_data": (self.github_costos_url, {}),
|
||||||
|
"_pendientes_raw": (self.github_pendientes_url, {}),
|
||||||
|
}
|
||||||
|
text_tasks = {
|
||||||
|
"query_sql": self.github_query_url,
|
||||||
|
"query_matriculas_sql": self.github_matriculas_url,
|
||||||
|
"query_cronograma_sql": self.github_cronograma_url,
|
||||||
|
"query_facturas_sql": self.github_facturas_url,
|
||||||
|
}
|
||||||
|
with ThreadPoolExecutor(max_workers=15) as ex:
|
||||||
|
json_futs = {k: ex.submit(self._get_json, url, default) for k,(url,default) in json_tasks.items()}
|
||||||
|
text_futs = {k: ex.submit(self._get_text, url) for k,url in text_tasks.items()}
|
||||||
|
for k, f in json_futs.items(): setattr(self, k, f.result())
|
||||||
|
for k, f in text_futs.items(): setattr(self, k, f.result())
|
||||||
|
# pendientes: parsear el dict ya descargado
|
||||||
|
try:
|
||||||
|
self.historico_pendientes = self._pendientes_raw.get("historico_pendientes", {})
|
||||||
|
except:
|
||||||
|
self.historico_pendientes = {}
|
||||||
|
self.pendientes_data = set()
|
||||||
|
|
||||||
|
def _get_json(self, url, default=None):
|
||||||
|
try:
|
||||||
|
r = requests.get(url, timeout=4)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except:
|
||||||
|
return default or {}
|
||||||
|
|
||||||
|
def _get_text(self, url):
|
||||||
|
try:
|
||||||
|
r = requests.get(url, timeout=4)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.text
|
||||||
|
except:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _cargar_pendientes(self):
|
||||||
|
# Ya cargado en paralelo dentro de cargar_toda_configuracion
|
||||||
|
if not hasattr(self, 'historico_pendientes'):
|
||||||
|
self.historico_pendientes = {}
|
||||||
|
if not hasattr(self, 'pendientes_data'):
|
||||||
|
self.pendientes_data = set()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CONEXIONES A BASE DE DATOS
|
||||||
|
# =========================================================================
|
||||||
|
def get_connection(self):
|
||||||
|
import pyodbc
|
||||||
|
conn_str = (
|
||||||
|
f"DRIVER={{SQL Server}};"
|
||||||
|
f"SERVER={self.server};"
|
||||||
|
f"DATABASE={self.database};"
|
||||||
|
f"UID={self.username};"
|
||||||
|
f"PWD={self.password}"
|
||||||
|
)
|
||||||
|
return pyodbc.connect(conn_str)
|
||||||
|
|
||||||
|
def get_pg_connection(self):
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"psycopg2 no está instalado. Para usar PostgreSQL ejecuta:\n"
|
||||||
|
"pip install psycopg2-binary"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=self.pg_host,
|
||||||
|
database=self.pg_database,
|
||||||
|
user=self.pg_user,
|
||||||
|
password=self.pg_password,
|
||||||
|
port=self.pg_port
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error PostgreSQL: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _init_supabase(self):
|
||||||
|
self.supabase_error = None
|
||||||
|
try:
|
||||||
|
from supabase import create_client
|
||||||
|
if self.supabase_url and self.supabase_key:
|
||||||
|
self.supabase_client = create_client(self.supabase_url, self.supabase_key)
|
||||||
|
print(f"✅ Supabase conectado: {self.supabase_url[:30]}...")
|
||||||
|
else:
|
||||||
|
self.supabase_error = "SUPABASE_URL o SUPABASE_KEY vacíos"
|
||||||
|
print(f"⚠️ {self.supabase_error}")
|
||||||
|
except Exception as e:
|
||||||
|
self.supabase_client = None
|
||||||
|
self.supabase_error = f"{type(e).__name__}: {e}"
|
||||||
|
print(f"❌ Error al conectar Supabase: {self.supabase_error}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CONSULTAS SQL
|
||||||
|
# =========================================================================
|
||||||
|
def ejecutar_consulta_cursos(self, ano, mes):
|
||||||
|
try:
|
||||||
|
if not self.query_sql:
|
||||||
|
raise Exception("No se pudo cargar la query BASE_CURSO")
|
||||||
|
|
||||||
|
ids_invitados = []
|
||||||
|
cursos_personalizados = self.config_data.get('cursos_personalizados', {})
|
||||||
|
for id_curso, datos in cursos_personalizados.items():
|
||||||
|
if 'fch_inicio' in datos:
|
||||||
|
try:
|
||||||
|
f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y')
|
||||||
|
if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes):
|
||||||
|
ids_invitados.append(str(int(id_curso)))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sql_final = self.query_sql
|
||||||
|
if ids_invitados:
|
||||||
|
ids_str = ",".join(ids_invitados)
|
||||||
|
if "ORDER BY" in sql_final:
|
||||||
|
parts = sql_final.split("ORDER BY")
|
||||||
|
sql_final = parts[0] + f" OR rp.num_indice IN ({ids_str}) \nORDER BY" + parts[1]
|
||||||
|
else:
|
||||||
|
sql_final += f" OR rp.num_indice IN ({ids_str})"
|
||||||
|
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql_final, ano, mes)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
cols = ['num_indice', 'dsc_det_programa', 'flg_activo', 'fch_inicio',
|
||||||
|
'cod_frecuencia', 'cod_estado', 'dsc_programa',
|
||||||
|
'Inscritos_Totales', 'Inscritos_Retirados', 'Inscritos_Activos']
|
||||||
|
datos = [{cols[i]: row[i] for i in range(len(cols))} for row in rows]
|
||||||
|
conn.close()
|
||||||
|
return datos
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error ejecutar_consulta_cursos: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def cargar_datos_matriculas(self, ano, mes):
|
||||||
|
try:
|
||||||
|
if not self.query_matriculas_sql:
|
||||||
|
return {}
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(self.query_matriculas_sql, ano, mes)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
d = {}
|
||||||
|
for row in rows:
|
||||||
|
d[row[0]] = d.get(row[0], 0) + 1
|
||||||
|
conn.close()
|
||||||
|
return d
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def ejecutar_consulta_matriculados_detalle(self, ano, mes):
|
||||||
|
try:
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
ids_invitados = []
|
||||||
|
cursos_personalizados = self.config_data.get('cursos_personalizados', {})
|
||||||
|
for id_curso, datos in cursos_personalizados.items():
|
||||||
|
if 'fch_inicio' in datos:
|
||||||
|
try:
|
||||||
|
f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y')
|
||||||
|
if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes):
|
||||||
|
ids_invitados.append(str(int(id_curso)))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sql_or = ""
|
||||||
|
if ids_invitados:
|
||||||
|
sql_or = f" OR sgede_RP_programa.num_indice IN ({','.join(ids_invitados)}) "
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
sgede_RP_programa.num_indice, sgeca_matricula.num_matricula,
|
||||||
|
sgeca_matricula.cod_estado AS estado_matricula,
|
||||||
|
sgeca_programa.dsc_programa, sgeca_matricula.cod_moneda,
|
||||||
|
sgema_alumno.dsc_documento,
|
||||||
|
ISNULL((SELECT SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1), 0) AS INV_NETA,
|
||||||
|
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula,
|
||||||
|
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1,
|
||||||
|
ISNULL((SELECT TOP 1 comp.imp_tc FROM vtaca_comprobante comp
|
||||||
|
INNER JOIN sgevi_cuotas_x_comprobante cxc
|
||||||
|
ON comp.cod_localidad = cxc.cod_localidad
|
||||||
|
AND comp.num_correlativo = cxc.num_correlativo
|
||||||
|
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
|
||||||
|
AND comp.cod_estado NOT IN ('ANU', 'ELI')
|
||||||
|
ORDER BY comp.fch_emision DESC), 0) AS imp_tc
|
||||||
|
FROM sgeca_matricula
|
||||||
|
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
|
||||||
|
LEFT JOIN sgede_RP_programa
|
||||||
|
ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
|
||||||
|
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
|
||||||
|
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
|
||||||
|
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||||
|
WHERE sgeca_matricula.cod_localidad LIKE 'SCENT'
|
||||||
|
AND ((YEAR(sgede_RP_programa.fch_inicio) = ? AND MONTH(sgede_RP_programa.fch_inicio) = ?) {sql_or})
|
||||||
|
AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS')
|
||||||
|
"""
|
||||||
|
cursor.execute(query, ano, mes)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error ejecutar_consulta_matriculados_detalle: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def consultar_historial_continuidad(self, lista_documentos):
|
||||||
|
if not lista_documentos:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
dnis = list(set([str(d).strip() for d in lista_documentos if d]))
|
||||||
|
if not dnis:
|
||||||
|
return {}
|
||||||
|
placeholders = ','.join(['?'] * len(dnis))
|
||||||
|
query = f"""
|
||||||
|
SELECT a.dsc_documento, p.dsc_programa, rp.fch_inicio
|
||||||
|
FROM sgeca_matricula m
|
||||||
|
INNER JOIN sgema_alumno a ON m.cod_alumno = a.cod_alumno
|
||||||
|
INNER JOIN sgeca_programa p ON m.cod_programa = p.cod_programa
|
||||||
|
INNER JOIN sgede_RP_programa rp
|
||||||
|
ON m.cod_periodo = rp.cod_detalle
|
||||||
|
AND m.cod_programa = rp.cod_programa
|
||||||
|
AND m.num_indice = rp.num_indice
|
||||||
|
WHERE a.dsc_documento IN ({placeholders})
|
||||||
|
AND m.cod_estado IN ('ALU', 'PRE', 'RET')
|
||||||
|
AND m.cod_localidad LIKE 'SCENT'
|
||||||
|
AND (p.dsc_programa LIKE '%TEAC%' OR p.dsc_programa LIKE '%TERC%'
|
||||||
|
OR p.dsc_programa LIKE '%AREQUIPA%' OR p.dsc_programa LIKE '%TRUJILLO%'
|
||||||
|
OR p.dsc_programa LIKE '%PIURA%' OR p.dsc_programa LIKE '%TECNICO ESPECIALISTA%')
|
||||||
|
"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(query, dnis)
|
||||||
|
historial = {}
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
dni = str(row[0]).strip()
|
||||||
|
if dni not in historial:
|
||||||
|
historial[dni] = []
|
||||||
|
historial[dni].append({'programa': row[1], 'fecha': row[2]})
|
||||||
|
conn.close()
|
||||||
|
return historial
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error historial continuidad: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _get_query_ventas_hibrido(self, lista_ids_vip):
|
||||||
|
if not lista_ids_vip:
|
||||||
|
sql_in_clause = "(-1)"
|
||||||
|
else:
|
||||||
|
ids_str = [str(x) for x in lista_ids_vip]
|
||||||
|
sql_in_clause = "(" + ",".join(ids_str) + ")"
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
rhuma_trabajador.dsc_apellido_paterno + ' ' +
|
||||||
|
SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' +
|
||||||
|
rhuma_trabajador.dsc_nombres
|
||||||
|
FROM rhuma_trabajador
|
||||||
|
WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor
|
||||||
|
) AS dsc_vendedor, -- [0]
|
||||||
|
ISNULL((
|
||||||
|
SELECT
|
||||||
|
SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
), 0) AS INV_NETA, -- [1]
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 0
|
||||||
|
), 0) AS imp_saldo_matricula, -- [2]
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
), 0) AS imp_saldo_cuota1, -- [3]
|
||||||
|
|
||||||
|
sgeca_matricula.num_matricula, -- [4]
|
||||||
|
YEAR(sgeca_matricula.fch_matricula) as anio_mat, -- [5]
|
||||||
|
MONTH(sgeca_matricula.fch_matricula) as mes_mat, -- [6]
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
) AS fch_cancelacion_cuota1, -- [7]
|
||||||
|
|
||||||
|
sgeca_matricula.cod_moneda, -- [8]
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT TOP 1 comp.imp_tc
|
||||||
|
FROM vtaca_comprobante comp
|
||||||
|
INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
|
||||||
|
AND comp.num_correlativo = cxc.num_correlativo
|
||||||
|
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
|
||||||
|
AND comp.cod_estado NOT IN ('ANU', 'ELI')
|
||||||
|
ORDER BY comp.fch_emision DESC
|
||||||
|
), 1) AS imp_tc, -- [9]
|
||||||
|
|
||||||
|
sgede_RP_programa.dsc_det_programa, -- [10] PROGRAMA (mismo num_indice)
|
||||||
|
sgede_RP_programa.fch_inicio, -- [11] FECHA INICIO (mismo num_indice)
|
||||||
|
sgeca_programa.dsc_programa, -- [12] PROGRAMA GENERAL (respaldo)
|
||||||
|
sgeca_matricula.cod_estado -- [13] ESTADO (ALU/PRE/RET)
|
||||||
|
|
||||||
|
FROM sgeca_matricula
|
||||||
|
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
|
||||||
|
LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
|
||||||
|
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
|
||||||
|
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
|
||||||
|
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||||
|
INNER JOIN vtama_localidad ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad
|
||||||
|
INNER JOIN vtama_tipo_documento ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento
|
||||||
|
WHERE
|
||||||
|
sgeca_matricula.cod_localidad LIKE 'SCENT'
|
||||||
|
AND (
|
||||||
|
sgeca_matricula.cod_estado IN ('ALU', 'PRE', 'RET')
|
||||||
|
OR sgeca_matricula.num_matricula IN {sql_in_clause}
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
(YEAR(sgeca_matricula.fch_matricula) = ? AND MONTH(sgeca_matricula.fch_matricula) = ?)
|
||||||
|
OR
|
||||||
|
sgeca_matricula.num_matricula IN {sql_in_clause}
|
||||||
|
OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sgede_cronograma_matricula crono
|
||||||
|
WHERE crono.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND crono.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND crono.num_cuota = 1
|
||||||
|
AND crono.fch_cancelacion IS NOT NULL
|
||||||
|
AND YEAR(crono.fch_cancelacion) = ?
|
||||||
|
AND MONTH(crono.fch_cancelacion) = ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
dsc_vendedor ASC
|
||||||
|
"""
|
||||||
|
|
||||||
|
def ejecutar_consulta_ventas(self, ano, mes, sede="TODOS", programa="TODOS"):
|
||||||
|
try:
|
||||||
|
key_mes = f"{int(mes):02d}-{ano}"
|
||||||
|
lista_raw = self.historico_pendientes.get(key_mes, [])
|
||||||
|
|
||||||
|
lista_vip_list = []
|
||||||
|
for item in lista_raw:
|
||||||
|
try: lista_vip_list.append(int(item))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Incluir matrículas con override de fecha (para que la query las traiga, incluso ANU)
|
||||||
|
_fov = getattr(self, '_fecha_canc_overrides', None) or {}
|
||||||
|
for mk in _fov.keys():
|
||||||
|
try: lista_vip_list.append(int(float(str(mk))))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
correcciones = self.config_data.get("correcciones_matriculas", {})
|
||||||
|
query_hibrido = self._get_query_ventas_hibrido(lista_vip_list)
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Clasificadores para filtros (mismos que Cobranza/Ocupabilidad)
|
||||||
|
_sede_sel = str(sede or "TODOS").upper()
|
||||||
|
_prog_sel = str(programa or "TODOS").upper()
|
||||||
|
def _clasif_sede(dscp):
|
||||||
|
if not dscp: return "LIMA"
|
||||||
|
up = str(dscp).upper()
|
||||||
|
sedes_data = self.sede_data.get("clasificacion_sedes", {})
|
||||||
|
for s, data in sedes_data.items():
|
||||||
|
if s == "DEFAULT": continue
|
||||||
|
for pat in data.get("patrones", []):
|
||||||
|
if pat.upper() in up: return s
|
||||||
|
_def = self.sede_data.get("clasificacion_default", {})
|
||||||
|
return _def.get("sede", "LIMA") if isinstance(_def, dict) else "LIMA"
|
||||||
|
def _clasif_prog(dscp):
|
||||||
|
# Clasifica el programa en UNA sola categoría, con prioridad de orden.
|
||||||
|
# Específicas primero (SEMINARIOS, TEAC, TERC); OTROS y el sobrante al final.
|
||||||
|
up = str(dscp or "").upper()
|
||||||
|
fdata = self.meta_data.get("clasificacion_filtro_programa", {})
|
||||||
|
orden = ["OTROS", "SEMINARIOS", "TEAC", "TERC"]
|
||||||
|
for cat in orden:
|
||||||
|
data = fdata.get(cat, {})
|
||||||
|
for pat in data.get("patrones", []):
|
||||||
|
if pat.upper() in up:
|
||||||
|
return cat
|
||||||
|
# Sobrante (no coincide con nada) → DEFAULT
|
||||||
|
return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS"
|
||||||
|
|
||||||
|
cursor.execute(query_hibrido, ano, mes, ano, mes)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
ventas_por_vendedor = {}
|
||||||
|
set_vip_actual = set(lista_vip_list)
|
||||||
|
|
||||||
|
vendedores_excluidos = {
|
||||||
|
"CASTILLO B. KARINA LISSET",
|
||||||
|
"CASTILLO B. KARINA LISSET",
|
||||||
|
"CALDERON S. LISSA GENA",
|
||||||
|
"CRUZ G. FIORELLA MELISSA",
|
||||||
|
"URIBE G. MARIA MERCEDES"
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
vendedor = row[0] if row[0] else "SIN VENDEDOR"
|
||||||
|
if vendedor in vendedores_excluidos: continue
|
||||||
|
|
||||||
|
# Filtro SEDE/PROGRAMA (mismas reglas que Ocupabilidad/Cobranza)
|
||||||
|
_dscp = row[10] if (len(row) > 10 and row[10]) else (row[12] if len(row) > 12 else "")
|
||||||
|
if _sede_sel != "TODOS" and _clasif_sede(_dscp) != _sede_sel:
|
||||||
|
continue
|
||||||
|
if _prog_sel != "TODOS" and _clasif_prog(_dscp) != _prog_sel:
|
||||||
|
continue
|
||||||
|
|
||||||
|
inv_neta_raw = float(row[1]) if row[1] else 0.0
|
||||||
|
cod_moneda = row[8]
|
||||||
|
imp_tc = float(row[9]) if row[9] else 1.0
|
||||||
|
|
||||||
|
saldo_mat = float(row[2]) if row[2] else 0.0
|
||||||
|
saldo_c1 = float(row[3]) if row[3] else 0.0
|
||||||
|
|
||||||
|
raw_mat = row[4]
|
||||||
|
row_year = row[5]
|
||||||
|
row_month = row[6]
|
||||||
|
fch_cancelacion_raw = row[7]
|
||||||
|
|
||||||
|
matricula_int = -1
|
||||||
|
matricula_str = ""
|
||||||
|
try:
|
||||||
|
if raw_mat is not None:
|
||||||
|
val_str = str(raw_mat).strip()
|
||||||
|
if val_str.endswith('.0'): val_str = val_str[:-2]
|
||||||
|
matricula_str = val_str
|
||||||
|
matricula_int = int(float(val_str))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if matricula_str in correcciones:
|
||||||
|
datos_corregidos = correcciones[matricula_str]
|
||||||
|
if "imp_tc" in datos_corregidos: imp_tc = float(datos_corregidos["imp_tc"])
|
||||||
|
if "imp_saldo_cuota1" in datos_corregidos: saldo_c1 = float(datos_corregidos["imp_saldo_cuota1"])
|
||||||
|
if "imp_saldo_matricula" in datos_corregidos: saldo_mat = float(datos_corregidos["imp_saldo_matricula"])
|
||||||
|
if "fch_cancelacion_cuota1" in datos_corregidos: fch_cancelacion_raw = datos_corregidos["fch_cancelacion_cuota1"]
|
||||||
|
|
||||||
|
# Override de FECHA CANCELACIÓN 1 desde Supabase (prioridad para clasificación)
|
||||||
|
_fov = getattr(self, '_fecha_canc_overrides', None)
|
||||||
|
_vacio_forzado = False
|
||||||
|
if _fov and matricula_str in _fov:
|
||||||
|
fov_val = _fov[matricula_str]
|
||||||
|
s = str(fov_val).strip()
|
||||||
|
if s == "__VACIO__":
|
||||||
|
# Fecha borrada a propósito → anula la del SQL (no cuenta como pagado)
|
||||||
|
fch_cancelacion_raw = None
|
||||||
|
_vacio_forzado = True
|
||||||
|
else:
|
||||||
|
# Normalizar dd/mm/yyyy o d/m/yyyy → yyyy-mm-dd
|
||||||
|
if '/' in s:
|
||||||
|
p = s.split('/')
|
||||||
|
if len(p) == 3:
|
||||||
|
s = f"{p[2]}-{int(p[1]):02d}-{int(p[0]):02d}"
|
||||||
|
fch_cancelacion_raw = s
|
||||||
|
|
||||||
|
# Prioridad TC: Supabase (mes) > corrección manual > SQL
|
||||||
|
_tc_ov = getattr(self, '_tc_override_mes', None)
|
||||||
|
if _tc_ov: imp_tc = float(_tc_ov)
|
||||||
|
|
||||||
|
if cod_moneda == 'DOL': inv_neta = inv_neta_raw * imp_tc
|
||||||
|
else: inv_neta = inv_neta_raw
|
||||||
|
|
||||||
|
# Override de INVERSIÓN NETA (Supabase): monto final en soles, reemplaza al SQL.
|
||||||
|
_inv_ov = getattr(self, '_inv_neta_overrides', None)
|
||||||
|
if _inv_ov and matricula_str in _inv_ov:
|
||||||
|
inv_neta = float(_inv_ov[matricula_str])
|
||||||
|
|
||||||
|
if vendedor not in ventas_por_vendedor:
|
||||||
|
ventas_por_vendedor[vendedor] = {
|
||||||
|
'monto': 0.0, 'cantidad': 0, 'monto_pc': 0.0, 'cantidad_pc': 0,
|
||||||
|
'monto_pendientes': 0.0, 'cantidad_pendientes': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes))
|
||||||
|
saldos_ok = (saldo_mat == 0 and saldo_c1 == 0)
|
||||||
|
|
||||||
|
# Si hay override de fecha de cancelación 1 (con fecha), se considera PAGADO.
|
||||||
|
# Si el override la vació a propósito, NO se fuerza pagado.
|
||||||
|
tiene_override_fecha = bool(_fov and matricula_str in _fov) and not _vacio_forzado
|
||||||
|
if tiene_override_fecha:
|
||||||
|
saldos_ok = True
|
||||||
|
|
||||||
|
# Si es RET y NO pagó (sin override ni saldos), NO entra en la tabla principal.
|
||||||
|
estado_row = str(row[13]).strip().upper() if len(row) > 13 and row[13] else ""
|
||||||
|
if estado_row == "RET" and not saldos_ok:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ¿Pagó la 1° cuota en el MES del filtro?
|
||||||
|
pago_en_fecha_correcta = False
|
||||||
|
if fch_cancelacion_raw:
|
||||||
|
try:
|
||||||
|
if isinstance(fch_cancelacion_raw, str):
|
||||||
|
f_obj = datetime.strptime(fch_cancelacion_raw[:10], '%Y-%m-%d')
|
||||||
|
f_ano, f_mes = f_obj.year, f_obj.month
|
||||||
|
else:
|
||||||
|
f_ano, f_mes = fch_cancelacion_raw.year, fch_cancelacion_raw.month
|
||||||
|
if str(f_ano) == str(ano) and str(f_mes) == str(mes):
|
||||||
|
pago_en_fecha_correcta = True
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# ¿La matrícula es de un mes ANTERIOR al filtro? (mismo año mes menor, o año anterior)
|
||||||
|
matricula_mes_pasado = False
|
||||||
|
try:
|
||||||
|
ym_mat = int(row_year) * 100 + int(row_month)
|
||||||
|
ym_filtro = int(ano) * 100 + int(mes)
|
||||||
|
matricula_mes_pasado = ym_mat < ym_filtro
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# COLUMNA 2 y 3 — MES EN CURSO: matrícula del mes + pagó en el mes
|
||||||
|
if es_venta_del_mes:
|
||||||
|
ventas_por_vendedor[vendedor]['monto'] += inv_neta
|
||||||
|
ventas_por_vendedor[vendedor]['cantidad'] += 1
|
||||||
|
if saldos_ok and pago_en_fecha_correcta:
|
||||||
|
ventas_por_vendedor[vendedor]['monto_pc'] += inv_neta
|
||||||
|
ventas_por_vendedor[vendedor]['cantidad_pc'] += 1
|
||||||
|
|
||||||
|
# COLUMNA 4 y 5 — MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro
|
||||||
|
if matricula_mes_pasado and saldos_ok and pago_en_fecha_correcta:
|
||||||
|
ventas_por_vendedor[vendedor]['monto_pendientes'] += inv_neta
|
||||||
|
ventas_por_vendedor[vendedor]['cantidad_pendientes'] += 1
|
||||||
|
|
||||||
|
datos_ventas = []
|
||||||
|
for vendedor, datos in ventas_por_vendedor.items():
|
||||||
|
datos_ventas.append({
|
||||||
|
'VENDEDOR': vendedor,
|
||||||
|
'MONTO': round(datos['monto'], 2),
|
||||||
|
'CANTIDAD': datos['cantidad'],
|
||||||
|
'VENTAS_PC': round(datos['monto_pc'], 2),
|
||||||
|
'INSCRITOS_PC': datos['cantidad_pc'],
|
||||||
|
'PENDIENTES': round(datos['monto_pendientes'], 2),
|
||||||
|
'INSCRITOS_PENDIENTES': datos['cantidad_pendientes']
|
||||||
|
})
|
||||||
|
|
||||||
|
datos_ventas.sort(key=lambda x: x['MONTO'], reverse=True)
|
||||||
|
conn.close()
|
||||||
|
return datos_ventas
|
||||||
|
except Exception as e:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def ejecutar_consulta_cronograma_cobranza(self, ano):
|
||||||
|
try:
|
||||||
|
if not self.query_cronograma_sql:
|
||||||
|
return []
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(self.query_cronograma_sql)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error cronograma cobranza: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def ejecutar_consulta_facturas_cobranza(self, ano):
|
||||||
|
try:
|
||||||
|
if not self.query_facturas_sql:
|
||||||
|
return []
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(self.query_facturas_sql)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error facturas cobranza: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def ejecutar_consulta_saldos_anual(self, ano):
|
||||||
|
try:
|
||||||
|
if not self.query_matriculas_sql:
|
||||||
|
return []
|
||||||
|
sql_anual = self.query_matriculas_sql.replace(
|
||||||
|
"AND MONTH(sgeca_matricula.fch_matricula) = ?", ""
|
||||||
|
)
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql_anual, ano)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error saldos anual: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# SUPABASE - OVERRIDES DE COSTOS
|
||||||
|
# =========================================================================
|
||||||
|
def cargar_overrides_costos(self):
|
||||||
|
if not self.supabase_client:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
response = self.supabase_client.table('costos_overrides').select('*').execute()
|
||||||
|
overrides = {}
|
||||||
|
for row in response.data:
|
||||||
|
idx = str(row.get('num_indice', '')).strip()
|
||||||
|
tipo = str(row.get('tipo_costo', '')).strip().lower()
|
||||||
|
if not idx or tipo not in ('inicial', 'actual'):
|
||||||
|
continue
|
||||||
|
if idx not in overrides:
|
||||||
|
overrides[idx] = {}
|
||||||
|
overrides[idx][tipo] = {
|
||||||
|
'epp': row.get('costo_epp'),
|
||||||
|
'certificado': row.get('costo_certificado'),
|
||||||
|
'docente': row.get('costo_docente'),
|
||||||
|
'consumibles': row.get('costo_consumibles'),
|
||||||
|
'marketing': row.get('costo_marketing'),
|
||||||
|
}
|
||||||
|
return overrides
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error overrides Supabase: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def obtener_fechas_originales(self):
|
||||||
|
"""Descarga todas las fechas originales guardadas en Supabase (inicios reprogramados)."""
|
||||||
|
if not self.supabase_client:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
respuesta = self.supabase_client.table('fechas_originales_cursos').select('*').execute()
|
||||||
|
datos = respuesta.data
|
||||||
|
diccionario_fechas = {}
|
||||||
|
for fila in datos:
|
||||||
|
indice = str(fila.get('num_indice'))
|
||||||
|
fecha = fila.get('fecha_inicio_original')
|
||||||
|
programa = fila.get('programa', 'CURSO REPROGRAMADO')
|
||||||
|
diccionario_fechas[indice] = {'fecha': fecha, 'programa': programa}
|
||||||
|
return diccionario_fechas
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error al cargar fechas originales: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def guardar_fecha_original(self, num_indice, programa, fecha_inicio):
|
||||||
|
"""Guarda silenciosamente un nuevo curso y su fecha inicial en Supabase."""
|
||||||
|
if not self.supabase_client:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
'num_indice': str(num_indice),
|
||||||
|
'programa': str(programa),
|
||||||
|
'fecha_inicio_original': str(fecha_inicio)
|
||||||
|
}
|
||||||
|
self.supabase_client.table('fechas_originales_cursos').upsert(payload).execute()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error al guardar fecha original: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def guardar_override_costo(self, num_indice, tipo_costo, costos_dict):
|
||||||
|
if not self.supabase_client:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
'num_indice': str(num_indice),
|
||||||
|
'tipo_costo': tipo_costo,
|
||||||
|
'costo_epp': costos_dict.get('epp'),
|
||||||
|
'costo_certificado': costos_dict.get('certificado'),
|
||||||
|
'costo_docente': costos_dict.get('docente'),
|
||||||
|
'costo_consumibles': costos_dict.get('consumibles'),
|
||||||
|
'costo_marketing': costos_dict.get('marketing'),
|
||||||
|
'fecha_actualizacion': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
self.supabase_client.table('costos_overrides').upsert(payload).execute()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error guardar override: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def eliminar_overrides_costo(self, num_indice):
|
||||||
|
if not self.supabase_client:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.supabase_client.table('costos_overrides').delete().eq(
|
||||||
|
'num_indice', str(num_indice)
|
||||||
|
).execute()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error eliminar overrides: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# HELPERS
|
||||||
|
# =========================================================================
|
||||||
|
def get_current_year(self):
|
||||||
|
return datetime.now().year
|
||||||
|
|
||||||
|
def get_current_month(self):
|
||||||
|
return datetime.now().month
|
||||||
410
backend/backend/main.py
Normal file
410
backend/backend/main.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
# backend/main.py
|
||||||
|
"""
|
||||||
|
API REST del Dashboard — FastAPI.
|
||||||
|
Reutiliza la lógica de negocio existente en modules/ y core/.
|
||||||
|
Cada endpoint devuelve JSON; el frontend (React) lo consume.
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI, Query, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from datetime import datetime
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
import services
|
||||||
|
from cache_manager import start_background_refresh, cache_invalidate, cache_stats
|
||||||
|
|
||||||
|
app = FastAPI(title="Dashboard API", version="1.0")
|
||||||
|
|
||||||
|
# CORS — permite que el frontend React (otro puerto) consuma la API
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # en red local está bien; en producción restringir
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Arranque: precarga + refresco en segundo plano ─────────────────────────
|
||||||
|
@app.on_event("startup")
|
||||||
|
def _startup():
|
||||||
|
print("[startup] Precargando datos iniciales...")
|
||||||
|
try:
|
||||||
|
services.precargar_todo()
|
||||||
|
print("[startup] Precarga completa.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] Precarga falló: {e}")
|
||||||
|
# Refresco cada 4 min en segundo plano (TTL del caché es 5 min)
|
||||||
|
start_background_refresh(services.refrescar_todo, interval=900)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Salud / utilidades ─────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "hora": datetime.now().isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cache/stats")
|
||||||
|
def cache_estadisticas():
|
||||||
|
return cache_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cache/refresh")
|
||||||
|
def cache_refresh():
|
||||||
|
services.refrescar_todo()
|
||||||
|
return {"status": "refrescado"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/periodo-actual")
|
||||||
|
def periodo_actual():
|
||||||
|
dm = services.get_dm()
|
||||||
|
return {"ano": dm.get_current_year(), "mes": dm.get_current_month()}
|
||||||
|
|
||||||
|
|
||||||
|
# ── OCUPABILIDAD ───────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/ocupabilidad")
|
||||||
|
def get_ocupabilidad(
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
sede: str = Query("TODOS"),
|
||||||
|
programa: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.ocupabilidad(ano, mes, sede, programa)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── VENTAS ─────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/ventas")
|
||||||
|
def get_ventas(ano: int = Query(...), mes: int = Query(...),
|
||||||
|
sede: str = Query("TODOS"), programa: str = Query("TODOS")):
|
||||||
|
try:
|
||||||
|
return services.ventas(ano, mes, sede, programa)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/ventas/detalle")
|
||||||
|
def get_ventas_detalle(
|
||||||
|
vendedor: str = Query(...),
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
tipo: str = Query("Venta Inscritos"),
|
||||||
|
sede: str = Query("TODOS"),
|
||||||
|
programa: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.ventas_detalle(vendedor, ano, mes, tipo, sede, programa)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── COBRANZA ───────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/cobranza")
|
||||||
|
def get_cobranza(
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
sectorista: str = Query("TODOS"),
|
||||||
|
agrupacion: str = Query("SEDE"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.cobranza(ano, mes, sectorista, agrupacion)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cobranza/detalle")
|
||||||
|
def get_cobranza_detalle(
|
||||||
|
grupo: str = Query(...),
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
sectorista: str = Query("TODOS"),
|
||||||
|
agrupacion: str = Query("PROGRAMA"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.cobranza_detalle(grupo, ano, mes, sectorista, agrupacion)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── RENTABILIDAD ───────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/rentabilidad")
|
||||||
|
def get_rentabilidad(
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
sede: str = Query("TODOS"),
|
||||||
|
programa: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.rentabilidad(ano, mes, sede, programa)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/rentabilidad/detalle")
|
||||||
|
def get_rentabilidad_detalle(
|
||||||
|
programa: str = Query(...),
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.rentabilidad_detalle(programa, ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/rentabilidad/costos")
|
||||||
|
def get_rentabilidad_costos(
|
||||||
|
programa: str = Query(...),
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.rentabilidad_costos(programa, ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── SALDO PENDIENTE ────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/saldo-pendiente")
|
||||||
|
def get_saldo_pendiente(tipo_cuota: str = Query("1° Cuota")):
|
||||||
|
try:
|
||||||
|
return services.saldo_pendiente(tipo_cuota)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── GUARDAR COSTOS ─────────────────────────────────────────────────────────
|
||||||
|
from fastapi import Body
|
||||||
|
|
||||||
|
@app.post("/api/rentabilidad/costos/guardar")
|
||||||
|
def post_guardar_costos(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
num_indice = payload.get("num_indice", "")
|
||||||
|
costos_inicial = payload.get("inicial", {})
|
||||||
|
costos_actual = payload.get("actual", {})
|
||||||
|
ok = services.guardar_costos(num_indice, costos_inicial, costos_actual)
|
||||||
|
if ok:
|
||||||
|
return {"status": "ok"}
|
||||||
|
raise HTTPException(status_code=500, detail="No se pudo guardar (verifica Supabase)")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cobranza/clasificar-sede")
|
||||||
|
def post_clasificar_sede(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
nombres = payload.get("programas", [])
|
||||||
|
return {"mapa": services.clasificar_programas(nombres)}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/debug/supabase-inicios")
|
||||||
|
def debug_supabase_inicios(ano: int, mes: int):
|
||||||
|
"""Diagnóstico: muestra qué hay en Supabase y por qué (no) se inyecta el rojo."""
|
||||||
|
try:
|
||||||
|
dm = services.get_dm()
|
||||||
|
# 1) ¿Supabase conectado?
|
||||||
|
conectado = dm.supabase_client is not None
|
||||||
|
error_conexion = getattr(dm, "supabase_error", None)
|
||||||
|
# 2) Fechas guardadas en Supabase
|
||||||
|
fechas = dm.obtener_fechas_originales()
|
||||||
|
# 3) Índices que SQL trae este mes
|
||||||
|
crudos = dm.ejecutar_consulta_cursos(str(ano), str(mes))
|
||||||
|
indices_sql = sorted({str(f.get("num_indice","")).strip() for f in (crudos or [])})
|
||||||
|
# 4) Para cada fecha en Supabase, calcular su mes y si coincide con el filtrado
|
||||||
|
analisis = []
|
||||||
|
for idx, info in fechas.items():
|
||||||
|
fecha_orig = info.get("fecha","") if isinstance(info, dict) else str(info)
|
||||||
|
mes_orig = ""
|
||||||
|
if "/" in fecha_orig:
|
||||||
|
p = fecha_orig.split("/")
|
||||||
|
if len(p) >= 2: mes_orig = str(int(p[1]))
|
||||||
|
elif "-" in fecha_orig:
|
||||||
|
p = fecha_orig.split("-")
|
||||||
|
if len(p) >= 2: mes_orig = str(int(p[1]))
|
||||||
|
analisis.append({
|
||||||
|
"num_indice": idx,
|
||||||
|
"fecha_supabase": fecha_orig,
|
||||||
|
"mes_detectado": mes_orig,
|
||||||
|
"mes_filtrado": str(int(mes)),
|
||||||
|
"coincide_mes": mes_orig == str(int(mes)),
|
||||||
|
"esta_en_sql_este_mes": idx in indices_sql,
|
||||||
|
"deberia_salir_rojo": (mes_orig == str(int(mes))) and (idx not in indices_sql),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"supabase_conectado": conectado,
|
||||||
|
"error_conexion": error_conexion,
|
||||||
|
"total_fechas_en_supabase": len(fechas),
|
||||||
|
"indices_en_sql_este_mes": indices_sql,
|
||||||
|
"analisis": analisis,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cobranza/detalle-todos")
|
||||||
|
def get_cobranza_detalle_todos(ano: int, mes: int, sectorista: str = "TODOS"):
|
||||||
|
try:
|
||||||
|
return services.cobranza_detalle_todos(ano, mes, sectorista)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── USUARIOS ───────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/usuarios")
|
||||||
|
def get_usuarios():
|
||||||
|
try:
|
||||||
|
return services.usuarios_listar()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/usuarios/crear")
|
||||||
|
def post_usuario_crear(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.usuarios_crear(
|
||||||
|
payload.get("email",""), payload.get("password",""),
|
||||||
|
payload.get("nombre",""), payload.get("rol","COBRANZA"))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/usuarios/rol")
|
||||||
|
def post_usuario_rol(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.usuarios_actualizar_rol(payload.get("id",""), payload.get("rol",""))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/usuarios/eliminar")
|
||||||
|
def post_usuario_eliminar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.usuarios_eliminar(payload.get("id",""))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── VENDEDORES MANUALES (Comisiones) ───────────────────────────────────────
|
||||||
|
@app.get("/api/comisiones/vendedores")
|
||||||
|
def get_vend_manuales(ano: int, mes: int):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_listar(ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/vendedores/crear")
|
||||||
|
def post_vend_manual_crear(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_crear(
|
||||||
|
payload.get("nombre",""), payload.get("descripcion",""),
|
||||||
|
payload.get("fch_emision",""), payload.get("monto",0),
|
||||||
|
payload.get("ano"), payload.get("mes"))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/vendedores/eliminar")
|
||||||
|
def post_vend_manual_eliminar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_eliminar(payload.get("id",""))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/override/guardar")
|
||||||
|
def post_com_override_guardar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.comisiones_override_guardar(payload.get("registros", []))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/override/restaurar")
|
||||||
|
def post_com_override_restaurar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.comisiones_override_restaurar(payload.get("num_matricula",""))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/vendedores/actualizar")
|
||||||
|
def post_vend_manual_actualizar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_actualizar(
|
||||||
|
payload.get("id",""), payload.get("descripcion",""),
|
||||||
|
payload.get("fch_emision",""), payload.get("monto",0))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/vendedores/lote")
|
||||||
|
def post_vend_manual_lote(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_guardar_lote(
|
||||||
|
payload.get("nombre",""), payload.get("ano"), payload.get("mes"),
|
||||||
|
payload.get("filas", []))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/comisiones/detalle-todos")
|
||||||
|
def get_comisiones_detalle_todos(ano: int, mes: int):
|
||||||
|
try:
|
||||||
|
return services.comisiones_detalle_todos(ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/comisiones/config")
|
||||||
|
def get_comisiones_config(ano: int = Query(...), mes: int = Query(...)):
|
||||||
|
try:
|
||||||
|
return services.comisiones_config_listar(ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/config/guardar")
|
||||||
|
def post_comisiones_config(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.comisiones_config_guardar(payload.get("ano"), payload.get("mes"), payload.get("filas", []))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── ASESORES (Chatwoot) ────────────────────────────────────────────────────
|
||||||
|
CHATWOOT_URL = "https://gestor.escueladerefrigeracion.edu.pe"
|
||||||
|
ACCESS_TOKEN = "4anazHvZnvKLtup8biu5Zuoh"
|
||||||
|
ACCOUNT_ID = "1"
|
||||||
|
CHATWOOT_HEADERS = {"api_access_token": ACCESS_TOKEN, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/asesores")
|
||||||
|
def get_asesores():
|
||||||
|
import requests
|
||||||
|
try:
|
||||||
|
url = f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents"
|
||||||
|
r = requests.get(url, headers=CHATWOOT_HEADERS, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
return {"agentes": r.json()}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/asesores/estado")
|
||||||
|
def set_asesor_estado(agent_id: int = Query(...), online: bool = Query(...)):
|
||||||
|
import requests, time
|
||||||
|
url = f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents/{agent_id}"
|
||||||
|
estado = "online" if online else "offline"
|
||||||
|
try:
|
||||||
|
requests.patch(url, headers=CHATWOOT_HEADERS, json={"auto_offline": False}, timeout=10)
|
||||||
|
time.sleep(0.5)
|
||||||
|
r = requests.patch(url, headers=CHATWOOT_HEADERS,
|
||||||
|
json={"availability_status": estado, "availability": estado}, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
return {"status": "ok", "estado": estado}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
|
||||||
0
backend/backend/modules/__init__.py
Normal file
0
backend/backend/modules/__init__.py
Normal file
0
backend/backend/modules/asesores/__init__.py
Normal file
0
backend/backend/modules/asesores/__init__.py
Normal file
86
backend/backend/modules/asesores/processor.py
Normal file
86
backend/backend/modules/asesores/processor.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# modules/asesores/processor.py
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
class AsesoresProcessor:
|
||||||
|
def __init__(self):
|
||||||
|
# Tus credenciales maestras de Chatwoot
|
||||||
|
self.chatwoot_url = "https://gestor.escueladerefrigeracion.edu.pe"
|
||||||
|
self.access_token = "4anazHvZnvKLtup8biu5Zuoh"
|
||||||
|
self.account_id = "1"
|
||||||
|
|
||||||
|
def obtener_agentes_chatwoot(self):
|
||||||
|
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents"
|
||||||
|
headers = {
|
||||||
|
"api_access_token": self.access_token,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
agentes = response.json()
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🕵️♂️ ESCÁNER DEBUG (Se imprimirá en tu consola negra)
|
||||||
|
# =================================================================
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🕵️♂️ ESCÁNER: REVISANDO 'DESCONEXIÓN AUTOMÁTICA'")
|
||||||
|
print("="*60)
|
||||||
|
for ag in agentes:
|
||||||
|
nombre = ag.get('available_name') or ag.get('name', 'Desconocido')
|
||||||
|
estado = ag.get('availability_status', 'offline')
|
||||||
|
auto_offline = ag.get('auto_offline', 'Desconocido')
|
||||||
|
print(f"👤 {nombre} | Estado: {estado} | Radar: {auto_offline}")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
return agentes
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# MOTOR DE ACTUALIZACIÓN (EL COMBO DE 2 GOLPES)
|
||||||
|
# =========================================================================
|
||||||
|
def cambiar_estado_agente(self, agent_id, is_online):
|
||||||
|
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents/{agent_id}"
|
||||||
|
headers = {
|
||||||
|
"api_access_token": self.access_token,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
estado_str = "online" if is_online else "offline"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# GOLPE 1: Desactivar el botón automático primero
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
payload_radar = {
|
||||||
|
"auto_offline": False
|
||||||
|
}
|
||||||
|
requests.patch(url, headers=headers, json=payload_radar, timeout=10)
|
||||||
|
|
||||||
|
# Le damos 0.5 segundos a la base de datos de Chatwoot para que
|
||||||
|
# asimile que el radar de este usuario acaba de ser destruido.
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# GOLPE 2: El código viejo y confiable para cambiar el estado
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
payload_estado = {
|
||||||
|
"availability_status": estado_str,
|
||||||
|
"availability": estado_str
|
||||||
|
}
|
||||||
|
response = requests.patch(url, headers=headers, json=payload_estado, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 500:
|
||||||
|
return False, "Error interno del servidor Chatwoot."
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# ⏳ Le damos 1.5 segundos para que le avise a la computadora
|
||||||
|
# del asesor que su estado acaba de cambiar en pantalla.
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
return True, "Orden ejecutada con el combo de 2 pasos"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Fallo de red: {str(e)}"
|
||||||
0
backend/backend/modules/cobranza/__init__.py
Normal file
0
backend/backend/modules/cobranza/__init__.py
Normal file
221
backend/backend/modules/cobranza/logic.py
Normal file
221
backend/backend/modules/cobranza/logic.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# modules/cobranza/logic.py
|
||||||
|
import pandas as pd
|
||||||
|
from .processor import CobranzaProcessor
|
||||||
|
|
||||||
|
class CobranzaLogic:
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.processor = CobranzaProcessor(data_manager)
|
||||||
|
|
||||||
|
def obtener_lista_sectoristas(self, ano, mes):
|
||||||
|
return self.processor.obtener_lista_sectoristas(ano, mes)
|
||||||
|
|
||||||
|
def obtener_datos_tabla(self, ano, mes, sectorista="TODOS", agrupacion="SEDE"):
|
||||||
|
datos_brutos = self.processor.obtener_datos_procesados(ano, mes, sectorista, agrupacion)
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0
|
||||||
|
|
||||||
|
for d in datos_brutos:
|
||||||
|
grupo = d.get('GRUPO', '')
|
||||||
|
frecuencia = d.get('FRECUENCIA', '-')
|
||||||
|
num_cuota = d.get('NUM_CUOTA', '-')
|
||||||
|
fecha_venc = d.get('FCH_VENC_MODA', '-')
|
||||||
|
|
||||||
|
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
|
||||||
|
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
|
||||||
|
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
|
||||||
|
cob_ant = float(d.get('COB_ANT', 0.0))
|
||||||
|
r1 = float(d.get('RATIO_1', 0.0))
|
||||||
|
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
|
||||||
|
r2 = float(d.get('RATIO_2', 0.0))
|
||||||
|
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
|
||||||
|
r3 = float(d.get('RATIO_3', 0.0))
|
||||||
|
saldo = float(d.get('SALDO', 0.0))
|
||||||
|
opciones = d.get('OPCIONES', ' ≡ ▼ ')
|
||||||
|
|
||||||
|
t_cta_ant += cta_ant
|
||||||
|
t_cta_cur += cta_cur
|
||||||
|
t_cta_tot += cta_tot
|
||||||
|
t_cob_ant += cob_ant
|
||||||
|
t_cob_cur += cob_cur
|
||||||
|
t_cob_tot += cob_tot
|
||||||
|
t_saldo += saldo
|
||||||
|
|
||||||
|
avance_ant = f"{r1:.0f}%" if cta_ant > 0 else ""
|
||||||
|
avance_cur = f"{r2:.0f}%" if cta_cur > 0 else ""
|
||||||
|
avance_tot = f"{r3:.0f}%" if cta_tot > 0 else ""
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
fila = [
|
||||||
|
grupo, frecuencia, num_cuota, fecha_venc,
|
||||||
|
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
|
||||||
|
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
|
||||||
|
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
|
||||||
|
f"S/ {saldo:,.0f}", opciones
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
fila = [
|
||||||
|
grupo,
|
||||||
|
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
|
||||||
|
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
|
||||||
|
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
|
||||||
|
f"S/ {saldo:,.0f}", opciones
|
||||||
|
]
|
||||||
|
sheet_data.append(fila)
|
||||||
|
|
||||||
|
r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0
|
||||||
|
r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0
|
||||||
|
r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
fila_total = [
|
||||||
|
"TOTAL GENERAL", "-", "-", "-",
|
||||||
|
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
|
||||||
|
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
|
||||||
|
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
|
||||||
|
f"S/ {t_saldo:,.0f}", ""
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
fila_total = [
|
||||||
|
"TOTAL GENERAL",
|
||||||
|
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
|
||||||
|
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
|
||||||
|
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
|
||||||
|
f"S/ {t_saldo:,.0f}", ""
|
||||||
|
]
|
||||||
|
sheet_data.append(fila_total)
|
||||||
|
return sheet_data
|
||||||
|
|
||||||
|
def obtener_detalle_programa_formateado(self, ano, mes, sectorista, programa):
|
||||||
|
datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, programa)
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0
|
||||||
|
|
||||||
|
for d in datos_brutos:
|
||||||
|
mat = d.get('MATRICULA', '')
|
||||||
|
alumno = d.get('ALUMNO', '')
|
||||||
|
num_cuota = d.get('NUM_CUOTA', '-')
|
||||||
|
fch_venc = d.get('FCH_VENC', '-')
|
||||||
|
|
||||||
|
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
|
||||||
|
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
|
||||||
|
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
|
||||||
|
cob_ant = float(d.get('COB_ANT', 0.0))
|
||||||
|
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
|
||||||
|
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
|
||||||
|
saldo = float(d.get('SALDO', 0.0))
|
||||||
|
|
||||||
|
t_cta_ant += cta_ant; t_cta_cur += cta_cur; t_cta_tot += cta_tot
|
||||||
|
t_cob_ant += cob_ant; t_cob_cur += cob_cur; t_cob_tot += cob_tot; t_saldo += saldo
|
||||||
|
|
||||||
|
r1 = (cob_ant / cta_ant) * 100 if cta_ant > 0 else 0.0
|
||||||
|
r2 = (cob_cur / cta_cur) * 100 if cta_cur > 0 else 0.0
|
||||||
|
r3 = (cob_tot / cta_tot) * 100 if cta_tot > 0 else 0.0
|
||||||
|
|
||||||
|
avance_ant = f"{r1:.0f}%" if cta_ant > 0 else ""
|
||||||
|
avance_cur = f"{r2:.0f}%" if cta_cur > 0 else ""
|
||||||
|
avance_tot = f"{r3:.0f}%" if cta_tot > 0 else ""
|
||||||
|
|
||||||
|
fila = [
|
||||||
|
mat, alumno, num_cuota, fch_venc,
|
||||||
|
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
|
||||||
|
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
|
||||||
|
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
|
||||||
|
f"S/ {saldo:,.0f}"
|
||||||
|
]
|
||||||
|
sheet_data.append(fila)
|
||||||
|
|
||||||
|
r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0
|
||||||
|
r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0
|
||||||
|
r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0
|
||||||
|
|
||||||
|
fila_total = [
|
||||||
|
"TOTAL", "GENERAL", "-", "-",
|
||||||
|
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
|
||||||
|
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
|
||||||
|
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
|
||||||
|
f"S/ {t_saldo:,.0f}"
|
||||||
|
]
|
||||||
|
sheet_data.append(fila_total)
|
||||||
|
return sheet_data
|
||||||
|
|
||||||
|
def exportar_detalle_excel(self, programa, headers, datos):
|
||||||
|
if not datos: raise ValueError("No hay datos para exportar")
|
||||||
|
|
||||||
|
prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:30]
|
||||||
|
archivo = f"Detalle_Cobranza_{prog_limpio}.xlsx"
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos, columns=[h.replace('\n', ' ') for h in headers])
|
||||||
|
try:
|
||||||
|
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='Detalle')
|
||||||
|
except:
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def exportar_reporte_global(self, ano, mes, sectorista):
|
||||||
|
datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, "TODOS")
|
||||||
|
if not datos_brutos: raise ValueError("No hay datos para exportar en este mes.")
|
||||||
|
|
||||||
|
filas_excel = []
|
||||||
|
for d in datos_brutos:
|
||||||
|
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
|
||||||
|
cob_ant = float(d.get('COB_ANT', 0.0))
|
||||||
|
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
|
||||||
|
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
|
||||||
|
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
|
||||||
|
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
|
||||||
|
|
||||||
|
filas_excel.append({
|
||||||
|
"ALUMNO": d.get('ALUMNO', ''),
|
||||||
|
"PROGRAMA": d.get('PROGRAMA', '-'),
|
||||||
|
"FRECUENCIA": d.get('FRECUENCIA', '-'),
|
||||||
|
"NUM CUOTA": d.get('NUM_CUOTA', '-'),
|
||||||
|
"FCH VENCIMIENTO": d.get('FCH_VENC', '-'),
|
||||||
|
"CUENTA PENDIENTE": cta_ant,
|
||||||
|
"COBRADO PENDIENTE": cob_ant,
|
||||||
|
"AVANCE % ": (cob_ant / cta_ant) if cta_ant > 0 else 0.0,
|
||||||
|
"CUENTA EN CURSO": cta_cur,
|
||||||
|
"COBRADO EN CURSO": cob_cur,
|
||||||
|
"AVANCE % ": (cob_cur / cta_cur) if cta_cur > 0 else 0.0,
|
||||||
|
"TOTAL CUENTA": cta_tot,
|
||||||
|
"TOTAL COBRADO": cob_tot,
|
||||||
|
"AVANCE %": (cob_tot / cta_tot) if cta_tot > 0 else 0.0,
|
||||||
|
"SALDO": float(d.get('SALDO', 0.0))
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(filas_excel)
|
||||||
|
archivo = f"Reporte_Cobranza_Global_{ano}_{mes}.xlsx"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='Reporte Global')
|
||||||
|
worksheet = writer.sheets['Reporte Global']
|
||||||
|
from openpyxl.styles import PatternFill, Font, Alignment
|
||||||
|
|
||||||
|
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
|
||||||
|
font_header = Font(color="FFFFFF", bold=True)
|
||||||
|
for cell in worksheet[1]:
|
||||||
|
cell.fill = fill_header
|
||||||
|
cell.font = font_header
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
|
||||||
|
for row in range(2, len(filas_excel) + 2):
|
||||||
|
for col in [6, 7, 9, 10, 12, 13, 15]:
|
||||||
|
worksheet.cell(row=row, column=col).number_format = '"S/" #,##0.00'
|
||||||
|
for col in [8, 11, 14]:
|
||||||
|
worksheet.cell(row=row, column=col).number_format = '0%'
|
||||||
|
|
||||||
|
for col in worksheet.columns:
|
||||||
|
max_len = 0
|
||||||
|
col_letter = col[0].column_letter
|
||||||
|
for cell in col:
|
||||||
|
if cell.value:
|
||||||
|
max_len = max(max_len, len(str(cell.value)))
|
||||||
|
worksheet.column_dimensions[col_letter].width = min(max_len + 2, 40)
|
||||||
|
except Exception:
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
|
||||||
|
return archivo
|
||||||
734
backend/backend/modules/cobranza/processor.py
Normal file
734
backend/backend/modules/cobranza/processor.py
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
# modules/cobranza/processor.py
|
||||||
|
from datetime import datetime
|
||||||
|
import requests
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
class CobranzaProcessor:
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.config_sedes = self._cargar_json_sedes()
|
||||||
|
# ── Caché de consultas SQL ────────────────────────────────────────
|
||||||
|
self._cache_cronograma = None
|
||||||
|
self._cache_facturas = None
|
||||||
|
self._cache_ano = None
|
||||||
|
|
||||||
|
def _obtener_datos_cached(self, ano):
|
||||||
|
"""Devuelve cronograma y facturas usando caché si el año coincide."""
|
||||||
|
if self._cache_ano == ano and self._cache_cronograma is not None:
|
||||||
|
print(f"[CACHE] Reutilizando datos en caché para año {ano}")
|
||||||
|
return self._cache_cronograma, self._cache_facturas
|
||||||
|
print(f"[CACHE] Consultando BD para año {ano}...")
|
||||||
|
cronograma = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano)
|
||||||
|
facturas = self.data_manager.ejecutar_consulta_facturas_cobranza(ano)
|
||||||
|
self._cache_cronograma = cronograma
|
||||||
|
self._cache_facturas = facturas
|
||||||
|
self._cache_ano = ano
|
||||||
|
return cronograma, facturas
|
||||||
|
|
||||||
|
def invalidar_cache(self):
|
||||||
|
"""Fuerza recarga desde BD en la siguiente consulta."""
|
||||||
|
self._cache_cronograma = None
|
||||||
|
self._cache_facturas = None
|
||||||
|
self._cache_ano = None
|
||||||
|
print("🧹 [CACHE] Memoria limpiada por actualización automática. La próxima consulta irá directo a la BD.")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CARGA DE CONFIGURACIÓN (sede.json)
|
||||||
|
# =========================================================================
|
||||||
|
def _cargar_json_sedes(self):
|
||||||
|
datos = None
|
||||||
|
try:
|
||||||
|
url = getattr(self.data_manager, 'github_sede_url', None)
|
||||||
|
if url:
|
||||||
|
response = requests.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
datos = response.json()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if datos is None:
|
||||||
|
datos = {
|
||||||
|
"clasificacion_sedes": {
|
||||||
|
"AREQUIPA": {"patrones": ["AREQUIPA"]},
|
||||||
|
"PIURA": {"patrones": ["PIURA"]},
|
||||||
|
"TRUJILLO": {"patrones": ["TRUJILLO"]}
|
||||||
|
},
|
||||||
|
"clasificacion_default": {"sede": "LIMA"}
|
||||||
|
}
|
||||||
|
|
||||||
|
self._reemplazos_nombre = datos.get("reemplazos_nombre", [])
|
||||||
|
return datos
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# REEMPLAZOS DE NOMBRE DE PROGRAMA
|
||||||
|
# =========================================================================
|
||||||
|
@staticmethod
|
||||||
|
def _normalizar(texto):
|
||||||
|
texto = unicodedata.normalize('NFD', str(texto))
|
||||||
|
return texto.encode('ascii', 'ignore').decode('utf-8')
|
||||||
|
|
||||||
|
def _aplicar_reemplazos_nombre(self, texto):
|
||||||
|
if not texto or not self._reemplazos_nombre:
|
||||||
|
return texto
|
||||||
|
|
||||||
|
resultado = texto
|
||||||
|
for entrada in self._reemplazos_nombre:
|
||||||
|
buscar = str(entrada.get("buscar", "")).strip()
|
||||||
|
reemplazar = str(entrada.get("reemplazar", "")).strip()
|
||||||
|
if not buscar:
|
||||||
|
continue
|
||||||
|
|
||||||
|
buscar_norm = self._normalizar(buscar).upper()
|
||||||
|
idx = self._normalizar(resultado).upper().find(buscar_norm)
|
||||||
|
while idx != -1:
|
||||||
|
resultado = resultado[:idx] + reemplazar + resultado[idx + len(buscar):]
|
||||||
|
idx = self._normalizar(resultado).upper().find(buscar_norm, idx + len(reemplazar))
|
||||||
|
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CLASIFICACIÓN DE SEDE
|
||||||
|
# =========================================================================
|
||||||
|
def clasificar_sede(self, dsc_programa):
|
||||||
|
if not dsc_programa:
|
||||||
|
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
|
||||||
|
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
sedes = self.config_sedes.get("clasificacion_sedes", {})
|
||||||
|
|
||||||
|
for nombre_sede, datos in sedes.items():
|
||||||
|
for patron in datos.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return nombre_sede
|
||||||
|
|
||||||
|
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# UTILIDADES
|
||||||
|
# =========================================================================
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-':
|
||||||
|
return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def obtener_fechas_filtro(self, ano, mes_numero):
|
||||||
|
ano_int = int(ano)
|
||||||
|
mes_int = int(mes_numero)
|
||||||
|
fecha_filtro = datetime(ano_int, mes_int, 1)
|
||||||
|
if mes_int == 12:
|
||||||
|
fecha_filtro_siguiente = datetime(ano_int + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
fecha_filtro_siguiente = datetime(ano_int, mes_int + 1, 1)
|
||||||
|
return fecha_filtro, fecha_filtro_siguiente
|
||||||
|
|
||||||
|
def calcular_importe_final(self, fila):
|
||||||
|
try: imp_total = float(fila.get('imp_total', 0) or 0)
|
||||||
|
except: imp_total = 0.0
|
||||||
|
try: imp_dscto = float(fila.get('imp_dscto', 0) or 0)
|
||||||
|
except: imp_dscto = 0.0
|
||||||
|
return imp_total - imp_dscto
|
||||||
|
|
||||||
|
def evaluar_ctaxcob_suma(self, fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats):
|
||||||
|
sectorista = str(fila.get('dsc_sectorista', ''))
|
||||||
|
if sectorista == " , " or sectorista.strip() == ",":
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
try: num_cuota = int(fila.get('num_cuota', 0))
|
||||||
|
except: num_cuota = 0
|
||||||
|
if not (num_cuota > 1):
|
||||||
|
debug_stats['falla_cuota'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
fch_vencimiento = self.parse_fecha(fila.get('fch_vencimiento'))
|
||||||
|
if not fch_vencimiento:
|
||||||
|
debug_stats['falla_vencimiento'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
es_anterior = fch_vencimiento < fecha_filtro
|
||||||
|
es_curso = (fch_vencimiento >= fecha_filtro) and (fch_vencimiento < fecha_filtro_siguiente)
|
||||||
|
|
||||||
|
if not (es_anterior or es_curso):
|
||||||
|
debug_stats['falla_vencimiento'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
fch_inicio = self.parse_fecha(fila.get('fch_inicio'))
|
||||||
|
if not fch_inicio or not (fch_inicio <= hoy):
|
||||||
|
debug_stats['falla_inicio'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
estado = str(fila.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado in ["ALU", "PRE"]:
|
||||||
|
pass
|
||||||
|
elif estado == "RET":
|
||||||
|
fch_retiro = self.parse_fecha(fila.get('fch_retiro'))
|
||||||
|
if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente):
|
||||||
|
debug_stats['falla_estado'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
else:
|
||||||
|
debug_stats['falla_estado'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
importe_convertido = importe_final
|
||||||
|
moneda = str(fila.get('cod_moneda', '')).strip().upper()
|
||||||
|
if moneda == 'DOL':
|
||||||
|
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||||
|
except: tc = 1.0
|
||||||
|
importe_convertido *= tc
|
||||||
|
|
||||||
|
debug_stats['pasa_todo'] += 1
|
||||||
|
|
||||||
|
if es_anterior: return importe_convertido, 0.0
|
||||||
|
if es_curso: return 0.0, importe_convertido
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
def evaluar_ctaxcob_resta(self, fila_factura, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats, tc_correcto):
|
||||||
|
sectorista = str(fila_factura.get('dsc_sectorista', ''))
|
||||||
|
if sectorista == " , " or sectorista.strip() == ",":
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
try: num_cuota = int(fila_factura.get('num_cuota', 0))
|
||||||
|
except: num_cuota = 0
|
||||||
|
if not (num_cuota > 1):
|
||||||
|
debug_stats['falla_cuota'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
cod_estado = str(fila_factura.get('cod_estado', '')).strip().upper()
|
||||||
|
if cod_estado != "CAN":
|
||||||
|
debug_stats['falla_estado_can'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
flg_nc = str(fila_factura.get('flg_nc', '')).strip().upper()
|
||||||
|
if flg_nc == "SI":
|
||||||
|
debug_stats['falla_nc'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
fch_venc_cuota = self.parse_fecha(fila_factura.get('fch_vencimiento_cuota'))
|
||||||
|
if not fch_venc_cuota:
|
||||||
|
debug_stats['falla_venc_cuota'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
es_anterior = fch_venc_cuota < fecha_filtro
|
||||||
|
es_curso = (fch_venc_cuota >= fecha_filtro) and (fch_venc_cuota < fecha_filtro_siguiente)
|
||||||
|
|
||||||
|
if not (es_anterior or es_curso):
|
||||||
|
debug_stats['falla_venc_cuota'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
fch_cancelacion = self.parse_fecha(fila_factura.get('fch_cancelacion'))
|
||||||
|
if not fch_cancelacion:
|
||||||
|
debug_stats['falla_cancelacion'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
pasa_anterior = es_anterior and (fch_cancelacion < fecha_filtro)
|
||||||
|
pasa_curso = es_curso and (fch_cancelacion < fecha_filtro)
|
||||||
|
pasa_cobrado_ant = es_anterior and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente)
|
||||||
|
pasa_cobrado_curso = es_curso and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente)
|
||||||
|
|
||||||
|
if not (pasa_anterior or pasa_curso or pasa_cobrado_ant or pasa_cobrado_curso):
|
||||||
|
debug_stats['falla_cancelacion'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
fch_inicio = self.parse_fecha(fila_factura.get('fch_inicio'))
|
||||||
|
if not fch_inicio or not (fch_inicio <= hoy):
|
||||||
|
debug_stats['falla_inicio'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
estado_mat = str(fila_factura.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado_mat in ["ALU", "PRE"]: pass
|
||||||
|
elif estado_mat == "RET":
|
||||||
|
fch_retiro = self.parse_fecha(fila_factura.get('fch_retiro'))
|
||||||
|
if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente):
|
||||||
|
debug_stats['falla_estado_mat'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
else:
|
||||||
|
debug_stats['falla_estado_mat'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
try: imp_emitido = float(fila_factura.get('imp_emitido', 0) or 0)
|
||||||
|
except: imp_emitido = 0.0
|
||||||
|
|
||||||
|
imp_emitido_convertido = imp_emitido
|
||||||
|
moneda = str(fila_factura.get('cod_moneda', '')).strip().upper()
|
||||||
|
if moneda == 'DOL':
|
||||||
|
imp_emitido_convertido *= tc_correcto
|
||||||
|
|
||||||
|
debug_stats['pasa_todo'] += 1
|
||||||
|
|
||||||
|
val_resta_ant = imp_emitido_convertido if pasa_anterior else 0.0
|
||||||
|
val_resta_cur = imp_emitido_convertido if pasa_curso else 0.0
|
||||||
|
val_cobrado_ant = imp_emitido_convertido if pasa_cobrado_ant else 0.0
|
||||||
|
val_cobrado_curso = imp_emitido_convertido if pasa_cobrado_curso else 0.0
|
||||||
|
|
||||||
|
return val_resta_ant, val_resta_cur, val_cobrado_ant, val_cobrado_curso
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# LISTA DE SECTORISTAS
|
||||||
|
# =========================================================================
|
||||||
|
def obtener_lista_sectoristas(self, ano, mes):
|
||||||
|
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
|
||||||
|
hoy = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cronograma_raw = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano)
|
||||||
|
facturas_raw = self.data_manager.ejecutar_consulta_facturas_cobranza(ano)
|
||||||
|
except Exception: return ["TODOS"]
|
||||||
|
|
||||||
|
sectoristas_neto = {}
|
||||||
|
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
|
||||||
|
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
|
||||||
|
|
||||||
|
indices_cronograma = set()
|
||||||
|
mapa_tc = {}
|
||||||
|
mapa_sectorista = {}
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
sec_str = str(fila.get('dsc_sectorista', ''))
|
||||||
|
sec_clean = sec_str.strip()
|
||||||
|
if sec_clean in ["", ",", "None"] or sec_str == " , ": continue
|
||||||
|
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx:
|
||||||
|
indices_cronograma.add(idx)
|
||||||
|
mapa_sectorista[idx] = sec_clean
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
if mat and mat not in mapa_tc:
|
||||||
|
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||||
|
except: tc = 1.0
|
||||||
|
mapa_tc[mat] = tc
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx not in indices_cronograma: continue
|
||||||
|
|
||||||
|
sec_clean = mapa_sectorista[idx]
|
||||||
|
importe_final = self.calcular_importe_final(fila)
|
||||||
|
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
|
||||||
|
|
||||||
|
if sec_clean not in sectoristas_neto: sectoristas_neto[sec_clean] = 0.0
|
||||||
|
sectoristas_neto[sec_clean] += (suma_ant + suma_cur)
|
||||||
|
|
||||||
|
for fila in facturas_raw:
|
||||||
|
idx_factura = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx_factura not in indices_cronograma: continue
|
||||||
|
|
||||||
|
sec_clean = mapa_sectorista.get(idx_factura, "")
|
||||||
|
if not sec_clean: continue
|
||||||
|
|
||||||
|
mat_factura = str(fila.get('num_matricula', '')).strip()
|
||||||
|
tc_correcto = mapa_tc.get(mat_factura, 1.0)
|
||||||
|
|
||||||
|
resta_ant, resta_cur, _, _ = self.evaluar_ctaxcob_resta(
|
||||||
|
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
|
||||||
|
)
|
||||||
|
|
||||||
|
if sec_clean in sectoristas_neto: sectoristas_neto[sec_clean] -= (resta_ant + resta_cur)
|
||||||
|
|
||||||
|
lista_final = []
|
||||||
|
for sec, total_cta_x_cob in sectoristas_neto.items():
|
||||||
|
if round(total_cta_x_cob, 2) > 0: lista_final.append(sec)
|
||||||
|
|
||||||
|
lista_final = sorted(lista_final)
|
||||||
|
lista_final.insert(0, "TODOS")
|
||||||
|
return lista_final
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# DATOS PROCESADOS (TABLA PRINCIPAL CON VISTA DE ASESORES)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes, sectorista_filtro="TODOS", agrupacion="SEDE"):
|
||||||
|
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
|
||||||
|
hoy = datetime.now()
|
||||||
|
datos_agrupados = {}
|
||||||
|
|
||||||
|
frecuencias_cuota = {}
|
||||||
|
frecuencias_fecha_venc = {}
|
||||||
|
mapa_frecuencia = {}
|
||||||
|
|
||||||
|
alumnos_tracking = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
cronograma_raw, facturas_raw = self._obtener_datos_cached(ano)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error crítico: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
|
||||||
|
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
|
||||||
|
|
||||||
|
indices_cronograma = set()
|
||||||
|
mapa_tc = {}
|
||||||
|
mapa_programa = {}
|
||||||
|
mapa_sectorista = {} # <-- NUEVO MAPA PARA CORREGIR ASESORES
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
sectorista_str = str(fila.get('dsc_sectorista', ''))
|
||||||
|
sectorista_clean = sectorista_str.strip()
|
||||||
|
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx:
|
||||||
|
# Guardamos el asesor real del cronograma para usarlo en las facturas
|
||||||
|
mapa_sectorista[idx] = sectorista_clean if sectorista_clean and sectorista_clean not in [",", "None"] else "SIN ASESOR"
|
||||||
|
|
||||||
|
if sectorista_clean in [",", "", "None"] or sectorista_str == " , ": continue
|
||||||
|
if sectorista_filtro != "TODOS" and sectorista_clean != sectorista_filtro: continue
|
||||||
|
|
||||||
|
if idx: indices_cronograma.add(idx)
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
if mat:
|
||||||
|
if mat not in mapa_tc:
|
||||||
|
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||||
|
except: tc = 1.0
|
||||||
|
mapa_tc[mat] = tc
|
||||||
|
|
||||||
|
if mat not in mapa_programa:
|
||||||
|
prog = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
mapa_programa[mat] = prog if (prog and prog != "None") else "SIN PROGRAMA"
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx not in indices_cronograma: continue
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
|
||||||
|
if agrupacion == "SEDE":
|
||||||
|
llave = self.clasificar_sede(fila.get('dsc_programa', ''))
|
||||||
|
elif agrupacion == "ASESOR":
|
||||||
|
llave = mapa_sectorista.get(idx, "SIN ASESOR")
|
||||||
|
else:
|
||||||
|
llave = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
if not llave or llave == "None": llave = "SIN PROGRAMA"
|
||||||
|
|
||||||
|
if llave not in datos_agrupados:
|
||||||
|
datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0}
|
||||||
|
|
||||||
|
importe_final = self.calcular_importe_final(fila)
|
||||||
|
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
|
||||||
|
|
||||||
|
datos_agrupados[llave]["Suma_Ant"] += suma_ant
|
||||||
|
datos_agrupados[llave]["Suma_Cur"] += suma_cur
|
||||||
|
|
||||||
|
clave_mat = (mat, llave)
|
||||||
|
if clave_mat not in alumnos_tracking:
|
||||||
|
alumnos_tracking[clave_mat] = {"suma": 0.0, "resta": 0.0, "cobrado": 0.0, "cuota_final": None, "fecha_final": None}
|
||||||
|
|
||||||
|
alumnos_tracking[clave_mat]["suma"] += (suma_ant + suma_cur)
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
if llave not in mapa_frecuencia:
|
||||||
|
frec_val = str(fila.get('cod_frecuencia', '-')).strip()
|
||||||
|
mapa_frecuencia[llave] = frec_val if frec_val and frec_val != 'None' and frec_val != '' else '-'
|
||||||
|
|
||||||
|
fch_venc_raw = fila.get('fch_vencimiento')
|
||||||
|
fch_venc_dt = self.parse_fecha(fch_venc_raw)
|
||||||
|
if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente):
|
||||||
|
try:
|
||||||
|
n_cuota = int(fila.get('num_cuota', -1))
|
||||||
|
if n_cuota >= 0: alumnos_tracking[clave_mat]["cuota_final"] = n_cuota
|
||||||
|
except: pass
|
||||||
|
try:
|
||||||
|
alumnos_tracking[clave_mat]["fecha_final"] = fch_venc_dt.strftime('%d/%m/%Y')
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
for fila in facturas_raw:
|
||||||
|
idx_factura = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx_factura not in indices_cronograma: continue
|
||||||
|
|
||||||
|
mat_factura = str(fila.get('num_matricula', '')).strip()
|
||||||
|
tc_correcto = mapa_tc.get(mat_factura, 1.0)
|
||||||
|
|
||||||
|
if agrupacion == "SEDE":
|
||||||
|
llave = self.clasificar_sede(fila.get('dsc_programa', ''))
|
||||||
|
elif agrupacion == "ASESOR":
|
||||||
|
# AQUÍ ESTÁ LA MAGIA: usa el asesor guardado desde el cronograma
|
||||||
|
llave = mapa_sectorista.get(idx_factura, "SIN ASESOR")
|
||||||
|
else:
|
||||||
|
llave = mapa_programa.get(mat_factura, "")
|
||||||
|
if not llave:
|
||||||
|
llave = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
if not llave or llave == "None": llave = "SIN PROGRAMA"
|
||||||
|
|
||||||
|
if llave not in datos_agrupados:
|
||||||
|
datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0}
|
||||||
|
|
||||||
|
resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta(
|
||||||
|
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
|
||||||
|
)
|
||||||
|
|
||||||
|
datos_agrupados[llave]["Resta_Ant"] += resta_ant
|
||||||
|
datos_agrupados[llave]["Resta_Cur"] += resta_cur
|
||||||
|
datos_agrupados[llave]["Cob_Ant"] += cobrado_ant
|
||||||
|
datos_agrupados[llave]["Cob_Cur"] += cobrado_curso
|
||||||
|
|
||||||
|
clave_mat = (mat_factura, llave)
|
||||||
|
if clave_mat in alumnos_tracking:
|
||||||
|
alumnos_tracking[clave_mat]["resta"] += (resta_ant + resta_cur)
|
||||||
|
alumnos_tracking[clave_mat]["cobrado"] += (cobrado_ant + cobrado_curso)
|
||||||
|
|
||||||
|
for (mat, llave), track in alumnos_tracking.items():
|
||||||
|
saldo_alumno = (track["suma"] - track["resta"]) - track["cobrado"]
|
||||||
|
if round(saldo_alumno, 2) > 0:
|
||||||
|
c = track["cuota_final"]
|
||||||
|
f = track["fecha_final"]
|
||||||
|
|
||||||
|
if c is not None:
|
||||||
|
if llave not in frecuencias_cuota: frecuencias_cuota[llave] = {}
|
||||||
|
frecuencias_cuota[llave][c] = frecuencias_cuota[llave].get(c, 0) + 1
|
||||||
|
if f is not None:
|
||||||
|
if llave not in frecuencias_fecha_venc: frecuencias_fecha_venc[llave] = {}
|
||||||
|
frecuencias_fecha_venc[llave][f] = frecuencias_fecha_venc[llave].get(f, 0) + 1
|
||||||
|
|
||||||
|
datos_limpios = []
|
||||||
|
llaves_ordenadas = sorted(datos_agrupados.keys())
|
||||||
|
|
||||||
|
asesores_validos = []
|
||||||
|
if agrupacion == "ASESOR":
|
||||||
|
asesores_validos = [s for s in self.obtener_lista_sectoristas(ano, mes) if s != "TODOS"]
|
||||||
|
|
||||||
|
if agrupacion == "SEDE":
|
||||||
|
orden_sedes = ["LIMA", "AREQUIPA", "TRUJILLO", "PIURA"]
|
||||||
|
llaves_ordenadas = [s for s in orden_sedes if s in datos_agrupados] + [s for s in llaves_ordenadas if s not in orden_sedes]
|
||||||
|
|
||||||
|
for llave in llaves_ordenadas:
|
||||||
|
if agrupacion == "ASESOR" and llave not in asesores_validos:
|
||||||
|
continue
|
||||||
|
|
||||||
|
totales = datos_agrupados[llave]
|
||||||
|
cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"]
|
||||||
|
cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"]
|
||||||
|
total_cta = cta_cob_ant + cta_cob_curso
|
||||||
|
|
||||||
|
cobrado_meses_ant = totales["Cob_Ant"]
|
||||||
|
cobrado_mes_curso = totales["Cob_Cur"]
|
||||||
|
total_cobrado_sede = cobrado_meses_ant + cobrado_mes_curso
|
||||||
|
|
||||||
|
if round(total_cta, 2) <= 0 and round(total_cobrado_sede, 2) <= 0: continue
|
||||||
|
|
||||||
|
ratio_1 = (cobrado_meses_ant / cta_cob_ant) * 100 if cta_cob_ant > 0 else 0.0
|
||||||
|
ratio_2 = (cobrado_mes_curso / cta_cob_curso) * 100 if cta_cob_curso > 0 else 0.0
|
||||||
|
ratio_3 = (total_cobrado_sede / total_cta) * 100 if total_cta > 0 else 0.0
|
||||||
|
saldo_sede = total_cta - total_cobrado_sede
|
||||||
|
|
||||||
|
frec_str = "-"
|
||||||
|
moda_cuota = "-"
|
||||||
|
moda_fecha = "-"
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
frec_str = mapa_frecuencia.get(llave, "-")
|
||||||
|
if llave in frecuencias_cuota and frecuencias_cuota[llave]:
|
||||||
|
moda_cuota = str(max(frecuencias_cuota[llave], key=frecuencias_cuota[llave].get))
|
||||||
|
if llave in frecuencias_fecha_venc and frecuencias_fecha_venc[llave]:
|
||||||
|
moda_fecha = str(max(frecuencias_fecha_venc[llave], key=frecuencias_fecha_venc[llave].get))
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
grupo_display = self._aplicar_reemplazos_nombre(llave)
|
||||||
|
else:
|
||||||
|
grupo_display = llave
|
||||||
|
|
||||||
|
datos_limpios.append({
|
||||||
|
'GRUPO': grupo_display,
|
||||||
|
'GRUPO_ORIGINAL': llave,
|
||||||
|
'FRECUENCIA': frec_str,
|
||||||
|
'NUM_CUOTA': moda_cuota,
|
||||||
|
'FCH_VENC_MODA': moda_fecha,
|
||||||
|
'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta,
|
||||||
|
'COB_ANT': cobrado_meses_ant, 'RATIO_1': ratio_1, 'COB_MES_CURSO': cobrado_mes_curso, 'RATIO_2': ratio_2,
|
||||||
|
'TOTAL_COBRADO': total_cobrado_sede, 'RATIO_3': ratio_3, 'SALDO': saldo_sede, 'OPCIONES': " ≡ ▼ "
|
||||||
|
})
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
def orden_personalizado_programa(x):
|
||||||
|
fecha_str = x['FCH_VENC_MODA']
|
||||||
|
if fecha_str == "-":
|
||||||
|
return (0, datetime.min, x['GRUPO'])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(fecha_str, '%d/%m/%Y')
|
||||||
|
return (1, dt, x['GRUPO'])
|
||||||
|
except:
|
||||||
|
return (1, datetime.max, x['GRUPO'])
|
||||||
|
|
||||||
|
return sorted(datos_limpios, key=orden_personalizado_programa)
|
||||||
|
|
||||||
|
return datos_limpios
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# DESGLOSE POR ALUMNO/MATRÍCULA
|
||||||
|
# =========================================================================
|
||||||
|
def obtener_detalle_programa(self, ano, mes, sectorista_filtro, programa_nombre):
|
||||||
|
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
|
||||||
|
hoy = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cronograma_raw, facturas_raw = self._obtener_datos_cached(ano)
|
||||||
|
except Exception: return []
|
||||||
|
|
||||||
|
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
|
||||||
|
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
|
||||||
|
|
||||||
|
indices_cronograma = set()
|
||||||
|
mat_validas = set()
|
||||||
|
mapa_tc = {}
|
||||||
|
mapa_programa_frec = {}
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
sectorista_str = str(fila.get('dsc_sectorista', '')).strip()
|
||||||
|
if sectorista_str in [",", ""] or fila.get('dsc_sectorista') == " , ": continue
|
||||||
|
if sectorista_filtro != "TODOS" and sectorista_str != sectorista_filtro: continue
|
||||||
|
|
||||||
|
prog = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
if not prog or prog == "None": prog = "SIN PROGRAMA"
|
||||||
|
|
||||||
|
if programa_nombre != "TODOS":
|
||||||
|
if self._aplicar_reemplazos_nombre(prog) != programa_nombre: continue
|
||||||
|
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx: indices_cronograma.add(idx)
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
if mat:
|
||||||
|
mat_validas.add(mat)
|
||||||
|
if mat not in mapa_tc:
|
||||||
|
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||||
|
except: tc = 1.0
|
||||||
|
mapa_tc[mat] = tc
|
||||||
|
if mat not in mapa_programa_frec:
|
||||||
|
prog_bd = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
frec_bd = str(fila.get('cod_frecuencia', '')).strip()
|
||||||
|
mapa_programa_frec[mat] = {
|
||||||
|
"prog": prog_bd if prog_bd and prog_bd != "None" else "-",
|
||||||
|
"frec": frec_bd if frec_bd and frec_bd != "None" else "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
mapa_alumnos = {}
|
||||||
|
if mat_validas:
|
||||||
|
try:
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
lista_mats = list(mat_validas)
|
||||||
|
|
||||||
|
for i in range(0, len(lista_mats), 1000):
|
||||||
|
chunk = lista_mats[i:i+1000]
|
||||||
|
chunk_str = ",".join([f"'{m}'" for m in chunk])
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT sgeca_matricula.num_matricula,
|
||||||
|
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno
|
||||||
|
FROM sgeca_matricula
|
||||||
|
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||||
|
WHERE sgeca_matricula.num_matricula IN ({chunk_str})
|
||||||
|
"""
|
||||||
|
cursor.execute(sql)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
num_mat_bd = str(row[0]).strip()
|
||||||
|
nombre_bd = str(row[1]).strip()
|
||||||
|
mapa_alumnos[num_mat_bd] = nombre_bd
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error consultando nombres de alumnos en BD: {e}")
|
||||||
|
|
||||||
|
datos_agrupados = {}
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx not in indices_cronograma: continue
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
if mat not in datos_agrupados:
|
||||||
|
prog_info = mapa_programa_frec.get(mat, {"prog": "-", "frec": "-"})
|
||||||
|
datos_agrupados[mat] = {
|
||||||
|
"Alumno": mapa_alumnos.get(mat, "SIN NOMBRE"),
|
||||||
|
"Programa": prog_info["prog"],
|
||||||
|
"Frecuencia": prog_info["frec"],
|
||||||
|
"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0,
|
||||||
|
"Cuota_Mes": "-", "Fch_Venc_Mes": "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
fch_venc_raw = fila.get('fch_vencimiento')
|
||||||
|
fch_venc_dt = self.parse_fecha(fch_venc_raw)
|
||||||
|
if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente):
|
||||||
|
try: datos_agrupados[mat]["Cuota_Mes"] = str(int(fila.get('num_cuota', -1)))
|
||||||
|
except: pass
|
||||||
|
datos_agrupados[mat]["Fch_Venc_Mes"] = fch_venc_dt.strftime('%d/%m/%Y')
|
||||||
|
|
||||||
|
importe_final = self.calcular_importe_final(fila)
|
||||||
|
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
|
||||||
|
|
||||||
|
datos_agrupados[mat]["Suma_Ant"] += suma_ant
|
||||||
|
datos_agrupados[mat]["Suma_Cur"] += suma_cur
|
||||||
|
|
||||||
|
for fila in facturas_raw:
|
||||||
|
idx_factura = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx_factura not in indices_cronograma: continue
|
||||||
|
|
||||||
|
mat_factura = str(fila.get('num_matricula', '')).strip()
|
||||||
|
tc_correcto = mapa_tc.get(mat_factura, 1.0)
|
||||||
|
|
||||||
|
if mat_factura not in datos_agrupados:
|
||||||
|
prog_info = mapa_programa_frec.get(mat_factura, {"prog": "-", "frec": "-"})
|
||||||
|
datos_agrupados[mat_factura] = {
|
||||||
|
"Alumno": mapa_alumnos.get(mat_factura, "SIN NOMBRE"),
|
||||||
|
"Programa": prog_info["prog"],
|
||||||
|
"Frecuencia": prog_info["frec"],
|
||||||
|
"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0,
|
||||||
|
"Cuota_Mes": "-", "Fch_Venc_Mes": "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta(
|
||||||
|
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
|
||||||
|
)
|
||||||
|
|
||||||
|
datos_agrupados[mat_factura]["Resta_Ant"] += resta_ant
|
||||||
|
datos_agrupados[mat_factura]["Resta_Cur"] += resta_cur
|
||||||
|
datos_agrupados[mat_factura]["Cob_Ant"] += cobrado_ant
|
||||||
|
datos_agrupados[mat_factura]["Cob_Cur"] += cobrado_curso
|
||||||
|
|
||||||
|
datos_limpios = []
|
||||||
|
for mat, totales in datos_agrupados.items():
|
||||||
|
cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"]
|
||||||
|
cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"]
|
||||||
|
total_cta = cta_cob_ant + cta_cob_curso
|
||||||
|
|
||||||
|
cob_ant = totales["Cob_Ant"]
|
||||||
|
cob_cur = totales["Cob_Cur"]
|
||||||
|
total_cob = cob_ant + cob_cur
|
||||||
|
saldo = total_cta - total_cob
|
||||||
|
|
||||||
|
if round(total_cta, 2) <= 0 and round(total_cob, 2) <= 0: continue
|
||||||
|
|
||||||
|
cuota_final = totales["Cuota_Mes"]
|
||||||
|
fecha_final = totales["Fch_Venc_Mes"]
|
||||||
|
if round(saldo, 2) <= 0:
|
||||||
|
cuota_final = "-"
|
||||||
|
fecha_final = "-"
|
||||||
|
|
||||||
|
datos_limpios.append({
|
||||||
|
'MATRICULA': mat, 'ALUMNO': totales["Alumno"],
|
||||||
|
'PROGRAMA': totales["Programa"], 'FRECUENCIA': totales["Frecuencia"],
|
||||||
|
'NUM_CUOTA': cuota_final,
|
||||||
|
'FCH_VENC': fecha_final,
|
||||||
|
'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta,
|
||||||
|
'COB_ANT': cob_ant, 'COB_MES_CURSO': cob_cur, 'TOTAL_COBRADO': total_cob, 'SALDO': saldo
|
||||||
|
})
|
||||||
|
|
||||||
|
def orden_personalizado(x):
|
||||||
|
fecha_str = x['FCH_VENC']
|
||||||
|
if fecha_str == "-":
|
||||||
|
return (0, datetime.min, x['ALUMNO'])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(fecha_str, '%d/%m/%Y')
|
||||||
|
return (1, dt, x['ALUMNO'])
|
||||||
|
except:
|
||||||
|
return (1, datetime.max, x['ALUMNO'])
|
||||||
|
|
||||||
|
return sorted(datos_limpios, key=orden_personalizado)
|
||||||
0
backend/backend/modules/ocupabilidad/__init__.py
Normal file
0
backend/backend/modules/ocupabilidad/__init__.py
Normal file
237
backend/backend/modules/ocupabilidad/logic.py
Normal file
237
backend/backend/modules/ocupabilidad/logic.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# modules/ocupabilidad/logic.py
|
||||||
|
import pandas as pd
|
||||||
|
from .processor import CursoProcessor
|
||||||
|
|
||||||
|
class AnalizadorCursos:
|
||||||
|
"""Analizador de cursos - Lógica de cálculo y estadísticas"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.curso_processor = CursoProcessor(self.data_manager)
|
||||||
|
self.columnas = self.curso_processor.columnas
|
||||||
|
|
||||||
|
def obtener_sedes(self):
|
||||||
|
"""Devuelve la lista de sedes para el nuevo filtro"""
|
||||||
|
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
|
||||||
|
|
||||||
|
def identificar_sede(self, dsc_programa):
|
||||||
|
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
|
||||||
|
if not dsc_programa: return "LIMA"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
|
||||||
|
for sede, data in sedes_data.items():
|
||||||
|
if sede == "DEFAULT": continue
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper: return sede
|
||||||
|
return sedes_data.get("DEFAULT", "LIMA")
|
||||||
|
|
||||||
|
def obtener_filtros_programa(self):
|
||||||
|
"""Devuelve la lista de programas para el nuevo filtro combinado"""
|
||||||
|
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||||
|
|
||||||
|
def identificar_filtro_programa(self, dsc_programa):
|
||||||
|
"""Identifica la categoría del programa para el filtro de la UI"""
|
||||||
|
if not dsc_programa: return "OTROS"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
|
||||||
|
for categoria, data in filtro_data.items():
|
||||||
|
if categoria == "DEFAULT": continue
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper: return categoria
|
||||||
|
return filtro_data.get("DEFAULT", "OTROS")
|
||||||
|
|
||||||
|
def get_current_month(self):
|
||||||
|
return self.data_manager.get_current_month()
|
||||||
|
|
||||||
|
def get_current_year(self):
|
||||||
|
return self.data_manager.get_current_year()
|
||||||
|
|
||||||
|
def actualizar_configuracion(self):
|
||||||
|
print("🔄 Actualización automática de configuración...")
|
||||||
|
self.data_manager.cargar_toda_configuracion()
|
||||||
|
|
||||||
|
# --- AGREGADO EL PARÁMETRO "TIPO" ---
|
||||||
|
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
|
||||||
|
# Sincronizamos el estado del toggle UI con el procesador
|
||||||
|
self.curso_processor.mostrar_reprogramados = getattr(self, 'mostrar_reprogramados', True)
|
||||||
|
|
||||||
|
datos = self.curso_processor.obtener_datos_procesados(ano, mes)
|
||||||
|
programas_disponibles = set()
|
||||||
|
|
||||||
|
if datos:
|
||||||
|
for d in datos:
|
||||||
|
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
|
||||||
|
# Inyectamos variables para el filtro UI
|
||||||
|
d['Sede'] = self.identificar_sede(prog)
|
||||||
|
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
|
||||||
|
|
||||||
|
if sede == "TODOS" or d['Sede'] == sede:
|
||||||
|
programas_disponibles.add(d['Filtro_Programa'])
|
||||||
|
|
||||||
|
# Ordenar lista dinámica
|
||||||
|
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||||
|
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
|
||||||
|
|
||||||
|
# Auto-corrección si el programa no existe en la sede actual
|
||||||
|
if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles:
|
||||||
|
filtro_prog = "TODOS"
|
||||||
|
self.filtro_corregido = "TODOS"
|
||||||
|
else:
|
||||||
|
self.filtro_corregido = None
|
||||||
|
|
||||||
|
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
|
||||||
|
datos_filtrados = []
|
||||||
|
for d in datos:
|
||||||
|
cumple_sede = (sede == "TODOS" or d.get('Sede') == sede)
|
||||||
|
cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog)
|
||||||
|
if cumple_sede and cumple_prog:
|
||||||
|
datos_filtrados.append(d)
|
||||||
|
return datos_filtrados
|
||||||
|
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def formatear_datos_para_tabla(self, datos):
|
||||||
|
if not datos:
|
||||||
|
return [], {}
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
totales = {
|
||||||
|
'mes': 0, 'retirados': 0, 'totales': 0,
|
||||||
|
'activos': 0, 'pc': 0, 'continuidad': 0, 'meta': 0,
|
||||||
|
'descuento': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for registro in datos:
|
||||||
|
fila = []
|
||||||
|
for col in self.columnas:
|
||||||
|
if col != 'Avance_Inscritos':
|
||||||
|
valor = registro.get(col, '')
|
||||||
|
fila.append(str(valor))
|
||||||
|
self._acumular_totales(totales, col, registro)
|
||||||
|
else:
|
||||||
|
total_insc = registro.get('Inscritos_Totales', 0)
|
||||||
|
meta = registro.get('Meta_Curso', 0)
|
||||||
|
|
||||||
|
if total_insc == "-" or meta == "-":
|
||||||
|
fila.append("-")
|
||||||
|
else:
|
||||||
|
porcentaje = self.calcular_porcentaje_avance(total_insc, meta)
|
||||||
|
fila.append(self.crear_barra_texto_color(porcentaje))
|
||||||
|
|
||||||
|
sheet_data.append(fila)
|
||||||
|
|
||||||
|
fila_totales = self.crear_fila_totales(totales)
|
||||||
|
sheet_data.append(fila_totales)
|
||||||
|
|
||||||
|
return sheet_data, totales
|
||||||
|
|
||||||
|
def _acumular_totales(self, totales, col, registro):
|
||||||
|
val = registro.get(col, 0)
|
||||||
|
if val == "-": return # Ignoramos los guiones para que no rompa la suma
|
||||||
|
|
||||||
|
if col == 'Descuento':
|
||||||
|
totales['descuento'] += val
|
||||||
|
elif col == 'Inscritos_Mes':
|
||||||
|
totales['mes'] += val
|
||||||
|
elif col == 'Retirados':
|
||||||
|
totales['retirados'] += val
|
||||||
|
elif col == 'Inscritos_Totales':
|
||||||
|
totales['totales'] += val
|
||||||
|
elif col == 'Inscritos_Activos':
|
||||||
|
totales['activos'] += val
|
||||||
|
elif col == 'Inscritos_PC':
|
||||||
|
totales['pc'] += val
|
||||||
|
elif col == 'Inscritos_Continuidad':
|
||||||
|
totales['continuidad'] += val
|
||||||
|
elif col == 'Meta_Curso':
|
||||||
|
totales['meta'] += val
|
||||||
|
|
||||||
|
def crear_fila_totales(self, totales):
|
||||||
|
porcentaje = self.calcular_porcentaje_avance(totales['totales'], totales['meta'])
|
||||||
|
# REORGANIZADO SEGÚN NUEVO ORDEN DE COLUMNAS SOLICITADO:
|
||||||
|
# [0]Prog, [1]Fch, [2]Dias, [3]Mes, [4]Total, [5]Retirados, [6]Activos, [7]PC, [8]Refriperu, [9]Cont, [10]Meta, [11]Avance
|
||||||
|
return [
|
||||||
|
"TOTAL GENERAL", # PROGRAMA
|
||||||
|
"", # FECHA INICIO
|
||||||
|
"", # DIAS PARA INICIO
|
||||||
|
str(totales['mes']), # INSCRITOS MES
|
||||||
|
str(totales['totales']), # TOTAL INSCRITOS
|
||||||
|
str(totales['retirados']), # RETIRADOS
|
||||||
|
str(totales['activos']), # INSCRITOS EN CURSO
|
||||||
|
str(totales['pc']), # INSCRITOS P.C
|
||||||
|
str(totales['descuento']), # INSCRITOS REFRIPERU (Descuento)
|
||||||
|
str(totales['continuidad']), # INSCRITOS CONTINUIDAD
|
||||||
|
str(totales['meta']), # META
|
||||||
|
self.crear_barra_texto_color(porcentaje) # AVANCE INSCRITOS
|
||||||
|
]
|
||||||
|
|
||||||
|
def calcular_porcentaje_avance(self, activos, meta):
|
||||||
|
return (activos / meta * 100) if meta > 0 else 0
|
||||||
|
|
||||||
|
def crear_barra_texto_color(self, porcentaje):
|
||||||
|
longitud = 10
|
||||||
|
llenas = int((min(porcentaje, 100) / 100) * longitud)
|
||||||
|
vacias = longitud - llenas
|
||||||
|
barra = '█' * llenas + '░' * vacias
|
||||||
|
porc_text = f"{porcentaje:.1f}%"
|
||||||
|
espacios = " " * (6 - len(porc_text))
|
||||||
|
return f"{porc_text}{espacios}{barra}"
|
||||||
|
|
||||||
|
def get_intervalo_actualizacion(self):
|
||||||
|
return self.data_manager.config_data.get(
|
||||||
|
'config_general', {}
|
||||||
|
).get('auto_update_minutos', 5) * 60000
|
||||||
|
|
||||||
|
def exportar_a_excel(self, datos, ano, mes):
|
||||||
|
df = pd.DataFrame(datos)
|
||||||
|
df_exportar = df[self.columnas]
|
||||||
|
archivo = f"cursos_{ano}_{mes}.xlsx"
|
||||||
|
df_exportar.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def calcular_metricas_generales(self, datos):
|
||||||
|
if not datos: return {}
|
||||||
|
# Filtramos las filas fantasmas para no romper la matemática de Pandas
|
||||||
|
datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO']
|
||||||
|
if not datos_validos: return {}
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos_validos)
|
||||||
|
metricas = {
|
||||||
|
'total_cursos': len(datos), # Mostramos el total real de filas (incluyendo fantasmas)
|
||||||
|
'total_inscritos': df['Inscritos_Activos'].sum(),
|
||||||
|
'total_meta': df['Meta_Curso'].sum(),
|
||||||
|
'porcentaje_avance_general': (df['Inscritos_Activos'].sum() / df['Meta_Curso'].sum() * 100) if df['Meta_Curso'].sum() > 0 else 0,
|
||||||
|
'cursos_sobre_meta': len(df[df['Inscritos_Activos'] >= df['Meta_Curso']]),
|
||||||
|
'cursos_bajo_meta': len(df[df['Inscritos_Activos'] < df['Meta_Curso']]),
|
||||||
|
'total_inscritos_pc': df['Inscritos_PC'].sum()
|
||||||
|
}
|
||||||
|
return metricas
|
||||||
|
|
||||||
|
def obtener_top_programas(self, datos, top_n=5):
|
||||||
|
if not datos: return []
|
||||||
|
# Filtramos las filas fantasmas
|
||||||
|
datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO']
|
||||||
|
if not datos_validos: return []
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos_validos)
|
||||||
|
df['Porcentaje_Avance'] = df.apply(
|
||||||
|
lambda x: self.calcular_porcentaje_avance(x['Inscritos_Activos'], x['Meta_Curso']),
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
top_programas = df.nlargest(top_n, 'Porcentaje_Avance')[
|
||||||
|
['programa_frecuencia', 'Inscritos_Activos', 'Meta_Curso', 'Porcentaje_Avance']
|
||||||
|
].to_dict('records')
|
||||||
|
return top_programas
|
||||||
|
|
||||||
|
def calcular_tendencias_mensuales(self, ano):
|
||||||
|
tendencias = {}
|
||||||
|
for mes in range(1, 13):
|
||||||
|
try:
|
||||||
|
datos_mes = self.obtener_datos_procesados(str(ano), str(mes))
|
||||||
|
if datos_mes:
|
||||||
|
metricas = self.calcular_metricas_generales(datos_mes)
|
||||||
|
tendencias[mes] = metricas
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error procesando mes {mes}: {e}")
|
||||||
|
continue
|
||||||
|
return tendencias
|
||||||
560
backend/backend/modules/ocupabilidad/processor.py
Normal file
560
backend/backend/modules/ocupabilidad/processor.py
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
# modules/ocupabilidad/processor.py
|
||||||
|
from datetime import datetime
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
class CursoProcessor:
|
||||||
|
"""Procesador de datos de cursos - Aplica transformaciones y cálculos"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
|
||||||
|
# --- COLUMNAS REORDENADAS Y REORGANIZADAS ---
|
||||||
|
self.columnas = [
|
||||||
|
'programa_frecuencia',
|
||||||
|
'fch_inicio',
|
||||||
|
'dias_para_inicio',
|
||||||
|
'Inscritos_Mes',
|
||||||
|
'Inscritos_Totales',
|
||||||
|
'Retirados',
|
||||||
|
'Inscritos_Activos',
|
||||||
|
'Inscritos_PC',
|
||||||
|
'Descuento', # Esto es 'INSCRITOS REFRIPERU'
|
||||||
|
'Inscritos_Continuidad',
|
||||||
|
'Meta_Curso',
|
||||||
|
'Avance_Inscritos'
|
||||||
|
]
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes):
|
||||||
|
try:
|
||||||
|
# 1. Cargar datos básicos
|
||||||
|
datos_matriculas = self.data_manager.cargar_datos_matriculas(ano, mes)
|
||||||
|
datos_raw_matriculas = self.data_manager.ejecutar_consulta_matriculados_detalle(ano, mes)
|
||||||
|
datos_originales = self.data_manager.ejecutar_consulta_cursos(ano, mes)
|
||||||
|
|
||||||
|
# --- NUEVO: EL GUARDIÁN DE FECHAS (SUPABASE) ---
|
||||||
|
fechas_historicas = self.data_manager.obtener_fechas_originales()
|
||||||
|
indices_en_sql = set()
|
||||||
|
|
||||||
|
for fila in datos_originales:
|
||||||
|
indice = str(fila.get('num_indice', '')).strip()
|
||||||
|
fecha_actual = fila.get('fch_inicio')
|
||||||
|
programa = fila.get('dsc_programa', '')
|
||||||
|
|
||||||
|
if indice and fecha_actual:
|
||||||
|
indices_en_sql.add(indice) # Registramos que sí vino en SQL este mes
|
||||||
|
if indice not in fechas_historicas:
|
||||||
|
fecha_str = fecha_actual.strftime('%d/%m/%Y') if isinstance(fecha_actual, datetime) else str(fecha_actual)
|
||||||
|
exito = self.data_manager.guardar_fecha_original(indice, programa, fecha_str)
|
||||||
|
if exito:
|
||||||
|
fechas_historicas[indice] = {'fecha': fecha_str, 'programa': programa}
|
||||||
|
# -----------------------------------------------
|
||||||
|
|
||||||
|
# 2. CONTINUIDAD
|
||||||
|
lista_dnis = []
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for alumno in datos_raw_matriculas:
|
||||||
|
dni = alumno.get('dsc_documento')
|
||||||
|
if dni: lista_dnis.append(dni)
|
||||||
|
|
||||||
|
datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||||
|
|
||||||
|
# 3. Procesar todo
|
||||||
|
datos_procesados = self.aplicar_personalizaciones(
|
||||||
|
datos_originales,
|
||||||
|
datos_matriculas,
|
||||||
|
datos_raw_matriculas,
|
||||||
|
datos_historial,
|
||||||
|
ano, mes
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- INYECTAR CURSOS REPROGRAMADOS (FILAS FANTASMAS) ---
|
||||||
|
if getattr(self, 'mostrar_reprogramados', True):
|
||||||
|
for idx, info in fechas_historicas.items():
|
||||||
|
if idx not in indices_en_sql: # Si el curso NO vino este mes desde SQL...
|
||||||
|
# Aseguramos compatibilidad si guardaste data vieja como texto o la nueva como diccionario
|
||||||
|
if isinstance(info, dict):
|
||||||
|
fecha_orig = info.get('fecha', '')
|
||||||
|
prog_orig = info.get('programa', 'CURSO REPROGRAMADO')
|
||||||
|
else:
|
||||||
|
fecha_orig = str(info)
|
||||||
|
prog_orig = 'CURSO REPROGRAMADO'
|
||||||
|
|
||||||
|
# Verificamos si la fecha original correspondía a ESTE mes Y AÑO filtrado
|
||||||
|
mes_orig = ""
|
||||||
|
ano_orig = ""
|
||||||
|
if "/" in fecha_orig:
|
||||||
|
partes = fecha_orig.split("/")
|
||||||
|
if len(partes) >= 2: mes_orig = str(int(partes[1]))
|
||||||
|
if len(partes) >= 3: ano_orig = str(int(partes[2]))
|
||||||
|
elif "-" in fecha_orig:
|
||||||
|
partes = fecha_orig.split("-")
|
||||||
|
if len(partes) >= 2: mes_orig = str(int(partes[1]))
|
||||||
|
if len(partes) >= 3: ano_orig = str(int(partes[2]))
|
||||||
|
|
||||||
|
# Mes debe coincidir; y si la fecha tiene año, el año también debe coincidir.
|
||||||
|
# Si la fecha NO tiene año (formato viejo dd/mm), no se inyecta para evitar
|
||||||
|
# mostrarla en años equivocados.
|
||||||
|
coincide_mes = (mes_orig == str(int(mes)))
|
||||||
|
coincide_ano = (ano_orig == str(int(ano))) if ano_orig else False
|
||||||
|
if coincide_mes and coincide_ano:
|
||||||
|
# Inyectamos la fila falsa para alertar en pantalla
|
||||||
|
datos_procesados.append({
|
||||||
|
'num_indice': idx,
|
||||||
|
'programa_frecuencia': prog_orig,
|
||||||
|
'dsc_programa': prog_orig,
|
||||||
|
'fch_inicio': fecha_orig,
|
||||||
|
'dias_para_inicio': 'REPROGRAMADO',
|
||||||
|
'Inscritos_Mes': "-",
|
||||||
|
'Inscritos_Totales': "-",
|
||||||
|
'Retirados': "-",
|
||||||
|
'Inscritos_Activos': "-",
|
||||||
|
'Inscritos_PC': "-",
|
||||||
|
'Descuento': "-",
|
||||||
|
'Inscritos_Continuidad': "-",
|
||||||
|
'Meta_Curso': "-",
|
||||||
|
'Avance_Inscritos': "-"
|
||||||
|
})
|
||||||
|
# -----------------------------------------------
|
||||||
|
|
||||||
|
## --- ORDENAR: NORMALES POR FECHA, REPROGRAMADOS POR SEDE ---
|
||||||
|
def parse_fecha_orden(fecha_str):
|
||||||
|
if not fecha_str or fecha_str == "-":
|
||||||
|
return datetime.max
|
||||||
|
s = str(fecha_str).replace("/", "-").strip()
|
||||||
|
try:
|
||||||
|
if len(s) == 5:
|
||||||
|
return datetime.strptime(f"{s}-{ano}", "%d-%m-%Y")
|
||||||
|
if len(s) >= 10:
|
||||||
|
return datetime.strptime(s[:10], "%d-%m-%Y")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return datetime.max
|
||||||
|
|
||||||
|
def obtener_orden_sede(programa):
|
||||||
|
prog_upper = str(programa).upper()
|
||||||
|
if 'LIMA' in prog_upper: return 1
|
||||||
|
if 'PIURA' in prog_upper: return 2
|
||||||
|
if 'TRUJILLO' in prog_upper: return 3
|
||||||
|
if 'AREQUIPA' in prog_upper: return 4
|
||||||
|
return 5
|
||||||
|
|
||||||
|
def logica_ordenamiento(x):
|
||||||
|
if x.get('dias_para_inicio') == 'REPROGRAMADO':
|
||||||
|
# Reprogramados: Van al final (1), ordenados por sede, luego alfabéticamente
|
||||||
|
return (1, obtener_orden_sede(x.get('dsc_programa', '')), x.get('dsc_programa', ''))
|
||||||
|
else:
|
||||||
|
# Normales: Van al inicio (0), ordenados por fecha
|
||||||
|
return (0, 0, parse_fecha_orden(x.get('fch_inicio', '')))
|
||||||
|
|
||||||
|
# Aplicamos el ordenamiento inteligente
|
||||||
|
datos_procesados.sort(key=logica_ordenamiento)
|
||||||
|
|
||||||
|
return datos_procesados
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error obteniendo datos procesados: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def normalizar_texto(self, texto):
|
||||||
|
if not texto: return ""
|
||||||
|
texto = str(texto).upper().strip()
|
||||||
|
texto = unicodedata.normalize('NFD', texto)
|
||||||
|
texto = texto.encode('ascii', 'ignore').decode("utf-8")
|
||||||
|
return texto
|
||||||
|
|
||||||
|
def obtener_categoria_programa(self, nombre_programa):
|
||||||
|
try:
|
||||||
|
nombre_limpio = self.normalizar_texto(nombre_programa)
|
||||||
|
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
|
||||||
|
|
||||||
|
for categoria, config in clasificaciones.items():
|
||||||
|
patrones = config.get('patrones', [])
|
||||||
|
for patron in patrones:
|
||||||
|
patron_limpio = self.normalizar_texto(patron)
|
||||||
|
if patron_limpio in nombre_limpio:
|
||||||
|
return categoria
|
||||||
|
|
||||||
|
return "OTROS"
|
||||||
|
except:
|
||||||
|
return "OTROS"
|
||||||
|
|
||||||
|
def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro):
|
||||||
|
print("🎯 Aplicando personalizaciones...")
|
||||||
|
cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {})
|
||||||
|
datos_filtrados = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
ano_target = int(ano_filtro)
|
||||||
|
mes_target = int(mes_filtro)
|
||||||
|
except ValueError:
|
||||||
|
ano_target = 0
|
||||||
|
mes_target = 0
|
||||||
|
|
||||||
|
# 1. FILTRADO
|
||||||
|
for curso in datos_originales:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
|
||||||
|
if num_indice in cursos_personalizados:
|
||||||
|
personalizacion = cursos_personalizados[num_indice]
|
||||||
|
if 'fch_inicio' in personalizacion:
|
||||||
|
curso['fch_inicio'] = personalizacion['fch_inicio']
|
||||||
|
if 'flg_activo' in personalizacion:
|
||||||
|
curso['flg_activo'] = personalizacion['flg_activo']
|
||||||
|
if 'dsc_programa' in personalizacion:
|
||||||
|
curso['dsc_programa'] = personalizacion['dsc_programa']
|
||||||
|
if 'dsc_det_programa' in personalizacion:
|
||||||
|
curso['dsc_det_programa'] = personalizacion['dsc_det_programa']
|
||||||
|
|
||||||
|
if curso.get('flg_activo', '') == 'NO':
|
||||||
|
continue
|
||||||
|
|
||||||
|
fch_valida = True
|
||||||
|
try:
|
||||||
|
raw_fecha = curso.get('fch_inicio')
|
||||||
|
if raw_fecha:
|
||||||
|
f_obj = None
|
||||||
|
s_fecha = str(raw_fecha).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-' and s_fecha[5] == '-':
|
||||||
|
f_obj = datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
elif '-' in s_fecha:
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
else:
|
||||||
|
f_obj = datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
|
||||||
|
if f_obj:
|
||||||
|
if f_obj.year != ano_target or f_obj.month != mes_target:
|
||||||
|
fch_valida = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if fch_valida:
|
||||||
|
datos_filtrados.append(curso)
|
||||||
|
|
||||||
|
# 2. TRANSFORMACIONES
|
||||||
|
datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados)
|
||||||
|
datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados)
|
||||||
|
|
||||||
|
for curso in datos_filtrados:
|
||||||
|
if 'fch_inicio' in curso:
|
||||||
|
curso['fch_inicio'] = self.formatear_fecha(curso['fch_inicio'])
|
||||||
|
|
||||||
|
# 3. DATOS CALCULADOS CON PRIORIDAD (REFRIPERU > CONTINUIDAD)
|
||||||
|
datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas)
|
||||||
|
datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu)
|
||||||
|
|
||||||
|
datos_filtrados = self.calcular_dias_para_inicio_cursos(datos_filtrados)
|
||||||
|
datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas)
|
||||||
|
datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas)
|
||||||
|
datos_filtrados = self.agregar_meta_curso(datos_filtrados)
|
||||||
|
datos_filtrados = self.agregar_avance_inscritos(datos_filtrados)
|
||||||
|
|
||||||
|
return datos_filtrados
|
||||||
|
|
||||||
|
def aplicar_reemplazos_programas(self, datos):
|
||||||
|
if 'reemplazos_programas' in self.data_manager.replace_data:
|
||||||
|
reemplazos = self.data_manager.replace_data['reemplazos_programas']
|
||||||
|
for curso in datos:
|
||||||
|
programa_original = curso.get('dsc_programa', '')
|
||||||
|
if programa_original in reemplazos:
|
||||||
|
curso['dsc_programa'] = reemplazos[programa_original]
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def concatenar_programa_frecuencia(self, datos):
|
||||||
|
for curso in datos:
|
||||||
|
programa = curso.get('dsc_programa', '')
|
||||||
|
frecuencia = curso.get('cod_frecuencia', '')
|
||||||
|
if programa and frecuencia:
|
||||||
|
curso['programa_frecuencia'] = f"{programa} - {frecuencia}"
|
||||||
|
elif programa:
|
||||||
|
curso['programa_frecuencia'] = programa
|
||||||
|
elif frecuencia:
|
||||||
|
curso['programa_frecuencia'] = frecuencia
|
||||||
|
else:
|
||||||
|
curso['programa_frecuencia'] = ""
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def formatear_fecha(self, fecha_str):
|
||||||
|
try:
|
||||||
|
if not fecha_str: return ""
|
||||||
|
if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-': return fecha_str
|
||||||
|
if '.' in str(fecha_str):
|
||||||
|
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y')
|
||||||
|
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y')
|
||||||
|
except: return str(fecha_str)
|
||||||
|
|
||||||
|
def identificar_linea_carrera(self, nombre_programa):
|
||||||
|
categoria = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
|
||||||
|
# --- AQUÍ ESTÁ EL CAMBIO ---
|
||||||
|
# Si es cualquiera de estas 5, las unimos bajo una misma "Línea Universal" de compatibilidad
|
||||||
|
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
|
||||||
|
return "CARRERA_COMPATIBLE"
|
||||||
|
|
||||||
|
# Si es GESTION, MASTERCLASS, SEMINARIOS, etc., retorna None (las sigue ignorando)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-':
|
||||||
|
return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# ❄️ REFRIPERU / DESCUENTO (ALTA PRIORIDAD)
|
||||||
|
# =========================================================================
|
||||||
|
def agregar_descuento(self, datos, datos_raw_matriculas):
|
||||||
|
print("\n💰 Calculando REFRIPERU (Descuentos)...")
|
||||||
|
print("="*80)
|
||||||
|
print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA':<20} | {'PAGO':<10} | {'ZONA/MOTIVO'}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
conteo_por_curso = {}
|
||||||
|
alumnos_refriperu = set()
|
||||||
|
|
||||||
|
correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||||
|
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for alumno in datos_raw_matriculas:
|
||||||
|
try:
|
||||||
|
estado = str(alumno.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado not in ['ALU', 'PRE']: continue
|
||||||
|
|
||||||
|
num_indice = str(alumno.get('num_indice', ''))
|
||||||
|
|
||||||
|
dni = str(alumno.get('dsc_documento', 'SD')).strip()
|
||||||
|
nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip()
|
||||||
|
|
||||||
|
mat_id = str(alumno.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
|
||||||
|
if mat_id in correcciones_descuento:
|
||||||
|
accion = str(correcciones_descuento[mat_id]).upper().strip()
|
||||||
|
if accion == "SI":
|
||||||
|
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||||
|
alumnos_refriperu.add(mat_id)
|
||||||
|
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {'---':<20} | {'---':<10} | MANUAL (JSON)")
|
||||||
|
continue
|
||||||
|
elif accion == "NO":
|
||||||
|
continue
|
||||||
|
|
||||||
|
nombre_programa = str(alumno.get('dsc_programa', ''))
|
||||||
|
|
||||||
|
cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
try:
|
||||||
|
inv_neta = float(alumno.get('INV_NETA', 0) or 0)
|
||||||
|
imp_tc = float(alumno.get('imp_tc', 1) or 1)
|
||||||
|
if imp_tc <= 0: imp_tc = 1.0
|
||||||
|
except:
|
||||||
|
inv_neta = 0.0; imp_tc = 1.0
|
||||||
|
|
||||||
|
if cod_moneda == 'DOL':
|
||||||
|
inv_neta_final = inv_neta * imp_tc
|
||||||
|
else:
|
||||||
|
inv_neta_final = inv_neta
|
||||||
|
|
||||||
|
inv_neta_final = round(inv_neta_final, 2)
|
||||||
|
|
||||||
|
categoria_json = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
es_descuento = False
|
||||||
|
motivo_debug = ""
|
||||||
|
|
||||||
|
if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"]:
|
||||||
|
if 1200 <= inv_neta_final <= 1700:
|
||||||
|
es_descuento = True
|
||||||
|
motivo_debug = f"{categoria_json} (Rango Prov)"
|
||||||
|
|
||||||
|
elif categoria_json in ["TEAC", "TERC"]:
|
||||||
|
if 1400 <= inv_neta_final <= 1900:
|
||||||
|
es_descuento = True
|
||||||
|
motivo_debug = f"{categoria_json} (Rango Lima)"
|
||||||
|
|
||||||
|
if es_descuento:
|
||||||
|
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||||
|
alumnos_refriperu.add(mat_id)
|
||||||
|
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_programa[:20]:<20} | S/{inv_neta_final:<8} | {motivo_debug}")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for curso in datos:
|
||||||
|
idx = str(curso.get('num_indice', ''))
|
||||||
|
curso['Descuento'] = conteo_por_curso.get(idx, 0)
|
||||||
|
|
||||||
|
return datos, alumnos_refriperu
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 🔄 CONTINUIDAD (BAJA PRIORIDAD)
|
||||||
|
# =========================================================================
|
||||||
|
def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu=None):
|
||||||
|
if alumnos_refriperu is None:
|
||||||
|
alumnos_refriperu = set()
|
||||||
|
|
||||||
|
print("🔄 Calculando CONTINUIDAD...")
|
||||||
|
print("="*80)
|
||||||
|
print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA ACTUAL':<30} | {'MOTIVO/ORIGEN'}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
conteo_por_curso = {}
|
||||||
|
|
||||||
|
correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||||
|
MIN_DIAS_DIFERENCIA = 60
|
||||||
|
|
||||||
|
if datos_raw_matriculas and datos_historial:
|
||||||
|
alumnos_por_curso = {}
|
||||||
|
for alu in datos_raw_matriculas:
|
||||||
|
idx = str(alu.get('num_indice', ''))
|
||||||
|
if idx not in alumnos_por_curso: alumnos_por_curso[idx] = []
|
||||||
|
alumnos_por_curso[idx].append(alu)
|
||||||
|
|
||||||
|
for curso in datos:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
nombre_curso_actual = str(curso.get('dsc_programa', ''))
|
||||||
|
fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio'))
|
||||||
|
|
||||||
|
# Identifica si el curso actual pertenece a la "Línea Universal" de 5 categorías
|
||||||
|
linea_actual = self.identificar_linea_carrera(nombre_curso_actual)
|
||||||
|
|
||||||
|
if not linea_actual or not fecha_inicio_actual: continue
|
||||||
|
|
||||||
|
lista_alumnos = alumnos_por_curso.get(num_indice, [])
|
||||||
|
contador_fieles = 0
|
||||||
|
|
||||||
|
for alumno in lista_alumnos:
|
||||||
|
estado = str(alumno.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado not in ['ALU', 'PRE']: continue
|
||||||
|
|
||||||
|
mat_id = str(alumno.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
|
||||||
|
if mat_id in alumnos_refriperu:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dni = str(alumno.get('dsc_documento', 'SD')).strip()
|
||||||
|
nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip()
|
||||||
|
|
||||||
|
es_manual_si = False
|
||||||
|
if mat_id in correcciones_continuidad:
|
||||||
|
accion = str(correcciones_continuidad[mat_id]).upper().strip()
|
||||||
|
if accion == "SI": es_manual_si = True
|
||||||
|
elif accion == "NO": continue
|
||||||
|
|
||||||
|
es_fiel = False
|
||||||
|
motivo = ""
|
||||||
|
|
||||||
|
if es_manual_si:
|
||||||
|
es_fiel = True
|
||||||
|
motivo = "MANUAL (JSON)"
|
||||||
|
elif dni and dni in datos_historial:
|
||||||
|
historial_alumno = datos_historial[dni]
|
||||||
|
for antecedente in historial_alumno:
|
||||||
|
nombre_pasado = str(antecedente.get('programa', ''))
|
||||||
|
fecha_pasada = antecedente.get('fecha')
|
||||||
|
if not fecha_pasada: continue
|
||||||
|
if not (fecha_pasada < fecha_inicio_actual): continue
|
||||||
|
dias_diff = (fecha_inicio_actual - fecha_pasada).days
|
||||||
|
if dias_diff < MIN_DIAS_DIFERENCIA: continue
|
||||||
|
|
||||||
|
# Revisa si el curso pasado también es de esa misma línea universal
|
||||||
|
linea_pasada = self.identificar_linea_carrera(nombre_pasado)
|
||||||
|
|
||||||
|
# Si ambos devuelven "CARRERA_COMPATIBLE", entonces hacen match
|
||||||
|
if linea_pasada == linea_actual:
|
||||||
|
es_fiel = True
|
||||||
|
motivo = f"Viene de: {nombre_pasado}"
|
||||||
|
break
|
||||||
|
|
||||||
|
if es_fiel:
|
||||||
|
contador_fieles += 1
|
||||||
|
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_curso_actual[:30]:<30} | {motivo}")
|
||||||
|
|
||||||
|
conteo_por_curso[num_indice] = contador_fieles
|
||||||
|
|
||||||
|
for curso in datos:
|
||||||
|
idx = str(curso.get('num_indice', ''))
|
||||||
|
curso['Inscritos_Continuidad'] = conteo_por_curso.get(idx, 0)
|
||||||
|
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def calcular_dias_para_inicio(self, fecha_str, cod_estado):
|
||||||
|
try:
|
||||||
|
if cod_estado == 'SUS': return "SUSPENDIDO"
|
||||||
|
if not fecha_str: return ""
|
||||||
|
if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-':
|
||||||
|
fecha_curso = datetime.strptime(fecha_str, '%d-%m-%Y')
|
||||||
|
else:
|
||||||
|
fecha_curso = datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S')
|
||||||
|
hoy = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
fecha_curso = fecha_curso.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
diferencia = (fecha_curso - hoy).days
|
||||||
|
if diferencia < 0: return "INICIADO"
|
||||||
|
return str(diferencia) if diferencia != 0 else "0"
|
||||||
|
except: return ""
|
||||||
|
|
||||||
|
def calcular_dias_para_inicio_cursos(self, datos):
|
||||||
|
for curso in datos:
|
||||||
|
curso['dias_para_inicio'] = self.calcular_dias_para_inicio(curso.get('fch_inicio'), curso.get('cod_estado'))
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def agregar_inscritos_mes(self, datos, datos_matriculas):
|
||||||
|
dict_norm = {str(k): v for k, v in datos_matriculas.items()}
|
||||||
|
for curso in datos:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
curso['Inscritos_Mes'] = dict_norm.get(num_indice, 0)
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def agregar_inscritos_pc(self, datos, datos_raw_matriculas):
|
||||||
|
conteo_pc = {}
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for m in datos_raw_matriculas:
|
||||||
|
try:
|
||||||
|
est = str(m.get('estado_matricula', '')).strip()
|
||||||
|
nid = str(m.get('num_indice', ''))
|
||||||
|
sm = float(m.get('imp_saldo_matricula', 0) or 0)
|
||||||
|
sc1 = float(m.get('imp_saldo_cuota1', 0) or 0)
|
||||||
|
if est in ['ALU', 'PRE'] and sm < 1 and sc1 < 1:
|
||||||
|
conteo_pc[nid] = conteo_pc.get(nid, 0) + 1
|
||||||
|
except: continue
|
||||||
|
for curso in datos:
|
||||||
|
curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
curso['Retirados'] = int(curso.get('Inscritos_Retirados', 0)) # <--- NUEVA EXTRACCIÓN
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def calcular_meta_curso(self, nombre_programa, cod_estado):
|
||||||
|
try:
|
||||||
|
if cod_estado == 'SUS': return 0
|
||||||
|
|
||||||
|
categoria = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
|
||||||
|
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
|
||||||
|
if categoria in clasificaciones:
|
||||||
|
return clasificaciones[categoria].get('valor', 0)
|
||||||
|
|
||||||
|
return self.data_manager.meta_data.get('clasificacion_default', {}).get('valor', 15)
|
||||||
|
except: return 0
|
||||||
|
|
||||||
|
def agregar_meta_curso(self, datos):
|
||||||
|
print("🎯 Calculando META_CURSO...")
|
||||||
|
for curso in datos:
|
||||||
|
curso['Meta_Curso'] = self.calcular_meta_curso(curso.get('dsc_programa', ''), curso.get('cod_estado', ''))
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def calcular_avance_inscritos(self, inscritos_activos, meta_curso):
|
||||||
|
try:
|
||||||
|
if meta_curso > 0:
|
||||||
|
porcentaje = (inscritos_activos / meta_curso) * 100
|
||||||
|
return f"{porcentaje:.1f}%"
|
||||||
|
else: return "0%"
|
||||||
|
except: return "0%"
|
||||||
|
|
||||||
|
def agregar_avance_inscritos(self, datos):
|
||||||
|
print("📊 Calculando AVANCE_INSCRITOS...")
|
||||||
|
for curso in datos:
|
||||||
|
curso['Avance_Inscritos'] = self.calcular_avance_inscritos(curso.get('Inscritos_Totales', 0), curso.get('Meta_Curso', 0))
|
||||||
|
return datos
|
||||||
0
backend/backend/modules/rentabilidad/__init__.py
Normal file
0
backend/backend/modules/rentabilidad/__init__.py
Normal file
950
backend/backend/modules/rentabilidad/logic.py
Normal file
950
backend/backend/modules/rentabilidad/logic.py
Normal file
@@ -0,0 +1,950 @@
|
|||||||
|
# modules/rentabilidad/logic.py
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from .processor import RentabilidadProcessor
|
||||||
|
|
||||||
|
class RentabilidadLogic:
|
||||||
|
"""Controlador lógico de Rentabilidad - Estructura la tabla y gestiona variables de clasificación"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.processor = RentabilidadProcessor(self.data_manager)
|
||||||
|
|
||||||
|
def obtener_categorias(self):
|
||||||
|
"""Devuelve la lista simplificada y agrupada para el filtro del Dashboard"""
|
||||||
|
return ["AREQUIPA", "TRUJILLO", "PIURA", "PROGRAMAS", "SEMINARIOS", "OTROS"]
|
||||||
|
|
||||||
|
def obtener_sedes(self):
|
||||||
|
"""Devuelve la lista de sedes para el nuevo filtro"""
|
||||||
|
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
|
||||||
|
|
||||||
|
def identificar_sede(self, dsc_programa):
|
||||||
|
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
|
||||||
|
if not dsc_programa: return "LIMA"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
|
||||||
|
|
||||||
|
for sede, data in sedes_data.items():
|
||||||
|
if sede == "DEFAULT": continue
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return sede
|
||||||
|
return sedes_data.get("DEFAULT", "LIMA")
|
||||||
|
|
||||||
|
def obtener_filtros_programa(self):
|
||||||
|
"""Devuelve la lista de programas para el nuevo filtro combinado"""
|
||||||
|
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||||
|
|
||||||
|
def identificar_filtro_programa(self, dsc_programa):
|
||||||
|
"""Identifica la categoría del programa para el filtro de la UI"""
|
||||||
|
if not dsc_programa: return "OTROS"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
|
||||||
|
|
||||||
|
for categoria, data in filtro_data.items():
|
||||||
|
if categoria == "DEFAULT": continue
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return categoria
|
||||||
|
return filtro_data.get("DEFAULT", "OTROS")
|
||||||
|
|
||||||
|
def clasificar_programa(self, dsc_programa):
|
||||||
|
if not dsc_programa: return "OTROS"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
meta_data = self.data_manager.meta_data
|
||||||
|
clasificaciones = meta_data.get("clasificacion_programas", {})
|
||||||
|
|
||||||
|
for clase, data in clasificaciones.items():
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return clase
|
||||||
|
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
|
||||||
|
|
||||||
|
def identificar_linea_carrera(self, nombre_programa):
|
||||||
|
"""Identifica si el programa es de la familia de Carreras Técnicas"""
|
||||||
|
categoria = self.clasificar_programa(nombre_programa)
|
||||||
|
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
|
||||||
|
return "CARRERA_COMPATIBLE"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
"""Convierte diferentes formatos de fecha a objeto datetime"""
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-':
|
||||||
|
return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calcular_descuento(self, clasificacion, inv_neta):
|
||||||
|
try: inv_neta = float(inv_neta)
|
||||||
|
except: inv_neta = 0.0
|
||||||
|
|
||||||
|
if clasificacion in ["TEAC", "TERC"]: return 3499.0 - inv_neta
|
||||||
|
elif clasificacion in ["AREQUIPA", "PIURA", "TRUJILLO"]: return 2839.0 - inv_neta
|
||||||
|
elif clasificacion == "CARRERA": return 4299.0 - inv_neta
|
||||||
|
else: return None
|
||||||
|
|
||||||
|
def calcular_estado_descuento(self, clasificacion, descuento):
|
||||||
|
if descuento is None: return ""
|
||||||
|
es_provincia = clasificacion in ["TRUJILLO", "AREQUIPA", "PIURA"]
|
||||||
|
tope = 1000.0 if es_provincia else 800.0
|
||||||
|
|
||||||
|
if descuento > tope or descuento < 0: return "NO"
|
||||||
|
return "SI"
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
|
||||||
|
# ========================================================================
|
||||||
|
# ========================================================================
|
||||||
|
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
|
||||||
|
# ========================================================================
|
||||||
|
def _obtener_costo_segun_categoria(self, diccionario_costo, categoria, dsc_programa):
|
||||||
|
"""Busca el costo inteligente. Si es 'otros', busca similitud de palabras."""
|
||||||
|
cat_lower = str(categoria).lower()
|
||||||
|
|
||||||
|
# 1. Agrupamos los técnicos dentro de 'programas'
|
||||||
|
if cat_lower in ["teac", "terc"]:
|
||||||
|
cat_lower = "programas"
|
||||||
|
|
||||||
|
# 2. EL EMBUDO: Si la categoría no existe en el diccionario (ej. seminarios, masterclass, etc.),
|
||||||
|
# la forzamos a que caiga siempre en la bolsa de "otros"
|
||||||
|
if cat_lower not in diccionario_costo:
|
||||||
|
cat_lower = "otros"
|
||||||
|
|
||||||
|
# 3. Buscamos el valor
|
||||||
|
if cat_lower in diccionario_costo:
|
||||||
|
valor = diccionario_costo[cat_lower]
|
||||||
|
|
||||||
|
# Si el valor es un bloque de similitudes (Como sucede ahora con "otros")
|
||||||
|
if isinstance(valor, dict):
|
||||||
|
dsc_upper = str(dsc_programa).upper()
|
||||||
|
|
||||||
|
# Buscamos coincidencias con el nombre completo del curso
|
||||||
|
for patron, monto in valor.items():
|
||||||
|
if patron != "DEFAULT" and patron.upper() in dsc_upper:
|
||||||
|
return float(monto)
|
||||||
|
|
||||||
|
# Si lee todo el diccionario y no encuentra coincidencia, usamos el DEFAULT
|
||||||
|
return float(valor.get("DEFAULT", 0))
|
||||||
|
else:
|
||||||
|
# Si es un número directo (ej. piura, trujillo, carrera)
|
||||||
|
return float(valor)
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
|
||||||
|
"""Obtiene datos, inyecta Categoria_Programa, Sede, Filtro_Programa y calcula promedios."""
|
||||||
|
datos = self.processor.obtener_datos_procesados(ano, mes)
|
||||||
|
programas_disponibles = set()
|
||||||
|
|
||||||
|
if datos:
|
||||||
|
for d in datos:
|
||||||
|
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
|
||||||
|
d['Categoria_Programa'] = self.clasificar_programa(prog)
|
||||||
|
d['Sede'] = self.identificar_sede(prog)
|
||||||
|
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
|
||||||
|
|
||||||
|
# Recolectar qué programas existen REALMENTE en la Sede seleccionada
|
||||||
|
if sede == "TODOS" or d['Sede'] == sede:
|
||||||
|
programas_disponibles.add(d['Filtro_Programa'])
|
||||||
|
|
||||||
|
# Ordenar para el Dropdown en la UI
|
||||||
|
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||||
|
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
|
||||||
|
|
||||||
|
# AUTO-CORRECCIÓN: Si el programa elegido ya no existe en la nueva sede, forzamos a TODOS
|
||||||
|
if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles:
|
||||||
|
filtro_prog = "TODOS"
|
||||||
|
self.filtro_corregido = "TODOS"
|
||||||
|
else:
|
||||||
|
self.filtro_corregido = None
|
||||||
|
|
||||||
|
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
|
||||||
|
datos_filtrados = []
|
||||||
|
for d in datos:
|
||||||
|
cumple_sede = (sede == "TODOS" or d.get('Sede') == sede)
|
||||||
|
cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog)
|
||||||
|
|
||||||
|
if cumple_sede and cumple_prog:
|
||||||
|
datos_filtrados.append(d)
|
||||||
|
datos = datos_filtrados
|
||||||
|
|
||||||
|
if not datos: return []
|
||||||
|
|
||||||
|
sql_matriculas = self._get_sql_matriculas_modificado()
|
||||||
|
sql_cuotas = self._get_sql_cuotas()
|
||||||
|
|
||||||
|
matriculados_raw = []
|
||||||
|
cuotas_raw = []
|
||||||
|
|
||||||
|
if sql_matriculas and sql_cuotas:
|
||||||
|
try:
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute(sql_matriculas, ano, mes)
|
||||||
|
cols_mat = [c[0] for c in cursor.description]
|
||||||
|
matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
cursor.execute(sql_cuotas, ano, mes)
|
||||||
|
cols_cuo = [c[0] for c in cursor.description]
|
||||||
|
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error en consultas DAX: {e}")
|
||||||
|
|
||||||
|
promedios_dax = self._calcular_promedios_dax(matriculados_raw, cuotas_raw)
|
||||||
|
|
||||||
|
for d in datos:
|
||||||
|
idx = str(d.get('num_indice', '')).strip()
|
||||||
|
if idx.endswith('.0'): idx = idx[:-2]
|
||||||
|
|
||||||
|
if idx in promedios_dax:
|
||||||
|
d['Promedio_Cuota'] = promedios_dax[idx]['promedio']
|
||||||
|
d['Suma_Dax_Real'] = promedios_dax[idx]['suma_real']
|
||||||
|
d['Conteo_Dax_Real'] = promedios_dax[idx]['conteo_real']
|
||||||
|
d['Promedio_Desc_E'] = promedios_dax[idx]['promedio_desc_e']
|
||||||
|
d['Suma_Desc_E_Real'] = promedios_dax[idx]['suma_desc_e_real']
|
||||||
|
d['Conteo_Desc_E_Real'] = promedios_dax[idx]['conteo_desc_e_real']
|
||||||
|
d['Valor_Venta'] = promedios_dax[idx]['suma_valor_venta']
|
||||||
|
d['Valor_Venta_Actual'] = promedios_dax[idx]['suma_valor_venta_actual']
|
||||||
|
else:
|
||||||
|
d['Promedio_Cuota'] = 0.0
|
||||||
|
d['Suma_Dax_Real'] = 0.0
|
||||||
|
d['Conteo_Dax_Real'] = 0
|
||||||
|
d['Promedio_Desc_E'] = 0.0
|
||||||
|
d['Suma_Desc_E_Real'] = 0.0
|
||||||
|
d['Conteo_Desc_E_Real'] = 0
|
||||||
|
d['Valor_Venta'] = 0.0
|
||||||
|
d['Valor_Venta_Actual'] = 0.0
|
||||||
|
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def _calcular_promedios_dax(self, matriculados, cuotas):
|
||||||
|
alumnos_dict = {}
|
||||||
|
agrupado_indices = {}
|
||||||
|
|
||||||
|
lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]
|
||||||
|
correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {})
|
||||||
|
correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {})
|
||||||
|
|
||||||
|
for m in matriculados:
|
||||||
|
estado_mat = str(m.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado_mat not in ['ALU', 'PRE', 'RET']: continue
|
||||||
|
|
||||||
|
num_matricula = str(m.get('num_matricula', '')).strip()
|
||||||
|
if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2]
|
||||||
|
|
||||||
|
num_indice = str(m.get('num_indice', '')).strip()
|
||||||
|
if num_indice.endswith('.0'): num_indice = num_indice[:-2]
|
||||||
|
|
||||||
|
prog = str(m.get('dsc_programa', ''))
|
||||||
|
try: inv_neta = float(m.get('INV_NETA', 0.0))
|
||||||
|
except: inv_neta = 0.0
|
||||||
|
|
||||||
|
tc_override = None
|
||||||
|
if num_matricula in correcciones_mat:
|
||||||
|
if "imp_tc" in correcciones_mat[num_matricula]:
|
||||||
|
tc_override = float(correcciones_mat[num_matricula]["imp_tc"])
|
||||||
|
|
||||||
|
# TC del COMPROBANTE — único TC para todo (INV_NETA + cuotas)
|
||||||
|
if tc_override is not None:
|
||||||
|
imp_tc_comp = tc_override
|
||||||
|
else:
|
||||||
|
try: imp_tc_comp = float(m.get('imp_tc', 0) or 0)
|
||||||
|
except: imp_tc_comp = 0
|
||||||
|
if imp_tc_comp < 2: imp_tc_comp = 3.45 # TC por defecto
|
||||||
|
|
||||||
|
# CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar)
|
||||||
|
if num_matricula in correcciones_inv_neta:
|
||||||
|
try: inv_neta = float(correcciones_inv_neta[num_matricula])
|
||||||
|
except: pass
|
||||||
|
else:
|
||||||
|
cod_moneda_mat = str(m.get('cod_moneda', '')).strip().upper()
|
||||||
|
if cod_moneda_mat == "DOL":
|
||||||
|
inv_neta = inv_neta * imp_tc_comp
|
||||||
|
|
||||||
|
clase = self.clasificar_programa(prog)
|
||||||
|
descuento = self.calcular_descuento(clase, inv_neta)
|
||||||
|
estado_desc = self.calcular_estado_descuento(clase, descuento)
|
||||||
|
|
||||||
|
alumnos_dict[num_matricula] = {
|
||||||
|
'num_indice': num_indice,
|
||||||
|
'clasificacion': clase,
|
||||||
|
'programa': prog,
|
||||||
|
'estado_descuento': estado_desc,
|
||||||
|
'inv_neta': inv_neta,
|
||||||
|
'estado_mat': estado_mat,
|
||||||
|
'monto_real_cuotas': 0.0,
|
||||||
|
'imp_tc_comprobante': imp_tc_comp
|
||||||
|
}
|
||||||
|
|
||||||
|
if num_indice not in agrupado_indices:
|
||||||
|
agrupado_indices[num_indice] = {
|
||||||
|
'suma': 0.0, 'conteo': 0,
|
||||||
|
'suma_desc_e': 0.0, 'conteo_desc_e': 0,
|
||||||
|
'suma_valor_venta': 0.0,
|
||||||
|
'suma_valor_venta_actual': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
agrupado_indices[num_indice]['suma_valor_venta'] += inv_neta
|
||||||
|
|
||||||
|
# ALU y PRE: suma INV_NETA completa
|
||||||
|
if estado_mat in ['ALU', 'PRE']:
|
||||||
|
agrupado_indices[num_indice]['suma_valor_venta_actual'] += inv_neta
|
||||||
|
# RET: suma solo lo que ya pagó
|
||||||
|
elif estado_mat == 'RET':
|
||||||
|
try: imp_pagado = float(m.get('imp_total_pagado', 0) or 0)
|
||||||
|
except: imp_pagado = 0.0
|
||||||
|
agrupado_indices[num_indice]['suma_valor_venta_actual'] += imp_pagado
|
||||||
|
|
||||||
|
if clase not in lista_mirar_cuotas:
|
||||||
|
agrupado_indices[num_indice]['suma'] += inv_neta
|
||||||
|
agrupado_indices[num_indice]['conteo'] += 1
|
||||||
|
|
||||||
|
for c in cuotas:
|
||||||
|
num_matricula = str(c.get('num_matricula', '')).strip()
|
||||||
|
if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2]
|
||||||
|
|
||||||
|
if num_matricula not in alumnos_dict: continue
|
||||||
|
|
||||||
|
alumno = alumnos_dict[num_matricula]
|
||||||
|
num_idx = alumno['num_indice']
|
||||||
|
|
||||||
|
if alumno['clasificacion'] in lista_mirar_cuotas:
|
||||||
|
try: num_cuota = int(c.get('num_cuota', 0))
|
||||||
|
except: num_cuota = 0
|
||||||
|
|
||||||
|
t_val = c.get('imp_total')
|
||||||
|
d_val = c.get('imp_dscto')
|
||||||
|
|
||||||
|
try: t_monto = float(t_val) if t_val is not None else 0.0
|
||||||
|
except: t_monto = 0.0
|
||||||
|
try: d_monto = float(d_val) if d_val is not None else 0.0
|
||||||
|
except: d_monto = 0.0
|
||||||
|
|
||||||
|
imp_cuota = t_monto - d_monto
|
||||||
|
|
||||||
|
cod_moneda_cuo = str(c.get('cod_moneda', '')).strip().upper()
|
||||||
|
if cod_moneda_cuo == "DOL":
|
||||||
|
# USAR el TC del COMPROBANTE (ya validado con fallback 3.45)
|
||||||
|
tc_cuo = alumno['imp_tc_comprobante']
|
||||||
|
imp_cuota = imp_cuota * tc_cuo
|
||||||
|
|
||||||
|
cumple_cuota_normal = (alumno['estado_descuento'] == "SI" and num_cuota > 0 and imp_cuota < 1000)
|
||||||
|
cumple_desc_especial = (alumno['estado_descuento'] == "SI" and num_cuota > 0)
|
||||||
|
|
||||||
|
if cumple_cuota_normal:
|
||||||
|
agrupado_indices[num_idx]['suma'] += imp_cuota
|
||||||
|
agrupado_indices[num_idx]['conteo'] += 1
|
||||||
|
|
||||||
|
if cumple_desc_especial:
|
||||||
|
alumno['monto_real_cuotas'] += imp_cuota
|
||||||
|
|
||||||
|
for mat_id, alumno in alumnos_dict.items():
|
||||||
|
idx = alumno['num_indice']
|
||||||
|
clase = alumno['clasificacion']
|
||||||
|
estado_desc = alumno['estado_descuento']
|
||||||
|
|
||||||
|
if clase in lista_mirar_cuotas:
|
||||||
|
monto_real = alumno['monto_real_cuotas']
|
||||||
|
else:
|
||||||
|
monto_real = alumno['inv_neta']
|
||||||
|
|
||||||
|
valor_final = 0.0
|
||||||
|
|
||||||
|
if monto_real >= 1900 and estado_desc == "SI":
|
||||||
|
if clase in ["TEAC", "TERC"]:
|
||||||
|
desc_calc = 3400 - monto_real
|
||||||
|
elif clase in ["AREQUIPA", "PIURA", "TRUJILLO"]:
|
||||||
|
desc_calc = 2740 - monto_real
|
||||||
|
else:
|
||||||
|
desc_calc = 0
|
||||||
|
|
||||||
|
resta = desc_calc - 200
|
||||||
|
valor_final = min(max(resta, 0.0), 200.0)
|
||||||
|
|
||||||
|
agrupado_indices[idx]['suma_desc_e'] += valor_final
|
||||||
|
agrupado_indices[idx]['conteo_desc_e'] += 1
|
||||||
|
|
||||||
|
promedios = {}
|
||||||
|
for idx, totales in agrupado_indices.items():
|
||||||
|
suma_total = totales['suma']
|
||||||
|
conteo_total = totales['conteo']
|
||||||
|
suma_desc_e = totales['suma_desc_e']
|
||||||
|
conteo_desc_e = totales['conteo_desc_e']
|
||||||
|
|
||||||
|
promedios[idx] = {
|
||||||
|
'promedio': suma_total / conteo_total if conteo_total > 0 else 0.0,
|
||||||
|
'suma_real': suma_total,
|
||||||
|
'conteo_real': conteo_total,
|
||||||
|
'promedio_desc_e': suma_desc_e / conteo_desc_e if conteo_desc_e > 0 else 0.0,
|
||||||
|
'suma_desc_e_real': suma_desc_e,
|
||||||
|
'conteo_desc_e_real': conteo_desc_e,
|
||||||
|
'suma_valor_venta': totales['suma_valor_venta'],
|
||||||
|
'suma_valor_venta_actual': totales['suma_valor_venta_actual'] # <-- NUEVO
|
||||||
|
}
|
||||||
|
|
||||||
|
return promedios
|
||||||
|
|
||||||
|
def formatear_datos_para_tabla(self, datos):
|
||||||
|
"""Estructura las filas finales para tksheet (14 columnas)"""
|
||||||
|
if not datos: return []
|
||||||
|
|
||||||
|
cfg_costos = self.data_manager.costos_data
|
||||||
|
if not cfg_costos:
|
||||||
|
cfg_costos = {
|
||||||
|
"epp": {"piura": 28, "trujillo": 28, "arequipa": 28, "programas": 28, "carrera": 28, "otros": 0},
|
||||||
|
"certificado": {"piura": 7, "trujillo": 7, "arequipa": 7, "programas": 7, "carrera": 7, "otros": 0},
|
||||||
|
"consumibles": {"piura": 676, "trujillo": 676, "arequipa": 676, "programas": 614, "carrera": 614, "otros": 0},
|
||||||
|
"marketing": {"piura": 2839, "trujillo": 2839, "arequipa": 2839, "programas": 3499, "carrera": 3499, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 1000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 900, "DIPLOMADO INTERNACIONAL": 1300, "SEMINARIO VIRTUAL METRADO , COSTEO": 800, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 1000, "DEFAULT": 0}},
|
||||||
|
"docente": {"piura": 5115, "trujillo": 5115, "arequipa": 6500, "programas": 4560, "carrera": 4560, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 4000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 3000, "DIPLOMADO INTERNACIONAL": 7000, "SEMINARIO VIRTUAL METRADO , COSTEO": 2000, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 4000, "DEFAULT": 0}}
|
||||||
|
}
|
||||||
|
|
||||||
|
# CARGA OVERRIDES DESDE SUPABASE (una sola vez por render)
|
||||||
|
overrides_supabase = self.data_manager.cargar_overrides_costos()
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
tot_total = tot_retirados = tot_curso = 0
|
||||||
|
tot_venta = 0.0
|
||||||
|
tot_venta_actual = 0.0
|
||||||
|
tot_costo = 0.0
|
||||||
|
tot_costo_actual = 0.0
|
||||||
|
|
||||||
|
bolsa_plata_global = 0.0
|
||||||
|
bolsa_recibos_global = 0
|
||||||
|
bolsa_desc_e_plata_global = 0.0
|
||||||
|
bolsa_desc_e_recibos_global = 0
|
||||||
|
|
||||||
|
for d in datos:
|
||||||
|
try: total_inscritos = int(d.get('Inscritos_Totales', 0))
|
||||||
|
except: total_inscritos = 0
|
||||||
|
try: retirados = int(d.get('Retirados', 0))
|
||||||
|
except: retirados = 0
|
||||||
|
try: en_curso = int(d.get('Inscritos_En_Curso', d.get('Inscritos_Activos', 0)))
|
||||||
|
except: en_curso = 0
|
||||||
|
|
||||||
|
p_cuota = float(d.get('Promedio_Cuota', 0.0))
|
||||||
|
p_desc = float(d.get('Promedio_Desc_E', 0.0))
|
||||||
|
venta = float(d.get('Valor_Venta', 0.0))
|
||||||
|
venta_actual = float(d.get('Valor_Venta_Actual', 0.0))
|
||||||
|
|
||||||
|
conteo_cuota_real = int(d.get('Conteo_Dax_Real', 0))
|
||||||
|
str_cuota_tabla = f"S/ {p_cuota:,.0f}" if conteo_cuota_real > 0 else ""
|
||||||
|
|
||||||
|
conteo_desc_e = int(d.get('Conteo_Desc_E_Real', 0))
|
||||||
|
str_desc_e_tabla = f"S/ {p_desc:,.0f}" if conteo_desc_e > 0 else ""
|
||||||
|
|
||||||
|
cat = d.get('Categoria_Programa', 'OTROS')
|
||||||
|
nombre_prog_crudo = d.get('dsc_programa', '')
|
||||||
|
|
||||||
|
# Identificador de curso para buscar override
|
||||||
|
num_idx_curso = str(d.get('num_indice', '')).strip()
|
||||||
|
if num_idx_curso.endswith('.0'): num_idx_curso = num_idx_curso[:-2]
|
||||||
|
ov_curso = overrides_supabase.get(num_idx_curso, {})
|
||||||
|
ov_inicial = ov_curso.get('inicial', {})
|
||||||
|
ov_actual = ov_curso.get('actual', {})
|
||||||
|
|
||||||
|
# ============= COSTO INICIAL (con override si existe) =============
|
||||||
|
def _costo_inicial(key_supa, key_cfg, multiplicar):
|
||||||
|
val = ov_inicial.get(key_supa)
|
||||||
|
if val is not None:
|
||||||
|
return float(val) # Supabase manda directo, sin multiplicar
|
||||||
|
base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo)
|
||||||
|
return total_inscritos * base if multiplicar else base
|
||||||
|
|
||||||
|
costo_epp = _costo_inicial('epp', 'epp', True)
|
||||||
|
costo_cert = _costo_inicial('certificado', 'certificado', True)
|
||||||
|
costo_cons = _costo_inicial('consumibles', 'consumibles', True)
|
||||||
|
costo_mkt = _costo_inicial('marketing', 'marketing', False)
|
||||||
|
costo_doc = _costo_inicial('docente', 'docente', False)
|
||||||
|
valor_costo_inicial = costo_epp + costo_cert + costo_cons + costo_mkt + costo_doc
|
||||||
|
|
||||||
|
# ============= COSTO ACTUAL (con override si existe) =============
|
||||||
|
def _costo_actual(key_supa, key_cfg, multiplicador):
|
||||||
|
val = ov_actual.get(key_supa)
|
||||||
|
if val is not None:
|
||||||
|
return float(val) # Supabase manda directo, sin multiplicar
|
||||||
|
base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo)
|
||||||
|
return multiplicador * base if multiplicador is not None else base
|
||||||
|
|
||||||
|
costo_epp_actual = _costo_actual('epp', 'epp', total_inscritos)
|
||||||
|
costo_cert_actual = _costo_actual('certificado', 'certificado', en_curso)
|
||||||
|
costo_cons_actual = _costo_actual('consumibles', 'consumibles', total_inscritos)
|
||||||
|
costo_mkt_actual = _costo_actual('marketing', 'marketing', None)
|
||||||
|
costo_doc_actual = _costo_actual('docente', 'docente', None)
|
||||||
|
costo_actual = costo_epp_actual + costo_cert_actual + costo_cons_actual + costo_mkt_actual + costo_doc_actual
|
||||||
|
|
||||||
|
# MARGEN BRUTO %
|
||||||
|
if venta > 0:
|
||||||
|
margen_bruto = 1 - (valor_costo_inicial / venta)
|
||||||
|
str_margen = f"{margen_bruto * 100:,.1f}%"
|
||||||
|
else:
|
||||||
|
str_margen = ""
|
||||||
|
|
||||||
|
# MARGEN BRUTO ACTUAL %
|
||||||
|
if venta_actual > 0:
|
||||||
|
margen_bruto_actual = 1 - (costo_actual / venta_actual)
|
||||||
|
str_margen_actual = f"{margen_bruto_actual * 100:,.1f}%"
|
||||||
|
else:
|
||||||
|
str_margen_actual = ""
|
||||||
|
|
||||||
|
tot_total += total_inscritos
|
||||||
|
tot_retirados += retirados
|
||||||
|
tot_curso += en_curso
|
||||||
|
tot_venta += venta
|
||||||
|
tot_venta_actual += venta_actual
|
||||||
|
tot_costo += valor_costo_inicial
|
||||||
|
tot_costo_actual += costo_actual
|
||||||
|
|
||||||
|
bolsa_plata_global += float(d.get('Suma_Dax_Real', 0.0))
|
||||||
|
bolsa_recibos_global += int(d.get('Conteo_Dax_Real', 0))
|
||||||
|
bolsa_desc_e_plata_global += float(d.get('Suma_Desc_E_Real', 0.0))
|
||||||
|
bolsa_desc_e_recibos_global += int(d.get('Conteo_Desc_E_Real', 0))
|
||||||
|
|
||||||
|
nombre_prog = d.get('programa_frecuencia', d.get('dsc_programa', ''))
|
||||||
|
|
||||||
|
fila = [
|
||||||
|
nombre_prog,
|
||||||
|
d.get('fch_inicio', ''),
|
||||||
|
total_inscritos,
|
||||||
|
retirados,
|
||||||
|
en_curso,
|
||||||
|
str_cuota_tabla,
|
||||||
|
str_desc_e_tabla,
|
||||||
|
f"S/ {venta:,.0f}",
|
||||||
|
f"S/ {valor_costo_inicial:,.0f}",
|
||||||
|
str_margen,
|
||||||
|
f"S/ {venta_actual:,.0f}",
|
||||||
|
f"S/ {costo_actual:,.0f}",
|
||||||
|
str_margen_actual,
|
||||||
|
" ≡ ▼ "
|
||||||
|
]
|
||||||
|
sheet_data.append(fila)
|
||||||
|
|
||||||
|
promedio_total_final = (bolsa_plata_global / bolsa_recibos_global) if bolsa_recibos_global > 0 else 0.0
|
||||||
|
promedio_desc_e_final = (bolsa_desc_e_plata_global / bolsa_desc_e_recibos_global) if bolsa_desc_e_recibos_global > 0 else 0.0
|
||||||
|
|
||||||
|
str_total_cuota = f"S/ {promedio_total_final:,.0f}" if bolsa_recibos_global > 0 else ""
|
||||||
|
str_total_desc_e = f"S/ {promedio_desc_e_final:,.0f}" if bolsa_desc_e_recibos_global > 0 else ""
|
||||||
|
|
||||||
|
margen_total = 1 - (tot_costo / tot_venta) if tot_venta > 0 else 0
|
||||||
|
margen_total_actual = 1 - (tot_costo_actual / tot_venta_actual) if tot_venta_actual > 0 else 0
|
||||||
|
fila_total = [
|
||||||
|
"TOTAL GENERAL", "",
|
||||||
|
tot_total,
|
||||||
|
tot_retirados,
|
||||||
|
tot_curso,
|
||||||
|
str_total_cuota,
|
||||||
|
str_total_desc_e,
|
||||||
|
f"S/ {tot_venta:,.0f}",
|
||||||
|
f"S/ {tot_costo:,.0f}",
|
||||||
|
f"{margen_total * 100:,.1f}%",
|
||||||
|
f"S/ {tot_venta_actual:,.0f}",
|
||||||
|
f"S/ {tot_costo_actual:,.0f}",
|
||||||
|
f"{margen_total_actual * 100:,.1f}%",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
sheet_data.append(fila_total)
|
||||||
|
|
||||||
|
return sheet_data
|
||||||
|
|
||||||
|
def exportar_detalle_alumnos_excel(self, programa, headers, datos):
|
||||||
|
if not datos:
|
||||||
|
raise ValueError("No hay datos para exportar")
|
||||||
|
|
||||||
|
prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:40]
|
||||||
|
archivo = f"Detalle_Alumnos_{prog_limpio}.xlsx"
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos, columns=headers)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='Detalle_Alumnos')
|
||||||
|
worksheet = writer.sheets['Detalle_Alumnos']
|
||||||
|
|
||||||
|
try:
|
||||||
|
from openpyxl.styles import PatternFill, Font, Border, Side, Alignment
|
||||||
|
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
|
||||||
|
font_header = Font(color="FFFFFF", bold=True)
|
||||||
|
thin_border = Border(
|
||||||
|
left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"),
|
||||||
|
top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD")
|
||||||
|
)
|
||||||
|
|
||||||
|
for cell in worksheet[1]:
|
||||||
|
cell.fill = fill_header
|
||||||
|
cell.font = font_header
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
for row in worksheet.iter_rows(min_row=2):
|
||||||
|
for cell in row:
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.alignment = Alignment(vertical="center")
|
||||||
|
|
||||||
|
for col in worksheet.columns:
|
||||||
|
max_len = 0
|
||||||
|
col_letter = col[0].column_letter
|
||||||
|
for cell in col:
|
||||||
|
if cell.value:
|
||||||
|
max_len = max(max_len, len(str(cell.value)))
|
||||||
|
worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50)
|
||||||
|
except Exception: pass
|
||||||
|
except Exception as e:
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def obtener_detalle_programa(self, programa_target, ano, mes_numero):
|
||||||
|
prog_t = str(programa_target).strip().upper()
|
||||||
|
|
||||||
|
sql_matriculas = self._get_sql_matriculas_modificado()
|
||||||
|
sql_cuotas = self._get_sql_cuotas()
|
||||||
|
|
||||||
|
matriculados_raw = []
|
||||||
|
cuotas_raw = []
|
||||||
|
|
||||||
|
if sql_matriculas and sql_cuotas:
|
||||||
|
try:
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute(sql_matriculas, ano, mes_numero)
|
||||||
|
cols_mat = [c[0] for c in cursor.description]
|
||||||
|
matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
cursor.execute(sql_cuotas, ano, mes_numero)
|
||||||
|
cols_cuo = [c[0] for c in cursor.description]
|
||||||
|
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error en consultas detalle: {e}")
|
||||||
|
|
||||||
|
cuotas_por_mat = {}
|
||||||
|
for c in cuotas_raw:
|
||||||
|
m_id = str(c.get('num_matricula', '')).strip()
|
||||||
|
if m_id.endswith('.0'): m_id = m_id[:-2]
|
||||||
|
if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = []
|
||||||
|
cuotas_por_mat[m_id].append(c)
|
||||||
|
|
||||||
|
cursos = self.processor.obtener_datos_procesados(ano, mes_numero)
|
||||||
|
indices_target = []
|
||||||
|
fecha_inicio_actual = None
|
||||||
|
linea_actual = self.identificar_linea_carrera(prog_t)
|
||||||
|
|
||||||
|
for c in cursos:
|
||||||
|
nombre = str(c.get('programa_frecuencia', c.get('dsc_programa', ''))).strip().upper()
|
||||||
|
if nombre == prog_t:
|
||||||
|
idx = str(c.get('num_indice', '')).strip()
|
||||||
|
if idx.endswith('.0'): idx = idx[:-2]
|
||||||
|
indices_target.append(idx)
|
||||||
|
if not fecha_inicio_actual:
|
||||||
|
fecha_inicio_actual = self.parse_fecha(c.get('fch_inicio'))
|
||||||
|
|
||||||
|
lista_dnis = []
|
||||||
|
for m in matriculados_raw:
|
||||||
|
dni = m.get('dsc_documento')
|
||||||
|
if dni: lista_dnis.append(str(dni).strip())
|
||||||
|
|
||||||
|
try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||||
|
except: datos_historial = {}
|
||||||
|
|
||||||
|
correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||||
|
correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||||
|
correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {})
|
||||||
|
correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {})
|
||||||
|
|
||||||
|
lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]
|
||||||
|
|
||||||
|
acum_inv_neta_global = 0.0
|
||||||
|
acum_suma_cuotas_global = 0.0
|
||||||
|
acum_cont_cuotas_global = 0
|
||||||
|
acum_suma_desc_e_global = 0.0
|
||||||
|
acum_cont_desc_e_global = 0
|
||||||
|
|
||||||
|
alumnos_lista = []
|
||||||
|
|
||||||
|
for m in matriculados_raw:
|
||||||
|
estado_mat = str(m.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado_mat not in ['ALU', 'PRE', 'RET']: continue
|
||||||
|
|
||||||
|
idx_mat = str(m.get('num_indice', '')).strip()
|
||||||
|
if idx_mat.endswith('.0'): idx_mat = idx_mat[:-2]
|
||||||
|
|
||||||
|
nombre_prog_crudo = str(m.get('dsc_programa', '')).strip().upper()
|
||||||
|
|
||||||
|
if (idx_mat in indices_target) or (prog_t in nombre_prog_crudo) or (nombre_prog_crudo in prog_t):
|
||||||
|
|
||||||
|
vendedor = str(m.get('dsc_vendedor', 'SIN VENDEDOR')).strip()
|
||||||
|
if vendedor == "None" or not vendedor: vendedor = "SIN VENDEDOR"
|
||||||
|
|
||||||
|
alumno_nombre = str(m.get('nombre_alumno', m.get('dsc_alumno', 'SIN NOMBRE'))).strip()
|
||||||
|
if alumno_nombre == "None" or not alumno_nombre: alumno_nombre = "SIN NOMBRE"
|
||||||
|
|
||||||
|
mat_id = str(m.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
dni = str(m.get('dsc_documento', '')).strip()
|
||||||
|
|
||||||
|
try: inv_neta_raw = float(m.get('INV_NETA', 0.0))
|
||||||
|
except: inv_neta_raw = 0.0
|
||||||
|
cod_moneda = str(m.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
try: tc = float(m.get('imp_tc', 0) or 0)
|
||||||
|
except: tc = 0
|
||||||
|
if tc < 2: tc = 3.45 # TC por defecto si comprobante inválido
|
||||||
|
|
||||||
|
fch_canc_raw = m.get('fch_cancelacion_cuota1', '')
|
||||||
|
|
||||||
|
if mat_id in correcciones_mat:
|
||||||
|
datos_corregidos = correcciones_mat[mat_id]
|
||||||
|
if "imp_tc" in datos_corregidos: tc = float(datos_corregidos["imp_tc"])
|
||||||
|
if "fch_cancelacion_cuota1" in datos_corregidos: fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"]
|
||||||
|
|
||||||
|
# CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar)
|
||||||
|
if mat_id in correcciones_inv_neta:
|
||||||
|
try: inv_neta_soles = float(correcciones_inv_neta[mat_id])
|
||||||
|
except: inv_neta_soles = inv_neta_raw
|
||||||
|
elif cod_moneda == "DOL":
|
||||||
|
inv_neta_soles = inv_neta_raw * tc
|
||||||
|
else:
|
||||||
|
inv_neta_soles = inv_neta_raw
|
||||||
|
inv_neta_soles = round(inv_neta_soles, 2)
|
||||||
|
|
||||||
|
acum_inv_neta_global += inv_neta_soles
|
||||||
|
|
||||||
|
fch_mat = m.get('fch_matricula', '')
|
||||||
|
fecha_mat_limpia = ""
|
||||||
|
if fch_mat:
|
||||||
|
try:
|
||||||
|
if hasattr(fch_mat, 'strftime'): fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
tmp = str(fch_mat)[:10]
|
||||||
|
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fecha_mat_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
else: fecha_mat_limpia = tmp
|
||||||
|
except: fecha_mat_limpia = str(fch_mat)[:10]
|
||||||
|
|
||||||
|
fch_canc_limpia = ""
|
||||||
|
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
|
||||||
|
try:
|
||||||
|
if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
tmp = str(fch_canc_raw)[:10]
|
||||||
|
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
else: fch_canc_limpia = tmp
|
||||||
|
except: fch_canc_limpia = str(fch_canc_raw)[:10]
|
||||||
|
|
||||||
|
clase_alumno = self.clasificar_programa(nombre_prog_crudo)
|
||||||
|
|
||||||
|
es_refriperu = False
|
||||||
|
if mat_id in correcciones_desc:
|
||||||
|
if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True
|
||||||
|
elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False
|
||||||
|
else:
|
||||||
|
if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True
|
||||||
|
elif clase_alumno in ["TEAC", "TERC"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True
|
||||||
|
|
||||||
|
tipo_final = "NUEVO"
|
||||||
|
if es_refriperu: tipo_final = "REFRIPERU"
|
||||||
|
else:
|
||||||
|
es_cont = False
|
||||||
|
if mat_id in correcciones_cont:
|
||||||
|
if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True
|
||||||
|
elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False
|
||||||
|
else:
|
||||||
|
if fecha_inicio_actual and linea_actual and (dni in datos_historial):
|
||||||
|
for antecedente in datos_historial[dni]:
|
||||||
|
nombre_pasado = str(antecedente.get('programa', ''))
|
||||||
|
fecha_pasada = antecedente.get('fecha')
|
||||||
|
if not fecha_pasada: continue
|
||||||
|
if not (fecha_pasada < fecha_inicio_actual): continue
|
||||||
|
if (fecha_inicio_actual - fecha_pasada).days < 60: continue
|
||||||
|
if self.identificar_linea_carrera(nombre_pasado) == linea_actual:
|
||||||
|
es_cont = True; break
|
||||||
|
if es_cont: tipo_final = "CONTINUIDAD"
|
||||||
|
|
||||||
|
descuento = self.calcular_descuento(clase_alumno, inv_neta_soles)
|
||||||
|
estado_desc = self.calcular_estado_descuento(clase_alumno, descuento)
|
||||||
|
|
||||||
|
str_cuota_val = ""
|
||||||
|
monto_eval = 0.0
|
||||||
|
|
||||||
|
if clase_alumno in lista_mirar_cuotas:
|
||||||
|
suma_c = 0.0; cont_c = 0; monto_real_c = 0.0
|
||||||
|
for c_dict in cuotas_por_mat.get(mat_id, []):
|
||||||
|
try: num_c = int(c_dict.get('num_cuota', 0))
|
||||||
|
except: num_c = 0
|
||||||
|
t_val = c_dict.get('imp_total'); d_val = c_dict.get('imp_dscto')
|
||||||
|
try: t_monto = float(t_val) if t_val is not None else 0.0
|
||||||
|
except: t_monto = 0.0
|
||||||
|
try: d_monto = float(d_val) if d_val is not None else 0.0
|
||||||
|
except: d_monto = 0.0
|
||||||
|
imp_c = t_monto - d_monto
|
||||||
|
tc_over = None
|
||||||
|
if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]:
|
||||||
|
tc_over = float(correcciones_mat[mat_id]["imp_tc"])
|
||||||
|
cod_m_c = str(c_dict.get('cod_moneda', '')).strip().upper()
|
||||||
|
if cod_m_c == "DOL":
|
||||||
|
# USAR TC del COMPROBANTE (no del cronograma)
|
||||||
|
tc_c = tc # tc ya está validado arriba (línea 654)
|
||||||
|
imp_c *= tc_c
|
||||||
|
if estado_desc == "SI" and num_c > 0 and imp_c < 1000:
|
||||||
|
suma_c += imp_c; cont_c += 1
|
||||||
|
if estado_desc == "SI" and num_c > 0:
|
||||||
|
monto_real_c += imp_c
|
||||||
|
if cont_c > 0:
|
||||||
|
str_cuota_val = f"S/ {suma_c / cont_c:,.0f}"
|
||||||
|
monto_eval = monto_real_c
|
||||||
|
acum_suma_cuotas_global += suma_c
|
||||||
|
acum_cont_cuotas_global += cont_c
|
||||||
|
else:
|
||||||
|
str_cuota_val = f"S/ {inv_neta_soles:,.0f}"
|
||||||
|
monto_eval = inv_neta_soles
|
||||||
|
acum_suma_cuotas_global += inv_neta_soles
|
||||||
|
acum_cont_cuotas_global += 1
|
||||||
|
|
||||||
|
str_desc_e_val = ""
|
||||||
|
if monto_eval >= 1900 and estado_desc == "SI":
|
||||||
|
if clase_alumno in ["TEAC", "TERC"]: d_calc = 3400 - monto_eval
|
||||||
|
elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - monto_eval
|
||||||
|
else: d_calc = 0
|
||||||
|
desc_e_val = min(max(d_calc - 200, 0.0), 200.0)
|
||||||
|
str_desc_e_val = f"S/ {desc_e_val:,.0f}"
|
||||||
|
acum_suma_desc_e_global += desc_e_val
|
||||||
|
acum_cont_desc_e_global += 1
|
||||||
|
|
||||||
|
alumnos_lista.append([
|
||||||
|
vendedor, alumno_nombre, fecha_mat_limpia, fch_canc_limpia,
|
||||||
|
tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}",
|
||||||
|
estado_mat # columna oculta para detectar RET
|
||||||
|
])
|
||||||
|
|
||||||
|
alumnos_lista.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
prom_gral_cuota = (acum_suma_cuotas_global / acum_cont_cuotas_global) if acum_cont_cuotas_global > 0 else 0.0
|
||||||
|
prom_gral_desc = (acum_suma_desc_e_global / acum_cont_desc_e_global) if acum_cont_desc_e_global > 0 else 0.0
|
||||||
|
|
||||||
|
str_gral_cuota = f"S/ {prom_gral_cuota:,.0f}" if acum_cont_cuotas_global > 0 else ""
|
||||||
|
str_gral_desc = f"S/ {prom_gral_desc:,.0f}" if acum_cont_desc_e_global > 0 else ""
|
||||||
|
|
||||||
|
alumnos_lista.append([
|
||||||
|
"TOTAL GENERAL", "", "", "", "",
|
||||||
|
str_gral_cuota, str_gral_desc, f"S/ {acum_inv_neta_global:,.0f}",
|
||||||
|
"" # columna oculta extra para TOTAL
|
||||||
|
])
|
||||||
|
|
||||||
|
return alumnos_lista
|
||||||
|
|
||||||
|
def exportar_a_excel(self, datos, ano, mes):
|
||||||
|
if not datos: raise ValueError("No hay datos para exportar")
|
||||||
|
df = pd.DataFrame(datos)
|
||||||
|
archivo = f"reporte_rentabilidad_{ano}_{mes}.xlsx"
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def _get_sql_matriculas_modificado(self):
|
||||||
|
if not hasattr(self, '_sql_matriculas_cache') or not self._sql_matriculas_cache:
|
||||||
|
sql = self.data_manager.query_matriculas_sql
|
||||||
|
if not sql: return ""
|
||||||
|
sql = re.sub(
|
||||||
|
r'YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)',
|
||||||
|
'YEAR(sgede_RP_programa.fch_inicio)',
|
||||||
|
sql, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
sql = re.sub(
|
||||||
|
r'MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)',
|
||||||
|
'MONTH(sgede_RP_programa.fch_inicio)',
|
||||||
|
sql, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
self._sql_matriculas_cache = sql
|
||||||
|
return self._sql_matriculas_cache
|
||||||
|
|
||||||
|
def _get_sql_cuotas(self):
|
||||||
|
if not hasattr(self, '_sql_cuotas_cache') or not self._sql_cuotas_cache:
|
||||||
|
try:
|
||||||
|
res = requests.get(self.data_manager.github_cuota_url)
|
||||||
|
self._sql_cuotas_cache = res.text
|
||||||
|
except:
|
||||||
|
self._sql_cuotas_cache = ""
|
||||||
|
return self._sql_cuotas_cache
|
||||||
|
def obtener_datos_costos_programa(self, programa, ano, mes):
|
||||||
|
"""Devuelve el desglose de costos e ingresos para el modal Editar"""
|
||||||
|
datos = self.obtener_datos_procesados(ano, mes)
|
||||||
|
if not datos: return None
|
||||||
|
|
||||||
|
curso = None
|
||||||
|
for d in datos:
|
||||||
|
nombre = d.get('programa_frecuencia', d.get('dsc_programa', ''))
|
||||||
|
if str(nombre).strip() == str(programa).strip():
|
||||||
|
curso = d; break
|
||||||
|
if not curso: return None
|
||||||
|
|
||||||
|
cfg_costos = self.data_manager.costos_data
|
||||||
|
if not cfg_costos: return None
|
||||||
|
|
||||||
|
# OVERRIDES desde Supabase
|
||||||
|
num_idx = str(curso.get('num_indice', '')).strip()
|
||||||
|
if num_idx.endswith('.0'): num_idx = num_idx[:-2]
|
||||||
|
overrides = self.data_manager.cargar_overrides_costos()
|
||||||
|
ov_curso = overrides.get(num_idx, {})
|
||||||
|
ov_inicial = ov_curso.get('inicial', {})
|
||||||
|
ov_actual = ov_curso.get('actual', {})
|
||||||
|
|
||||||
|
cat = curso.get('Categoria_Programa', 'OTROS')
|
||||||
|
prog_crudo = curso.get('dsc_programa', '')
|
||||||
|
try: total_inscritos = int(curso.get('Inscritos_Totales', 0))
|
||||||
|
except: total_inscritos = 0
|
||||||
|
try: en_curso = int(curso.get('Inscritos_En_Curso', curso.get('Inscritos_Activos', 0)))
|
||||||
|
except: en_curso = 0
|
||||||
|
|
||||||
|
epp_u = self._obtener_costo_segun_categoria(cfg_costos.get("epp", {}), cat, prog_crudo)
|
||||||
|
cert_u = self._obtener_costo_segun_categoria(cfg_costos.get("certificado", {}), cat, prog_crudo)
|
||||||
|
cons_u = self._obtener_costo_segun_categoria(cfg_costos.get("consumibles", {}), cat, prog_crudo)
|
||||||
|
mkt = self._obtener_costo_segun_categoria(cfg_costos.get("marketing", {}), cat, prog_crudo)
|
||||||
|
doc = self._obtener_costo_segun_categoria(cfg_costos.get("docente", {}), cat, prog_crudo)
|
||||||
|
|
||||||
|
# INICIAL
|
||||||
|
epp_i = float(ov_inicial['epp']) if ov_inicial.get('epp') is not None else total_inscritos * epp_u
|
||||||
|
cert_i = float(ov_inicial['certificado']) if ov_inicial.get('certificado') is not None else total_inscritos * cert_u
|
||||||
|
cons_i = float(ov_inicial['consumibles']) if ov_inicial.get('consumibles') is not None else total_inscritos * cons_u
|
||||||
|
mkt_i = float(ov_inicial['marketing']) if ov_inicial.get('marketing') is not None else mkt
|
||||||
|
doc_i = float(ov_inicial['docente']) if ov_inicial.get('docente') is not None else doc
|
||||||
|
costo_i = epp_i + cert_i + cons_i + mkt_i + doc_i
|
||||||
|
|
||||||
|
# ACTUAL
|
||||||
|
epp_a = float(ov_actual['epp']) if ov_actual.get('epp') is not None else total_inscritos * epp_u
|
||||||
|
cert_a = float(ov_actual['certificado']) if ov_actual.get('certificado') is not None else en_curso * cert_u
|
||||||
|
cons_a = float(ov_actual['consumibles']) if ov_actual.get('consumibles') is not None else total_inscritos * cons_u
|
||||||
|
mkt_a = float(ov_actual['marketing']) if ov_actual.get('marketing') is not None else mkt
|
||||||
|
doc_a = float(ov_actual['docente']) if ov_actual.get('docente') is not None else doc
|
||||||
|
costo_a = epp_a + cert_a + cons_a + mkt_a + doc_a
|
||||||
|
|
||||||
|
vi = float(curso.get('Valor_Venta', 0.0))
|
||||||
|
va = float(curso.get('Valor_Venta_Actual', 0.0))
|
||||||
|
|
||||||
|
mbi = (1 - costo_i / vi) * 100 if vi > 0 else 0.0
|
||||||
|
mba = (1 - costo_a / va) * 100 if va > 0 else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'num_indice': num_idx,
|
||||||
|
'venta_inicial': vi, 'costo_inicial': costo_i, 'mb_inicial': mbi,
|
||||||
|
'venta_actual': va, 'costo_actual': costo_a, 'mb_actual': mba,
|
||||||
|
'epp_inicial': epp_i, 'cert_inicial': cert_i, 'cons_inicial': cons_i,
|
||||||
|
'mkt_inicial': mkt_i, 'doc_inicial': doc_i,
|
||||||
|
'epp_actual': epp_a, 'cert_actual': cert_a, 'cons_actual': cons_a,
|
||||||
|
'mkt_actual': mkt_a, 'doc_actual': doc_a,
|
||||||
|
# Lo que ya estaba en Supabase, para detectar qué fue editado
|
||||||
|
'ov_inicial': ov_inicial,
|
||||||
|
'ov_actual': ov_actual,
|
||||||
|
}
|
||||||
290
backend/backend/modules/rentabilidad/processor.py
Normal file
290
backend/backend/modules/rentabilidad/processor.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# modules/rentabilidad/processor.py
|
||||||
|
from datetime import datetime
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
class RentabilidadProcessor:
|
||||||
|
"""Procesador de datos para Rentabilidad - Hereda la lógica estricta de Ocupabilidad"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
|
||||||
|
# Columnas exclusivas de rentabilidad
|
||||||
|
self.columnas = [
|
||||||
|
'programa_frecuencia',
|
||||||
|
'fch_inicio',
|
||||||
|
'Descuento', # INSCRITOS REFRIPERU
|
||||||
|
'Inscritos_Continuidad', # INSCRITOS CONTINUIDAD
|
||||||
|
'Inscritos_Nuevos', # Calculado en Logic
|
||||||
|
'Inscritos_Totales', # TOTAL INSCRITOS
|
||||||
|
'Inscritos_PC', # INSCRITOS P.C
|
||||||
|
'Retirados', # INSCRITOS RETIRADOS
|
||||||
|
'Inscritos_En_Curso', # INSCRITOS ACTIVOS
|
||||||
|
'Promedio_Cuota', # Pendiente (0)
|
||||||
|
'Promedio_Desc_E', # Pendiente (0)
|
||||||
|
'Valor_Venta', # Pendiente (0)
|
||||||
|
'Opciones' # Fijo "en cu"
|
||||||
|
]
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes):
|
||||||
|
try:
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||||
|
fut_cursos = executor.submit(self.data_manager.ejecutar_consulta_cursos, ano, mes)
|
||||||
|
fut_raw = executor.submit(self.data_manager.ejecutar_consulta_matriculados_detalle, ano, mes)
|
||||||
|
fut_matriculas = executor.submit(self.data_manager.cargar_datos_matriculas, ano, mes)
|
||||||
|
|
||||||
|
datos_originales = fut_cursos.result()
|
||||||
|
datos_raw_matriculas = fut_raw.result()
|
||||||
|
datos_matriculas = fut_matriculas.result()
|
||||||
|
|
||||||
|
lista_dnis = []
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for alumno in datos_raw_matriculas:
|
||||||
|
dni = alumno.get('dsc_documento')
|
||||||
|
if dni: lista_dnis.append(dni)
|
||||||
|
|
||||||
|
datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||||
|
|
||||||
|
datos_procesados = self.aplicar_personalizaciones(
|
||||||
|
datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano, mes
|
||||||
|
)
|
||||||
|
return datos_procesados
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error obteniendo datos rentabilidad: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def normalizar_texto(self, texto):
|
||||||
|
if not texto: return ""
|
||||||
|
texto = str(texto).upper().strip()
|
||||||
|
texto = unicodedata.normalize('NFD', texto)
|
||||||
|
return texto.encode('ascii', 'ignore').decode("utf-8")
|
||||||
|
|
||||||
|
def obtener_categoria_programa(self, nombre_programa):
|
||||||
|
try:
|
||||||
|
nombre_limpio = self.normalizar_texto(nombre_programa)
|
||||||
|
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
|
||||||
|
for categoria, config in clasificaciones.items():
|
||||||
|
patrones = config.get('patrones', [])
|
||||||
|
for patron in patrones:
|
||||||
|
if self.normalizar_texto(patron) in nombre_limpio:
|
||||||
|
return categoria
|
||||||
|
return "OTROS"
|
||||||
|
except: return "OTROS"
|
||||||
|
|
||||||
|
def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro):
|
||||||
|
print("💰 Procesando variables de Rentabilidad...")
|
||||||
|
cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {})
|
||||||
|
datos_filtrados = []
|
||||||
|
|
||||||
|
try: ano_target, mes_target = int(ano_filtro), int(mes_filtro)
|
||||||
|
except: ano_target, mes_target = 0, 0
|
||||||
|
|
||||||
|
# FILTRADO Y ACTUALIZADOR EN VIVO
|
||||||
|
for curso in datos_originales:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
if num_indice in cursos_personalizados:
|
||||||
|
curso.update(cursos_personalizados[num_indice])
|
||||||
|
|
||||||
|
if curso.get('flg_activo', '') == 'NO': continue
|
||||||
|
|
||||||
|
# Si el curso está suspendido, lo ignoramos y no se muestra en Rentabilidad
|
||||||
|
if str(curso.get('cod_estado', '')).strip().upper() == 'SUS': continue
|
||||||
|
|
||||||
|
fch_valida = True
|
||||||
|
try:
|
||||||
|
raw_fecha = curso.get('fch_inicio')
|
||||||
|
if raw_fecha:
|
||||||
|
s_fecha = str(raw_fecha).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-': f_obj = datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
elif ' ' in s_fecha: f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
else: f_obj = datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
|
||||||
|
if f_obj and (f_obj.year != ano_target or f_obj.month != mes_target):
|
||||||
|
fch_valida = False
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if fch_valida: datos_filtrados.append(curso)
|
||||||
|
|
||||||
|
datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados)
|
||||||
|
datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados)
|
||||||
|
for c in datos_filtrados: c['fch_inicio'] = self.formatear_fecha(c.get('fch_inicio', ''))
|
||||||
|
|
||||||
|
# CÁLCULOS CENTRALES
|
||||||
|
datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas)
|
||||||
|
datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu)
|
||||||
|
datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas)
|
||||||
|
datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas)
|
||||||
|
|
||||||
|
# EXTRACCIÓN REAL DE BD Y VALORES PENDIENTES
|
||||||
|
for c in datos_filtrados:
|
||||||
|
c['Retirados'] = int(c.get('Inscritos_Retirados', 0)) # <--- AHORA JALA LA DATA REAL DEL SQL
|
||||||
|
|
||||||
|
c['Promedio_Cuota'] = 0.0
|
||||||
|
c['Promedio_Desc_E'] = 0.0
|
||||||
|
c['Valor_Venta'] = 0.0
|
||||||
|
c['Opciones'] = "en cu"
|
||||||
|
|
||||||
|
return datos_filtrados
|
||||||
|
|
||||||
|
def aplicar_reemplazos_programas(self, datos):
|
||||||
|
reemplazos = self.data_manager.replace_data.get('reemplazos_programas', {})
|
||||||
|
for curso in datos:
|
||||||
|
prog = curso.get('dsc_programa', '')
|
||||||
|
if prog in reemplazos: curso['dsc_programa'] = reemplazos[prog]
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def concatenar_programa_frecuencia(self, datos):
|
||||||
|
for c in datos:
|
||||||
|
p, f = c.get('dsc_programa', ''), c.get('cod_frecuencia', '')
|
||||||
|
c['programa_frecuencia'] = f"{p} - {f}" if p and f else p or f or ""
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def formatear_fecha(self, fecha_str):
|
||||||
|
try:
|
||||||
|
if not fecha_str: return ""
|
||||||
|
if len(str(fecha_str)) == 10 and str(fecha_str)[2] == '-': return str(fecha_str)
|
||||||
|
if '.' in str(fecha_str): return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y')
|
||||||
|
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y')
|
||||||
|
except: return str(fecha_str)
|
||||||
|
|
||||||
|
def identificar_linea_carrera(self, nombre_programa):
|
||||||
|
categoria = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
|
||||||
|
return "CARRERA_COMPATIBLE"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-': return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha: return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except: return None
|
||||||
|
|
||||||
|
# Lógica estricta de Refriperu
|
||||||
|
def agregar_descuento(self, datos, datos_raw_matriculas):
|
||||||
|
conteo_por_curso = {}
|
||||||
|
alumnos_refriperu = set()
|
||||||
|
correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||||
|
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for alumno in datos_raw_matriculas:
|
||||||
|
try:
|
||||||
|
if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue
|
||||||
|
num_indice = str(alumno.get('num_indice', ''))
|
||||||
|
mat_id = str(alumno.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
|
||||||
|
if mat_id in correcciones_descuento:
|
||||||
|
if str(correcciones_descuento[mat_id]).upper().strip() == "SI":
|
||||||
|
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||||
|
alumnos_refriperu.add(mat_id)
|
||||||
|
continue
|
||||||
|
elif str(correcciones_descuento[mat_id]).upper().strip() == "NO": continue
|
||||||
|
|
||||||
|
nombre_programa = str(alumno.get('dsc_programa', ''))
|
||||||
|
cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
try:
|
||||||
|
inv_neta = float(alumno.get('INV_NETA', 0) or 0)
|
||||||
|
imp_tc = float(alumno.get('imp_tc', 0) or 0)
|
||||||
|
if imp_tc <= 0: imp_tc = 3.45 # TC por defecto si no hay comprobante
|
||||||
|
except: inv_neta, imp_tc = 0.0, 3.45
|
||||||
|
|
||||||
|
inv_neta_final = round(inv_neta * imp_tc if cod_moneda == 'DOL' else inv_neta, 2)
|
||||||
|
categoria_json = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
|
||||||
|
es_descuento = False
|
||||||
|
if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_final <= 1700: es_descuento = True
|
||||||
|
elif categoria_json in ["TEAC", "TERC"] and 1400 <= inv_neta_final <= 1900: es_descuento = True
|
||||||
|
|
||||||
|
if es_descuento:
|
||||||
|
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||||
|
alumnos_refriperu.add(mat_id)
|
||||||
|
except: continue
|
||||||
|
|
||||||
|
for curso in datos: curso['Descuento'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
return datos, alumnos_refriperu
|
||||||
|
|
||||||
|
# Lógica estricta de Continuidad
|
||||||
|
def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu):
|
||||||
|
conteo_por_curso = {}
|
||||||
|
correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||||
|
|
||||||
|
if datos_raw_matriculas and datos_historial:
|
||||||
|
|
||||||
|
# PRE-ÍNDICE: {dni: [(linea_carrera, fecha), ...]}
|
||||||
|
# Calculamos identificar_linea_carrera UNA sola vez por programa del historial
|
||||||
|
historial_indexado = {}
|
||||||
|
for dni, antecedentes in datos_historial.items():
|
||||||
|
lineas = []
|
||||||
|
for ant in antecedentes:
|
||||||
|
linea = self.identificar_linea_carrera(str(ant.get('programa', '')))
|
||||||
|
fecha = ant.get('fecha')
|
||||||
|
if linea and fecha:
|
||||||
|
lineas.append((linea, fecha))
|
||||||
|
if lineas:
|
||||||
|
historial_indexado[dni] = lineas
|
||||||
|
|
||||||
|
alumnos_por_curso = {}
|
||||||
|
for alu in datos_raw_matriculas:
|
||||||
|
idx = str(alu.get('num_indice', ''))
|
||||||
|
if idx not in alumnos_por_curso: alumnos_por_curso[idx] = []
|
||||||
|
alumnos_por_curso[idx].append(alu)
|
||||||
|
|
||||||
|
for curso in datos:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio'))
|
||||||
|
linea_actual = self.identificar_linea_carrera(str(curso.get('dsc_programa', '')))
|
||||||
|
|
||||||
|
if not linea_actual or not fecha_inicio_actual: continue
|
||||||
|
|
||||||
|
contador_fieles = 0
|
||||||
|
for alumno in alumnos_por_curso.get(num_indice, []):
|
||||||
|
if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue
|
||||||
|
|
||||||
|
mat_id = str(alumno.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
|
||||||
|
if mat_id in alumnos_refriperu: continue
|
||||||
|
|
||||||
|
dni = str(alumno.get('dsc_documento', 'SD')).strip()
|
||||||
|
es_fiel = False
|
||||||
|
|
||||||
|
if mat_id in correcciones_continuidad:
|
||||||
|
accion = str(correcciones_continuidad[mat_id]).upper().strip()
|
||||||
|
if accion == "SI": es_fiel = True
|
||||||
|
elif accion == "NO": continue
|
||||||
|
|
||||||
|
if not es_fiel and dni in historial_indexado:
|
||||||
|
for linea_pasada, fecha_pasada in historial_indexado[dni]:
|
||||||
|
if fecha_pasada >= fecha_inicio_actual: continue
|
||||||
|
if (fecha_inicio_actual - fecha_pasada).days < 60: continue
|
||||||
|
if linea_pasada == linea_actual:
|
||||||
|
es_fiel = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if es_fiel: contador_fieles += 1
|
||||||
|
conteo_por_curso[num_indice] = contador_fieles
|
||||||
|
|
||||||
|
for curso in datos: curso['Inscritos_Continuidad'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def agregar_inscritos_mes(self, datos, datos_matriculas):
|
||||||
|
dict_norm = {str(k): v for k, v in datos_matriculas.items()}
|
||||||
|
for curso in datos: curso['Inscritos_Mes'] = dict_norm.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def agregar_inscritos_pc(self, datos, datos_raw_matriculas):
|
||||||
|
conteo_pc = {}
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for m in datos_raw_matriculas:
|
||||||
|
try:
|
||||||
|
if str(m.get('estado_matricula', '')).strip() in ['ALU', 'PRE'] and float(m.get('imp_saldo_matricula', 0) or 0) < 1 and float(m.get('imp_saldo_cuota1', 0) or 0) < 1:
|
||||||
|
nid = str(m.get('num_indice', ''))
|
||||||
|
conteo_pc[nid] = conteo_pc.get(nid, 0) + 1
|
||||||
|
except: continue
|
||||||
|
for curso in datos: curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
return datos
|
||||||
0
backend/backend/modules/saldo_pendiente/__init__.py
Normal file
0
backend/backend/modules/saldo_pendiente/__init__.py
Normal file
193
backend/backend/modules/saldo_pendiente/logic.py
Normal file
193
backend/backend/modules/saldo_pendiente/logic.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# modules/saldo_pendiente/logic.py
|
||||||
|
from .processor import SaldoProcessor
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class SaldoLogic:
|
||||||
|
"""
|
||||||
|
Controlador lógico optimizado.
|
||||||
|
1. Filtro Global Manual (lista_saldo_pendiente).
|
||||||
|
2. Filtro REFRIPERU (Precio Neto < 1900/1000 o Etiquetas).
|
||||||
|
3. Filtros de Negocio (Vendedor, Fecha, Estado).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.processor = SaldoProcessor(self.data_manager)
|
||||||
|
|
||||||
|
def obtener_saldos_consolidados(self, tipo_cuota):
|
||||||
|
# 1. Recargar Configuración (Para leer tu nueva lista en vivo)
|
||||||
|
try: self.data_manager.cargar_toda_configuracion()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
all_debtors = []
|
||||||
|
years_to_scan = [2025, 2026]
|
||||||
|
|
||||||
|
print(f"⚡ [Logic] Iniciando carga para {tipo_cuota}...")
|
||||||
|
|
||||||
|
for year in years_to_scan:
|
||||||
|
try:
|
||||||
|
# Usamos la consulta anual optimizada
|
||||||
|
raw_year_data = self.data_manager.ejecutar_consulta_saldos_anual(str(year))
|
||||||
|
if raw_year_data:
|
||||||
|
datos_procesados = self._procesar_lote_local(raw_year_data, tipo_cuota)
|
||||||
|
all_debtors.extend(datos_procesados)
|
||||||
|
except AttributeError:
|
||||||
|
print("⚠️ Error: DataManager no actualizado.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ordenar alfabéticamente
|
||||||
|
all_debtors.sort(key=lambda x: (x.get('VENDEDOR', '') or "ZZZ", x.get('ALUMNO', '') or ""))
|
||||||
|
print(f"✅ Carga Finalizada. Total en tabla: {len(all_debtors)}")
|
||||||
|
return all_debtors
|
||||||
|
|
||||||
|
def _procesar_lote_local(self, raw_data, tipo_cuota):
|
||||||
|
datos_limpios = []
|
||||||
|
|
||||||
|
# --- Configuración ---
|
||||||
|
ahora = datetime.now()
|
||||||
|
ano_actual = ahora.year
|
||||||
|
mes_actual = ahora.month
|
||||||
|
|
||||||
|
config = self.data_manager.config_data
|
||||||
|
|
||||||
|
# 1. CARGAMOS TUS LISTAS DEL JSON
|
||||||
|
correcciones_refri = config.get("correcciones_descuento", {})
|
||||||
|
exclusion_manual_global = config.get("lista_saldo_pendiente", {}) # <--- TU NUEVA LISTA
|
||||||
|
|
||||||
|
# 2. Lista Vendedores Permitidos
|
||||||
|
lista_raw = config.get("lista_pendientes", [])
|
||||||
|
if not lista_raw:
|
||||||
|
lista_raw = ["AGUILAR U. JUAN CARLOS", "CHAVEZ P. DIANA", "HUAMAN C. ALONSO AGUSTIN",
|
||||||
|
"LA ROSA C. VERONICA ASTRID", "LAZARO Q. DIEGO ARTURO",
|
||||||
|
"MONTOYA D. CARMEN ISABEL", "PERALTA C. ALMENDRA LUCIA"]
|
||||||
|
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
|
||||||
|
|
||||||
|
# 3. Mapeo de columnas
|
||||||
|
mapa_cols = {
|
||||||
|
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
|
||||||
|
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
|
||||||
|
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
|
||||||
|
"4° Cuota": ("imp_saldo_cuota4", "fch_venc_cuota4"),
|
||||||
|
"5° Cuota": ("imp_saldo_cuota5", "fch_venc_cuota5"),
|
||||||
|
}
|
||||||
|
col_saldo, col_venc = mapa_cols.get(tipo_cuota, (None, None))
|
||||||
|
if not col_saldo: return []
|
||||||
|
|
||||||
|
for row in raw_data:
|
||||||
|
# Identificadores
|
||||||
|
num_mat = str(row.get('num_matricula', '')).split('.')[0].strip()
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 0: EXCLUSIÓN GLOBAL MANUAL (TU NUEVO PEDIDO)
|
||||||
|
# =================================================================
|
||||||
|
# Si la matrícula está en "lista_saldo_pendiente" con valor "NO", ADIÓS.
|
||||||
|
if num_mat in exclusion_manual_global:
|
||||||
|
val_excl = str(exclusion_manual_global[num_mat]).strip().upper()
|
||||||
|
if val_excl == "NO":
|
||||||
|
continue # Se salta inmediatamente, no importa nada más.
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 1: DETECCIÓN DE REFRIPERU (PRECIO NETO / ETIQUETAS)
|
||||||
|
# =================================================================
|
||||||
|
es_refriperu = False
|
||||||
|
|
||||||
|
# A) Manual (correcciones_descuento)
|
||||||
|
if num_mat in correcciones_refri:
|
||||||
|
val = str(correcciones_refri[num_mat]).strip().upper()
|
||||||
|
if val == "SI": es_refriperu = True
|
||||||
|
elif val == "NO": es_refriperu = False # Forzamos a cobrar
|
||||||
|
else:
|
||||||
|
# B) Automático (Etiquetas)
|
||||||
|
dsc_beca = str(row.get('dsc_beca', '')).strip().upper()
|
||||||
|
dsc_prog = str(row.get('dsc_programa', '')).strip().upper()
|
||||||
|
dsc_prom = str(row.get('dsc_promocion', '')).strip().upper()
|
||||||
|
|
||||||
|
if 'REFRIPERU' in dsc_beca or '100%' in dsc_beca or 'BECA' in dsc_beca:
|
||||||
|
es_refriperu = True
|
||||||
|
elif 'REFRIPERU' in dsc_prog or 'REFRIPERU' in dsc_prom:
|
||||||
|
es_refriperu = True
|
||||||
|
|
||||||
|
# C) Automático por PRECIO FINAL (INV_NETA)
|
||||||
|
if not es_refriperu:
|
||||||
|
try:
|
||||||
|
# Usamos INV_NETA (Precio Real)
|
||||||
|
inv_neta = float(row.get('INV_NETA', 0))
|
||||||
|
except:
|
||||||
|
inv_neta = 0.0
|
||||||
|
|
||||||
|
if inv_neta > 0:
|
||||||
|
# Rango Técnicos/Especialistas
|
||||||
|
if ("TECNICO" in dsc_prog or "ESPECIALISTA" in dsc_prog or "TEAC" in dsc_prog):
|
||||||
|
if inv_neta < 1900: es_refriperu = True # ej: 1799
|
||||||
|
|
||||||
|
# Rango Gestión/Ventas
|
||||||
|
elif "GESTION" in dsc_prog or "VENTA" in dsc_prog:
|
||||||
|
if inv_neta < 1000: es_refriperu = True
|
||||||
|
|
||||||
|
if es_refriperu:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTROS ESTÁNDAR (VENDEDOR, ESTADO, FECHA)
|
||||||
|
# =================================================================
|
||||||
|
|
||||||
|
# Vendedor
|
||||||
|
vendedor = str(row.get('dsc_vendedor', '')).strip().upper()
|
||||||
|
if lista_permitidos:
|
||||||
|
encontrado = False
|
||||||
|
if vendedor in lista_permitidos: encontrado = True
|
||||||
|
else:
|
||||||
|
for p in lista_permitidos:
|
||||||
|
if p.replace(" ","") in vendedor.replace(" ",""):
|
||||||
|
encontrado = True; break
|
||||||
|
if not encontrado: continue
|
||||||
|
|
||||||
|
# Estado
|
||||||
|
estado = str(row.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado not in ('ALU', 'PRE'): continue
|
||||||
|
|
||||||
|
# Fecha Futura (Solo 2da cuota en adelante)
|
||||||
|
if tipo_cuota != "1° Cuota":
|
||||||
|
fch_raw = row.get(col_venc)
|
||||||
|
if fch_raw:
|
||||||
|
try:
|
||||||
|
if isinstance(fch_raw, str): f_obj = datetime.strptime(fch_raw[:10], '%Y-%m-%d')
|
||||||
|
else: f_obj = fch_raw
|
||||||
|
if f_obj.year > ano_actual: continue
|
||||||
|
elif f_obj.year == ano_actual and f_obj.month > mes_actual: continue
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Saldo Positivo
|
||||||
|
try: val = float(row.get(col_saldo, 0))
|
||||||
|
except: val = 0.0
|
||||||
|
if val <= 0.1: continue
|
||||||
|
|
||||||
|
# --- 4. Construcción de Fila ---
|
||||||
|
def fmt(v):
|
||||||
|
if not v: return ""
|
||||||
|
if isinstance(v, str): return v[:10]
|
||||||
|
if isinstance(v, datetime): return v.strftime('%d-%m-%Y')
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
fila = {
|
||||||
|
'MATRICULA': row.get('num_matricula'),
|
||||||
|
'VENDEDOR': row.get('dsc_vendedor'),
|
||||||
|
'ALUMNO': row.get('dsc_alumno'),
|
||||||
|
'PROGRAMA': row.get('dsc_promocion'),
|
||||||
|
'FECHA INICIO': fmt(row.get('fch_inicio')),
|
||||||
|
'FECHA MATR.': fmt(row.get('fch_matricula')),
|
||||||
|
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
|
||||||
|
f'SALDO {tipo_cuota.upper()}': val,
|
||||||
|
'VENCIMIENTO': fmt(row.get(col_venc)),
|
||||||
|
'INV. NETA': float(row.get('INV_NETA', 0)),
|
||||||
|
'DNI': row.get('dsc_documento', ''),
|
||||||
|
'CELULAR': row.get('dsc_telefono_1', '')
|
||||||
|
}
|
||||||
|
datos_limpios.append(fila)
|
||||||
|
|
||||||
|
return datos_limpios
|
||||||
|
|
||||||
|
# Métodos legacy
|
||||||
|
def obtener_saldos(self, ano, mes, tipo_cuota): return self.processor.obtener_datos_procesados(ano, mes, tipo_cuota)
|
||||||
|
def get_current_year(self): return self.data_manager.get_current_year()
|
||||||
|
def get_current_month(self): return self.data_manager.get_current_month()
|
||||||
162
backend/backend/modules/saldo_pendiente/processor.py
Normal file
162
backend/backend/modules/saldo_pendiente/processor.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# modules/saldo_pendiente/processor.py
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class SaldoProcessor:
|
||||||
|
"""
|
||||||
|
Procesador de lógica de negocio para Saldos Pendientes.
|
||||||
|
Filtra estrictamente por estado ALU/PRE, saldos positivos,
|
||||||
|
vendedores permitidos y FECHA DE VENCIMIENTO (No mostrar futuro).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes, tipo_cuota):
|
||||||
|
"""
|
||||||
|
Recupera datos crudos y aplica los filtros de negocio.
|
||||||
|
"""
|
||||||
|
# 1. Traer la data cruda desde DataManager
|
||||||
|
raw_data = self.data_manager.ejecutar_consulta_saldos_pendientes(ano, mes)
|
||||||
|
|
||||||
|
datos_limpios = []
|
||||||
|
|
||||||
|
# 2. Obtener fecha actual para saber qué es "futuro"
|
||||||
|
ahora = datetime.now()
|
||||||
|
ano_actual = ahora.year
|
||||||
|
mes_actual = ahora.month
|
||||||
|
|
||||||
|
# 3. Cargar la "Lista Blanca" de vendedores desde el JSON
|
||||||
|
config = self.data_manager.config_data
|
||||||
|
lista_raw = config.get("lista_pendientes", [])
|
||||||
|
|
||||||
|
# RESPALDO DE EMERGENCIA
|
||||||
|
if not lista_raw:
|
||||||
|
lista_raw = [
|
||||||
|
"AGUILAR U. JUAN CARLOS",
|
||||||
|
"CHAVEZ P. DIANA",
|
||||||
|
"HUAMAN C. ALONSO AGUSTIN",
|
||||||
|
"LA ROSA C. VERONICA ASTRID",
|
||||||
|
"LAZARO Q. DIEGO ARTURO",
|
||||||
|
"MONTOYA D. CARMEN ISABEL",
|
||||||
|
"PERALTA C. ALMENDRA LUCIA"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Normalizamos la lista
|
||||||
|
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
|
||||||
|
|
||||||
|
# 4. Mapeo de columnas según la selección
|
||||||
|
mapa_columnas = {
|
||||||
|
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
|
||||||
|
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
|
||||||
|
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
|
||||||
|
}
|
||||||
|
|
||||||
|
col_saldo_target, col_venc_target = mapa_columnas.get(tipo_cuota, (None, None))
|
||||||
|
|
||||||
|
if not col_saldo_target:
|
||||||
|
return []
|
||||||
|
|
||||||
|
for row in raw_data:
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 1: VENDEDOR PERMITIDO
|
||||||
|
# =================================================================
|
||||||
|
vendedor_actual = str(row.get('dsc_vendedor', '')).strip().upper()
|
||||||
|
|
||||||
|
if lista_permitidos:
|
||||||
|
if vendedor_actual not in lista_permitidos:
|
||||||
|
# Búsqueda parcial por si hay errores de espacios
|
||||||
|
encontrado = False
|
||||||
|
for permitido in lista_permitidos:
|
||||||
|
v_norm = vendedor_actual.replace(" ", "")
|
||||||
|
p_norm = permitido.replace(" ", "")
|
||||||
|
if p_norm in v_norm:
|
||||||
|
encontrado = True
|
||||||
|
break
|
||||||
|
if not encontrado:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 2: ESTADO (ALU/PRE)
|
||||||
|
# =================================================================
|
||||||
|
estado = str(row.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado not in ('ALU', 'PRE'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 3: FECHA DE VENCIMIENTO (NO MOSTRAR FUTURO)
|
||||||
|
# =================================================================
|
||||||
|
# Este filtro aplica PRINCIPALMENTE para 2° Cuota en adelante.
|
||||||
|
# (Aunque la lógica es válida para todas, la 1° suele ser inmediata).
|
||||||
|
|
||||||
|
if tipo_cuota != "1° Cuota":
|
||||||
|
fch_venc_raw = row.get(col_venc_target)
|
||||||
|
|
||||||
|
es_futuro = False
|
||||||
|
if fch_venc_raw:
|
||||||
|
try:
|
||||||
|
# Convertir a objeto fecha si es texto
|
||||||
|
if isinstance(fch_venc_raw, str):
|
||||||
|
# Asumimos formato SQL YYYY-MM-DD
|
||||||
|
f_obj = datetime.strptime(fch_venc_raw[:10], '%Y-%m-%d')
|
||||||
|
else:
|
||||||
|
f_obj = fch_venc_raw # Ya es datetime
|
||||||
|
|
||||||
|
venc_ano = f_obj.year
|
||||||
|
venc_mes = f_obj.month
|
||||||
|
|
||||||
|
# LÓGICA DE TIEMPO:
|
||||||
|
# Si el año de vencimiento es mayor al actual -> ES FUTURO
|
||||||
|
if venc_ano > ano_actual:
|
||||||
|
es_futuro = True
|
||||||
|
# Si es el mismo año, pero el mes es mayor al actual -> ES FUTURO
|
||||||
|
elif venc_ano == ano_actual and venc_mes > mes_actual:
|
||||||
|
es_futuro = True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Si falla la fecha, asumimos que no es futuro para no ocultar por error
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Si la cuota vence en el futuro (ej: Marzo cuando estamos en Febrero), LA OCULTAMOS.
|
||||||
|
if es_futuro:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 4: SALDO > 0
|
||||||
|
# =================================================================
|
||||||
|
try:
|
||||||
|
val = row.get(col_saldo_target, 0)
|
||||||
|
saldo_a_mostrar = float(val) if val is not None else 0.0
|
||||||
|
except:
|
||||||
|
saldo_a_mostrar = 0.0
|
||||||
|
|
||||||
|
if saldo_a_mostrar <= 0.1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- FORMATEO PARA VISUALIZACIÓN ---
|
||||||
|
def format_date(val):
|
||||||
|
if not val: return ""
|
||||||
|
if isinstance(val, str): return val[:10]
|
||||||
|
if isinstance(val, datetime): return val.strftime('%d-%m-%Y')
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
f_inicio = format_date(row.get('fch_inicio'))
|
||||||
|
f_matr = format_date(row.get('fch_matricula'))
|
||||||
|
f_vencimiento = format_date(row.get(col_venc_target))
|
||||||
|
|
||||||
|
# --- CONSTRUCCIÓN DE LA FILA FINAL ---
|
||||||
|
fila = {
|
||||||
|
'MATRICULA': row.get('num_matricula'),
|
||||||
|
'VENDEDOR': row.get('dsc_vendedor'),
|
||||||
|
'ALUMNO': row.get('dsc_alumno'),
|
||||||
|
'PROGRAMA': row.get('dsc_promocion'),
|
||||||
|
'FECHA INICIO': f_inicio,
|
||||||
|
'FECHA MATR.': f_matr,
|
||||||
|
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
|
||||||
|
f'SALDO {tipo_cuota.upper()}': saldo_a_mostrar,
|
||||||
|
'VENCIMIENTO': f_vencimiento, # Aquí se verá la fecha (ej: 14-02-2026)
|
||||||
|
'INV. NETA': float(row.get('INV_NETA', 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
datos_limpios.append(fila)
|
||||||
|
|
||||||
|
return datos_limpios
|
||||||
0
backend/backend/modules/ventas/__init__.py
Normal file
0
backend/backend/modules/ventas/__init__.py
Normal file
736
backend/backend/modules/ventas/logic.py
Normal file
736
backend/backend/modules/ventas/logic.py
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
# modules/ventas/logic.py
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
|
||||||
|
class VentasLogic:
|
||||||
|
"""Clase que maneja la lógica de negocio y procesamiento de datos para Ventas"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self._config_sedes = None
|
||||||
|
|
||||||
|
def _clasificar_sede_comisiones(self, dsc_programa):
|
||||||
|
"""Clasifica la sede usando EXACTAMENTE la misma lógica de Cobranza (sede.json)."""
|
||||||
|
try:
|
||||||
|
if not hasattr(self, '_cob_processor') or self._cob_processor is None:
|
||||||
|
from modules.cobranza.processor import CobranzaProcessor
|
||||||
|
self._cob_processor = CobranzaProcessor(self.data_manager)
|
||||||
|
return self._cob_processor.clasificar_sede(dsc_programa)
|
||||||
|
except Exception:
|
||||||
|
prog_up = str(dsc_programa or "").upper()
|
||||||
|
for s in ("AREQUIPA", "PIURA", "TRUJILLO"):
|
||||||
|
if s in prog_up:
|
||||||
|
return s
|
||||||
|
return "LIMA"
|
||||||
|
|
||||||
|
def buscar_todos_matriculados(self):
|
||||||
|
"""Devuelve TODOS los matriculados (todos los años/meses) para el buscador.
|
||||||
|
Ejecuta la query de matrículas quitando el filtro de YEAR/MONTH de fch_matricula.
|
||||||
|
Retorna filas con el mismo formato que el detalle (para reusar columnas)."""
|
||||||
|
import re as _re
|
||||||
|
sql_base = self.data_manager.query_matriculas_sql
|
||||||
|
if not sql_base:
|
||||||
|
return []
|
||||||
|
# Quitar las condiciones de año y mes de fch_matricula
|
||||||
|
sql = _re.sub(r"AND\s+YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql_base, flags=_re.IGNORECASE)
|
||||||
|
sql = _re.sub(r"AND\s+MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql, flags=_re.IGNORECASE)
|
||||||
|
# Por si están sin AND (primera condición)
|
||||||
|
sql = _re.sub(r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
|
||||||
|
sql = _re.sub(r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
|
||||||
|
# El buscador debe traer TODOS los matriculados excepto ANU (incluye SUS, no pagados, etc.)
|
||||||
|
# El buscador muestra TODOS los matriculados, incluso ANU
|
||||||
|
sql = _re.sub(r"AND\s+sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\([^)]*\)", "", sql, flags=_re.IGNORECASE)
|
||||||
|
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
|
||||||
|
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*,\s*'RET'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
|
||||||
|
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql) # sin parámetros de fecha
|
||||||
|
columns = [c[0] for c in cursor.description]
|
||||||
|
datos = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# Traer fecha de cancelación de la cuota 1 de TODAS las matrículas (cualquier refinanciamiento)
|
||||||
|
fechas_canc = {}
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT num_matricula, MAX(fch_cancelacion)
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE num_cuota = 1 AND fch_cancelacion IS NOT NULL
|
||||||
|
GROUP BY num_matricula
|
||||||
|
""")
|
||||||
|
for r in cursor.fetchall():
|
||||||
|
k = str(r[0]).strip()
|
||||||
|
if k.endswith('.0'): k = k[:-2]
|
||||||
|
fechas_canc[k] = r[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
filas = []
|
||||||
|
for d in datos:
|
||||||
|
raw_mat = d.get('num_matricula')
|
||||||
|
mat_id = ""
|
||||||
|
if raw_mat is not None:
|
||||||
|
v = str(raw_mat).strip()
|
||||||
|
if v.endswith('.0'): v = v[:-2]
|
||||||
|
mat_id = v
|
||||||
|
alumno = d.get('dsc_alumno', 'SIN NOMBRE')
|
||||||
|
# MOSTRAR: detallado (dsc_promocion = sgede_RP_programa.dsc_det_programa)
|
||||||
|
programa = (d.get('dsc_promocion') or d.get('dsc_det_programa') or d.get('dsc_programa') or '')
|
||||||
|
# CLASIFICAR sede: prioriza dsc_programa
|
||||||
|
prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa') or d.get('dsc_promocion') or '')
|
||||||
|
vendedor = d.get('dsc_vendedor', 'SIN VENDEDOR') or 'SIN VENDEDOR'
|
||||||
|
inv = d.get('INV_NETA', 0) or 0
|
||||||
|
saldo_mat = d.get('imp_saldo_matricula', 0) or 0
|
||||||
|
saldo_c1 = d.get('imp_saldo_cuota1', 0) or 0
|
||||||
|
def _fdate(v):
|
||||||
|
if not v or str(v).strip() in ("","None"): return ""
|
||||||
|
if hasattr(v,'strftime'): return v.strftime('%d/%m/%Y')
|
||||||
|
s = str(v)[:10]
|
||||||
|
if len(s)==10 and s[4]=='-': p=s.split('-'); return f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
return s
|
||||||
|
f_mat = _fdate(d.get('fch_matricula'))
|
||||||
|
f_ini = _fdate(d.get('fch_inicio'))
|
||||||
|
f_canc = _fdate(fechas_canc.get(mat_id))
|
||||||
|
sede = self._clasificar_sede_comisiones(prog_clasificacion)
|
||||||
|
# mismo layout que el detalle: 16 columnas
|
||||||
|
fila = [
|
||||||
|
vendedor, alumno, f_mat, f_canc, "", "", "",
|
||||||
|
f"S/ {float(inv):,.0f}", programa, f_ini,
|
||||||
|
f"S/ {float(saldo_mat):,.0f}", f"S/ {float(saldo_c1):,.0f}",
|
||||||
|
"", "-", sede, mat_id,
|
||||||
|
]
|
||||||
|
filas.append(fila)
|
||||||
|
return filas
|
||||||
|
|
||||||
|
def obtener_datos_brutos(self, ano, mes_numero):
|
||||||
|
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero))
|
||||||
|
return datos or []
|
||||||
|
|
||||||
|
def obtener_datos_brutos_filtrado(self, ano, mes_numero, sede="TODOS", programa="TODOS"):
|
||||||
|
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero), sede, programa)
|
||||||
|
return datos or []
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# FUNCIONES AUXILIARES
|
||||||
|
# =========================================================================
|
||||||
|
def clasificar_programa(self, dsc_programa):
|
||||||
|
if not dsc_programa: return "OTROS"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
meta_data = self.data_manager.meta_data
|
||||||
|
clasificaciones = meta_data.get("clasificacion_programas", {})
|
||||||
|
|
||||||
|
for clase, data in clasificaciones.items():
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return clase
|
||||||
|
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
|
||||||
|
|
||||||
|
def identificar_linea_carrera(self, nombre_programa):
|
||||||
|
categoria = self.clasificar_programa(nombre_programa)
|
||||||
|
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC", "CARRERA"]:
|
||||||
|
return "CARRERA_COMPATIBLE"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-':
|
||||||
|
return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# OBTENCIÓN DE DETALLE POR VENDEDOR (LÓGICA PURA)
|
||||||
|
# =========================================================================
|
||||||
|
def obtener_detalle_vendedor(self, vendedor, ano, mes_numero):
|
||||||
|
try:
|
||||||
|
key_mes = f"{int(mes_numero):02d}-{ano}"
|
||||||
|
lista_raw = self.data_manager.historico_pendientes.get(key_mes, [])
|
||||||
|
set_vip_actual = set()
|
||||||
|
lista_vip_str = []
|
||||||
|
|
||||||
|
for item in lista_raw:
|
||||||
|
try:
|
||||||
|
val = int(item)
|
||||||
|
set_vip_actual.add(val)
|
||||||
|
lista_vip_str.append(str(val))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas)
|
||||||
|
_fov_vip = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
|
||||||
|
for mk in _fov_vip.keys():
|
||||||
|
try:
|
||||||
|
val = int(float(str(mk)))
|
||||||
|
set_vip_actual.add(val)
|
||||||
|
if str(val) not in lista_vip_str:
|
||||||
|
lista_vip_str.append(str(val))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
correcciones_mat = self.data_manager.config_data.get("correcciones_matriculas", {})
|
||||||
|
correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||||
|
correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||||
|
|
||||||
|
sql_base = self.data_manager.query_matriculas_sql
|
||||||
|
if not sql_base:
|
||||||
|
return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []}
|
||||||
|
|
||||||
|
sql_in_clause = "(-1)"
|
||||||
|
if lista_vip_str:
|
||||||
|
sql_in_clause = "(" + ",".join(lista_vip_str) + ")"
|
||||||
|
|
||||||
|
sql_modificado = re.sub(
|
||||||
|
r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?",
|
||||||
|
r"( (YEAR(sgeca_matricula.fch_matricula) = ?",
|
||||||
|
sql_base, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
sql_modificado = re.sub(
|
||||||
|
r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?",
|
||||||
|
rf"""MONTH(sgeca_matricula.fch_matricula) = ?) OR sgeca_matricula.num_matricula IN {sql_in_clause}
|
||||||
|
OR EXISTS (SELECT 1 FROM sgede_cronograma_matricula crono
|
||||||
|
WHERE crono.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND crono.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND crono.num_cuota = 1 AND crono.fch_cancelacion IS NOT NULL
|
||||||
|
AND YEAR(crono.fch_cancelacion) = ? AND MONTH(crono.fch_cancelacion) = ?) )""",
|
||||||
|
sql_modificado, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Permitir matrículas VIP/override aunque estén ANU/SUS (igual que la tabla principal)
|
||||||
|
if lista_vip_str:
|
||||||
|
sql_modificado = re.sub(
|
||||||
|
r"sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)",
|
||||||
|
rf"(sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS') OR sgeca_matricula.num_matricula IN {sql_in_clause})",
|
||||||
|
sql_modificado, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(sql_modificado, ano, mes_numero, ano, mes_numero)
|
||||||
|
columns = [column[0] for column in cursor.description]
|
||||||
|
datos_raw = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# 🔥 DESCARGA DEL CRONOGRAMA COMPLETO (SIN IMPORTAR EL MES) 🔥
|
||||||
|
lista_mats = list(set([str(d.get('num_matricula')).replace('.0','').strip() for d in datos_raw if d.get('num_matricula')]))
|
||||||
|
cuotas_raw = []
|
||||||
|
|
||||||
|
if lista_mats:
|
||||||
|
mats_str_q = ",".join([f"'{m}'" for m in lista_mats])
|
||||||
|
try:
|
||||||
|
res_cuo = requests.get(self.data_manager.github_cuota_url)
|
||||||
|
sql_cuotas_base = res_cuo.text
|
||||||
|
|
||||||
|
# Convertimos las validaciones de Año y Mes en "1=1" para que no esconda los cronogramas de otros meses
|
||||||
|
sql_cuotas_base = re.sub(r"YEAR\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE)
|
||||||
|
sql_cuotas_base = re.sub(r"MONTH\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE)
|
||||||
|
sql_cuotas_base = re.sub(r"ORDER\s+BY\s+.*$", "", sql_cuotas_base, flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
sql_final_cuotas = f"SELECT * FROM ({sql_cuotas_base}) AS sub_cuotas WHERE num_matricula IN ({mats_str_q})"
|
||||||
|
|
||||||
|
cursor.execute(sql_final_cuotas)
|
||||||
|
cols_cuo = [c[0] for c in cursor.description]
|
||||||
|
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error DAX Cuotas: {e}")
|
||||||
|
|
||||||
|
try: cursos_raw = self.data_manager.ejecutar_consulta_cursos(ano, mes_numero)
|
||||||
|
except: cursos_raw = []
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Agrupar Cuotas
|
||||||
|
cuotas_por_mat = {}
|
||||||
|
for c in cuotas_raw:
|
||||||
|
m_id = str(c.get('num_matricula', '')).strip()
|
||||||
|
if m_id.endswith('.0'): m_id = m_id[:-2]
|
||||||
|
if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = []
|
||||||
|
cuotas_por_mat[m_id].append(c)
|
||||||
|
|
||||||
|
fecha_inicio_prog = {}
|
||||||
|
for c in cursos_raw:
|
||||||
|
prog = str(c.get('dsc_programa', '')).strip().upper()
|
||||||
|
if prog not in fecha_inicio_prog:
|
||||||
|
fecha_inicio_prog[prog] = self.parse_fecha(c.get('fch_inicio'))
|
||||||
|
|
||||||
|
lista_dnis = [str(d.get('dsc_documento', '')).strip() for d in datos_raw if d.get('dsc_documento')]
|
||||||
|
try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||||
|
except: datos_historial = {}
|
||||||
|
|
||||||
|
listas_datos = {
|
||||||
|
"Venta Inscritos": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0},
|
||||||
|
"Venta P.C": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0},
|
||||||
|
"Venta Pendientes":{"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0}
|
||||||
|
}
|
||||||
|
|
||||||
|
vendedor_target = str(vendedor).upper().replace(".", "").replace(",", "")
|
||||||
|
vendedor_target = " ".join(vendedor_target.split())
|
||||||
|
traer_todos = (vendedor_target == "__TODOS__")
|
||||||
|
|
||||||
|
for d in datos_raw:
|
||||||
|
vend_name = str(d.get('dsc_vendedor', 'SIN VENDEDOR')).upper().replace(".", "").replace(",", "")
|
||||||
|
vend_name_clean = " ".join(vend_name.split())
|
||||||
|
|
||||||
|
if not traer_todos and vendedor_target not in vend_name_clean and vend_name_clean not in vendedor_target:
|
||||||
|
continue
|
||||||
|
|
||||||
|
estado = str(d.get('estado_matricula', '')).strip().upper()
|
||||||
|
# Permitir ANU/otros si tiene override de fecha; si no, solo ALU/PRE/RET
|
||||||
|
_rm = d.get('num_matricula')
|
||||||
|
_mk = ""
|
||||||
|
if _rm is not None:
|
||||||
|
_mk = str(_rm).strip()
|
||||||
|
if _mk.endswith('.0'): _mk = _mk[:-2]
|
||||||
|
_fov_chk = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
|
||||||
|
if estado not in ['ALU', 'PRE', 'RET'] and _mk not in _fov_chk: continue
|
||||||
|
|
||||||
|
raw_mat = d.get('num_matricula')
|
||||||
|
mat_id = ""
|
||||||
|
matricula_int = -1
|
||||||
|
if raw_mat is not None:
|
||||||
|
val_str = str(raw_mat).strip()
|
||||||
|
if val_str.endswith('.0'): val_str = val_str[:-2]
|
||||||
|
mat_id = val_str
|
||||||
|
try: matricula_int = int(float(val_str))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
dni = str(d.get('dsc_documento', '')).strip()
|
||||||
|
alumno_nombre = d.get('dsc_alumno', 'SIN NOMBRE')
|
||||||
|
nombre_prog_crudo = str(d.get('dsc_programa', '')).strip().upper()
|
||||||
|
|
||||||
|
inv_neta_raw = float(d.get('INV_NETA', 0.0))
|
||||||
|
cod_moneda = str(d.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
|
||||||
|
# Buscar imp_tc desde el CRONOGRAMA (no del comprobante)
|
||||||
|
imp_tc = 0
|
||||||
|
cuotas_alumno = cuotas_por_mat.get(mat_id, [])
|
||||||
|
for c_aux in cuotas_alumno:
|
||||||
|
try:
|
||||||
|
tc_aux = float(c_aux.get('imp_tc', 0) or 0)
|
||||||
|
if tc_aux >= 2: # Tomar el primer TC válido del cronograma
|
||||||
|
imp_tc = tc_aux
|
||||||
|
break
|
||||||
|
except: continue
|
||||||
|
if imp_tc < 2: imp_tc = 3.45 # TC por defecto si no hay cronograma o es inválido
|
||||||
|
|
||||||
|
saldo_mat = float(d.get('imp_saldo_matricula', 0.0))
|
||||||
|
saldo_c1 = float(d.get('imp_saldo_cuota1', 0.0))
|
||||||
|
fch_canc_raw = d.get('fch_cancelacion_cuota1')
|
||||||
|
fch_mat = d.get('fch_matricula')
|
||||||
|
|
||||||
|
# Override de fecha cancelación 1 (Supabase) → prioridad para clasificación + cuenta como pagado
|
||||||
|
_fov = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
|
||||||
|
_mat_key = str(mat_id).strip()
|
||||||
|
if _mat_key.endswith('.0'): _mat_key = _mat_key[:-2]
|
||||||
|
tiene_override_fecha = _mat_key in _fov
|
||||||
|
fecha_vaciada_override = False # override "borrar" → tiene prioridad sobre todo
|
||||||
|
if tiene_override_fecha:
|
||||||
|
s = str(_fov[_mat_key]).strip()
|
||||||
|
if s == "__VACIO__":
|
||||||
|
# Fecha borrada a propósito → anula la del SQL (no cuenta como pagado)
|
||||||
|
fch_canc_raw = None
|
||||||
|
tiene_override_fecha = False
|
||||||
|
fecha_vaciada_override = True
|
||||||
|
else:
|
||||||
|
# Normalizar dd/mm/yyyy o d/m/yyyy → yyyy-mm-dd
|
||||||
|
if '/' in s:
|
||||||
|
p = s.split('/')
|
||||||
|
if len(p) == 3:
|
||||||
|
s = f"{p[2]}-{int(p[1]):02d}-{int(p[0]):02d}"
|
||||||
|
fch_canc_raw = s
|
||||||
|
|
||||||
|
if mat_id in correcciones_mat:
|
||||||
|
datos_corregidos = correcciones_mat[mat_id]
|
||||||
|
if "imp_tc" in datos_corregidos: imp_tc = float(datos_corregidos["imp_tc"])
|
||||||
|
if "imp_saldo_cuota1" in datos_corregidos: saldo_c1 = float(datos_corregidos["imp_saldo_cuota1"])
|
||||||
|
if "imp_saldo_matricula" in datos_corregidos: saldo_mat = float(datos_corregidos["imp_saldo_matricula"])
|
||||||
|
# Supabase (vaciado) tiene prioridad: la corrección manual NO restaura la fecha
|
||||||
|
if "fch_cancelacion_cuota1" in datos_corregidos and not fecha_vaciada_override:
|
||||||
|
fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"]
|
||||||
|
|
||||||
|
# Prioridad TC: Supabase (mes) > corrección > cronograma
|
||||||
|
_tc_ov = getattr(self.data_manager, '_tc_override_mes', None)
|
||||||
|
if _tc_ov: imp_tc = float(_tc_ov)
|
||||||
|
|
||||||
|
if cod_moneda == 'DOL': inv_neta_soles = round(inv_neta_raw * imp_tc, 2)
|
||||||
|
else: inv_neta_soles = round(inv_neta_raw, 2)
|
||||||
|
|
||||||
|
row_year, row_month = -1, -1
|
||||||
|
fecha_mat_limpia = ""
|
||||||
|
if fch_mat:
|
||||||
|
try:
|
||||||
|
if hasattr(fch_mat, 'strftime'):
|
||||||
|
row_year, row_month = fch_mat.year, fch_mat.month
|
||||||
|
fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
f_obj = datetime.strptime(str(fch_mat)[:10], '%Y-%m-%d')
|
||||||
|
row_year, row_month = f_obj.year, f_obj.month
|
||||||
|
fecha_mat_limpia = f_obj.strftime('%d/%m/%Y')
|
||||||
|
except: fecha_mat_limpia = str(fch_mat)[:10]
|
||||||
|
|
||||||
|
fch_canc_limpia = ""
|
||||||
|
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
|
||||||
|
try:
|
||||||
|
if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
tmp = str(fch_canc_raw)[:10]
|
||||||
|
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
else: fch_canc_limpia = tmp
|
||||||
|
except: fch_canc_limpia = str(fch_canc_raw)[:10]
|
||||||
|
|
||||||
|
clase_alumno = self.clasificar_programa(nombre_prog_crudo)
|
||||||
|
linea_actual = self.identificar_linea_carrera(nombre_prog_crudo)
|
||||||
|
fecha_inicio_actual = fecha_inicio_prog.get(nombre_prog_crudo)
|
||||||
|
|
||||||
|
es_refriperu = False
|
||||||
|
if mat_id in correcciones_desc:
|
||||||
|
if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True
|
||||||
|
elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False
|
||||||
|
else:
|
||||||
|
if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True
|
||||||
|
elif clase_alumno in ["TEAC", "TERC", "CARRERA"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True
|
||||||
|
|
||||||
|
tipo_final = "NUEVO"
|
||||||
|
if es_refriperu: tipo_final = "REFRIPERU"
|
||||||
|
else:
|
||||||
|
es_cont = False
|
||||||
|
if mat_id in correcciones_cont:
|
||||||
|
if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True
|
||||||
|
elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False
|
||||||
|
else:
|
||||||
|
if fecha_inicio_actual and linea_actual and (dni in datos_historial):
|
||||||
|
for antecedente in datos_historial[dni]:
|
||||||
|
nombre_pasado = str(antecedente.get('programa', ''))
|
||||||
|
fecha_pasada = antecedente.get('fecha')
|
||||||
|
if not fecha_pasada: continue
|
||||||
|
if not (fecha_pasada < fecha_inicio_actual): continue
|
||||||
|
dias_diff = (fecha_inicio_actual - fecha_pasada).days
|
||||||
|
if dias_diff < 60: continue
|
||||||
|
linea_pasada = self.identificar_linea_carrera(nombre_pasado)
|
||||||
|
if linea_pasada == linea_actual:
|
||||||
|
es_cont = True
|
||||||
|
break
|
||||||
|
if es_cont: tipo_final = "CONTINUIDAD"
|
||||||
|
|
||||||
|
# ===============================================================
|
||||||
|
# 🔥 MATEMÁTICA PURA EXACTAMENTE COMO LA PEDISTE 🔥
|
||||||
|
# ===============================================================
|
||||||
|
suma_cuotas = 0.0
|
||||||
|
mis_cuotas = cuotas_por_mat.get(mat_id, [])
|
||||||
|
|
||||||
|
for c_dict in mis_cuotas:
|
||||||
|
try: num_c = int(c_dict.get('num_cuota', 0))
|
||||||
|
except: num_c = 0
|
||||||
|
|
||||||
|
# REGLA: Si el número de cuota es mayor a 0, sumar (imp_total - imp_dscto)
|
||||||
|
if num_c > 0:
|
||||||
|
t_val = c_dict.get('imp_total', 0)
|
||||||
|
d_val = c_dict.get('imp_dscto', 0)
|
||||||
|
|
||||||
|
try: t_monto = float(t_val) if t_val is not None else 0.0
|
||||||
|
except: t_monto = 0.0
|
||||||
|
try: d_monto = float(d_val) if d_val is not None else 0.0
|
||||||
|
except: d_monto = 0.0
|
||||||
|
|
||||||
|
imp_c = t_monto - d_monto
|
||||||
|
|
||||||
|
# Conversión si es Dólares
|
||||||
|
tc_over = None
|
||||||
|
if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]:
|
||||||
|
tc_over = float(correcciones_mat[mat_id]["imp_tc"])
|
||||||
|
|
||||||
|
cod_m_c = str(c_dict.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
if cod_m_c == "DOL":
|
||||||
|
if tc_over is not None: tc_c = tc_over
|
||||||
|
else:
|
||||||
|
try: tc_c = float(c_dict.get('imp_tc', 0) or 0)
|
||||||
|
except: tc_c = 0
|
||||||
|
if tc_c < 2: tc_c = 3.45 # TC por defecto si es inválido
|
||||||
|
imp_c *= tc_c
|
||||||
|
|
||||||
|
suma_cuotas += imp_c
|
||||||
|
|
||||||
|
# ===============================================================
|
||||||
|
# DIVISIÓN SEGÚN CLASIFICACIÓN
|
||||||
|
# ===============================================================
|
||||||
|
if suma_cuotas > 0:
|
||||||
|
if clase_alumno in ["TEAC", "TERC", "CARRERA"]:
|
||||||
|
valor_cuota_final = suma_cuotas / 5.0
|
||||||
|
elif clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"]:
|
||||||
|
valor_cuota_final = suma_cuotas / 6.0
|
||||||
|
else:
|
||||||
|
valor_cuota_final = suma_cuotas
|
||||||
|
|
||||||
|
str_cuota_val = f"S/ {valor_cuota_final:,.0f}"
|
||||||
|
else:
|
||||||
|
valor_cuota_final = 0.0
|
||||||
|
# Si no tiene cuotas mayores a 0, se queda en blanco limpio
|
||||||
|
str_cuota_val = ""
|
||||||
|
|
||||||
|
# ===============================================================
|
||||||
|
# LÓGICA DESC. ESPECIAL (Sobre la suma pura)
|
||||||
|
# ===============================================================
|
||||||
|
desc_e_raw = 0.0
|
||||||
|
if suma_cuotas >= 1900:
|
||||||
|
if clase_alumno in ["TEAC", "TERC", "CARRERA"]: d_calc = 3400 - suma_cuotas
|
||||||
|
elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - suma_cuotas
|
||||||
|
else: d_calc = 0
|
||||||
|
|
||||||
|
if d_calc > 0:
|
||||||
|
desc_e_raw = min(max(d_calc - 200, 0.0), 200.0)
|
||||||
|
|
||||||
|
str_desc_e_val = f"S/ {desc_e_raw:,.0f}" if desc_e_raw > 0 else ""
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# DISTRIBUCIÓN EN LAS 3 LISTAS
|
||||||
|
# ==========================================
|
||||||
|
vendedor_real = d.get('dsc_vendedor', 'SIN VENDEDOR')
|
||||||
|
if not vendedor_real: vendedor_real = 'SIN VENDEDOR'
|
||||||
|
|
||||||
|
# Campos extra para el popup de Comisiones (mismo alumno / num_indice)
|
||||||
|
# NOTA: la query trae el programa DETALLADO con alias 'dsc_promocion'
|
||||||
|
# (sgede_RP_programa.dsc_det_programa AS dsc_promocion).
|
||||||
|
# MOSTRAR: prioriza el detallado (dsc_promocion)
|
||||||
|
det_programa = (d.get('dsc_promocion') or d.get('dsc_det_programa')
|
||||||
|
or d.get('dsc_programa') or '')
|
||||||
|
# CLASIFICAR (sede/filtro): prioriza dsc_programa
|
||||||
|
prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa')
|
||||||
|
or d.get('dsc_promocion') or '')
|
||||||
|
fch_ini_raw = d.get('fch_inicio', '')
|
||||||
|
fch_ini_limpia = ""
|
||||||
|
if fch_ini_raw and str(fch_ini_raw).strip() != "None":
|
||||||
|
try:
|
||||||
|
if hasattr(fch_ini_raw, 'strftime'): fch_ini_limpia = fch_ini_raw.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
t = str(fch_ini_raw)[:10]
|
||||||
|
if len(t) == 10 and t[4] == '-':
|
||||||
|
p = t.split('-'); fch_ini_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
else: fch_ini_limpia = t
|
||||||
|
except: fch_ini_limpia = str(fch_ini_raw)[:10]
|
||||||
|
str_saldo_mat = f"S/ {saldo_mat:,.0f}" if saldo_mat else "S/ 0"
|
||||||
|
str_saldo_c1 = f"S/ {saldo_c1:,.0f}" if saldo_c1 else "S/ 0"
|
||||||
|
|
||||||
|
# ── Cálculos extra para Comisiones ──────────────────────────
|
||||||
|
# DÍAS ANTICIPACIÓN = Fecha Inicio - Fecha Cancelación 1 (en días)
|
||||||
|
dias_anticipacion = "-"
|
||||||
|
try:
|
||||||
|
def _to_date(v):
|
||||||
|
if not v or str(v).strip() in ("", "None"): return None
|
||||||
|
if hasattr(v, 'year'): return datetime(v.year, v.month, v.day)
|
||||||
|
s = str(v)[:10]
|
||||||
|
if len(s) == 10 and s[4] == '-': return datetime.strptime(s, '%Y-%m-%d')
|
||||||
|
if len(s) == 10 and s[2] == '/':
|
||||||
|
p = s.split('/'); return datetime(int(p[2]), int(p[1]), int(p[0]))
|
||||||
|
return None
|
||||||
|
di = _to_date(fch_ini_raw)
|
||||||
|
dc = _to_date(fch_canc_raw)
|
||||||
|
if di and dc:
|
||||||
|
dias_anticipacion = str((di - dc).days)
|
||||||
|
except Exception:
|
||||||
|
dias_anticipacion = "-"
|
||||||
|
|
||||||
|
# TIPO PROGRAMA y sede (clasificación tipo Cobranza vía sede.json)
|
||||||
|
sede_alumno = self._clasificar_sede_comisiones(prog_clasificacion or nombre_prog_crudo)
|
||||||
|
tipo_programa = sede_alumno # según sede.json (LIMA/AREQUIPA/PIURA/TRUJILLO)
|
||||||
|
|
||||||
|
# VALOR CUOTA ADICIONAL = Promedio cuota - 640 (solo si sede = LIMA, mínimo 0)
|
||||||
|
if sede_alumno == "LIMA" and valor_cuota_final > 0:
|
||||||
|
valor_adicional = valor_cuota_final - 640
|
||||||
|
if valor_adicional < 0:
|
||||||
|
valor_adicional = 0
|
||||||
|
str_valor_adicional = f"S/ {valor_adicional:,.0f}"
|
||||||
|
else:
|
||||||
|
str_valor_adicional = "-"
|
||||||
|
|
||||||
|
fila_final = [
|
||||||
|
vendedor_real, alumno_nombre, fecha_mat_limpia, fch_canc_limpia,
|
||||||
|
tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}",
|
||||||
|
# Extra para Comisiones (índices 8+): programa, fch inicio, saldos
|
||||||
|
det_programa, fch_ini_limpia, str_saldo_mat, str_saldo_c1,
|
||||||
|
# Nuevos (índices 12+): días anticipación, valor cuota adicional, tipo programa
|
||||||
|
dias_anticipacion, str_valor_adicional, tipo_programa,
|
||||||
|
# Índice [15]: num_matricula (clave para overrides de Comisiones)
|
||||||
|
mat_id,
|
||||||
|
# Índice [16]: nombre para CLASIFICAR (prioriza dsc_programa). Oculto en UI.
|
||||||
|
prog_clasificacion
|
||||||
|
]
|
||||||
|
|
||||||
|
es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes_numero))
|
||||||
|
saldos_ok = (saldo_mat == 0 and saldo_c1 == 0)
|
||||||
|
if tiene_override_fecha:
|
||||||
|
saldos_ok = True
|
||||||
|
|
||||||
|
def agregar_a_lista(nombre_lista):
|
||||||
|
listas_datos[nombre_lista]["filas"].append(fila_final)
|
||||||
|
listas_datos[nombre_lista]["s_neta"] += inv_neta_soles
|
||||||
|
|
||||||
|
if valor_cuota_final > 0:
|
||||||
|
listas_datos[nombre_lista]["s_cuota"] += valor_cuota_final
|
||||||
|
listas_datos[nombre_lista]["c_cuota"] += 1
|
||||||
|
|
||||||
|
if desc_e_raw > 0:
|
||||||
|
listas_datos[nombre_lista]["s_desc"] += desc_e_raw
|
||||||
|
listas_datos[nombre_lista]["c_desc"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas
|
||||||
|
# Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas
|
||||||
|
pago_en_fecha = False
|
||||||
|
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
|
||||||
|
try:
|
||||||
|
if isinstance(fch_canc_raw, str):
|
||||||
|
f_obj = datetime.strptime(fch_canc_raw[:10], '%Y-%m-%d')
|
||||||
|
f_ano, f_mes = f_obj.year, f_obj.month
|
||||||
|
else:
|
||||||
|
f_ano, f_mes = fch_canc_raw.year, fch_canc_raw.month
|
||||||
|
|
||||||
|
if str(f_ano) == str(ano) and str(f_mes) == str(mes_numero):
|
||||||
|
pago_en_fecha = True
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# ¿Matrícula de un mes ANTERIOR al filtro?
|
||||||
|
matricula_mes_pasado = False
|
||||||
|
try:
|
||||||
|
ym_mat = int(row_year) * 100 + int(row_month)
|
||||||
|
ym_filtro = int(ano) * 100 + int(mes_numero)
|
||||||
|
matricula_mes_pasado = ym_mat < ym_filtro
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if es_venta_del_mes:
|
||||||
|
agregar_a_lista("Venta Inscritos")
|
||||||
|
if saldos_ok and pago_en_fecha:
|
||||||
|
agregar_a_lista("Venta P.C")
|
||||||
|
|
||||||
|
# MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro
|
||||||
|
if matricula_mes_pasado and saldos_ok and pago_en_fecha:
|
||||||
|
agregar_a_lista("Venta Pendientes")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# ORDENAR Y ARMAR TOTAL GENERAL
|
||||||
|
# ==========================================
|
||||||
|
resultado_final = {}
|
||||||
|
for k, dict_data in listas_datos.items():
|
||||||
|
dict_data["filas"].sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
p_cuota = (dict_data["s_cuota"] / dict_data["c_cuota"]) if dict_data["c_cuota"] > 0 else 0.0
|
||||||
|
p_desc = (dict_data["s_desc"] / dict_data["c_desc"]) if dict_data["c_desc"] > 0 else 0.0
|
||||||
|
|
||||||
|
str_gral_cuota = f"S/ {p_cuota:,.0f}" if dict_data["c_cuota"] > 0 else ""
|
||||||
|
str_gral_desc = f"S/ {p_desc:,.0f}" if dict_data["c_desc"] > 0 else ""
|
||||||
|
|
||||||
|
dict_data["filas"].append([
|
||||||
|
"TOTAL GENERAL", "", "", "", "",
|
||||||
|
str_gral_cuota, str_gral_desc, f"S/ {dict_data['s_neta']:,.0f}"
|
||||||
|
])
|
||||||
|
resultado_final[k] = dict_data["filas"]
|
||||||
|
|
||||||
|
return resultado_final
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error procesando alumnos para modal Ventas: {e}")
|
||||||
|
return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []}
|
||||||
|
|
||||||
|
def formatear_datos_para_tabla(self, datos):
|
||||||
|
if not datos:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
total_monto = total_pc = total_pendientes = total_avance_pc_total = 0.0
|
||||||
|
total_cantidad = total_cantidad_pc = total_cantidad_pendientes = 0
|
||||||
|
|
||||||
|
for registro in datos:
|
||||||
|
vendedor = registro.get('VENDEDOR', 'SIN VENDEDOR')
|
||||||
|
monto = float(registro.get('MONTO', 0.0))
|
||||||
|
cantidad = int(registro.get('CANTIDAD', 0))
|
||||||
|
ventas_pc = float(registro.get('VENTAS_PC', 0.0))
|
||||||
|
cantidad_pc = int(registro.get('INSCRITOS_PC', 0))
|
||||||
|
pendientes = float(registro.get('PENDIENTES', 0.0))
|
||||||
|
cant_pendientes = int(registro.get('INSCRITOS_PENDIENTES', 0))
|
||||||
|
|
||||||
|
avance_pc_total = ventas_pc + pendientes
|
||||||
|
|
||||||
|
total_monto += monto; total_cantidad += cantidad; total_pc += ventas_pc
|
||||||
|
total_cantidad_pc += cantidad_pc; total_pendientes += pendientes
|
||||||
|
total_cantidad_pendientes += cant_pendientes; total_avance_pc_total += avance_pc_total
|
||||||
|
|
||||||
|
sheet_data.append([
|
||||||
|
vendedor, cantidad, f"S/ {monto:,.0f}", cantidad_pc, f"S/ {ventas_pc:,.0f}",
|
||||||
|
cant_pendientes, f"S/ {pendientes:,.0f}", f"S/ {avance_pc_total:,.0f}",
|
||||||
|
" ≡ ▼ "
|
||||||
|
])
|
||||||
|
|
||||||
|
sheet_data.append([
|
||||||
|
"TOTAL GENERAL", total_cantidad, f"S/ {total_monto:,.0f}",
|
||||||
|
total_cantidad_pc, f"S/ {total_pc:,.0f}", total_cantidad_pendientes,
|
||||||
|
f"S/ {total_pendientes:,.0f}", f"S/ {total_avance_pc_total:,.0f}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
|
||||||
|
return sheet_data
|
||||||
|
|
||||||
|
def exportar_detalle_alumnos_excel(self, vendedor, lista_nombre, headers, datos):
|
||||||
|
if not datos: raise ValueError("No hay datos para exportar")
|
||||||
|
vend_limpio = "".join([c if c.isalnum() else "_" for c in str(vendedor)])[:30]
|
||||||
|
lista_limpia = "".join([c if c.isalnum() else "_" for c in str(lista_nombre)])
|
||||||
|
archivo = f"Ventas_{vend_limpio}_{lista_limpia}.xlsx"
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos, columns=headers)
|
||||||
|
try:
|
||||||
|
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='Detalle_Ventas')
|
||||||
|
worksheet = writer.sheets['Detalle_Ventas']
|
||||||
|
try:
|
||||||
|
from openpyxl.styles import PatternFill, Font, Border, Side, Alignment
|
||||||
|
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
|
||||||
|
font_header = Font(color="FFFFFF", bold=True)
|
||||||
|
thin_border = Border(left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"),
|
||||||
|
top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD"))
|
||||||
|
|
||||||
|
for cell in worksheet[1]:
|
||||||
|
cell.fill = fill_header
|
||||||
|
cell.font = font_header
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
for row in worksheet.iter_rows(min_row=2):
|
||||||
|
for cell in row:
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.alignment = Alignment(vertical="center")
|
||||||
|
|
||||||
|
for col in worksheet.columns:
|
||||||
|
max_len = 0
|
||||||
|
col_letter = col[0].column_letter
|
||||||
|
for cell in col:
|
||||||
|
if cell.value: max_len = max(max_len, len(str(cell.value)))
|
||||||
|
worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50)
|
||||||
|
except: pass
|
||||||
|
except: df.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def exportar_a_excel(self, datos, ano, nombre_mes):
|
||||||
|
if not datos: raise ValueError("No hay datos para exportar")
|
||||||
|
datos_export = []
|
||||||
|
for d in datos:
|
||||||
|
nuevo_d = dict(d)
|
||||||
|
nuevo_d['AVANCE_PC_TOTAL'] = float(d.get('VENTAS_PC', 0)) + float(d.get('PENDIENTES', 0))
|
||||||
|
datos_export.append(nuevo_d)
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos_export)
|
||||||
|
archivo = f"ventas_{ano}_{nombre_mes}.xlsx"
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
10
backend/backend/requirements.txt
Normal file
10
backend/backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
pyodbc==5.1.0
|
||||||
|
requests==2.32.3
|
||||||
|
pandas==2.2.2
|
||||||
|
openpyxl==3.1.5
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
supabase==2.15.1
|
||||||
|
httpx==0.28.1
|
||||||
|
pytz==2024.1
|
||||||
587
backend/backend/services.py
Normal file
587
backend/backend/services.py
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
# backend/services.py
|
||||||
|
"""
|
||||||
|
Capa de servicios: envuelve la lógica existente (modules/) con caché.
|
||||||
|
NO modifica la lógica de negocio — solo la llama y serializa el resultado.
|
||||||
|
"""
|
||||||
|
from core.data_manager import DataManager
|
||||||
|
from modules.ocupabilidad.logic import AnalizadorCursos
|
||||||
|
from modules.ventas.logic import VentasLogic
|
||||||
|
from modules.cobranza.logic import CobranzaLogic
|
||||||
|
from modules.rentabilidad.logic import RentabilidadLogic
|
||||||
|
from modules.saldo_pendiente.logic import SaldoLogic
|
||||||
|
from cache_manager import cache_get_or_set
|
||||||
|
|
||||||
|
# Instancia única del DataManager (como @st.cache_resource)
|
||||||
|
_DM = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_dm() -> DataManager:
|
||||||
|
global _DM
|
||||||
|
if _DM is None:
|
||||||
|
_DM = DataManager()
|
||||||
|
return _DM
|
||||||
|
|
||||||
|
|
||||||
|
def _serializar_dicts(datos):
|
||||||
|
out = []
|
||||||
|
for d in (datos or []):
|
||||||
|
fila = {}
|
||||||
|
for k, v in d.items():
|
||||||
|
fila[k] = v if isinstance(v, (str, int, float, bool)) or v is None else str(v)
|
||||||
|
out.append(fila)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _serializar_filas(filas, limite=None):
|
||||||
|
out = []
|
||||||
|
for f in (filas or []):
|
||||||
|
vals = f[:limite] if limite else f
|
||||||
|
out.append([str(v) if v is not None else "" for v in vals])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── OCUPABILIDAD ───────────────────────────────────────────────────────────
|
||||||
|
def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
analizador = AnalizadorCursos(dm)
|
||||||
|
datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa)
|
||||||
|
filas = analizador.formatear_datos_para_tabla(datos)
|
||||||
|
return {
|
||||||
|
"datos": _serializar_dicts(datos),
|
||||||
|
"filas": _serializar_filas(filas),
|
||||||
|
}
|
||||||
|
return cache_get_or_set("ocupabilidad", (ano, mes, sede, programa), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── VENTAS ─────────────────────────────────────────────────────────────────
|
||||||
|
def ventas(ano, mes, sede="TODOS", programa="TODOS"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) # TC de Supabase o None
|
||||||
|
dm._fecha_canc_overrides = comisiones_overrides_fecha() # {mat: fecha_canc override}
|
||||||
|
dm._inv_neta_overrides = comisiones_overrides_inv_neta() # {mat: inversion_neta override}
|
||||||
|
logic = VentasLogic(dm)
|
||||||
|
datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa) \
|
||||||
|
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes))
|
||||||
|
datos = [d for d in datos if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR']
|
||||||
|
filas = logic.formatear_datos_para_tabla(datos)
|
||||||
|
return {
|
||||||
|
"filas": _serializar_filas(filas),
|
||||||
|
"datos": _serializar_dicts(datos),
|
||||||
|
}
|
||||||
|
return cache_get_or_set("ventas", (ano, mes, sede, programa), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def _clasif_filtro_programa(dm, dscp):
|
||||||
|
"""Misma clasificación de programa que ejecutar_consulta_ventas (SEMINARIOS/TEAC/TERC/OTROS)."""
|
||||||
|
up = str(dscp or "").upper()
|
||||||
|
fdata = dm.meta_data.get("clasificacion_filtro_programa", {})
|
||||||
|
for cat in ["OTROS", "SEMINARIOS", "TEAC", "TERC"]:
|
||||||
|
for pat in fdata.get(cat, {}).get("patrones", []):
|
||||||
|
if pat.upper() in up:
|
||||||
|
return cat
|
||||||
|
return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS"
|
||||||
|
|
||||||
|
|
||||||
|
def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes)
|
||||||
|
dm._fecha_canc_overrides = comisiones_overrides_fecha()
|
||||||
|
logic = VentasLogic(dm)
|
||||||
|
resultado = logic.obtener_detalle_vendedor(vendedor, str(ano), str(mes))
|
||||||
|
filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else []
|
||||||
|
filas = _aplicar_overrides_comisiones(filas)
|
||||||
|
# Filtro por SEDE [14] y PROGRAMA (clasificado desde el nombre [16]),
|
||||||
|
# mismas reglas que la tabla principal. Omite la fila TOTAL GENERAL.
|
||||||
|
_sede = str(sede or "TODOS").upper()
|
||||||
|
_prog = str(programa or "TODOS").upper()
|
||||||
|
if _sede != "TODOS" or _prog != "TODOS":
|
||||||
|
out = []
|
||||||
|
for f in filas:
|
||||||
|
if str(f[0]).strip().upper() == "TOTAL GENERAL":
|
||||||
|
continue # el TOTAL se recalcula en el frontend
|
||||||
|
if len(f) < 15:
|
||||||
|
out.append(f); continue
|
||||||
|
if _sede != "TODOS" and str(f[14]).strip().upper() != _sede:
|
||||||
|
continue
|
||||||
|
# Clasificar programa por el nombre de CLASIFICACIÓN [16] (prioriza dsc_programa);
|
||||||
|
# si no existe, usar el mostrado [8].
|
||||||
|
_prog_nombre = f[16] if len(f) > 16 else f[8]
|
||||||
|
if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog:
|
||||||
|
continue
|
||||||
|
out.append(f)
|
||||||
|
filas = out
|
||||||
|
return {"filas": _serializar_filas(filas)}
|
||||||
|
return cache_get_or_set("ventas_det", (vendedor, ano, mes, tipo_lista, sede, programa), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def comisiones_detalle_todos(ano, mes):
|
||||||
|
"""Devuelve TODOS los matriculados (todos los años/meses) para el buscador.
|
||||||
|
Aplica overrides de Supabase. Cacheado globalmente (no depende del mes)."""
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = VentasLogic(dm)
|
||||||
|
filas = logic.buscar_todos_matriculados()
|
||||||
|
filas = _aplicar_overrides_comisiones(filas)
|
||||||
|
return {"filas": _serializar_filas(filas)}
|
||||||
|
return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def comisiones_overrides_fecha():
|
||||||
|
"""Devuelve dict {num_matricula: fecha_cancelacion1_override} para clasificación."""
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute()
|
||||||
|
out = {}
|
||||||
|
for r in (res.data or []):
|
||||||
|
mat = str(r["num_matricula"])
|
||||||
|
v = r.get("fecha_cancelacion1")
|
||||||
|
sv = "" if v is None else str(v).strip()
|
||||||
|
# Solo "__VACIO__" = fecha borrada a propósito → anula la del SQL.
|
||||||
|
# None/"" = la fila tiene override de OTROS campos pero no toco la fecha -> usar SQL.
|
||||||
|
if sv == "__VACIO__":
|
||||||
|
out[mat] = "__VACIO__"
|
||||||
|
elif sv != "":
|
||||||
|
out[mat] = sv
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def comisiones_overrides_inv_neta():
|
||||||
|
"""Devuelve dict {num_matricula: inversion_neta(float)} para la tabla principal.
|
||||||
|
El valor es el monto final en soles (no se re-convierte por TC)."""
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.table("comisiones_overrides").select("num_matricula,inversion_neta").execute()
|
||||||
|
out = {}
|
||||||
|
for r in (res.data or []):
|
||||||
|
v = r.get("inversion_neta")
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
s = str(v).replace("S/", "").replace(",", "").strip()
|
||||||
|
if s == "":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out[str(r["num_matricula"])] = float(s)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _aplicar_overrides_comisiones(filas):
|
||||||
|
"""Sobreescribe valores de cada fila con los guardados en Supabase (por num_matricula).
|
||||||
|
Índices de fila: [2]F.MAT [7]INV.NETA [9]F.INI [10]SALDO MAT [11]SALDO C1
|
||||||
|
[12]DÍAS [13]VALOR ADIC [14]TIPO PROG [15]num_matricula. (5=PROMEDIO CUOTA)"""
|
||||||
|
try:
|
||||||
|
dm = get_dm()
|
||||||
|
if not getattr(dm, "supabase_client", None):
|
||||||
|
return filas
|
||||||
|
res = dm.supabase_client.table("comisiones_overrides").select("*").execute()
|
||||||
|
ov = {str(r["num_matricula"]): r for r in (res.data or [])}
|
||||||
|
if not ov:
|
||||||
|
return filas
|
||||||
|
# columna override -> índice en la fila
|
||||||
|
mapa = {
|
||||||
|
"fch_matricula": 2, "promedio_cuota": 5, "inversion_neta": 7,
|
||||||
|
"fecha_inicio": 9, "saldo_matricula": 10, "saldo_cuota1": 11,
|
||||||
|
"dias_anticipacion": 12, "valor_cuota_adicional": 13, "tipo_programa": 14,
|
||||||
|
"fecha_cancelacion1": 3,
|
||||||
|
}
|
||||||
|
for f in filas:
|
||||||
|
if len(f) < 16:
|
||||||
|
continue
|
||||||
|
mat = str(f[15])
|
||||||
|
# [16] = nombre para clasificar (si no vino, usar el mostrado [8])
|
||||||
|
# [17] = flag "tiene override" (0 por defecto)
|
||||||
|
if len(f) == 16:
|
||||||
|
f.append(f[8]) # buscador: no trae [16] → reutiliza el nombre mostrado
|
||||||
|
if len(f) == 17:
|
||||||
|
f.append("0")
|
||||||
|
if mat in ov:
|
||||||
|
r = ov[mat]
|
||||||
|
for col, idx in mapa.items():
|
||||||
|
val = r.get(col)
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
if str(val) == "__VACIO__":
|
||||||
|
f[idx] = "" # forzar vacío explícito (override "borrar")
|
||||||
|
elif str(val) != "":
|
||||||
|
f[idx] = val
|
||||||
|
f[17] = "1" # marcar fila como editada
|
||||||
|
return filas
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[overrides comisiones] {e}")
|
||||||
|
return filas
|
||||||
|
|
||||||
|
|
||||||
|
# ── COBRANZA ───────────────────────────────────────────────────────────────
|
||||||
|
def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = CobranzaLogic(dm)
|
||||||
|
filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion)
|
||||||
|
sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes))
|
||||||
|
return {
|
||||||
|
"filas": _serializar_filas(filas),
|
||||||
|
"sectoristas": sectoristas or ["TODOS"],
|
||||||
|
}
|
||||||
|
return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = CobranzaLogic(dm)
|
||||||
|
if agrupacion == "ASESOR":
|
||||||
|
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS")
|
||||||
|
elif agrupacion == "SEDE":
|
||||||
|
# Filtrar por sede usando el MISMO clasificador del processor (sede.json)
|
||||||
|
crudos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS")
|
||||||
|
grupo_up = str(grupo).upper()
|
||||||
|
mats_sede = set()
|
||||||
|
for d in (crudos or []):
|
||||||
|
prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "")
|
||||||
|
sede_det = str(logic.processor.clasificar_sede(prog)).upper()
|
||||||
|
if sede_det == grupo_up:
|
||||||
|
mats_sede.add(str(d.get("MATRICULA", "")))
|
||||||
|
todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS")
|
||||||
|
# Solo alumnos de la sede (sin la fila TOTAL global; el frontend recalcula el total)
|
||||||
|
datos = [f for f in (todas or []) if str(f[0]) in mats_sede]
|
||||||
|
else: # PROGRAMA
|
||||||
|
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo)
|
||||||
|
return {"filas": _serializar_filas(datos)}
|
||||||
|
return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── RENTABILIDAD ───────────────────────────────────────────────────────────
|
||||||
|
def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = RentabilidadLogic(dm)
|
||||||
|
datos = logic.obtener_datos_procesados(str(ano), str(mes), sede, filtro_prog)
|
||||||
|
filas = logic.formatear_datos_para_tabla(datos)
|
||||||
|
filas_s = _serializar_filas(filas, 13)
|
||||||
|
nombres = [str(f[0]) for f in filas_s if "TOTAL" not in str(f[0]).upper()]
|
||||||
|
return {"filas": filas_s, "programas": nombres}
|
||||||
|
return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def rentabilidad_detalle(programa, ano, mes):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = RentabilidadLogic(dm)
|
||||||
|
resultado = logic.obtener_detalle_programa(programa, str(ano), str(mes))
|
||||||
|
return {"filas": _serializar_filas(resultado)}
|
||||||
|
return cache_get_or_set("rentabilidad_det", (programa, ano, mes), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def rentabilidad_costos(programa, ano, mes):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = RentabilidadLogic(dm)
|
||||||
|
return {"costos": logic.obtener_datos_costos_programa(programa, str(ano), str(mes))}
|
||||||
|
return cache_get_or_set("rentabilidad_costos", (programa, ano, mes), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SALDO PENDIENTE ────────────────────────────────────────────────────────
|
||||||
|
def saldo_pendiente(tipo_cuota):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = SaldoLogic(dm)
|
||||||
|
datos = logic.obtener_saldos_consolidados(tipo_cuota)
|
||||||
|
return {"datos": _serializar_dicts(datos)}
|
||||||
|
return cache_get_or_set("saldo", (tipo_cuota,), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PRECARGA GLOBAL (calienta todo el caché) ───────────────────────────────
|
||||||
|
def precargar_todo():
|
||||||
|
from datetime import datetime
|
||||||
|
ahora = datetime.now()
|
||||||
|
ano, mes = ahora.year, ahora.month
|
||||||
|
tareas = [
|
||||||
|
lambda: ocupabilidad(ano, mes),
|
||||||
|
lambda: ventas(ano, mes),
|
||||||
|
lambda: cobranza(ano, mes, "TODOS", "SEDE"),
|
||||||
|
lambda: cobranza(ano, mes, "TODOS", "PROGRAMA"),
|
||||||
|
lambda: cobranza(ano, mes, "TODOS", "ASESOR"),
|
||||||
|
lambda: rentabilidad(ano, mes),
|
||||||
|
lambda: saldo_pendiente("1° Cuota"),
|
||||||
|
]
|
||||||
|
for t in tareas:
|
||||||
|
try:
|
||||||
|
t()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[precarga] error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
_refresh_count = 0
|
||||||
|
|
||||||
|
def _recalcular_clave(key):
|
||||||
|
"""Dada una clave 'prefijo:arg1|arg2|...', recalcula su valor sin usar el caché viejo."""
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
try:
|
||||||
|
prefijo, _, resto = key.partition(":")
|
||||||
|
args = resto.split("|") if resto else []
|
||||||
|
# Invalidar SOLO esta entrada para forzar su recálculo
|
||||||
|
cache_invalidate(prefijo + ":" + resto if resto else prefijo)
|
||||||
|
if prefijo == "ocupabilidad" and len(args) == 4:
|
||||||
|
return ocupabilidad(args[0], args[1], args[2], args[3])
|
||||||
|
if prefijo == "ventas" and len(args) == 2:
|
||||||
|
return ventas(args[0], args[1])
|
||||||
|
if prefijo == "cobranza" and len(args) == 4:
|
||||||
|
return cobranza(args[0], args[1], args[2], args[3])
|
||||||
|
if prefijo == "rentabilidad" and len(args) == 4:
|
||||||
|
return rentabilidad(args[0], args[1], args[2], args[3])
|
||||||
|
if prefijo == "saldo" and len(args) == 1:
|
||||||
|
return saldo_pendiente(args[0])
|
||||||
|
if prefijo == "cobranza_det_todos" and len(args) == 3:
|
||||||
|
return cobranza_detalle_todos(args[0], args[1], args[2])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[recalcular] {key}: {e}")
|
||||||
|
return None # los detalles puntuales se recalculan al pedirse
|
||||||
|
|
||||||
|
|
||||||
|
def refrescar_todo():
|
||||||
|
"""Refresca SOLO las entradas ya cacheadas (no vacía el caché), para que los
|
||||||
|
meses ya visitados se mantengan rápidos. Cada ~1h recarga config de GitHub."""
|
||||||
|
global _refresh_count
|
||||||
|
from cache_manager import cache_refresh_existing, cache_keys
|
||||||
|
_refresh_count += 1
|
||||||
|
# El hilo corre cada 900s (15 min); 4 ciclos ≈ 60 min → recargar config GitHub
|
||||||
|
if _refresh_count % 4 == 0:
|
||||||
|
try:
|
||||||
|
get_dm().cargar_toda_configuracion()
|
||||||
|
print("[config] Recarga de queries/JSON de GitHub completada")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[config] error al recargar GitHub: {e}")
|
||||||
|
|
||||||
|
# Si el caché está vacío (primer arranque), hacer precarga normal
|
||||||
|
if not cache_keys():
|
||||||
|
precargar_todo()
|
||||||
|
else:
|
||||||
|
cache_refresh_existing(_recalcular_clave)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GUARDAR COSTOS (override Supabase) ─────────────────────────────────────
|
||||||
|
def guardar_costos(num_indice, costos_inicial, costos_actual):
|
||||||
|
dm = get_dm()
|
||||||
|
ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial)
|
||||||
|
ok2 = dm.guardar_override_costo(num_indice, "actual", costos_actual)
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
cache_invalidate("rentabilidad")
|
||||||
|
cache_invalidate("rentabilidad_costos")
|
||||||
|
return bool(ok1 and ok2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLASIFICACIÓN DE SEDE (sede.json) ──────────────────────────────────────
|
||||||
|
def clasificar_programas(nombres):
|
||||||
|
"""Devuelve {programa: sede} usando el clasificador del processor (sede.json)."""
|
||||||
|
dm = get_dm()
|
||||||
|
logic = CobranzaLogic(dm)
|
||||||
|
return {n: str(logic.processor.clasificar_sede(n)) for n in nombres}
|
||||||
|
|
||||||
|
|
||||||
|
def cobranza_detalle_todos(ano, mes, sectorista="TODOS"):
|
||||||
|
"""Trae TODOS los alumnos en UNA sola consulta (para export y buscador)."""
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = CobranzaLogic(dm)
|
||||||
|
brutos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS")
|
||||||
|
alumnos = []
|
||||||
|
for d in (brutos or []):
|
||||||
|
prog = d.get("Programa", d.get("PROGRAMA", "-")) or "-"
|
||||||
|
cta_ant = float(d.get("CTA_COB_ANT", 0.0)); cob_ant = float(d.get("COB_ANT", 0.0))
|
||||||
|
cta_cur = float(d.get("CTA_COB_MES_CURSO", 0.0)); cob_cur = float(d.get("COB_MES_CURSO", 0.0))
|
||||||
|
cta_tot = float(d.get("TOTAL_CTA_COB", 0.0)); cob_tot = float(d.get("TOTAL_COBRADO", 0.0))
|
||||||
|
saldo = float(d.get("SALDO", 0.0))
|
||||||
|
alumnos.append({
|
||||||
|
"matricula": str(d.get("MATRICULA", "")),
|
||||||
|
"alumno": d.get("ALUMNO", d.get("Alumno", "")),
|
||||||
|
"programa": prog,
|
||||||
|
"sede": str(logic.processor.clasificar_sede(prog)),
|
||||||
|
"frecuencia": d.get("FRECUENCIA", d.get("Frecuencia", "-")),
|
||||||
|
"num_cuota": d.get("NUM_CUOTA", "-"),
|
||||||
|
"fch_venc": d.get("FCH_VENC", "-"),
|
||||||
|
"cta_ant": cta_ant, "cob_ant": cob_ant,
|
||||||
|
"cta_cur": cta_cur, "cob_cur": cob_cur,
|
||||||
|
"cta_tot": cta_tot, "cob_tot": cob_tot,
|
||||||
|
"saldo": saldo,
|
||||||
|
})
|
||||||
|
return {"alumnos": alumnos}
|
||||||
|
return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GESTIÓN DE USUARIOS (Supabase Auth + tabla perfiles) ───────────────────
|
||||||
|
def _sb():
|
||||||
|
dm = get_dm()
|
||||||
|
if not dm.supabase_client:
|
||||||
|
raise RuntimeError("Supabase no está conectado")
|
||||||
|
return dm.supabase_client
|
||||||
|
|
||||||
|
def usuarios_listar():
|
||||||
|
sb = _sb()
|
||||||
|
perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or []
|
||||||
|
try:
|
||||||
|
users = sb.auth.admin.list_users()
|
||||||
|
lista = users if isinstance(users, list) else getattr(users, "users", [])
|
||||||
|
correo_por_id = {str(u.id): u.email for u in lista}
|
||||||
|
except Exception:
|
||||||
|
correo_por_id = {}
|
||||||
|
out = []
|
||||||
|
for p in perfiles:
|
||||||
|
out.append({
|
||||||
|
"id": p["id"],
|
||||||
|
"email": correo_por_id.get(str(p["id"]), "-"),
|
||||||
|
"nombre": p.get("nombre"),
|
||||||
|
"rol": p.get("rol"),
|
||||||
|
"activo": p.get("activo", True),
|
||||||
|
})
|
||||||
|
return {"usuarios": out}
|
||||||
|
|
||||||
|
def usuarios_crear(email, password, nombre, rol):
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.auth.admin.create_user({
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"email_confirm": True,
|
||||||
|
})
|
||||||
|
user = getattr(res, "user", None) or res
|
||||||
|
uid = str(user.id)
|
||||||
|
sb.table("perfiles").upsert({"id": uid, "nombre": nombre or email, "rol": rol, "activo": True}).execute()
|
||||||
|
return {"status": "ok", "id": uid}
|
||||||
|
|
||||||
|
def usuarios_actualizar_rol(user_id, rol):
|
||||||
|
sb = _sb()
|
||||||
|
sb.table("perfiles").update({"rol": rol}).eq("id", user_id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def usuarios_eliminar(user_id):
|
||||||
|
sb = _sb()
|
||||||
|
sb.auth.admin.delete_user(user_id)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── VENDEDORES MANUALES (Comisiones, en Supabase) ──────────────────────────
|
||||||
|
# ── CONFIG MENSUAL COMISIONES (IMP_TC, META, COMISIÓN por tipo de programa) ──
|
||||||
|
def comisiones_config_listar(ano, mes):
|
||||||
|
"""Lista la config de un mes (todas las filas por tipo_programa)."""
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute()
|
||||||
|
return {"config": res.data or []}
|
||||||
|
|
||||||
|
def comisiones_config_guardar(ano, mes, filas):
|
||||||
|
"""Reemplaza la config del mes. filas = [{tipo_programa, imp_tc, meta, comision}]."""
|
||||||
|
sb = _sb()
|
||||||
|
sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute()
|
||||||
|
payload = []
|
||||||
|
for f in filas:
|
||||||
|
payload.append({
|
||||||
|
"ano": int(ano), "mes": int(mes),
|
||||||
|
"tipo_programa": f.get("tipo_programa", ""),
|
||||||
|
"imp_tc": float(f.get("imp_tc", 0) or 0),
|
||||||
|
"meta": float(f.get("meta", 0) or 0),
|
||||||
|
"comision": float(f.get("comision", 0) or 0),
|
||||||
|
})
|
||||||
|
if payload:
|
||||||
|
sb.table("comisiones_config_mensual").insert(payload).execute()
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
cache_invalidate("ventas")
|
||||||
|
cache_invalidate("ventas_det")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def comisiones_tc_del_mes(ano, mes):
|
||||||
|
"""Devuelve el IMP_TC configurado para el mes (toma el primero que tenga TC > 0), o None."""
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute()
|
||||||
|
for r in (res.data or []):
|
||||||
|
tc = float(r.get("imp_tc", 0) or 0)
|
||||||
|
if tc > 0: return tc
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def vendedores_manuales_listar(ano, mes):
|
||||||
|
sb = _sb()
|
||||||
|
query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano))
|
||||||
|
if int(mes) != 0: # mes=0 → traer todo el año (para cache en frontend)
|
||||||
|
query = query.eq("mes", int(mes))
|
||||||
|
res = query.execute()
|
||||||
|
return {"vendedores": res.data or []}
|
||||||
|
|
||||||
|
def vendedores_manuales_crear(nombre, descripcion, fch_emision, monto, ano, mes):
|
||||||
|
sb = _sb()
|
||||||
|
payload = {
|
||||||
|
"nombre": nombre, "descripcion": descripcion or "",
|
||||||
|
"fch_emision": fch_emision or "", "monto": float(monto or 0),
|
||||||
|
"ano": int(ano), "mes": int(mes),
|
||||||
|
}
|
||||||
|
res = sb.table("vendedores_manuales").insert(payload).execute()
|
||||||
|
return {"status": "ok", "data": res.data}
|
||||||
|
|
||||||
|
def vendedores_manuales_eliminar(id):
|
||||||
|
sb = _sb()
|
||||||
|
sb.table("vendedores_manuales").delete().eq("id", id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto):
|
||||||
|
sb = _sb()
|
||||||
|
sb.table("vendedores_manuales").update({
|
||||||
|
"descripcion": descripcion or "", "fch_emision": fch_emision or "",
|
||||||
|
"monto": float(monto or 0),
|
||||||
|
}).eq("id", id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def vendedores_manuales_guardar_lote(nombre, ano, mes, filas):
|
||||||
|
"""Reemplaza TODAS las filas de un vendedor manual en una sola operación.
|
||||||
|
filas = lista de dicts {descripcion, fch_emision, monto}. Si filas vacía → elimina el vendedor."""
|
||||||
|
sb = _sb()
|
||||||
|
# Borrar las existentes de ese vendedor/mes
|
||||||
|
sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute()
|
||||||
|
# Insertar las nuevas en un solo batch
|
||||||
|
payload = []
|
||||||
|
for f in filas:
|
||||||
|
payload.append({
|
||||||
|
"nombre": nombre,
|
||||||
|
"descripcion": f.get("descripcion","") or "",
|
||||||
|
"fch_emision": f.get("fch_emision","") or "",
|
||||||
|
"monto": float(f.get("monto",0) or 0),
|
||||||
|
"ano": int(ano), "mes": int(mes),
|
||||||
|
})
|
||||||
|
if payload:
|
||||||
|
sb.table("vendedores_manuales").insert(payload).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── OVERRIDES DE COMISIONES (editar valores por num_matricula) ─────────────
|
||||||
|
def comisiones_override_guardar(registros):
|
||||||
|
"""Guarda/actualiza overrides en UNA sola operación (batch upsert)."""
|
||||||
|
sb = _sb()
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
validos = [r for r in registros if r.get("num_matricula")]
|
||||||
|
if validos:
|
||||||
|
sb.table("comisiones_overrides").upsert(validos).execute()
|
||||||
|
cache_invalidate("ventas_det")
|
||||||
|
cache_invalidate("ventas")
|
||||||
|
cache_invalidate("comisiones_det_todos")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def comisiones_override_restaurar(num_matricula):
|
||||||
|
"""Borra el override de un alumno (vuelve a los valores de SQL)."""
|
||||||
|
sb = _sb()
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute()
|
||||||
|
cache_invalidate("ventas_det")
|
||||||
|
cache_invalidate("ventas")
|
||||||
|
cache_invalidate("comisiones_det_todos")
|
||||||
|
return {"status": "ok"}
|
||||||
98
backend/cache_manager.py
Normal file
98
backend/cache_manager.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# backend/cache_manager.py
|
||||||
|
"""
|
||||||
|
Caché global en memoria con refresco en segundo plano.
|
||||||
|
Independiente de Streamlit. Mantiene los datos calientes para respuestas instantáneas.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from typing import Any, Callable, Dict, Tuple
|
||||||
|
|
||||||
|
_CACHE: Dict[str, Tuple[float, Any]] = {}
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_DEFAULT_TTL = 900 # 15 min
|
||||||
|
|
||||||
|
|
||||||
|
def _make_key(prefix: str, args: tuple) -> str:
|
||||||
|
return prefix + ":" + "|".join(str(a) for a in args)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get_or_set(prefix: str, args: tuple, loader: Callable[[], Any],
|
||||||
|
ttl: int = _DEFAULT_TTL) -> Any:
|
||||||
|
"""Devuelve el valor cacheado si está fresco; si no, lo calcula y guarda."""
|
||||||
|
key = _make_key(prefix, args)
|
||||||
|
now = time.time()
|
||||||
|
with _LOCK:
|
||||||
|
hit = _CACHE.get(key)
|
||||||
|
if hit and (now - hit[0] < ttl):
|
||||||
|
return hit[1]
|
||||||
|
# Calcular fuera del lock (puede tardar)
|
||||||
|
value = loader()
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE[key] = (now, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set(prefix: str, args: tuple, value: Any) -> None:
|
||||||
|
key = _make_key(prefix, args)
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE[key] = (time.time(), value)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_keys():
|
||||||
|
"""Lista las claves actualmente cacheadas (para refrescarlas sin vaciar)."""
|
||||||
|
with _LOCK:
|
||||||
|
return list(_CACHE.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def cache_refresh_existing(loader_for_key):
|
||||||
|
"""Recalcula SOLO las entradas que ya existen, sin vaciar el caché.
|
||||||
|
Así los meses ya visitados se mantienen calientes y nunca quedan 'fríos'."""
|
||||||
|
for key in cache_keys():
|
||||||
|
try:
|
||||||
|
nuevo = loader_for_key(key)
|
||||||
|
if nuevo is not None:
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE[key] = (time.time(), nuevo)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[cache refresh] {key}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_invalidate(prefix: str = None) -> None:
|
||||||
|
"""Invalida todo el caché, o solo las claves de un prefijo."""
|
||||||
|
with _LOCK:
|
||||||
|
if prefix is None:
|
||||||
|
_CACHE.clear()
|
||||||
|
else:
|
||||||
|
for k in list(_CACHE.keys()):
|
||||||
|
if k.startswith(prefix + ":"):
|
||||||
|
del _CACHE[k]
|
||||||
|
|
||||||
|
|
||||||
|
def cache_stats() -> dict:
|
||||||
|
with _LOCK:
|
||||||
|
return {"entradas": len(_CACHE), "claves": list(_CACHE.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Refresco en segundo plano ──────────────────────────────────────────────
|
||||||
|
_background_started = False
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 240):
|
||||||
|
"""Lanza un hilo que ejecuta refresh_fn cada `interval` segundos (4 min por defecto,
|
||||||
|
antes de que expire el TTL de 5 min, para mantener el caché siempre caliente)."""
|
||||||
|
global _background_started
|
||||||
|
if _background_started:
|
||||||
|
return
|
||||||
|
_background_started = True
|
||||||
|
|
||||||
|
def _loop():
|
||||||
|
while True:
|
||||||
|
time.sleep(interval)
|
||||||
|
try:
|
||||||
|
refresh_fn()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[background refresh] error: {e}")
|
||||||
|
|
||||||
|
t = threading.Thread(target=_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
print(f"[cache] refresco en segundo plano cada {interval}s iniciado")
|
||||||
0
backend/core/__init__.py
Normal file
0
backend/core/__init__.py
Normal file
794
backend/core/data_manager.py
Normal file
794
backend/core/data_manager.py
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
# core/data_manager.py
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
class DataManager:
|
||||||
|
def __init__(self):
|
||||||
|
# --- SQL SERVER ---
|
||||||
|
self.server = os.getenv("SQL_SERVER", "191.98.134.80")
|
||||||
|
self.database = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
|
||||||
|
self.username = os.getenv("SQL_USERNAME", "ASEBASTIAN")
|
||||||
|
self.password = os.getenv("SQL_PASSWORD", "")
|
||||||
|
|
||||||
|
# --- POSTGRESQL ---
|
||||||
|
self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
|
||||||
|
self.pg_database = os.getenv("PG_DATABASE", "chatwoot_production")
|
||||||
|
self.pg_user = os.getenv("PG_USER", "postgres")
|
||||||
|
self.pg_password = os.getenv("PG_PASSWORD", "")
|
||||||
|
self.pg_port = os.getenv("PG_PORT", "5432")
|
||||||
|
|
||||||
|
# --- SUPABASE ---
|
||||||
|
self.supabase_url = os.getenv("SUPABASE_URL", "")
|
||||||
|
self.supabase_key = os.getenv("SUPABASE_KEY", "")
|
||||||
|
self.supabase_client = None
|
||||||
|
self._init_supabase()
|
||||||
|
|
||||||
|
# --- GITHUB ---
|
||||||
|
base = os.getenv("GITHUB_BASE", "https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD")
|
||||||
|
self.github_json_url = f"{base}/actualizador.json"
|
||||||
|
self.github_replace_url = f"{base}/REPLACE_CURSO.json"
|
||||||
|
self.github_meta_url = f"{base}/REPLACE_META.json"
|
||||||
|
self.github_costos_url = f"{base}/REPLACE_COSTOS.json"
|
||||||
|
self.github_pronostico_url = f"{base}/BASE_PRONOSTICO.json"
|
||||||
|
self.github_pendientes_url = f"{base}/BASE_PENDIENTES.json"
|
||||||
|
self.github_sede_url = f"{base}/sede.json"
|
||||||
|
self.github_horario_url = f"{base}/HORARIO_ASESOR.json"
|
||||||
|
self.github_query_url = f"{base}/SQL_QUERY_2/BASE_CURSO_2.query"
|
||||||
|
self.github_cuota_url = f"{base}/SQL_QUERY_2/BASE_CUOTAS_2.query"
|
||||||
|
self.github_cronograma_url = f"{base}/SQL_QUERY_2/BASE_CRONOGRAMA_2.query"
|
||||||
|
self.github_facturas_url = f"{base}/SQL_QUERY_2/BASE_FACTURAS_2.query"
|
||||||
|
self.github_matriculas_url = f"{base}/SQL_QUERY_2/BASE_MATRICULADOS_2.query"
|
||||||
|
|
||||||
|
# --- Variables de estado ---
|
||||||
|
self.config_data = {}
|
||||||
|
self.replace_data = {}
|
||||||
|
self.meta_data = {}
|
||||||
|
self.sede_data = {}
|
||||||
|
self.pronostico_data = {}
|
||||||
|
self.costos_data = {}
|
||||||
|
self.historico_pendientes = {}
|
||||||
|
self.pendientes_data = set()
|
||||||
|
self.query_sql = ""
|
||||||
|
self.query_matriculas_sql = ""
|
||||||
|
self.query_cronograma_sql = ""
|
||||||
|
self.query_facturas_sql = ""
|
||||||
|
|
||||||
|
self.cargar_toda_configuracion()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CARGA DE CONFIGURACIÓN
|
||||||
|
# =========================================================================
|
||||||
|
def cargar_toda_configuracion(self):
|
||||||
|
# OPTIMIZACIÓN: descargar todas las URLs en paralelo
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
json_tasks = {
|
||||||
|
"config_data": (self.github_json_url, {"config_general": {"auto_update_minutos": 5}}),
|
||||||
|
"replace_data": (self.github_replace_url, {}),
|
||||||
|
"meta_data": (self.github_meta_url, {}),
|
||||||
|
"sede_data": (self.github_sede_url, {}),
|
||||||
|
"pronostico_data": (self.github_pronostico_url, {"pronosticos": []}),
|
||||||
|
"costos_data": (self.github_costos_url, {}),
|
||||||
|
"_pendientes_raw": (self.github_pendientes_url, {}),
|
||||||
|
}
|
||||||
|
text_tasks = {
|
||||||
|
"query_sql": self.github_query_url,
|
||||||
|
"query_matriculas_sql": self.github_matriculas_url,
|
||||||
|
"query_cronograma_sql": self.github_cronograma_url,
|
||||||
|
"query_facturas_sql": self.github_facturas_url,
|
||||||
|
}
|
||||||
|
with ThreadPoolExecutor(max_workers=15) as ex:
|
||||||
|
json_futs = {k: ex.submit(self._get_json, url, default) for k,(url,default) in json_tasks.items()}
|
||||||
|
text_futs = {k: ex.submit(self._get_text, url) for k,url in text_tasks.items()}
|
||||||
|
for k, f in json_futs.items(): setattr(self, k, f.result())
|
||||||
|
for k, f in text_futs.items(): setattr(self, k, f.result())
|
||||||
|
# pendientes: parsear el dict ya descargado
|
||||||
|
try:
|
||||||
|
self.historico_pendientes = self._pendientes_raw.get("historico_pendientes", {})
|
||||||
|
except:
|
||||||
|
self.historico_pendientes = {}
|
||||||
|
self.pendientes_data = set()
|
||||||
|
|
||||||
|
def _get_json(self, url, default=None):
|
||||||
|
try:
|
||||||
|
r = requests.get(url, timeout=4)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except:
|
||||||
|
return default or {}
|
||||||
|
|
||||||
|
def _get_text(self, url):
|
||||||
|
try:
|
||||||
|
r = requests.get(url, timeout=4)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.text
|
||||||
|
except:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _cargar_pendientes(self):
|
||||||
|
# Ya cargado en paralelo dentro de cargar_toda_configuracion
|
||||||
|
if not hasattr(self, 'historico_pendientes'):
|
||||||
|
self.historico_pendientes = {}
|
||||||
|
if not hasattr(self, 'pendientes_data'):
|
||||||
|
self.pendientes_data = set()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CONEXIONES A BASE DE DATOS
|
||||||
|
# =========================================================================
|
||||||
|
def get_connection(self):
|
||||||
|
import pyodbc
|
||||||
|
conn_str = (
|
||||||
|
f"DRIVER={{SQL Server}};"
|
||||||
|
f"SERVER={self.server};"
|
||||||
|
f"DATABASE={self.database};"
|
||||||
|
f"UID={self.username};"
|
||||||
|
f"PWD={self.password}"
|
||||||
|
)
|
||||||
|
return pyodbc.connect(conn_str)
|
||||||
|
|
||||||
|
def get_pg_connection(self):
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"psycopg2 no está instalado. Para usar PostgreSQL ejecuta:\n"
|
||||||
|
"pip install psycopg2-binary"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=self.pg_host,
|
||||||
|
database=self.pg_database,
|
||||||
|
user=self.pg_user,
|
||||||
|
password=self.pg_password,
|
||||||
|
port=self.pg_port
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error PostgreSQL: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _init_supabase(self):
|
||||||
|
self.supabase_error = None
|
||||||
|
try:
|
||||||
|
from supabase import create_client
|
||||||
|
if self.supabase_url and self.supabase_key:
|
||||||
|
self.supabase_client = create_client(self.supabase_url, self.supabase_key)
|
||||||
|
print(f"✅ Supabase conectado: {self.supabase_url[:30]}...")
|
||||||
|
else:
|
||||||
|
self.supabase_error = "SUPABASE_URL o SUPABASE_KEY vacíos"
|
||||||
|
print(f"⚠️ {self.supabase_error}")
|
||||||
|
except Exception as e:
|
||||||
|
self.supabase_client = None
|
||||||
|
self.supabase_error = f"{type(e).__name__}: {e}"
|
||||||
|
print(f"❌ Error al conectar Supabase: {self.supabase_error}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CONSULTAS SQL
|
||||||
|
# =========================================================================
|
||||||
|
def ejecutar_consulta_cursos(self, ano, mes):
|
||||||
|
try:
|
||||||
|
if not self.query_sql:
|
||||||
|
raise Exception("No se pudo cargar la query BASE_CURSO")
|
||||||
|
|
||||||
|
ids_invitados = []
|
||||||
|
cursos_personalizados = self.config_data.get('cursos_personalizados', {})
|
||||||
|
for id_curso, datos in cursos_personalizados.items():
|
||||||
|
if 'fch_inicio' in datos:
|
||||||
|
try:
|
||||||
|
f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y')
|
||||||
|
if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes):
|
||||||
|
ids_invitados.append(str(int(id_curso)))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sql_final = self.query_sql
|
||||||
|
if ids_invitados:
|
||||||
|
ids_str = ",".join(ids_invitados)
|
||||||
|
if "ORDER BY" in sql_final:
|
||||||
|
parts = sql_final.split("ORDER BY")
|
||||||
|
sql_final = parts[0] + f" OR rp.num_indice IN ({ids_str}) \nORDER BY" + parts[1]
|
||||||
|
else:
|
||||||
|
sql_final += f" OR rp.num_indice IN ({ids_str})"
|
||||||
|
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql_final, ano, mes)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
cols = ['num_indice', 'dsc_det_programa', 'flg_activo', 'fch_inicio',
|
||||||
|
'cod_frecuencia', 'cod_estado', 'dsc_programa',
|
||||||
|
'Inscritos_Totales', 'Inscritos_Retirados', 'Inscritos_Activos']
|
||||||
|
datos = [{cols[i]: row[i] for i in range(len(cols))} for row in rows]
|
||||||
|
conn.close()
|
||||||
|
return datos
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error ejecutar_consulta_cursos: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def cargar_datos_matriculas(self, ano, mes):
|
||||||
|
try:
|
||||||
|
if not self.query_matriculas_sql:
|
||||||
|
return {}
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(self.query_matriculas_sql, ano, mes)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
d = {}
|
||||||
|
for row in rows:
|
||||||
|
d[row[0]] = d.get(row[0], 0) + 1
|
||||||
|
conn.close()
|
||||||
|
return d
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def ejecutar_consulta_matriculados_detalle(self, ano, mes):
|
||||||
|
try:
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
ids_invitados = []
|
||||||
|
cursos_personalizados = self.config_data.get('cursos_personalizados', {})
|
||||||
|
for id_curso, datos in cursos_personalizados.items():
|
||||||
|
if 'fch_inicio' in datos:
|
||||||
|
try:
|
||||||
|
f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y')
|
||||||
|
if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes):
|
||||||
|
ids_invitados.append(str(int(id_curso)))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sql_or = ""
|
||||||
|
if ids_invitados:
|
||||||
|
sql_or = f" OR sgede_RP_programa.num_indice IN ({','.join(ids_invitados)}) "
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
sgede_RP_programa.num_indice, sgeca_matricula.num_matricula,
|
||||||
|
sgeca_matricula.cod_estado AS estado_matricula,
|
||||||
|
sgeca_programa.dsc_programa, sgeca_matricula.cod_moneda,
|
||||||
|
sgema_alumno.dsc_documento,
|
||||||
|
ISNULL((SELECT SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1), 0) AS INV_NETA,
|
||||||
|
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula,
|
||||||
|
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1,
|
||||||
|
ISNULL((SELECT TOP 1 comp.imp_tc FROM vtaca_comprobante comp
|
||||||
|
INNER JOIN sgevi_cuotas_x_comprobante cxc
|
||||||
|
ON comp.cod_localidad = cxc.cod_localidad
|
||||||
|
AND comp.num_correlativo = cxc.num_correlativo
|
||||||
|
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
|
||||||
|
AND comp.cod_estado NOT IN ('ANU', 'ELI')
|
||||||
|
ORDER BY comp.fch_emision DESC), 0) AS imp_tc
|
||||||
|
FROM sgeca_matricula
|
||||||
|
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
|
||||||
|
LEFT JOIN sgede_RP_programa
|
||||||
|
ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
|
||||||
|
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
|
||||||
|
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
|
||||||
|
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||||
|
WHERE sgeca_matricula.cod_localidad LIKE 'SCENT'
|
||||||
|
AND ((YEAR(sgede_RP_programa.fch_inicio) = ? AND MONTH(sgede_RP_programa.fch_inicio) = ?) {sql_or})
|
||||||
|
AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS')
|
||||||
|
"""
|
||||||
|
cursor.execute(query, ano, mes)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error ejecutar_consulta_matriculados_detalle: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def consultar_historial_continuidad(self, lista_documentos):
|
||||||
|
if not lista_documentos:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
dnis = list(set([str(d).strip() for d in lista_documentos if d]))
|
||||||
|
if not dnis:
|
||||||
|
return {}
|
||||||
|
placeholders = ','.join(['?'] * len(dnis))
|
||||||
|
query = f"""
|
||||||
|
SELECT a.dsc_documento, p.dsc_programa, rp.fch_inicio
|
||||||
|
FROM sgeca_matricula m
|
||||||
|
INNER JOIN sgema_alumno a ON m.cod_alumno = a.cod_alumno
|
||||||
|
INNER JOIN sgeca_programa p ON m.cod_programa = p.cod_programa
|
||||||
|
INNER JOIN sgede_RP_programa rp
|
||||||
|
ON m.cod_periodo = rp.cod_detalle
|
||||||
|
AND m.cod_programa = rp.cod_programa
|
||||||
|
AND m.num_indice = rp.num_indice
|
||||||
|
WHERE a.dsc_documento IN ({placeholders})
|
||||||
|
AND m.cod_estado IN ('ALU', 'PRE', 'RET')
|
||||||
|
AND m.cod_localidad LIKE 'SCENT'
|
||||||
|
AND (p.dsc_programa LIKE '%TEAC%' OR p.dsc_programa LIKE '%TERC%'
|
||||||
|
OR p.dsc_programa LIKE '%AREQUIPA%' OR p.dsc_programa LIKE '%TRUJILLO%'
|
||||||
|
OR p.dsc_programa LIKE '%PIURA%' OR p.dsc_programa LIKE '%TECNICO ESPECIALISTA%')
|
||||||
|
"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(query, dnis)
|
||||||
|
historial = {}
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
dni = str(row[0]).strip()
|
||||||
|
if dni not in historial:
|
||||||
|
historial[dni] = []
|
||||||
|
historial[dni].append({'programa': row[1], 'fecha': row[2]})
|
||||||
|
conn.close()
|
||||||
|
return historial
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error historial continuidad: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _get_query_ventas_hibrido(self, lista_ids_vip):
|
||||||
|
if not lista_ids_vip:
|
||||||
|
sql_in_clause = "(-1)"
|
||||||
|
else:
|
||||||
|
ids_str = [str(x) for x in lista_ids_vip]
|
||||||
|
sql_in_clause = "(" + ",".join(ids_str) + ")"
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
rhuma_trabajador.dsc_apellido_paterno + ' ' +
|
||||||
|
SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' +
|
||||||
|
rhuma_trabajador.dsc_nombres
|
||||||
|
FROM rhuma_trabajador
|
||||||
|
WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor
|
||||||
|
) AS dsc_vendedor, -- [0]
|
||||||
|
ISNULL((
|
||||||
|
SELECT
|
||||||
|
SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
), 0) AS INV_NETA, -- [1]
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 0
|
||||||
|
), 0) AS imp_saldo_matricula, -- [2]
|
||||||
|
ISNULL((
|
||||||
|
SELECT sgede_cronograma_matricula.imp_saldo
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
), 0) AS imp_saldo_cuota1, -- [3]
|
||||||
|
|
||||||
|
sgeca_matricula.num_matricula, -- [4]
|
||||||
|
YEAR(sgeca_matricula.fch_matricula) as anio_mat, -- [5]
|
||||||
|
MONTH(sgeca_matricula.fch_matricula) as mes_mat, -- [6]
|
||||||
|
|
||||||
|
(
|
||||||
|
SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||||
|
AND sgede_cronograma_matricula.num_cuota = 1
|
||||||
|
) AS fch_cancelacion_cuota1, -- [7]
|
||||||
|
|
||||||
|
sgeca_matricula.cod_moneda, -- [8]
|
||||||
|
|
||||||
|
ISNULL((
|
||||||
|
SELECT TOP 1 comp.imp_tc
|
||||||
|
FROM vtaca_comprobante comp
|
||||||
|
INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
|
||||||
|
AND comp.num_correlativo = cxc.num_correlativo
|
||||||
|
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
|
||||||
|
AND comp.cod_estado NOT IN ('ANU', 'ELI')
|
||||||
|
ORDER BY comp.fch_emision DESC
|
||||||
|
), 1) AS imp_tc, -- [9]
|
||||||
|
|
||||||
|
sgede_RP_programa.dsc_det_programa, -- [10] PROGRAMA (mismo num_indice)
|
||||||
|
sgede_RP_programa.fch_inicio, -- [11] FECHA INICIO (mismo num_indice)
|
||||||
|
sgeca_programa.dsc_programa, -- [12] PROGRAMA GENERAL (respaldo)
|
||||||
|
sgeca_matricula.cod_estado -- [13] ESTADO (ALU/PRE/RET)
|
||||||
|
|
||||||
|
FROM sgeca_matricula
|
||||||
|
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
|
||||||
|
LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
|
||||||
|
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
|
||||||
|
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
|
||||||
|
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||||
|
INNER JOIN vtama_localidad ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad
|
||||||
|
INNER JOIN vtama_tipo_documento ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento
|
||||||
|
WHERE
|
||||||
|
sgeca_matricula.cod_localidad LIKE 'SCENT'
|
||||||
|
AND (
|
||||||
|
sgeca_matricula.cod_estado IN ('ALU', 'PRE', 'RET')
|
||||||
|
OR sgeca_matricula.num_matricula IN {sql_in_clause}
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
(YEAR(sgeca_matricula.fch_matricula) = ? AND MONTH(sgeca_matricula.fch_matricula) = ?)
|
||||||
|
OR
|
||||||
|
sgeca_matricula.num_matricula IN {sql_in_clause}
|
||||||
|
OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sgede_cronograma_matricula crono
|
||||||
|
WHERE crono.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND crono.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND crono.num_cuota = 1
|
||||||
|
AND crono.fch_cancelacion IS NOT NULL
|
||||||
|
AND YEAR(crono.fch_cancelacion) = ?
|
||||||
|
AND MONTH(crono.fch_cancelacion) = ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
dsc_vendedor ASC
|
||||||
|
"""
|
||||||
|
|
||||||
|
def ejecutar_consulta_ventas(self, ano, mes, sede="TODOS", programa="TODOS"):
|
||||||
|
try:
|
||||||
|
key_mes = f"{int(mes):02d}-{ano}"
|
||||||
|
lista_raw = self.historico_pendientes.get(key_mes, [])
|
||||||
|
|
||||||
|
lista_vip_list = []
|
||||||
|
for item in lista_raw:
|
||||||
|
try: lista_vip_list.append(int(item))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Incluir matrículas con override de fecha (para que la query las traiga, incluso ANU)
|
||||||
|
_fov = getattr(self, '_fecha_canc_overrides', None) or {}
|
||||||
|
for mk in _fov.keys():
|
||||||
|
try: lista_vip_list.append(int(float(str(mk))))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
correcciones = self.config_data.get("correcciones_matriculas", {})
|
||||||
|
query_hibrido = self._get_query_ventas_hibrido(lista_vip_list)
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Clasificadores para filtros (mismos que Cobranza/Ocupabilidad)
|
||||||
|
_sede_sel = str(sede or "TODOS").upper()
|
||||||
|
_prog_sel = str(programa or "TODOS").upper()
|
||||||
|
def _clasif_sede(dscp):
|
||||||
|
if not dscp: return "LIMA"
|
||||||
|
up = str(dscp).upper()
|
||||||
|
sedes_data = self.sede_data.get("clasificacion_sedes", {})
|
||||||
|
for s, data in sedes_data.items():
|
||||||
|
if s == "DEFAULT": continue
|
||||||
|
for pat in data.get("patrones", []):
|
||||||
|
if pat.upper() in up: return s
|
||||||
|
_def = self.sede_data.get("clasificacion_default", {})
|
||||||
|
return _def.get("sede", "LIMA") if isinstance(_def, dict) else "LIMA"
|
||||||
|
def _clasif_prog(dscp):
|
||||||
|
# Clasifica el programa en UNA sola categoría, con prioridad de orden.
|
||||||
|
# Específicas primero (SEMINARIOS, TEAC, TERC); OTROS y el sobrante al final.
|
||||||
|
up = str(dscp or "").upper()
|
||||||
|
fdata = self.meta_data.get("clasificacion_filtro_programa", {})
|
||||||
|
orden = ["OTROS", "SEMINARIOS", "TEAC", "TERC"]
|
||||||
|
for cat in orden:
|
||||||
|
data = fdata.get(cat, {})
|
||||||
|
for pat in data.get("patrones", []):
|
||||||
|
if pat.upper() in up:
|
||||||
|
return cat
|
||||||
|
# Sobrante (no coincide con nada) → DEFAULT
|
||||||
|
return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS"
|
||||||
|
|
||||||
|
cursor.execute(query_hibrido, ano, mes, ano, mes)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
ventas_por_vendedor = {}
|
||||||
|
set_vip_actual = set(lista_vip_list)
|
||||||
|
|
||||||
|
vendedores_excluidos = {
|
||||||
|
"CASTILLO B. KARINA LISSET",
|
||||||
|
"CASTILLO B. KARINA LISSET",
|
||||||
|
"CALDERON S. LISSA GENA",
|
||||||
|
"CRUZ G. FIORELLA MELISSA",
|
||||||
|
"URIBE G. MARIA MERCEDES"
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
vendedor = row[0] if row[0] else "SIN VENDEDOR"
|
||||||
|
if vendedor in vendedores_excluidos: continue
|
||||||
|
|
||||||
|
# Filtro SEDE/PROGRAMA (mismas reglas que Ocupabilidad/Cobranza)
|
||||||
|
_dscp = row[10] if (len(row) > 10 and row[10]) else (row[12] if len(row) > 12 else "")
|
||||||
|
if _sede_sel != "TODOS" and _clasif_sede(_dscp) != _sede_sel:
|
||||||
|
continue
|
||||||
|
if _prog_sel != "TODOS" and _clasif_prog(_dscp) != _prog_sel:
|
||||||
|
continue
|
||||||
|
|
||||||
|
inv_neta_raw = float(row[1]) if row[1] else 0.0
|
||||||
|
cod_moneda = row[8]
|
||||||
|
imp_tc = float(row[9]) if row[9] else 1.0
|
||||||
|
|
||||||
|
saldo_mat = float(row[2]) if row[2] else 0.0
|
||||||
|
saldo_c1 = float(row[3]) if row[3] else 0.0
|
||||||
|
|
||||||
|
raw_mat = row[4]
|
||||||
|
row_year = row[5]
|
||||||
|
row_month = row[6]
|
||||||
|
fch_cancelacion_raw = row[7]
|
||||||
|
|
||||||
|
matricula_int = -1
|
||||||
|
matricula_str = ""
|
||||||
|
try:
|
||||||
|
if raw_mat is not None:
|
||||||
|
val_str = str(raw_mat).strip()
|
||||||
|
if val_str.endswith('.0'): val_str = val_str[:-2]
|
||||||
|
matricula_str = val_str
|
||||||
|
matricula_int = int(float(val_str))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if matricula_str in correcciones:
|
||||||
|
datos_corregidos = correcciones[matricula_str]
|
||||||
|
if "imp_tc" in datos_corregidos: imp_tc = float(datos_corregidos["imp_tc"])
|
||||||
|
if "imp_saldo_cuota1" in datos_corregidos: saldo_c1 = float(datos_corregidos["imp_saldo_cuota1"])
|
||||||
|
if "imp_saldo_matricula" in datos_corregidos: saldo_mat = float(datos_corregidos["imp_saldo_matricula"])
|
||||||
|
if "fch_cancelacion_cuota1" in datos_corregidos: fch_cancelacion_raw = datos_corregidos["fch_cancelacion_cuota1"]
|
||||||
|
|
||||||
|
# Override de FECHA CANCELACIÓN 1 desde Supabase (prioridad para clasificación)
|
||||||
|
_fov = getattr(self, '_fecha_canc_overrides', None)
|
||||||
|
_vacio_forzado = False
|
||||||
|
if _fov and matricula_str in _fov:
|
||||||
|
fov_val = _fov[matricula_str]
|
||||||
|
s = str(fov_val).strip()
|
||||||
|
if s == "__VACIO__":
|
||||||
|
# Fecha borrada a propósito → anula la del SQL (no cuenta como pagado)
|
||||||
|
fch_cancelacion_raw = None
|
||||||
|
_vacio_forzado = True
|
||||||
|
else:
|
||||||
|
# Normalizar dd/mm/yyyy o d/m/yyyy → yyyy-mm-dd
|
||||||
|
if '/' in s:
|
||||||
|
p = s.split('/')
|
||||||
|
if len(p) == 3:
|
||||||
|
s = f"{p[2]}-{int(p[1]):02d}-{int(p[0]):02d}"
|
||||||
|
fch_cancelacion_raw = s
|
||||||
|
|
||||||
|
# Prioridad TC: Supabase (mes) > corrección manual > SQL
|
||||||
|
_tc_ov = getattr(self, '_tc_override_mes', None)
|
||||||
|
if _tc_ov: imp_tc = float(_tc_ov)
|
||||||
|
|
||||||
|
if cod_moneda == 'DOL': inv_neta = inv_neta_raw * imp_tc
|
||||||
|
else: inv_neta = inv_neta_raw
|
||||||
|
|
||||||
|
# Override de INVERSIÓN NETA (Supabase): monto final en soles, reemplaza al SQL.
|
||||||
|
_inv_ov = getattr(self, '_inv_neta_overrides', None)
|
||||||
|
if _inv_ov and matricula_str in _inv_ov:
|
||||||
|
inv_neta = float(_inv_ov[matricula_str])
|
||||||
|
|
||||||
|
if vendedor not in ventas_por_vendedor:
|
||||||
|
ventas_por_vendedor[vendedor] = {
|
||||||
|
'monto': 0.0, 'cantidad': 0, 'monto_pc': 0.0, 'cantidad_pc': 0,
|
||||||
|
'monto_pendientes': 0.0, 'cantidad_pendientes': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes))
|
||||||
|
saldos_ok = (saldo_mat == 0 and saldo_c1 == 0)
|
||||||
|
|
||||||
|
# Si hay override de fecha de cancelación 1 (con fecha), se considera PAGADO.
|
||||||
|
# Si el override la vació a propósito, NO se fuerza pagado.
|
||||||
|
tiene_override_fecha = bool(_fov and matricula_str in _fov) and not _vacio_forzado
|
||||||
|
if tiene_override_fecha:
|
||||||
|
saldos_ok = True
|
||||||
|
|
||||||
|
# Si es RET y NO pagó (sin override ni saldos), NO entra en la tabla principal.
|
||||||
|
estado_row = str(row[13]).strip().upper() if len(row) > 13 and row[13] else ""
|
||||||
|
if estado_row == "RET" and not saldos_ok:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ¿Pagó la 1° cuota en el MES del filtro?
|
||||||
|
pago_en_fecha_correcta = False
|
||||||
|
if fch_cancelacion_raw:
|
||||||
|
try:
|
||||||
|
if isinstance(fch_cancelacion_raw, str):
|
||||||
|
f_obj = datetime.strptime(fch_cancelacion_raw[:10], '%Y-%m-%d')
|
||||||
|
f_ano, f_mes = f_obj.year, f_obj.month
|
||||||
|
else:
|
||||||
|
f_ano, f_mes = fch_cancelacion_raw.year, fch_cancelacion_raw.month
|
||||||
|
if str(f_ano) == str(ano) and str(f_mes) == str(mes):
|
||||||
|
pago_en_fecha_correcta = True
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# ¿La matrícula es de un mes ANTERIOR al filtro? (mismo año mes menor, o año anterior)
|
||||||
|
matricula_mes_pasado = False
|
||||||
|
try:
|
||||||
|
ym_mat = int(row_year) * 100 + int(row_month)
|
||||||
|
ym_filtro = int(ano) * 100 + int(mes)
|
||||||
|
matricula_mes_pasado = ym_mat < ym_filtro
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# COLUMNA 2 y 3 — MES EN CURSO: matrícula del mes + pagó en el mes
|
||||||
|
if es_venta_del_mes:
|
||||||
|
ventas_por_vendedor[vendedor]['monto'] += inv_neta
|
||||||
|
ventas_por_vendedor[vendedor]['cantidad'] += 1
|
||||||
|
if saldos_ok and pago_en_fecha_correcta:
|
||||||
|
ventas_por_vendedor[vendedor]['monto_pc'] += inv_neta
|
||||||
|
ventas_por_vendedor[vendedor]['cantidad_pc'] += 1
|
||||||
|
|
||||||
|
# COLUMNA 4 y 5 — MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro
|
||||||
|
if matricula_mes_pasado and saldos_ok and pago_en_fecha_correcta:
|
||||||
|
ventas_por_vendedor[vendedor]['monto_pendientes'] += inv_neta
|
||||||
|
ventas_por_vendedor[vendedor]['cantidad_pendientes'] += 1
|
||||||
|
|
||||||
|
datos_ventas = []
|
||||||
|
for vendedor, datos in ventas_por_vendedor.items():
|
||||||
|
datos_ventas.append({
|
||||||
|
'VENDEDOR': vendedor,
|
||||||
|
'MONTO': round(datos['monto'], 2),
|
||||||
|
'CANTIDAD': datos['cantidad'],
|
||||||
|
'VENTAS_PC': round(datos['monto_pc'], 2),
|
||||||
|
'INSCRITOS_PC': datos['cantidad_pc'],
|
||||||
|
'PENDIENTES': round(datos['monto_pendientes'], 2),
|
||||||
|
'INSCRITOS_PENDIENTES': datos['cantidad_pendientes']
|
||||||
|
})
|
||||||
|
|
||||||
|
datos_ventas.sort(key=lambda x: x['MONTO'], reverse=True)
|
||||||
|
conn.close()
|
||||||
|
return datos_ventas
|
||||||
|
except Exception as e:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def ejecutar_consulta_cronograma_cobranza(self, ano):
|
||||||
|
try:
|
||||||
|
if not self.query_cronograma_sql:
|
||||||
|
return []
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(self.query_cronograma_sql)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error cronograma cobranza: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def ejecutar_consulta_facturas_cobranza(self, ano):
|
||||||
|
try:
|
||||||
|
if not self.query_facturas_sql:
|
||||||
|
return []
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(self.query_facturas_sql)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error facturas cobranza: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def ejecutar_consulta_saldos_anual(self, ano):
|
||||||
|
try:
|
||||||
|
if not self.query_matriculas_sql:
|
||||||
|
return []
|
||||||
|
sql_anual = self.query_matriculas_sql.replace(
|
||||||
|
"AND MONTH(sgeca_matricula.fch_matricula) = ?", ""
|
||||||
|
)
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql_anual, ano)
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error saldos anual: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# SUPABASE - OVERRIDES DE COSTOS
|
||||||
|
# =========================================================================
|
||||||
|
def cargar_overrides_costos(self):
|
||||||
|
if not self.supabase_client:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
response = self.supabase_client.table('costos_overrides').select('*').execute()
|
||||||
|
overrides = {}
|
||||||
|
for row in response.data:
|
||||||
|
idx = str(row.get('num_indice', '')).strip()
|
||||||
|
tipo = str(row.get('tipo_costo', '')).strip().lower()
|
||||||
|
if not idx or tipo not in ('inicial', 'actual'):
|
||||||
|
continue
|
||||||
|
if idx not in overrides:
|
||||||
|
overrides[idx] = {}
|
||||||
|
overrides[idx][tipo] = {
|
||||||
|
'epp': row.get('costo_epp'),
|
||||||
|
'certificado': row.get('costo_certificado'),
|
||||||
|
'docente': row.get('costo_docente'),
|
||||||
|
'consumibles': row.get('costo_consumibles'),
|
||||||
|
'marketing': row.get('costo_marketing'),
|
||||||
|
}
|
||||||
|
return overrides
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error overrides Supabase: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def obtener_fechas_originales(self):
|
||||||
|
"""Descarga todas las fechas originales guardadas en Supabase (inicios reprogramados)."""
|
||||||
|
if not self.supabase_client:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
respuesta = self.supabase_client.table('fechas_originales_cursos').select('*').execute()
|
||||||
|
datos = respuesta.data
|
||||||
|
diccionario_fechas = {}
|
||||||
|
for fila in datos:
|
||||||
|
indice = str(fila.get('num_indice'))
|
||||||
|
fecha = fila.get('fecha_inicio_original')
|
||||||
|
programa = fila.get('programa', 'CURSO REPROGRAMADO')
|
||||||
|
diccionario_fechas[indice] = {'fecha': fecha, 'programa': programa}
|
||||||
|
return diccionario_fechas
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error al cargar fechas originales: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def guardar_fecha_original(self, num_indice, programa, fecha_inicio):
|
||||||
|
"""Guarda silenciosamente un nuevo curso y su fecha inicial en Supabase."""
|
||||||
|
if not self.supabase_client:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
'num_indice': str(num_indice),
|
||||||
|
'programa': str(programa),
|
||||||
|
'fecha_inicio_original': str(fecha_inicio)
|
||||||
|
}
|
||||||
|
self.supabase_client.table('fechas_originales_cursos').upsert(payload).execute()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error al guardar fecha original: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def guardar_override_costo(self, num_indice, tipo_costo, costos_dict):
|
||||||
|
if not self.supabase_client:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
'num_indice': str(num_indice),
|
||||||
|
'tipo_costo': tipo_costo,
|
||||||
|
'costo_epp': costos_dict.get('epp'),
|
||||||
|
'costo_certificado': costos_dict.get('certificado'),
|
||||||
|
'costo_docente': costos_dict.get('docente'),
|
||||||
|
'costo_consumibles': costos_dict.get('consumibles'),
|
||||||
|
'costo_marketing': costos_dict.get('marketing'),
|
||||||
|
'fecha_actualizacion': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
self.supabase_client.table('costos_overrides').upsert(payload).execute()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error guardar override: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def eliminar_overrides_costo(self, num_indice):
|
||||||
|
if not self.supabase_client:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.supabase_client.table('costos_overrides').delete().eq(
|
||||||
|
'num_indice', str(num_indice)
|
||||||
|
).execute()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error eliminar overrides: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# HELPERS
|
||||||
|
# =========================================================================
|
||||||
|
def get_current_year(self):
|
||||||
|
return datetime.now().year
|
||||||
|
|
||||||
|
def get_current_month(self):
|
||||||
|
return datetime.now().month
|
||||||
410
backend/main.py
Normal file
410
backend/main.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
# backend/main.py
|
||||||
|
"""
|
||||||
|
API REST del Dashboard — FastAPI.
|
||||||
|
Reutiliza la lógica de negocio existente en modules/ y core/.
|
||||||
|
Cada endpoint devuelve JSON; el frontend (React) lo consume.
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI, Query, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from datetime import datetime
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
import services
|
||||||
|
from cache_manager import start_background_refresh, cache_invalidate, cache_stats
|
||||||
|
|
||||||
|
app = FastAPI(title="Dashboard API", version="1.0")
|
||||||
|
|
||||||
|
# CORS — permite que el frontend React (otro puerto) consuma la API
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # en red local está bien; en producción restringir
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Arranque: precarga + refresco en segundo plano ─────────────────────────
|
||||||
|
@app.on_event("startup")
|
||||||
|
def _startup():
|
||||||
|
print("[startup] Precargando datos iniciales...")
|
||||||
|
try:
|
||||||
|
services.precargar_todo()
|
||||||
|
print("[startup] Precarga completa.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] Precarga falló: {e}")
|
||||||
|
# Refresco cada 4 min en segundo plano (TTL del caché es 5 min)
|
||||||
|
start_background_refresh(services.refrescar_todo, interval=900)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Salud / utilidades ─────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "hora": datetime.now().isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cache/stats")
|
||||||
|
def cache_estadisticas():
|
||||||
|
return cache_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cache/refresh")
|
||||||
|
def cache_refresh():
|
||||||
|
services.refrescar_todo()
|
||||||
|
return {"status": "refrescado"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/periodo-actual")
|
||||||
|
def periodo_actual():
|
||||||
|
dm = services.get_dm()
|
||||||
|
return {"ano": dm.get_current_year(), "mes": dm.get_current_month()}
|
||||||
|
|
||||||
|
|
||||||
|
# ── OCUPABILIDAD ───────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/ocupabilidad")
|
||||||
|
def get_ocupabilidad(
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
sede: str = Query("TODOS"),
|
||||||
|
programa: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.ocupabilidad(ano, mes, sede, programa)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── VENTAS ─────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/ventas")
|
||||||
|
def get_ventas(ano: int = Query(...), mes: int = Query(...),
|
||||||
|
sede: str = Query("TODOS"), programa: str = Query("TODOS")):
|
||||||
|
try:
|
||||||
|
return services.ventas(ano, mes, sede, programa)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/ventas/detalle")
|
||||||
|
def get_ventas_detalle(
|
||||||
|
vendedor: str = Query(...),
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
tipo: str = Query("Venta Inscritos"),
|
||||||
|
sede: str = Query("TODOS"),
|
||||||
|
programa: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.ventas_detalle(vendedor, ano, mes, tipo, sede, programa)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── COBRANZA ───────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/cobranza")
|
||||||
|
def get_cobranza(
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
sectorista: str = Query("TODOS"),
|
||||||
|
agrupacion: str = Query("SEDE"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.cobranza(ano, mes, sectorista, agrupacion)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cobranza/detalle")
|
||||||
|
def get_cobranza_detalle(
|
||||||
|
grupo: str = Query(...),
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
sectorista: str = Query("TODOS"),
|
||||||
|
agrupacion: str = Query("PROGRAMA"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.cobranza_detalle(grupo, ano, mes, sectorista, agrupacion)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── RENTABILIDAD ───────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/rentabilidad")
|
||||||
|
def get_rentabilidad(
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
sede: str = Query("TODOS"),
|
||||||
|
programa: str = Query("TODOS"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.rentabilidad(ano, mes, sede, programa)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/rentabilidad/detalle")
|
||||||
|
def get_rentabilidad_detalle(
|
||||||
|
programa: str = Query(...),
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.rentabilidad_detalle(programa, ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/rentabilidad/costos")
|
||||||
|
def get_rentabilidad_costos(
|
||||||
|
programa: str = Query(...),
|
||||||
|
ano: int = Query(...),
|
||||||
|
mes: int = Query(...),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return services.rentabilidad_costos(programa, ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── SALDO PENDIENTE ────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/saldo-pendiente")
|
||||||
|
def get_saldo_pendiente(tipo_cuota: str = Query("1° Cuota")):
|
||||||
|
try:
|
||||||
|
return services.saldo_pendiente(tipo_cuota)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── GUARDAR COSTOS ─────────────────────────────────────────────────────────
|
||||||
|
from fastapi import Body
|
||||||
|
|
||||||
|
@app.post("/api/rentabilidad/costos/guardar")
|
||||||
|
def post_guardar_costos(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
num_indice = payload.get("num_indice", "")
|
||||||
|
costos_inicial = payload.get("inicial", {})
|
||||||
|
costos_actual = payload.get("actual", {})
|
||||||
|
ok = services.guardar_costos(num_indice, costos_inicial, costos_actual)
|
||||||
|
if ok:
|
||||||
|
return {"status": "ok"}
|
||||||
|
raise HTTPException(status_code=500, detail="No se pudo guardar (verifica Supabase)")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cobranza/clasificar-sede")
|
||||||
|
def post_clasificar_sede(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
nombres = payload.get("programas", [])
|
||||||
|
return {"mapa": services.clasificar_programas(nombres)}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/debug/supabase-inicios")
|
||||||
|
def debug_supabase_inicios(ano: int, mes: int):
|
||||||
|
"""Diagnóstico: muestra qué hay en Supabase y por qué (no) se inyecta el rojo."""
|
||||||
|
try:
|
||||||
|
dm = services.get_dm()
|
||||||
|
# 1) ¿Supabase conectado?
|
||||||
|
conectado = dm.supabase_client is not None
|
||||||
|
error_conexion = getattr(dm, "supabase_error", None)
|
||||||
|
# 2) Fechas guardadas en Supabase
|
||||||
|
fechas = dm.obtener_fechas_originales()
|
||||||
|
# 3) Índices que SQL trae este mes
|
||||||
|
crudos = dm.ejecutar_consulta_cursos(str(ano), str(mes))
|
||||||
|
indices_sql = sorted({str(f.get("num_indice","")).strip() for f in (crudos or [])})
|
||||||
|
# 4) Para cada fecha en Supabase, calcular su mes y si coincide con el filtrado
|
||||||
|
analisis = []
|
||||||
|
for idx, info in fechas.items():
|
||||||
|
fecha_orig = info.get("fecha","") if isinstance(info, dict) else str(info)
|
||||||
|
mes_orig = ""
|
||||||
|
if "/" in fecha_orig:
|
||||||
|
p = fecha_orig.split("/")
|
||||||
|
if len(p) >= 2: mes_orig = str(int(p[1]))
|
||||||
|
elif "-" in fecha_orig:
|
||||||
|
p = fecha_orig.split("-")
|
||||||
|
if len(p) >= 2: mes_orig = str(int(p[1]))
|
||||||
|
analisis.append({
|
||||||
|
"num_indice": idx,
|
||||||
|
"fecha_supabase": fecha_orig,
|
||||||
|
"mes_detectado": mes_orig,
|
||||||
|
"mes_filtrado": str(int(mes)),
|
||||||
|
"coincide_mes": mes_orig == str(int(mes)),
|
||||||
|
"esta_en_sql_este_mes": idx in indices_sql,
|
||||||
|
"deberia_salir_rojo": (mes_orig == str(int(mes))) and (idx not in indices_sql),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"supabase_conectado": conectado,
|
||||||
|
"error_conexion": error_conexion,
|
||||||
|
"total_fechas_en_supabase": len(fechas),
|
||||||
|
"indices_en_sql_este_mes": indices_sql,
|
||||||
|
"analisis": analisis,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cobranza/detalle-todos")
|
||||||
|
def get_cobranza_detalle_todos(ano: int, mes: int, sectorista: str = "TODOS"):
|
||||||
|
try:
|
||||||
|
return services.cobranza_detalle_todos(ano, mes, sectorista)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── USUARIOS ───────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/usuarios")
|
||||||
|
def get_usuarios():
|
||||||
|
try:
|
||||||
|
return services.usuarios_listar()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/usuarios/crear")
|
||||||
|
def post_usuario_crear(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.usuarios_crear(
|
||||||
|
payload.get("email",""), payload.get("password",""),
|
||||||
|
payload.get("nombre",""), payload.get("rol","COBRANZA"))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/usuarios/rol")
|
||||||
|
def post_usuario_rol(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.usuarios_actualizar_rol(payload.get("id",""), payload.get("rol",""))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/usuarios/eliminar")
|
||||||
|
def post_usuario_eliminar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.usuarios_eliminar(payload.get("id",""))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── VENDEDORES MANUALES (Comisiones) ───────────────────────────────────────
|
||||||
|
@app.get("/api/comisiones/vendedores")
|
||||||
|
def get_vend_manuales(ano: int, mes: int):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_listar(ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/vendedores/crear")
|
||||||
|
def post_vend_manual_crear(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_crear(
|
||||||
|
payload.get("nombre",""), payload.get("descripcion",""),
|
||||||
|
payload.get("fch_emision",""), payload.get("monto",0),
|
||||||
|
payload.get("ano"), payload.get("mes"))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/vendedores/eliminar")
|
||||||
|
def post_vend_manual_eliminar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_eliminar(payload.get("id",""))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/override/guardar")
|
||||||
|
def post_com_override_guardar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.comisiones_override_guardar(payload.get("registros", []))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/override/restaurar")
|
||||||
|
def post_com_override_restaurar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.comisiones_override_restaurar(payload.get("num_matricula",""))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/vendedores/actualizar")
|
||||||
|
def post_vend_manual_actualizar(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_actualizar(
|
||||||
|
payload.get("id",""), payload.get("descripcion",""),
|
||||||
|
payload.get("fch_emision",""), payload.get("monto",0))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/vendedores/lote")
|
||||||
|
def post_vend_manual_lote(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.vendedores_manuales_guardar_lote(
|
||||||
|
payload.get("nombre",""), payload.get("ano"), payload.get("mes"),
|
||||||
|
payload.get("filas", []))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/comisiones/detalle-todos")
|
||||||
|
def get_comisiones_detalle_todos(ano: int, mes: int):
|
||||||
|
try:
|
||||||
|
return services.comisiones_detalle_todos(ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/comisiones/config")
|
||||||
|
def get_comisiones_config(ano: int = Query(...), mes: int = Query(...)):
|
||||||
|
try:
|
||||||
|
return services.comisiones_config_listar(ano, mes)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/comisiones/config/guardar")
|
||||||
|
def post_comisiones_config(payload: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
return services.comisiones_config_guardar(payload.get("ano"), payload.get("mes"), payload.get("filas", []))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── ASESORES (Chatwoot) ────────────────────────────────────────────────────
|
||||||
|
CHATWOOT_URL = "https://gestor.escueladerefrigeracion.edu.pe"
|
||||||
|
ACCESS_TOKEN = "4anazHvZnvKLtup8biu5Zuoh"
|
||||||
|
ACCOUNT_ID = "1"
|
||||||
|
CHATWOOT_HEADERS = {"api_access_token": ACCESS_TOKEN, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/asesores")
|
||||||
|
def get_asesores():
|
||||||
|
import requests
|
||||||
|
try:
|
||||||
|
url = f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents"
|
||||||
|
r = requests.get(url, headers=CHATWOOT_HEADERS, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
return {"agentes": r.json()}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/asesores/estado")
|
||||||
|
def set_asesor_estado(agent_id: int = Query(...), online: bool = Query(...)):
|
||||||
|
import requests, time
|
||||||
|
url = f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents/{agent_id}"
|
||||||
|
estado = "online" if online else "offline"
|
||||||
|
try:
|
||||||
|
requests.patch(url, headers=CHATWOOT_HEADERS, json={"auto_offline": False}, timeout=10)
|
||||||
|
time.sleep(0.5)
|
||||||
|
r = requests.patch(url, headers=CHATWOOT_HEADERS,
|
||||||
|
json={"availability_status": estado, "availability": estado}, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
return {"status": "ok", "estado": estado}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
|
||||||
0
backend/modules/__init__.py
Normal file
0
backend/modules/__init__.py
Normal file
0
backend/modules/asesores/__init__.py
Normal file
0
backend/modules/asesores/__init__.py
Normal file
86
backend/modules/asesores/processor.py
Normal file
86
backend/modules/asesores/processor.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# modules/asesores/processor.py
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
class AsesoresProcessor:
|
||||||
|
def __init__(self):
|
||||||
|
# Tus credenciales maestras de Chatwoot
|
||||||
|
self.chatwoot_url = "https://gestor.escueladerefrigeracion.edu.pe"
|
||||||
|
self.access_token = "4anazHvZnvKLtup8biu5Zuoh"
|
||||||
|
self.account_id = "1"
|
||||||
|
|
||||||
|
def obtener_agentes_chatwoot(self):
|
||||||
|
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents"
|
||||||
|
headers = {
|
||||||
|
"api_access_token": self.access_token,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
agentes = response.json()
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🕵️♂️ ESCÁNER DEBUG (Se imprimirá en tu consola negra)
|
||||||
|
# =================================================================
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🕵️♂️ ESCÁNER: REVISANDO 'DESCONEXIÓN AUTOMÁTICA'")
|
||||||
|
print("="*60)
|
||||||
|
for ag in agentes:
|
||||||
|
nombre = ag.get('available_name') or ag.get('name', 'Desconocido')
|
||||||
|
estado = ag.get('availability_status', 'offline')
|
||||||
|
auto_offline = ag.get('auto_offline', 'Desconocido')
|
||||||
|
print(f"👤 {nombre} | Estado: {estado} | Radar: {auto_offline}")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
return agentes
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# MOTOR DE ACTUALIZACIÓN (EL COMBO DE 2 GOLPES)
|
||||||
|
# =========================================================================
|
||||||
|
def cambiar_estado_agente(self, agent_id, is_online):
|
||||||
|
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents/{agent_id}"
|
||||||
|
headers = {
|
||||||
|
"api_access_token": self.access_token,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
estado_str = "online" if is_online else "offline"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# GOLPE 1: Desactivar el botón automático primero
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
payload_radar = {
|
||||||
|
"auto_offline": False
|
||||||
|
}
|
||||||
|
requests.patch(url, headers=headers, json=payload_radar, timeout=10)
|
||||||
|
|
||||||
|
# Le damos 0.5 segundos a la base de datos de Chatwoot para que
|
||||||
|
# asimile que el radar de este usuario acaba de ser destruido.
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# GOLPE 2: El código viejo y confiable para cambiar el estado
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
payload_estado = {
|
||||||
|
"availability_status": estado_str,
|
||||||
|
"availability": estado_str
|
||||||
|
}
|
||||||
|
response = requests.patch(url, headers=headers, json=payload_estado, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 500:
|
||||||
|
return False, "Error interno del servidor Chatwoot."
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# ⏳ Le damos 1.5 segundos para que le avise a la computadora
|
||||||
|
# del asesor que su estado acaba de cambiar en pantalla.
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
return True, "Orden ejecutada con el combo de 2 pasos"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Fallo de red: {str(e)}"
|
||||||
0
backend/modules/cobranza/__init__.py
Normal file
0
backend/modules/cobranza/__init__.py
Normal file
221
backend/modules/cobranza/logic.py
Normal file
221
backend/modules/cobranza/logic.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# modules/cobranza/logic.py
|
||||||
|
import pandas as pd
|
||||||
|
from .processor import CobranzaProcessor
|
||||||
|
|
||||||
|
class CobranzaLogic:
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.processor = CobranzaProcessor(data_manager)
|
||||||
|
|
||||||
|
def obtener_lista_sectoristas(self, ano, mes):
|
||||||
|
return self.processor.obtener_lista_sectoristas(ano, mes)
|
||||||
|
|
||||||
|
def obtener_datos_tabla(self, ano, mes, sectorista="TODOS", agrupacion="SEDE"):
|
||||||
|
datos_brutos = self.processor.obtener_datos_procesados(ano, mes, sectorista, agrupacion)
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0
|
||||||
|
|
||||||
|
for d in datos_brutos:
|
||||||
|
grupo = d.get('GRUPO', '')
|
||||||
|
frecuencia = d.get('FRECUENCIA', '-')
|
||||||
|
num_cuota = d.get('NUM_CUOTA', '-')
|
||||||
|
fecha_venc = d.get('FCH_VENC_MODA', '-')
|
||||||
|
|
||||||
|
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
|
||||||
|
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
|
||||||
|
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
|
||||||
|
cob_ant = float(d.get('COB_ANT', 0.0))
|
||||||
|
r1 = float(d.get('RATIO_1', 0.0))
|
||||||
|
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
|
||||||
|
r2 = float(d.get('RATIO_2', 0.0))
|
||||||
|
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
|
||||||
|
r3 = float(d.get('RATIO_3', 0.0))
|
||||||
|
saldo = float(d.get('SALDO', 0.0))
|
||||||
|
opciones = d.get('OPCIONES', ' ≡ ▼ ')
|
||||||
|
|
||||||
|
t_cta_ant += cta_ant
|
||||||
|
t_cta_cur += cta_cur
|
||||||
|
t_cta_tot += cta_tot
|
||||||
|
t_cob_ant += cob_ant
|
||||||
|
t_cob_cur += cob_cur
|
||||||
|
t_cob_tot += cob_tot
|
||||||
|
t_saldo += saldo
|
||||||
|
|
||||||
|
avance_ant = f"{r1:.0f}%" if cta_ant > 0 else ""
|
||||||
|
avance_cur = f"{r2:.0f}%" if cta_cur > 0 else ""
|
||||||
|
avance_tot = f"{r3:.0f}%" if cta_tot > 0 else ""
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
fila = [
|
||||||
|
grupo, frecuencia, num_cuota, fecha_venc,
|
||||||
|
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
|
||||||
|
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
|
||||||
|
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
|
||||||
|
f"S/ {saldo:,.0f}", opciones
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
fila = [
|
||||||
|
grupo,
|
||||||
|
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
|
||||||
|
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
|
||||||
|
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
|
||||||
|
f"S/ {saldo:,.0f}", opciones
|
||||||
|
]
|
||||||
|
sheet_data.append(fila)
|
||||||
|
|
||||||
|
r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0
|
||||||
|
r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0
|
||||||
|
r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
fila_total = [
|
||||||
|
"TOTAL GENERAL", "-", "-", "-",
|
||||||
|
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
|
||||||
|
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
|
||||||
|
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
|
||||||
|
f"S/ {t_saldo:,.0f}", ""
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
fila_total = [
|
||||||
|
"TOTAL GENERAL",
|
||||||
|
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
|
||||||
|
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
|
||||||
|
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
|
||||||
|
f"S/ {t_saldo:,.0f}", ""
|
||||||
|
]
|
||||||
|
sheet_data.append(fila_total)
|
||||||
|
return sheet_data
|
||||||
|
|
||||||
|
def obtener_detalle_programa_formateado(self, ano, mes, sectorista, programa):
|
||||||
|
datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, programa)
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0
|
||||||
|
|
||||||
|
for d in datos_brutos:
|
||||||
|
mat = d.get('MATRICULA', '')
|
||||||
|
alumno = d.get('ALUMNO', '')
|
||||||
|
num_cuota = d.get('NUM_CUOTA', '-')
|
||||||
|
fch_venc = d.get('FCH_VENC', '-')
|
||||||
|
|
||||||
|
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
|
||||||
|
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
|
||||||
|
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
|
||||||
|
cob_ant = float(d.get('COB_ANT', 0.0))
|
||||||
|
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
|
||||||
|
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
|
||||||
|
saldo = float(d.get('SALDO', 0.0))
|
||||||
|
|
||||||
|
t_cta_ant += cta_ant; t_cta_cur += cta_cur; t_cta_tot += cta_tot
|
||||||
|
t_cob_ant += cob_ant; t_cob_cur += cob_cur; t_cob_tot += cob_tot; t_saldo += saldo
|
||||||
|
|
||||||
|
r1 = (cob_ant / cta_ant) * 100 if cta_ant > 0 else 0.0
|
||||||
|
r2 = (cob_cur / cta_cur) * 100 if cta_cur > 0 else 0.0
|
||||||
|
r3 = (cob_tot / cta_tot) * 100 if cta_tot > 0 else 0.0
|
||||||
|
|
||||||
|
avance_ant = f"{r1:.0f}%" if cta_ant > 0 else ""
|
||||||
|
avance_cur = f"{r2:.0f}%" if cta_cur > 0 else ""
|
||||||
|
avance_tot = f"{r3:.0f}%" if cta_tot > 0 else ""
|
||||||
|
|
||||||
|
fila = [
|
||||||
|
mat, alumno, num_cuota, fch_venc,
|
||||||
|
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
|
||||||
|
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
|
||||||
|
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
|
||||||
|
f"S/ {saldo:,.0f}"
|
||||||
|
]
|
||||||
|
sheet_data.append(fila)
|
||||||
|
|
||||||
|
r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0
|
||||||
|
r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0
|
||||||
|
r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0
|
||||||
|
|
||||||
|
fila_total = [
|
||||||
|
"TOTAL", "GENERAL", "-", "-",
|
||||||
|
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
|
||||||
|
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
|
||||||
|
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
|
||||||
|
f"S/ {t_saldo:,.0f}"
|
||||||
|
]
|
||||||
|
sheet_data.append(fila_total)
|
||||||
|
return sheet_data
|
||||||
|
|
||||||
|
def exportar_detalle_excel(self, programa, headers, datos):
|
||||||
|
if not datos: raise ValueError("No hay datos para exportar")
|
||||||
|
|
||||||
|
prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:30]
|
||||||
|
archivo = f"Detalle_Cobranza_{prog_limpio}.xlsx"
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos, columns=[h.replace('\n', ' ') for h in headers])
|
||||||
|
try:
|
||||||
|
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='Detalle')
|
||||||
|
except:
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def exportar_reporte_global(self, ano, mes, sectorista):
|
||||||
|
datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, "TODOS")
|
||||||
|
if not datos_brutos: raise ValueError("No hay datos para exportar en este mes.")
|
||||||
|
|
||||||
|
filas_excel = []
|
||||||
|
for d in datos_brutos:
|
||||||
|
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
|
||||||
|
cob_ant = float(d.get('COB_ANT', 0.0))
|
||||||
|
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
|
||||||
|
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
|
||||||
|
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
|
||||||
|
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
|
||||||
|
|
||||||
|
filas_excel.append({
|
||||||
|
"ALUMNO": d.get('ALUMNO', ''),
|
||||||
|
"PROGRAMA": d.get('PROGRAMA', '-'),
|
||||||
|
"FRECUENCIA": d.get('FRECUENCIA', '-'),
|
||||||
|
"NUM CUOTA": d.get('NUM_CUOTA', '-'),
|
||||||
|
"FCH VENCIMIENTO": d.get('FCH_VENC', '-'),
|
||||||
|
"CUENTA PENDIENTE": cta_ant,
|
||||||
|
"COBRADO PENDIENTE": cob_ant,
|
||||||
|
"AVANCE % ": (cob_ant / cta_ant) if cta_ant > 0 else 0.0,
|
||||||
|
"CUENTA EN CURSO": cta_cur,
|
||||||
|
"COBRADO EN CURSO": cob_cur,
|
||||||
|
"AVANCE % ": (cob_cur / cta_cur) if cta_cur > 0 else 0.0,
|
||||||
|
"TOTAL CUENTA": cta_tot,
|
||||||
|
"TOTAL COBRADO": cob_tot,
|
||||||
|
"AVANCE %": (cob_tot / cta_tot) if cta_tot > 0 else 0.0,
|
||||||
|
"SALDO": float(d.get('SALDO', 0.0))
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(filas_excel)
|
||||||
|
archivo = f"Reporte_Cobranza_Global_{ano}_{mes}.xlsx"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='Reporte Global')
|
||||||
|
worksheet = writer.sheets['Reporte Global']
|
||||||
|
from openpyxl.styles import PatternFill, Font, Alignment
|
||||||
|
|
||||||
|
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
|
||||||
|
font_header = Font(color="FFFFFF", bold=True)
|
||||||
|
for cell in worksheet[1]:
|
||||||
|
cell.fill = fill_header
|
||||||
|
cell.font = font_header
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
|
||||||
|
for row in range(2, len(filas_excel) + 2):
|
||||||
|
for col in [6, 7, 9, 10, 12, 13, 15]:
|
||||||
|
worksheet.cell(row=row, column=col).number_format = '"S/" #,##0.00'
|
||||||
|
for col in [8, 11, 14]:
|
||||||
|
worksheet.cell(row=row, column=col).number_format = '0%'
|
||||||
|
|
||||||
|
for col in worksheet.columns:
|
||||||
|
max_len = 0
|
||||||
|
col_letter = col[0].column_letter
|
||||||
|
for cell in col:
|
||||||
|
if cell.value:
|
||||||
|
max_len = max(max_len, len(str(cell.value)))
|
||||||
|
worksheet.column_dimensions[col_letter].width = min(max_len + 2, 40)
|
||||||
|
except Exception:
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
|
||||||
|
return archivo
|
||||||
734
backend/modules/cobranza/processor.py
Normal file
734
backend/modules/cobranza/processor.py
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
# modules/cobranza/processor.py
|
||||||
|
from datetime import datetime
|
||||||
|
import requests
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
class CobranzaProcessor:
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.config_sedes = self._cargar_json_sedes()
|
||||||
|
# ── Caché de consultas SQL ────────────────────────────────────────
|
||||||
|
self._cache_cronograma = None
|
||||||
|
self._cache_facturas = None
|
||||||
|
self._cache_ano = None
|
||||||
|
|
||||||
|
def _obtener_datos_cached(self, ano):
|
||||||
|
"""Devuelve cronograma y facturas usando caché si el año coincide."""
|
||||||
|
if self._cache_ano == ano and self._cache_cronograma is not None:
|
||||||
|
print(f"[CACHE] Reutilizando datos en caché para año {ano}")
|
||||||
|
return self._cache_cronograma, self._cache_facturas
|
||||||
|
print(f"[CACHE] Consultando BD para año {ano}...")
|
||||||
|
cronograma = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano)
|
||||||
|
facturas = self.data_manager.ejecutar_consulta_facturas_cobranza(ano)
|
||||||
|
self._cache_cronograma = cronograma
|
||||||
|
self._cache_facturas = facturas
|
||||||
|
self._cache_ano = ano
|
||||||
|
return cronograma, facturas
|
||||||
|
|
||||||
|
def invalidar_cache(self):
|
||||||
|
"""Fuerza recarga desde BD en la siguiente consulta."""
|
||||||
|
self._cache_cronograma = None
|
||||||
|
self._cache_facturas = None
|
||||||
|
self._cache_ano = None
|
||||||
|
print("🧹 [CACHE] Memoria limpiada por actualización automática. La próxima consulta irá directo a la BD.")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CARGA DE CONFIGURACIÓN (sede.json)
|
||||||
|
# =========================================================================
|
||||||
|
def _cargar_json_sedes(self):
|
||||||
|
datos = None
|
||||||
|
try:
|
||||||
|
url = getattr(self.data_manager, 'github_sede_url', None)
|
||||||
|
if url:
|
||||||
|
response = requests.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
datos = response.json()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if datos is None:
|
||||||
|
datos = {
|
||||||
|
"clasificacion_sedes": {
|
||||||
|
"AREQUIPA": {"patrones": ["AREQUIPA"]},
|
||||||
|
"PIURA": {"patrones": ["PIURA"]},
|
||||||
|
"TRUJILLO": {"patrones": ["TRUJILLO"]}
|
||||||
|
},
|
||||||
|
"clasificacion_default": {"sede": "LIMA"}
|
||||||
|
}
|
||||||
|
|
||||||
|
self._reemplazos_nombre = datos.get("reemplazos_nombre", [])
|
||||||
|
return datos
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# REEMPLAZOS DE NOMBRE DE PROGRAMA
|
||||||
|
# =========================================================================
|
||||||
|
@staticmethod
|
||||||
|
def _normalizar(texto):
|
||||||
|
texto = unicodedata.normalize('NFD', str(texto))
|
||||||
|
return texto.encode('ascii', 'ignore').decode('utf-8')
|
||||||
|
|
||||||
|
def _aplicar_reemplazos_nombre(self, texto):
|
||||||
|
if not texto or not self._reemplazos_nombre:
|
||||||
|
return texto
|
||||||
|
|
||||||
|
resultado = texto
|
||||||
|
for entrada in self._reemplazos_nombre:
|
||||||
|
buscar = str(entrada.get("buscar", "")).strip()
|
||||||
|
reemplazar = str(entrada.get("reemplazar", "")).strip()
|
||||||
|
if not buscar:
|
||||||
|
continue
|
||||||
|
|
||||||
|
buscar_norm = self._normalizar(buscar).upper()
|
||||||
|
idx = self._normalizar(resultado).upper().find(buscar_norm)
|
||||||
|
while idx != -1:
|
||||||
|
resultado = resultado[:idx] + reemplazar + resultado[idx + len(buscar):]
|
||||||
|
idx = self._normalizar(resultado).upper().find(buscar_norm, idx + len(reemplazar))
|
||||||
|
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CLASIFICACIÓN DE SEDE
|
||||||
|
# =========================================================================
|
||||||
|
def clasificar_sede(self, dsc_programa):
|
||||||
|
if not dsc_programa:
|
||||||
|
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
|
||||||
|
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
sedes = self.config_sedes.get("clasificacion_sedes", {})
|
||||||
|
|
||||||
|
for nombre_sede, datos in sedes.items():
|
||||||
|
for patron in datos.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return nombre_sede
|
||||||
|
|
||||||
|
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# UTILIDADES
|
||||||
|
# =========================================================================
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-':
|
||||||
|
return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def obtener_fechas_filtro(self, ano, mes_numero):
|
||||||
|
ano_int = int(ano)
|
||||||
|
mes_int = int(mes_numero)
|
||||||
|
fecha_filtro = datetime(ano_int, mes_int, 1)
|
||||||
|
if mes_int == 12:
|
||||||
|
fecha_filtro_siguiente = datetime(ano_int + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
fecha_filtro_siguiente = datetime(ano_int, mes_int + 1, 1)
|
||||||
|
return fecha_filtro, fecha_filtro_siguiente
|
||||||
|
|
||||||
|
def calcular_importe_final(self, fila):
|
||||||
|
try: imp_total = float(fila.get('imp_total', 0) or 0)
|
||||||
|
except: imp_total = 0.0
|
||||||
|
try: imp_dscto = float(fila.get('imp_dscto', 0) or 0)
|
||||||
|
except: imp_dscto = 0.0
|
||||||
|
return imp_total - imp_dscto
|
||||||
|
|
||||||
|
def evaluar_ctaxcob_suma(self, fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats):
|
||||||
|
sectorista = str(fila.get('dsc_sectorista', ''))
|
||||||
|
if sectorista == " , " or sectorista.strip() == ",":
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
try: num_cuota = int(fila.get('num_cuota', 0))
|
||||||
|
except: num_cuota = 0
|
||||||
|
if not (num_cuota > 1):
|
||||||
|
debug_stats['falla_cuota'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
fch_vencimiento = self.parse_fecha(fila.get('fch_vencimiento'))
|
||||||
|
if not fch_vencimiento:
|
||||||
|
debug_stats['falla_vencimiento'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
es_anterior = fch_vencimiento < fecha_filtro
|
||||||
|
es_curso = (fch_vencimiento >= fecha_filtro) and (fch_vencimiento < fecha_filtro_siguiente)
|
||||||
|
|
||||||
|
if not (es_anterior or es_curso):
|
||||||
|
debug_stats['falla_vencimiento'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
fch_inicio = self.parse_fecha(fila.get('fch_inicio'))
|
||||||
|
if not fch_inicio or not (fch_inicio <= hoy):
|
||||||
|
debug_stats['falla_inicio'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
estado = str(fila.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado in ["ALU", "PRE"]:
|
||||||
|
pass
|
||||||
|
elif estado == "RET":
|
||||||
|
fch_retiro = self.parse_fecha(fila.get('fch_retiro'))
|
||||||
|
if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente):
|
||||||
|
debug_stats['falla_estado'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
else:
|
||||||
|
debug_stats['falla_estado'] += 1
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
importe_convertido = importe_final
|
||||||
|
moneda = str(fila.get('cod_moneda', '')).strip().upper()
|
||||||
|
if moneda == 'DOL':
|
||||||
|
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||||
|
except: tc = 1.0
|
||||||
|
importe_convertido *= tc
|
||||||
|
|
||||||
|
debug_stats['pasa_todo'] += 1
|
||||||
|
|
||||||
|
if es_anterior: return importe_convertido, 0.0
|
||||||
|
if es_curso: return 0.0, importe_convertido
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
def evaluar_ctaxcob_resta(self, fila_factura, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats, tc_correcto):
|
||||||
|
sectorista = str(fila_factura.get('dsc_sectorista', ''))
|
||||||
|
if sectorista == " , " or sectorista.strip() == ",":
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
try: num_cuota = int(fila_factura.get('num_cuota', 0))
|
||||||
|
except: num_cuota = 0
|
||||||
|
if not (num_cuota > 1):
|
||||||
|
debug_stats['falla_cuota'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
cod_estado = str(fila_factura.get('cod_estado', '')).strip().upper()
|
||||||
|
if cod_estado != "CAN":
|
||||||
|
debug_stats['falla_estado_can'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
flg_nc = str(fila_factura.get('flg_nc', '')).strip().upper()
|
||||||
|
if flg_nc == "SI":
|
||||||
|
debug_stats['falla_nc'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
fch_venc_cuota = self.parse_fecha(fila_factura.get('fch_vencimiento_cuota'))
|
||||||
|
if not fch_venc_cuota:
|
||||||
|
debug_stats['falla_venc_cuota'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
es_anterior = fch_venc_cuota < fecha_filtro
|
||||||
|
es_curso = (fch_venc_cuota >= fecha_filtro) and (fch_venc_cuota < fecha_filtro_siguiente)
|
||||||
|
|
||||||
|
if not (es_anterior or es_curso):
|
||||||
|
debug_stats['falla_venc_cuota'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
fch_cancelacion = self.parse_fecha(fila_factura.get('fch_cancelacion'))
|
||||||
|
if not fch_cancelacion:
|
||||||
|
debug_stats['falla_cancelacion'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
pasa_anterior = es_anterior and (fch_cancelacion < fecha_filtro)
|
||||||
|
pasa_curso = es_curso and (fch_cancelacion < fecha_filtro)
|
||||||
|
pasa_cobrado_ant = es_anterior and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente)
|
||||||
|
pasa_cobrado_curso = es_curso and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente)
|
||||||
|
|
||||||
|
if not (pasa_anterior or pasa_curso or pasa_cobrado_ant or pasa_cobrado_curso):
|
||||||
|
debug_stats['falla_cancelacion'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
fch_inicio = self.parse_fecha(fila_factura.get('fch_inicio'))
|
||||||
|
if not fch_inicio or not (fch_inicio <= hoy):
|
||||||
|
debug_stats['falla_inicio'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
estado_mat = str(fila_factura.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado_mat in ["ALU", "PRE"]: pass
|
||||||
|
elif estado_mat == "RET":
|
||||||
|
fch_retiro = self.parse_fecha(fila_factura.get('fch_retiro'))
|
||||||
|
if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente):
|
||||||
|
debug_stats['falla_estado_mat'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
else:
|
||||||
|
debug_stats['falla_estado_mat'] += 1
|
||||||
|
return 0.0, 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
try: imp_emitido = float(fila_factura.get('imp_emitido', 0) or 0)
|
||||||
|
except: imp_emitido = 0.0
|
||||||
|
|
||||||
|
imp_emitido_convertido = imp_emitido
|
||||||
|
moneda = str(fila_factura.get('cod_moneda', '')).strip().upper()
|
||||||
|
if moneda == 'DOL':
|
||||||
|
imp_emitido_convertido *= tc_correcto
|
||||||
|
|
||||||
|
debug_stats['pasa_todo'] += 1
|
||||||
|
|
||||||
|
val_resta_ant = imp_emitido_convertido if pasa_anterior else 0.0
|
||||||
|
val_resta_cur = imp_emitido_convertido if pasa_curso else 0.0
|
||||||
|
val_cobrado_ant = imp_emitido_convertido if pasa_cobrado_ant else 0.0
|
||||||
|
val_cobrado_curso = imp_emitido_convertido if pasa_cobrado_curso else 0.0
|
||||||
|
|
||||||
|
return val_resta_ant, val_resta_cur, val_cobrado_ant, val_cobrado_curso
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# LISTA DE SECTORISTAS
|
||||||
|
# =========================================================================
|
||||||
|
def obtener_lista_sectoristas(self, ano, mes):
|
||||||
|
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
|
||||||
|
hoy = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cronograma_raw = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano)
|
||||||
|
facturas_raw = self.data_manager.ejecutar_consulta_facturas_cobranza(ano)
|
||||||
|
except Exception: return ["TODOS"]
|
||||||
|
|
||||||
|
sectoristas_neto = {}
|
||||||
|
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
|
||||||
|
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
|
||||||
|
|
||||||
|
indices_cronograma = set()
|
||||||
|
mapa_tc = {}
|
||||||
|
mapa_sectorista = {}
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
sec_str = str(fila.get('dsc_sectorista', ''))
|
||||||
|
sec_clean = sec_str.strip()
|
||||||
|
if sec_clean in ["", ",", "None"] or sec_str == " , ": continue
|
||||||
|
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx:
|
||||||
|
indices_cronograma.add(idx)
|
||||||
|
mapa_sectorista[idx] = sec_clean
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
if mat and mat not in mapa_tc:
|
||||||
|
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||||
|
except: tc = 1.0
|
||||||
|
mapa_tc[mat] = tc
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx not in indices_cronograma: continue
|
||||||
|
|
||||||
|
sec_clean = mapa_sectorista[idx]
|
||||||
|
importe_final = self.calcular_importe_final(fila)
|
||||||
|
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
|
||||||
|
|
||||||
|
if sec_clean not in sectoristas_neto: sectoristas_neto[sec_clean] = 0.0
|
||||||
|
sectoristas_neto[sec_clean] += (suma_ant + suma_cur)
|
||||||
|
|
||||||
|
for fila in facturas_raw:
|
||||||
|
idx_factura = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx_factura not in indices_cronograma: continue
|
||||||
|
|
||||||
|
sec_clean = mapa_sectorista.get(idx_factura, "")
|
||||||
|
if not sec_clean: continue
|
||||||
|
|
||||||
|
mat_factura = str(fila.get('num_matricula', '')).strip()
|
||||||
|
tc_correcto = mapa_tc.get(mat_factura, 1.0)
|
||||||
|
|
||||||
|
resta_ant, resta_cur, _, _ = self.evaluar_ctaxcob_resta(
|
||||||
|
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
|
||||||
|
)
|
||||||
|
|
||||||
|
if sec_clean in sectoristas_neto: sectoristas_neto[sec_clean] -= (resta_ant + resta_cur)
|
||||||
|
|
||||||
|
lista_final = []
|
||||||
|
for sec, total_cta_x_cob in sectoristas_neto.items():
|
||||||
|
if round(total_cta_x_cob, 2) > 0: lista_final.append(sec)
|
||||||
|
|
||||||
|
lista_final = sorted(lista_final)
|
||||||
|
lista_final.insert(0, "TODOS")
|
||||||
|
return lista_final
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# DATOS PROCESADOS (TABLA PRINCIPAL CON VISTA DE ASESORES)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes, sectorista_filtro="TODOS", agrupacion="SEDE"):
|
||||||
|
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
|
||||||
|
hoy = datetime.now()
|
||||||
|
datos_agrupados = {}
|
||||||
|
|
||||||
|
frecuencias_cuota = {}
|
||||||
|
frecuencias_fecha_venc = {}
|
||||||
|
mapa_frecuencia = {}
|
||||||
|
|
||||||
|
alumnos_tracking = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
cronograma_raw, facturas_raw = self._obtener_datos_cached(ano)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error crítico: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
|
||||||
|
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
|
||||||
|
|
||||||
|
indices_cronograma = set()
|
||||||
|
mapa_tc = {}
|
||||||
|
mapa_programa = {}
|
||||||
|
mapa_sectorista = {} # <-- NUEVO MAPA PARA CORREGIR ASESORES
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
sectorista_str = str(fila.get('dsc_sectorista', ''))
|
||||||
|
sectorista_clean = sectorista_str.strip()
|
||||||
|
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx:
|
||||||
|
# Guardamos el asesor real del cronograma para usarlo en las facturas
|
||||||
|
mapa_sectorista[idx] = sectorista_clean if sectorista_clean and sectorista_clean not in [",", "None"] else "SIN ASESOR"
|
||||||
|
|
||||||
|
if sectorista_clean in [",", "", "None"] or sectorista_str == " , ": continue
|
||||||
|
if sectorista_filtro != "TODOS" and sectorista_clean != sectorista_filtro: continue
|
||||||
|
|
||||||
|
if idx: indices_cronograma.add(idx)
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
if mat:
|
||||||
|
if mat not in mapa_tc:
|
||||||
|
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||||
|
except: tc = 1.0
|
||||||
|
mapa_tc[mat] = tc
|
||||||
|
|
||||||
|
if mat not in mapa_programa:
|
||||||
|
prog = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
mapa_programa[mat] = prog if (prog and prog != "None") else "SIN PROGRAMA"
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx not in indices_cronograma: continue
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
|
||||||
|
if agrupacion == "SEDE":
|
||||||
|
llave = self.clasificar_sede(fila.get('dsc_programa', ''))
|
||||||
|
elif agrupacion == "ASESOR":
|
||||||
|
llave = mapa_sectorista.get(idx, "SIN ASESOR")
|
||||||
|
else:
|
||||||
|
llave = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
if not llave or llave == "None": llave = "SIN PROGRAMA"
|
||||||
|
|
||||||
|
if llave not in datos_agrupados:
|
||||||
|
datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0}
|
||||||
|
|
||||||
|
importe_final = self.calcular_importe_final(fila)
|
||||||
|
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
|
||||||
|
|
||||||
|
datos_agrupados[llave]["Suma_Ant"] += suma_ant
|
||||||
|
datos_agrupados[llave]["Suma_Cur"] += suma_cur
|
||||||
|
|
||||||
|
clave_mat = (mat, llave)
|
||||||
|
if clave_mat not in alumnos_tracking:
|
||||||
|
alumnos_tracking[clave_mat] = {"suma": 0.0, "resta": 0.0, "cobrado": 0.0, "cuota_final": None, "fecha_final": None}
|
||||||
|
|
||||||
|
alumnos_tracking[clave_mat]["suma"] += (suma_ant + suma_cur)
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
if llave not in mapa_frecuencia:
|
||||||
|
frec_val = str(fila.get('cod_frecuencia', '-')).strip()
|
||||||
|
mapa_frecuencia[llave] = frec_val if frec_val and frec_val != 'None' and frec_val != '' else '-'
|
||||||
|
|
||||||
|
fch_venc_raw = fila.get('fch_vencimiento')
|
||||||
|
fch_venc_dt = self.parse_fecha(fch_venc_raw)
|
||||||
|
if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente):
|
||||||
|
try:
|
||||||
|
n_cuota = int(fila.get('num_cuota', -1))
|
||||||
|
if n_cuota >= 0: alumnos_tracking[clave_mat]["cuota_final"] = n_cuota
|
||||||
|
except: pass
|
||||||
|
try:
|
||||||
|
alumnos_tracking[clave_mat]["fecha_final"] = fch_venc_dt.strftime('%d/%m/%Y')
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
for fila in facturas_raw:
|
||||||
|
idx_factura = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx_factura not in indices_cronograma: continue
|
||||||
|
|
||||||
|
mat_factura = str(fila.get('num_matricula', '')).strip()
|
||||||
|
tc_correcto = mapa_tc.get(mat_factura, 1.0)
|
||||||
|
|
||||||
|
if agrupacion == "SEDE":
|
||||||
|
llave = self.clasificar_sede(fila.get('dsc_programa', ''))
|
||||||
|
elif agrupacion == "ASESOR":
|
||||||
|
# AQUÍ ESTÁ LA MAGIA: usa el asesor guardado desde el cronograma
|
||||||
|
llave = mapa_sectorista.get(idx_factura, "SIN ASESOR")
|
||||||
|
else:
|
||||||
|
llave = mapa_programa.get(mat_factura, "")
|
||||||
|
if not llave:
|
||||||
|
llave = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
if not llave or llave == "None": llave = "SIN PROGRAMA"
|
||||||
|
|
||||||
|
if llave not in datos_agrupados:
|
||||||
|
datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0}
|
||||||
|
|
||||||
|
resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta(
|
||||||
|
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
|
||||||
|
)
|
||||||
|
|
||||||
|
datos_agrupados[llave]["Resta_Ant"] += resta_ant
|
||||||
|
datos_agrupados[llave]["Resta_Cur"] += resta_cur
|
||||||
|
datos_agrupados[llave]["Cob_Ant"] += cobrado_ant
|
||||||
|
datos_agrupados[llave]["Cob_Cur"] += cobrado_curso
|
||||||
|
|
||||||
|
clave_mat = (mat_factura, llave)
|
||||||
|
if clave_mat in alumnos_tracking:
|
||||||
|
alumnos_tracking[clave_mat]["resta"] += (resta_ant + resta_cur)
|
||||||
|
alumnos_tracking[clave_mat]["cobrado"] += (cobrado_ant + cobrado_curso)
|
||||||
|
|
||||||
|
for (mat, llave), track in alumnos_tracking.items():
|
||||||
|
saldo_alumno = (track["suma"] - track["resta"]) - track["cobrado"]
|
||||||
|
if round(saldo_alumno, 2) > 0:
|
||||||
|
c = track["cuota_final"]
|
||||||
|
f = track["fecha_final"]
|
||||||
|
|
||||||
|
if c is not None:
|
||||||
|
if llave not in frecuencias_cuota: frecuencias_cuota[llave] = {}
|
||||||
|
frecuencias_cuota[llave][c] = frecuencias_cuota[llave].get(c, 0) + 1
|
||||||
|
if f is not None:
|
||||||
|
if llave not in frecuencias_fecha_venc: frecuencias_fecha_venc[llave] = {}
|
||||||
|
frecuencias_fecha_venc[llave][f] = frecuencias_fecha_venc[llave].get(f, 0) + 1
|
||||||
|
|
||||||
|
datos_limpios = []
|
||||||
|
llaves_ordenadas = sorted(datos_agrupados.keys())
|
||||||
|
|
||||||
|
asesores_validos = []
|
||||||
|
if agrupacion == "ASESOR":
|
||||||
|
asesores_validos = [s for s in self.obtener_lista_sectoristas(ano, mes) if s != "TODOS"]
|
||||||
|
|
||||||
|
if agrupacion == "SEDE":
|
||||||
|
orden_sedes = ["LIMA", "AREQUIPA", "TRUJILLO", "PIURA"]
|
||||||
|
llaves_ordenadas = [s for s in orden_sedes if s in datos_agrupados] + [s for s in llaves_ordenadas if s not in orden_sedes]
|
||||||
|
|
||||||
|
for llave in llaves_ordenadas:
|
||||||
|
if agrupacion == "ASESOR" and llave not in asesores_validos:
|
||||||
|
continue
|
||||||
|
|
||||||
|
totales = datos_agrupados[llave]
|
||||||
|
cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"]
|
||||||
|
cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"]
|
||||||
|
total_cta = cta_cob_ant + cta_cob_curso
|
||||||
|
|
||||||
|
cobrado_meses_ant = totales["Cob_Ant"]
|
||||||
|
cobrado_mes_curso = totales["Cob_Cur"]
|
||||||
|
total_cobrado_sede = cobrado_meses_ant + cobrado_mes_curso
|
||||||
|
|
||||||
|
if round(total_cta, 2) <= 0 and round(total_cobrado_sede, 2) <= 0: continue
|
||||||
|
|
||||||
|
ratio_1 = (cobrado_meses_ant / cta_cob_ant) * 100 if cta_cob_ant > 0 else 0.0
|
||||||
|
ratio_2 = (cobrado_mes_curso / cta_cob_curso) * 100 if cta_cob_curso > 0 else 0.0
|
||||||
|
ratio_3 = (total_cobrado_sede / total_cta) * 100 if total_cta > 0 else 0.0
|
||||||
|
saldo_sede = total_cta - total_cobrado_sede
|
||||||
|
|
||||||
|
frec_str = "-"
|
||||||
|
moda_cuota = "-"
|
||||||
|
moda_fecha = "-"
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
frec_str = mapa_frecuencia.get(llave, "-")
|
||||||
|
if llave in frecuencias_cuota and frecuencias_cuota[llave]:
|
||||||
|
moda_cuota = str(max(frecuencias_cuota[llave], key=frecuencias_cuota[llave].get))
|
||||||
|
if llave in frecuencias_fecha_venc and frecuencias_fecha_venc[llave]:
|
||||||
|
moda_fecha = str(max(frecuencias_fecha_venc[llave], key=frecuencias_fecha_venc[llave].get))
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
grupo_display = self._aplicar_reemplazos_nombre(llave)
|
||||||
|
else:
|
||||||
|
grupo_display = llave
|
||||||
|
|
||||||
|
datos_limpios.append({
|
||||||
|
'GRUPO': grupo_display,
|
||||||
|
'GRUPO_ORIGINAL': llave,
|
||||||
|
'FRECUENCIA': frec_str,
|
||||||
|
'NUM_CUOTA': moda_cuota,
|
||||||
|
'FCH_VENC_MODA': moda_fecha,
|
||||||
|
'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta,
|
||||||
|
'COB_ANT': cobrado_meses_ant, 'RATIO_1': ratio_1, 'COB_MES_CURSO': cobrado_mes_curso, 'RATIO_2': ratio_2,
|
||||||
|
'TOTAL_COBRADO': total_cobrado_sede, 'RATIO_3': ratio_3, 'SALDO': saldo_sede, 'OPCIONES': " ≡ ▼ "
|
||||||
|
})
|
||||||
|
|
||||||
|
if agrupacion == "PROGRAMA":
|
||||||
|
def orden_personalizado_programa(x):
|
||||||
|
fecha_str = x['FCH_VENC_MODA']
|
||||||
|
if fecha_str == "-":
|
||||||
|
return (0, datetime.min, x['GRUPO'])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(fecha_str, '%d/%m/%Y')
|
||||||
|
return (1, dt, x['GRUPO'])
|
||||||
|
except:
|
||||||
|
return (1, datetime.max, x['GRUPO'])
|
||||||
|
|
||||||
|
return sorted(datos_limpios, key=orden_personalizado_programa)
|
||||||
|
|
||||||
|
return datos_limpios
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# DESGLOSE POR ALUMNO/MATRÍCULA
|
||||||
|
# =========================================================================
|
||||||
|
def obtener_detalle_programa(self, ano, mes, sectorista_filtro, programa_nombre):
|
||||||
|
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
|
||||||
|
hoy = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cronograma_raw, facturas_raw = self._obtener_datos_cached(ano)
|
||||||
|
except Exception: return []
|
||||||
|
|
||||||
|
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
|
||||||
|
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
|
||||||
|
|
||||||
|
indices_cronograma = set()
|
||||||
|
mat_validas = set()
|
||||||
|
mapa_tc = {}
|
||||||
|
mapa_programa_frec = {}
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
sectorista_str = str(fila.get('dsc_sectorista', '')).strip()
|
||||||
|
if sectorista_str in [",", ""] or fila.get('dsc_sectorista') == " , ": continue
|
||||||
|
if sectorista_filtro != "TODOS" and sectorista_str != sectorista_filtro: continue
|
||||||
|
|
||||||
|
prog = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
if not prog or prog == "None": prog = "SIN PROGRAMA"
|
||||||
|
|
||||||
|
if programa_nombre != "TODOS":
|
||||||
|
if self._aplicar_reemplazos_nombre(prog) != programa_nombre: continue
|
||||||
|
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx: indices_cronograma.add(idx)
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
if mat:
|
||||||
|
mat_validas.add(mat)
|
||||||
|
if mat not in mapa_tc:
|
||||||
|
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||||
|
except: tc = 1.0
|
||||||
|
mapa_tc[mat] = tc
|
||||||
|
if mat not in mapa_programa_frec:
|
||||||
|
prog_bd = str(fila.get('dsc_det_programa', '')).strip()
|
||||||
|
frec_bd = str(fila.get('cod_frecuencia', '')).strip()
|
||||||
|
mapa_programa_frec[mat] = {
|
||||||
|
"prog": prog_bd if prog_bd and prog_bd != "None" else "-",
|
||||||
|
"frec": frec_bd if frec_bd and frec_bd != "None" else "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
mapa_alumnos = {}
|
||||||
|
if mat_validas:
|
||||||
|
try:
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
lista_mats = list(mat_validas)
|
||||||
|
|
||||||
|
for i in range(0, len(lista_mats), 1000):
|
||||||
|
chunk = lista_mats[i:i+1000]
|
||||||
|
chunk_str = ",".join([f"'{m}'" for m in chunk])
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT sgeca_matricula.num_matricula,
|
||||||
|
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno
|
||||||
|
FROM sgeca_matricula
|
||||||
|
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||||
|
WHERE sgeca_matricula.num_matricula IN ({chunk_str})
|
||||||
|
"""
|
||||||
|
cursor.execute(sql)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
num_mat_bd = str(row[0]).strip()
|
||||||
|
nombre_bd = str(row[1]).strip()
|
||||||
|
mapa_alumnos[num_mat_bd] = nombre_bd
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error consultando nombres de alumnos en BD: {e}")
|
||||||
|
|
||||||
|
datos_agrupados = {}
|
||||||
|
|
||||||
|
for fila in cronograma_raw:
|
||||||
|
idx = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx not in indices_cronograma: continue
|
||||||
|
|
||||||
|
mat = str(fila.get('num_matricula', '')).strip()
|
||||||
|
if mat not in datos_agrupados:
|
||||||
|
prog_info = mapa_programa_frec.get(mat, {"prog": "-", "frec": "-"})
|
||||||
|
datos_agrupados[mat] = {
|
||||||
|
"Alumno": mapa_alumnos.get(mat, "SIN NOMBRE"),
|
||||||
|
"Programa": prog_info["prog"],
|
||||||
|
"Frecuencia": prog_info["frec"],
|
||||||
|
"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0,
|
||||||
|
"Cuota_Mes": "-", "Fch_Venc_Mes": "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
fch_venc_raw = fila.get('fch_vencimiento')
|
||||||
|
fch_venc_dt = self.parse_fecha(fch_venc_raw)
|
||||||
|
if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente):
|
||||||
|
try: datos_agrupados[mat]["Cuota_Mes"] = str(int(fila.get('num_cuota', -1)))
|
||||||
|
except: pass
|
||||||
|
datos_agrupados[mat]["Fch_Venc_Mes"] = fch_venc_dt.strftime('%d/%m/%Y')
|
||||||
|
|
||||||
|
importe_final = self.calcular_importe_final(fila)
|
||||||
|
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
|
||||||
|
|
||||||
|
datos_agrupados[mat]["Suma_Ant"] += suma_ant
|
||||||
|
datos_agrupados[mat]["Suma_Cur"] += suma_cur
|
||||||
|
|
||||||
|
for fila in facturas_raw:
|
||||||
|
idx_factura = str(fila.get('num_indice', '')).strip()
|
||||||
|
if idx_factura not in indices_cronograma: continue
|
||||||
|
|
||||||
|
mat_factura = str(fila.get('num_matricula', '')).strip()
|
||||||
|
tc_correcto = mapa_tc.get(mat_factura, 1.0)
|
||||||
|
|
||||||
|
if mat_factura not in datos_agrupados:
|
||||||
|
prog_info = mapa_programa_frec.get(mat_factura, {"prog": "-", "frec": "-"})
|
||||||
|
datos_agrupados[mat_factura] = {
|
||||||
|
"Alumno": mapa_alumnos.get(mat_factura, "SIN NOMBRE"),
|
||||||
|
"Programa": prog_info["prog"],
|
||||||
|
"Frecuencia": prog_info["frec"],
|
||||||
|
"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0,
|
||||||
|
"Cuota_Mes": "-", "Fch_Venc_Mes": "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta(
|
||||||
|
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
|
||||||
|
)
|
||||||
|
|
||||||
|
datos_agrupados[mat_factura]["Resta_Ant"] += resta_ant
|
||||||
|
datos_agrupados[mat_factura]["Resta_Cur"] += resta_cur
|
||||||
|
datos_agrupados[mat_factura]["Cob_Ant"] += cobrado_ant
|
||||||
|
datos_agrupados[mat_factura]["Cob_Cur"] += cobrado_curso
|
||||||
|
|
||||||
|
datos_limpios = []
|
||||||
|
for mat, totales in datos_agrupados.items():
|
||||||
|
cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"]
|
||||||
|
cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"]
|
||||||
|
total_cta = cta_cob_ant + cta_cob_curso
|
||||||
|
|
||||||
|
cob_ant = totales["Cob_Ant"]
|
||||||
|
cob_cur = totales["Cob_Cur"]
|
||||||
|
total_cob = cob_ant + cob_cur
|
||||||
|
saldo = total_cta - total_cob
|
||||||
|
|
||||||
|
if round(total_cta, 2) <= 0 and round(total_cob, 2) <= 0: continue
|
||||||
|
|
||||||
|
cuota_final = totales["Cuota_Mes"]
|
||||||
|
fecha_final = totales["Fch_Venc_Mes"]
|
||||||
|
if round(saldo, 2) <= 0:
|
||||||
|
cuota_final = "-"
|
||||||
|
fecha_final = "-"
|
||||||
|
|
||||||
|
datos_limpios.append({
|
||||||
|
'MATRICULA': mat, 'ALUMNO': totales["Alumno"],
|
||||||
|
'PROGRAMA': totales["Programa"], 'FRECUENCIA': totales["Frecuencia"],
|
||||||
|
'NUM_CUOTA': cuota_final,
|
||||||
|
'FCH_VENC': fecha_final,
|
||||||
|
'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta,
|
||||||
|
'COB_ANT': cob_ant, 'COB_MES_CURSO': cob_cur, 'TOTAL_COBRADO': total_cob, 'SALDO': saldo
|
||||||
|
})
|
||||||
|
|
||||||
|
def orden_personalizado(x):
|
||||||
|
fecha_str = x['FCH_VENC']
|
||||||
|
if fecha_str == "-":
|
||||||
|
return (0, datetime.min, x['ALUMNO'])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(fecha_str, '%d/%m/%Y')
|
||||||
|
return (1, dt, x['ALUMNO'])
|
||||||
|
except:
|
||||||
|
return (1, datetime.max, x['ALUMNO'])
|
||||||
|
|
||||||
|
return sorted(datos_limpios, key=orden_personalizado)
|
||||||
0
backend/modules/ocupabilidad/__init__.py
Normal file
0
backend/modules/ocupabilidad/__init__.py
Normal file
237
backend/modules/ocupabilidad/logic.py
Normal file
237
backend/modules/ocupabilidad/logic.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# modules/ocupabilidad/logic.py
|
||||||
|
import pandas as pd
|
||||||
|
from .processor import CursoProcessor
|
||||||
|
|
||||||
|
class AnalizadorCursos:
|
||||||
|
"""Analizador de cursos - Lógica de cálculo y estadísticas"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.curso_processor = CursoProcessor(self.data_manager)
|
||||||
|
self.columnas = self.curso_processor.columnas
|
||||||
|
|
||||||
|
def obtener_sedes(self):
|
||||||
|
"""Devuelve la lista de sedes para el nuevo filtro"""
|
||||||
|
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
|
||||||
|
|
||||||
|
def identificar_sede(self, dsc_programa):
|
||||||
|
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
|
||||||
|
if not dsc_programa: return "LIMA"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
|
||||||
|
for sede, data in sedes_data.items():
|
||||||
|
if sede == "DEFAULT": continue
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper: return sede
|
||||||
|
return sedes_data.get("DEFAULT", "LIMA")
|
||||||
|
|
||||||
|
def obtener_filtros_programa(self):
|
||||||
|
"""Devuelve la lista de programas para el nuevo filtro combinado"""
|
||||||
|
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||||
|
|
||||||
|
def identificar_filtro_programa(self, dsc_programa):
|
||||||
|
"""Identifica la categoría del programa para el filtro de la UI"""
|
||||||
|
if not dsc_programa: return "OTROS"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
|
||||||
|
for categoria, data in filtro_data.items():
|
||||||
|
if categoria == "DEFAULT": continue
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper: return categoria
|
||||||
|
return filtro_data.get("DEFAULT", "OTROS")
|
||||||
|
|
||||||
|
def get_current_month(self):
|
||||||
|
return self.data_manager.get_current_month()
|
||||||
|
|
||||||
|
def get_current_year(self):
|
||||||
|
return self.data_manager.get_current_year()
|
||||||
|
|
||||||
|
def actualizar_configuracion(self):
|
||||||
|
print("🔄 Actualización automática de configuración...")
|
||||||
|
self.data_manager.cargar_toda_configuracion()
|
||||||
|
|
||||||
|
# --- AGREGADO EL PARÁMETRO "TIPO" ---
|
||||||
|
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
|
||||||
|
# Sincronizamos el estado del toggle UI con el procesador
|
||||||
|
self.curso_processor.mostrar_reprogramados = getattr(self, 'mostrar_reprogramados', True)
|
||||||
|
|
||||||
|
datos = self.curso_processor.obtener_datos_procesados(ano, mes)
|
||||||
|
programas_disponibles = set()
|
||||||
|
|
||||||
|
if datos:
|
||||||
|
for d in datos:
|
||||||
|
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
|
||||||
|
# Inyectamos variables para el filtro UI
|
||||||
|
d['Sede'] = self.identificar_sede(prog)
|
||||||
|
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
|
||||||
|
|
||||||
|
if sede == "TODOS" or d['Sede'] == sede:
|
||||||
|
programas_disponibles.add(d['Filtro_Programa'])
|
||||||
|
|
||||||
|
# Ordenar lista dinámica
|
||||||
|
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||||
|
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
|
||||||
|
|
||||||
|
# Auto-corrección si el programa no existe en la sede actual
|
||||||
|
if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles:
|
||||||
|
filtro_prog = "TODOS"
|
||||||
|
self.filtro_corregido = "TODOS"
|
||||||
|
else:
|
||||||
|
self.filtro_corregido = None
|
||||||
|
|
||||||
|
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
|
||||||
|
datos_filtrados = []
|
||||||
|
for d in datos:
|
||||||
|
cumple_sede = (sede == "TODOS" or d.get('Sede') == sede)
|
||||||
|
cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog)
|
||||||
|
if cumple_sede and cumple_prog:
|
||||||
|
datos_filtrados.append(d)
|
||||||
|
return datos_filtrados
|
||||||
|
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def formatear_datos_para_tabla(self, datos):
|
||||||
|
if not datos:
|
||||||
|
return [], {}
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
totales = {
|
||||||
|
'mes': 0, 'retirados': 0, 'totales': 0,
|
||||||
|
'activos': 0, 'pc': 0, 'continuidad': 0, 'meta': 0,
|
||||||
|
'descuento': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for registro in datos:
|
||||||
|
fila = []
|
||||||
|
for col in self.columnas:
|
||||||
|
if col != 'Avance_Inscritos':
|
||||||
|
valor = registro.get(col, '')
|
||||||
|
fila.append(str(valor))
|
||||||
|
self._acumular_totales(totales, col, registro)
|
||||||
|
else:
|
||||||
|
total_insc = registro.get('Inscritos_Totales', 0)
|
||||||
|
meta = registro.get('Meta_Curso', 0)
|
||||||
|
|
||||||
|
if total_insc == "-" or meta == "-":
|
||||||
|
fila.append("-")
|
||||||
|
else:
|
||||||
|
porcentaje = self.calcular_porcentaje_avance(total_insc, meta)
|
||||||
|
fila.append(self.crear_barra_texto_color(porcentaje))
|
||||||
|
|
||||||
|
sheet_data.append(fila)
|
||||||
|
|
||||||
|
fila_totales = self.crear_fila_totales(totales)
|
||||||
|
sheet_data.append(fila_totales)
|
||||||
|
|
||||||
|
return sheet_data, totales
|
||||||
|
|
||||||
|
def _acumular_totales(self, totales, col, registro):
|
||||||
|
val = registro.get(col, 0)
|
||||||
|
if val == "-": return # Ignoramos los guiones para que no rompa la suma
|
||||||
|
|
||||||
|
if col == 'Descuento':
|
||||||
|
totales['descuento'] += val
|
||||||
|
elif col == 'Inscritos_Mes':
|
||||||
|
totales['mes'] += val
|
||||||
|
elif col == 'Retirados':
|
||||||
|
totales['retirados'] += val
|
||||||
|
elif col == 'Inscritos_Totales':
|
||||||
|
totales['totales'] += val
|
||||||
|
elif col == 'Inscritos_Activos':
|
||||||
|
totales['activos'] += val
|
||||||
|
elif col == 'Inscritos_PC':
|
||||||
|
totales['pc'] += val
|
||||||
|
elif col == 'Inscritos_Continuidad':
|
||||||
|
totales['continuidad'] += val
|
||||||
|
elif col == 'Meta_Curso':
|
||||||
|
totales['meta'] += val
|
||||||
|
|
||||||
|
def crear_fila_totales(self, totales):
|
||||||
|
porcentaje = self.calcular_porcentaje_avance(totales['totales'], totales['meta'])
|
||||||
|
# REORGANIZADO SEGÚN NUEVO ORDEN DE COLUMNAS SOLICITADO:
|
||||||
|
# [0]Prog, [1]Fch, [2]Dias, [3]Mes, [4]Total, [5]Retirados, [6]Activos, [7]PC, [8]Refriperu, [9]Cont, [10]Meta, [11]Avance
|
||||||
|
return [
|
||||||
|
"TOTAL GENERAL", # PROGRAMA
|
||||||
|
"", # FECHA INICIO
|
||||||
|
"", # DIAS PARA INICIO
|
||||||
|
str(totales['mes']), # INSCRITOS MES
|
||||||
|
str(totales['totales']), # TOTAL INSCRITOS
|
||||||
|
str(totales['retirados']), # RETIRADOS
|
||||||
|
str(totales['activos']), # INSCRITOS EN CURSO
|
||||||
|
str(totales['pc']), # INSCRITOS P.C
|
||||||
|
str(totales['descuento']), # INSCRITOS REFRIPERU (Descuento)
|
||||||
|
str(totales['continuidad']), # INSCRITOS CONTINUIDAD
|
||||||
|
str(totales['meta']), # META
|
||||||
|
self.crear_barra_texto_color(porcentaje) # AVANCE INSCRITOS
|
||||||
|
]
|
||||||
|
|
||||||
|
def calcular_porcentaje_avance(self, activos, meta):
|
||||||
|
return (activos / meta * 100) if meta > 0 else 0
|
||||||
|
|
||||||
|
def crear_barra_texto_color(self, porcentaje):
|
||||||
|
longitud = 10
|
||||||
|
llenas = int((min(porcentaje, 100) / 100) * longitud)
|
||||||
|
vacias = longitud - llenas
|
||||||
|
barra = '█' * llenas + '░' * vacias
|
||||||
|
porc_text = f"{porcentaje:.1f}%"
|
||||||
|
espacios = " " * (6 - len(porc_text))
|
||||||
|
return f"{porc_text}{espacios}{barra}"
|
||||||
|
|
||||||
|
def get_intervalo_actualizacion(self):
|
||||||
|
return self.data_manager.config_data.get(
|
||||||
|
'config_general', {}
|
||||||
|
).get('auto_update_minutos', 5) * 60000
|
||||||
|
|
||||||
|
def exportar_a_excel(self, datos, ano, mes):
|
||||||
|
df = pd.DataFrame(datos)
|
||||||
|
df_exportar = df[self.columnas]
|
||||||
|
archivo = f"cursos_{ano}_{mes}.xlsx"
|
||||||
|
df_exportar.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def calcular_metricas_generales(self, datos):
|
||||||
|
if not datos: return {}
|
||||||
|
# Filtramos las filas fantasmas para no romper la matemática de Pandas
|
||||||
|
datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO']
|
||||||
|
if not datos_validos: return {}
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos_validos)
|
||||||
|
metricas = {
|
||||||
|
'total_cursos': len(datos), # Mostramos el total real de filas (incluyendo fantasmas)
|
||||||
|
'total_inscritos': df['Inscritos_Activos'].sum(),
|
||||||
|
'total_meta': df['Meta_Curso'].sum(),
|
||||||
|
'porcentaje_avance_general': (df['Inscritos_Activos'].sum() / df['Meta_Curso'].sum() * 100) if df['Meta_Curso'].sum() > 0 else 0,
|
||||||
|
'cursos_sobre_meta': len(df[df['Inscritos_Activos'] >= df['Meta_Curso']]),
|
||||||
|
'cursos_bajo_meta': len(df[df['Inscritos_Activos'] < df['Meta_Curso']]),
|
||||||
|
'total_inscritos_pc': df['Inscritos_PC'].sum()
|
||||||
|
}
|
||||||
|
return metricas
|
||||||
|
|
||||||
|
def obtener_top_programas(self, datos, top_n=5):
|
||||||
|
if not datos: return []
|
||||||
|
# Filtramos las filas fantasmas
|
||||||
|
datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO']
|
||||||
|
if not datos_validos: return []
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos_validos)
|
||||||
|
df['Porcentaje_Avance'] = df.apply(
|
||||||
|
lambda x: self.calcular_porcentaje_avance(x['Inscritos_Activos'], x['Meta_Curso']),
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
top_programas = df.nlargest(top_n, 'Porcentaje_Avance')[
|
||||||
|
['programa_frecuencia', 'Inscritos_Activos', 'Meta_Curso', 'Porcentaje_Avance']
|
||||||
|
].to_dict('records')
|
||||||
|
return top_programas
|
||||||
|
|
||||||
|
def calcular_tendencias_mensuales(self, ano):
|
||||||
|
tendencias = {}
|
||||||
|
for mes in range(1, 13):
|
||||||
|
try:
|
||||||
|
datos_mes = self.obtener_datos_procesados(str(ano), str(mes))
|
||||||
|
if datos_mes:
|
||||||
|
metricas = self.calcular_metricas_generales(datos_mes)
|
||||||
|
tendencias[mes] = metricas
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error procesando mes {mes}: {e}")
|
||||||
|
continue
|
||||||
|
return tendencias
|
||||||
560
backend/modules/ocupabilidad/processor.py
Normal file
560
backend/modules/ocupabilidad/processor.py
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
# modules/ocupabilidad/processor.py
|
||||||
|
from datetime import datetime
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
class CursoProcessor:
|
||||||
|
"""Procesador de datos de cursos - Aplica transformaciones y cálculos"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
|
||||||
|
# --- COLUMNAS REORDENADAS Y REORGANIZADAS ---
|
||||||
|
self.columnas = [
|
||||||
|
'programa_frecuencia',
|
||||||
|
'fch_inicio',
|
||||||
|
'dias_para_inicio',
|
||||||
|
'Inscritos_Mes',
|
||||||
|
'Inscritos_Totales',
|
||||||
|
'Retirados',
|
||||||
|
'Inscritos_Activos',
|
||||||
|
'Inscritos_PC',
|
||||||
|
'Descuento', # Esto es 'INSCRITOS REFRIPERU'
|
||||||
|
'Inscritos_Continuidad',
|
||||||
|
'Meta_Curso',
|
||||||
|
'Avance_Inscritos'
|
||||||
|
]
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes):
|
||||||
|
try:
|
||||||
|
# 1. Cargar datos básicos
|
||||||
|
datos_matriculas = self.data_manager.cargar_datos_matriculas(ano, mes)
|
||||||
|
datos_raw_matriculas = self.data_manager.ejecutar_consulta_matriculados_detalle(ano, mes)
|
||||||
|
datos_originales = self.data_manager.ejecutar_consulta_cursos(ano, mes)
|
||||||
|
|
||||||
|
# --- NUEVO: EL GUARDIÁN DE FECHAS (SUPABASE) ---
|
||||||
|
fechas_historicas = self.data_manager.obtener_fechas_originales()
|
||||||
|
indices_en_sql = set()
|
||||||
|
|
||||||
|
for fila in datos_originales:
|
||||||
|
indice = str(fila.get('num_indice', '')).strip()
|
||||||
|
fecha_actual = fila.get('fch_inicio')
|
||||||
|
programa = fila.get('dsc_programa', '')
|
||||||
|
|
||||||
|
if indice and fecha_actual:
|
||||||
|
indices_en_sql.add(indice) # Registramos que sí vino en SQL este mes
|
||||||
|
if indice not in fechas_historicas:
|
||||||
|
fecha_str = fecha_actual.strftime('%d/%m/%Y') if isinstance(fecha_actual, datetime) else str(fecha_actual)
|
||||||
|
exito = self.data_manager.guardar_fecha_original(indice, programa, fecha_str)
|
||||||
|
if exito:
|
||||||
|
fechas_historicas[indice] = {'fecha': fecha_str, 'programa': programa}
|
||||||
|
# -----------------------------------------------
|
||||||
|
|
||||||
|
# 2. CONTINUIDAD
|
||||||
|
lista_dnis = []
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for alumno in datos_raw_matriculas:
|
||||||
|
dni = alumno.get('dsc_documento')
|
||||||
|
if dni: lista_dnis.append(dni)
|
||||||
|
|
||||||
|
datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||||
|
|
||||||
|
# 3. Procesar todo
|
||||||
|
datos_procesados = self.aplicar_personalizaciones(
|
||||||
|
datos_originales,
|
||||||
|
datos_matriculas,
|
||||||
|
datos_raw_matriculas,
|
||||||
|
datos_historial,
|
||||||
|
ano, mes
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- INYECTAR CURSOS REPROGRAMADOS (FILAS FANTASMAS) ---
|
||||||
|
if getattr(self, 'mostrar_reprogramados', True):
|
||||||
|
for idx, info in fechas_historicas.items():
|
||||||
|
if idx not in indices_en_sql: # Si el curso NO vino este mes desde SQL...
|
||||||
|
# Aseguramos compatibilidad si guardaste data vieja como texto o la nueva como diccionario
|
||||||
|
if isinstance(info, dict):
|
||||||
|
fecha_orig = info.get('fecha', '')
|
||||||
|
prog_orig = info.get('programa', 'CURSO REPROGRAMADO')
|
||||||
|
else:
|
||||||
|
fecha_orig = str(info)
|
||||||
|
prog_orig = 'CURSO REPROGRAMADO'
|
||||||
|
|
||||||
|
# Verificamos si la fecha original correspondía a ESTE mes Y AÑO filtrado
|
||||||
|
mes_orig = ""
|
||||||
|
ano_orig = ""
|
||||||
|
if "/" in fecha_orig:
|
||||||
|
partes = fecha_orig.split("/")
|
||||||
|
if len(partes) >= 2: mes_orig = str(int(partes[1]))
|
||||||
|
if len(partes) >= 3: ano_orig = str(int(partes[2]))
|
||||||
|
elif "-" in fecha_orig:
|
||||||
|
partes = fecha_orig.split("-")
|
||||||
|
if len(partes) >= 2: mes_orig = str(int(partes[1]))
|
||||||
|
if len(partes) >= 3: ano_orig = str(int(partes[2]))
|
||||||
|
|
||||||
|
# Mes debe coincidir; y si la fecha tiene año, el año también debe coincidir.
|
||||||
|
# Si la fecha NO tiene año (formato viejo dd/mm), no se inyecta para evitar
|
||||||
|
# mostrarla en años equivocados.
|
||||||
|
coincide_mes = (mes_orig == str(int(mes)))
|
||||||
|
coincide_ano = (ano_orig == str(int(ano))) if ano_orig else False
|
||||||
|
if coincide_mes and coincide_ano:
|
||||||
|
# Inyectamos la fila falsa para alertar en pantalla
|
||||||
|
datos_procesados.append({
|
||||||
|
'num_indice': idx,
|
||||||
|
'programa_frecuencia': prog_orig,
|
||||||
|
'dsc_programa': prog_orig,
|
||||||
|
'fch_inicio': fecha_orig,
|
||||||
|
'dias_para_inicio': 'REPROGRAMADO',
|
||||||
|
'Inscritos_Mes': "-",
|
||||||
|
'Inscritos_Totales': "-",
|
||||||
|
'Retirados': "-",
|
||||||
|
'Inscritos_Activos': "-",
|
||||||
|
'Inscritos_PC': "-",
|
||||||
|
'Descuento': "-",
|
||||||
|
'Inscritos_Continuidad': "-",
|
||||||
|
'Meta_Curso': "-",
|
||||||
|
'Avance_Inscritos': "-"
|
||||||
|
})
|
||||||
|
# -----------------------------------------------
|
||||||
|
|
||||||
|
## --- ORDENAR: NORMALES POR FECHA, REPROGRAMADOS POR SEDE ---
|
||||||
|
def parse_fecha_orden(fecha_str):
|
||||||
|
if not fecha_str or fecha_str == "-":
|
||||||
|
return datetime.max
|
||||||
|
s = str(fecha_str).replace("/", "-").strip()
|
||||||
|
try:
|
||||||
|
if len(s) == 5:
|
||||||
|
return datetime.strptime(f"{s}-{ano}", "%d-%m-%Y")
|
||||||
|
if len(s) >= 10:
|
||||||
|
return datetime.strptime(s[:10], "%d-%m-%Y")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return datetime.max
|
||||||
|
|
||||||
|
def obtener_orden_sede(programa):
|
||||||
|
prog_upper = str(programa).upper()
|
||||||
|
if 'LIMA' in prog_upper: return 1
|
||||||
|
if 'PIURA' in prog_upper: return 2
|
||||||
|
if 'TRUJILLO' in prog_upper: return 3
|
||||||
|
if 'AREQUIPA' in prog_upper: return 4
|
||||||
|
return 5
|
||||||
|
|
||||||
|
def logica_ordenamiento(x):
|
||||||
|
if x.get('dias_para_inicio') == 'REPROGRAMADO':
|
||||||
|
# Reprogramados: Van al final (1), ordenados por sede, luego alfabéticamente
|
||||||
|
return (1, obtener_orden_sede(x.get('dsc_programa', '')), x.get('dsc_programa', ''))
|
||||||
|
else:
|
||||||
|
# Normales: Van al inicio (0), ordenados por fecha
|
||||||
|
return (0, 0, parse_fecha_orden(x.get('fch_inicio', '')))
|
||||||
|
|
||||||
|
# Aplicamos el ordenamiento inteligente
|
||||||
|
datos_procesados.sort(key=logica_ordenamiento)
|
||||||
|
|
||||||
|
return datos_procesados
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error obteniendo datos procesados: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def normalizar_texto(self, texto):
|
||||||
|
if not texto: return ""
|
||||||
|
texto = str(texto).upper().strip()
|
||||||
|
texto = unicodedata.normalize('NFD', texto)
|
||||||
|
texto = texto.encode('ascii', 'ignore').decode("utf-8")
|
||||||
|
return texto
|
||||||
|
|
||||||
|
def obtener_categoria_programa(self, nombre_programa):
|
||||||
|
try:
|
||||||
|
nombre_limpio = self.normalizar_texto(nombre_programa)
|
||||||
|
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
|
||||||
|
|
||||||
|
for categoria, config in clasificaciones.items():
|
||||||
|
patrones = config.get('patrones', [])
|
||||||
|
for patron in patrones:
|
||||||
|
patron_limpio = self.normalizar_texto(patron)
|
||||||
|
if patron_limpio in nombre_limpio:
|
||||||
|
return categoria
|
||||||
|
|
||||||
|
return "OTROS"
|
||||||
|
except:
|
||||||
|
return "OTROS"
|
||||||
|
|
||||||
|
def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro):
|
||||||
|
print("🎯 Aplicando personalizaciones...")
|
||||||
|
cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {})
|
||||||
|
datos_filtrados = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
ano_target = int(ano_filtro)
|
||||||
|
mes_target = int(mes_filtro)
|
||||||
|
except ValueError:
|
||||||
|
ano_target = 0
|
||||||
|
mes_target = 0
|
||||||
|
|
||||||
|
# 1. FILTRADO
|
||||||
|
for curso in datos_originales:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
|
||||||
|
if num_indice in cursos_personalizados:
|
||||||
|
personalizacion = cursos_personalizados[num_indice]
|
||||||
|
if 'fch_inicio' in personalizacion:
|
||||||
|
curso['fch_inicio'] = personalizacion['fch_inicio']
|
||||||
|
if 'flg_activo' in personalizacion:
|
||||||
|
curso['flg_activo'] = personalizacion['flg_activo']
|
||||||
|
if 'dsc_programa' in personalizacion:
|
||||||
|
curso['dsc_programa'] = personalizacion['dsc_programa']
|
||||||
|
if 'dsc_det_programa' in personalizacion:
|
||||||
|
curso['dsc_det_programa'] = personalizacion['dsc_det_programa']
|
||||||
|
|
||||||
|
if curso.get('flg_activo', '') == 'NO':
|
||||||
|
continue
|
||||||
|
|
||||||
|
fch_valida = True
|
||||||
|
try:
|
||||||
|
raw_fecha = curso.get('fch_inicio')
|
||||||
|
if raw_fecha:
|
||||||
|
f_obj = None
|
||||||
|
s_fecha = str(raw_fecha).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-' and s_fecha[5] == '-':
|
||||||
|
f_obj = datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
elif '-' in s_fecha:
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
else:
|
||||||
|
f_obj = datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
|
||||||
|
if f_obj:
|
||||||
|
if f_obj.year != ano_target or f_obj.month != mes_target:
|
||||||
|
fch_valida = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if fch_valida:
|
||||||
|
datos_filtrados.append(curso)
|
||||||
|
|
||||||
|
# 2. TRANSFORMACIONES
|
||||||
|
datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados)
|
||||||
|
datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados)
|
||||||
|
|
||||||
|
for curso in datos_filtrados:
|
||||||
|
if 'fch_inicio' in curso:
|
||||||
|
curso['fch_inicio'] = self.formatear_fecha(curso['fch_inicio'])
|
||||||
|
|
||||||
|
# 3. DATOS CALCULADOS CON PRIORIDAD (REFRIPERU > CONTINUIDAD)
|
||||||
|
datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas)
|
||||||
|
datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu)
|
||||||
|
|
||||||
|
datos_filtrados = self.calcular_dias_para_inicio_cursos(datos_filtrados)
|
||||||
|
datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas)
|
||||||
|
datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas)
|
||||||
|
datos_filtrados = self.agregar_meta_curso(datos_filtrados)
|
||||||
|
datos_filtrados = self.agregar_avance_inscritos(datos_filtrados)
|
||||||
|
|
||||||
|
return datos_filtrados
|
||||||
|
|
||||||
|
def aplicar_reemplazos_programas(self, datos):
|
||||||
|
if 'reemplazos_programas' in self.data_manager.replace_data:
|
||||||
|
reemplazos = self.data_manager.replace_data['reemplazos_programas']
|
||||||
|
for curso in datos:
|
||||||
|
programa_original = curso.get('dsc_programa', '')
|
||||||
|
if programa_original in reemplazos:
|
||||||
|
curso['dsc_programa'] = reemplazos[programa_original]
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def concatenar_programa_frecuencia(self, datos):
|
||||||
|
for curso in datos:
|
||||||
|
programa = curso.get('dsc_programa', '')
|
||||||
|
frecuencia = curso.get('cod_frecuencia', '')
|
||||||
|
if programa and frecuencia:
|
||||||
|
curso['programa_frecuencia'] = f"{programa} - {frecuencia}"
|
||||||
|
elif programa:
|
||||||
|
curso['programa_frecuencia'] = programa
|
||||||
|
elif frecuencia:
|
||||||
|
curso['programa_frecuencia'] = frecuencia
|
||||||
|
else:
|
||||||
|
curso['programa_frecuencia'] = ""
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def formatear_fecha(self, fecha_str):
|
||||||
|
try:
|
||||||
|
if not fecha_str: return ""
|
||||||
|
if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-': return fecha_str
|
||||||
|
if '.' in str(fecha_str):
|
||||||
|
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y')
|
||||||
|
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y')
|
||||||
|
except: return str(fecha_str)
|
||||||
|
|
||||||
|
def identificar_linea_carrera(self, nombre_programa):
|
||||||
|
categoria = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
|
||||||
|
# --- AQUÍ ESTÁ EL CAMBIO ---
|
||||||
|
# Si es cualquiera de estas 5, las unimos bajo una misma "Línea Universal" de compatibilidad
|
||||||
|
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
|
||||||
|
return "CARRERA_COMPATIBLE"
|
||||||
|
|
||||||
|
# Si es GESTION, MASTERCLASS, SEMINARIOS, etc., retorna None (las sigue ignorando)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-':
|
||||||
|
return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# ❄️ REFRIPERU / DESCUENTO (ALTA PRIORIDAD)
|
||||||
|
# =========================================================================
|
||||||
|
def agregar_descuento(self, datos, datos_raw_matriculas):
|
||||||
|
print("\n💰 Calculando REFRIPERU (Descuentos)...")
|
||||||
|
print("="*80)
|
||||||
|
print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA':<20} | {'PAGO':<10} | {'ZONA/MOTIVO'}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
conteo_por_curso = {}
|
||||||
|
alumnos_refriperu = set()
|
||||||
|
|
||||||
|
correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||||
|
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for alumno in datos_raw_matriculas:
|
||||||
|
try:
|
||||||
|
estado = str(alumno.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado not in ['ALU', 'PRE']: continue
|
||||||
|
|
||||||
|
num_indice = str(alumno.get('num_indice', ''))
|
||||||
|
|
||||||
|
dni = str(alumno.get('dsc_documento', 'SD')).strip()
|
||||||
|
nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip()
|
||||||
|
|
||||||
|
mat_id = str(alumno.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
|
||||||
|
if mat_id in correcciones_descuento:
|
||||||
|
accion = str(correcciones_descuento[mat_id]).upper().strip()
|
||||||
|
if accion == "SI":
|
||||||
|
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||||
|
alumnos_refriperu.add(mat_id)
|
||||||
|
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {'---':<20} | {'---':<10} | MANUAL (JSON)")
|
||||||
|
continue
|
||||||
|
elif accion == "NO":
|
||||||
|
continue
|
||||||
|
|
||||||
|
nombre_programa = str(alumno.get('dsc_programa', ''))
|
||||||
|
|
||||||
|
cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
try:
|
||||||
|
inv_neta = float(alumno.get('INV_NETA', 0) or 0)
|
||||||
|
imp_tc = float(alumno.get('imp_tc', 1) or 1)
|
||||||
|
if imp_tc <= 0: imp_tc = 1.0
|
||||||
|
except:
|
||||||
|
inv_neta = 0.0; imp_tc = 1.0
|
||||||
|
|
||||||
|
if cod_moneda == 'DOL':
|
||||||
|
inv_neta_final = inv_neta * imp_tc
|
||||||
|
else:
|
||||||
|
inv_neta_final = inv_neta
|
||||||
|
|
||||||
|
inv_neta_final = round(inv_neta_final, 2)
|
||||||
|
|
||||||
|
categoria_json = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
es_descuento = False
|
||||||
|
motivo_debug = ""
|
||||||
|
|
||||||
|
if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"]:
|
||||||
|
if 1200 <= inv_neta_final <= 1700:
|
||||||
|
es_descuento = True
|
||||||
|
motivo_debug = f"{categoria_json} (Rango Prov)"
|
||||||
|
|
||||||
|
elif categoria_json in ["TEAC", "TERC"]:
|
||||||
|
if 1400 <= inv_neta_final <= 1900:
|
||||||
|
es_descuento = True
|
||||||
|
motivo_debug = f"{categoria_json} (Rango Lima)"
|
||||||
|
|
||||||
|
if es_descuento:
|
||||||
|
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||||
|
alumnos_refriperu.add(mat_id)
|
||||||
|
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_programa[:20]:<20} | S/{inv_neta_final:<8} | {motivo_debug}")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for curso in datos:
|
||||||
|
idx = str(curso.get('num_indice', ''))
|
||||||
|
curso['Descuento'] = conteo_por_curso.get(idx, 0)
|
||||||
|
|
||||||
|
return datos, alumnos_refriperu
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 🔄 CONTINUIDAD (BAJA PRIORIDAD)
|
||||||
|
# =========================================================================
|
||||||
|
def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu=None):
|
||||||
|
if alumnos_refriperu is None:
|
||||||
|
alumnos_refriperu = set()
|
||||||
|
|
||||||
|
print("🔄 Calculando CONTINUIDAD...")
|
||||||
|
print("="*80)
|
||||||
|
print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA ACTUAL':<30} | {'MOTIVO/ORIGEN'}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
conteo_por_curso = {}
|
||||||
|
|
||||||
|
correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||||
|
MIN_DIAS_DIFERENCIA = 60
|
||||||
|
|
||||||
|
if datos_raw_matriculas and datos_historial:
|
||||||
|
alumnos_por_curso = {}
|
||||||
|
for alu in datos_raw_matriculas:
|
||||||
|
idx = str(alu.get('num_indice', ''))
|
||||||
|
if idx not in alumnos_por_curso: alumnos_por_curso[idx] = []
|
||||||
|
alumnos_por_curso[idx].append(alu)
|
||||||
|
|
||||||
|
for curso in datos:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
nombre_curso_actual = str(curso.get('dsc_programa', ''))
|
||||||
|
fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio'))
|
||||||
|
|
||||||
|
# Identifica si el curso actual pertenece a la "Línea Universal" de 5 categorías
|
||||||
|
linea_actual = self.identificar_linea_carrera(nombre_curso_actual)
|
||||||
|
|
||||||
|
if not linea_actual or not fecha_inicio_actual: continue
|
||||||
|
|
||||||
|
lista_alumnos = alumnos_por_curso.get(num_indice, [])
|
||||||
|
contador_fieles = 0
|
||||||
|
|
||||||
|
for alumno in lista_alumnos:
|
||||||
|
estado = str(alumno.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado not in ['ALU', 'PRE']: continue
|
||||||
|
|
||||||
|
mat_id = str(alumno.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
|
||||||
|
if mat_id in alumnos_refriperu:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dni = str(alumno.get('dsc_documento', 'SD')).strip()
|
||||||
|
nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip()
|
||||||
|
|
||||||
|
es_manual_si = False
|
||||||
|
if mat_id in correcciones_continuidad:
|
||||||
|
accion = str(correcciones_continuidad[mat_id]).upper().strip()
|
||||||
|
if accion == "SI": es_manual_si = True
|
||||||
|
elif accion == "NO": continue
|
||||||
|
|
||||||
|
es_fiel = False
|
||||||
|
motivo = ""
|
||||||
|
|
||||||
|
if es_manual_si:
|
||||||
|
es_fiel = True
|
||||||
|
motivo = "MANUAL (JSON)"
|
||||||
|
elif dni and dni in datos_historial:
|
||||||
|
historial_alumno = datos_historial[dni]
|
||||||
|
for antecedente in historial_alumno:
|
||||||
|
nombre_pasado = str(antecedente.get('programa', ''))
|
||||||
|
fecha_pasada = antecedente.get('fecha')
|
||||||
|
if not fecha_pasada: continue
|
||||||
|
if not (fecha_pasada < fecha_inicio_actual): continue
|
||||||
|
dias_diff = (fecha_inicio_actual - fecha_pasada).days
|
||||||
|
if dias_diff < MIN_DIAS_DIFERENCIA: continue
|
||||||
|
|
||||||
|
# Revisa si el curso pasado también es de esa misma línea universal
|
||||||
|
linea_pasada = self.identificar_linea_carrera(nombre_pasado)
|
||||||
|
|
||||||
|
# Si ambos devuelven "CARRERA_COMPATIBLE", entonces hacen match
|
||||||
|
if linea_pasada == linea_actual:
|
||||||
|
es_fiel = True
|
||||||
|
motivo = f"Viene de: {nombre_pasado}"
|
||||||
|
break
|
||||||
|
|
||||||
|
if es_fiel:
|
||||||
|
contador_fieles += 1
|
||||||
|
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_curso_actual[:30]:<30} | {motivo}")
|
||||||
|
|
||||||
|
conteo_por_curso[num_indice] = contador_fieles
|
||||||
|
|
||||||
|
for curso in datos:
|
||||||
|
idx = str(curso.get('num_indice', ''))
|
||||||
|
curso['Inscritos_Continuidad'] = conteo_por_curso.get(idx, 0)
|
||||||
|
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def calcular_dias_para_inicio(self, fecha_str, cod_estado):
|
||||||
|
try:
|
||||||
|
if cod_estado == 'SUS': return "SUSPENDIDO"
|
||||||
|
if not fecha_str: return ""
|
||||||
|
if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-':
|
||||||
|
fecha_curso = datetime.strptime(fecha_str, '%d-%m-%Y')
|
||||||
|
else:
|
||||||
|
fecha_curso = datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S')
|
||||||
|
hoy = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
fecha_curso = fecha_curso.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
diferencia = (fecha_curso - hoy).days
|
||||||
|
if diferencia < 0: return "INICIADO"
|
||||||
|
return str(diferencia) if diferencia != 0 else "0"
|
||||||
|
except: return ""
|
||||||
|
|
||||||
|
def calcular_dias_para_inicio_cursos(self, datos):
|
||||||
|
for curso in datos:
|
||||||
|
curso['dias_para_inicio'] = self.calcular_dias_para_inicio(curso.get('fch_inicio'), curso.get('cod_estado'))
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def agregar_inscritos_mes(self, datos, datos_matriculas):
|
||||||
|
dict_norm = {str(k): v for k, v in datos_matriculas.items()}
|
||||||
|
for curso in datos:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
curso['Inscritos_Mes'] = dict_norm.get(num_indice, 0)
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def agregar_inscritos_pc(self, datos, datos_raw_matriculas):
|
||||||
|
conteo_pc = {}
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for m in datos_raw_matriculas:
|
||||||
|
try:
|
||||||
|
est = str(m.get('estado_matricula', '')).strip()
|
||||||
|
nid = str(m.get('num_indice', ''))
|
||||||
|
sm = float(m.get('imp_saldo_matricula', 0) or 0)
|
||||||
|
sc1 = float(m.get('imp_saldo_cuota1', 0) or 0)
|
||||||
|
if est in ['ALU', 'PRE'] and sm < 1 and sc1 < 1:
|
||||||
|
conteo_pc[nid] = conteo_pc.get(nid, 0) + 1
|
||||||
|
except: continue
|
||||||
|
for curso in datos:
|
||||||
|
curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
curso['Retirados'] = int(curso.get('Inscritos_Retirados', 0)) # <--- NUEVA EXTRACCIÓN
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def calcular_meta_curso(self, nombre_programa, cod_estado):
|
||||||
|
try:
|
||||||
|
if cod_estado == 'SUS': return 0
|
||||||
|
|
||||||
|
categoria = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
|
||||||
|
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
|
||||||
|
if categoria in clasificaciones:
|
||||||
|
return clasificaciones[categoria].get('valor', 0)
|
||||||
|
|
||||||
|
return self.data_manager.meta_data.get('clasificacion_default', {}).get('valor', 15)
|
||||||
|
except: return 0
|
||||||
|
|
||||||
|
def agregar_meta_curso(self, datos):
|
||||||
|
print("🎯 Calculando META_CURSO...")
|
||||||
|
for curso in datos:
|
||||||
|
curso['Meta_Curso'] = self.calcular_meta_curso(curso.get('dsc_programa', ''), curso.get('cod_estado', ''))
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def calcular_avance_inscritos(self, inscritos_activos, meta_curso):
|
||||||
|
try:
|
||||||
|
if meta_curso > 0:
|
||||||
|
porcentaje = (inscritos_activos / meta_curso) * 100
|
||||||
|
return f"{porcentaje:.1f}%"
|
||||||
|
else: return "0%"
|
||||||
|
except: return "0%"
|
||||||
|
|
||||||
|
def agregar_avance_inscritos(self, datos):
|
||||||
|
print("📊 Calculando AVANCE_INSCRITOS...")
|
||||||
|
for curso in datos:
|
||||||
|
curso['Avance_Inscritos'] = self.calcular_avance_inscritos(curso.get('Inscritos_Totales', 0), curso.get('Meta_Curso', 0))
|
||||||
|
return datos
|
||||||
0
backend/modules/rentabilidad/__init__.py
Normal file
0
backend/modules/rentabilidad/__init__.py
Normal file
950
backend/modules/rentabilidad/logic.py
Normal file
950
backend/modules/rentabilidad/logic.py
Normal file
@@ -0,0 +1,950 @@
|
|||||||
|
# modules/rentabilidad/logic.py
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from .processor import RentabilidadProcessor
|
||||||
|
|
||||||
|
class RentabilidadLogic:
|
||||||
|
"""Controlador lógico de Rentabilidad - Estructura la tabla y gestiona variables de clasificación"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.processor = RentabilidadProcessor(self.data_manager)
|
||||||
|
|
||||||
|
def obtener_categorias(self):
|
||||||
|
"""Devuelve la lista simplificada y agrupada para el filtro del Dashboard"""
|
||||||
|
return ["AREQUIPA", "TRUJILLO", "PIURA", "PROGRAMAS", "SEMINARIOS", "OTROS"]
|
||||||
|
|
||||||
|
def obtener_sedes(self):
|
||||||
|
"""Devuelve la lista de sedes para el nuevo filtro"""
|
||||||
|
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
|
||||||
|
|
||||||
|
def identificar_sede(self, dsc_programa):
|
||||||
|
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
|
||||||
|
if not dsc_programa: return "LIMA"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
|
||||||
|
|
||||||
|
for sede, data in sedes_data.items():
|
||||||
|
if sede == "DEFAULT": continue
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return sede
|
||||||
|
return sedes_data.get("DEFAULT", "LIMA")
|
||||||
|
|
||||||
|
def obtener_filtros_programa(self):
|
||||||
|
"""Devuelve la lista de programas para el nuevo filtro combinado"""
|
||||||
|
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||||
|
|
||||||
|
def identificar_filtro_programa(self, dsc_programa):
|
||||||
|
"""Identifica la categoría del programa para el filtro de la UI"""
|
||||||
|
if not dsc_programa: return "OTROS"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
|
||||||
|
|
||||||
|
for categoria, data in filtro_data.items():
|
||||||
|
if categoria == "DEFAULT": continue
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return categoria
|
||||||
|
return filtro_data.get("DEFAULT", "OTROS")
|
||||||
|
|
||||||
|
def clasificar_programa(self, dsc_programa):
|
||||||
|
if not dsc_programa: return "OTROS"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
meta_data = self.data_manager.meta_data
|
||||||
|
clasificaciones = meta_data.get("clasificacion_programas", {})
|
||||||
|
|
||||||
|
for clase, data in clasificaciones.items():
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return clase
|
||||||
|
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
|
||||||
|
|
||||||
|
def identificar_linea_carrera(self, nombre_programa):
|
||||||
|
"""Identifica si el programa es de la familia de Carreras Técnicas"""
|
||||||
|
categoria = self.clasificar_programa(nombre_programa)
|
||||||
|
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
|
||||||
|
return "CARRERA_COMPATIBLE"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
"""Convierte diferentes formatos de fecha a objeto datetime"""
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-':
|
||||||
|
return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calcular_descuento(self, clasificacion, inv_neta):
|
||||||
|
try: inv_neta = float(inv_neta)
|
||||||
|
except: inv_neta = 0.0
|
||||||
|
|
||||||
|
if clasificacion in ["TEAC", "TERC"]: return 3499.0 - inv_neta
|
||||||
|
elif clasificacion in ["AREQUIPA", "PIURA", "TRUJILLO"]: return 2839.0 - inv_neta
|
||||||
|
elif clasificacion == "CARRERA": return 4299.0 - inv_neta
|
||||||
|
else: return None
|
||||||
|
|
||||||
|
def calcular_estado_descuento(self, clasificacion, descuento):
|
||||||
|
if descuento is None: return ""
|
||||||
|
es_provincia = clasificacion in ["TRUJILLO", "AREQUIPA", "PIURA"]
|
||||||
|
tope = 1000.0 if es_provincia else 800.0
|
||||||
|
|
||||||
|
if descuento > tope or descuento < 0: return "NO"
|
||||||
|
return "SI"
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
|
||||||
|
# ========================================================================
|
||||||
|
# ========================================================================
|
||||||
|
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
|
||||||
|
# ========================================================================
|
||||||
|
def _obtener_costo_segun_categoria(self, diccionario_costo, categoria, dsc_programa):
|
||||||
|
"""Busca el costo inteligente. Si es 'otros', busca similitud de palabras."""
|
||||||
|
cat_lower = str(categoria).lower()
|
||||||
|
|
||||||
|
# 1. Agrupamos los técnicos dentro de 'programas'
|
||||||
|
if cat_lower in ["teac", "terc"]:
|
||||||
|
cat_lower = "programas"
|
||||||
|
|
||||||
|
# 2. EL EMBUDO: Si la categoría no existe en el diccionario (ej. seminarios, masterclass, etc.),
|
||||||
|
# la forzamos a que caiga siempre en la bolsa de "otros"
|
||||||
|
if cat_lower not in diccionario_costo:
|
||||||
|
cat_lower = "otros"
|
||||||
|
|
||||||
|
# 3. Buscamos el valor
|
||||||
|
if cat_lower in diccionario_costo:
|
||||||
|
valor = diccionario_costo[cat_lower]
|
||||||
|
|
||||||
|
# Si el valor es un bloque de similitudes (Como sucede ahora con "otros")
|
||||||
|
if isinstance(valor, dict):
|
||||||
|
dsc_upper = str(dsc_programa).upper()
|
||||||
|
|
||||||
|
# Buscamos coincidencias con el nombre completo del curso
|
||||||
|
for patron, monto in valor.items():
|
||||||
|
if patron != "DEFAULT" and patron.upper() in dsc_upper:
|
||||||
|
return float(monto)
|
||||||
|
|
||||||
|
# Si lee todo el diccionario y no encuentra coincidencia, usamos el DEFAULT
|
||||||
|
return float(valor.get("DEFAULT", 0))
|
||||||
|
else:
|
||||||
|
# Si es un número directo (ej. piura, trujillo, carrera)
|
||||||
|
return float(valor)
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
|
||||||
|
"""Obtiene datos, inyecta Categoria_Programa, Sede, Filtro_Programa y calcula promedios."""
|
||||||
|
datos = self.processor.obtener_datos_procesados(ano, mes)
|
||||||
|
programas_disponibles = set()
|
||||||
|
|
||||||
|
if datos:
|
||||||
|
for d in datos:
|
||||||
|
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
|
||||||
|
d['Categoria_Programa'] = self.clasificar_programa(prog)
|
||||||
|
d['Sede'] = self.identificar_sede(prog)
|
||||||
|
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
|
||||||
|
|
||||||
|
# Recolectar qué programas existen REALMENTE en la Sede seleccionada
|
||||||
|
if sede == "TODOS" or d['Sede'] == sede:
|
||||||
|
programas_disponibles.add(d['Filtro_Programa'])
|
||||||
|
|
||||||
|
# Ordenar para el Dropdown en la UI
|
||||||
|
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||||
|
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
|
||||||
|
|
||||||
|
# AUTO-CORRECCIÓN: Si el programa elegido ya no existe en la nueva sede, forzamos a TODOS
|
||||||
|
if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles:
|
||||||
|
filtro_prog = "TODOS"
|
||||||
|
self.filtro_corregido = "TODOS"
|
||||||
|
else:
|
||||||
|
self.filtro_corregido = None
|
||||||
|
|
||||||
|
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
|
||||||
|
datos_filtrados = []
|
||||||
|
for d in datos:
|
||||||
|
cumple_sede = (sede == "TODOS" or d.get('Sede') == sede)
|
||||||
|
cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog)
|
||||||
|
|
||||||
|
if cumple_sede and cumple_prog:
|
||||||
|
datos_filtrados.append(d)
|
||||||
|
datos = datos_filtrados
|
||||||
|
|
||||||
|
if not datos: return []
|
||||||
|
|
||||||
|
sql_matriculas = self._get_sql_matriculas_modificado()
|
||||||
|
sql_cuotas = self._get_sql_cuotas()
|
||||||
|
|
||||||
|
matriculados_raw = []
|
||||||
|
cuotas_raw = []
|
||||||
|
|
||||||
|
if sql_matriculas and sql_cuotas:
|
||||||
|
try:
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute(sql_matriculas, ano, mes)
|
||||||
|
cols_mat = [c[0] for c in cursor.description]
|
||||||
|
matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
cursor.execute(sql_cuotas, ano, mes)
|
||||||
|
cols_cuo = [c[0] for c in cursor.description]
|
||||||
|
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error en consultas DAX: {e}")
|
||||||
|
|
||||||
|
promedios_dax = self._calcular_promedios_dax(matriculados_raw, cuotas_raw)
|
||||||
|
|
||||||
|
for d in datos:
|
||||||
|
idx = str(d.get('num_indice', '')).strip()
|
||||||
|
if idx.endswith('.0'): idx = idx[:-2]
|
||||||
|
|
||||||
|
if idx in promedios_dax:
|
||||||
|
d['Promedio_Cuota'] = promedios_dax[idx]['promedio']
|
||||||
|
d['Suma_Dax_Real'] = promedios_dax[idx]['suma_real']
|
||||||
|
d['Conteo_Dax_Real'] = promedios_dax[idx]['conteo_real']
|
||||||
|
d['Promedio_Desc_E'] = promedios_dax[idx]['promedio_desc_e']
|
||||||
|
d['Suma_Desc_E_Real'] = promedios_dax[idx]['suma_desc_e_real']
|
||||||
|
d['Conteo_Desc_E_Real'] = promedios_dax[idx]['conteo_desc_e_real']
|
||||||
|
d['Valor_Venta'] = promedios_dax[idx]['suma_valor_venta']
|
||||||
|
d['Valor_Venta_Actual'] = promedios_dax[idx]['suma_valor_venta_actual']
|
||||||
|
else:
|
||||||
|
d['Promedio_Cuota'] = 0.0
|
||||||
|
d['Suma_Dax_Real'] = 0.0
|
||||||
|
d['Conteo_Dax_Real'] = 0
|
||||||
|
d['Promedio_Desc_E'] = 0.0
|
||||||
|
d['Suma_Desc_E_Real'] = 0.0
|
||||||
|
d['Conteo_Desc_E_Real'] = 0
|
||||||
|
d['Valor_Venta'] = 0.0
|
||||||
|
d['Valor_Venta_Actual'] = 0.0
|
||||||
|
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def _calcular_promedios_dax(self, matriculados, cuotas):
|
||||||
|
alumnos_dict = {}
|
||||||
|
agrupado_indices = {}
|
||||||
|
|
||||||
|
lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]
|
||||||
|
correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {})
|
||||||
|
correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {})
|
||||||
|
|
||||||
|
for m in matriculados:
|
||||||
|
estado_mat = str(m.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado_mat not in ['ALU', 'PRE', 'RET']: continue
|
||||||
|
|
||||||
|
num_matricula = str(m.get('num_matricula', '')).strip()
|
||||||
|
if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2]
|
||||||
|
|
||||||
|
num_indice = str(m.get('num_indice', '')).strip()
|
||||||
|
if num_indice.endswith('.0'): num_indice = num_indice[:-2]
|
||||||
|
|
||||||
|
prog = str(m.get('dsc_programa', ''))
|
||||||
|
try: inv_neta = float(m.get('INV_NETA', 0.0))
|
||||||
|
except: inv_neta = 0.0
|
||||||
|
|
||||||
|
tc_override = None
|
||||||
|
if num_matricula in correcciones_mat:
|
||||||
|
if "imp_tc" in correcciones_mat[num_matricula]:
|
||||||
|
tc_override = float(correcciones_mat[num_matricula]["imp_tc"])
|
||||||
|
|
||||||
|
# TC del COMPROBANTE — único TC para todo (INV_NETA + cuotas)
|
||||||
|
if tc_override is not None:
|
||||||
|
imp_tc_comp = tc_override
|
||||||
|
else:
|
||||||
|
try: imp_tc_comp = float(m.get('imp_tc', 0) or 0)
|
||||||
|
except: imp_tc_comp = 0
|
||||||
|
if imp_tc_comp < 2: imp_tc_comp = 3.45 # TC por defecto
|
||||||
|
|
||||||
|
# CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar)
|
||||||
|
if num_matricula in correcciones_inv_neta:
|
||||||
|
try: inv_neta = float(correcciones_inv_neta[num_matricula])
|
||||||
|
except: pass
|
||||||
|
else:
|
||||||
|
cod_moneda_mat = str(m.get('cod_moneda', '')).strip().upper()
|
||||||
|
if cod_moneda_mat == "DOL":
|
||||||
|
inv_neta = inv_neta * imp_tc_comp
|
||||||
|
|
||||||
|
clase = self.clasificar_programa(prog)
|
||||||
|
descuento = self.calcular_descuento(clase, inv_neta)
|
||||||
|
estado_desc = self.calcular_estado_descuento(clase, descuento)
|
||||||
|
|
||||||
|
alumnos_dict[num_matricula] = {
|
||||||
|
'num_indice': num_indice,
|
||||||
|
'clasificacion': clase,
|
||||||
|
'programa': prog,
|
||||||
|
'estado_descuento': estado_desc,
|
||||||
|
'inv_neta': inv_neta,
|
||||||
|
'estado_mat': estado_mat,
|
||||||
|
'monto_real_cuotas': 0.0,
|
||||||
|
'imp_tc_comprobante': imp_tc_comp
|
||||||
|
}
|
||||||
|
|
||||||
|
if num_indice not in agrupado_indices:
|
||||||
|
agrupado_indices[num_indice] = {
|
||||||
|
'suma': 0.0, 'conteo': 0,
|
||||||
|
'suma_desc_e': 0.0, 'conteo_desc_e': 0,
|
||||||
|
'suma_valor_venta': 0.0,
|
||||||
|
'suma_valor_venta_actual': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
agrupado_indices[num_indice]['suma_valor_venta'] += inv_neta
|
||||||
|
|
||||||
|
# ALU y PRE: suma INV_NETA completa
|
||||||
|
if estado_mat in ['ALU', 'PRE']:
|
||||||
|
agrupado_indices[num_indice]['suma_valor_venta_actual'] += inv_neta
|
||||||
|
# RET: suma solo lo que ya pagó
|
||||||
|
elif estado_mat == 'RET':
|
||||||
|
try: imp_pagado = float(m.get('imp_total_pagado', 0) or 0)
|
||||||
|
except: imp_pagado = 0.0
|
||||||
|
agrupado_indices[num_indice]['suma_valor_venta_actual'] += imp_pagado
|
||||||
|
|
||||||
|
if clase not in lista_mirar_cuotas:
|
||||||
|
agrupado_indices[num_indice]['suma'] += inv_neta
|
||||||
|
agrupado_indices[num_indice]['conteo'] += 1
|
||||||
|
|
||||||
|
for c in cuotas:
|
||||||
|
num_matricula = str(c.get('num_matricula', '')).strip()
|
||||||
|
if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2]
|
||||||
|
|
||||||
|
if num_matricula not in alumnos_dict: continue
|
||||||
|
|
||||||
|
alumno = alumnos_dict[num_matricula]
|
||||||
|
num_idx = alumno['num_indice']
|
||||||
|
|
||||||
|
if alumno['clasificacion'] in lista_mirar_cuotas:
|
||||||
|
try: num_cuota = int(c.get('num_cuota', 0))
|
||||||
|
except: num_cuota = 0
|
||||||
|
|
||||||
|
t_val = c.get('imp_total')
|
||||||
|
d_val = c.get('imp_dscto')
|
||||||
|
|
||||||
|
try: t_monto = float(t_val) if t_val is not None else 0.0
|
||||||
|
except: t_monto = 0.0
|
||||||
|
try: d_monto = float(d_val) if d_val is not None else 0.0
|
||||||
|
except: d_monto = 0.0
|
||||||
|
|
||||||
|
imp_cuota = t_monto - d_monto
|
||||||
|
|
||||||
|
cod_moneda_cuo = str(c.get('cod_moneda', '')).strip().upper()
|
||||||
|
if cod_moneda_cuo == "DOL":
|
||||||
|
# USAR el TC del COMPROBANTE (ya validado con fallback 3.45)
|
||||||
|
tc_cuo = alumno['imp_tc_comprobante']
|
||||||
|
imp_cuota = imp_cuota * tc_cuo
|
||||||
|
|
||||||
|
cumple_cuota_normal = (alumno['estado_descuento'] == "SI" and num_cuota > 0 and imp_cuota < 1000)
|
||||||
|
cumple_desc_especial = (alumno['estado_descuento'] == "SI" and num_cuota > 0)
|
||||||
|
|
||||||
|
if cumple_cuota_normal:
|
||||||
|
agrupado_indices[num_idx]['suma'] += imp_cuota
|
||||||
|
agrupado_indices[num_idx]['conteo'] += 1
|
||||||
|
|
||||||
|
if cumple_desc_especial:
|
||||||
|
alumno['monto_real_cuotas'] += imp_cuota
|
||||||
|
|
||||||
|
for mat_id, alumno in alumnos_dict.items():
|
||||||
|
idx = alumno['num_indice']
|
||||||
|
clase = alumno['clasificacion']
|
||||||
|
estado_desc = alumno['estado_descuento']
|
||||||
|
|
||||||
|
if clase in lista_mirar_cuotas:
|
||||||
|
monto_real = alumno['monto_real_cuotas']
|
||||||
|
else:
|
||||||
|
monto_real = alumno['inv_neta']
|
||||||
|
|
||||||
|
valor_final = 0.0
|
||||||
|
|
||||||
|
if monto_real >= 1900 and estado_desc == "SI":
|
||||||
|
if clase in ["TEAC", "TERC"]:
|
||||||
|
desc_calc = 3400 - monto_real
|
||||||
|
elif clase in ["AREQUIPA", "PIURA", "TRUJILLO"]:
|
||||||
|
desc_calc = 2740 - monto_real
|
||||||
|
else:
|
||||||
|
desc_calc = 0
|
||||||
|
|
||||||
|
resta = desc_calc - 200
|
||||||
|
valor_final = min(max(resta, 0.0), 200.0)
|
||||||
|
|
||||||
|
agrupado_indices[idx]['suma_desc_e'] += valor_final
|
||||||
|
agrupado_indices[idx]['conteo_desc_e'] += 1
|
||||||
|
|
||||||
|
promedios = {}
|
||||||
|
for idx, totales in agrupado_indices.items():
|
||||||
|
suma_total = totales['suma']
|
||||||
|
conteo_total = totales['conteo']
|
||||||
|
suma_desc_e = totales['suma_desc_e']
|
||||||
|
conteo_desc_e = totales['conteo_desc_e']
|
||||||
|
|
||||||
|
promedios[idx] = {
|
||||||
|
'promedio': suma_total / conteo_total if conteo_total > 0 else 0.0,
|
||||||
|
'suma_real': suma_total,
|
||||||
|
'conteo_real': conteo_total,
|
||||||
|
'promedio_desc_e': suma_desc_e / conteo_desc_e if conteo_desc_e > 0 else 0.0,
|
||||||
|
'suma_desc_e_real': suma_desc_e,
|
||||||
|
'conteo_desc_e_real': conteo_desc_e,
|
||||||
|
'suma_valor_venta': totales['suma_valor_venta'],
|
||||||
|
'suma_valor_venta_actual': totales['suma_valor_venta_actual'] # <-- NUEVO
|
||||||
|
}
|
||||||
|
|
||||||
|
return promedios
|
||||||
|
|
||||||
|
def formatear_datos_para_tabla(self, datos):
|
||||||
|
"""Estructura las filas finales para tksheet (14 columnas)"""
|
||||||
|
if not datos: return []
|
||||||
|
|
||||||
|
cfg_costos = self.data_manager.costos_data
|
||||||
|
if not cfg_costos:
|
||||||
|
cfg_costos = {
|
||||||
|
"epp": {"piura": 28, "trujillo": 28, "arequipa": 28, "programas": 28, "carrera": 28, "otros": 0},
|
||||||
|
"certificado": {"piura": 7, "trujillo": 7, "arequipa": 7, "programas": 7, "carrera": 7, "otros": 0},
|
||||||
|
"consumibles": {"piura": 676, "trujillo": 676, "arequipa": 676, "programas": 614, "carrera": 614, "otros": 0},
|
||||||
|
"marketing": {"piura": 2839, "trujillo": 2839, "arequipa": 2839, "programas": 3499, "carrera": 3499, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 1000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 900, "DIPLOMADO INTERNACIONAL": 1300, "SEMINARIO VIRTUAL METRADO , COSTEO": 800, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 1000, "DEFAULT": 0}},
|
||||||
|
"docente": {"piura": 5115, "trujillo": 5115, "arequipa": 6500, "programas": 4560, "carrera": 4560, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 4000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 3000, "DIPLOMADO INTERNACIONAL": 7000, "SEMINARIO VIRTUAL METRADO , COSTEO": 2000, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 4000, "DEFAULT": 0}}
|
||||||
|
}
|
||||||
|
|
||||||
|
# CARGA OVERRIDES DESDE SUPABASE (una sola vez por render)
|
||||||
|
overrides_supabase = self.data_manager.cargar_overrides_costos()
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
tot_total = tot_retirados = tot_curso = 0
|
||||||
|
tot_venta = 0.0
|
||||||
|
tot_venta_actual = 0.0
|
||||||
|
tot_costo = 0.0
|
||||||
|
tot_costo_actual = 0.0
|
||||||
|
|
||||||
|
bolsa_plata_global = 0.0
|
||||||
|
bolsa_recibos_global = 0
|
||||||
|
bolsa_desc_e_plata_global = 0.0
|
||||||
|
bolsa_desc_e_recibos_global = 0
|
||||||
|
|
||||||
|
for d in datos:
|
||||||
|
try: total_inscritos = int(d.get('Inscritos_Totales', 0))
|
||||||
|
except: total_inscritos = 0
|
||||||
|
try: retirados = int(d.get('Retirados', 0))
|
||||||
|
except: retirados = 0
|
||||||
|
try: en_curso = int(d.get('Inscritos_En_Curso', d.get('Inscritos_Activos', 0)))
|
||||||
|
except: en_curso = 0
|
||||||
|
|
||||||
|
p_cuota = float(d.get('Promedio_Cuota', 0.0))
|
||||||
|
p_desc = float(d.get('Promedio_Desc_E', 0.0))
|
||||||
|
venta = float(d.get('Valor_Venta', 0.0))
|
||||||
|
venta_actual = float(d.get('Valor_Venta_Actual', 0.0))
|
||||||
|
|
||||||
|
conteo_cuota_real = int(d.get('Conteo_Dax_Real', 0))
|
||||||
|
str_cuota_tabla = f"S/ {p_cuota:,.0f}" if conteo_cuota_real > 0 else ""
|
||||||
|
|
||||||
|
conteo_desc_e = int(d.get('Conteo_Desc_E_Real', 0))
|
||||||
|
str_desc_e_tabla = f"S/ {p_desc:,.0f}" if conteo_desc_e > 0 else ""
|
||||||
|
|
||||||
|
cat = d.get('Categoria_Programa', 'OTROS')
|
||||||
|
nombre_prog_crudo = d.get('dsc_programa', '')
|
||||||
|
|
||||||
|
# Identificador de curso para buscar override
|
||||||
|
num_idx_curso = str(d.get('num_indice', '')).strip()
|
||||||
|
if num_idx_curso.endswith('.0'): num_idx_curso = num_idx_curso[:-2]
|
||||||
|
ov_curso = overrides_supabase.get(num_idx_curso, {})
|
||||||
|
ov_inicial = ov_curso.get('inicial', {})
|
||||||
|
ov_actual = ov_curso.get('actual', {})
|
||||||
|
|
||||||
|
# ============= COSTO INICIAL (con override si existe) =============
|
||||||
|
def _costo_inicial(key_supa, key_cfg, multiplicar):
|
||||||
|
val = ov_inicial.get(key_supa)
|
||||||
|
if val is not None:
|
||||||
|
return float(val) # Supabase manda directo, sin multiplicar
|
||||||
|
base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo)
|
||||||
|
return total_inscritos * base if multiplicar else base
|
||||||
|
|
||||||
|
costo_epp = _costo_inicial('epp', 'epp', True)
|
||||||
|
costo_cert = _costo_inicial('certificado', 'certificado', True)
|
||||||
|
costo_cons = _costo_inicial('consumibles', 'consumibles', True)
|
||||||
|
costo_mkt = _costo_inicial('marketing', 'marketing', False)
|
||||||
|
costo_doc = _costo_inicial('docente', 'docente', False)
|
||||||
|
valor_costo_inicial = costo_epp + costo_cert + costo_cons + costo_mkt + costo_doc
|
||||||
|
|
||||||
|
# ============= COSTO ACTUAL (con override si existe) =============
|
||||||
|
def _costo_actual(key_supa, key_cfg, multiplicador):
|
||||||
|
val = ov_actual.get(key_supa)
|
||||||
|
if val is not None:
|
||||||
|
return float(val) # Supabase manda directo, sin multiplicar
|
||||||
|
base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo)
|
||||||
|
return multiplicador * base if multiplicador is not None else base
|
||||||
|
|
||||||
|
costo_epp_actual = _costo_actual('epp', 'epp', total_inscritos)
|
||||||
|
costo_cert_actual = _costo_actual('certificado', 'certificado', en_curso)
|
||||||
|
costo_cons_actual = _costo_actual('consumibles', 'consumibles', total_inscritos)
|
||||||
|
costo_mkt_actual = _costo_actual('marketing', 'marketing', None)
|
||||||
|
costo_doc_actual = _costo_actual('docente', 'docente', None)
|
||||||
|
costo_actual = costo_epp_actual + costo_cert_actual + costo_cons_actual + costo_mkt_actual + costo_doc_actual
|
||||||
|
|
||||||
|
# MARGEN BRUTO %
|
||||||
|
if venta > 0:
|
||||||
|
margen_bruto = 1 - (valor_costo_inicial / venta)
|
||||||
|
str_margen = f"{margen_bruto * 100:,.1f}%"
|
||||||
|
else:
|
||||||
|
str_margen = ""
|
||||||
|
|
||||||
|
# MARGEN BRUTO ACTUAL %
|
||||||
|
if venta_actual > 0:
|
||||||
|
margen_bruto_actual = 1 - (costo_actual / venta_actual)
|
||||||
|
str_margen_actual = f"{margen_bruto_actual * 100:,.1f}%"
|
||||||
|
else:
|
||||||
|
str_margen_actual = ""
|
||||||
|
|
||||||
|
tot_total += total_inscritos
|
||||||
|
tot_retirados += retirados
|
||||||
|
tot_curso += en_curso
|
||||||
|
tot_venta += venta
|
||||||
|
tot_venta_actual += venta_actual
|
||||||
|
tot_costo += valor_costo_inicial
|
||||||
|
tot_costo_actual += costo_actual
|
||||||
|
|
||||||
|
bolsa_plata_global += float(d.get('Suma_Dax_Real', 0.0))
|
||||||
|
bolsa_recibos_global += int(d.get('Conteo_Dax_Real', 0))
|
||||||
|
bolsa_desc_e_plata_global += float(d.get('Suma_Desc_E_Real', 0.0))
|
||||||
|
bolsa_desc_e_recibos_global += int(d.get('Conteo_Desc_E_Real', 0))
|
||||||
|
|
||||||
|
nombre_prog = d.get('programa_frecuencia', d.get('dsc_programa', ''))
|
||||||
|
|
||||||
|
fila = [
|
||||||
|
nombre_prog,
|
||||||
|
d.get('fch_inicio', ''),
|
||||||
|
total_inscritos,
|
||||||
|
retirados,
|
||||||
|
en_curso,
|
||||||
|
str_cuota_tabla,
|
||||||
|
str_desc_e_tabla,
|
||||||
|
f"S/ {venta:,.0f}",
|
||||||
|
f"S/ {valor_costo_inicial:,.0f}",
|
||||||
|
str_margen,
|
||||||
|
f"S/ {venta_actual:,.0f}",
|
||||||
|
f"S/ {costo_actual:,.0f}",
|
||||||
|
str_margen_actual,
|
||||||
|
" ≡ ▼ "
|
||||||
|
]
|
||||||
|
sheet_data.append(fila)
|
||||||
|
|
||||||
|
promedio_total_final = (bolsa_plata_global / bolsa_recibos_global) if bolsa_recibos_global > 0 else 0.0
|
||||||
|
promedio_desc_e_final = (bolsa_desc_e_plata_global / bolsa_desc_e_recibos_global) if bolsa_desc_e_recibos_global > 0 else 0.0
|
||||||
|
|
||||||
|
str_total_cuota = f"S/ {promedio_total_final:,.0f}" if bolsa_recibos_global > 0 else ""
|
||||||
|
str_total_desc_e = f"S/ {promedio_desc_e_final:,.0f}" if bolsa_desc_e_recibos_global > 0 else ""
|
||||||
|
|
||||||
|
margen_total = 1 - (tot_costo / tot_venta) if tot_venta > 0 else 0
|
||||||
|
margen_total_actual = 1 - (tot_costo_actual / tot_venta_actual) if tot_venta_actual > 0 else 0
|
||||||
|
fila_total = [
|
||||||
|
"TOTAL GENERAL", "",
|
||||||
|
tot_total,
|
||||||
|
tot_retirados,
|
||||||
|
tot_curso,
|
||||||
|
str_total_cuota,
|
||||||
|
str_total_desc_e,
|
||||||
|
f"S/ {tot_venta:,.0f}",
|
||||||
|
f"S/ {tot_costo:,.0f}",
|
||||||
|
f"{margen_total * 100:,.1f}%",
|
||||||
|
f"S/ {tot_venta_actual:,.0f}",
|
||||||
|
f"S/ {tot_costo_actual:,.0f}",
|
||||||
|
f"{margen_total_actual * 100:,.1f}%",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
sheet_data.append(fila_total)
|
||||||
|
|
||||||
|
return sheet_data
|
||||||
|
|
||||||
|
def exportar_detalle_alumnos_excel(self, programa, headers, datos):
|
||||||
|
if not datos:
|
||||||
|
raise ValueError("No hay datos para exportar")
|
||||||
|
|
||||||
|
prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:40]
|
||||||
|
archivo = f"Detalle_Alumnos_{prog_limpio}.xlsx"
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos, columns=headers)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='Detalle_Alumnos')
|
||||||
|
worksheet = writer.sheets['Detalle_Alumnos']
|
||||||
|
|
||||||
|
try:
|
||||||
|
from openpyxl.styles import PatternFill, Font, Border, Side, Alignment
|
||||||
|
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
|
||||||
|
font_header = Font(color="FFFFFF", bold=True)
|
||||||
|
thin_border = Border(
|
||||||
|
left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"),
|
||||||
|
top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD")
|
||||||
|
)
|
||||||
|
|
||||||
|
for cell in worksheet[1]:
|
||||||
|
cell.fill = fill_header
|
||||||
|
cell.font = font_header
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
for row in worksheet.iter_rows(min_row=2):
|
||||||
|
for cell in row:
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.alignment = Alignment(vertical="center")
|
||||||
|
|
||||||
|
for col in worksheet.columns:
|
||||||
|
max_len = 0
|
||||||
|
col_letter = col[0].column_letter
|
||||||
|
for cell in col:
|
||||||
|
if cell.value:
|
||||||
|
max_len = max(max_len, len(str(cell.value)))
|
||||||
|
worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50)
|
||||||
|
except Exception: pass
|
||||||
|
except Exception as e:
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def obtener_detalle_programa(self, programa_target, ano, mes_numero):
|
||||||
|
prog_t = str(programa_target).strip().upper()
|
||||||
|
|
||||||
|
sql_matriculas = self._get_sql_matriculas_modificado()
|
||||||
|
sql_cuotas = self._get_sql_cuotas()
|
||||||
|
|
||||||
|
matriculados_raw = []
|
||||||
|
cuotas_raw = []
|
||||||
|
|
||||||
|
if sql_matriculas and sql_cuotas:
|
||||||
|
try:
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute(sql_matriculas, ano, mes_numero)
|
||||||
|
cols_mat = [c[0] for c in cursor.description]
|
||||||
|
matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
cursor.execute(sql_cuotas, ano, mes_numero)
|
||||||
|
cols_cuo = [c[0] for c in cursor.description]
|
||||||
|
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error en consultas detalle: {e}")
|
||||||
|
|
||||||
|
cuotas_por_mat = {}
|
||||||
|
for c in cuotas_raw:
|
||||||
|
m_id = str(c.get('num_matricula', '')).strip()
|
||||||
|
if m_id.endswith('.0'): m_id = m_id[:-2]
|
||||||
|
if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = []
|
||||||
|
cuotas_por_mat[m_id].append(c)
|
||||||
|
|
||||||
|
cursos = self.processor.obtener_datos_procesados(ano, mes_numero)
|
||||||
|
indices_target = []
|
||||||
|
fecha_inicio_actual = None
|
||||||
|
linea_actual = self.identificar_linea_carrera(prog_t)
|
||||||
|
|
||||||
|
for c in cursos:
|
||||||
|
nombre = str(c.get('programa_frecuencia', c.get('dsc_programa', ''))).strip().upper()
|
||||||
|
if nombre == prog_t:
|
||||||
|
idx = str(c.get('num_indice', '')).strip()
|
||||||
|
if idx.endswith('.0'): idx = idx[:-2]
|
||||||
|
indices_target.append(idx)
|
||||||
|
if not fecha_inicio_actual:
|
||||||
|
fecha_inicio_actual = self.parse_fecha(c.get('fch_inicio'))
|
||||||
|
|
||||||
|
lista_dnis = []
|
||||||
|
for m in matriculados_raw:
|
||||||
|
dni = m.get('dsc_documento')
|
||||||
|
if dni: lista_dnis.append(str(dni).strip())
|
||||||
|
|
||||||
|
try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||||
|
except: datos_historial = {}
|
||||||
|
|
||||||
|
correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||||
|
correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||||
|
correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {})
|
||||||
|
correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {})
|
||||||
|
|
||||||
|
lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]
|
||||||
|
|
||||||
|
acum_inv_neta_global = 0.0
|
||||||
|
acum_suma_cuotas_global = 0.0
|
||||||
|
acum_cont_cuotas_global = 0
|
||||||
|
acum_suma_desc_e_global = 0.0
|
||||||
|
acum_cont_desc_e_global = 0
|
||||||
|
|
||||||
|
alumnos_lista = []
|
||||||
|
|
||||||
|
for m in matriculados_raw:
|
||||||
|
estado_mat = str(m.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado_mat not in ['ALU', 'PRE', 'RET']: continue
|
||||||
|
|
||||||
|
idx_mat = str(m.get('num_indice', '')).strip()
|
||||||
|
if idx_mat.endswith('.0'): idx_mat = idx_mat[:-2]
|
||||||
|
|
||||||
|
nombre_prog_crudo = str(m.get('dsc_programa', '')).strip().upper()
|
||||||
|
|
||||||
|
if (idx_mat in indices_target) or (prog_t in nombre_prog_crudo) or (nombre_prog_crudo in prog_t):
|
||||||
|
|
||||||
|
vendedor = str(m.get('dsc_vendedor', 'SIN VENDEDOR')).strip()
|
||||||
|
if vendedor == "None" or not vendedor: vendedor = "SIN VENDEDOR"
|
||||||
|
|
||||||
|
alumno_nombre = str(m.get('nombre_alumno', m.get('dsc_alumno', 'SIN NOMBRE'))).strip()
|
||||||
|
if alumno_nombre == "None" or not alumno_nombre: alumno_nombre = "SIN NOMBRE"
|
||||||
|
|
||||||
|
mat_id = str(m.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
dni = str(m.get('dsc_documento', '')).strip()
|
||||||
|
|
||||||
|
try: inv_neta_raw = float(m.get('INV_NETA', 0.0))
|
||||||
|
except: inv_neta_raw = 0.0
|
||||||
|
cod_moneda = str(m.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
try: tc = float(m.get('imp_tc', 0) or 0)
|
||||||
|
except: tc = 0
|
||||||
|
if tc < 2: tc = 3.45 # TC por defecto si comprobante inválido
|
||||||
|
|
||||||
|
fch_canc_raw = m.get('fch_cancelacion_cuota1', '')
|
||||||
|
|
||||||
|
if mat_id in correcciones_mat:
|
||||||
|
datos_corregidos = correcciones_mat[mat_id]
|
||||||
|
if "imp_tc" in datos_corregidos: tc = float(datos_corregidos["imp_tc"])
|
||||||
|
if "fch_cancelacion_cuota1" in datos_corregidos: fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"]
|
||||||
|
|
||||||
|
# CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar)
|
||||||
|
if mat_id in correcciones_inv_neta:
|
||||||
|
try: inv_neta_soles = float(correcciones_inv_neta[mat_id])
|
||||||
|
except: inv_neta_soles = inv_neta_raw
|
||||||
|
elif cod_moneda == "DOL":
|
||||||
|
inv_neta_soles = inv_neta_raw * tc
|
||||||
|
else:
|
||||||
|
inv_neta_soles = inv_neta_raw
|
||||||
|
inv_neta_soles = round(inv_neta_soles, 2)
|
||||||
|
|
||||||
|
acum_inv_neta_global += inv_neta_soles
|
||||||
|
|
||||||
|
fch_mat = m.get('fch_matricula', '')
|
||||||
|
fecha_mat_limpia = ""
|
||||||
|
if fch_mat:
|
||||||
|
try:
|
||||||
|
if hasattr(fch_mat, 'strftime'): fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
tmp = str(fch_mat)[:10]
|
||||||
|
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fecha_mat_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
else: fecha_mat_limpia = tmp
|
||||||
|
except: fecha_mat_limpia = str(fch_mat)[:10]
|
||||||
|
|
||||||
|
fch_canc_limpia = ""
|
||||||
|
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
|
||||||
|
try:
|
||||||
|
if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
tmp = str(fch_canc_raw)[:10]
|
||||||
|
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
else: fch_canc_limpia = tmp
|
||||||
|
except: fch_canc_limpia = str(fch_canc_raw)[:10]
|
||||||
|
|
||||||
|
clase_alumno = self.clasificar_programa(nombre_prog_crudo)
|
||||||
|
|
||||||
|
es_refriperu = False
|
||||||
|
if mat_id in correcciones_desc:
|
||||||
|
if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True
|
||||||
|
elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False
|
||||||
|
else:
|
||||||
|
if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True
|
||||||
|
elif clase_alumno in ["TEAC", "TERC"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True
|
||||||
|
|
||||||
|
tipo_final = "NUEVO"
|
||||||
|
if es_refriperu: tipo_final = "REFRIPERU"
|
||||||
|
else:
|
||||||
|
es_cont = False
|
||||||
|
if mat_id in correcciones_cont:
|
||||||
|
if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True
|
||||||
|
elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False
|
||||||
|
else:
|
||||||
|
if fecha_inicio_actual and linea_actual and (dni in datos_historial):
|
||||||
|
for antecedente in datos_historial[dni]:
|
||||||
|
nombre_pasado = str(antecedente.get('programa', ''))
|
||||||
|
fecha_pasada = antecedente.get('fecha')
|
||||||
|
if not fecha_pasada: continue
|
||||||
|
if not (fecha_pasada < fecha_inicio_actual): continue
|
||||||
|
if (fecha_inicio_actual - fecha_pasada).days < 60: continue
|
||||||
|
if self.identificar_linea_carrera(nombre_pasado) == linea_actual:
|
||||||
|
es_cont = True; break
|
||||||
|
if es_cont: tipo_final = "CONTINUIDAD"
|
||||||
|
|
||||||
|
descuento = self.calcular_descuento(clase_alumno, inv_neta_soles)
|
||||||
|
estado_desc = self.calcular_estado_descuento(clase_alumno, descuento)
|
||||||
|
|
||||||
|
str_cuota_val = ""
|
||||||
|
monto_eval = 0.0
|
||||||
|
|
||||||
|
if clase_alumno in lista_mirar_cuotas:
|
||||||
|
suma_c = 0.0; cont_c = 0; monto_real_c = 0.0
|
||||||
|
for c_dict in cuotas_por_mat.get(mat_id, []):
|
||||||
|
try: num_c = int(c_dict.get('num_cuota', 0))
|
||||||
|
except: num_c = 0
|
||||||
|
t_val = c_dict.get('imp_total'); d_val = c_dict.get('imp_dscto')
|
||||||
|
try: t_monto = float(t_val) if t_val is not None else 0.0
|
||||||
|
except: t_monto = 0.0
|
||||||
|
try: d_monto = float(d_val) if d_val is not None else 0.0
|
||||||
|
except: d_monto = 0.0
|
||||||
|
imp_c = t_monto - d_monto
|
||||||
|
tc_over = None
|
||||||
|
if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]:
|
||||||
|
tc_over = float(correcciones_mat[mat_id]["imp_tc"])
|
||||||
|
cod_m_c = str(c_dict.get('cod_moneda', '')).strip().upper()
|
||||||
|
if cod_m_c == "DOL":
|
||||||
|
# USAR TC del COMPROBANTE (no del cronograma)
|
||||||
|
tc_c = tc # tc ya está validado arriba (línea 654)
|
||||||
|
imp_c *= tc_c
|
||||||
|
if estado_desc == "SI" and num_c > 0 and imp_c < 1000:
|
||||||
|
suma_c += imp_c; cont_c += 1
|
||||||
|
if estado_desc == "SI" and num_c > 0:
|
||||||
|
monto_real_c += imp_c
|
||||||
|
if cont_c > 0:
|
||||||
|
str_cuota_val = f"S/ {suma_c / cont_c:,.0f}"
|
||||||
|
monto_eval = monto_real_c
|
||||||
|
acum_suma_cuotas_global += suma_c
|
||||||
|
acum_cont_cuotas_global += cont_c
|
||||||
|
else:
|
||||||
|
str_cuota_val = f"S/ {inv_neta_soles:,.0f}"
|
||||||
|
monto_eval = inv_neta_soles
|
||||||
|
acum_suma_cuotas_global += inv_neta_soles
|
||||||
|
acum_cont_cuotas_global += 1
|
||||||
|
|
||||||
|
str_desc_e_val = ""
|
||||||
|
if monto_eval >= 1900 and estado_desc == "SI":
|
||||||
|
if clase_alumno in ["TEAC", "TERC"]: d_calc = 3400 - monto_eval
|
||||||
|
elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - monto_eval
|
||||||
|
else: d_calc = 0
|
||||||
|
desc_e_val = min(max(d_calc - 200, 0.0), 200.0)
|
||||||
|
str_desc_e_val = f"S/ {desc_e_val:,.0f}"
|
||||||
|
acum_suma_desc_e_global += desc_e_val
|
||||||
|
acum_cont_desc_e_global += 1
|
||||||
|
|
||||||
|
alumnos_lista.append([
|
||||||
|
vendedor, alumno_nombre, fecha_mat_limpia, fch_canc_limpia,
|
||||||
|
tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}",
|
||||||
|
estado_mat # columna oculta para detectar RET
|
||||||
|
])
|
||||||
|
|
||||||
|
alumnos_lista.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
prom_gral_cuota = (acum_suma_cuotas_global / acum_cont_cuotas_global) if acum_cont_cuotas_global > 0 else 0.0
|
||||||
|
prom_gral_desc = (acum_suma_desc_e_global / acum_cont_desc_e_global) if acum_cont_desc_e_global > 0 else 0.0
|
||||||
|
|
||||||
|
str_gral_cuota = f"S/ {prom_gral_cuota:,.0f}" if acum_cont_cuotas_global > 0 else ""
|
||||||
|
str_gral_desc = f"S/ {prom_gral_desc:,.0f}" if acum_cont_desc_e_global > 0 else ""
|
||||||
|
|
||||||
|
alumnos_lista.append([
|
||||||
|
"TOTAL GENERAL", "", "", "", "",
|
||||||
|
str_gral_cuota, str_gral_desc, f"S/ {acum_inv_neta_global:,.0f}",
|
||||||
|
"" # columna oculta extra para TOTAL
|
||||||
|
])
|
||||||
|
|
||||||
|
return alumnos_lista
|
||||||
|
|
||||||
|
def exportar_a_excel(self, datos, ano, mes):
|
||||||
|
if not datos: raise ValueError("No hay datos para exportar")
|
||||||
|
df = pd.DataFrame(datos)
|
||||||
|
archivo = f"reporte_rentabilidad_{ano}_{mes}.xlsx"
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def _get_sql_matriculas_modificado(self):
|
||||||
|
if not hasattr(self, '_sql_matriculas_cache') or not self._sql_matriculas_cache:
|
||||||
|
sql = self.data_manager.query_matriculas_sql
|
||||||
|
if not sql: return ""
|
||||||
|
sql = re.sub(
|
||||||
|
r'YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)',
|
||||||
|
'YEAR(sgede_RP_programa.fch_inicio)',
|
||||||
|
sql, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
sql = re.sub(
|
||||||
|
r'MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)',
|
||||||
|
'MONTH(sgede_RP_programa.fch_inicio)',
|
||||||
|
sql, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
self._sql_matriculas_cache = sql
|
||||||
|
return self._sql_matriculas_cache
|
||||||
|
|
||||||
|
def _get_sql_cuotas(self):
|
||||||
|
if not hasattr(self, '_sql_cuotas_cache') or not self._sql_cuotas_cache:
|
||||||
|
try:
|
||||||
|
res = requests.get(self.data_manager.github_cuota_url)
|
||||||
|
self._sql_cuotas_cache = res.text
|
||||||
|
except:
|
||||||
|
self._sql_cuotas_cache = ""
|
||||||
|
return self._sql_cuotas_cache
|
||||||
|
def obtener_datos_costos_programa(self, programa, ano, mes):
|
||||||
|
"""Devuelve el desglose de costos e ingresos para el modal Editar"""
|
||||||
|
datos = self.obtener_datos_procesados(ano, mes)
|
||||||
|
if not datos: return None
|
||||||
|
|
||||||
|
curso = None
|
||||||
|
for d in datos:
|
||||||
|
nombre = d.get('programa_frecuencia', d.get('dsc_programa', ''))
|
||||||
|
if str(nombre).strip() == str(programa).strip():
|
||||||
|
curso = d; break
|
||||||
|
if not curso: return None
|
||||||
|
|
||||||
|
cfg_costos = self.data_manager.costos_data
|
||||||
|
if not cfg_costos: return None
|
||||||
|
|
||||||
|
# OVERRIDES desde Supabase
|
||||||
|
num_idx = str(curso.get('num_indice', '')).strip()
|
||||||
|
if num_idx.endswith('.0'): num_idx = num_idx[:-2]
|
||||||
|
overrides = self.data_manager.cargar_overrides_costos()
|
||||||
|
ov_curso = overrides.get(num_idx, {})
|
||||||
|
ov_inicial = ov_curso.get('inicial', {})
|
||||||
|
ov_actual = ov_curso.get('actual', {})
|
||||||
|
|
||||||
|
cat = curso.get('Categoria_Programa', 'OTROS')
|
||||||
|
prog_crudo = curso.get('dsc_programa', '')
|
||||||
|
try: total_inscritos = int(curso.get('Inscritos_Totales', 0))
|
||||||
|
except: total_inscritos = 0
|
||||||
|
try: en_curso = int(curso.get('Inscritos_En_Curso', curso.get('Inscritos_Activos', 0)))
|
||||||
|
except: en_curso = 0
|
||||||
|
|
||||||
|
epp_u = self._obtener_costo_segun_categoria(cfg_costos.get("epp", {}), cat, prog_crudo)
|
||||||
|
cert_u = self._obtener_costo_segun_categoria(cfg_costos.get("certificado", {}), cat, prog_crudo)
|
||||||
|
cons_u = self._obtener_costo_segun_categoria(cfg_costos.get("consumibles", {}), cat, prog_crudo)
|
||||||
|
mkt = self._obtener_costo_segun_categoria(cfg_costos.get("marketing", {}), cat, prog_crudo)
|
||||||
|
doc = self._obtener_costo_segun_categoria(cfg_costos.get("docente", {}), cat, prog_crudo)
|
||||||
|
|
||||||
|
# INICIAL
|
||||||
|
epp_i = float(ov_inicial['epp']) if ov_inicial.get('epp') is not None else total_inscritos * epp_u
|
||||||
|
cert_i = float(ov_inicial['certificado']) if ov_inicial.get('certificado') is not None else total_inscritos * cert_u
|
||||||
|
cons_i = float(ov_inicial['consumibles']) if ov_inicial.get('consumibles') is not None else total_inscritos * cons_u
|
||||||
|
mkt_i = float(ov_inicial['marketing']) if ov_inicial.get('marketing') is not None else mkt
|
||||||
|
doc_i = float(ov_inicial['docente']) if ov_inicial.get('docente') is not None else doc
|
||||||
|
costo_i = epp_i + cert_i + cons_i + mkt_i + doc_i
|
||||||
|
|
||||||
|
# ACTUAL
|
||||||
|
epp_a = float(ov_actual['epp']) if ov_actual.get('epp') is not None else total_inscritos * epp_u
|
||||||
|
cert_a = float(ov_actual['certificado']) if ov_actual.get('certificado') is not None else en_curso * cert_u
|
||||||
|
cons_a = float(ov_actual['consumibles']) if ov_actual.get('consumibles') is not None else total_inscritos * cons_u
|
||||||
|
mkt_a = float(ov_actual['marketing']) if ov_actual.get('marketing') is not None else mkt
|
||||||
|
doc_a = float(ov_actual['docente']) if ov_actual.get('docente') is not None else doc
|
||||||
|
costo_a = epp_a + cert_a + cons_a + mkt_a + doc_a
|
||||||
|
|
||||||
|
vi = float(curso.get('Valor_Venta', 0.0))
|
||||||
|
va = float(curso.get('Valor_Venta_Actual', 0.0))
|
||||||
|
|
||||||
|
mbi = (1 - costo_i / vi) * 100 if vi > 0 else 0.0
|
||||||
|
mba = (1 - costo_a / va) * 100 if va > 0 else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'num_indice': num_idx,
|
||||||
|
'venta_inicial': vi, 'costo_inicial': costo_i, 'mb_inicial': mbi,
|
||||||
|
'venta_actual': va, 'costo_actual': costo_a, 'mb_actual': mba,
|
||||||
|
'epp_inicial': epp_i, 'cert_inicial': cert_i, 'cons_inicial': cons_i,
|
||||||
|
'mkt_inicial': mkt_i, 'doc_inicial': doc_i,
|
||||||
|
'epp_actual': epp_a, 'cert_actual': cert_a, 'cons_actual': cons_a,
|
||||||
|
'mkt_actual': mkt_a, 'doc_actual': doc_a,
|
||||||
|
# Lo que ya estaba en Supabase, para detectar qué fue editado
|
||||||
|
'ov_inicial': ov_inicial,
|
||||||
|
'ov_actual': ov_actual,
|
||||||
|
}
|
||||||
290
backend/modules/rentabilidad/processor.py
Normal file
290
backend/modules/rentabilidad/processor.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# modules/rentabilidad/processor.py
|
||||||
|
from datetime import datetime
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
class RentabilidadProcessor:
|
||||||
|
"""Procesador de datos para Rentabilidad - Hereda la lógica estricta de Ocupabilidad"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
|
||||||
|
# Columnas exclusivas de rentabilidad
|
||||||
|
self.columnas = [
|
||||||
|
'programa_frecuencia',
|
||||||
|
'fch_inicio',
|
||||||
|
'Descuento', # INSCRITOS REFRIPERU
|
||||||
|
'Inscritos_Continuidad', # INSCRITOS CONTINUIDAD
|
||||||
|
'Inscritos_Nuevos', # Calculado en Logic
|
||||||
|
'Inscritos_Totales', # TOTAL INSCRITOS
|
||||||
|
'Inscritos_PC', # INSCRITOS P.C
|
||||||
|
'Retirados', # INSCRITOS RETIRADOS
|
||||||
|
'Inscritos_En_Curso', # INSCRITOS ACTIVOS
|
||||||
|
'Promedio_Cuota', # Pendiente (0)
|
||||||
|
'Promedio_Desc_E', # Pendiente (0)
|
||||||
|
'Valor_Venta', # Pendiente (0)
|
||||||
|
'Opciones' # Fijo "en cu"
|
||||||
|
]
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes):
|
||||||
|
try:
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||||
|
fut_cursos = executor.submit(self.data_manager.ejecutar_consulta_cursos, ano, mes)
|
||||||
|
fut_raw = executor.submit(self.data_manager.ejecutar_consulta_matriculados_detalle, ano, mes)
|
||||||
|
fut_matriculas = executor.submit(self.data_manager.cargar_datos_matriculas, ano, mes)
|
||||||
|
|
||||||
|
datos_originales = fut_cursos.result()
|
||||||
|
datos_raw_matriculas = fut_raw.result()
|
||||||
|
datos_matriculas = fut_matriculas.result()
|
||||||
|
|
||||||
|
lista_dnis = []
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for alumno in datos_raw_matriculas:
|
||||||
|
dni = alumno.get('dsc_documento')
|
||||||
|
if dni: lista_dnis.append(dni)
|
||||||
|
|
||||||
|
datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||||
|
|
||||||
|
datos_procesados = self.aplicar_personalizaciones(
|
||||||
|
datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano, mes
|
||||||
|
)
|
||||||
|
return datos_procesados
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error obteniendo datos rentabilidad: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def normalizar_texto(self, texto):
|
||||||
|
if not texto: return ""
|
||||||
|
texto = str(texto).upper().strip()
|
||||||
|
texto = unicodedata.normalize('NFD', texto)
|
||||||
|
return texto.encode('ascii', 'ignore').decode("utf-8")
|
||||||
|
|
||||||
|
def obtener_categoria_programa(self, nombre_programa):
|
||||||
|
try:
|
||||||
|
nombre_limpio = self.normalizar_texto(nombre_programa)
|
||||||
|
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
|
||||||
|
for categoria, config in clasificaciones.items():
|
||||||
|
patrones = config.get('patrones', [])
|
||||||
|
for patron in patrones:
|
||||||
|
if self.normalizar_texto(patron) in nombre_limpio:
|
||||||
|
return categoria
|
||||||
|
return "OTROS"
|
||||||
|
except: return "OTROS"
|
||||||
|
|
||||||
|
def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro):
|
||||||
|
print("💰 Procesando variables de Rentabilidad...")
|
||||||
|
cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {})
|
||||||
|
datos_filtrados = []
|
||||||
|
|
||||||
|
try: ano_target, mes_target = int(ano_filtro), int(mes_filtro)
|
||||||
|
except: ano_target, mes_target = 0, 0
|
||||||
|
|
||||||
|
# FILTRADO Y ACTUALIZADOR EN VIVO
|
||||||
|
for curso in datos_originales:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
if num_indice in cursos_personalizados:
|
||||||
|
curso.update(cursos_personalizados[num_indice])
|
||||||
|
|
||||||
|
if curso.get('flg_activo', '') == 'NO': continue
|
||||||
|
|
||||||
|
# Si el curso está suspendido, lo ignoramos y no se muestra en Rentabilidad
|
||||||
|
if str(curso.get('cod_estado', '')).strip().upper() == 'SUS': continue
|
||||||
|
|
||||||
|
fch_valida = True
|
||||||
|
try:
|
||||||
|
raw_fecha = curso.get('fch_inicio')
|
||||||
|
if raw_fecha:
|
||||||
|
s_fecha = str(raw_fecha).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-': f_obj = datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
elif ' ' in s_fecha: f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
else: f_obj = datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
|
||||||
|
if f_obj and (f_obj.year != ano_target or f_obj.month != mes_target):
|
||||||
|
fch_valida = False
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if fch_valida: datos_filtrados.append(curso)
|
||||||
|
|
||||||
|
datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados)
|
||||||
|
datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados)
|
||||||
|
for c in datos_filtrados: c['fch_inicio'] = self.formatear_fecha(c.get('fch_inicio', ''))
|
||||||
|
|
||||||
|
# CÁLCULOS CENTRALES
|
||||||
|
datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas)
|
||||||
|
datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu)
|
||||||
|
datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas)
|
||||||
|
datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas)
|
||||||
|
|
||||||
|
# EXTRACCIÓN REAL DE BD Y VALORES PENDIENTES
|
||||||
|
for c in datos_filtrados:
|
||||||
|
c['Retirados'] = int(c.get('Inscritos_Retirados', 0)) # <--- AHORA JALA LA DATA REAL DEL SQL
|
||||||
|
|
||||||
|
c['Promedio_Cuota'] = 0.0
|
||||||
|
c['Promedio_Desc_E'] = 0.0
|
||||||
|
c['Valor_Venta'] = 0.0
|
||||||
|
c['Opciones'] = "en cu"
|
||||||
|
|
||||||
|
return datos_filtrados
|
||||||
|
|
||||||
|
def aplicar_reemplazos_programas(self, datos):
|
||||||
|
reemplazos = self.data_manager.replace_data.get('reemplazos_programas', {})
|
||||||
|
for curso in datos:
|
||||||
|
prog = curso.get('dsc_programa', '')
|
||||||
|
if prog in reemplazos: curso['dsc_programa'] = reemplazos[prog]
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def concatenar_programa_frecuencia(self, datos):
|
||||||
|
for c in datos:
|
||||||
|
p, f = c.get('dsc_programa', ''), c.get('cod_frecuencia', '')
|
||||||
|
c['programa_frecuencia'] = f"{p} - {f}" if p and f else p or f or ""
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def formatear_fecha(self, fecha_str):
|
||||||
|
try:
|
||||||
|
if not fecha_str: return ""
|
||||||
|
if len(str(fecha_str)) == 10 and str(fecha_str)[2] == '-': return str(fecha_str)
|
||||||
|
if '.' in str(fecha_str): return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y')
|
||||||
|
return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y')
|
||||||
|
except: return str(fecha_str)
|
||||||
|
|
||||||
|
def identificar_linea_carrera(self, nombre_programa):
|
||||||
|
categoria = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
|
||||||
|
return "CARRERA_COMPATIBLE"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-': return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha: return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except: return None
|
||||||
|
|
||||||
|
# Lógica estricta de Refriperu
|
||||||
|
def agregar_descuento(self, datos, datos_raw_matriculas):
|
||||||
|
conteo_por_curso = {}
|
||||||
|
alumnos_refriperu = set()
|
||||||
|
correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||||
|
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for alumno in datos_raw_matriculas:
|
||||||
|
try:
|
||||||
|
if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue
|
||||||
|
num_indice = str(alumno.get('num_indice', ''))
|
||||||
|
mat_id = str(alumno.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
|
||||||
|
if mat_id in correcciones_descuento:
|
||||||
|
if str(correcciones_descuento[mat_id]).upper().strip() == "SI":
|
||||||
|
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||||
|
alumnos_refriperu.add(mat_id)
|
||||||
|
continue
|
||||||
|
elif str(correcciones_descuento[mat_id]).upper().strip() == "NO": continue
|
||||||
|
|
||||||
|
nombre_programa = str(alumno.get('dsc_programa', ''))
|
||||||
|
cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
try:
|
||||||
|
inv_neta = float(alumno.get('INV_NETA', 0) or 0)
|
||||||
|
imp_tc = float(alumno.get('imp_tc', 0) or 0)
|
||||||
|
if imp_tc <= 0: imp_tc = 3.45 # TC por defecto si no hay comprobante
|
||||||
|
except: inv_neta, imp_tc = 0.0, 3.45
|
||||||
|
|
||||||
|
inv_neta_final = round(inv_neta * imp_tc if cod_moneda == 'DOL' else inv_neta, 2)
|
||||||
|
categoria_json = self.obtener_categoria_programa(nombre_programa)
|
||||||
|
|
||||||
|
es_descuento = False
|
||||||
|
if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_final <= 1700: es_descuento = True
|
||||||
|
elif categoria_json in ["TEAC", "TERC"] and 1400 <= inv_neta_final <= 1900: es_descuento = True
|
||||||
|
|
||||||
|
if es_descuento:
|
||||||
|
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||||
|
alumnos_refriperu.add(mat_id)
|
||||||
|
except: continue
|
||||||
|
|
||||||
|
for curso in datos: curso['Descuento'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
return datos, alumnos_refriperu
|
||||||
|
|
||||||
|
# Lógica estricta de Continuidad
|
||||||
|
def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu):
|
||||||
|
conteo_por_curso = {}
|
||||||
|
correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||||
|
|
||||||
|
if datos_raw_matriculas and datos_historial:
|
||||||
|
|
||||||
|
# PRE-ÍNDICE: {dni: [(linea_carrera, fecha), ...]}
|
||||||
|
# Calculamos identificar_linea_carrera UNA sola vez por programa del historial
|
||||||
|
historial_indexado = {}
|
||||||
|
for dni, antecedentes in datos_historial.items():
|
||||||
|
lineas = []
|
||||||
|
for ant in antecedentes:
|
||||||
|
linea = self.identificar_linea_carrera(str(ant.get('programa', '')))
|
||||||
|
fecha = ant.get('fecha')
|
||||||
|
if linea and fecha:
|
||||||
|
lineas.append((linea, fecha))
|
||||||
|
if lineas:
|
||||||
|
historial_indexado[dni] = lineas
|
||||||
|
|
||||||
|
alumnos_por_curso = {}
|
||||||
|
for alu in datos_raw_matriculas:
|
||||||
|
idx = str(alu.get('num_indice', ''))
|
||||||
|
if idx not in alumnos_por_curso: alumnos_por_curso[idx] = []
|
||||||
|
alumnos_por_curso[idx].append(alu)
|
||||||
|
|
||||||
|
for curso in datos:
|
||||||
|
num_indice = str(curso.get('num_indice', ''))
|
||||||
|
fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio'))
|
||||||
|
linea_actual = self.identificar_linea_carrera(str(curso.get('dsc_programa', '')))
|
||||||
|
|
||||||
|
if not linea_actual or not fecha_inicio_actual: continue
|
||||||
|
|
||||||
|
contador_fieles = 0
|
||||||
|
for alumno in alumnos_por_curso.get(num_indice, []):
|
||||||
|
if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue
|
||||||
|
|
||||||
|
mat_id = str(alumno.get('num_matricula', '')).strip()
|
||||||
|
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||||
|
|
||||||
|
if mat_id in alumnos_refriperu: continue
|
||||||
|
|
||||||
|
dni = str(alumno.get('dsc_documento', 'SD')).strip()
|
||||||
|
es_fiel = False
|
||||||
|
|
||||||
|
if mat_id in correcciones_continuidad:
|
||||||
|
accion = str(correcciones_continuidad[mat_id]).upper().strip()
|
||||||
|
if accion == "SI": es_fiel = True
|
||||||
|
elif accion == "NO": continue
|
||||||
|
|
||||||
|
if not es_fiel and dni in historial_indexado:
|
||||||
|
for linea_pasada, fecha_pasada in historial_indexado[dni]:
|
||||||
|
if fecha_pasada >= fecha_inicio_actual: continue
|
||||||
|
if (fecha_inicio_actual - fecha_pasada).days < 60: continue
|
||||||
|
if linea_pasada == linea_actual:
|
||||||
|
es_fiel = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if es_fiel: contador_fieles += 1
|
||||||
|
conteo_por_curso[num_indice] = contador_fieles
|
||||||
|
|
||||||
|
for curso in datos: curso['Inscritos_Continuidad'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def agregar_inscritos_mes(self, datos, datos_matriculas):
|
||||||
|
dict_norm = {str(k): v for k, v in datos_matriculas.items()}
|
||||||
|
for curso in datos: curso['Inscritos_Mes'] = dict_norm.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
return datos
|
||||||
|
|
||||||
|
def agregar_inscritos_pc(self, datos, datos_raw_matriculas):
|
||||||
|
conteo_pc = {}
|
||||||
|
if datos_raw_matriculas:
|
||||||
|
for m in datos_raw_matriculas:
|
||||||
|
try:
|
||||||
|
if str(m.get('estado_matricula', '')).strip() in ['ALU', 'PRE'] and float(m.get('imp_saldo_matricula', 0) or 0) < 1 and float(m.get('imp_saldo_cuota1', 0) or 0) < 1:
|
||||||
|
nid = str(m.get('num_indice', ''))
|
||||||
|
conteo_pc[nid] = conteo_pc.get(nid, 0) + 1
|
||||||
|
except: continue
|
||||||
|
for curso in datos: curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0)
|
||||||
|
return datos
|
||||||
0
backend/modules/saldo_pendiente/__init__.py
Normal file
0
backend/modules/saldo_pendiente/__init__.py
Normal file
193
backend/modules/saldo_pendiente/logic.py
Normal file
193
backend/modules/saldo_pendiente/logic.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# modules/saldo_pendiente/logic.py
|
||||||
|
from .processor import SaldoProcessor
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class SaldoLogic:
|
||||||
|
"""
|
||||||
|
Controlador lógico optimizado.
|
||||||
|
1. Filtro Global Manual (lista_saldo_pendiente).
|
||||||
|
2. Filtro REFRIPERU (Precio Neto < 1900/1000 o Etiquetas).
|
||||||
|
3. Filtros de Negocio (Vendedor, Fecha, Estado).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self.processor = SaldoProcessor(self.data_manager)
|
||||||
|
|
||||||
|
def obtener_saldos_consolidados(self, tipo_cuota):
|
||||||
|
# 1. Recargar Configuración (Para leer tu nueva lista en vivo)
|
||||||
|
try: self.data_manager.cargar_toda_configuracion()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
all_debtors = []
|
||||||
|
years_to_scan = [2025, 2026]
|
||||||
|
|
||||||
|
print(f"⚡ [Logic] Iniciando carga para {tipo_cuota}...")
|
||||||
|
|
||||||
|
for year in years_to_scan:
|
||||||
|
try:
|
||||||
|
# Usamos la consulta anual optimizada
|
||||||
|
raw_year_data = self.data_manager.ejecutar_consulta_saldos_anual(str(year))
|
||||||
|
if raw_year_data:
|
||||||
|
datos_procesados = self._procesar_lote_local(raw_year_data, tipo_cuota)
|
||||||
|
all_debtors.extend(datos_procesados)
|
||||||
|
except AttributeError:
|
||||||
|
print("⚠️ Error: DataManager no actualizado.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ordenar alfabéticamente
|
||||||
|
all_debtors.sort(key=lambda x: (x.get('VENDEDOR', '') or "ZZZ", x.get('ALUMNO', '') or ""))
|
||||||
|
print(f"✅ Carga Finalizada. Total en tabla: {len(all_debtors)}")
|
||||||
|
return all_debtors
|
||||||
|
|
||||||
|
def _procesar_lote_local(self, raw_data, tipo_cuota):
|
||||||
|
datos_limpios = []
|
||||||
|
|
||||||
|
# --- Configuración ---
|
||||||
|
ahora = datetime.now()
|
||||||
|
ano_actual = ahora.year
|
||||||
|
mes_actual = ahora.month
|
||||||
|
|
||||||
|
config = self.data_manager.config_data
|
||||||
|
|
||||||
|
# 1. CARGAMOS TUS LISTAS DEL JSON
|
||||||
|
correcciones_refri = config.get("correcciones_descuento", {})
|
||||||
|
exclusion_manual_global = config.get("lista_saldo_pendiente", {}) # <--- TU NUEVA LISTA
|
||||||
|
|
||||||
|
# 2. Lista Vendedores Permitidos
|
||||||
|
lista_raw = config.get("lista_pendientes", [])
|
||||||
|
if not lista_raw:
|
||||||
|
lista_raw = ["AGUILAR U. JUAN CARLOS", "CHAVEZ P. DIANA", "HUAMAN C. ALONSO AGUSTIN",
|
||||||
|
"LA ROSA C. VERONICA ASTRID", "LAZARO Q. DIEGO ARTURO",
|
||||||
|
"MONTOYA D. CARMEN ISABEL", "PERALTA C. ALMENDRA LUCIA"]
|
||||||
|
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
|
||||||
|
|
||||||
|
# 3. Mapeo de columnas
|
||||||
|
mapa_cols = {
|
||||||
|
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
|
||||||
|
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
|
||||||
|
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
|
||||||
|
"4° Cuota": ("imp_saldo_cuota4", "fch_venc_cuota4"),
|
||||||
|
"5° Cuota": ("imp_saldo_cuota5", "fch_venc_cuota5"),
|
||||||
|
}
|
||||||
|
col_saldo, col_venc = mapa_cols.get(tipo_cuota, (None, None))
|
||||||
|
if not col_saldo: return []
|
||||||
|
|
||||||
|
for row in raw_data:
|
||||||
|
# Identificadores
|
||||||
|
num_mat = str(row.get('num_matricula', '')).split('.')[0].strip()
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 0: EXCLUSIÓN GLOBAL MANUAL (TU NUEVO PEDIDO)
|
||||||
|
# =================================================================
|
||||||
|
# Si la matrícula está en "lista_saldo_pendiente" con valor "NO", ADIÓS.
|
||||||
|
if num_mat in exclusion_manual_global:
|
||||||
|
val_excl = str(exclusion_manual_global[num_mat]).strip().upper()
|
||||||
|
if val_excl == "NO":
|
||||||
|
continue # Se salta inmediatamente, no importa nada más.
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 1: DETECCIÓN DE REFRIPERU (PRECIO NETO / ETIQUETAS)
|
||||||
|
# =================================================================
|
||||||
|
es_refriperu = False
|
||||||
|
|
||||||
|
# A) Manual (correcciones_descuento)
|
||||||
|
if num_mat in correcciones_refri:
|
||||||
|
val = str(correcciones_refri[num_mat]).strip().upper()
|
||||||
|
if val == "SI": es_refriperu = True
|
||||||
|
elif val == "NO": es_refriperu = False # Forzamos a cobrar
|
||||||
|
else:
|
||||||
|
# B) Automático (Etiquetas)
|
||||||
|
dsc_beca = str(row.get('dsc_beca', '')).strip().upper()
|
||||||
|
dsc_prog = str(row.get('dsc_programa', '')).strip().upper()
|
||||||
|
dsc_prom = str(row.get('dsc_promocion', '')).strip().upper()
|
||||||
|
|
||||||
|
if 'REFRIPERU' in dsc_beca or '100%' in dsc_beca or 'BECA' in dsc_beca:
|
||||||
|
es_refriperu = True
|
||||||
|
elif 'REFRIPERU' in dsc_prog or 'REFRIPERU' in dsc_prom:
|
||||||
|
es_refriperu = True
|
||||||
|
|
||||||
|
# C) Automático por PRECIO FINAL (INV_NETA)
|
||||||
|
if not es_refriperu:
|
||||||
|
try:
|
||||||
|
# Usamos INV_NETA (Precio Real)
|
||||||
|
inv_neta = float(row.get('INV_NETA', 0))
|
||||||
|
except:
|
||||||
|
inv_neta = 0.0
|
||||||
|
|
||||||
|
if inv_neta > 0:
|
||||||
|
# Rango Técnicos/Especialistas
|
||||||
|
if ("TECNICO" in dsc_prog or "ESPECIALISTA" in dsc_prog or "TEAC" in dsc_prog):
|
||||||
|
if inv_neta < 1900: es_refriperu = True # ej: 1799
|
||||||
|
|
||||||
|
# Rango Gestión/Ventas
|
||||||
|
elif "GESTION" in dsc_prog or "VENTA" in dsc_prog:
|
||||||
|
if inv_neta < 1000: es_refriperu = True
|
||||||
|
|
||||||
|
if es_refriperu:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTROS ESTÁNDAR (VENDEDOR, ESTADO, FECHA)
|
||||||
|
# =================================================================
|
||||||
|
|
||||||
|
# Vendedor
|
||||||
|
vendedor = str(row.get('dsc_vendedor', '')).strip().upper()
|
||||||
|
if lista_permitidos:
|
||||||
|
encontrado = False
|
||||||
|
if vendedor in lista_permitidos: encontrado = True
|
||||||
|
else:
|
||||||
|
for p in lista_permitidos:
|
||||||
|
if p.replace(" ","") in vendedor.replace(" ",""):
|
||||||
|
encontrado = True; break
|
||||||
|
if not encontrado: continue
|
||||||
|
|
||||||
|
# Estado
|
||||||
|
estado = str(row.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado not in ('ALU', 'PRE'): continue
|
||||||
|
|
||||||
|
# Fecha Futura (Solo 2da cuota en adelante)
|
||||||
|
if tipo_cuota != "1° Cuota":
|
||||||
|
fch_raw = row.get(col_venc)
|
||||||
|
if fch_raw:
|
||||||
|
try:
|
||||||
|
if isinstance(fch_raw, str): f_obj = datetime.strptime(fch_raw[:10], '%Y-%m-%d')
|
||||||
|
else: f_obj = fch_raw
|
||||||
|
if f_obj.year > ano_actual: continue
|
||||||
|
elif f_obj.year == ano_actual and f_obj.month > mes_actual: continue
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Saldo Positivo
|
||||||
|
try: val = float(row.get(col_saldo, 0))
|
||||||
|
except: val = 0.0
|
||||||
|
if val <= 0.1: continue
|
||||||
|
|
||||||
|
# --- 4. Construcción de Fila ---
|
||||||
|
def fmt(v):
|
||||||
|
if not v: return ""
|
||||||
|
if isinstance(v, str): return v[:10]
|
||||||
|
if isinstance(v, datetime): return v.strftime('%d-%m-%Y')
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
fila = {
|
||||||
|
'MATRICULA': row.get('num_matricula'),
|
||||||
|
'VENDEDOR': row.get('dsc_vendedor'),
|
||||||
|
'ALUMNO': row.get('dsc_alumno'),
|
||||||
|
'PROGRAMA': row.get('dsc_promocion'),
|
||||||
|
'FECHA INICIO': fmt(row.get('fch_inicio')),
|
||||||
|
'FECHA MATR.': fmt(row.get('fch_matricula')),
|
||||||
|
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
|
||||||
|
f'SALDO {tipo_cuota.upper()}': val,
|
||||||
|
'VENCIMIENTO': fmt(row.get(col_venc)),
|
||||||
|
'INV. NETA': float(row.get('INV_NETA', 0)),
|
||||||
|
'DNI': row.get('dsc_documento', ''),
|
||||||
|
'CELULAR': row.get('dsc_telefono_1', '')
|
||||||
|
}
|
||||||
|
datos_limpios.append(fila)
|
||||||
|
|
||||||
|
return datos_limpios
|
||||||
|
|
||||||
|
# Métodos legacy
|
||||||
|
def obtener_saldos(self, ano, mes, tipo_cuota): return self.processor.obtener_datos_procesados(ano, mes, tipo_cuota)
|
||||||
|
def get_current_year(self): return self.data_manager.get_current_year()
|
||||||
|
def get_current_month(self): return self.data_manager.get_current_month()
|
||||||
162
backend/modules/saldo_pendiente/processor.py
Normal file
162
backend/modules/saldo_pendiente/processor.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# modules/saldo_pendiente/processor.py
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class SaldoProcessor:
|
||||||
|
"""
|
||||||
|
Procesador de lógica de negocio para Saldos Pendientes.
|
||||||
|
Filtra estrictamente por estado ALU/PRE, saldos positivos,
|
||||||
|
vendedores permitidos y FECHA DE VENCIMIENTO (No mostrar futuro).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
|
||||||
|
def obtener_datos_procesados(self, ano, mes, tipo_cuota):
|
||||||
|
"""
|
||||||
|
Recupera datos crudos y aplica los filtros de negocio.
|
||||||
|
"""
|
||||||
|
# 1. Traer la data cruda desde DataManager
|
||||||
|
raw_data = self.data_manager.ejecutar_consulta_saldos_pendientes(ano, mes)
|
||||||
|
|
||||||
|
datos_limpios = []
|
||||||
|
|
||||||
|
# 2. Obtener fecha actual para saber qué es "futuro"
|
||||||
|
ahora = datetime.now()
|
||||||
|
ano_actual = ahora.year
|
||||||
|
mes_actual = ahora.month
|
||||||
|
|
||||||
|
# 3. Cargar la "Lista Blanca" de vendedores desde el JSON
|
||||||
|
config = self.data_manager.config_data
|
||||||
|
lista_raw = config.get("lista_pendientes", [])
|
||||||
|
|
||||||
|
# RESPALDO DE EMERGENCIA
|
||||||
|
if not lista_raw:
|
||||||
|
lista_raw = [
|
||||||
|
"AGUILAR U. JUAN CARLOS",
|
||||||
|
"CHAVEZ P. DIANA",
|
||||||
|
"HUAMAN C. ALONSO AGUSTIN",
|
||||||
|
"LA ROSA C. VERONICA ASTRID",
|
||||||
|
"LAZARO Q. DIEGO ARTURO",
|
||||||
|
"MONTOYA D. CARMEN ISABEL",
|
||||||
|
"PERALTA C. ALMENDRA LUCIA"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Normalizamos la lista
|
||||||
|
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
|
||||||
|
|
||||||
|
# 4. Mapeo de columnas según la selección
|
||||||
|
mapa_columnas = {
|
||||||
|
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
|
||||||
|
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
|
||||||
|
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
|
||||||
|
}
|
||||||
|
|
||||||
|
col_saldo_target, col_venc_target = mapa_columnas.get(tipo_cuota, (None, None))
|
||||||
|
|
||||||
|
if not col_saldo_target:
|
||||||
|
return []
|
||||||
|
|
||||||
|
for row in raw_data:
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 1: VENDEDOR PERMITIDO
|
||||||
|
# =================================================================
|
||||||
|
vendedor_actual = str(row.get('dsc_vendedor', '')).strip().upper()
|
||||||
|
|
||||||
|
if lista_permitidos:
|
||||||
|
if vendedor_actual not in lista_permitidos:
|
||||||
|
# Búsqueda parcial por si hay errores de espacios
|
||||||
|
encontrado = False
|
||||||
|
for permitido in lista_permitidos:
|
||||||
|
v_norm = vendedor_actual.replace(" ", "")
|
||||||
|
p_norm = permitido.replace(" ", "")
|
||||||
|
if p_norm in v_norm:
|
||||||
|
encontrado = True
|
||||||
|
break
|
||||||
|
if not encontrado:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 2: ESTADO (ALU/PRE)
|
||||||
|
# =================================================================
|
||||||
|
estado = str(row.get('estado_matricula', '')).strip().upper()
|
||||||
|
if estado not in ('ALU', 'PRE'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 3: FECHA DE VENCIMIENTO (NO MOSTRAR FUTURO)
|
||||||
|
# =================================================================
|
||||||
|
# Este filtro aplica PRINCIPALMENTE para 2° Cuota en adelante.
|
||||||
|
# (Aunque la lógica es válida para todas, la 1° suele ser inmediata).
|
||||||
|
|
||||||
|
if tipo_cuota != "1° Cuota":
|
||||||
|
fch_venc_raw = row.get(col_venc_target)
|
||||||
|
|
||||||
|
es_futuro = False
|
||||||
|
if fch_venc_raw:
|
||||||
|
try:
|
||||||
|
# Convertir a objeto fecha si es texto
|
||||||
|
if isinstance(fch_venc_raw, str):
|
||||||
|
# Asumimos formato SQL YYYY-MM-DD
|
||||||
|
f_obj = datetime.strptime(fch_venc_raw[:10], '%Y-%m-%d')
|
||||||
|
else:
|
||||||
|
f_obj = fch_venc_raw # Ya es datetime
|
||||||
|
|
||||||
|
venc_ano = f_obj.year
|
||||||
|
venc_mes = f_obj.month
|
||||||
|
|
||||||
|
# LÓGICA DE TIEMPO:
|
||||||
|
# Si el año de vencimiento es mayor al actual -> ES FUTURO
|
||||||
|
if venc_ano > ano_actual:
|
||||||
|
es_futuro = True
|
||||||
|
# Si es el mismo año, pero el mes es mayor al actual -> ES FUTURO
|
||||||
|
elif venc_ano == ano_actual and venc_mes > mes_actual:
|
||||||
|
es_futuro = True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Si falla la fecha, asumimos que no es futuro para no ocultar por error
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Si la cuota vence en el futuro (ej: Marzo cuando estamos en Febrero), LA OCULTAMOS.
|
||||||
|
if es_futuro:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# 🛑 FILTRO 4: SALDO > 0
|
||||||
|
# =================================================================
|
||||||
|
try:
|
||||||
|
val = row.get(col_saldo_target, 0)
|
||||||
|
saldo_a_mostrar = float(val) if val is not None else 0.0
|
||||||
|
except:
|
||||||
|
saldo_a_mostrar = 0.0
|
||||||
|
|
||||||
|
if saldo_a_mostrar <= 0.1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- FORMATEO PARA VISUALIZACIÓN ---
|
||||||
|
def format_date(val):
|
||||||
|
if not val: return ""
|
||||||
|
if isinstance(val, str): return val[:10]
|
||||||
|
if isinstance(val, datetime): return val.strftime('%d-%m-%Y')
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
f_inicio = format_date(row.get('fch_inicio'))
|
||||||
|
f_matr = format_date(row.get('fch_matricula'))
|
||||||
|
f_vencimiento = format_date(row.get(col_venc_target))
|
||||||
|
|
||||||
|
# --- CONSTRUCCIÓN DE LA FILA FINAL ---
|
||||||
|
fila = {
|
||||||
|
'MATRICULA': row.get('num_matricula'),
|
||||||
|
'VENDEDOR': row.get('dsc_vendedor'),
|
||||||
|
'ALUMNO': row.get('dsc_alumno'),
|
||||||
|
'PROGRAMA': row.get('dsc_promocion'),
|
||||||
|
'FECHA INICIO': f_inicio,
|
||||||
|
'FECHA MATR.': f_matr,
|
||||||
|
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
|
||||||
|
f'SALDO {tipo_cuota.upper()}': saldo_a_mostrar,
|
||||||
|
'VENCIMIENTO': f_vencimiento, # Aquí se verá la fecha (ej: 14-02-2026)
|
||||||
|
'INV. NETA': float(row.get('INV_NETA', 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
datos_limpios.append(fila)
|
||||||
|
|
||||||
|
return datos_limpios
|
||||||
0
backend/modules/ventas/__init__.py
Normal file
0
backend/modules/ventas/__init__.py
Normal file
736
backend/modules/ventas/logic.py
Normal file
736
backend/modules/ventas/logic.py
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
# modules/ventas/logic.py
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
|
||||||
|
class VentasLogic:
|
||||||
|
"""Clase que maneja la lógica de negocio y procesamiento de datos para Ventas"""
|
||||||
|
|
||||||
|
def __init__(self, data_manager):
|
||||||
|
self.data_manager = data_manager
|
||||||
|
self._config_sedes = None
|
||||||
|
|
||||||
|
def _clasificar_sede_comisiones(self, dsc_programa):
|
||||||
|
"""Clasifica la sede usando EXACTAMENTE la misma lógica de Cobranza (sede.json)."""
|
||||||
|
try:
|
||||||
|
if not hasattr(self, '_cob_processor') or self._cob_processor is None:
|
||||||
|
from modules.cobranza.processor import CobranzaProcessor
|
||||||
|
self._cob_processor = CobranzaProcessor(self.data_manager)
|
||||||
|
return self._cob_processor.clasificar_sede(dsc_programa)
|
||||||
|
except Exception:
|
||||||
|
prog_up = str(dsc_programa or "").upper()
|
||||||
|
for s in ("AREQUIPA", "PIURA", "TRUJILLO"):
|
||||||
|
if s in prog_up:
|
||||||
|
return s
|
||||||
|
return "LIMA"
|
||||||
|
|
||||||
|
def buscar_todos_matriculados(self):
|
||||||
|
"""Devuelve TODOS los matriculados (todos los años/meses) para el buscador.
|
||||||
|
Ejecuta la query de matrículas quitando el filtro de YEAR/MONTH de fch_matricula.
|
||||||
|
Retorna filas con el mismo formato que el detalle (para reusar columnas)."""
|
||||||
|
import re as _re
|
||||||
|
sql_base = self.data_manager.query_matriculas_sql
|
||||||
|
if not sql_base:
|
||||||
|
return []
|
||||||
|
# Quitar las condiciones de año y mes de fch_matricula
|
||||||
|
sql = _re.sub(r"AND\s+YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql_base, flags=_re.IGNORECASE)
|
||||||
|
sql = _re.sub(r"AND\s+MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql, flags=_re.IGNORECASE)
|
||||||
|
# Por si están sin AND (primera condición)
|
||||||
|
sql = _re.sub(r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
|
||||||
|
sql = _re.sub(r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
|
||||||
|
# El buscador debe traer TODOS los matriculados excepto ANU (incluye SUS, no pagados, etc.)
|
||||||
|
# El buscador muestra TODOS los matriculados, incluso ANU
|
||||||
|
sql = _re.sub(r"AND\s+sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\([^)]*\)", "", sql, flags=_re.IGNORECASE)
|
||||||
|
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
|
||||||
|
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*,\s*'RET'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
|
||||||
|
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql) # sin parámetros de fecha
|
||||||
|
columns = [c[0] for c in cursor.description]
|
||||||
|
datos = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# Traer fecha de cancelación de la cuota 1 de TODAS las matrículas (cualquier refinanciamiento)
|
||||||
|
fechas_canc = {}
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT num_matricula, MAX(fch_cancelacion)
|
||||||
|
FROM sgede_cronograma_matricula
|
||||||
|
WHERE num_cuota = 1 AND fch_cancelacion IS NOT NULL
|
||||||
|
GROUP BY num_matricula
|
||||||
|
""")
|
||||||
|
for r in cursor.fetchall():
|
||||||
|
k = str(r[0]).strip()
|
||||||
|
if k.endswith('.0'): k = k[:-2]
|
||||||
|
fechas_canc[k] = r[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
filas = []
|
||||||
|
for d in datos:
|
||||||
|
raw_mat = d.get('num_matricula')
|
||||||
|
mat_id = ""
|
||||||
|
if raw_mat is not None:
|
||||||
|
v = str(raw_mat).strip()
|
||||||
|
if v.endswith('.0'): v = v[:-2]
|
||||||
|
mat_id = v
|
||||||
|
alumno = d.get('dsc_alumno', 'SIN NOMBRE')
|
||||||
|
# MOSTRAR: detallado (dsc_promocion = sgede_RP_programa.dsc_det_programa)
|
||||||
|
programa = (d.get('dsc_promocion') or d.get('dsc_det_programa') or d.get('dsc_programa') or '')
|
||||||
|
# CLASIFICAR sede: prioriza dsc_programa
|
||||||
|
prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa') or d.get('dsc_promocion') or '')
|
||||||
|
vendedor = d.get('dsc_vendedor', 'SIN VENDEDOR') or 'SIN VENDEDOR'
|
||||||
|
inv = d.get('INV_NETA', 0) or 0
|
||||||
|
saldo_mat = d.get('imp_saldo_matricula', 0) or 0
|
||||||
|
saldo_c1 = d.get('imp_saldo_cuota1', 0) or 0
|
||||||
|
def _fdate(v):
|
||||||
|
if not v or str(v).strip() in ("","None"): return ""
|
||||||
|
if hasattr(v,'strftime'): return v.strftime('%d/%m/%Y')
|
||||||
|
s = str(v)[:10]
|
||||||
|
if len(s)==10 and s[4]=='-': p=s.split('-'); return f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
return s
|
||||||
|
f_mat = _fdate(d.get('fch_matricula'))
|
||||||
|
f_ini = _fdate(d.get('fch_inicio'))
|
||||||
|
f_canc = _fdate(fechas_canc.get(mat_id))
|
||||||
|
sede = self._clasificar_sede_comisiones(prog_clasificacion)
|
||||||
|
# mismo layout que el detalle: 16 columnas
|
||||||
|
fila = [
|
||||||
|
vendedor, alumno, f_mat, f_canc, "", "", "",
|
||||||
|
f"S/ {float(inv):,.0f}", programa, f_ini,
|
||||||
|
f"S/ {float(saldo_mat):,.0f}", f"S/ {float(saldo_c1):,.0f}",
|
||||||
|
"", "-", sede, mat_id,
|
||||||
|
]
|
||||||
|
filas.append(fila)
|
||||||
|
return filas
|
||||||
|
|
||||||
|
def obtener_datos_brutos(self, ano, mes_numero):
|
||||||
|
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero))
|
||||||
|
return datos or []
|
||||||
|
|
||||||
|
def obtener_datos_brutos_filtrado(self, ano, mes_numero, sede="TODOS", programa="TODOS"):
|
||||||
|
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero), sede, programa)
|
||||||
|
return datos or []
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# FUNCIONES AUXILIARES
|
||||||
|
# =========================================================================
|
||||||
|
def clasificar_programa(self, dsc_programa):
|
||||||
|
if not dsc_programa: return "OTROS"
|
||||||
|
dsc_prog_upper = str(dsc_programa).upper()
|
||||||
|
meta_data = self.data_manager.meta_data
|
||||||
|
clasificaciones = meta_data.get("clasificacion_programas", {})
|
||||||
|
|
||||||
|
for clase, data in clasificaciones.items():
|
||||||
|
for patron in data.get("patrones", []):
|
||||||
|
if patron.upper() in dsc_prog_upper:
|
||||||
|
return clase
|
||||||
|
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
|
||||||
|
|
||||||
|
def identificar_linea_carrera(self, nombre_programa):
|
||||||
|
categoria = self.clasificar_programa(nombre_programa)
|
||||||
|
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC", "CARRERA"]:
|
||||||
|
return "CARRERA_COMPATIBLE"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_fecha(self, fecha_raw):
|
||||||
|
try:
|
||||||
|
if isinstance(fecha_raw, datetime): return fecha_raw
|
||||||
|
if not fecha_raw: return None
|
||||||
|
s_fecha = str(fecha_raw).strip()
|
||||||
|
if len(s_fecha) == 10 and s_fecha[2] == '-':
|
||||||
|
return datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||||
|
if ' ' in s_fecha:
|
||||||
|
return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S')
|
||||||
|
return datetime.strptime(s_fecha, '%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# OBTENCIÓN DE DETALLE POR VENDEDOR (LÓGICA PURA)
|
||||||
|
# =========================================================================
|
||||||
|
def obtener_detalle_vendedor(self, vendedor, ano, mes_numero):
|
||||||
|
try:
|
||||||
|
key_mes = f"{int(mes_numero):02d}-{ano}"
|
||||||
|
lista_raw = self.data_manager.historico_pendientes.get(key_mes, [])
|
||||||
|
set_vip_actual = set()
|
||||||
|
lista_vip_str = []
|
||||||
|
|
||||||
|
for item in lista_raw:
|
||||||
|
try:
|
||||||
|
val = int(item)
|
||||||
|
set_vip_actual.add(val)
|
||||||
|
lista_vip_str.append(str(val))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas)
|
||||||
|
_fov_vip = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
|
||||||
|
for mk in _fov_vip.keys():
|
||||||
|
try:
|
||||||
|
val = int(float(str(mk)))
|
||||||
|
set_vip_actual.add(val)
|
||||||
|
if str(val) not in lista_vip_str:
|
||||||
|
lista_vip_str.append(str(val))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
correcciones_mat = self.data_manager.config_data.get("correcciones_matriculas", {})
|
||||||
|
correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||||
|
correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||||
|
|
||||||
|
sql_base = self.data_manager.query_matriculas_sql
|
||||||
|
if not sql_base:
|
||||||
|
return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []}
|
||||||
|
|
||||||
|
sql_in_clause = "(-1)"
|
||||||
|
if lista_vip_str:
|
||||||
|
sql_in_clause = "(" + ",".join(lista_vip_str) + ")"
|
||||||
|
|
||||||
|
sql_modificado = re.sub(
|
||||||
|
r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?",
|
||||||
|
r"( (YEAR(sgeca_matricula.fch_matricula) = ?",
|
||||||
|
sql_base, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
sql_modificado = re.sub(
|
||||||
|
r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?",
|
||||||
|
rf"""MONTH(sgeca_matricula.fch_matricula) = ?) OR sgeca_matricula.num_matricula IN {sql_in_clause}
|
||||||
|
OR EXISTS (SELECT 1 FROM sgede_cronograma_matricula crono
|
||||||
|
WHERE crono.cod_localidad = sgeca_matricula.cod_localidad
|
||||||
|
AND crono.num_matricula = sgeca_matricula.num_matricula
|
||||||
|
AND crono.num_cuota = 1 AND crono.fch_cancelacion IS NOT NULL
|
||||||
|
AND YEAR(crono.fch_cancelacion) = ? AND MONTH(crono.fch_cancelacion) = ?) )""",
|
||||||
|
sql_modificado, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Permitir matrículas VIP/override aunque estén ANU/SUS (igual que la tabla principal)
|
||||||
|
if lista_vip_str:
|
||||||
|
sql_modificado = re.sub(
|
||||||
|
r"sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)",
|
||||||
|
rf"(sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS') OR sgeca_matricula.num_matricula IN {sql_in_clause})",
|
||||||
|
sql_modificado, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = self.data_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(sql_modificado, ano, mes_numero, ano, mes_numero)
|
||||||
|
columns = [column[0] for column in cursor.description]
|
||||||
|
datos_raw = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# 🔥 DESCARGA DEL CRONOGRAMA COMPLETO (SIN IMPORTAR EL MES) 🔥
|
||||||
|
lista_mats = list(set([str(d.get('num_matricula')).replace('.0','').strip() for d in datos_raw if d.get('num_matricula')]))
|
||||||
|
cuotas_raw = []
|
||||||
|
|
||||||
|
if lista_mats:
|
||||||
|
mats_str_q = ",".join([f"'{m}'" for m in lista_mats])
|
||||||
|
try:
|
||||||
|
res_cuo = requests.get(self.data_manager.github_cuota_url)
|
||||||
|
sql_cuotas_base = res_cuo.text
|
||||||
|
|
||||||
|
# Convertimos las validaciones de Año y Mes en "1=1" para que no esconda los cronogramas de otros meses
|
||||||
|
sql_cuotas_base = re.sub(r"YEAR\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE)
|
||||||
|
sql_cuotas_base = re.sub(r"MONTH\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE)
|
||||||
|
sql_cuotas_base = re.sub(r"ORDER\s+BY\s+.*$", "", sql_cuotas_base, flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
sql_final_cuotas = f"SELECT * FROM ({sql_cuotas_base}) AS sub_cuotas WHERE num_matricula IN ({mats_str_q})"
|
||||||
|
|
||||||
|
cursor.execute(sql_final_cuotas)
|
||||||
|
cols_cuo = [c[0] for c in cursor.description]
|
||||||
|
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error DAX Cuotas: {e}")
|
||||||
|
|
||||||
|
try: cursos_raw = self.data_manager.ejecutar_consulta_cursos(ano, mes_numero)
|
||||||
|
except: cursos_raw = []
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Agrupar Cuotas
|
||||||
|
cuotas_por_mat = {}
|
||||||
|
for c in cuotas_raw:
|
||||||
|
m_id = str(c.get('num_matricula', '')).strip()
|
||||||
|
if m_id.endswith('.0'): m_id = m_id[:-2]
|
||||||
|
if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = []
|
||||||
|
cuotas_por_mat[m_id].append(c)
|
||||||
|
|
||||||
|
fecha_inicio_prog = {}
|
||||||
|
for c in cursos_raw:
|
||||||
|
prog = str(c.get('dsc_programa', '')).strip().upper()
|
||||||
|
if prog not in fecha_inicio_prog:
|
||||||
|
fecha_inicio_prog[prog] = self.parse_fecha(c.get('fch_inicio'))
|
||||||
|
|
||||||
|
lista_dnis = [str(d.get('dsc_documento', '')).strip() for d in datos_raw if d.get('dsc_documento')]
|
||||||
|
try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||||
|
except: datos_historial = {}
|
||||||
|
|
||||||
|
listas_datos = {
|
||||||
|
"Venta Inscritos": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0},
|
||||||
|
"Venta P.C": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0},
|
||||||
|
"Venta Pendientes":{"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0}
|
||||||
|
}
|
||||||
|
|
||||||
|
vendedor_target = str(vendedor).upper().replace(".", "").replace(",", "")
|
||||||
|
vendedor_target = " ".join(vendedor_target.split())
|
||||||
|
traer_todos = (vendedor_target == "__TODOS__")
|
||||||
|
|
||||||
|
for d in datos_raw:
|
||||||
|
vend_name = str(d.get('dsc_vendedor', 'SIN VENDEDOR')).upper().replace(".", "").replace(",", "")
|
||||||
|
vend_name_clean = " ".join(vend_name.split())
|
||||||
|
|
||||||
|
if not traer_todos and vendedor_target not in vend_name_clean and vend_name_clean not in vendedor_target:
|
||||||
|
continue
|
||||||
|
|
||||||
|
estado = str(d.get('estado_matricula', '')).strip().upper()
|
||||||
|
# Permitir ANU/otros si tiene override de fecha; si no, solo ALU/PRE/RET
|
||||||
|
_rm = d.get('num_matricula')
|
||||||
|
_mk = ""
|
||||||
|
if _rm is not None:
|
||||||
|
_mk = str(_rm).strip()
|
||||||
|
if _mk.endswith('.0'): _mk = _mk[:-2]
|
||||||
|
_fov_chk = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
|
||||||
|
if estado not in ['ALU', 'PRE', 'RET'] and _mk not in _fov_chk: continue
|
||||||
|
|
||||||
|
raw_mat = d.get('num_matricula')
|
||||||
|
mat_id = ""
|
||||||
|
matricula_int = -1
|
||||||
|
if raw_mat is not None:
|
||||||
|
val_str = str(raw_mat).strip()
|
||||||
|
if val_str.endswith('.0'): val_str = val_str[:-2]
|
||||||
|
mat_id = val_str
|
||||||
|
try: matricula_int = int(float(val_str))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
dni = str(d.get('dsc_documento', '')).strip()
|
||||||
|
alumno_nombre = d.get('dsc_alumno', 'SIN NOMBRE')
|
||||||
|
nombre_prog_crudo = str(d.get('dsc_programa', '')).strip().upper()
|
||||||
|
|
||||||
|
inv_neta_raw = float(d.get('INV_NETA', 0.0))
|
||||||
|
cod_moneda = str(d.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
|
||||||
|
# Buscar imp_tc desde el CRONOGRAMA (no del comprobante)
|
||||||
|
imp_tc = 0
|
||||||
|
cuotas_alumno = cuotas_por_mat.get(mat_id, [])
|
||||||
|
for c_aux in cuotas_alumno:
|
||||||
|
try:
|
||||||
|
tc_aux = float(c_aux.get('imp_tc', 0) or 0)
|
||||||
|
if tc_aux >= 2: # Tomar el primer TC válido del cronograma
|
||||||
|
imp_tc = tc_aux
|
||||||
|
break
|
||||||
|
except: continue
|
||||||
|
if imp_tc < 2: imp_tc = 3.45 # TC por defecto si no hay cronograma o es inválido
|
||||||
|
|
||||||
|
saldo_mat = float(d.get('imp_saldo_matricula', 0.0))
|
||||||
|
saldo_c1 = float(d.get('imp_saldo_cuota1', 0.0))
|
||||||
|
fch_canc_raw = d.get('fch_cancelacion_cuota1')
|
||||||
|
fch_mat = d.get('fch_matricula')
|
||||||
|
|
||||||
|
# Override de fecha cancelación 1 (Supabase) → prioridad para clasificación + cuenta como pagado
|
||||||
|
_fov = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
|
||||||
|
_mat_key = str(mat_id).strip()
|
||||||
|
if _mat_key.endswith('.0'): _mat_key = _mat_key[:-2]
|
||||||
|
tiene_override_fecha = _mat_key in _fov
|
||||||
|
fecha_vaciada_override = False # override "borrar" → tiene prioridad sobre todo
|
||||||
|
if tiene_override_fecha:
|
||||||
|
s = str(_fov[_mat_key]).strip()
|
||||||
|
if s == "__VACIO__":
|
||||||
|
# Fecha borrada a propósito → anula la del SQL (no cuenta como pagado)
|
||||||
|
fch_canc_raw = None
|
||||||
|
tiene_override_fecha = False
|
||||||
|
fecha_vaciada_override = True
|
||||||
|
else:
|
||||||
|
# Normalizar dd/mm/yyyy o d/m/yyyy → yyyy-mm-dd
|
||||||
|
if '/' in s:
|
||||||
|
p = s.split('/')
|
||||||
|
if len(p) == 3:
|
||||||
|
s = f"{p[2]}-{int(p[1]):02d}-{int(p[0]):02d}"
|
||||||
|
fch_canc_raw = s
|
||||||
|
|
||||||
|
if mat_id in correcciones_mat:
|
||||||
|
datos_corregidos = correcciones_mat[mat_id]
|
||||||
|
if "imp_tc" in datos_corregidos: imp_tc = float(datos_corregidos["imp_tc"])
|
||||||
|
if "imp_saldo_cuota1" in datos_corregidos: saldo_c1 = float(datos_corregidos["imp_saldo_cuota1"])
|
||||||
|
if "imp_saldo_matricula" in datos_corregidos: saldo_mat = float(datos_corregidos["imp_saldo_matricula"])
|
||||||
|
# Supabase (vaciado) tiene prioridad: la corrección manual NO restaura la fecha
|
||||||
|
if "fch_cancelacion_cuota1" in datos_corregidos and not fecha_vaciada_override:
|
||||||
|
fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"]
|
||||||
|
|
||||||
|
# Prioridad TC: Supabase (mes) > corrección > cronograma
|
||||||
|
_tc_ov = getattr(self.data_manager, '_tc_override_mes', None)
|
||||||
|
if _tc_ov: imp_tc = float(_tc_ov)
|
||||||
|
|
||||||
|
if cod_moneda == 'DOL': inv_neta_soles = round(inv_neta_raw * imp_tc, 2)
|
||||||
|
else: inv_neta_soles = round(inv_neta_raw, 2)
|
||||||
|
|
||||||
|
row_year, row_month = -1, -1
|
||||||
|
fecha_mat_limpia = ""
|
||||||
|
if fch_mat:
|
||||||
|
try:
|
||||||
|
if hasattr(fch_mat, 'strftime'):
|
||||||
|
row_year, row_month = fch_mat.year, fch_mat.month
|
||||||
|
fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
f_obj = datetime.strptime(str(fch_mat)[:10], '%Y-%m-%d')
|
||||||
|
row_year, row_month = f_obj.year, f_obj.month
|
||||||
|
fecha_mat_limpia = f_obj.strftime('%d/%m/%Y')
|
||||||
|
except: fecha_mat_limpia = str(fch_mat)[:10]
|
||||||
|
|
||||||
|
fch_canc_limpia = ""
|
||||||
|
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
|
||||||
|
try:
|
||||||
|
if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
tmp = str(fch_canc_raw)[:10]
|
||||||
|
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
else: fch_canc_limpia = tmp
|
||||||
|
except: fch_canc_limpia = str(fch_canc_raw)[:10]
|
||||||
|
|
||||||
|
clase_alumno = self.clasificar_programa(nombre_prog_crudo)
|
||||||
|
linea_actual = self.identificar_linea_carrera(nombre_prog_crudo)
|
||||||
|
fecha_inicio_actual = fecha_inicio_prog.get(nombre_prog_crudo)
|
||||||
|
|
||||||
|
es_refriperu = False
|
||||||
|
if mat_id in correcciones_desc:
|
||||||
|
if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True
|
||||||
|
elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False
|
||||||
|
else:
|
||||||
|
if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True
|
||||||
|
elif clase_alumno in ["TEAC", "TERC", "CARRERA"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True
|
||||||
|
|
||||||
|
tipo_final = "NUEVO"
|
||||||
|
if es_refriperu: tipo_final = "REFRIPERU"
|
||||||
|
else:
|
||||||
|
es_cont = False
|
||||||
|
if mat_id in correcciones_cont:
|
||||||
|
if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True
|
||||||
|
elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False
|
||||||
|
else:
|
||||||
|
if fecha_inicio_actual and linea_actual and (dni in datos_historial):
|
||||||
|
for antecedente in datos_historial[dni]:
|
||||||
|
nombre_pasado = str(antecedente.get('programa', ''))
|
||||||
|
fecha_pasada = antecedente.get('fecha')
|
||||||
|
if not fecha_pasada: continue
|
||||||
|
if not (fecha_pasada < fecha_inicio_actual): continue
|
||||||
|
dias_diff = (fecha_inicio_actual - fecha_pasada).days
|
||||||
|
if dias_diff < 60: continue
|
||||||
|
linea_pasada = self.identificar_linea_carrera(nombre_pasado)
|
||||||
|
if linea_pasada == linea_actual:
|
||||||
|
es_cont = True
|
||||||
|
break
|
||||||
|
if es_cont: tipo_final = "CONTINUIDAD"
|
||||||
|
|
||||||
|
# ===============================================================
|
||||||
|
# 🔥 MATEMÁTICA PURA EXACTAMENTE COMO LA PEDISTE 🔥
|
||||||
|
# ===============================================================
|
||||||
|
suma_cuotas = 0.0
|
||||||
|
mis_cuotas = cuotas_por_mat.get(mat_id, [])
|
||||||
|
|
||||||
|
for c_dict in mis_cuotas:
|
||||||
|
try: num_c = int(c_dict.get('num_cuota', 0))
|
||||||
|
except: num_c = 0
|
||||||
|
|
||||||
|
# REGLA: Si el número de cuota es mayor a 0, sumar (imp_total - imp_dscto)
|
||||||
|
if num_c > 0:
|
||||||
|
t_val = c_dict.get('imp_total', 0)
|
||||||
|
d_val = c_dict.get('imp_dscto', 0)
|
||||||
|
|
||||||
|
try: t_monto = float(t_val) if t_val is not None else 0.0
|
||||||
|
except: t_monto = 0.0
|
||||||
|
try: d_monto = float(d_val) if d_val is not None else 0.0
|
||||||
|
except: d_monto = 0.0
|
||||||
|
|
||||||
|
imp_c = t_monto - d_monto
|
||||||
|
|
||||||
|
# Conversión si es Dólares
|
||||||
|
tc_over = None
|
||||||
|
if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]:
|
||||||
|
tc_over = float(correcciones_mat[mat_id]["imp_tc"])
|
||||||
|
|
||||||
|
cod_m_c = str(c_dict.get('cod_moneda', 'SOL')).strip().upper()
|
||||||
|
if cod_m_c == "DOL":
|
||||||
|
if tc_over is not None: tc_c = tc_over
|
||||||
|
else:
|
||||||
|
try: tc_c = float(c_dict.get('imp_tc', 0) or 0)
|
||||||
|
except: tc_c = 0
|
||||||
|
if tc_c < 2: tc_c = 3.45 # TC por defecto si es inválido
|
||||||
|
imp_c *= tc_c
|
||||||
|
|
||||||
|
suma_cuotas += imp_c
|
||||||
|
|
||||||
|
# ===============================================================
|
||||||
|
# DIVISIÓN SEGÚN CLASIFICACIÓN
|
||||||
|
# ===============================================================
|
||||||
|
if suma_cuotas > 0:
|
||||||
|
if clase_alumno in ["TEAC", "TERC", "CARRERA"]:
|
||||||
|
valor_cuota_final = suma_cuotas / 5.0
|
||||||
|
elif clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"]:
|
||||||
|
valor_cuota_final = suma_cuotas / 6.0
|
||||||
|
else:
|
||||||
|
valor_cuota_final = suma_cuotas
|
||||||
|
|
||||||
|
str_cuota_val = f"S/ {valor_cuota_final:,.0f}"
|
||||||
|
else:
|
||||||
|
valor_cuota_final = 0.0
|
||||||
|
# Si no tiene cuotas mayores a 0, se queda en blanco limpio
|
||||||
|
str_cuota_val = ""
|
||||||
|
|
||||||
|
# ===============================================================
|
||||||
|
# LÓGICA DESC. ESPECIAL (Sobre la suma pura)
|
||||||
|
# ===============================================================
|
||||||
|
desc_e_raw = 0.0
|
||||||
|
if suma_cuotas >= 1900:
|
||||||
|
if clase_alumno in ["TEAC", "TERC", "CARRERA"]: d_calc = 3400 - suma_cuotas
|
||||||
|
elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - suma_cuotas
|
||||||
|
else: d_calc = 0
|
||||||
|
|
||||||
|
if d_calc > 0:
|
||||||
|
desc_e_raw = min(max(d_calc - 200, 0.0), 200.0)
|
||||||
|
|
||||||
|
str_desc_e_val = f"S/ {desc_e_raw:,.0f}" if desc_e_raw > 0 else ""
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# DISTRIBUCIÓN EN LAS 3 LISTAS
|
||||||
|
# ==========================================
|
||||||
|
vendedor_real = d.get('dsc_vendedor', 'SIN VENDEDOR')
|
||||||
|
if not vendedor_real: vendedor_real = 'SIN VENDEDOR'
|
||||||
|
|
||||||
|
# Campos extra para el popup de Comisiones (mismo alumno / num_indice)
|
||||||
|
# NOTA: la query trae el programa DETALLADO con alias 'dsc_promocion'
|
||||||
|
# (sgede_RP_programa.dsc_det_programa AS dsc_promocion).
|
||||||
|
# MOSTRAR: prioriza el detallado (dsc_promocion)
|
||||||
|
det_programa = (d.get('dsc_promocion') or d.get('dsc_det_programa')
|
||||||
|
or d.get('dsc_programa') or '')
|
||||||
|
# CLASIFICAR (sede/filtro): prioriza dsc_programa
|
||||||
|
prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa')
|
||||||
|
or d.get('dsc_promocion') or '')
|
||||||
|
fch_ini_raw = d.get('fch_inicio', '')
|
||||||
|
fch_ini_limpia = ""
|
||||||
|
if fch_ini_raw and str(fch_ini_raw).strip() != "None":
|
||||||
|
try:
|
||||||
|
if hasattr(fch_ini_raw, 'strftime'): fch_ini_limpia = fch_ini_raw.strftime('%d/%m/%Y')
|
||||||
|
else:
|
||||||
|
t = str(fch_ini_raw)[:10]
|
||||||
|
if len(t) == 10 and t[4] == '-':
|
||||||
|
p = t.split('-'); fch_ini_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||||
|
else: fch_ini_limpia = t
|
||||||
|
except: fch_ini_limpia = str(fch_ini_raw)[:10]
|
||||||
|
str_saldo_mat = f"S/ {saldo_mat:,.0f}" if saldo_mat else "S/ 0"
|
||||||
|
str_saldo_c1 = f"S/ {saldo_c1:,.0f}" if saldo_c1 else "S/ 0"
|
||||||
|
|
||||||
|
# ── Cálculos extra para Comisiones ──────────────────────────
|
||||||
|
# DÍAS ANTICIPACIÓN = Fecha Inicio - Fecha Cancelación 1 (en días)
|
||||||
|
dias_anticipacion = "-"
|
||||||
|
try:
|
||||||
|
def _to_date(v):
|
||||||
|
if not v or str(v).strip() in ("", "None"): return None
|
||||||
|
if hasattr(v, 'year'): return datetime(v.year, v.month, v.day)
|
||||||
|
s = str(v)[:10]
|
||||||
|
if len(s) == 10 and s[4] == '-': return datetime.strptime(s, '%Y-%m-%d')
|
||||||
|
if len(s) == 10 and s[2] == '/':
|
||||||
|
p = s.split('/'); return datetime(int(p[2]), int(p[1]), int(p[0]))
|
||||||
|
return None
|
||||||
|
di = _to_date(fch_ini_raw)
|
||||||
|
dc = _to_date(fch_canc_raw)
|
||||||
|
if di and dc:
|
||||||
|
dias_anticipacion = str((di - dc).days)
|
||||||
|
except Exception:
|
||||||
|
dias_anticipacion = "-"
|
||||||
|
|
||||||
|
# TIPO PROGRAMA y sede (clasificación tipo Cobranza vía sede.json)
|
||||||
|
sede_alumno = self._clasificar_sede_comisiones(prog_clasificacion or nombre_prog_crudo)
|
||||||
|
tipo_programa = sede_alumno # según sede.json (LIMA/AREQUIPA/PIURA/TRUJILLO)
|
||||||
|
|
||||||
|
# VALOR CUOTA ADICIONAL = Promedio cuota - 640 (solo si sede = LIMA, mínimo 0)
|
||||||
|
if sede_alumno == "LIMA" and valor_cuota_final > 0:
|
||||||
|
valor_adicional = valor_cuota_final - 640
|
||||||
|
if valor_adicional < 0:
|
||||||
|
valor_adicional = 0
|
||||||
|
str_valor_adicional = f"S/ {valor_adicional:,.0f}"
|
||||||
|
else:
|
||||||
|
str_valor_adicional = "-"
|
||||||
|
|
||||||
|
fila_final = [
|
||||||
|
vendedor_real, alumno_nombre, fecha_mat_limpia, fch_canc_limpia,
|
||||||
|
tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}",
|
||||||
|
# Extra para Comisiones (índices 8+): programa, fch inicio, saldos
|
||||||
|
det_programa, fch_ini_limpia, str_saldo_mat, str_saldo_c1,
|
||||||
|
# Nuevos (índices 12+): días anticipación, valor cuota adicional, tipo programa
|
||||||
|
dias_anticipacion, str_valor_adicional, tipo_programa,
|
||||||
|
# Índice [15]: num_matricula (clave para overrides de Comisiones)
|
||||||
|
mat_id,
|
||||||
|
# Índice [16]: nombre para CLASIFICAR (prioriza dsc_programa). Oculto en UI.
|
||||||
|
prog_clasificacion
|
||||||
|
]
|
||||||
|
|
||||||
|
es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes_numero))
|
||||||
|
saldos_ok = (saldo_mat == 0 and saldo_c1 == 0)
|
||||||
|
if tiene_override_fecha:
|
||||||
|
saldos_ok = True
|
||||||
|
|
||||||
|
def agregar_a_lista(nombre_lista):
|
||||||
|
listas_datos[nombre_lista]["filas"].append(fila_final)
|
||||||
|
listas_datos[nombre_lista]["s_neta"] += inv_neta_soles
|
||||||
|
|
||||||
|
if valor_cuota_final > 0:
|
||||||
|
listas_datos[nombre_lista]["s_cuota"] += valor_cuota_final
|
||||||
|
listas_datos[nombre_lista]["c_cuota"] += 1
|
||||||
|
|
||||||
|
if desc_e_raw > 0:
|
||||||
|
listas_datos[nombre_lista]["s_desc"] += desc_e_raw
|
||||||
|
listas_datos[nombre_lista]["c_desc"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas
|
||||||
|
# Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas
|
||||||
|
pago_en_fecha = False
|
||||||
|
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
|
||||||
|
try:
|
||||||
|
if isinstance(fch_canc_raw, str):
|
||||||
|
f_obj = datetime.strptime(fch_canc_raw[:10], '%Y-%m-%d')
|
||||||
|
f_ano, f_mes = f_obj.year, f_obj.month
|
||||||
|
else:
|
||||||
|
f_ano, f_mes = fch_canc_raw.year, fch_canc_raw.month
|
||||||
|
|
||||||
|
if str(f_ano) == str(ano) and str(f_mes) == str(mes_numero):
|
||||||
|
pago_en_fecha = True
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# ¿Matrícula de un mes ANTERIOR al filtro?
|
||||||
|
matricula_mes_pasado = False
|
||||||
|
try:
|
||||||
|
ym_mat = int(row_year) * 100 + int(row_month)
|
||||||
|
ym_filtro = int(ano) * 100 + int(mes_numero)
|
||||||
|
matricula_mes_pasado = ym_mat < ym_filtro
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if es_venta_del_mes:
|
||||||
|
agregar_a_lista("Venta Inscritos")
|
||||||
|
if saldos_ok and pago_en_fecha:
|
||||||
|
agregar_a_lista("Venta P.C")
|
||||||
|
|
||||||
|
# MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro
|
||||||
|
if matricula_mes_pasado and saldos_ok and pago_en_fecha:
|
||||||
|
agregar_a_lista("Venta Pendientes")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# ORDENAR Y ARMAR TOTAL GENERAL
|
||||||
|
# ==========================================
|
||||||
|
resultado_final = {}
|
||||||
|
for k, dict_data in listas_datos.items():
|
||||||
|
dict_data["filas"].sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
p_cuota = (dict_data["s_cuota"] / dict_data["c_cuota"]) if dict_data["c_cuota"] > 0 else 0.0
|
||||||
|
p_desc = (dict_data["s_desc"] / dict_data["c_desc"]) if dict_data["c_desc"] > 0 else 0.0
|
||||||
|
|
||||||
|
str_gral_cuota = f"S/ {p_cuota:,.0f}" if dict_data["c_cuota"] > 0 else ""
|
||||||
|
str_gral_desc = f"S/ {p_desc:,.0f}" if dict_data["c_desc"] > 0 else ""
|
||||||
|
|
||||||
|
dict_data["filas"].append([
|
||||||
|
"TOTAL GENERAL", "", "", "", "",
|
||||||
|
str_gral_cuota, str_gral_desc, f"S/ {dict_data['s_neta']:,.0f}"
|
||||||
|
])
|
||||||
|
resultado_final[k] = dict_data["filas"]
|
||||||
|
|
||||||
|
return resultado_final
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error procesando alumnos para modal Ventas: {e}")
|
||||||
|
return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []}
|
||||||
|
|
||||||
|
def formatear_datos_para_tabla(self, datos):
|
||||||
|
if not datos:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sheet_data = []
|
||||||
|
total_monto = total_pc = total_pendientes = total_avance_pc_total = 0.0
|
||||||
|
total_cantidad = total_cantidad_pc = total_cantidad_pendientes = 0
|
||||||
|
|
||||||
|
for registro in datos:
|
||||||
|
vendedor = registro.get('VENDEDOR', 'SIN VENDEDOR')
|
||||||
|
monto = float(registro.get('MONTO', 0.0))
|
||||||
|
cantidad = int(registro.get('CANTIDAD', 0))
|
||||||
|
ventas_pc = float(registro.get('VENTAS_PC', 0.0))
|
||||||
|
cantidad_pc = int(registro.get('INSCRITOS_PC', 0))
|
||||||
|
pendientes = float(registro.get('PENDIENTES', 0.0))
|
||||||
|
cant_pendientes = int(registro.get('INSCRITOS_PENDIENTES', 0))
|
||||||
|
|
||||||
|
avance_pc_total = ventas_pc + pendientes
|
||||||
|
|
||||||
|
total_monto += monto; total_cantidad += cantidad; total_pc += ventas_pc
|
||||||
|
total_cantidad_pc += cantidad_pc; total_pendientes += pendientes
|
||||||
|
total_cantidad_pendientes += cant_pendientes; total_avance_pc_total += avance_pc_total
|
||||||
|
|
||||||
|
sheet_data.append([
|
||||||
|
vendedor, cantidad, f"S/ {monto:,.0f}", cantidad_pc, f"S/ {ventas_pc:,.0f}",
|
||||||
|
cant_pendientes, f"S/ {pendientes:,.0f}", f"S/ {avance_pc_total:,.0f}",
|
||||||
|
" ≡ ▼ "
|
||||||
|
])
|
||||||
|
|
||||||
|
sheet_data.append([
|
||||||
|
"TOTAL GENERAL", total_cantidad, f"S/ {total_monto:,.0f}",
|
||||||
|
total_cantidad_pc, f"S/ {total_pc:,.0f}", total_cantidad_pendientes,
|
||||||
|
f"S/ {total_pendientes:,.0f}", f"S/ {total_avance_pc_total:,.0f}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
|
||||||
|
return sheet_data
|
||||||
|
|
||||||
|
def exportar_detalle_alumnos_excel(self, vendedor, lista_nombre, headers, datos):
|
||||||
|
if not datos: raise ValueError("No hay datos para exportar")
|
||||||
|
vend_limpio = "".join([c if c.isalnum() else "_" for c in str(vendedor)])[:30]
|
||||||
|
lista_limpia = "".join([c if c.isalnum() else "_" for c in str(lista_nombre)])
|
||||||
|
archivo = f"Ventas_{vend_limpio}_{lista_limpia}.xlsx"
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos, columns=headers)
|
||||||
|
try:
|
||||||
|
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='Detalle_Ventas')
|
||||||
|
worksheet = writer.sheets['Detalle_Ventas']
|
||||||
|
try:
|
||||||
|
from openpyxl.styles import PatternFill, Font, Border, Side, Alignment
|
||||||
|
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
|
||||||
|
font_header = Font(color="FFFFFF", bold=True)
|
||||||
|
thin_border = Border(left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"),
|
||||||
|
top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD"))
|
||||||
|
|
||||||
|
for cell in worksheet[1]:
|
||||||
|
cell.fill = fill_header
|
||||||
|
cell.font = font_header
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
for row in worksheet.iter_rows(min_row=2):
|
||||||
|
for cell in row:
|
||||||
|
cell.border = thin_border
|
||||||
|
cell.alignment = Alignment(vertical="center")
|
||||||
|
|
||||||
|
for col in worksheet.columns:
|
||||||
|
max_len = 0
|
||||||
|
col_letter = col[0].column_letter
|
||||||
|
for cell in col:
|
||||||
|
if cell.value: max_len = max(max_len, len(str(cell.value)))
|
||||||
|
worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50)
|
||||||
|
except: pass
|
||||||
|
except: df.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
|
|
||||||
|
def exportar_a_excel(self, datos, ano, nombre_mes):
|
||||||
|
if not datos: raise ValueError("No hay datos para exportar")
|
||||||
|
datos_export = []
|
||||||
|
for d in datos:
|
||||||
|
nuevo_d = dict(d)
|
||||||
|
nuevo_d['AVANCE_PC_TOTAL'] = float(d.get('VENTAS_PC', 0)) + float(d.get('PENDIENTES', 0))
|
||||||
|
datos_export.append(nuevo_d)
|
||||||
|
|
||||||
|
df = pd.DataFrame(datos_export)
|
||||||
|
archivo = f"ventas_{ano}_{nombre_mes}.xlsx"
|
||||||
|
df.to_excel(archivo, index=False)
|
||||||
|
return archivo
|
||||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
pyodbc==5.1.0
|
||||||
|
requests==2.32.3
|
||||||
|
pandas==2.2.2
|
||||||
|
openpyxl==3.1.5
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
supabase==2.15.1
|
||||||
|
httpx==0.28.1
|
||||||
|
pytz==2024.1
|
||||||
587
backend/services.py
Normal file
587
backend/services.py
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
# backend/services.py
|
||||||
|
"""
|
||||||
|
Capa de servicios: envuelve la lógica existente (modules/) con caché.
|
||||||
|
NO modifica la lógica de negocio — solo la llama y serializa el resultado.
|
||||||
|
"""
|
||||||
|
from core.data_manager import DataManager
|
||||||
|
from modules.ocupabilidad.logic import AnalizadorCursos
|
||||||
|
from modules.ventas.logic import VentasLogic
|
||||||
|
from modules.cobranza.logic import CobranzaLogic
|
||||||
|
from modules.rentabilidad.logic import RentabilidadLogic
|
||||||
|
from modules.saldo_pendiente.logic import SaldoLogic
|
||||||
|
from cache_manager import cache_get_or_set
|
||||||
|
|
||||||
|
# Instancia única del DataManager (como @st.cache_resource)
|
||||||
|
_DM = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_dm() -> DataManager:
|
||||||
|
global _DM
|
||||||
|
if _DM is None:
|
||||||
|
_DM = DataManager()
|
||||||
|
return _DM
|
||||||
|
|
||||||
|
|
||||||
|
def _serializar_dicts(datos):
|
||||||
|
out = []
|
||||||
|
for d in (datos or []):
|
||||||
|
fila = {}
|
||||||
|
for k, v in d.items():
|
||||||
|
fila[k] = v if isinstance(v, (str, int, float, bool)) or v is None else str(v)
|
||||||
|
out.append(fila)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _serializar_filas(filas, limite=None):
|
||||||
|
out = []
|
||||||
|
for f in (filas or []):
|
||||||
|
vals = f[:limite] if limite else f
|
||||||
|
out.append([str(v) if v is not None else "" for v in vals])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── OCUPABILIDAD ───────────────────────────────────────────────────────────
|
||||||
|
def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
analizador = AnalizadorCursos(dm)
|
||||||
|
datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa)
|
||||||
|
filas = analizador.formatear_datos_para_tabla(datos)
|
||||||
|
return {
|
||||||
|
"datos": _serializar_dicts(datos),
|
||||||
|
"filas": _serializar_filas(filas),
|
||||||
|
}
|
||||||
|
return cache_get_or_set("ocupabilidad", (ano, mes, sede, programa), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── VENTAS ─────────────────────────────────────────────────────────────────
|
||||||
|
def ventas(ano, mes, sede="TODOS", programa="TODOS"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) # TC de Supabase o None
|
||||||
|
dm._fecha_canc_overrides = comisiones_overrides_fecha() # {mat: fecha_canc override}
|
||||||
|
dm._inv_neta_overrides = comisiones_overrides_inv_neta() # {mat: inversion_neta override}
|
||||||
|
logic = VentasLogic(dm)
|
||||||
|
datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa) \
|
||||||
|
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes))
|
||||||
|
datos = [d for d in datos if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR']
|
||||||
|
filas = logic.formatear_datos_para_tabla(datos)
|
||||||
|
return {
|
||||||
|
"filas": _serializar_filas(filas),
|
||||||
|
"datos": _serializar_dicts(datos),
|
||||||
|
}
|
||||||
|
return cache_get_or_set("ventas", (ano, mes, sede, programa), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def _clasif_filtro_programa(dm, dscp):
|
||||||
|
"""Misma clasificación de programa que ejecutar_consulta_ventas (SEMINARIOS/TEAC/TERC/OTROS)."""
|
||||||
|
up = str(dscp or "").upper()
|
||||||
|
fdata = dm.meta_data.get("clasificacion_filtro_programa", {})
|
||||||
|
for cat in ["OTROS", "SEMINARIOS", "TEAC", "TERC"]:
|
||||||
|
for pat in fdata.get(cat, {}).get("patrones", []):
|
||||||
|
if pat.upper() in up:
|
||||||
|
return cat
|
||||||
|
return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS"
|
||||||
|
|
||||||
|
|
||||||
|
def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes)
|
||||||
|
dm._fecha_canc_overrides = comisiones_overrides_fecha()
|
||||||
|
logic = VentasLogic(dm)
|
||||||
|
resultado = logic.obtener_detalle_vendedor(vendedor, str(ano), str(mes))
|
||||||
|
filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else []
|
||||||
|
filas = _aplicar_overrides_comisiones(filas)
|
||||||
|
# Filtro por SEDE [14] y PROGRAMA (clasificado desde el nombre [16]),
|
||||||
|
# mismas reglas que la tabla principal. Omite la fila TOTAL GENERAL.
|
||||||
|
_sede = str(sede or "TODOS").upper()
|
||||||
|
_prog = str(programa or "TODOS").upper()
|
||||||
|
if _sede != "TODOS" or _prog != "TODOS":
|
||||||
|
out = []
|
||||||
|
for f in filas:
|
||||||
|
if str(f[0]).strip().upper() == "TOTAL GENERAL":
|
||||||
|
continue # el TOTAL se recalcula en el frontend
|
||||||
|
if len(f) < 15:
|
||||||
|
out.append(f); continue
|
||||||
|
if _sede != "TODOS" and str(f[14]).strip().upper() != _sede:
|
||||||
|
continue
|
||||||
|
# Clasificar programa por el nombre de CLASIFICACIÓN [16] (prioriza dsc_programa);
|
||||||
|
# si no existe, usar el mostrado [8].
|
||||||
|
_prog_nombre = f[16] if len(f) > 16 else f[8]
|
||||||
|
if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog:
|
||||||
|
continue
|
||||||
|
out.append(f)
|
||||||
|
filas = out
|
||||||
|
return {"filas": _serializar_filas(filas)}
|
||||||
|
return cache_get_or_set("ventas_det", (vendedor, ano, mes, tipo_lista, sede, programa), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def comisiones_detalle_todos(ano, mes):
|
||||||
|
"""Devuelve TODOS los matriculados (todos los años/meses) para el buscador.
|
||||||
|
Aplica overrides de Supabase. Cacheado globalmente (no depende del mes)."""
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = VentasLogic(dm)
|
||||||
|
filas = logic.buscar_todos_matriculados()
|
||||||
|
filas = _aplicar_overrides_comisiones(filas)
|
||||||
|
return {"filas": _serializar_filas(filas)}
|
||||||
|
return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def comisiones_overrides_fecha():
|
||||||
|
"""Devuelve dict {num_matricula: fecha_cancelacion1_override} para clasificación."""
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute()
|
||||||
|
out = {}
|
||||||
|
for r in (res.data or []):
|
||||||
|
mat = str(r["num_matricula"])
|
||||||
|
v = r.get("fecha_cancelacion1")
|
||||||
|
sv = "" if v is None else str(v).strip()
|
||||||
|
# Solo "__VACIO__" = fecha borrada a propósito → anula la del SQL.
|
||||||
|
# None/"" = la fila tiene override de OTROS campos pero no toco la fecha -> usar SQL.
|
||||||
|
if sv == "__VACIO__":
|
||||||
|
out[mat] = "__VACIO__"
|
||||||
|
elif sv != "":
|
||||||
|
out[mat] = sv
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def comisiones_overrides_inv_neta():
|
||||||
|
"""Devuelve dict {num_matricula: inversion_neta(float)} para la tabla principal.
|
||||||
|
El valor es el monto final en soles (no se re-convierte por TC)."""
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.table("comisiones_overrides").select("num_matricula,inversion_neta").execute()
|
||||||
|
out = {}
|
||||||
|
for r in (res.data or []):
|
||||||
|
v = r.get("inversion_neta")
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
s = str(v).replace("S/", "").replace(",", "").strip()
|
||||||
|
if s == "":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out[str(r["num_matricula"])] = float(s)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _aplicar_overrides_comisiones(filas):
|
||||||
|
"""Sobreescribe valores de cada fila con los guardados en Supabase (por num_matricula).
|
||||||
|
Índices de fila: [2]F.MAT [7]INV.NETA [9]F.INI [10]SALDO MAT [11]SALDO C1
|
||||||
|
[12]DÍAS [13]VALOR ADIC [14]TIPO PROG [15]num_matricula. (5=PROMEDIO CUOTA)"""
|
||||||
|
try:
|
||||||
|
dm = get_dm()
|
||||||
|
if not getattr(dm, "supabase_client", None):
|
||||||
|
return filas
|
||||||
|
res = dm.supabase_client.table("comisiones_overrides").select("*").execute()
|
||||||
|
ov = {str(r["num_matricula"]): r for r in (res.data or [])}
|
||||||
|
if not ov:
|
||||||
|
return filas
|
||||||
|
# columna override -> índice en la fila
|
||||||
|
mapa = {
|
||||||
|
"fch_matricula": 2, "promedio_cuota": 5, "inversion_neta": 7,
|
||||||
|
"fecha_inicio": 9, "saldo_matricula": 10, "saldo_cuota1": 11,
|
||||||
|
"dias_anticipacion": 12, "valor_cuota_adicional": 13, "tipo_programa": 14,
|
||||||
|
"fecha_cancelacion1": 3,
|
||||||
|
}
|
||||||
|
for f in filas:
|
||||||
|
if len(f) < 16:
|
||||||
|
continue
|
||||||
|
mat = str(f[15])
|
||||||
|
# [16] = nombre para clasificar (si no vino, usar el mostrado [8])
|
||||||
|
# [17] = flag "tiene override" (0 por defecto)
|
||||||
|
if len(f) == 16:
|
||||||
|
f.append(f[8]) # buscador: no trae [16] → reutiliza el nombre mostrado
|
||||||
|
if len(f) == 17:
|
||||||
|
f.append("0")
|
||||||
|
if mat in ov:
|
||||||
|
r = ov[mat]
|
||||||
|
for col, idx in mapa.items():
|
||||||
|
val = r.get(col)
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
if str(val) == "__VACIO__":
|
||||||
|
f[idx] = "" # forzar vacío explícito (override "borrar")
|
||||||
|
elif str(val) != "":
|
||||||
|
f[idx] = val
|
||||||
|
f[17] = "1" # marcar fila como editada
|
||||||
|
return filas
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[overrides comisiones] {e}")
|
||||||
|
return filas
|
||||||
|
|
||||||
|
|
||||||
|
# ── COBRANZA ───────────────────────────────────────────────────────────────
|
||||||
|
def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = CobranzaLogic(dm)
|
||||||
|
filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion)
|
||||||
|
sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes))
|
||||||
|
return {
|
||||||
|
"filas": _serializar_filas(filas),
|
||||||
|
"sectoristas": sectoristas or ["TODOS"],
|
||||||
|
}
|
||||||
|
return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = CobranzaLogic(dm)
|
||||||
|
if agrupacion == "ASESOR":
|
||||||
|
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS")
|
||||||
|
elif agrupacion == "SEDE":
|
||||||
|
# Filtrar por sede usando el MISMO clasificador del processor (sede.json)
|
||||||
|
crudos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS")
|
||||||
|
grupo_up = str(grupo).upper()
|
||||||
|
mats_sede = set()
|
||||||
|
for d in (crudos or []):
|
||||||
|
prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "")
|
||||||
|
sede_det = str(logic.processor.clasificar_sede(prog)).upper()
|
||||||
|
if sede_det == grupo_up:
|
||||||
|
mats_sede.add(str(d.get("MATRICULA", "")))
|
||||||
|
todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS")
|
||||||
|
# Solo alumnos de la sede (sin la fila TOTAL global; el frontend recalcula el total)
|
||||||
|
datos = [f for f in (todas or []) if str(f[0]) in mats_sede]
|
||||||
|
else: # PROGRAMA
|
||||||
|
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo)
|
||||||
|
return {"filas": _serializar_filas(datos)}
|
||||||
|
return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── RENTABILIDAD ───────────────────────────────────────────────────────────
|
||||||
|
def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = RentabilidadLogic(dm)
|
||||||
|
datos = logic.obtener_datos_procesados(str(ano), str(mes), sede, filtro_prog)
|
||||||
|
filas = logic.formatear_datos_para_tabla(datos)
|
||||||
|
filas_s = _serializar_filas(filas, 13)
|
||||||
|
nombres = [str(f[0]) for f in filas_s if "TOTAL" not in str(f[0]).upper()]
|
||||||
|
return {"filas": filas_s, "programas": nombres}
|
||||||
|
return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def rentabilidad_detalle(programa, ano, mes):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = RentabilidadLogic(dm)
|
||||||
|
resultado = logic.obtener_detalle_programa(programa, str(ano), str(mes))
|
||||||
|
return {"filas": _serializar_filas(resultado)}
|
||||||
|
return cache_get_or_set("rentabilidad_det", (programa, ano, mes), _load)
|
||||||
|
|
||||||
|
|
||||||
|
def rentabilidad_costos(programa, ano, mes):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = RentabilidadLogic(dm)
|
||||||
|
return {"costos": logic.obtener_datos_costos_programa(programa, str(ano), str(mes))}
|
||||||
|
return cache_get_or_set("rentabilidad_costos", (programa, ano, mes), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SALDO PENDIENTE ────────────────────────────────────────────────────────
|
||||||
|
def saldo_pendiente(tipo_cuota):
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = SaldoLogic(dm)
|
||||||
|
datos = logic.obtener_saldos_consolidados(tipo_cuota)
|
||||||
|
return {"datos": _serializar_dicts(datos)}
|
||||||
|
return cache_get_or_set("saldo", (tipo_cuota,), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PRECARGA GLOBAL (calienta todo el caché) ───────────────────────────────
|
||||||
|
def precargar_todo():
|
||||||
|
from datetime import datetime
|
||||||
|
ahora = datetime.now()
|
||||||
|
ano, mes = ahora.year, ahora.month
|
||||||
|
tareas = [
|
||||||
|
lambda: ocupabilidad(ano, mes),
|
||||||
|
lambda: ventas(ano, mes),
|
||||||
|
lambda: cobranza(ano, mes, "TODOS", "SEDE"),
|
||||||
|
lambda: cobranza(ano, mes, "TODOS", "PROGRAMA"),
|
||||||
|
lambda: cobranza(ano, mes, "TODOS", "ASESOR"),
|
||||||
|
lambda: rentabilidad(ano, mes),
|
||||||
|
lambda: saldo_pendiente("1° Cuota"),
|
||||||
|
]
|
||||||
|
for t in tareas:
|
||||||
|
try:
|
||||||
|
t()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[precarga] error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
_refresh_count = 0
|
||||||
|
|
||||||
|
def _recalcular_clave(key):
|
||||||
|
"""Dada una clave 'prefijo:arg1|arg2|...', recalcula su valor sin usar el caché viejo."""
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
try:
|
||||||
|
prefijo, _, resto = key.partition(":")
|
||||||
|
args = resto.split("|") if resto else []
|
||||||
|
# Invalidar SOLO esta entrada para forzar su recálculo
|
||||||
|
cache_invalidate(prefijo + ":" + resto if resto else prefijo)
|
||||||
|
if prefijo == "ocupabilidad" and len(args) == 4:
|
||||||
|
return ocupabilidad(args[0], args[1], args[2], args[3])
|
||||||
|
if prefijo == "ventas" and len(args) == 2:
|
||||||
|
return ventas(args[0], args[1])
|
||||||
|
if prefijo == "cobranza" and len(args) == 4:
|
||||||
|
return cobranza(args[0], args[1], args[2], args[3])
|
||||||
|
if prefijo == "rentabilidad" and len(args) == 4:
|
||||||
|
return rentabilidad(args[0], args[1], args[2], args[3])
|
||||||
|
if prefijo == "saldo" and len(args) == 1:
|
||||||
|
return saldo_pendiente(args[0])
|
||||||
|
if prefijo == "cobranza_det_todos" and len(args) == 3:
|
||||||
|
return cobranza_detalle_todos(args[0], args[1], args[2])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[recalcular] {key}: {e}")
|
||||||
|
return None # los detalles puntuales se recalculan al pedirse
|
||||||
|
|
||||||
|
|
||||||
|
def refrescar_todo():
|
||||||
|
"""Refresca SOLO las entradas ya cacheadas (no vacía el caché), para que los
|
||||||
|
meses ya visitados se mantengan rápidos. Cada ~1h recarga config de GitHub."""
|
||||||
|
global _refresh_count
|
||||||
|
from cache_manager import cache_refresh_existing, cache_keys
|
||||||
|
_refresh_count += 1
|
||||||
|
# El hilo corre cada 900s (15 min); 4 ciclos ≈ 60 min → recargar config GitHub
|
||||||
|
if _refresh_count % 4 == 0:
|
||||||
|
try:
|
||||||
|
get_dm().cargar_toda_configuracion()
|
||||||
|
print("[config] Recarga de queries/JSON de GitHub completada")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[config] error al recargar GitHub: {e}")
|
||||||
|
|
||||||
|
# Si el caché está vacío (primer arranque), hacer precarga normal
|
||||||
|
if not cache_keys():
|
||||||
|
precargar_todo()
|
||||||
|
else:
|
||||||
|
cache_refresh_existing(_recalcular_clave)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GUARDAR COSTOS (override Supabase) ─────────────────────────────────────
|
||||||
|
def guardar_costos(num_indice, costos_inicial, costos_actual):
|
||||||
|
dm = get_dm()
|
||||||
|
ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial)
|
||||||
|
ok2 = dm.guardar_override_costo(num_indice, "actual", costos_actual)
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
cache_invalidate("rentabilidad")
|
||||||
|
cache_invalidate("rentabilidad_costos")
|
||||||
|
return bool(ok1 and ok2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLASIFICACIÓN DE SEDE (sede.json) ──────────────────────────────────────
|
||||||
|
def clasificar_programas(nombres):
|
||||||
|
"""Devuelve {programa: sede} usando el clasificador del processor (sede.json)."""
|
||||||
|
dm = get_dm()
|
||||||
|
logic = CobranzaLogic(dm)
|
||||||
|
return {n: str(logic.processor.clasificar_sede(n)) for n in nombres}
|
||||||
|
|
||||||
|
|
||||||
|
def cobranza_detalle_todos(ano, mes, sectorista="TODOS"):
|
||||||
|
"""Trae TODOS los alumnos en UNA sola consulta (para export y buscador)."""
|
||||||
|
def _load():
|
||||||
|
dm = get_dm()
|
||||||
|
logic = CobranzaLogic(dm)
|
||||||
|
brutos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS")
|
||||||
|
alumnos = []
|
||||||
|
for d in (brutos or []):
|
||||||
|
prog = d.get("Programa", d.get("PROGRAMA", "-")) or "-"
|
||||||
|
cta_ant = float(d.get("CTA_COB_ANT", 0.0)); cob_ant = float(d.get("COB_ANT", 0.0))
|
||||||
|
cta_cur = float(d.get("CTA_COB_MES_CURSO", 0.0)); cob_cur = float(d.get("COB_MES_CURSO", 0.0))
|
||||||
|
cta_tot = float(d.get("TOTAL_CTA_COB", 0.0)); cob_tot = float(d.get("TOTAL_COBRADO", 0.0))
|
||||||
|
saldo = float(d.get("SALDO", 0.0))
|
||||||
|
alumnos.append({
|
||||||
|
"matricula": str(d.get("MATRICULA", "")),
|
||||||
|
"alumno": d.get("ALUMNO", d.get("Alumno", "")),
|
||||||
|
"programa": prog,
|
||||||
|
"sede": str(logic.processor.clasificar_sede(prog)),
|
||||||
|
"frecuencia": d.get("FRECUENCIA", d.get("Frecuencia", "-")),
|
||||||
|
"num_cuota": d.get("NUM_CUOTA", "-"),
|
||||||
|
"fch_venc": d.get("FCH_VENC", "-"),
|
||||||
|
"cta_ant": cta_ant, "cob_ant": cob_ant,
|
||||||
|
"cta_cur": cta_cur, "cob_cur": cob_cur,
|
||||||
|
"cta_tot": cta_tot, "cob_tot": cob_tot,
|
||||||
|
"saldo": saldo,
|
||||||
|
})
|
||||||
|
return {"alumnos": alumnos}
|
||||||
|
return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GESTIÓN DE USUARIOS (Supabase Auth + tabla perfiles) ───────────────────
|
||||||
|
def _sb():
|
||||||
|
dm = get_dm()
|
||||||
|
if not dm.supabase_client:
|
||||||
|
raise RuntimeError("Supabase no está conectado")
|
||||||
|
return dm.supabase_client
|
||||||
|
|
||||||
|
def usuarios_listar():
|
||||||
|
sb = _sb()
|
||||||
|
perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or []
|
||||||
|
try:
|
||||||
|
users = sb.auth.admin.list_users()
|
||||||
|
lista = users if isinstance(users, list) else getattr(users, "users", [])
|
||||||
|
correo_por_id = {str(u.id): u.email for u in lista}
|
||||||
|
except Exception:
|
||||||
|
correo_por_id = {}
|
||||||
|
out = []
|
||||||
|
for p in perfiles:
|
||||||
|
out.append({
|
||||||
|
"id": p["id"],
|
||||||
|
"email": correo_por_id.get(str(p["id"]), "-"),
|
||||||
|
"nombre": p.get("nombre"),
|
||||||
|
"rol": p.get("rol"),
|
||||||
|
"activo": p.get("activo", True),
|
||||||
|
})
|
||||||
|
return {"usuarios": out}
|
||||||
|
|
||||||
|
def usuarios_crear(email, password, nombre, rol):
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.auth.admin.create_user({
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"email_confirm": True,
|
||||||
|
})
|
||||||
|
user = getattr(res, "user", None) or res
|
||||||
|
uid = str(user.id)
|
||||||
|
sb.table("perfiles").upsert({"id": uid, "nombre": nombre or email, "rol": rol, "activo": True}).execute()
|
||||||
|
return {"status": "ok", "id": uid}
|
||||||
|
|
||||||
|
def usuarios_actualizar_rol(user_id, rol):
|
||||||
|
sb = _sb()
|
||||||
|
sb.table("perfiles").update({"rol": rol}).eq("id", user_id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def usuarios_eliminar(user_id):
|
||||||
|
sb = _sb()
|
||||||
|
sb.auth.admin.delete_user(user_id)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── VENDEDORES MANUALES (Comisiones, en Supabase) ──────────────────────────
|
||||||
|
# ── CONFIG MENSUAL COMISIONES (IMP_TC, META, COMISIÓN por tipo de programa) ──
|
||||||
|
def comisiones_config_listar(ano, mes):
|
||||||
|
"""Lista la config de un mes (todas las filas por tipo_programa)."""
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute()
|
||||||
|
return {"config": res.data or []}
|
||||||
|
|
||||||
|
def comisiones_config_guardar(ano, mes, filas):
|
||||||
|
"""Reemplaza la config del mes. filas = [{tipo_programa, imp_tc, meta, comision}]."""
|
||||||
|
sb = _sb()
|
||||||
|
sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute()
|
||||||
|
payload = []
|
||||||
|
for f in filas:
|
||||||
|
payload.append({
|
||||||
|
"ano": int(ano), "mes": int(mes),
|
||||||
|
"tipo_programa": f.get("tipo_programa", ""),
|
||||||
|
"imp_tc": float(f.get("imp_tc", 0) or 0),
|
||||||
|
"meta": float(f.get("meta", 0) or 0),
|
||||||
|
"comision": float(f.get("comision", 0) or 0),
|
||||||
|
})
|
||||||
|
if payload:
|
||||||
|
sb.table("comisiones_config_mensual").insert(payload).execute()
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
cache_invalidate("ventas")
|
||||||
|
cache_invalidate("ventas_det")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def comisiones_tc_del_mes(ano, mes):
|
||||||
|
"""Devuelve el IMP_TC configurado para el mes (toma el primero que tenga TC > 0), o None."""
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute()
|
||||||
|
for r in (res.data or []):
|
||||||
|
tc = float(r.get("imp_tc", 0) or 0)
|
||||||
|
if tc > 0: return tc
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def vendedores_manuales_listar(ano, mes):
|
||||||
|
sb = _sb()
|
||||||
|
query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano))
|
||||||
|
if int(mes) != 0: # mes=0 → traer todo el año (para cache en frontend)
|
||||||
|
query = query.eq("mes", int(mes))
|
||||||
|
res = query.execute()
|
||||||
|
return {"vendedores": res.data or []}
|
||||||
|
|
||||||
|
def vendedores_manuales_crear(nombre, descripcion, fch_emision, monto, ano, mes):
|
||||||
|
sb = _sb()
|
||||||
|
payload = {
|
||||||
|
"nombre": nombre, "descripcion": descripcion or "",
|
||||||
|
"fch_emision": fch_emision or "", "monto": float(monto or 0),
|
||||||
|
"ano": int(ano), "mes": int(mes),
|
||||||
|
}
|
||||||
|
res = sb.table("vendedores_manuales").insert(payload).execute()
|
||||||
|
return {"status": "ok", "data": res.data}
|
||||||
|
|
||||||
|
def vendedores_manuales_eliminar(id):
|
||||||
|
sb = _sb()
|
||||||
|
sb.table("vendedores_manuales").delete().eq("id", id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto):
|
||||||
|
sb = _sb()
|
||||||
|
sb.table("vendedores_manuales").update({
|
||||||
|
"descripcion": descripcion or "", "fch_emision": fch_emision or "",
|
||||||
|
"monto": float(monto or 0),
|
||||||
|
}).eq("id", id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def vendedores_manuales_guardar_lote(nombre, ano, mes, filas):
|
||||||
|
"""Reemplaza TODAS las filas de un vendedor manual en una sola operación.
|
||||||
|
filas = lista de dicts {descripcion, fch_emision, monto}. Si filas vacía → elimina el vendedor."""
|
||||||
|
sb = _sb()
|
||||||
|
# Borrar las existentes de ese vendedor/mes
|
||||||
|
sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute()
|
||||||
|
# Insertar las nuevas en un solo batch
|
||||||
|
payload = []
|
||||||
|
for f in filas:
|
||||||
|
payload.append({
|
||||||
|
"nombre": nombre,
|
||||||
|
"descripcion": f.get("descripcion","") or "",
|
||||||
|
"fch_emision": f.get("fch_emision","") or "",
|
||||||
|
"monto": float(f.get("monto",0) or 0),
|
||||||
|
"ano": int(ano), "mes": int(mes),
|
||||||
|
})
|
||||||
|
if payload:
|
||||||
|
sb.table("vendedores_manuales").insert(payload).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── OVERRIDES DE COMISIONES (editar valores por num_matricula) ─────────────
|
||||||
|
def comisiones_override_guardar(registros):
|
||||||
|
"""Guarda/actualiza overrides en UNA sola operación (batch upsert)."""
|
||||||
|
sb = _sb()
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
validos = [r for r in registros if r.get("num_matricula")]
|
||||||
|
if validos:
|
||||||
|
sb.table("comisiones_overrides").upsert(validos).execute()
|
||||||
|
cache_invalidate("ventas_det")
|
||||||
|
cache_invalidate("ventas")
|
||||||
|
cache_invalidate("comisiones_det_todos")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def comisiones_override_restaurar(num_matricula):
|
||||||
|
"""Borra el override de un alumno (vuelve a los valores de SQL)."""
|
||||||
|
sb = _sb()
|
||||||
|
from cache_manager import cache_invalidate
|
||||||
|
sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute()
|
||||||
|
cache_invalidate("ventas_det")
|
||||||
|
cache_invalidate("ventas")
|
||||||
|
cache_invalidate("comisiones_det_todos")
|
||||||
|
return {"status": "ok"}
|
||||||
1
frontend
1
frontend
Submodule frontend deleted from d6c65c44b1
5
frontend/.env.example
Normal file
5
frontend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Credenciales públicas de Supabase para el login (frontend)
|
||||||
|
# La URL es la misma de tu proyecto.
|
||||||
|
# La ANON KEY es la PÚBLICA (anon/public), NO la service_role.
|
||||||
|
VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=pega_aqui_tu_anon_public_key
|
||||||
4
frontend/.gitignore
vendored
Normal file
4
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
29
frontend/README.md
Normal file
29
frontend/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend — Dashboard React
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
- Node.js 18+ (descarga: https://nodejs.org)
|
||||||
|
|
||||||
|
## Instalación (una sola vez)
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejecutar
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Abre: http://localhost:3000
|
||||||
|
|
||||||
|
⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000
|
||||||
|
(en otra terminal: `py -3.12 main.py`)
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
- ✅ Sidebar + navegación
|
||||||
|
- ✅ Ocupabilidad (conectado al backend)
|
||||||
|
- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones)
|
||||||
|
|
||||||
|
## Cambiar IP del backend
|
||||||
|
Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3:
|
||||||
|
```js
|
||||||
|
const BASE_URL = "http://IP_DEL_SERVIDOR:8000";
|
||||||
|
```
|
||||||
5
frontend/frontend/.env.example
Normal file
5
frontend/frontend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Credenciales públicas de Supabase para el login (frontend)
|
||||||
|
# La URL es la misma de tu proyecto.
|
||||||
|
# La ANON KEY es la PÚBLICA (anon/public), NO la service_role.
|
||||||
|
VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=pega_aqui_tu_anon_public_key
|
||||||
4
frontend/frontend/.gitignore
vendored
Normal file
4
frontend/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
29
frontend/frontend/README.md
Normal file
29
frontend/frontend/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend — Dashboard React
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
- Node.js 18+ (descarga: https://nodejs.org)
|
||||||
|
|
||||||
|
## Instalación (una sola vez)
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejecutar
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Abre: http://localhost:3000
|
||||||
|
|
||||||
|
⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000
|
||||||
|
(en otra terminal: `py -3.12 main.py`)
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
- ✅ Sidebar + navegación
|
||||||
|
- ✅ Ocupabilidad (conectado al backend)
|
||||||
|
- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones)
|
||||||
|
|
||||||
|
## Cambiar IP del backend
|
||||||
|
Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3:
|
||||||
|
```js
|
||||||
|
const BASE_URL = "http://IP_DEL_SERVIDOR:8000";
|
||||||
|
```
|
||||||
12
frontend/frontend/index.html
Normal file
12
frontend/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!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>
|
||||||
1980
frontend/frontend/package-lock.json
generated
Normal file
1980
frontend/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/frontend/package.json
Normal file
22
frontend/frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboard-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.107.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
|
"xlsx-js-style": "^1.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
frontend/frontend/src/App.jsx
Normal file
65
frontend/frontend/src/App.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// src/App.jsx
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useAuth } from "./lib/auth";
|
||||||
|
import Sidebar from "./components/Sidebar";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Ocupabilidad from "./pages/Ocupabilidad";
|
||||||
|
import Ventas from "./pages/Ventas";
|
||||||
|
import Comisiones from "./pages/Comisiones";
|
||||||
|
import Cobranza from "./pages/Cobranza";
|
||||||
|
import Rentabilidad from "./pages/Rentabilidad";
|
||||||
|
import Asesores from "./pages/Asesores";
|
||||||
|
import Usuarios from "./pages/Usuarios";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { autenticado, cargando, permisos, puedeVer } = useAuth();
|
||||||
|
const [pagina, setPagina] = useState(null);
|
||||||
|
|
||||||
|
// Al entrar, seleccionar el primer módulo permitido
|
||||||
|
useEffect(() => {
|
||||||
|
if (autenticado && permisos.length > 0 && !pagina) {
|
||||||
|
setPagina(permisos[0]);
|
||||||
|
}
|
||||||
|
if (!autenticado) setPagina(null);
|
||||||
|
}, [autenticado, permisos, pagina]);
|
||||||
|
|
||||||
|
if (cargando) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/frontend/src/components/Modal.jsx
Normal file
34
frontend/frontend/src/components/Modal.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/frontend/src/components/Sidebar.jsx
Normal file
54
frontend/frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// src/components/Sidebar.jsx
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
|
|
||||||
|
const MENU = [
|
||||||
|
{ id: "ocupabilidad", ico: "📊", label: "Ocupabilidad" },
|
||||||
|
{ id: "ventas", ico: "💰", label: "Ventas" },
|
||||||
|
{ id: "comisiones", ico: "🧮", label: "Comisiones" },
|
||||||
|
{ id: "cobranza", ico: "📋", label: "Cobranza" },
|
||||||
|
{ id: "rentabilidad", ico: "📈", label: "Rentabilidad" },
|
||||||
|
{ id: "asesores", ico: "👥", label: "Asesores" },
|
||||||
|
{ id: "usuarios", ico: "🔐", label: "Usuarios" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar({ active, onChange }) {
|
||||||
|
const { perfil, permisos, logout } = useAuth();
|
||||||
|
const visibles = MENU.filter((m) => permisos.includes(m.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/frontend/src/components/UI.jsx
Normal file
51
frontend/frontend/src/components/UI.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/frontend/src/lib/api.js
Normal file
73
frontend/frontend/src/lib/api.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// src/lib/api.js
|
||||||
|
// Cliente para el backend FastAPI. Cambia BASE_URL si el backend corre en otra IP.
|
||||||
|
const BASE_URL = "http://localhost:8000";
|
||||||
|
|
||||||
|
async function get(path, params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(path, body) {
|
||||||
|
const r = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
|
||||||
|
const r = await fetch(url, { method: "POST" });
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
periodoActual: () => get("/api/periodo-actual"),
|
||||||
|
ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
|
||||||
|
get("/api/ocupabilidad", { ano, mes, sede, programa }),
|
||||||
|
ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => get("/api/ventas", { ano, mes, sede, programa }),
|
||||||
|
ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") =>
|
||||||
|
get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }),
|
||||||
|
cobranza: (ano, mes, sectorista = "TODOS", agrupacion = "SEDE") =>
|
||||||
|
get("/api/cobranza", { ano, mes, sectorista, agrupacion }),
|
||||||
|
cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") =>
|
||||||
|
get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }),
|
||||||
|
rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
|
||||||
|
get("/api/rentabilidad", { ano, mes, sede, programa }),
|
||||||
|
rentabilidadDetalle: (programa, ano, mes) =>
|
||||||
|
get("/api/rentabilidad/detalle", { programa, ano, mes }),
|
||||||
|
rentabilidadCostos: (programa, ano, mes) =>
|
||||||
|
get("/api/rentabilidad/costos", { programa, ano, mes }),
|
||||||
|
saldoPendiente: (tipo_cuota) => get("/api/saldo-pendiente", { tipo_cuota }),
|
||||||
|
asesores: () => get("/api/asesores"),
|
||||||
|
asesorEstado: (agent_id, online) =>
|
||||||
|
post("/api/asesores/estado", { agent_id, online }),
|
||||||
|
guardarCostos: (num_indice, inicial, actual) =>
|
||||||
|
postJson("/api/rentabilidad/costos/guardar", { num_indice, inicial, actual }),
|
||||||
|
clasificarSede: (programas) =>
|
||||||
|
postJson("/api/cobranza/clasificar-sede", { programas }),
|
||||||
|
cobranzaDetalleTodos: (ano, mes, sectorista = "TODOS") =>
|
||||||
|
get("/api/cobranza/detalle-todos", { ano, mes, sectorista }),
|
||||||
|
usuariosListar: () => get("/api/usuarios"),
|
||||||
|
usuariosCrear: (email, password, nombre, rol) =>
|
||||||
|
postJson("/api/usuarios/crear", { email, password, nombre, rol }),
|
||||||
|
usuariosActualizarRol: (id, rol) => postJson("/api/usuarios/rol", { id, rol }),
|
||||||
|
usuariosEliminar: (id) => postJson("/api/usuarios/eliminar", { id }),
|
||||||
|
vendedoresManualesListar: (ano, mes) => get("/api/comisiones/vendedores", { ano, mes }),
|
||||||
|
vendedoresManualesCrear: (datos) => postJson("/api/comisiones/vendedores/crear", datos),
|
||||||
|
vendedoresManualesEliminar: (id) => postJson("/api/comisiones/vendedores/eliminar", { id }),
|
||||||
|
vendedoresManualesActualizar: (datos) => postJson("/api/comisiones/vendedores/actualizar", datos),
|
||||||
|
vendedoresManualesLote: (datos) => postJson("/api/comisiones/vendedores/lote", datos),
|
||||||
|
comisionesOverrideGuardar: (registros) => postJson("/api/comisiones/override/guardar", { registros }),
|
||||||
|
comisionesOverrideRestaurar: (num_matricula) => postJson("/api/comisiones/override/restaurar", { num_matricula }),
|
||||||
|
comisionesDetalleTodos: (ano, mes) => get("/api/comisiones/detalle-todos", { ano, mes }),
|
||||||
|
comisionesConfig: (ano, mes) => get("/api/comisiones/config", { ano, mes }),
|
||||||
|
comisionesConfigGuardar: (datos) => postJson("/api/comisiones/config/guardar", datos),
|
||||||
|
};
|
||||||
106
frontend/frontend/src/lib/auth.jsx
Normal file
106
frontend/frontend/src/lib/auth.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// src/lib/auth.jsx
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||||
|
import { supabase } from "./supabase";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [session, setSession] = useState(null);
|
||||||
|
const [perfil, setPerfil] = useState(null); // { nombre, rol }
|
||||||
|
const [permisos, setPermisos] = useState([]); // ["ocupabilidad", "cobranza", ...]
|
||||||
|
const [cargando, setCargando] = useState(true);
|
||||||
|
|
||||||
|
// Cargar perfil (rol) y permisos del usuario logueado
|
||||||
|
const cargarPerfil = useCallback(async (userId) => {
|
||||||
|
// 1) Traer el rol del usuario desde la tabla "perfiles"
|
||||||
|
const { data: perf, error: e1 } = await supabase
|
||||||
|
.from("perfiles")
|
||||||
|
.select("nombre, rol, activo")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (e1 || !perf || perf.activo === false) {
|
||||||
|
setPerfil(null);
|
||||||
|
setPermisos([]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Traer los módulos permitidos para ese rol desde "permisos"
|
||||||
|
const { data: perms } = await supabase
|
||||||
|
.from("permisos")
|
||||||
|
.select("modulo, puede_ver")
|
||||||
|
.eq("rol", perf.rol);
|
||||||
|
|
||||||
|
const modulos = (perms || [])
|
||||||
|
.filter((p) => p.puede_ver)
|
||||||
|
.map((p) => p.modulo);
|
||||||
|
|
||||||
|
setPerfil({ nombre: perf.nombre, rol: perf.rol });
|
||||||
|
setPermisos(modulos);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Al iniciar: revisar si ya hay sesión activa
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
supabase.auth.getSession().then(async ({ data }) => {
|
||||||
|
if (!activo) return;
|
||||||
|
const s = data?.session || null;
|
||||||
|
setSession(s);
|
||||||
|
if (s?.user) await cargarPerfil(s.user.id);
|
||||||
|
setCargando(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escuchar cambios de sesión (login/logout)
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => {
|
||||||
|
setSession(s);
|
||||||
|
if (s?.user) await cargarPerfil(s.user.id);
|
||||||
|
else { setPerfil(null); setPermisos([]); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { activo = false; sub?.subscription?.unsubscribe(); };
|
||||||
|
}, [cargarPerfil]);
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
|
if (error) return { ok: false, msg: traducirError(error.message) };
|
||||||
|
// Verificar que tenga perfil/rol válido
|
||||||
|
const ok = await cargarPerfil(data.user.id);
|
||||||
|
if (!ok) {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
return { ok: false, msg: "Tu usuario no tiene un perfil asignado. Contacta al administrador." };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
setPerfil(null);
|
||||||
|
setPermisos([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
session,
|
||||||
|
perfil,
|
||||||
|
permisos,
|
||||||
|
cargando,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
autenticado: !!session && !!perfil,
|
||||||
|
puedeVer: (modulo) => permisos.includes(modulo),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <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.";
|
||||||
|
}
|
||||||
10
frontend/frontend/src/lib/supabase.js
Normal file
10
frontend/frontend/src/lib/supabase.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// src/lib/supabase.js
|
||||||
|
// Cliente de Supabase para autenticación (login).
|
||||||
|
// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role).
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
// Estos valores se leen de frontend/.env (ver .env.example)
|
||||||
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
|
||||||
|
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
|
||||||
|
|
||||||
|
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||||
63
frontend/frontend/src/lib/useColumnasAjustables.jsx
Normal file
63
frontend/frontend/src/lib/useColumnasAjustables.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// src/lib/useColumnasAjustables.jsx
|
||||||
|
// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla.
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useColumnasAjustables(anchosIniciales) {
|
||||||
|
const [anchos, setAnchos] = useState(anchosIniciales);
|
||||||
|
|
||||||
|
// Si cambia el número de columnas (ej. Cobranza Sede↔Programa), reiniciar anchos
|
||||||
|
useEffect(() => {
|
||||||
|
if (anchos.length !== anchosIniciales.length) {
|
||||||
|
setAnchos(anchosIniciales);
|
||||||
|
}
|
||||||
|
}, [anchosIniciales.length]);
|
||||||
|
|
||||||
|
function iniciarResize(e, i) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const xInicial = e.clientX;
|
||||||
|
const anchoInicial = anchos[i];
|
||||||
|
function onMove(ev) {
|
||||||
|
const nuevo = Math.max(50, anchoInicial + (ev.clientX - xInicial));
|
||||||
|
setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; });
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve los elementos <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 };
|
||||||
|
}
|
||||||
13
frontend/frontend/src/main.jsx
Normal file
13
frontend/frontend/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import { AuthProvider } from "./lib/auth";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
105
frontend/frontend/src/pages/Asesores.jsx
Normal file
105
frontend/frontend/src/pages/Asesores.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// src/pages/Asesores.jsx
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox } from "../components/UI";
|
||||||
|
|
||||||
|
const ESTADO_INFO = {
|
||||||
|
online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" },
|
||||||
|
busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" },
|
||||||
|
offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Asesores() {
|
||||||
|
const [agentes, setAgentes] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [cambiando, setCambiando] = useState(null);
|
||||||
|
|
||||||
|
const cargar = useCallback(() => {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.asesores()
|
||||||
|
.then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); })
|
||||||
|
.catch((e)=>{ setError(e.message); setLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
async function toggle(ag) {
|
||||||
|
const online = ag.availability_status !== "online";
|
||||||
|
setCambiando(ag.id);
|
||||||
|
try {
|
||||||
|
await api.asesorEstado(ag.id, online);
|
||||||
|
setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a));
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
setCambiando(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function todos(online) {
|
||||||
|
setCambiando("all");
|
||||||
|
for (const ag of agentes) {
|
||||||
|
try { await api.asesorEstado(ag.id, online); } catch (e) {}
|
||||||
|
}
|
||||||
|
setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"})));
|
||||||
|
setCambiando(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = agentes.length;
|
||||||
|
const enLinea = agentes.filter((a)=>a.availability_status==="online").length;
|
||||||
|
const ocupados = agentes.filter((a)=>a.availability_status==="busy").length;
|
||||||
|
const offline = total - enLinea - ocupados;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
660
frontend/frontend/src/pages/Cobranza.jsx
Normal file
660
frontend/frontend/src/pages/Cobranza.jsx
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
// src/pages/Cobranza.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}];
|
||||||
|
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
|
||||||
|
const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"];
|
||||||
|
|
||||||
|
// Colores por cartera — diferenciados y legibles (sin azul, reservado para TOTAL)
|
||||||
|
// ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave
|
||||||
|
const BG_ANT = "#fef3c7", BG_MES = "#d1fae5", BG_TOT = "#ede9fe";
|
||||||
|
// Versiones más oscuras para hover de cada cartera
|
||||||
|
const HV_ANT = "#fde68a", HV_MES = "#a7f3d0", HV_TOT = "#ddd6fe";
|
||||||
|
|
||||||
|
function toMonto(s){ const n = parseFloat(String(s).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||||||
|
function toPct(s){ const n = parseFloat(String(s).replace("%","").trim()); return isNaN(n)?0:n; }
|
||||||
|
// Formato moneda SIN decimales (solo visualización)
|
||||||
|
function fmtMoneda(s){
|
||||||
|
if (s == null) return "";
|
||||||
|
if (!String(s).includes("S/")) return s; // no es monto, devolver tal cual
|
||||||
|
const n = toMonto(s);
|
||||||
|
return "S/ " + n.toLocaleString("es-PE", { maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniBar({ valStr }) {
|
||||||
|
if (!valStr || !String(valStr).includes("%")) return <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1004
frontend/frontend/src/pages/Comisiones.jsx
Normal file
1004
frontend/frontend/src/pages/Comisiones.jsx
Normal file
File diff suppressed because it is too large
Load Diff
82
frontend/frontend/src/pages/Login.jsx
Normal file
82
frontend/frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// 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",
|
||||||
|
};
|
||||||
194
frontend/frontend/src/pages/Ocupabilidad.jsx
Normal file
194
frontend/frontend/src/pages/Ocupabilidad.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// src/pages/Ocupabilidad.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||||||
|
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||||||
|
|
||||||
|
const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD"];
|
||||||
|
|
||||||
|
function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; }
|
||||||
|
|
||||||
|
export default function Ocupabilidad() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
|
const [mostrarRepro, setMostrarRepro] = useState(true);
|
||||||
|
const [datos, setDatos] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.ocupabilidad(ano, mes, sede, programa)
|
||||||
|
.then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } })
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sede, programa]);
|
||||||
|
|
||||||
|
// Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio
|
||||||
|
const datosVisibles = useMemo(() => {
|
||||||
|
if (mostrarRepro) return datos;
|
||||||
|
return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO");
|
||||||
|
}, [datos, mostrarRepro]);
|
||||||
|
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
if (!datosVisibles.length) return null;
|
||||||
|
const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
|
||||||
|
// Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio)
|
||||||
|
const validos = datosVisibles.filter((d) => !esRepro(d));
|
||||||
|
const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0);
|
||||||
|
const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0);
|
||||||
|
const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0);
|
||||||
|
const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0);
|
||||||
|
return {
|
||||||
|
cursos: validos.length, // solo programados (sin reprogramados)
|
||||||
|
reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO)
|
||||||
|
inscritos: totalInsc, // columna TOTAL INSCRITOS
|
||||||
|
meta,
|
||||||
|
avance: meta > 0 ? (totalInsc / meta * 100) : 0,
|
||||||
|
desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0,
|
||||||
|
sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length,
|
||||||
|
pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0),
|
||||||
|
};
|
||||||
|
}, [datosVisibles]);
|
||||||
|
|
||||||
|
const totales = useMemo(() => {
|
||||||
|
const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 };
|
||||||
|
datosVisibles.forEach((d) => {
|
||||||
|
t.mes += parseInt(d.Inscritos_Mes)||0;
|
||||||
|
t.total += parseInt(d.Inscritos_Totales)||0;
|
||||||
|
t.pc += parseInt(d.Inscritos_PC)||0;
|
||||||
|
t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0;
|
||||||
|
t.activos += parseInt(d.Inscritos_Activos)||0;
|
||||||
|
t.refri += parseInt(d.Descuento)||0;
|
||||||
|
t.cont += parseInt(d.Inscritos_Continuidad)||0;
|
||||||
|
t.meta += parseInt(d.Meta_Curso)||0;
|
||||||
|
});
|
||||||
|
return t;
|
||||||
|
}, [datosVisibles]);
|
||||||
|
|
||||||
|
const KPI_CARDS = kpis ? [
|
||||||
|
["📚","Cursos Activos", kpis.cursos, "programas este mes"],
|
||||||
|
["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"],
|
||||||
|
["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"],
|
||||||
|
["🎯","Meta Total", kpis.meta, "inscritos objetivo"],
|
||||||
|
["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"],
|
||||||
|
["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"],
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
async function exportarExcel() {
|
||||||
|
if (!datosVisibles.length) return;
|
||||||
|
// Cargar SheetJS desde CDN solo cuando se necesita
|
||||||
|
const XLSX = await import("xlsx");
|
||||||
|
const filas = datosVisibles.map((d) => ({
|
||||||
|
"PROGRAMA": d.programa_frecuencia || d.dsc_programa || "",
|
||||||
|
"FECHA INICIO": d.fch_inicio || "",
|
||||||
|
"DÍAS PARA INICIO": d.dias_para_inicio ?? "",
|
||||||
|
"TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0,
|
||||||
|
"RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0,
|
||||||
|
"INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0,
|
||||||
|
"META INSCRITOS": parseInt(d.Meta_Curso)||0,
|
||||||
|
"AVANCE INSCRITOS": d.Avance_Inscritos || "",
|
||||||
|
"INSCRITOS MES": parseInt(d.Inscritos_Mes)||0,
|
||||||
|
"INSCRITOS P.C": parseInt(d.Inscritos_PC)||0,
|
||||||
|
"INSCRITOS REFRIPERU": parseInt(d.Descuento)||0,
|
||||||
|
"INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0,
|
||||||
|
}));
|
||||||
|
const ws = XLSX.utils.json_to_sheet(filas);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad");
|
||||||
|
XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
frontend/frontend/src/pages/Rentabilidad.jsx
Normal file
270
frontend/frontend/src/pages/Rentabilidad.jsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
// src/pages/Rentabilidad.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
|
||||||
|
const PROGRAMAS = ["TODOS","SEMINARIOS","OTROS","TEAC","TERC"];
|
||||||
|
|
||||||
|
const COLS = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."];
|
||||||
|
// índices: inicial = 7,8,9 | actual = 10,11,12
|
||||||
|
const BG_INI = "#eef4ff", BG_ACT = "#edfff6";
|
||||||
|
|
||||||
|
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; }
|
||||||
|
|
||||||
|
export default function Rentabilidad() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
|
const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [modalVer, setModalVer] = useState(null);
|
||||||
|
// Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado
|
||||||
|
const IDX_INSCRITOS = [2, 3, 4];
|
||||||
|
const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i);
|
||||||
|
const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80];
|
||||||
|
const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i));
|
||||||
|
const cols = useColumnasAjustables(anchosVisibles);
|
||||||
|
const [modalCostos, setModalCostos] = useState(null);
|
||||||
|
|
||||||
|
function cargar() {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.rentabilidad(ano, mes, sede, programa)
|
||||||
|
.then((res) => { setFilas(res.filas || []); setLoading(false); })
|
||||||
|
.catch((e) => { setError(e.message); setLoading(false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.rentabilidad(ano, mes, sede, programa)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sede, programa]);
|
||||||
|
|
||||||
|
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);
|
||||||
|
const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]);
|
||||||
|
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
if (!total) return null;
|
||||||
|
return {
|
||||||
|
programas: datos.length,
|
||||||
|
enCurso: total[4],
|
||||||
|
ventaAct: total[10],
|
||||||
|
costoAct: total[11],
|
||||||
|
mbAct: total[12],
|
||||||
|
};
|
||||||
|
}, [datos, total]);
|
||||||
|
|
||||||
|
function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/frontend/src/pages/SaldoPendiente.jsx
Normal file
96
frontend/frontend/src/pages/SaldoPendiente.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// src/pages/SaldoPendiente.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
|
||||||
|
const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"];
|
||||||
|
|
||||||
|
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||||||
|
function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); }
|
||||||
|
|
||||||
|
export default function SaldoPendiente() {
|
||||||
|
const [tipoCuota, setTipoCuota] = useState("1° Cuota");
|
||||||
|
const [datos, setDatos] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [vendedor, setVendedor] = useState("TODOS");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.saldoPendiente(tipoCuota)
|
||||||
|
.then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} })
|
||||||
|
.catch((e)=>{ if(activo){setError(e.message);setLoading(false);} });
|
||||||
|
return ()=>{activo=false;};
|
||||||
|
}, [tipoCuota]);
|
||||||
|
|
||||||
|
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||||
|
const vendedores = useMemo(() => {
|
||||||
|
const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean));
|
||||||
|
return ["TODOS", ...Array.from(set).sort()];
|
||||||
|
}, [datos]);
|
||||||
|
|
||||||
|
const filtrados = useMemo(() => {
|
||||||
|
let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01);
|
||||||
|
if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase());
|
||||||
|
return arr;
|
||||||
|
}, [datos, vendedor, colSaldo]);
|
||||||
|
|
||||||
|
const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]);
|
||||||
|
const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]);
|
||||||
|
|
||||||
|
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/frontend/src/pages/Usuarios.jsx
Normal file
130
frontend/frontend/src/pages/Usuarios.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// src/pages/Usuarios.jsx
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
|
||||||
|
const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"];
|
||||||
|
|
||||||
|
export default function Usuarios() {
|
||||||
|
const [usuarios, setUsuarios] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// form nuevo usuario
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [nombre, setNombre] = useState("");
|
||||||
|
const [rol, setRol] = useState("COBRANZA");
|
||||||
|
const [creando, setCreando] = useState(false);
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
|
||||||
|
const cargar = useCallback(() => {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.usuariosListar()
|
||||||
|
.then((res) => { setUsuarios(res.usuarios || []); setLoading(false); })
|
||||||
|
.catch((e) => { setError(e.message); setLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
async function crear() {
|
||||||
|
setMsg(null);
|
||||||
|
if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; }
|
||||||
|
setCreando(true);
|
||||||
|
try {
|
||||||
|
await api.usuariosCrear(email.trim(), password, nombre.trim(), rol);
|
||||||
|
setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` });
|
||||||
|
setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA");
|
||||||
|
cargar();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ ok:false, txt:"Error: " + e.message });
|
||||||
|
}
|
||||||
|
setCreando(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cambiarRol(id, nuevoRol) {
|
||||||
|
try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); }
|
||||||
|
catch (e) { alert("No se pudo cambiar el rol: " + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminar(id, correo) {
|
||||||
|
if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return;
|
||||||
|
try { await api.usuariosEliminar(id); cargar(); }
|
||||||
|
catch (e) { alert("No se pudo eliminar: " + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 };
|
||||||
266
frontend/frontend/src/pages/Ventas.jsx
Normal file
266
frontend/frontend/src/pages/Ventas.jsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// src/pages/Ventas.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||||||
|
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||||||
|
const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."];
|
||||||
|
const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"];
|
||||||
|
const HEADERS_DETALLE = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"];
|
||||||
|
|
||||||
|
export default function Ventas() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [modal, setModal] = useState(null); // { vendedor }
|
||||||
|
const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.ventas(ano, mes, sede, programa)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sede, programa]);
|
||||||
|
|
||||||
|
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
|
||||||
|
const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]);
|
||||||
|
|
||||||
|
const KPIS = total ? [
|
||||||
|
["👤","Vendedores", datos.length, "activos este mes"],
|
||||||
|
["🎓","Total Inscritos", total[1], "matriculados"],
|
||||||
|
["💵","Venta Total", total[2], "inversión neta"],
|
||||||
|
["✅","Venta P.C del Mes", total[4], "pagos completos"],
|
||||||
|
["🚀","Avance P.C Total", total[7], "P.C + Pendientes"],
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
// Exportar saldos por cuota — Excel con formato (anchos, encabezado de color,
|
||||||
|
// fechas reales, montos numéricos en soles y fila TOTAL GENERAL).
|
||||||
|
async function exportarCuota(tipoCuota) {
|
||||||
|
const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; };
|
||||||
|
// Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original
|
||||||
|
const toDate = (v) => {
|
||||||
|
if (!v) return "";
|
||||||
|
const s = String(v).trim().replace(/\//g,"-");
|
||||||
|
const p = s.split("-");
|
||||||
|
if (p.length === 3) {
|
||||||
|
let [d,m,y] = p;
|
||||||
|
if (y.length === 4) return new Date(+y, +m-1, +d);
|
||||||
|
}
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await api.saldoPendiente(tipoCuota);
|
||||||
|
} catch (e) {
|
||||||
|
alert("No se pudo obtener los datos de saldos: " + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const datosS = res.datos || [];
|
||||||
|
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||||
|
const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01);
|
||||||
|
if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; }
|
||||||
|
|
||||||
|
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"];
|
||||||
|
const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"];
|
||||||
|
|
||||||
|
const XLSX = await import("xlsx-js-style");
|
||||||
|
|
||||||
|
// Construir matriz de celdas (AOA) con tipos correctos
|
||||||
|
const headerStyle = {
|
||||||
|
font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 },
|
||||||
|
fill: { fgColor: { rgb: "1E40AF" } },
|
||||||
|
alignment: { horizontal: "center", vertical: "center" },
|
||||||
|
};
|
||||||
|
const moneyFmt = '"S/" #,##0';
|
||||||
|
const dateFmt = "dd/mm/yyyy";
|
||||||
|
|
||||||
|
const ws = {};
|
||||||
|
const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } };
|
||||||
|
|
||||||
|
// Encabezado
|
||||||
|
COLS.forEach((h, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||||
|
ws[ref] = { v: h, t: "s", s: headerStyle };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filas de datos
|
||||||
|
filtrados.forEach((d, ri) => {
|
||||||
|
KEYS.forEach((k, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r: ri + 1, c });
|
||||||
|
const col = COLS[c];
|
||||||
|
if (COL_MONTO.includes(col)) {
|
||||||
|
ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt };
|
||||||
|
} else if (COL_FECHA.includes(col)) {
|
||||||
|
const dt = toDate(d[k]);
|
||||||
|
if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt };
|
||||||
|
else ws[ref] = { v: dt, t: "s" };
|
||||||
|
} else {
|
||||||
|
ws[ref] = { v: d[k] ?? "", t: "s" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA
|
||||||
|
const rTot = filtrados.length + 1;
|
||||||
|
const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0);
|
||||||
|
const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0;
|
||||||
|
const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } };
|
||||||
|
COLS.forEach((col, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r: rTot, c });
|
||||||
|
if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle };
|
||||||
|
else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle };
|
||||||
|
else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle };
|
||||||
|
else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle };
|
||||||
|
else ws[ref] = { v: "", t: "s", s: totalStyle };
|
||||||
|
});
|
||||||
|
|
||||||
|
ws["!ref"] = XLSX.utils.encode_range(range);
|
||||||
|
ws["!cols"] = [
|
||||||
|
{ wch: 12 }, // MATRÍCULA
|
||||||
|
{ wch: 22 }, // VENDEDOR
|
||||||
|
{ wch: 28 }, // ALUMNO
|
||||||
|
{ wch: 32 }, // PROGRAMA
|
||||||
|
{ wch: 12 }, // F. INICIO
|
||||||
|
{ wch: 12 }, // F. MATR.
|
||||||
|
{ wch: 13 }, // SALDO MAT.
|
||||||
|
{ wch: 15 }, // SALDO CUOTA
|
||||||
|
{ wch: 13 }, // VENCIMIENTO
|
||||||
|
{ wch: 13 }, // INV. NETA
|
||||||
|
];
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Saldos");
|
||||||
|
const nombre = tipoCuota.replace("°","").replace(/\s/g,"_");
|
||||||
|
XLSX.writeFile(wb, `saldos_${nombre}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/frontend/src/styles.css
Normal file
127
frontend/frontend/src/styles.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/* 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; }
|
||||||
7
frontend/frontend/vite.config.js
Normal file
7
frontend/frontend/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 3000, host: true }
|
||||||
|
})
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!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>
|
||||||
1980
frontend/package-lock.json
generated
Normal file
1980
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboard-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.107.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
|
"xlsx-js-style": "^1.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
frontend/src/App.jsx
Normal file
65
frontend/src/App.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// src/App.jsx
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useAuth } from "./lib/auth";
|
||||||
|
import Sidebar from "./components/Sidebar";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Ocupabilidad from "./pages/Ocupabilidad";
|
||||||
|
import Ventas from "./pages/Ventas";
|
||||||
|
import Comisiones from "./pages/Comisiones";
|
||||||
|
import Cobranza from "./pages/Cobranza";
|
||||||
|
import Rentabilidad from "./pages/Rentabilidad";
|
||||||
|
import Asesores from "./pages/Asesores";
|
||||||
|
import Usuarios from "./pages/Usuarios";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { autenticado, cargando, permisos, puedeVer } = useAuth();
|
||||||
|
const [pagina, setPagina] = useState(null);
|
||||||
|
|
||||||
|
// Al entrar, seleccionar el primer módulo permitido
|
||||||
|
useEffect(() => {
|
||||||
|
if (autenticado && permisos.length > 0 && !pagina) {
|
||||||
|
setPagina(permisos[0]);
|
||||||
|
}
|
||||||
|
if (!autenticado) setPagina(null);
|
||||||
|
}, [autenticado, permisos, pagina]);
|
||||||
|
|
||||||
|
if (cargando) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/components/Modal.jsx
Normal file
34
frontend/src/components/Modal.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/src/components/Sidebar.jsx
Normal file
54
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// src/components/Sidebar.jsx
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
|
|
||||||
|
const MENU = [
|
||||||
|
{ id: "ocupabilidad", ico: "📊", label: "Ocupabilidad" },
|
||||||
|
{ id: "ventas", ico: "💰", label: "Ventas" },
|
||||||
|
{ id: "comisiones", ico: "🧮", label: "Comisiones" },
|
||||||
|
{ id: "cobranza", ico: "📋", label: "Cobranza" },
|
||||||
|
{ id: "rentabilidad", ico: "📈", label: "Rentabilidad" },
|
||||||
|
{ id: "asesores", ico: "👥", label: "Asesores" },
|
||||||
|
{ id: "usuarios", ico: "🔐", label: "Usuarios" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar({ active, onChange }) {
|
||||||
|
const { perfil, permisos, logout } = useAuth();
|
||||||
|
const visibles = MENU.filter((m) => permisos.includes(m.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/UI.jsx
Normal file
51
frontend/src/components/UI.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user