Initial commit - dashboard leads
This commit is contained in:
22
backend/.env
Normal file
22
backend/.env
Normal file
@@ -0,0 +1,22 @@
|
||||
PG_HOST=191.98.134.81
|
||||
PG_DATABASE=chatwoot_production
|
||||
PG_USER=postgres
|
||||
PG_PASSWORD=2165$%sd3%DFG
|
||||
PG_PORT=5432
|
||||
SQL_SERVER=191.98.134.80
|
||||
SQL_DATABASE=BDUS_CK000040_0001
|
||||
SQL_USERNAME=ASEBASTIAN
|
||||
SQL_PASSWORD=24MY36$z>&Uf
|
||||
GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD_LEADS
|
||||
SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
|
||||
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y
|
||||
SUPABASE_TABLA_LEADS=datos_unificados
|
||||
SUPABASE_PAUTA_URL=https://uztqscimtsihrzgybsyb.supabase.co
|
||||
SUPABASE_PAUTA_KEY=sb_publishable_aJCh5J6UghfKmSz6Swy4iA_orAqeHb7
|
||||
SUPABASE_TABLA_PAUTA=basebi_programacion
|
||||
CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co
|
||||
CARTERA_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6dHFzY2ltdHNpaHJ6Z3lic3liIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2MTMyMzQ0MCwiZXhwIjoyMDc2ODk5NDQwfQ.pvqzwJ7fBxggNka3oga7SdhiADIgIVKGEuxGbzHQj0E
|
||||
SUPABASE_TABLA_CARTERA=cartera_junta
|
||||
SUPABASE_TABLA_ALIAS=alias_normalizacion
|
||||
SUPABASE_TABLA_CONJUNTO=conjunto_pauta
|
||||
META_CSV_URL=https://docs.google.com/spreadsheets/d/e/2PACX-1vQa5BFEeF-p0-6-FuEXDXT4eb_ZK0fkYgLMxK_Ly3EJJPwQBsYDfNsSW09ppMNrH2LIiwVdAnC5q64C/pub?gid=1241390845&single=true&output=csv
|
||||
BIN
backend/ROAS_Marzo_2026.xlsx
Normal file
BIN
backend/ROAS_Marzo_2026.xlsx
Normal file
Binary file not shown.
BIN
backend/__pycache__/cache_manager.cpython-310.pyc
Normal file
BIN
backend/__pycache__/cache_manager.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/cache_manager.cpython-312.pyc
Normal file
BIN
backend/__pycache__/cache_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/cache_manager.cpython-314.pyc
Normal file
BIN
backend/__pycache__/cache_manager.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/data_manager.cpython-310.pyc
Normal file
BIN
backend/__pycache__/data_manager.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/data_manager.cpython-312.pyc
Normal file
BIN
backend/__pycache__/data_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/data_manager_v2.cpython-310.pyc
Normal file
BIN
backend/__pycache__/data_manager_v2.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/data_manager_v2.cpython-312.pyc
Normal file
BIN
backend/__pycache__/data_manager_v2.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/data_manager_v2.cpython-314.pyc
Normal file
BIN
backend/__pycache__/data_manager_v2.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/leads_logic.cpython-310.pyc
Normal file
BIN
backend/__pycache__/leads_logic.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/leads_logic.cpython-312.pyc
Normal file
BIN
backend/__pycache__/leads_logic.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/leads_logic.cpython-314.pyc
Normal file
BIN
backend/__pycache__/leads_logic.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-310.pyc
Normal file
BIN
backend/__pycache__/main.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/services.cpython-310.pyc
Normal file
BIN
backend/__pycache__/services.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/services.cpython-312.pyc
Normal file
BIN
backend/__pycache__/services.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/services.cpython-314.pyc
Normal file
BIN
backend/__pycache__/services.cpython-314.pyc
Normal file
Binary file not shown.
49
backend/bajar_cartera.py
Normal file
49
backend/bajar_cartera.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os, csv
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from collections import Counter
|
||||
|
||||
load_dotenv()
|
||||
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
|
||||
|
||||
assert url and key, "Falta CARTERA_URL / CARTERA_KEY en .env"
|
||||
|
||||
sel = "telefono,sede,programa,fecha_creada,asesor,es_origen,canal,origen_base"
|
||||
paso, desde = 1000, 0
|
||||
out = []
|
||||
while True:
|
||||
hdr = {"apikey": key, "Authorization": f"Bearer {key}",
|
||||
"Range-Unit": "items", "Range": f"{desde}-{desde + paso - 1}"}
|
||||
r = requests.get(f"{url}/rest/v1/{t}",
|
||||
params={"select": sel, "order": "id.asc"},
|
||||
headers=hdr, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data:
|
||||
break # parar SOLO cuando devuelve 0
|
||||
out.extend(data)
|
||||
desde += len(data)
|
||||
print(f" descargadas: {desde}", end="\r")
|
||||
|
||||
print(f"\n\nTOTAL filas descargadas: {len(out)}")
|
||||
|
||||
ori = Counter(str(x.get("es_origen") or "").strip().upper() for x in out)
|
||||
print("es_origen:", ori.most_common(5))
|
||||
|
||||
ob = Counter(str(x.get("origen_base") or "").strip().upper() for x in out)
|
||||
print("origen_base:", ob.most_common(5))
|
||||
|
||||
sede = Counter(str(x.get("sede") or "").strip().upper() for x in out)
|
||||
print("por SEDE:", sede.most_common(10))
|
||||
|
||||
# Exporta a CSV para comparar en Excel
|
||||
salida = "cartera_bajada.csv"
|
||||
with open(salida, "w", newline="", encoding="utf-8-sig") as f:
|
||||
w = csv.DictWriter(f, fieldnames=sel.split(","))
|
||||
w.writeheader()
|
||||
for x in out:
|
||||
w.writerow({k: x.get(k, "") for k in sel.split(",")})
|
||||
print(f"\nExportado a: {salida}")
|
||||
64
backend/bajar_datos_unificados.py
Normal file
64
backend/bajar_datos_unificados.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import os, csv
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from collections import Counter
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# datos_unificados vive en el proyecto SUPABASE_URL2 / SUPABASE_KEY2 (el del sync)
|
||||
url = (os.getenv("SUPABASE_URL2") or os.getenv("SUPABASE_URL")
|
||||
or os.getenv("CARTERA_URL") or "")
|
||||
key = (os.getenv("SUPABASE_KEY2") or os.getenv("SUPABASE_KEY")
|
||||
or os.getenv("CARTERA_KEY") or "")
|
||||
t = "datos_unificados"
|
||||
|
||||
assert url and key, "Falta SUPABASE_URL2 / SUPABASE_KEY2 (o SUPABASE_URL/KEY) en .env"
|
||||
|
||||
sel = "Ejecutivo,Telefono,Fechacreada,Sede,Programa,Canal,Codigo,Codigo_Vendedor"
|
||||
paso, desde = 1000, 0
|
||||
out = []
|
||||
while True:
|
||||
hdr = {"apikey": key, "Authorization": f"Bearer {key}",
|
||||
"Range-Unit": "items", "Range": f"{desde}-{desde + paso - 1}"}
|
||||
r = requests.get(f"{url}/rest/v1/{t}",
|
||||
params={"select": sel, "order": "id.asc"},
|
||||
headers=hdr, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
out.extend(data)
|
||||
desde += len(data)
|
||||
print(f" descargadas: {desde}", end="\r")
|
||||
|
||||
print(f"\n\nTOTAL filas en datos_unificados: {len(out)}")
|
||||
|
||||
def _tel_vacio(v):
|
||||
s = str(v or "")
|
||||
for x in ("+51", "+", " ", "-", "(", ")", ".0"):
|
||||
s = s.replace(x, "")
|
||||
s = s.strip()
|
||||
if not s or s in ("-", "nan") or set(s) == {"0"}:
|
||||
return True
|
||||
return s.isdigit() and len(s) <= 6
|
||||
|
||||
vacios = sum(1 for x in out if _tel_vacio(x.get("Telefono")))
|
||||
print(f"Filas con teléfono VÁLIDO: {len(out) - vacios}")
|
||||
print(f"Filas BASURA (sin teléfono): {vacios}")
|
||||
|
||||
# desglose por sede (para ver de qué Excel viene la basura)
|
||||
sede_basura = Counter()
|
||||
for x in out:
|
||||
if _tel_vacio(x.get("Telefono")):
|
||||
sede_basura[str(x.get("Sede") or "").strip().upper()] += 1
|
||||
print("\nBASURA por SEDE:")
|
||||
for s, n in sede_basura.most_common(12):
|
||||
print(f" {s if s else '(vacio)':16} {n}")
|
||||
|
||||
salida = "datos_unificados.csv"
|
||||
with open(salida, "w", newline="", encoding="utf-8-sig") as f:
|
||||
w = csv.DictWriter(f, fieldnames=sel.split(","))
|
||||
w.writeheader()
|
||||
for x in out:
|
||||
w.writerow({k: x.get(k, "") for k in sel.split(",")})
|
||||
print(f"\nExportado a: {salida}")
|
||||
44
backend/borrar_branding.py
Normal file
44
backend/borrar_branding.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# borrar_branding.py
|
||||
# Pone en BLANCO el campo 'programa' de todos los conjuntos que tienen BRANDING
|
||||
# en la tabla conjunto_sede_programa de Supabase. La SEDE se conserva.
|
||||
# Ejecutar en backend/: python borrar_branding.py
|
||||
import os, requests
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
T = "conjunto_sede_programa"
|
||||
H = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||
assert URL and KEY, "Falta CARTERA_URL/KEY en .env"
|
||||
|
||||
# 1) traer todos y ver cuales tienen BRANDING
|
||||
r = requests.get(f"{URL}/rest/v1/{T}", params={"select": "*"}, headers=H, timeout=30)
|
||||
r.raise_for_status()
|
||||
filas = r.json()
|
||||
brand = [f for f in filas if "BRANDING" in str(f.get("programa") or "").upper()]
|
||||
|
||||
print(f"\nTotal filas: {len(filas)}")
|
||||
print(f"Con BRANDING en programa: {len(brand)}")
|
||||
for f in brand[:60]:
|
||||
print(f" {f.get('conjunto'):45} sede={f.get('sede'):10} prog={f.get('programa')}")
|
||||
|
||||
if not brand:
|
||||
print("\nNo hay nada con BRANDING. Listo.")
|
||||
raise SystemExit
|
||||
|
||||
# 2) actualizar programa='' donde el programa contenga BRANDING
|
||||
hp = dict(H); hp["Content-Type"] = "application/json"; hp["Prefer"] = "return=minimal"
|
||||
ok = 0
|
||||
for f in brand:
|
||||
co = f.get("conjunto")
|
||||
rr = requests.patch(f"{URL}/rest/v1/{T}",
|
||||
params={"conjunto": f"eq.{co}"},
|
||||
headers=hp, json={"programa": ""}, timeout=30)
|
||||
if rr.ok:
|
||||
ok += 1
|
||||
else:
|
||||
print(f" ERROR {co}: {rr.status_code} {rr.text[:80]}")
|
||||
|
||||
print(f"\nOK -> {ok} conjuntos actualizados (programa BRANDING borrado, sede conservada).")
|
||||
print("Ahora en el dashboard: reinicia backend o dale 'Actualizar'.")
|
||||
79
backend/cache_manager.py
Normal file
79
backend/cache_manager.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# backend/cache_manager.py
|
||||
"""Caché global en memoria con refresco en segundo plano (igual que el dashboard de ventas)."""
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, Tuple
|
||||
|
||||
_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:
|
||||
key = _make_key(prefix, args)
|
||||
now = time.time()
|
||||
with _LOCK:
|
||||
hit = _CACHE.get(key)
|
||||
if hit and (now - hit[0] < ttl):
|
||||
return hit[1]
|
||||
value = loader()
|
||||
with _LOCK:
|
||||
_CACHE[key] = (now, value)
|
||||
return value
|
||||
|
||||
|
||||
def cache_keys():
|
||||
with _LOCK:
|
||||
return list(_CACHE.keys())
|
||||
|
||||
|
||||
def cache_refresh_existing(loader_for_key):
|
||||
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:
|
||||
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())}
|
||||
|
||||
|
||||
_background_started = False
|
||||
|
||||
|
||||
def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 900):
|
||||
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")
|
||||
30
backend/carga_tiempos.log
Normal file
30
backend/carga_tiempos.log
Normal file
@@ -0,0 +1,30 @@
|
||||
============================================================
|
||||
>>> Hilo 2: CARTERA (Supabase) + OTROS GENERAL
|
||||
CARGA DEL DASHBOARD LEADS — 17/08/2026 16:57:12
|
||||
============================================================
|
||||
>>> Hilo 1: LEADS (PostgreSQL + SQL Server)
|
||||
[carga] Plantillas Google Sheet 1.21s (9117 filas)
|
||||
[carga] Leads (mensajes) PostgreSQL 21.85s (58986 filas)
|
||||
[carga] Cursos SQL Server 0.00s (289 filas)
|
||||
[carga] Cartera Supabase 20.88s (106389 filas)
|
||||
[carga] Matriculas SQL Server 0.34s (3267 filas)
|
||||
[carga] Leads asignados/respuesta PostgreSQL 4.18s (15885 filas)
|
||||
[carga] Otros General 2026-8 cache 5.65s (4 filas)
|
||||
[carga] Otros General 2026-7 cache 0.30s (4 filas)
|
||||
>>> Hilo 2 (Cartera/Otros) LISTO.
|
||||
[carga] Apartado Leads 2026-8 cache 4.06s (6 filas)
|
||||
[carga] Apartado Leads 2026-7 cache 0.38s (6 filas)
|
||||
[carga] Apartado Vendedores cache 2.47s (13 filas)
|
||||
[carga] Apartado ROAS cache 0.00s (2 filas)
|
||||
>>> Hilo 1 (Leads) LISTO.
|
||||
|
||||
============================================================
|
||||
RESUMEN — tiempo listo para abrir cada apartado
|
||||
============================================================
|
||||
Leads 4.45s -> listo tras 4.4s
|
||||
Vendedores 2.47s -> listo tras 2.5s
|
||||
Otros General 5.96s -> listo tras 6.0s
|
||||
ROAS 0.00s -> instantaneo al abrir
|
||||
------------------------------------------------------------
|
||||
Ya precargados: al hacer clic se muestran AL INSTANTE (cache).
|
||||
============================================================
|
||||
95
backend/check_choque_campania.py
Normal file
95
backend/check_choque_campania.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# check_choque_campania.py
|
||||
# Revisa si una campaña NUEVA chocaría (duplicaría leads) contra las existentes.
|
||||
# Regla de choque (igual que el JOIN LIKE del dashboard):
|
||||
# - una frase es sub-cadena de la otra (contención de texto), Y
|
||||
# - los rangos de fecha se solapan.
|
||||
# Un mensaje que caiga en ambas frases + ambas fechas => 2 filas => lead duplicado.
|
||||
#
|
||||
# Edita NUEVA abajo y ejecuta: python check_choque_campania.py
|
||||
import os, requests
|
||||
from datetime import date
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# ── CAMPAÑA NUEVA A PROBAR (edita aquí) ──
|
||||
NUEVA = {
|
||||
"frase": "🎟️ Me interesa el programa de Aire Acondicionado",
|
||||
"inicio": "2026-08-10",
|
||||
"fin": "2026-10-10",
|
||||
}
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
TABLA = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
|
||||
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||
assert URL and KEY, "Falta CARTERA_URL / CARTERA_KEY en .env"
|
||||
|
||||
|
||||
def traer_todo():
|
||||
filas, paso, desde = [], 1000, 0
|
||||
while True:
|
||||
hdr = dict(HEAD); hdr["Range-Unit"] = "items"; hdr["Range"] = f"{desde}-{desde+paso-1}"
|
||||
r = requests.get(f"{URL}/rest/v1/{TABLA}", params={"select": "*", "order": "id"},
|
||||
headers=hdr, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
filas.extend(data); desde += len(data)
|
||||
if len(data) < paso:
|
||||
break
|
||||
return filas
|
||||
|
||||
|
||||
def _d(s):
|
||||
try:
|
||||
return date.fromisoformat(str(s)[:10])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _solapan(i1, f1, i2, f2):
|
||||
if not (i1 and f1 and i2 and f2):
|
||||
return False
|
||||
return i1 <= f2 and i2 <= f1 # rangos [i1,f1] y [i2,f2] se cruzan
|
||||
|
||||
|
||||
def _norm(t):
|
||||
return " ".join(str(t or "").lower().split())
|
||||
|
||||
|
||||
filas = traer_todo()
|
||||
ni, nf = _norm(NUEVA["frase"]), None
|
||||
n_ini, n_fin = _d(NUEVA["inicio"]), _d(NUEVA["fin"])
|
||||
|
||||
print("\n" + "="*90)
|
||||
print(f"CAMPAÑA NUEVA: \"{NUEVA['frase']}\"")
|
||||
print(f"FECHAS: {NUEVA['inicio']} → {NUEVA['fin']}")
|
||||
print("="*90)
|
||||
|
||||
choques = []
|
||||
for f in filas:
|
||||
fr = _norm(f.get("frase_busqueda"))
|
||||
if not fr:
|
||||
continue
|
||||
ei, ef = _d(f.get("fecha_inicio")), _d(f.get("fecha_fin"))
|
||||
contiene = (fr in ni) or (ni in fr) # una es sub-cadena de la otra
|
||||
fechas = _solapan(n_ini, n_fin, ei, ef)
|
||||
if contiene and fechas:
|
||||
choques.append((f, fr))
|
||||
|
||||
if not choques:
|
||||
print("\n✅ SIN CHOQUES. Ninguna campaña existente comparte texto contenido")
|
||||
print(" y fechas solapadas. Tu campaña NO duplicaría leads.\n")
|
||||
else:
|
||||
print(f"\n⚠️ {len(choques)} CHOQUE(S) DETECTADO(S) — estas campañas duplicarían leads:\n")
|
||||
for f, fr in choques:
|
||||
rel = "existente ⊂ nueva" if fr in ni else "nueva ⊂ existente"
|
||||
print(f" id {f.get('id')} cod {f.get('codigo')} [{rel}]")
|
||||
print(f" frase: \"{f.get('frase_busqueda')}\"")
|
||||
print(f" fechas: {str(f.get('fecha_inicio'))[:10]} → {str(f.get('fecha_fin'))[:10]}")
|
||||
print()
|
||||
print(" Un mensaje que contenga ambas frases dentro de estas fechas se")
|
||||
print(" contaría 2 veces (2 códigos de pauta para el mismo lead).\n")
|
||||
print("="*90)
|
||||
271
backend/data_manager.py
Normal file
271
backend/data_manager.py
Normal file
@@ -0,0 +1,271 @@
|
||||
# backend/data_manager.py
|
||||
"""
|
||||
Capa de acceso a datos del módulo LEADS.
|
||||
- PostgreSQL (Chatwoot): leads de pauta (consolidado en UNA sola consulta).
|
||||
- SQL Server (Académico): matrículas y cursos.
|
||||
- GitHub: mapa de campañas (frases -> programa/sede), config editable.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class DataManager:
|
||||
def __init__(self):
|
||||
# PostgreSQL (Chatwoot)
|
||||
self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
|
||||
self.pg_db = os.getenv("PG_DATABASE", "chatwoot_production")
|
||||
self.pg_user = os.getenv("PG_USER", "postgres")
|
||||
self.pg_pass = os.getenv("PG_PASSWORD", "")
|
||||
self.pg_port = os.getenv("PG_PORT", "5432")
|
||||
# SQL Server (Académico)
|
||||
self.sql_server = os.getenv("SQL_SERVER", "191.98.134.80")
|
||||
self.sql_db = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
|
||||
self.sql_user = os.getenv("SQL_USERNAME", "")
|
||||
self.sql_pass = os.getenv("SQL_PASSWORD", "")
|
||||
# GitHub
|
||||
base = os.getenv("GITHUB_BASE", "")
|
||||
self.github_campanias_url = f"{base}/campanias.json" if base else ""
|
||||
self.campanias = []
|
||||
self._cargar_campanias()
|
||||
|
||||
# ── Conexiones ──────────────────────────────────────────────
|
||||
def pg_conn(self):
|
||||
import psycopg2
|
||||
return psycopg2.connect(
|
||||
host=self.pg_host, dbname=self.pg_db, user=self.pg_user,
|
||||
password=self.pg_pass, port=self.pg_port,
|
||||
)
|
||||
|
||||
def sql_conn(self):
|
||||
import pyodbc
|
||||
conn_str = (
|
||||
f"DRIVER={{SQL Server}};SERVER={self.sql_server};"
|
||||
f"DATABASE={self.sql_db};UID={self.sql_user};PWD={self.sql_pass}"
|
||||
)
|
||||
return pyodbc.connect(conn_str)
|
||||
|
||||
# ── Config de campañas (GitHub, opcional) ───────────────────
|
||||
def _cargar_campanias(self):
|
||||
if not self.github_campanias_url:
|
||||
self.campanias = []
|
||||
return
|
||||
try:
|
||||
r = requests.get(self.github_campanias_url, timeout=5)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
self.campanias = data.get("campanias", []) if isinstance(data, dict) else data
|
||||
except Exception:
|
||||
self.campanias = []
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# CHATWOOT — UN SOLO QUERY CONSOLIDADO DE LEADS
|
||||
# Reemplaza los 4 queries del PBI (procesados, asignados,
|
||||
# contactados, etiquetas) por uno solo. El resto se calcula
|
||||
# en Python (Cantidad_Veces, Ultima_Etiqueta, Estado/Objeción).
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def traer_leads_chatwoot(self):
|
||||
"""Leads de PAUTA (igual que el PBI): un mensaje cuenta como lead solo si su
|
||||
texto coincide con una FRASE de campaña y está dentro del rango de fechas de
|
||||
esa campaña. El mapa de campañas está incrustado (como en el Power BI)."""
|
||||
sql = """
|
||||
SELECT DISTINCT ON (m.id)
|
||||
REGEXP_REPLACE(c.phone_number, '[^0-9]', '', 'g') AS telefono,
|
||||
u.name AS asesor,
|
||||
(m.created_at - INTERVAL '5 hours') AS fecha_creada,
|
||||
cv.cached_label_list AS etiquetas,
|
||||
map.cargo AS programa,
|
||||
map.sede AS sede,
|
||||
map.codigo AS codigo,
|
||||
map.origen AS origen
|
||||
FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id = cv.id
|
||||
JOIN contacts c ON cv.contact_id = c.id
|
||||
LEFT JOIN users u ON cv.assignee_id = u.id
|
||||
JOIN (VALUES
|
||||
('📚 Me interesa el', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-10-01', '2025-12-31'),
|
||||
('📚 Me interesa el', 'TEAC', '227', 'Lima', 'Domingo', 'Pauta_wsp', '2026-01-15', '2026-03-30'),
|
||||
('Hola! 🚨', 'TEAC', '191', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 💯 Me interesa', 'TERC', '193', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 💼', 'TEAC', '195', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 💸', 'TEAC', '197', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! ⚡', 'TEAC', '198', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 🦺', 'TERC', '199', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! 🍾', 'TEAC', '201', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('¡Hola! 📚', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 🥶', 'TEAC', '202', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🎯', 'TEAC', '203', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! 🌞', 'TERC', '204', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🗿', 'TERC', '205', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('Hola! 😎', 'TEAC', '206', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
|
||||
('Hola! 🎓', 'TEAC', '207', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
|
||||
('Hola! 👀', 'TEAC', '208', 'Trujillo', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🎮', 'TERC', '209', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
|
||||
('Hola! 👾', 'TERC', '210', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-25'),
|
||||
('Hola! 🕹️', 'TERC', '211', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
|
||||
('Hola! 🚀', 'TEAC', '212', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🛸', 'TERC', '213', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🦾', 'Amoniaco', '63a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
|
||||
('Hola! 🛠️', 'Diseño Chillers', '64a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
|
||||
('¡Hola! 🥽', 'VRF', '65a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
|
||||
('¡Hola! ❄️ Estoy interesado Seminario', 'Cámaras', '66a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-05-28'),
|
||||
('¡Hola! 🤩', 'Diseño de Sistemas', '67a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
|
||||
('¡Hola! 🙌🏼', 'Metrado y Costeo', '68a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
|
||||
('¡Hola! 🥽', 'VRF', '69a', 'Lima', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 🧊 Más información del Seminario', 'VRF', '71a', 'Arequipa', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 📐 Más información del Seminario', 'VRF', '72a', 'Trujillo', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 👾 Más información del Seminario en Instalación', 'VRF', '73a', 'Piura', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
|
||||
(' 🖥️ Estoy interesado en sus Seminarios', 'Seminarios', '74a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
|
||||
(' 🙋 Deseo más info. del Diplomado ', 'Diplomado REF', '75a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
|
||||
('💸 Deseo más info. del Seminario', 'CAD', '76a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
|
||||
('🦾 Estoy interesado Instalación de Sistemas', 'VRF', '77a', 'Lima', '-', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('🙀 Estoy interesado Seminario', 'SUPER.OBRAS', '78a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
|
||||
('🛠️ más info del Seminario Diseño de Chillers', 'Diseño', '79a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
|
||||
('👾Estoy interesado en el Seminario Refrigeración', 'CO2', '80a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
|
||||
('Hola! 👉', 'TEAC', '214', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🤝', 'TEAC', '215', 'Lima', 'Domingo', 'Pauta_wsp', '2025-12-01', '2026-03-15'),
|
||||
('🆕 Me interesa', 'TERC', '217', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🌬️', 'TEAC', '218', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🌍', 'TERC', '219', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🎟️', 'TEAC', '220', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-03-31'),
|
||||
('Hola! 🌈', 'TERC', '221', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
|
||||
('¡Hola! ❄️ Me interesa el programa', 'TEAC', '222', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('Hola! 🔝', 'TERC', '223', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('🔋 Me interesa', 'TEAC', '224', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
|
||||
('¡Hola! 🌡️', 'TEAC', '225', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
|
||||
('¡Hola! ☃️', 'TERC', '226', 'Lima', 'Sábado', 'Pauta_wsp', '2026-01-01', '2026-04-15'),
|
||||
('¡Hola! 📚', 'TERC', '227', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-10'),
|
||||
('¡Hola! 🗺️', 'TEAC', '228', 'Arequipa','Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-05'),
|
||||
('¡Hola! 🌤️', 'TEAC', '229', 'Piura', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
|
||||
('¡Hola! ⚡', 'TEAC', '230', 'Trujillo','Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-07'),
|
||||
('¡Hola! 🎤', 'TERC', '231', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
|
||||
('¡Hola! 👾 Me interesa el programa', 'TEAC', '232', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-26', '2026-05-30'),
|
||||
('¡Hola! 🍨', 'TEAC', '233', 'Lima', 'Sábado', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
|
||||
('¡Hola! 🐶 Me interesa', 'TEAC', '234', 'Piura', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 💸 Me interesa', 'TERC', '235', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🗿 Me interesa', 'TERC', '236', 'Trujillo', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🦺 Me interesa', 'TEAC', '237', 'Lima', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🍾 Me interesa', 'TEAC', '238', 'Lima', 'Domingo', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('💡 deseo más info del programa', 'TERC', '239', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
|
||||
('¡Hola! 🎯 Me interesa el programa', 'TERC', '240', 'Lima', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
|
||||
('¡Hola! 🤝 Me interesa el programa', 'TERC', '241', 'Lima', 'Sábado', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
|
||||
('🪐 Me interesa el programa', 'TERC', '242', 'Piura', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
|
||||
('🎓 Me interesa el programa', 'TEAC', '243', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
|
||||
('💼 Me interesa el programa ', 'TEAC', '244', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
|
||||
('☃️ Me interesa el programa', 'TEAC', '245', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('🔋 Me interesa el programa', 'TERC', '246', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('😎 Me interesa el programa', 'TEAC', '247', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('🌈Me interesa el programa', 'TEAC', '248', 'Lima', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
|
||||
('⭐ Me interesa el programa', 'TEAC', '249', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('🕵️ Me interesa el programa', 'TEAC', '250', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('📈 Me interesa el programa', 'TEAC', '251', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('🗺️ Me interesa el programa', 'TEAC', '252', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-06', '2026-07-30'),
|
||||
('🎮 Me interesa el programa', 'TEAC', '253', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
|
||||
('🏞️ Me interesa el programa', 'TERC', '254', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('⚙️ Me interesa el programa', 'TERC', '255', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('⚡ Me interesa el programa', 'TERC', '256', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-08', '2026-12-31'),
|
||||
('🎟️ Me interesa el programa', 'TERC', '257', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('🕑 Me interesa el', 'TERC', '258', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
|
||||
('📚 Me interesa el programa', 'TEAC', '259', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
|
||||
('🤗 Me interesa el programa', 'TEAC', '260', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
|
||||
('🕹️ Me interesa el programa', 'TERC', '261', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
|
||||
('🐶 Me interesa el programa', 'TEAC', '262', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-11', '2026-08-30'),
|
||||
('🍨 Me interesa el programa', 'TERC', '263', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('⛱️ Me interesa el programa de Aire Acondicionado', 'TEAC', '264', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('🌤️ Me interesa el programa', 'TEAC', '265', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('⛱️ Me interesa el programa de Refrigeración comercial', 'TERC', '266', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
|
||||
('¡Hola! Vengo de su web, quiero saber', 'TEAC', '0', 'Lima', '-', 'Web_Whatsapp', '2025-11-01', '2026-12-31')
|
||||
) AS map(frase_busqueda, cargo, codigo, sede, dia, origen, fecha_inicio, fecha_fin)
|
||||
ON m.content LIKE '%' || map.frase_busqueda || '%'
|
||||
AND m.created_at >= CAST(map.fecha_inicio AS TIMESTAMP)
|
||||
AND m.created_at <= CAST(map.fecha_fin AS TIMESTAMP) + INTERVAL '1 day'
|
||||
WHERE m.sender_type = 'Contact'
|
||||
ORDER BY m.id ASC, m.created_at ASC
|
||||
"""
|
||||
conn = self.pg_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# SQL SERVER — CURSOS (igual que el PBI: Fact_SQL_Base_Cursos)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def traer_cursos(self):
|
||||
sql = """
|
||||
SELECT
|
||||
rp.num_indice,
|
||||
rp.dsc_det_programa,
|
||||
p.dsc_programa,
|
||||
rp.cod_frecuencia,
|
||||
rp.fch_inicio,
|
||||
rp.cod_estado,
|
||||
(SELECT COUNT(*) FROM sgeca_matricula m
|
||||
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
|
||||
AND m.cod_estado NOT IN ('ANU')) AS Inscritos_Totales,
|
||||
(SELECT COUNT(*) FROM sgeca_matricula m
|
||||
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
|
||||
AND m.cod_estado = 'RET') AS Inscritos_Retirados,
|
||||
(SELECT COUNT(*) FROM sgeca_matricula m
|
||||
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
|
||||
AND m.cod_estado NOT IN ('ANU','RET','SUS')) AS Inscritos_Activos
|
||||
FROM sgede_RP_programa rp
|
||||
INNER JOIN sgeca_programa p ON rp.cod_programa = p.cod_programa
|
||||
WHERE YEAR(rp.fch_inicio) IN (2025, 2026)
|
||||
ORDER BY rp.fch_inicio ASC
|
||||
"""
|
||||
conn = self.sql_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# SQL SERVER — MATRÍCULAS (Fact_SQL_Base_Matriculas, resumido)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def traer_matriculas(self):
|
||||
sql = """
|
||||
SELECT
|
||||
sgeca_matricula.num_matricula,
|
||||
sgeca_matricula.num_indice,
|
||||
sgeca_matricula.fch_matricula,
|
||||
sgeca_matricula.cod_estado AS estado_matricula,
|
||||
REPLACE(sgema_alumno.dsc_telefono_1,' ','') AS dsc_telefono_1,
|
||||
sgeca_programa.dsc_programa,
|
||||
sgede_RP_programa.dsc_det_programa AS dsc_promocion,
|
||||
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||
AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula,
|
||||
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
|
||||
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
|
||||
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
|
||||
AND sgede_cronograma_matricula.num_refinanciamiento = 1
|
||||
AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1
|
||||
FROM sgeca_matricula
|
||||
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
|
||||
LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
|
||||
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
|
||||
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
|
||||
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||
WHERE sgeca_matricula.cod_localidad LIKE 'SCENT'
|
||||
AND sgeca_matricula.fch_matricula BETWEEN '01-11-2024 00:00:00.000' AND '31-12-2026 23:59:00.000'
|
||||
AND sgeca_matricula.cod_estado <> 'ANU'
|
||||
"""
|
||||
conn = self.sql_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
return filas
|
||||
# --- fin data_manager ---
|
||||
1040
backend/data_manager_v2.py
Normal file
1040
backend/data_manager_v2.py
Normal file
File diff suppressed because it is too large
Load Diff
30
backend/diag_asesores.py
Normal file
30
backend/diag_asesores.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# diag_asesores.py — Valores UNICOS de la columna 'asesor' en cartera_junta.
|
||||
import os, requests
|
||||
from collections import Counter
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
t = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
|
||||
h = {"apikey": key, "Authorization": f"Bearer {key}"}
|
||||
|
||||
# traer solo la columna asesor (paginado)
|
||||
cont = Counter()
|
||||
desde, paso = 0, 1000
|
||||
while True:
|
||||
r = requests.get(f"{url}/rest/v1/{t}", params={"select": "asesor", "offset": str(desde), "limit": str(paso)},
|
||||
headers=h, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
for row in data:
|
||||
cont[(row.get("asesor") or "").strip()] += 1
|
||||
if len(data) < paso:
|
||||
break
|
||||
desde += paso
|
||||
|
||||
print(f"Valores distintos de 'asesor' en cartera_junta: {len(cont)}\n")
|
||||
for val, n in cont.most_common():
|
||||
print(f" {n:>6} {val or '(vacio)'}")
|
||||
37
backend/diag_asignados_diana_hoy.py
Normal file
37
backend/diag_asignados_diana_hoy.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# diag_asignados_diana_hoy.py
|
||||
# Leads ASIGNADOS a DIANA el dia 13 (hoy) de agosto 2026, con telefono, hora y
|
||||
# si respondio o no. Fuente: mensajes de asignacion de Chatwoot.
|
||||
from datetime import datetime
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES, DIA = 2026, 8, 13
|
||||
NOMBRE = "DIANA"
|
||||
|
||||
dm = DataManager()
|
||||
|
||||
filas = []
|
||||
for r in dm.traer_asignacion_respuesta():
|
||||
if NOMBRE not in (r.get("user_name") or "").upper():
|
||||
continue
|
||||
t1 = r.get("created_at")
|
||||
if not isinstance(t1, datetime):
|
||||
continue
|
||||
if t1.year == ANO and t1.month == MES and t1.day == DIA:
|
||||
filas.append(r)
|
||||
|
||||
print(f"\n=== DIANA — leads ASIGNADOS el {DIA:02d}/{MES:02d}/{ANO} ===\n")
|
||||
print(f"Total asignados hoy: {len(filas)}\n")
|
||||
print(f"{'#':3} {'telefono':13} {'asignado':11} {'respondio':11} estado")
|
||||
print("-"*55)
|
||||
resp = 0; sinr = 0
|
||||
for i, r in enumerate(sorted(filas, key=lambda x: x['created_at']), 1):
|
||||
t1 = r['created_at']; t2 = r.get('respuesta_fecha')
|
||||
tel = r.get('telefono', '')
|
||||
if isinstance(t2, datetime):
|
||||
resp += 1
|
||||
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {t2.strftime('%H:%M:%S'):11} respondio")
|
||||
else:
|
||||
sinr += 1
|
||||
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {'':11} SIN RESPONDER")
|
||||
print("-"*55)
|
||||
print(f"Respondidos: {resp} | Sin responder: {sinr} | Total: {len(filas)}")
|
||||
86
backend/diag_base.json
Normal file
86
backend/diag_base.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"2026|2|TODOS|TODOS|TODOS": {
|
||||
"leads_recibidos": 6186,
|
||||
"leads_procesados": 1489,
|
||||
"total_matriculados": 117,
|
||||
"cursos_programados": 7,
|
||||
"pauta_total_imp": 4255.16,
|
||||
"always_total_imp": 1730.51,
|
||||
"webform_total_rec": 151,
|
||||
"webform_total_matr": 15,
|
||||
"asignados_total": 1390,
|
||||
"matriz_cursos_filas": 6
|
||||
},
|
||||
"2026|2|TODOS|TEAC|LIMA": {
|
||||
"leads_recibidos": 2198,
|
||||
"leads_procesados": 499,
|
||||
"total_matriculados": 53,
|
||||
"cursos_programados": 3,
|
||||
"pauta_total_imp": 4255.16,
|
||||
"always_total_imp": 810.03,
|
||||
"webform_total_rec": 94,
|
||||
"webform_total_matr": 9,
|
||||
"asignados_total": 1390,
|
||||
"matriz_cursos_filas": 2
|
||||
},
|
||||
"2026|2|TODOS|TERC|AREQUIPA": {
|
||||
"leads_recibidos": 325,
|
||||
"leads_procesados": 64,
|
||||
"total_matriculados": 10,
|
||||
"cursos_programados": 1,
|
||||
"pauta_total_imp": 4255.16,
|
||||
"always_total_imp": 0,
|
||||
"webform_total_rec": 5,
|
||||
"webform_total_matr": 0,
|
||||
"asignados_total": 1390,
|
||||
"matriz_cursos_filas": 1
|
||||
},
|
||||
"2026|1|TODOS|TODOS|TODOS": {
|
||||
"leads_recibidos": 7417,
|
||||
"leads_procesados": 2370,
|
||||
"total_matriculados": 128,
|
||||
"cursos_programados": 9,
|
||||
"pauta_total_imp": 4499.9,
|
||||
"always_total_imp": 776.0,
|
||||
"webform_total_rec": 143,
|
||||
"webform_total_matr": 15,
|
||||
"asignados_total": 1529,
|
||||
"matriz_cursos_filas": 5
|
||||
},
|
||||
"2026|1|TODOS|SEMINARIOS|LIMA": {
|
||||
"leads_recibidos": 946,
|
||||
"leads_procesados": 342,
|
||||
"total_matriculados": 22,
|
||||
"cursos_programados": 1,
|
||||
"pauta_total_imp": 4499.9,
|
||||
"always_total_imp": 0,
|
||||
"webform_total_rec": 1,
|
||||
"webform_total_matr": 0,
|
||||
"asignados_total": 1529,
|
||||
"matriz_cursos_filas": 0
|
||||
},
|
||||
"2026|7|TODOS|TODOS|TODOS": {
|
||||
"leads_recibidos": 1664,
|
||||
"leads_procesados": 1435,
|
||||
"total_matriculados": 59,
|
||||
"cursos_programados": 16,
|
||||
"pauta_total_imp": 2212.41,
|
||||
"always_total_imp": 1540.57,
|
||||
"webform_total_rec": 34,
|
||||
"webform_total_matr": 1,
|
||||
"asignados_total": 2275,
|
||||
"matriz_cursos_filas": 16
|
||||
},
|
||||
"2026|7|TODOS|TEAC|PIURA": {
|
||||
"leads_recibidos": 104,
|
||||
"leads_procesados": 102,
|
||||
"total_matriculados": 1,
|
||||
"cursos_programados": 1,
|
||||
"pauta_total_imp": 2212.41,
|
||||
"always_total_imp": 172.48,
|
||||
"webform_total_rec": 0,
|
||||
"webform_total_matr": 0,
|
||||
"asignados_total": 2275,
|
||||
"matriz_cursos_filas": 1
|
||||
}
|
||||
}
|
||||
38
backend/diag_base.py
Normal file
38
backend/diag_base.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# diag_base.py — Captura los numeros ACTUALES (antes de optimizar) para comparar despues.
|
||||
import json
|
||||
import services as S
|
||||
|
||||
combos = [
|
||||
("2026","2","TODOS","TODOS","TODOS"),
|
||||
("2026","2","TODOS","TEAC","LIMA"),
|
||||
("2026","2","TODOS","TERC","AREQUIPA"),
|
||||
("2026","1","TODOS","TODOS","TODOS"),
|
||||
("2026","1","TODOS","SEMINARIOS","LIMA"),
|
||||
("2026","7","TODOS","TODOS","TODOS"),
|
||||
("2026","7","TODOS","TEAC","PIURA"),
|
||||
]
|
||||
|
||||
def resumen(d):
|
||||
k = d["kpis"]
|
||||
return {
|
||||
"leads_recibidos": k["leads_recibidos"],
|
||||
"leads_procesados": k["leads_procesados"],
|
||||
"total_matriculados": k["total_matriculados"],
|
||||
"cursos_programados": k["cursos_programados"],
|
||||
"pauta_total_imp": d["tabla_pauta"]["total"].get("importe"),
|
||||
"always_total_imp": d["matriz_always"]["total"]["importe"],
|
||||
"webform_total_rec": d["matriz_webform"]["total"]["recibidos"],
|
||||
"webform_total_matr": d["matriz_webform"]["total"]["matriculas"],
|
||||
"asignados_total": d["matriz_asignados"]["total"],
|
||||
"matriz_cursos_filas": len(d["matriz_cursos"]["filas"]),
|
||||
}
|
||||
|
||||
out = {}
|
||||
for c in combos:
|
||||
d = S.leads_dashboard(*c)
|
||||
out["|".join(c)] = resumen(d)
|
||||
print("|".join(c), "->", out["|".join(c)])
|
||||
|
||||
with open("diag_base.json", "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, indent=2, ensure_ascii=False)
|
||||
print("\nGuardado: diag_base.json (linea base ANTES de optimizar)")
|
||||
58
backend/diag_buscar_numero.py
Normal file
58
backend/diag_buscar_numero.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# diag_buscar_numero.py - Rastrea un telefono en cartera_junta: como y donde se cuenta.
|
||||
# Ejecutar en backend/: python diag_buscar_numero.py
|
||||
import os, requests
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
TELEFONO = "18099166706" # <-- numero a buscar (tal cual y normalizado)
|
||||
|
||||
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
T = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
|
||||
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||
|
||||
def _norm(v):
|
||||
"""Misma normalizacion de la cartera: quita +51/+ y el 51 inicial de celulares PE."""
|
||||
s = str(v or "")
|
||||
for x in ("+51","+"," ","-","(",")"): s=s.replace(x,"")
|
||||
s=s.strip()
|
||||
if s.isdigit() and 51900000000 <= int(s) <= 51999999999: s=s[2:]
|
||||
return s
|
||||
|
||||
# variantes a buscar: el numero tal cual y su version normalizada
|
||||
variantes = {TELEFONO, _norm(TELEFONO)}
|
||||
print(f"Buscando telefono: {TELEFONO} (variantes: {variantes})\n")
|
||||
|
||||
encontrado = False
|
||||
for tel in variantes:
|
||||
r = requests.get(f"{URL}/rest/v1/{T}",
|
||||
params={"select":"*", "telefono": f"eq.{tel}"},
|
||||
headers=HEAD, timeout=60)
|
||||
r.raise_for_status()
|
||||
filas = r.json()
|
||||
if not filas:
|
||||
continue
|
||||
encontrado = True
|
||||
print(f"=== {len(filas)} fila(s) con telefono = {tel} ===")
|
||||
for i,f in enumerate(filas,1):
|
||||
print(f"\n Fila {i}:")
|
||||
for campo in ["telefono","es_origen","canal","origen_base","asesor","sede",
|
||||
"programa","codigo","fecha_creada","solo_contacto"]:
|
||||
if campo in f:
|
||||
print(f" {campo:14}= {f.get(campo)}")
|
||||
# mostrar cualquier otro campo extra
|
||||
otros=[k for k in f.keys() if k not in
|
||||
("telefono","es_origen","canal","origen_base","asesor","sede",
|
||||
"programa","codigo","fecha_creada","solo_contacto")]
|
||||
for k in otros:
|
||||
print(f" {k:14}= {f.get(k)}")
|
||||
|
||||
if not encontrado:
|
||||
print("El telefono NO aparece en cartera_junta (ni tal cual ni normalizado).")
|
||||
print("=> No se esta contando en ninguna pauta/origen de la cartera.")
|
||||
else:
|
||||
print("\n--- Como leer esto ---")
|
||||
print(" es_origen=SI -> esta fila es la que CUENTA como origen del telefono.")
|
||||
print(" canal -> COPITO / PAUTA_WSP / WEB_FORMULARIO / etc.")
|
||||
print(" origen_base -> POSTGRE (Chatwoot) o SUPABASE (tu Excel).")
|
||||
print(" codigo -> la pauta/campania a la que esta asociado (si tiene).")
|
||||
61
backend/diag_campanias_cruce.py
Normal file
61
backend/diag_campanias_cruce.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Diagnostico: revisa la tabla 'campanias' y detecta cruces = misma frase_busqueda
|
||||
(mismo emoji/texto) con rangos de fecha [fecha_inicio, fecha_fin] que se SOLAPAN.
|
||||
Un mensaje que caiga en el solape haria match con 2 campanias a la vez.
|
||||
"""
|
||||
import data_manager_v2 as dm
|
||||
import leads_logic as L
|
||||
|
||||
# _campanias_supabase() trae tuplas (frase, cargo, codigo, sede, dia, origen, fi, ff)
|
||||
crudas = dm._campanias_supabase()
|
||||
campanias = [{"frase_busqueda": t[0], "cargo": t[1], "codigo": t[2], "sede": t[3],
|
||||
"dia": t[4], "origen": t[5], "fecha_inicio": t[6], "fecha_fin": t[7]}
|
||||
for t in crudas]
|
||||
print(f"Total campanias: {len(campanias)}\n")
|
||||
|
||||
# agrupar por frase_busqueda
|
||||
por_frase = {}
|
||||
for c in campanias:
|
||||
frase = " ".join(str(c.get("frase_busqueda") or "").split())
|
||||
por_frase.setdefault(frase, []).append(c)
|
||||
|
||||
def _rango(c):
|
||||
fi = L._to_date(str(c.get("fecha_inicio") or "")[:10])
|
||||
ff = L._to_date(str(c.get("fecha_fin") or "")[:10])
|
||||
return fi, ff
|
||||
|
||||
cruces = 0
|
||||
print("=== CRUCES (misma frase + fechas solapadas) ===\n")
|
||||
for frase, lista in por_frase.items():
|
||||
if len(lista) < 2:
|
||||
continue
|
||||
# comparar cada par
|
||||
for i in range(len(lista)):
|
||||
for j in range(i + 1, len(lista)):
|
||||
a, b = lista[i], lista[j]
|
||||
fia, ffa = _rango(a)
|
||||
fib, ffb = _rango(b)
|
||||
if not (fia and ffa and fib and ffb):
|
||||
continue
|
||||
# solapan si a.inicio <= b.fin y b.inicio <= a.fin
|
||||
if fia <= ffb and fib <= ffa:
|
||||
cruces += 1
|
||||
print(f"FRASE: {frase!r}")
|
||||
print(f" A) cod={a.get('codigo')} sede={a.get('sede')} cargo={a.get('cargo')} "
|
||||
f"[{fia} -> {ffa}]")
|
||||
print(f" B) cod={b.get('codigo')} sede={b.get('sede')} cargo={b.get('cargo')} "
|
||||
f"[{fib} -> {ffb}]")
|
||||
print(f" SOLAPE: {max(fia,fib)} -> {min(ffa,ffb)}\n")
|
||||
|
||||
if cruces == 0:
|
||||
print("No se encontraron cruces (ninguna frase repetida con fechas solapadas).")
|
||||
else:
|
||||
print(f"TOTAL de cruces detectados: {cruces}")
|
||||
|
||||
# tambien listar frases repetidas (aunque no solapen) por si acaso
|
||||
print("\n=== Frases que se repiten en varias campanias ===")
|
||||
rep = {f: l for f, l in por_frase.items() if len(l) > 1}
|
||||
for f, l in rep.items():
|
||||
print(f" {len(l)}x {f!r}")
|
||||
if not rep:
|
||||
print(" (ninguna frase repetida)")
|
||||
28
backend/diag_canal.py
Normal file
28
backend/diag_canal.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# diag_canal.py — Revisa num_indice 1161: pauta en basebi vs conjuntos.
|
||||
import services as S
|
||||
|
||||
NI = "1161"
|
||||
pm = S._pauta_cruda() # {num_indice: {pauta, estado, contar}}
|
||||
cp = S._conjunto_pauta() # {pauta: [conjuntos]}
|
||||
|
||||
info = pm.get(NI)
|
||||
print(f"num_indice {NI} en basebi_programacion:", info)
|
||||
cod = info.get("pauta") if info else None
|
||||
print(f" -> pauta (cod): {cod!r}")
|
||||
print(f" -> conjuntos por esa pauta: {cp.get(str(cod).strip()) if cod else '(sin cod)'}")
|
||||
|
||||
# Buscar en la matriz esa fila
|
||||
d = S.leads_dashboard("2026","5","TODOS","TODOS","TODOS")["matriz_cursos"]
|
||||
for c in d["filas"]:
|
||||
if str(c["num_indice"]) == NI:
|
||||
print(f"\nEn la matriz -> pauta={c['pauta']!r} conjuntos={c.get('conjuntos')}")
|
||||
break
|
||||
else:
|
||||
print(f"\n(num_indice {NI} no esta en la matriz de mayo)")
|
||||
|
||||
# Buscar en conjunto_pauta si algun conjunto tiene ese nombre TEAC_TRUJILLO_MAY...
|
||||
print("\nBuscando conjunto 'TEAC_TRUJILLO_MAY' en conjunto_pauta:")
|
||||
for pauta, cjs in cp.items():
|
||||
for cj in cjs:
|
||||
if "TEAC_TRUJILLO_MAY" in str(cj).upper():
|
||||
print(f" pauta={pauta!r} -> {cj}")
|
||||
15
backend/diag_cart.py
Normal file
15
backend/diag_cart.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import services as S
|
||||
from collections import Counter
|
||||
|
||||
rows = S._cartera_rows_cache()
|
||||
print("TOTAL filas cartera:", len(rows))
|
||||
|
||||
si = [r for r in rows if str(r.get("es_origen") or "").strip().upper() == "SI"]
|
||||
print("Filas es_origen=SI:", len(si))
|
||||
print("Valores es_origen:", Counter(str(r.get("es_origen")) for r in rows).most_common(10))
|
||||
|
||||
ase = S._asesores(); amap = ase["map"]
|
||||
cnt_si = sum(1 for r in si if S._norm_asesor(r.get("asesor"), amap) == "ALMENDRA PERALTA")
|
||||
cnt_all = sum(1 for r in rows if S._norm_asesor(r.get("asesor"), amap) == "ALMENDRA PERALTA")
|
||||
print("Almendra en es_origen=SI :", cnt_si)
|
||||
print("Almendra en TODA cartera :", cnt_all)
|
||||
62
backend/diag_caso_469.py
Normal file
62
backend/diag_caso_469.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# diag_caso_469.py - Investiga un telefono puntual: asignacion, respuesta, y
|
||||
# actividad de la asesora ese dia (para saber si estuvo o no de descanso).
|
||||
from datetime import datetime
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
TEL = "923306769" # telefono a investigar
|
||||
NOMBRE = "DIANA"
|
||||
DIA_OBJ = (2026, 8, 10)
|
||||
|
||||
dm = DataManager()
|
||||
|
||||
# 1) Todos los mensajes de ese contacto (asignaciones y respuestas), en hora Peru
|
||||
conn = dm.pg_conn(); cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT (m.created_at - INTERVAL '5 hours') AS fecha, m.id, m.sender_id, m.sender_type,
|
||||
LEFT(m.content, 70)
|
||||
FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id = cv.id
|
||||
JOIN contacts c ON cv.contact_id = c.id
|
||||
WHERE REPLACE(REPLACE(c.phone_number,'+51',''),'+','') LIKE %s
|
||||
ORDER BY m.id ASC
|
||||
""", ('%'+TEL,))
|
||||
rows = cur.fetchall()
|
||||
print(f"\n=== Mensajes del contacto {TEL} (hora Peru) ===\n")
|
||||
print(f"{'fecha':20} {'msg_id':>9} {'sender_id':>9} {'tipo':8} contenido")
|
||||
print("-"*90)
|
||||
for fecha, mid, sid, stype, cont in rows:
|
||||
marca = " <- ASESOR" if (sid is not None and 15 <= sid <= 25) else ""
|
||||
stype = str(stype or "-"); sid_s = str(sid if sid is not None else "-")
|
||||
print(f"{str(fecha):26} {str(mid):>9} {sid_s:>9} {stype:8} {str(cont or '')[:40]}{marca}")
|
||||
|
||||
# 2) Que trajo el query de asignacion+respuesta para este telefono
|
||||
print(f"\n=== Lo que el dashboard tomo como T1 (asignacion) y T2 (respuesta) ===")
|
||||
for r in dm.traer_asignacion_respuesta():
|
||||
if str(r.get('telefono','')).endswith(TEL[-9:]):
|
||||
print(f" T1 asignacion = {r.get('created_at')}")
|
||||
print(f" T2 respuesta = {r.get('respuesta_fecha')}")
|
||||
print(f" asesor = {r.get('user_name')}")
|
||||
|
||||
# 3) Actividad de Diana ese dia (para saber si trabajo o descanso)
|
||||
print(f"\n=== Actividad de {NOMBRE} el {DIA_OBJ[2]:02d}/{DIA_OBJ[1]:02d}/{DIA_OBJ[0]} (mensajes enviados) ===")
|
||||
asig = dm.traer_asignacion_respuesta()
|
||||
uid_diana = None
|
||||
for r in asig:
|
||||
if NOMBRE in (r.get('user_name') or '').upper() and r.get('user_id') is not None:
|
||||
uid_diana = r['user_id']; break
|
||||
if uid_diana is None:
|
||||
print(" (no se hallo user_id de Diana)")
|
||||
else:
|
||||
hrs = []
|
||||
for a in dm.traer_actividad_asesores():
|
||||
ts = a.get('created_at')
|
||||
if a.get('user_id')==uid_diana and isinstance(ts,datetime) and \
|
||||
(ts.year,ts.month,ts.day)==DIA_OBJ:
|
||||
hrs.append(ts.strftime('%H:%M'))
|
||||
if not hrs:
|
||||
print(f" SIN actividad ese dia -> probablemente DESCANSO / no trabajo.")
|
||||
else:
|
||||
hrs.sort()
|
||||
print(f" {len(hrs)} mensajes. Primera: {hrs[0]} Ultima: {hrs[-1]}")
|
||||
print(f" (si hay actividad en la tarde 13-18 = turno 1; si en la noche = turno 2)")
|
||||
conn.close()
|
||||
53
backend/diag_comparar_asignados_diana.py
Normal file
53
backend/diag_comparar_asignados_diana.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# diag_comparar_asignados_diana.py
|
||||
# Compara, para DIANA en un mes, los asignados por dia entre:
|
||||
# FUENTE A = traer_leads_asignados (la de la TABLA de Otros General)
|
||||
# FUENTE B = traer_asignacion_respuesta (la de la GRAFICA de Vendedores)
|
||||
# Muestra el conteo por dia y que telefonos difieren.
|
||||
from datetime import datetime
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES = 2026, 8
|
||||
NOMBRE = "DIANA"
|
||||
|
||||
dm = DataManager()
|
||||
|
||||
def _to_date(v):
|
||||
if isinstance(v, datetime): return v.date()
|
||||
return None
|
||||
|
||||
# FUENTE A: tabla Otros General
|
||||
A = {} # dia -> set(telefonos)
|
||||
for r in dm.traer_leads_asignados():
|
||||
nom = (r.get("user_name") or "")
|
||||
if NOMBRE not in nom.upper(): continue
|
||||
f = _to_date(r.get("created_at"))
|
||||
if not f or f.year!=ANO or f.month!=MES: continue
|
||||
A.setdefault(f.day, set()).add(str(r.get("telefono") or "").strip())
|
||||
|
||||
# FUENTE B: grafica Vendedores
|
||||
B = {} # dia -> set(telefonos)
|
||||
for r in dm.traer_asignacion_respuesta():
|
||||
nom = (r.get("user_name") or "")
|
||||
if NOMBRE not in nom.upper(): continue
|
||||
f = _to_date(r.get("created_at"))
|
||||
if not f or f.year!=ANO or f.month!=MES: continue
|
||||
B.setdefault(f.day, set()).add(str(r.get("telefono") or "").strip())
|
||||
|
||||
dias = sorted(set(list(A.keys())+list(B.keys())))
|
||||
print(f"\n=== DIANA — asignados por dia: TABLA(A) vs GRAFICA(B) — {MES}/{ANO} ===\n")
|
||||
print(f"{'DIA':>4} {'TABLA(A)':>9} {'GRAFICA(B)':>11} {'DIF':>5}")
|
||||
print("-"*34)
|
||||
for d in dias:
|
||||
a=len(A.get(d,set())); b=len(B.get(d,set()))
|
||||
marca = " <==" if a!=b else ""
|
||||
print(f"{d:>4} {a:>9} {b:>11} {a-b:>5}{marca}")
|
||||
|
||||
print(f"\n=== Detalle de los dias que DIFIEREN ===")
|
||||
for d in dias:
|
||||
sa=A.get(d,set()); sb=B.get(d,set())
|
||||
if sa==sb: continue
|
||||
solo_A = sa - sb # estan en tabla pero NO en grafica
|
||||
solo_B = sb - sa # estan en grafica pero NO en tabla
|
||||
print(f"\n DIA {d}:")
|
||||
if solo_A: print(f" solo en TABLA (A), no en grafica: {sorted(solo_A)}")
|
||||
if solo_B: print(f" solo en GRAFICA (B), no en tabla: {sorted(solo_B)}")
|
||||
57
backend/diag_con_msje_sin_campania.py
Normal file
57
backend/diag_con_msje_sin_campania.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Diagnostico: contactos que SI tienen mensajes, pero NINGUNO de campaña.
|
||||
Muestra sus mensajes (content) para revisar por que no hicieron match.
|
||||
"""
|
||||
import os
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
import data_manager_v2 as dm # reutiliza las frases de campaña
|
||||
|
||||
load_dotenv()
|
||||
|
||||
PG_HOST = os.getenv("PG_HOST")
|
||||
PG_DB = os.getenv("PG_DATABASE") or os.getenv("PG_DB")
|
||||
PG_USER = os.getenv("PG_USER")
|
||||
PG_PASS = os.getenv("PG_PASSWORD") or os.getenv("PG_PASS")
|
||||
PG_PORT = os.getenv("PG_PORT", "5432")
|
||||
|
||||
FRASES = [c[0] for c in dm._campanias_supabase()]
|
||||
likes = " OR ".join(["m.content LIKE %s"] * len(FRASES))
|
||||
params = [f"%{fr}%" for fr in FRASES]
|
||||
|
||||
conn = psycopg2.connect(host=PG_HOST, dbname=PG_DB, user=PG_USER,
|
||||
password=PG_PASS, port=PG_PORT, connect_timeout=30)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Buscar contactos con >=1 mensaje pero 0 de campaña (traemos algunos pocos)
|
||||
sql = f"""
|
||||
SELECT c.id, c.phone_number, c.created_at,
|
||||
COUNT(m.id) AS total_msjes,
|
||||
SUM(CASE WHEN ({likes}) THEN 1 ELSE 0 END) AS msjes_campania
|
||||
FROM contacts c
|
||||
JOIN conversations cv ON cv.contact_id = c.id
|
||||
JOIN messages m ON m.conversation_id = cv.id AND m.sender_type = 'Contact'
|
||||
GROUP BY c.id, c.phone_number, c.created_at
|
||||
HAVING COUNT(m.id) >= 1 AND SUM(CASE WHEN ({likes}) THEN 1 ELSE 0 END) = 0
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
cur.execute(sql, params + params)
|
||||
contactos = cur.fetchall()
|
||||
|
||||
for cid, ph, creado, tot, camp in contactos:
|
||||
print("=" * 80)
|
||||
print(f"CONTACTO id={cid} tel={ph} creado={creado} #msjes={tot} campaña={camp}")
|
||||
# Mostrar sus mensajes (content) del contacto
|
||||
cur.execute("""
|
||||
SELECT m.created_at, LEFT(m.content, 90)
|
||||
FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id = cv.id
|
||||
WHERE cv.contact_id = %s AND m.sender_type = 'Contact'
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT 10
|
||||
""", (cid,))
|
||||
for fecha, txt in cur.fetchall():
|
||||
print(f" [{fecha}] {txt!r}")
|
||||
|
||||
conn.close()
|
||||
43
backend/diag_content.py
Normal file
43
backend/diag_content.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# diag_content.py — Revisa que valores toma content_attributes (para replicar ISBLANK).
|
||||
# Ejecutar dentro de backend/: python diag_content.py
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
dm = DataManager()
|
||||
conn = dm.pg_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Distribucion de content_attributes en los mensajes de plantilla
|
||||
sql = """
|
||||
SELECT
|
||||
CASE
|
||||
WHEN m.content_attributes IS NULL THEN '(NULL)'
|
||||
WHEN m.content_attributes::text = '{}' THEN '(vacio {})'
|
||||
WHEN m.content_attributes::text = 'null' THEN "(texto 'null')"
|
||||
ELSE 'CON DATOS'
|
||||
END AS tipo,
|
||||
COUNT(*) AS n
|
||||
FROM messages m
|
||||
WHERE m.sender_type = 'User'
|
||||
AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC
|
||||
"""
|
||||
cur.execute(sql)
|
||||
print("== content_attributes en mensajes de plantilla ==")
|
||||
for tipo, n in cur.fetchall():
|
||||
print(f" {tipo:16} {n}")
|
||||
|
||||
# Muestra 3 ejemplos de cada tipo con datos
|
||||
print("\n== ejemplos de content_attributes CON DATOS (primeros 3) ==")
|
||||
cur.execute("""
|
||||
SELECT m.additional_attributes -> 'template_params' ->> 'name', m.content_attributes::text
|
||||
FROM messages m
|
||||
WHERE m.sender_type='User'
|
||||
AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL
|
||||
AND m.content_attributes IS NOT NULL
|
||||
AND m.content_attributes::text NOT IN ('{}','null')
|
||||
LIMIT 3
|
||||
""")
|
||||
for nombre, ca in cur.fetchall():
|
||||
print(f" {nombre}: {ca[:120]}")
|
||||
conn.close()
|
||||
32
backend/diag_curso_indice.py
Normal file
32
backend/diag_curso_indice.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Diagnostico: para num_indices dados, muestra las columnas de la matriz
|
||||
'Detalle por Curso' del apartado LEADS (importe pauta, cartera, leads, matriculas...).
|
||||
Filtro por defecto: TODOS (sin mes). Cambia ANO/MES/DIA si quieres un periodo.
|
||||
"""
|
||||
import services as S
|
||||
|
||||
NUM_INDICES = ["1157", "1174"]
|
||||
ANO, MES, DIA = "TODOS", "TODOS", "TODOS" # cambia si quieres filtrar (ej "2026","7","TODOS")
|
||||
|
||||
d = S.leads_dashboard(ANO, MES, DIA, "TODOS", "TODOS")
|
||||
matriz = d.get("matriz_cursos", {})
|
||||
filas = matriz.get("filas", []) if isinstance(matriz, dict) else matriz
|
||||
|
||||
def _ni(v):
|
||||
s = str(v or "").strip()
|
||||
return s[:-2] if s.endswith(".0") else s
|
||||
|
||||
objetivo = set(NUM_INDICES)
|
||||
encontrados = [f for f in filas if _ni(f.get("num_indice")) in objetivo]
|
||||
|
||||
if not encontrados:
|
||||
print("No se encontraron esos num_indice en la matriz. num_indices disponibles (muestra):")
|
||||
print([_ni(f.get("num_indice")) for f in filas[:40]])
|
||||
else:
|
||||
for f in encontrados:
|
||||
print("=" * 70)
|
||||
print(f"NUM_INDICE: {_ni(f.get('num_indice'))} | {f.get('personalizado') or f.get('programa') or ''}")
|
||||
print("-" * 70)
|
||||
for k, v in f.items():
|
||||
print(f" {k:32}: {v}")
|
||||
print()
|
||||
110
backend/diag_dayana_6_7.py
Normal file
110
backend/diag_dayana_6_7.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# diag_dayana_6_7.py
|
||||
# Dias 6 y 7 de AGOSTO 2026 de DAYANA: turno, tiempo asignacion, respuesta y minutos.
|
||||
# min = tiempo LABORAL. Los del refrigerio (13-15 turno1) llevan -45 si superan 45 (marcado).
|
||||
from datetime import datetime, timedelta, date
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES = 2026, 8
|
||||
DIAS = [6, 7]
|
||||
NOMBRE = "DAYANA"
|
||||
|
||||
dm = DataManager()
|
||||
asig = dm.traer_asignacion_respuesta()
|
||||
act_rows = dm.traer_actividad_asesores()
|
||||
|
||||
uid2name = {}
|
||||
for r in asig:
|
||||
if r.get("user_id") is not None:
|
||||
uid2name[r["user_id"]] = r.get("user_name") or ""
|
||||
|
||||
from collections import defaultdict
|
||||
act = defaultdict(lambda: defaultdict(int))
|
||||
for a in act_rows:
|
||||
uid=a.get("user_id"); ts=a.get("created_at")
|
||||
if uid not in uid2name or not isinstance(ts,datetime): continue
|
||||
nom=uid2name[uid].upper(); d=ts.date(); h=ts.hour+ts.minute/60.0; key=(nom,d)
|
||||
if d.weekday()==6: continue
|
||||
if d.weekday()==5:
|
||||
if 9<=h<13: act[key]["sab_manana"]+=1
|
||||
elif 14<=h<18: act[key]["sab_tarde"]+=1
|
||||
else:
|
||||
if 13<=h<18: act[key]["tarde"]+=1
|
||||
elif 18<=h<22: act[key]["noche"]+=1
|
||||
|
||||
def turno_dia(nom,d):
|
||||
if d.weekday()==6: return None
|
||||
fr=act.get((nom,d))
|
||||
if not fr: return None
|
||||
if d.weekday()==5:
|
||||
m=fr.get("sab_manana",0); t=fr.get("sab_tarde",0)
|
||||
if m==0 and t==0: return None
|
||||
return [(9,13)] if m>=t else [(14,18)]
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
if t==0 and n==0: return None
|
||||
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
|
||||
|
||||
def es_t1(nom,d):
|
||||
fr=act.get((nom,d))
|
||||
if not fr or d.weekday()>=5: return False
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
return (not (n>t)) and (t>0 or n>0)
|
||||
|
||||
def _dt(d,hh): return datetime(d.year,d.month,d.day)+timedelta(hours=hh)
|
||||
|
||||
def mins_laborales(nom,t1,t2):
|
||||
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
|
||||
total=0.0; cur=t1; s=0
|
||||
while cur<t2 and s<60:
|
||||
d=cur.date(); bl=turno_dia(nom,d)
|
||||
if not bl: cur=_dt(d,24); s+=1; continue
|
||||
for (hi,hf) in bl:
|
||||
ini=_dt(d,hi); fin=_dt(d,hf)
|
||||
if t2<=ini: break
|
||||
if cur>=fin: continue
|
||||
a=max(cur,ini); b=min(t2,fin)
|
||||
if b>a: total+=(b-a).total_seconds()/60.0
|
||||
cur=fin
|
||||
if cur>=t2: break
|
||||
if cur.date()==d: cur=_dt(d,24); s+=1
|
||||
return round(total)
|
||||
|
||||
for DIA in DIAS:
|
||||
d1=date(ANO,MES,DIA)
|
||||
filas=[]
|
||||
for r in asig:
|
||||
if NOMBRE not in (r.get("user_name") or "").upper(): continue
|
||||
t1=r.get("created_at")
|
||||
if isinstance(t1,datetime) and t1.year==ANO and t1.month==MES and t1.day==DIA:
|
||||
filas.append(r)
|
||||
nom_up=(filas[0].get("user_name") or "").upper() if filas else NOMBRE
|
||||
tt=turno_dia(nom_up,d1)
|
||||
if tt is None: turno="LIBRE/DESCANSO"
|
||||
elif tt==[(9,13),(14,18)]: turno="TURNO 1 (9-18)"
|
||||
elif tt==[(9,13),(18,22)]: turno="TURNO 2 (9-13+18-22)"
|
||||
else: turno=str(tt)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f"DAYANA — {DIA:02d}/{MES:02d}/{ANO} -> {turno} (asignados: {len(filas)})")
|
||||
print(f"{'='*70}")
|
||||
print(f"{'#':3} {'telefono':13} {'asignado':11} {'respondio':11} {'min':>5} nota")
|
||||
print("-"*60)
|
||||
resp=0; sinr=0; suma=0
|
||||
for i,r in enumerate(sorted(filas,key=lambda x:x['created_at']),1):
|
||||
t1=r['created_at']; t2=r.get('respuesta_fecha'); tel=r.get('telefono','')
|
||||
h=t1.hour+t1.minute/60.0
|
||||
refri = es_t1(nom_up,d1) and (13<=h<15)
|
||||
if not isinstance(t2,datetime):
|
||||
sinr+=1
|
||||
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {'':11} {'':>5}")
|
||||
continue
|
||||
if refri:
|
||||
dif=(t2-t1).total_seconds()/60.0
|
||||
m=round(dif-45) if dif>45 else round(dif)
|
||||
if m<0: m=0
|
||||
nota=" refrig(-45 si >45)"
|
||||
else:
|
||||
m=mins_laborales(nom_up,t1,t2); nota=""
|
||||
resp+=1; suma+=m
|
||||
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {t2.strftime('%H:%M:%S'):11} {m:>5}{nota}")
|
||||
print("-"*60)
|
||||
print(f"Respondidos: {resp} | Sin responder: {sinr} | Promedio: {round(suma/resp) if resp else 0} min")
|
||||
102
backend/diag_dayana_grafica_6_7.py
Normal file
102
backend/diag_dayana_grafica_6_7.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# diag_dayana_grafica_6_7.py
|
||||
# Replica EXACTA de las dos series del grafico para DAYANA dias 6 y 7:
|
||||
# NARANJA = por fecha de ASIGNACION (todos, con -45 refrigerio)
|
||||
# AZUL = por fecha de RESPUESTA (solo respondidos, -45 refrigerio, y EXCLUYE >960 min)
|
||||
from datetime import datetime, timedelta, date
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES = 2026, 8
|
||||
DIAS = [6, 7]
|
||||
NOMBRE = "DAYANA"
|
||||
LIMITE = 960
|
||||
|
||||
dm = DataManager()
|
||||
asig = dm.traer_asignacion_respuesta()
|
||||
act_rows = dm.traer_actividad_asesores()
|
||||
uid2name = {r["user_id"]: (r.get("user_name") or "") for r in asig if r.get("user_id") is not None}
|
||||
|
||||
from collections import defaultdict
|
||||
act = defaultdict(lambda: defaultdict(int))
|
||||
for a in act_rows:
|
||||
uid=a.get("user_id"); ts=a.get("created_at")
|
||||
if uid not in uid2name or not isinstance(ts,datetime): continue
|
||||
nom=uid2name[uid].upper(); d=ts.date(); h=ts.hour+ts.minute/60.0; key=(nom,d)
|
||||
if d.weekday()==6: continue
|
||||
if d.weekday()==5:
|
||||
if 9<=h<13: act[key]["sab_manana"]+=1
|
||||
elif 14<=h<18: act[key]["sab_tarde"]+=1
|
||||
else:
|
||||
if 13<=h<18: act[key]["tarde"]+=1
|
||||
elif 18<=h<22: act[key]["noche"]+=1
|
||||
|
||||
def turno_dia(nom,d):
|
||||
if d.weekday()==6: return None
|
||||
fr=act.get((nom,d))
|
||||
if not fr: return None
|
||||
if d.weekday()==5:
|
||||
m=fr.get("sab_manana",0); t=fr.get("sab_tarde",0)
|
||||
if m==0 and t==0: return None
|
||||
return [(9,13)] if m>=t else [(14,18)]
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
if t==0 and n==0: return None
|
||||
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
|
||||
|
||||
def es_t1(nom,d):
|
||||
fr=act.get((nom,d))
|
||||
if not fr or d.weekday()>=5: return False
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
return (not (n>t)) and (t>0 or n>0)
|
||||
|
||||
def _dt(d,hh): return datetime(d.year,d.month,d.day)+timedelta(hours=hh)
|
||||
|
||||
def mins_lab(nom,t1,t2):
|
||||
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
|
||||
total=0.0; cur=t1; s=0
|
||||
while cur<t2 and s<60:
|
||||
d=cur.date(); bl=turno_dia(nom,d)
|
||||
if not bl: cur=_dt(d,24); s+=1; continue
|
||||
for (hi,hf) in bl:
|
||||
ini=_dt(d,hi); fin=_dt(d,hf)
|
||||
if t2<=ini: break
|
||||
if cur>=fin: continue
|
||||
a=max(cur,ini); b=min(t2,fin)
|
||||
if b>a: total+=(b-a).total_seconds()/60.0
|
||||
cur=fin
|
||||
if cur>=t2: break
|
||||
if cur.date()==d: cur=_dt(d,24); s+=1
|
||||
return round(total)
|
||||
|
||||
def calc_min(nom,t1,t2,d1):
|
||||
h=t1.hour+t1.minute/60.0
|
||||
if es_t1(nom,d1) and (13<=h<15):
|
||||
dif=(t2-t1).total_seconds()/60.0
|
||||
m=round(dif-45) if dif>45 else round(dif)
|
||||
return max(m,0)
|
||||
return mins_lab(nom,t1,t2)
|
||||
|
||||
for DIA in DIAS:
|
||||
d1=date(ANO,MES,DIA)
|
||||
print(f"\n{'#'*72}")
|
||||
print(f"DAYANA — DIA {DIA:02d}/{MES:02d}/{ANO}")
|
||||
print(f"{'#'*72}")
|
||||
|
||||
# ---- AZUL: respondidos ese dia (excluye >960) ----
|
||||
print(f"\n>>> GRAFICO AZUL (por fecha de RESPUESTA): respondidos el {DIA} (excluye >960 min)")
|
||||
print(f"{'telefono':13} {'asignado':14} {'respondio':14} {'min':>6} nota")
|
||||
print("-"*60)
|
||||
az=[]; tard=0
|
||||
for r in asig:
|
||||
if NOMBRE not in (r.get("user_name") or "").upper(): continue
|
||||
t1=r.get("created_at"); t2=r.get("respuesta_fecha")
|
||||
if not isinstance(t1,datetime) or not isinstance(t2,datetime): continue
|
||||
if t2.date()!=d1: continue
|
||||
m=calc_min((r.get('user_name') or '').upper(),t1,t2,t1.date())
|
||||
nota = " (asignado otro dia)" if t1.date()!=d1 else ""
|
||||
if m>LIMITE:
|
||||
tard+=1
|
||||
print(f"{r.get('telefono',''):13} {t1.strftime('%d/%m %H:%M'):14} {t2.strftime('%d/%m %H:%M'):14} {m:>6} TARDIO(>960) fuera{nota}")
|
||||
continue
|
||||
az.append(m)
|
||||
print(f"{r.get('telefono',''):13} {t1.strftime('%d/%m %H:%M'):14} {t2.strftime('%d/%m %H:%M'):14} {m:>6}{nota}")
|
||||
print("-"*60)
|
||||
print(f" {len(az)} respondidos | promedio {round(sum(az)/len(az)) if az else 0} min | tardios(>960): {tard}")
|
||||
114
backend/diag_diana_8_9_10.py
Normal file
114
backend/diag_diana_8_9_10.py
Normal file
@@ -0,0 +1,114 @@
|
||||
# diag_diana_8_9_10.py
|
||||
# Dias 8, 9 y 10 de AGOSTO 2026 de DIANA: turno detectado (o LIBRE) y tiempos.
|
||||
# Aplica la regla del refrigerio (13-15 turno1: diferencia directa, -45 si supera 45).
|
||||
from datetime import datetime, timedelta, date
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES = 2026, 8
|
||||
DIAS = [8, 9, 10]
|
||||
NOMBRE = "DIANA"
|
||||
|
||||
dm = DataManager()
|
||||
asig = dm.traer_asignacion_respuesta()
|
||||
act_rows = dm.traer_actividad_asesores()
|
||||
|
||||
uid2name = {}
|
||||
for r in asig:
|
||||
if r.get("user_id") is not None:
|
||||
uid2name[r["user_id"]] = r.get("user_name") or ""
|
||||
|
||||
from collections import defaultdict
|
||||
act = defaultdict(lambda: defaultdict(int))
|
||||
for a in act_rows:
|
||||
uid = a.get("user_id"); ts = a.get("created_at")
|
||||
if uid not in uid2name or not isinstance(ts, datetime): continue
|
||||
nom = uid2name[uid].upper(); d = ts.date(); h = ts.hour + ts.minute/60.0
|
||||
key=(nom,d)
|
||||
if d.weekday()==6: continue
|
||||
if d.weekday()==5:
|
||||
if 9<=h<13: act[key]["sab_manana"]+=1
|
||||
elif 14<=h<18: act[key]["sab_tarde"]+=1
|
||||
else:
|
||||
if 13<=h<18: act[key]["tarde"]+=1
|
||||
elif 18<=h<22: act[key]["noche"]+=1
|
||||
|
||||
def turno_dia(nom,d):
|
||||
if d.weekday()==6: return None
|
||||
fr=act.get((nom,d))
|
||||
if not fr: return None
|
||||
if d.weekday()==5:
|
||||
m=fr.get("sab_manana",0); t=fr.get("sab_tarde",0)
|
||||
if m==0 and t==0: return None
|
||||
return [(9,13)] if m>=t else [(14,18)]
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
if t==0 and n==0: return None
|
||||
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
|
||||
|
||||
def es_t1(nom,d):
|
||||
fr=act.get((nom,d))
|
||||
if not fr or d.weekday()>=5: return False
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
return (not (n>t)) and (t>0 or n>0)
|
||||
|
||||
def _dt(d,hh): return datetime(d.year,d.month,d.day)+timedelta(hours=hh)
|
||||
|
||||
def mins_laborales(nom,t1,t2):
|
||||
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
|
||||
total=0.0; cur=t1; s=0
|
||||
while cur<t2 and s<60:
|
||||
d=cur.date(); bl=turno_dia(nom,d)
|
||||
if not bl: cur=_dt(d,24); s+=1; continue
|
||||
for (hi,hf) in bl:
|
||||
ini=_dt(d,hi); fin=_dt(d,hf)
|
||||
if t2<=ini: break
|
||||
if cur>=fin: continue
|
||||
a=max(cur,ini); b=min(t2,fin)
|
||||
if b>a: total+=(b-a).total_seconds()/60.0
|
||||
cur=fin
|
||||
if cur>=t2: break
|
||||
if cur.date()==d: cur=_dt(d,24); s+=1
|
||||
return round(total)
|
||||
|
||||
for DIA in DIAS:
|
||||
d1=date(ANO,MES,DIA)
|
||||
filas=[]
|
||||
for r in asig:
|
||||
if NOMBRE not in (r.get("user_name") or "").upper(): continue
|
||||
t1=r.get("created_at")
|
||||
if not isinstance(t1,datetime): continue
|
||||
if t1.year==ANO and t1.month==MES and t1.day==DIA: filas.append(r)
|
||||
# turno del dia (por actividad de Diana)
|
||||
nom_up = (filas[0].get("user_name") or "").upper() if filas else NOMBRE
|
||||
tt=turno_dia(nom_up,d1)
|
||||
if tt is None:
|
||||
turno="LIBRE / DESCANSO (sin actividad ese dia)"
|
||||
elif tt==[(9,13),(14,18)]: turno="TURNO 1 (9-18)"
|
||||
elif tt==[(9,13),(18,22)]: turno="TURNO 2 (9-13 + 18-22)"
|
||||
else: turno=str(tt)
|
||||
|
||||
print(f"\n{'='*66}")
|
||||
print(f"DIANA — {DIA:02d}/{MES:02d}/{ANO} -> {turno}")
|
||||
print(f"Total asignados: {len(filas)}")
|
||||
print(f"{'='*66}")
|
||||
print(f"{'#':3} {'telefono':13} {'asignado':11} {'respondio':11} {'min':>5} nota")
|
||||
print("-"*60)
|
||||
resp=0; sinr=0; suma=0
|
||||
for i,r in enumerate(sorted(filas,key=lambda x:x['created_at']),1):
|
||||
t1=r['created_at']; t2=r.get('respuesta_fecha'); tel=r.get('telefono','')
|
||||
h=t1.hour+t1.minute/60.0
|
||||
refri = es_t1(nom_up,d1) and (13<=h<15)
|
||||
if not isinstance(t2,datetime):
|
||||
sinr+=1
|
||||
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {'':11} {'':>5}")
|
||||
continue
|
||||
if refri:
|
||||
dif=(t2-t1).total_seconds()/60.0
|
||||
m=round(dif-45) if dif>45 else round(dif)
|
||||
if m<0: m=0
|
||||
nota=" refrigerio (-45 si >45)"
|
||||
else:
|
||||
m=mins_laborales(nom_up,t1,t2); nota=""
|
||||
resp+=1; suma+=m
|
||||
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {t2.strftime('%H:%M:%S'):11} {m:>5}{nota}")
|
||||
print("-"*60)
|
||||
print(f"Respondidos: {resp} | Sin responder: {sinr} | Promedio: {round(suma/resp) if resp else 0} min")
|
||||
100
backend/diag_diana_resp_ago11.py
Normal file
100
backend/diag_diana_resp_ago11.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# diag_diana_resp_ago11.py
|
||||
# NUEVO grafico (por FECHA DE RESPUESTA): leads que DIANA RESPONDIO el 11/08/2026,
|
||||
# sin importar cuando se asignaron. Muestra asignacion, respuesta y minutos.
|
||||
from datetime import datetime, timedelta, date
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES, DIA_RESP = 2026, 8, 11 # dia de RESPUESTA
|
||||
NOMBRE = "DIANA"
|
||||
|
||||
dm = DataManager()
|
||||
asig = dm.traer_asignacion_respuesta()
|
||||
act_rows = dm.traer_actividad_asesores()
|
||||
|
||||
uid2name = {}
|
||||
for r in asig:
|
||||
if r.get("user_id") is not None:
|
||||
uid2name[r["user_id"]] = r.get("user_name") or ""
|
||||
|
||||
from collections import defaultdict
|
||||
act = defaultdict(lambda: defaultdict(int))
|
||||
for a in act_rows:
|
||||
uid=a.get("user_id"); ts=a.get("created_at")
|
||||
if uid not in uid2name or not isinstance(ts,datetime): continue
|
||||
nom=uid2name[uid].upper(); d=ts.date(); h=ts.hour+ts.minute/60.0; key=(nom,d)
|
||||
if d.weekday()==6: continue
|
||||
if d.weekday()==5:
|
||||
if 9<=h<13: act[key]["sab_manana"]+=1
|
||||
elif 14<=h<18: act[key]["sab_tarde"]+=1
|
||||
else:
|
||||
if 13<=h<18: act[key]["tarde"]+=1
|
||||
elif 18<=h<22: act[key]["noche"]+=1
|
||||
|
||||
def turno_dia(nom,d):
|
||||
if d.weekday()==6: return None
|
||||
fr=act.get((nom,d))
|
||||
if not fr: return None
|
||||
if d.weekday()==5:
|
||||
m=fr.get("sab_manana",0); t=fr.get("sab_tarde",0)
|
||||
if m==0 and t==0: return None
|
||||
return [(9,13)] if m>=t else [(14,18)]
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
if t==0 and n==0: return None
|
||||
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
|
||||
|
||||
def es_t1(nom,d):
|
||||
fr=act.get((nom,d))
|
||||
if not fr or d.weekday()>=5: return False
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
return (not (n>t)) and (t>0 or n>0)
|
||||
|
||||
def _dt(d,hh): return datetime(d.year,d.month,d.day)+timedelta(hours=hh)
|
||||
|
||||
def mins_laborales(nom,t1,t2):
|
||||
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
|
||||
total=0.0; cur=t1; s=0
|
||||
while cur<t2 and s<60:
|
||||
d=cur.date(); bl=turno_dia(nom,d)
|
||||
if not bl: cur=_dt(d,24); s+=1; continue
|
||||
for (hi,hf) in bl:
|
||||
ini=_dt(d,hi); fin=_dt(d,hf)
|
||||
if t2<=ini: break
|
||||
if cur>=fin: continue
|
||||
a=max(cur,ini); b=min(t2,fin)
|
||||
if b>a: total+=(b-a).total_seconds()/60.0
|
||||
cur=fin
|
||||
if cur>=t2: break
|
||||
if cur.date()==d: cur=_dt(d,24); s+=1
|
||||
return round(total)
|
||||
|
||||
# filtrar Diana + RESPUESTA el 11
|
||||
filas=[]
|
||||
for r in asig:
|
||||
if NOMBRE not in (r.get("user_name") or "").upper(): continue
|
||||
t1=r.get("created_at"); t2=r.get("respuesta_fecha")
|
||||
if not isinstance(t1,datetime) or not isinstance(t2,datetime): continue # sin respuesta -> fuera
|
||||
if t2.year==ANO and t2.month==MES and t2.day==DIA_RESP:
|
||||
filas.append(r)
|
||||
|
||||
print(f"\n=== DIANA — leads RESPONDIDOS el {DIA_RESP:02d}/{MES:02d}/{ANO} (grafico por fecha respuesta) ===\n")
|
||||
print(f"Total respondidos ese dia: {len(filas)}\n")
|
||||
print(f"{'#':3} {'telefono':13} {'asignado':17} {'respondio':17} {'min':>5} nota")
|
||||
print("-"*74)
|
||||
suma=0
|
||||
for i,r in enumerate(sorted(filas, key=lambda x: x['respuesta_fecha']),1):
|
||||
t1=r['created_at']; t2=r['respuesta_fecha']; tel=r.get('telefono','')
|
||||
d1=t1.date(); h=t1.hour+t1.minute/60.0
|
||||
refri = es_t1((r.get('user_name') or '').upper(), d1) and (13<=h<15)
|
||||
if refri:
|
||||
dif=(t2-t1).total_seconds()/60.0
|
||||
m=round(dif-45) if dif>45 else round(dif)
|
||||
if m<0: m=0
|
||||
nota=" refrig(-45)"
|
||||
else:
|
||||
m=mins_laborales((r.get('user_name') or '').upper(),t1,t2); nota=""
|
||||
# marca si se asigno OTRO dia
|
||||
if t1.date()!=t2.date(): nota += f" (asignado {t1.strftime('%d/%m')})"
|
||||
suma+=m
|
||||
print(f"{i:3} {tel:13} {t1.strftime('%d/%m %H:%M'):17} {t2.strftime('%d/%m %H:%M'):17} {m:>5}{nota}")
|
||||
print("-"*74)
|
||||
print(f"Promedio del dia {DIA_RESP} (por fecha respuesta): {round(suma/len(filas)) if filas else 0} min")
|
||||
44
backend/diag_diff_asignados_diana13.py
Normal file
44
backend/diag_diff_asignados_diana13.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# diag_diff_asignados_diana13.py
|
||||
# Compara las 2 fuentes de "leads asignados" para DIANA el dia 13/08/2026:
|
||||
# A = traer_leads_asignados (la de la TARJETA)
|
||||
# B = traer_asignacion_respuesta (la del GRAFICO)
|
||||
# Muestra los telefonos de cada una y cuales difieren.
|
||||
from datetime import datetime
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES, DIA = 2026, 8, 13
|
||||
NOMBRE = "DIANA"
|
||||
dm = DataManager()
|
||||
|
||||
def _d(v): return v.date() if isinstance(v, datetime) else None
|
||||
|
||||
A = {} # telefono -> hora asignacion (TARJETA)
|
||||
for r in dm.traer_leads_asignados():
|
||||
if NOMBRE not in (r.get("user_name") or "").upper(): continue
|
||||
f = r.get("created_at")
|
||||
if _d(f) and f.year==ANO and f.month==MES and f.day==DIA:
|
||||
A[str(r.get("telefono") or "").strip()] = f
|
||||
|
||||
B = {} # telefono -> hora asignacion (GRAFICO)
|
||||
for r in dm.traer_asignacion_respuesta():
|
||||
if NOMBRE not in (r.get("user_name") or "").upper(): continue
|
||||
f = r.get("created_at")
|
||||
if _d(f) and f.year==ANO and f.month==MES and f.day==DIA:
|
||||
B[str(r.get("telefono") or "").strip()] = f
|
||||
|
||||
print(f"\n=== DIANA dia {DIA} — comparacion de fuentes ===\n")
|
||||
print(f"A) TARJETA (traer_leads_asignados): {len(A)} telefonos")
|
||||
print(f"B) GRAFICO (traer_asignacion_respuesta): {len(B)} telefonos\n")
|
||||
|
||||
print("Telefonos en A (tarjeta):")
|
||||
for t in sorted(A): print(f" {t} asignado {A[t].strftime('%H:%M:%S')}")
|
||||
print("\nTelefonos en B (grafico):")
|
||||
for t in sorted(B): print(f" {t} asignado {B[t].strftime('%H:%M:%S')}")
|
||||
|
||||
solo_A = set(A) - set(B)
|
||||
solo_B = set(B) - set(A)
|
||||
print("\n--- DIFERENCIAS ---")
|
||||
if solo_A: print(f" Solo en TARJETA (A), no en grafico: {sorted(solo_A)}")
|
||||
if solo_B: print(f" Solo en GRAFICO (B), no en tarjeta: {sorted(solo_B)}")
|
||||
if not solo_A and not solo_B:
|
||||
print(" Los telefonos son IDENTICOS. La diferencia debe ser cache (reinicia backend).")
|
||||
42
backend/diag_excel_arequipa.py
Normal file
42
backend/diag_excel_arequipa.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Diagnostico: baja el Excel de Arequipa de SharePoint y muestra que hojas
|
||||
y que tablas nombradas detecta openpyxl, y cuantas filas tiene cada una.
|
||||
Asi sabemos por que el sync cae al fallback (10000 filas).
|
||||
"""
|
||||
import os, io, openpyxl, requests, msal
|
||||
|
||||
CLIENT_ID = os.environ["MS_CLIENT_ID"]
|
||||
TENANT_ID = os.environ["MS_TENANT_ID"]
|
||||
REFRESH_TOKEN = os.environ["MS_REFRESH_TOKEN"]
|
||||
SHAREPOINT_SITE = "escuelarefrigeracion.sharepoint.com"
|
||||
SITE_PATH = "/sites/ASESORASCOMERCIALES"
|
||||
SUBFOLDER = "2. BASE PROSPECTOS/BASE GENERAL"
|
||||
SCOPES = ["Sites.Read.All", "Files.Read.All"]
|
||||
|
||||
ARCHIVOS = ["Base Sede Arequipa.xlsx", "Base Diana Chavez.xlsx"] # uno malo + uno bueno para comparar
|
||||
|
||||
app = msal.PublicClientApplication(CLIENT_ID, authority=f"https://login.microsoftonline.com/{TENANT_ID}")
|
||||
tok = app.acquire_token_by_refresh_token(REFRESH_TOKEN, scopes=SCOPES)["access_token"]
|
||||
h = {"Authorization": f"Bearer {tok}"}
|
||||
|
||||
sid = requests.get(f"https://graph.microsoft.com/v1.0/sites/{SHAREPOINT_SITE}:{SITE_PATH}", headers=h).json()["id"]
|
||||
drives = requests.get(f"https://graph.microsoft.com/v1.0/sites/{sid}/drives", headers=h).json()["value"]
|
||||
did = next((d["id"] for d in drives if "document" in d["name"].lower() or "compartid" in d["name"].lower()), drives[0]["id"])
|
||||
items = requests.get(f"https://graph.microsoft.com/v1.0/drives/{did}/root:/{SUBFOLDER}:/children", headers=h).json()["value"]
|
||||
|
||||
for nombre in ARCHIVOS:
|
||||
it = next((x for x in items if x["name"] == nombre), None)
|
||||
print("\n" + "="*60)
|
||||
if not it:
|
||||
print(f"❌ NO ENCONTRADO en SharePoint: '{nombre}'")
|
||||
print(" Archivos disponibles:", [x["name"] for x in items if x["name"].endswith(".xlsx")])
|
||||
continue
|
||||
print(f"📄 {nombre}")
|
||||
cont = requests.get(f"https://graph.microsoft.com/v1.0/drives/{did}/items/{it['id']}/content", headers=h).content
|
||||
wb = openpyxl.load_workbook(io.BytesIO(cont), data_only=True)
|
||||
for ws in wb.worksheets:
|
||||
print(f" Hoja: '{ws.title}' dims={ws.dimensions} max_row={ws.max_row}")
|
||||
tbls = list(ws.tables.keys())
|
||||
print(f" Tablas nombradas: {tbls}")
|
||||
for tn, tb in ws.tables.items():
|
||||
print(f" · {tn} -> ref={tb.ref}")
|
||||
45
backend/diag_fecha_contacto.py
Normal file
45
backend/diag_fecha_contacto.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Diagnostico: para una lista de telefonos, muestra la fecha de creacion del
|
||||
contacto (contacts.created_at) y la fecha del primer/ultimo mensaje.
|
||||
"""
|
||||
import os
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
PG_HOST = os.getenv("PG_HOST")
|
||||
PG_DB = os.getenv("PG_DATABASE") or os.getenv("PG_DB")
|
||||
PG_USER = os.getenv("PG_USER")
|
||||
PG_PASS = os.getenv("PG_PASSWORD") or os.getenv("PG_PASS")
|
||||
PG_PORT = os.getenv("PG_PORT", "5432")
|
||||
|
||||
TELEFONOS = ["948400152", "953932854", "959735396", "972132288"]
|
||||
|
||||
conn = psycopg2.connect(host=PG_HOST, dbname=PG_DB, user=PG_USER,
|
||||
password=PG_PASS, port=PG_PORT, connect_timeout=30)
|
||||
cur = conn.cursor()
|
||||
|
||||
print(f"{'TELEFONO':14} {'phone_number':16} {'CONTACTO_CREADO':22} {'1ER_MSJE':22} {'ULT_MSJE':22}")
|
||||
print("-" * 100)
|
||||
|
||||
for tel in TELEFONOS:
|
||||
cur.execute("""
|
||||
SELECT c.phone_number, c.created_at,
|
||||
MIN(m.created_at) AS primer_msje,
|
||||
MAX(m.created_at) AS ultimo_msje
|
||||
FROM contacts c
|
||||
LEFT JOIN conversations cv ON cv.contact_id = c.id
|
||||
LEFT JOIN messages m ON m.conversation_id = cv.id
|
||||
WHERE REPLACE(REPLACE(c.phone_number, '+51',''), '+','') LIKE %s
|
||||
GROUP BY c.phone_number, c.created_at
|
||||
ORDER BY c.created_at ASC
|
||||
""", (f"%{tel}",))
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
print(f"{tel:14} {'(no encontrado)':16}")
|
||||
continue
|
||||
for ph, creado, primer, ultimo in rows:
|
||||
print(f"{tel:14} {str(ph):16} {str(creado):22} {str(primer):22} {str(ultimo):22}")
|
||||
|
||||
conn.close()
|
||||
12
backend/diag_importe.py
Normal file
12
backend/diag_importe.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# diag_importe.py — Verifica matriz_asignados (pivot asesor x dia).
|
||||
import services as S
|
||||
|
||||
d = S.matriz_asignados("2026", "7", "TODOS")
|
||||
print("MES 7 2026 TOTAL asignados:", d["total"])
|
||||
print("dias (columnas):", d["dias"][:5], "...", d["dias"][-3:])
|
||||
print("\nPor asesor (total):")
|
||||
for f in d["filas"]:
|
||||
dias_con = {k:v for k,v in f["por_dia"].items() if v}
|
||||
print(f" {f['asesor']:22} total={f['total']:4} dias con datos: {len(dias_con)}")
|
||||
print("\ntotal_por_dia (primeros 10):", {k:v for k,v in list(d['total_por_dia'].items())[:10]})
|
||||
print("OK.")
|
||||
58
backend/diag_matri_50_vs_51.py
Normal file
58
backend/diag_matri_50_vs_51.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# diag_matri_50_vs_51.py
|
||||
# Busca la matricula de diferencia (dashboard 51 vs cmd 50) en TEAC agosto.
|
||||
# Replica EXACTAMENTE el bloque de la matriz del dashboard y compara.
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import services as S
|
||||
import leads_logic as L
|
||||
|
||||
ANO, MES = 2026, 8
|
||||
ase = S._asesores(); amap = ase["map"]; lista = ase["lista"]
|
||||
|
||||
# ---- REPLICA EXACTA del bloque de matriz del dashboard ----
|
||||
GRUPOS = ["TEAC","TERC","SEMINARIOS","OTROS"]
|
||||
prog_tot = defaultdict(int)
|
||||
detalle = defaultdict(list) # grupo -> [matriculas]
|
||||
for m in S._matriculas_crudas():
|
||||
vend = S._norm_asesor(m.get("dsc_vendedor"), amap)
|
||||
if not vend:
|
||||
continue
|
||||
estado = str(m.get("estado_matricula") or "").strip().upper()
|
||||
if estado == "ANU":
|
||||
continue
|
||||
fm = L._to_date(m.get("fch_matricula"))
|
||||
if not L._en_periodo(fm, str(ANO), str(MES), "TODOS"):
|
||||
continue
|
||||
grupo = L.grupo_programa_curso(m.get("dsc_programa"))
|
||||
prog_tot[grupo] += 1
|
||||
detalle[grupo].append(m)
|
||||
|
||||
print("=== Conteo por grupo (replica dashboard) ===")
|
||||
tot=0
|
||||
for g in GRUPOS:
|
||||
print(f" {g:12} {prog_tot[g]}")
|
||||
tot+=prog_tot[g]
|
||||
print(f" TOTAL {tot}")
|
||||
|
||||
# ---- Detalle de TEAC: mostrar TODAS con num_matricula para ver duplicados/algo raro ----
|
||||
print(f"\n=== TEAC — {len(detalle['TEAC'])} matriculas (con num_matricula y num_indice) ===")
|
||||
for m in sorted(detalle['TEAC'], key=lambda x: str(x.get('fch_matricula'))):
|
||||
fm = L._to_date(m.get('fch_matricula'))
|
||||
print(f" mat={m.get('num_matricula')} idx={m.get('num_indice')} "
|
||||
f"{S._norm_asesor(m.get('dsc_vendedor'),amap):16} {str(fm)[:10]} "
|
||||
f"est={m.get('estado_matricula')} | {str(m.get('dsc_programa'))[:40]}")
|
||||
|
||||
# ---- Ver si hay matriculas cuyo asesor NO cae en los 8 (por eso el dashboard podria contar distinto) ----
|
||||
print(f"\n=== Matriculas agosto con asesor que NO es de los 8 (quedan fuera del cmd) ===")
|
||||
n_fuera=0
|
||||
for m in S._matriculas_crudas():
|
||||
estado = str(m.get("estado_matricula") or "").strip().upper()
|
||||
if estado == "ANU": continue
|
||||
fm = L._to_date(m.get("fch_matricula"))
|
||||
if not (fm and fm.year==ANO and fm.month==MES): continue
|
||||
vend = S._norm_asesor(m.get("dsc_vendedor"), amap)
|
||||
if not vend:
|
||||
n_fuera += 1
|
||||
print(f" mat={m.get('num_matricula')} asesor_original='{m.get('dsc_vendedor')}' "
|
||||
f"grupo={L.grupo_programa_curso(m.get('dsc_programa'))} est={estado}")
|
||||
print(f" -> {n_fuera} matriculas con asesor fuera de los 8")
|
||||
51
backend/diag_matri_51_perdida.py
Normal file
51
backend/diag_matri_51_perdida.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# diag_matri_51_perdida.py
|
||||
# Busca la matricula #51: revisa TODAS las de agosto (incluso ANU/RET) para ver
|
||||
# cual pudo aparecer/desaparecer. Va directo a SQL Server (fresco, sin cache).
|
||||
from datetime import datetime
|
||||
import services as S
|
||||
import leads_logic as L
|
||||
|
||||
ANO, MES = 2026, 8
|
||||
ase = S._asesores(); amap = ase["map"]
|
||||
|
||||
# TODAS las matriculas de agosto (sin excluir estado), directo de traer_matriculas
|
||||
todas = []
|
||||
for m in S.get_dm().traer_matriculas():
|
||||
fm = L._to_date(m.get("fch_matricula"))
|
||||
if not (fm and fm.year==ANO and fm.month==MES): continue
|
||||
todas.append(m)
|
||||
|
||||
print(f"=== TODAS las matriculas de agosto {ANO} (cualquier estado): {len(todas)} ===\n")
|
||||
from collections import Counter
|
||||
est = Counter(str(m.get("estado_matricula") or "").strip().upper() for m in todas)
|
||||
print("Por estado:", dict(est))
|
||||
|
||||
# las que cuentan (no ANU, asesor de los 8)
|
||||
cuentan = 0
|
||||
no_asesor = 0
|
||||
anu = 0
|
||||
for m in todas:
|
||||
e = str(m.get("estado_matricula") or "").strip().upper()
|
||||
if e == "ANU":
|
||||
anu += 1; continue
|
||||
v = S._norm_asesor(m.get("dsc_vendedor"), amap)
|
||||
if not v:
|
||||
no_asesor += 1
|
||||
print(f" [FUERA 8] mat={m.get('num_matricula')} asesor='{m.get('dsc_vendedor')}' est={e} "
|
||||
f"grupo={L.grupo_programa_curso(m.get('dsc_programa'))}")
|
||||
continue
|
||||
cuentan += 1
|
||||
|
||||
print(f"\nCuentan (no ANU + asesor de los 8): {cuentan}")
|
||||
print(f"Anuladas (ANU): {anu}")
|
||||
print(f"Con asesor fuera de los 8: {no_asesor}")
|
||||
|
||||
# mostrar TODAS con su estado para ver si hay algo raro (RET, SUS, etc.)
|
||||
print(f"\n=== Detalle: matriculas con estado != ALU/PRE (posibles casos raros) ===")
|
||||
for m in todas:
|
||||
e = str(m.get("estado_matricula") or "").strip().upper()
|
||||
if e not in ("ALU","PRE"):
|
||||
v = S._norm_asesor(m.get("dsc_vendedor"), amap)
|
||||
fm = L._to_date(m.get("fch_matricula"))
|
||||
print(f" mat={m.get('num_matricula')} est={e} asesor={v or m.get('dsc_vendedor')} "
|
||||
f"{str(fm)[:10]} grupo={L.grupo_programa_curso(m.get('dsc_programa'))}")
|
||||
51
backend/diag_matriculas_agosto_detalle.py
Normal file
51
backend/diag_matriculas_agosto_detalle.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# diag_matriculas_agosto_detalle.py
|
||||
# Las 51 matriculas de AGOSTO 2026 (todos los asesores de los 8), con su
|
||||
# dsc_det_programa (detalle del programa) y grupo. Mismo criterio del dashboard:
|
||||
# dsc_vendedor de los 8, estado != ANU, fch_matricula en agosto.
|
||||
from datetime import datetime
|
||||
from collections import defaultdict, Counter
|
||||
import services as S
|
||||
import leads_logic as L
|
||||
|
||||
ANO, MES = 2026, 8
|
||||
|
||||
ase = S._asesores(); amap = ase["map"]
|
||||
|
||||
filas = []
|
||||
for m in S._matriculas_crudas():
|
||||
vend = S._norm_asesor(m.get("dsc_vendedor"), amap)
|
||||
if not vend:
|
||||
continue
|
||||
estado = str(m.get("estado_matricula") or "").strip().upper()
|
||||
if estado == "ANU":
|
||||
continue
|
||||
fm = L._to_date(m.get("fch_matricula"))
|
||||
if not fm or fm.year != ANO or fm.month != MES:
|
||||
continue
|
||||
filas.append({
|
||||
"vendedor": vend,
|
||||
"num_matricula": m.get("num_matricula"),
|
||||
"estado": estado,
|
||||
"fch": fm.strftime("%d/%m/%Y"),
|
||||
"dsc_programa": str(m.get("dsc_programa") or ""),
|
||||
"dsc_det": str(m.get("dsc_promocion") or ""), # dsc_det_programa
|
||||
"grupo": L.grupo_programa_curso(m.get("dsc_programa")),
|
||||
})
|
||||
|
||||
print(f"\n=== MATRICULAS AGOSTO {ANO} — todos los asesores (total: {len(filas)}) ===\n")
|
||||
print(f"{'#':3} {'vendedor':16} {'estado':6} {'fch':11} {'grupo':10} dsc_det_programa")
|
||||
print("-"*100)
|
||||
for i, f in enumerate(sorted(filas, key=lambda x: (x['grupo'], x['vendedor'])), 1):
|
||||
print(f"{i:3} {f['vendedor'][:16]:16} {f['estado']:6} {f['fch']:11} {f['grupo']:10} {f['dsc_det'][:45]}")
|
||||
|
||||
print("-"*100)
|
||||
print("\n=== RESUMEN por dsc_det_programa (cuantas matriculas de cada uno) ===")
|
||||
cnt = Counter(f["dsc_det"] for f in filas)
|
||||
for prog, n in sorted(cnt.items(), key=lambda x: -x[1]):
|
||||
print(f" {n:3} {prog}")
|
||||
|
||||
print("\n=== RESUMEN por GRUPO (debe cuadrar con la matriz del dashboard) ===")
|
||||
gr = Counter(f["grupo"] for f in filas)
|
||||
for g in ["TEAC","TERC","SEMINARIOS","OTROS"]:
|
||||
print(f" {g:12} {gr.get(g,0)}")
|
||||
print(f" {'TOTAL':12} {len(filas)}")
|
||||
22
backend/diag_multi.py
Normal file
22
backend/diag_multi.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# diag_multi.py — Revisa programa_pautas y la suma por num_indice.
|
||||
import services as S
|
||||
from cache_manager import cache_invalidate
|
||||
|
||||
# forzar leer fresco de supabase
|
||||
cache_invalidate("programa_pautas")
|
||||
cache_invalidate("pautas_de_indice")
|
||||
|
||||
pp = S._programa_pautas() # {num_indice: [pautas]} SOLO de la tabla nueva
|
||||
print("== Tabla programa_pautas (lo que se guardo) ==")
|
||||
if not pp:
|
||||
print(" (VACIA - no se guardo nada, o no se lee)")
|
||||
for ni, ps in pp.items():
|
||||
print(f" num_indice {ni} -> {ps}")
|
||||
|
||||
ppi = S._pautas_de_indice() # combinado basebi + nueva
|
||||
print("\n== num_indices con VARIAS pautas (basebi + nueva) ==")
|
||||
multi = {ni: ps for ni, ps in ppi.items() if len(ps) > 1}
|
||||
for ni, ps in list(multi.items())[:15]:
|
||||
print(f" num_indice {ni} -> {ps}")
|
||||
if not multi:
|
||||
print(" (ninguno con varias)")
|
||||
54
backend/diag_numero_chatwoot.py
Normal file
54
backend/diag_numero_chatwoot.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# diag_numero_chatwoot.py - Ver los mensajes de un numero en Chatwoot y que campania hace match.
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import psycopg2
|
||||
load_dotenv()
|
||||
|
||||
TEL = "8099166706" # buscar por terminacion (sin prefijo pais)
|
||||
|
||||
dm_host=os.getenv("PG_HOST","191.98.134.81"); dm_db=os.getenv("PG_DATABASE","chatwoot_production")
|
||||
dm_user=os.getenv("PG_USER","postgres"); dm_pass=os.getenv("PG_PASSWORD",""); dm_port=os.getenv("PG_PORT","5432")
|
||||
conn=psycopg2.connect(host=dm_host,dbname=dm_db,user=dm_user,password=dm_pass,port=dm_port)
|
||||
cur=conn.cursor()
|
||||
|
||||
# mensajes del contacto (por terminacion del telefono)
|
||||
cur.execute("""
|
||||
SELECT c.phone_number, m.id, m.created_at, m.sender_type, LEFT(m.content, 90)
|
||||
FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id=cv.id
|
||||
JOIN contacts c ON cv.contact_id=c.id
|
||||
WHERE c.phone_number LIKE %s
|
||||
ORDER BY m.id ASC
|
||||
""", ('%'+TEL,))
|
||||
rows=cur.fetchall()
|
||||
print(f"\n=== Mensajes en Chatwoot del numero terminado en {TEL} ===\n")
|
||||
if not rows:
|
||||
print(" (no se encontro el numero en Chatwoot)")
|
||||
for ph,mid,fecha,stype,cont in rows:
|
||||
print(f" [{ph}] {fecha} {stype:8} | {cont}")
|
||||
|
||||
# ¿el primer mensaje del contacto hace match con alguna campania por texto?
|
||||
print("\n=== Campanias cuyo texto aparece en algun mensaje de este numero ===")
|
||||
cur.execute("""
|
||||
SELECT map.codigo, map.cargo, map.fecha_inicio, map.fecha_fin, map.frase
|
||||
FROM (VALUES
|
||||
(' 🖥️ Estoy interesado en sus Seminarios','Seminarios','74a','2026-04-01','2026-07-30'),
|
||||
('👾Estoy interesado en el Seminario Refrigeración','CO2','80a','2026-05-15','2026-07-30')
|
||||
) AS map(frase, cargo, codigo, fecha_inicio, fecha_fin)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id=cv.id
|
||||
JOIN contacts c ON cv.contact_id=c.id
|
||||
WHERE c.phone_number LIKE %s AND m.sender_type='Contact'
|
||||
AND m.content LIKE '%%'||map.frase||'%%'
|
||||
)
|
||||
""", ('%'+TEL,))
|
||||
mt=cur.fetchall()
|
||||
if not mt:
|
||||
print(" (ninguna de las 2 frases de ejemplo hizo match textual - revisar frase exacta)")
|
||||
for cod,cargo,fi,ff,frase in mt:
|
||||
print(f" codigo={cod} cargo={cargo} rango={fi}..{ff} frase='{frase}'")
|
||||
|
||||
conn.close()
|
||||
print("\nNOTA: si el match textual SI existe pero la fecha del mensaje esta fuera")
|
||||
print("del rango de la campania, por eso NO cuenta como pauta (queda Copito).")
|
||||
12
backend/diag_plantillas.py
Normal file
12
backend/diag_plantillas.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# diag_plantillas.py — Verifica otros_general (endpoint liviano).
|
||||
import time, services as S
|
||||
|
||||
for mes in ["TODOS", "1", "2"]:
|
||||
t0 = time.time()
|
||||
d = S.otros_general("2026", mes, "TODOS")
|
||||
seg = time.time() - t0
|
||||
p = d["matriz_plantillas"]["total"]
|
||||
print(f"MES {mes:6} ({seg:.1f}s) plantillas_env={p['enviadas']} matric={p['matriculas']} "
|
||||
f"always_filas={len(d['matriz_always']['filas'])} webform_filas={len(d['matriz_webform']['filas'])} "
|
||||
f"asignados_total={d['matriz_asignados']['total']}")
|
||||
print("OK. (2da vez el mismo mes debe ser instantaneo por cache)")
|
||||
58
backend/diag_roas_faltantes.py
Normal file
58
backend/diag_roas_faltantes.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Diagnostico ROAS: conjuntos del Meta CSV (enero) que NO se suman en la matriz
|
||||
por sede, porque no tienen pauta ligada o su pauta no tiene sede en campanias.
|
||||
"""
|
||||
import services as S
|
||||
import leads_logic as L
|
||||
|
||||
ANO, MES = "2026", "1" # enero 2026
|
||||
SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"}
|
||||
|
||||
conj_pauta = S._conjunto_pauta() # {pauta: [conjunto,...]}
|
||||
camp = S._campanias_sede_cargo() # {pauta: {sede, cargo}}
|
||||
|
||||
conj_sede = {}
|
||||
conj_pauta_map = {}
|
||||
for pauta, conjuntos in conj_pauta.items():
|
||||
sp = camp.get(str(pauta).strip())
|
||||
sede = " ".join(str((sp or {}).get("sede") or "").upper().split())
|
||||
for co in conjuntos:
|
||||
k = " ".join(str(co).split()).upper()
|
||||
conj_pauta_map[k] = str(pauta).strip()
|
||||
if sp and sede in SEDES:
|
||||
conj_sede[k] = sede
|
||||
|
||||
sin_sumar = {}
|
||||
suma_ok = 0.0
|
||||
suma_falta = 0.0
|
||||
for f in S._meta_filas():
|
||||
fx = L._to_date(f.get("inicio"))
|
||||
if not L._en_periodo(fx, ANO, MES, "TODOS"):
|
||||
continue
|
||||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||||
imp = float(f.get("importe") or 0.0)
|
||||
res = int(float(f.get("resultados") or 0.0))
|
||||
if k in conj_sede:
|
||||
suma_ok += imp
|
||||
continue
|
||||
if k not in conj_pauta_map:
|
||||
motivo = "SIN PAUTA"
|
||||
else:
|
||||
p = conj_pauta_map[k]
|
||||
sp = camp.get(p)
|
||||
motivo = (f"pauta '{p}' SIN campania" if not sp
|
||||
else f"pauta '{p}' sede='{sp.get('sede')}' (fuera de las 4)")
|
||||
d = sin_sumar.setdefault(k, {"importe": 0.0, "resultados": 0, "motivo": motivo})
|
||||
d["importe"] += imp
|
||||
d["resultados"] += res
|
||||
suma_falta += imp
|
||||
|
||||
print("=== ENERO 2026 — conjuntos que NO se suman en ROAS ===\n")
|
||||
print(f"{'CONJUNTO':45} {'IMPORTE':>12} {'RESULTADOS':>12} MOTIVO")
|
||||
print("-"*100)
|
||||
for k, d in sorted(sin_sumar.items(), key=lambda x: -x[1]["importe"]):
|
||||
print(f"{k[:45]:45} {('$'+format(d['importe'],',.2f')):>12} {d['resultados']:>12,} {d['motivo']}")
|
||||
print("-"*100)
|
||||
print(f"{'IMPORTE QUE SÍ SUMA (4 sedes)':45} {('$'+format(suma_ok,',.2f')):>12}")
|
||||
print(f"{'IMPORTE QUE NO SUMA (faltante)':45} {('$'+format(suma_falta,',.2f')):>12}")
|
||||
print(f"Conjuntos distintos sin sumar: {len(sin_sumar)}")
|
||||
73
backend/diag_roas_faltantes_2026.py
Normal file
73
backend/diag_roas_faltantes_2026.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Diagnostico ROAS por MES (2026): para cada mes, los conjuntos del Meta CSV que
|
||||
NO se suman en la matriz por sede (sin pauta / sin sede valida), con su importe.
|
||||
"""
|
||||
import services as S
|
||||
import leads_logic as L
|
||||
|
||||
ANO = "2026"
|
||||
SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"}
|
||||
MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO",
|
||||
"SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]
|
||||
|
||||
conj_pauta = S._conjunto_pauta() # {pauta: [conjunto,...]}
|
||||
camp = S._campanias_sede_cargo() # {pauta: {sede, cargo}}
|
||||
|
||||
conj_sede = {}
|
||||
conj_pauta_map = {}
|
||||
for pauta, conjuntos in conj_pauta.items():
|
||||
sp = camp.get(str(pauta).strip())
|
||||
sede = " ".join(str((sp or {}).get("sede") or "").upper().split())
|
||||
for co in conjuntos:
|
||||
k = " ".join(str(co).split()).upper()
|
||||
conj_pauta_map[k] = str(pauta).strip()
|
||||
if sp and sede in SEDES:
|
||||
conj_sede[k] = sede
|
||||
|
||||
filas = S._meta_filas()
|
||||
|
||||
for mi in range(1, 13):
|
||||
mes = str(mi)
|
||||
sin_sumar = {}
|
||||
suma_ok = 0.0
|
||||
suma_falta = 0.0
|
||||
for f in filas:
|
||||
fx = L._to_date(f.get("inicio"))
|
||||
if not L._en_periodo(fx, ANO, mes, "TODOS"):
|
||||
continue
|
||||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||||
imp = float(f.get("importe") or 0.0)
|
||||
res = int(float(f.get("resultados") or 0.0))
|
||||
if k in conj_sede:
|
||||
suma_ok += imp
|
||||
continue
|
||||
if k not in conj_pauta_map:
|
||||
motivo = "SIN PAUTA"
|
||||
else:
|
||||
p = conj_pauta_map[k]
|
||||
sp = camp.get(p)
|
||||
motivo = (f"pauta '{p}' SIN campania" if not sp
|
||||
else f"pauta '{p}' sede='{sp.get('sede')}' (fuera de las 4)")
|
||||
d = sin_sumar.setdefault(k, {"importe": 0.0, "resultados": 0, "motivo": motivo})
|
||||
d["importe"] += imp
|
||||
d["resultados"] += res
|
||||
suma_falta += imp
|
||||
|
||||
# solo imprimir meses con algo de data
|
||||
if suma_ok == 0 and not sin_sumar:
|
||||
continue
|
||||
|
||||
print("=" * 100)
|
||||
print(f" {MESES[mi-1]} {ANO}")
|
||||
print("=" * 100)
|
||||
if sin_sumar:
|
||||
print(f"{'CONJUNTO':45} {'IMPORTE':>12} {'RESULTADOS':>12} MOTIVO")
|
||||
print("-" * 100)
|
||||
for k, d in sorted(sin_sumar.items(), key=lambda x: -x[1]["importe"]):
|
||||
print(f"{k[:45]:45} {('$'+format(d['importe'],',.2f')):>12} {d['resultados']:>12,} {d['motivo']}")
|
||||
else:
|
||||
print(" (sin conjuntos faltantes)")
|
||||
print("-" * 100)
|
||||
print(f"{'IMPORTE QUE SÍ SUMA (4 sedes)':45} {('$'+format(suma_ok,',.2f')):>12}")
|
||||
print(f"{'IMPORTE QUE NO SUMA (faltante)':45} {('$'+format(suma_falta,',.2f')):>12}")
|
||||
print(f" Conjuntos distintos sin sumar: {len(sin_sumar)}\n")
|
||||
56
backend/diag_sin_mensaje.py
Normal file
56
backend/diag_sin_mensaje.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Diagnostico: contactos de Chatwoot que NO tienen mensaje de campaña
|
||||
(no entran por el query de Postgre de la cartera).
|
||||
Muestra: telefono, fecha del contacto (contacts.created_at),
|
||||
fecha del primer y ultimo mensaje (si tiene), y si tiene algun mensaje.
|
||||
"""
|
||||
import os
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
import data_manager_v2 as dm # para reutilizar las campañas (frases de campaña)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
PG_HOST = os.getenv("PG_HOST")
|
||||
PG_DB = os.getenv("PG_DATABASE") or os.getenv("PG_DB")
|
||||
PG_USER = os.getenv("PG_USER")
|
||||
PG_PASS = os.getenv("PG_PASSWORD") or os.getenv("PG_PASS")
|
||||
PG_PORT = os.getenv("PG_PORT", "5432")
|
||||
|
||||
# Frases de campaña (mismas que usa la cartera / dashboard)
|
||||
FRASES = [c[0] for c in dm._campanias_supabase()]
|
||||
|
||||
conn = psycopg2.connect(host=PG_HOST, dbname=PG_DB, user=PG_USER,
|
||||
password=PG_PASS, port=PG_PORT, connect_timeout=30)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Construir condicion: contacto que NO tiene NINGUN mensaje que haga match con alguna frase
|
||||
likes = " OR ".join(["m.content LIKE %s"] * len(FRASES))
|
||||
params = [f"%{fr}%" for fr in FRASES]
|
||||
|
||||
sql = f"""
|
||||
SELECT c.phone_number, c.created_at,
|
||||
COUNT(m.id) AS total_msjes,
|
||||
MIN(m.created_at) AS primer_msje,
|
||||
MAX(m.created_at) AS ultimo_msje,
|
||||
SUM(CASE WHEN ({likes}) THEN 1 ELSE 0 END) AS msjes_campania
|
||||
FROM contacts c
|
||||
LEFT JOIN conversations cv ON cv.contact_id = c.id
|
||||
LEFT JOIN messages m ON m.conversation_id = cv.id AND m.sender_type = 'Contact'
|
||||
GROUP BY c.phone_number, c.created_at
|
||||
HAVING SUM(CASE WHEN ({likes}) THEN 1 ELSE 0 END) = 0 -- SIN mensaje de campaña
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
|
||||
cur.execute(sql, params + params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
print(f"Contactos SIN mensaje de campaña (primeros 100):")
|
||||
print(f"{'phone_number':16} {'contacto_creado':22} {'#msjes':>7} {'primer_msje':22} {'ultimo_msje':22}")
|
||||
print("-" * 95)
|
||||
for ph, creado, tot, primer, ultimo, _camp in rows:
|
||||
print(f"{str(ph):16} {str(creado):22} {tot:>7} {str(primer):22} {str(ultimo):22}")
|
||||
|
||||
print(f"\nTotal mostrados: {len(rows)} (limite 100)")
|
||||
conn.close()
|
||||
102
backend/diag_tiempo_diana_ago10.py
Normal file
102
backend/diag_tiempo_diana_ago10.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# diag_tiempo_diana_ago10.py
|
||||
# Detalle del 10 de AGOSTO 2026 para DIANA CHAVEZ: asignados, hora de asignacion,
|
||||
# hora de respuesta y minutos laborales (misma logica del dashboard). Promedio al final.
|
||||
# Ejecutar en backend/: python diag_tiempo_diana_ago10.py
|
||||
from datetime import datetime, timedelta, date
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES, DIA = 2026, 8, 10
|
||||
NOMBRE = "DIANA" # user_name contiene esto
|
||||
|
||||
dm = DataManager()
|
||||
asig = dm.traer_asignacion_respuesta()
|
||||
act_rows = dm.traer_actividad_asesores()
|
||||
|
||||
uid2name = {}
|
||||
for r in asig:
|
||||
if r.get("user_id") is not None:
|
||||
uid2name[r["user_id"]] = r.get("user_name") or ""
|
||||
|
||||
from collections import defaultdict
|
||||
act = defaultdict(lambda: defaultdict(int))
|
||||
for a in act_rows:
|
||||
uid = a.get("user_id"); ts = a.get("created_at")
|
||||
if uid not in uid2name or not isinstance(ts, datetime): continue
|
||||
nom = uid2name[uid].upper(); d = ts.date(); h = ts.hour + ts.minute/60.0
|
||||
key = (nom, d)
|
||||
if d.weekday() == 6: continue
|
||||
if d.weekday() == 5:
|
||||
if 9 <= h < 13: act[key]["sab_manana"] += 1
|
||||
elif 14 <= h < 18: act[key]["sab_tarde"] += 1
|
||||
else:
|
||||
if 13 <= h < 18: act[key]["tarde"] += 1
|
||||
elif 18 <= h < 22: act[key]["noche"] += 1
|
||||
|
||||
def turno_dia(nom, d):
|
||||
if d.weekday() == 6: return None
|
||||
fr = act.get((nom, d))
|
||||
if not fr: return None
|
||||
if d.weekday() == 5:
|
||||
m = fr.get("sab_manana",0); t = fr.get("sab_tarde",0)
|
||||
if m==0 and t==0: return None
|
||||
return [(9,13)] if m>=t else [(14,18)]
|
||||
t = fr.get("tarde",0); n = fr.get("noche",0)
|
||||
if t==0 and n==0: return None
|
||||
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
|
||||
|
||||
def _dt(d, hh): return datetime(d.year,d.month,d.day) + timedelta(hours=hh)
|
||||
|
||||
def mins_laborales(nom, t1, t2):
|
||||
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
|
||||
total=0.0; cur=t1; saltos=0
|
||||
while cur < t2 and saltos < 60:
|
||||
d=cur.date(); bloques=turno_dia(nom,d)
|
||||
if not bloques:
|
||||
cur=_dt(d,24); saltos+=1; continue
|
||||
for (hi,hf) in bloques:
|
||||
ini=_dt(d,hi); fin=_dt(d,hf)
|
||||
if t2<=ini: break
|
||||
if cur>=fin: continue
|
||||
a=max(cur,ini); b=min(t2,fin)
|
||||
if b>a: total += (b-a).total_seconds()/60.0
|
||||
cur=fin
|
||||
if cur>=t2: break
|
||||
if cur.date()==d:
|
||||
cur=_dt(d,24); saltos+=1
|
||||
return round(total)
|
||||
|
||||
# filtrar Diana + 10 agosto por fecha de asignacion
|
||||
filas=[]
|
||||
for r in asig:
|
||||
nom=(r.get("user_name") or "")
|
||||
if NOMBRE not in nom.upper(): continue
|
||||
t1=r.get("created_at")
|
||||
if not isinstance(t1,datetime): continue
|
||||
if not (t1.year==ANO and t1.month==MES and t1.day==DIA): continue
|
||||
filas.append(r)
|
||||
|
||||
d1=date(ANO,MES,DIA)
|
||||
turno=None
|
||||
if filas:
|
||||
tt=turno_dia((filas[0].get("user_name") or "").upper(), d1)
|
||||
turno="Turno 1 (9-18)" if tt==[(9,13),(14,18)] else ("Turno 2 (9-13+18-22)" if tt==[(9,13),(18,22)] else str(tt))
|
||||
|
||||
print(f"\n=== DIANA CHAVEZ — {DIA:02d}/{MES:02d}/{ANO} ===")
|
||||
print(f"Turno detectado ese dia: {turno}")
|
||||
print(f"Total asignados ese dia: {len(filas)}\n")
|
||||
print(f"{'#':3} {'telefono':13} {'asignado':17} {'respondio':17} {'min':>5}")
|
||||
print("-"*62)
|
||||
resp=0; sinr=0; suma=0
|
||||
for i,r in enumerate(sorted(filas, key=lambda x: x['created_at']),1):
|
||||
t1=r['created_at']; t2=r.get('respuesta_fecha')
|
||||
nom_up=(r.get('user_name') or '').upper()
|
||||
tel=r.get('telefono',''); a_str=t1.strftime('%H:%M:%S')
|
||||
if not isinstance(t2,datetime):
|
||||
sinr+=1
|
||||
print(f"{i:3} {tel:13} {a_str:17} {'':17} {'':>5}") # sin respuesta -> vacio
|
||||
continue
|
||||
m=mins_laborales(nom_up,t1,t2); resp+=1; suma+=m
|
||||
print(f"{i:3} {tel:13} {a_str:17} {t2.strftime('%H:%M:%S'):17} {m:>5}")
|
||||
print("-"*62)
|
||||
print(f"Respondidos: {resp} | Sin responder: {sinr}")
|
||||
print(f"Promedio (solo respondidos): {round(suma/resp) if resp else 0} min")
|
||||
110
backend/diag_tiempo_diana_ago3.py
Normal file
110
backend/diag_tiempo_diana_ago3.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# diag_tiempo_diana_ago3.py
|
||||
# Detalle del 3 de AGOSTO 2026 para DIANA CHAVEZ: asignados, hora de asignacion,
|
||||
# hora de respuesta y minutos. Marca los asignados en refrigerio (13:00-14:00 turno1),
|
||||
# que son los que la GRAFICA excluye.
|
||||
from datetime import datetime, timedelta, date
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES, DIA = 2026, 8, 3
|
||||
NOMBRE = "DIANA"
|
||||
|
||||
dm = DataManager()
|
||||
asig = dm.traer_asignacion_respuesta()
|
||||
act_rows = dm.traer_actividad_asesores()
|
||||
|
||||
uid2name = {}
|
||||
for r in asig:
|
||||
if r.get("user_id") is not None:
|
||||
uid2name[r["user_id"]] = r.get("user_name") or ""
|
||||
|
||||
from collections import defaultdict
|
||||
act = defaultdict(lambda: defaultdict(int))
|
||||
for a in act_rows:
|
||||
uid = a.get("user_id"); ts = a.get("created_at")
|
||||
if uid not in uid2name or not isinstance(ts, datetime): continue
|
||||
nom = uid2name[uid].upper(); d = ts.date(); h = ts.hour + ts.minute/60.0
|
||||
key = (nom, d)
|
||||
if d.weekday() == 6: continue
|
||||
if d.weekday() == 5:
|
||||
if 9 <= h < 13: act[key]["sab_manana"] += 1
|
||||
elif 14 <= h < 18: act[key]["sab_tarde"] += 1
|
||||
else:
|
||||
if 13 <= h < 18: act[key]["tarde"] += 1
|
||||
elif 18 <= h < 22: act[key]["noche"] += 1
|
||||
|
||||
def turno_dia(nom, d):
|
||||
if d.weekday() == 6: return None
|
||||
fr = act.get((nom, d))
|
||||
if not fr: return None
|
||||
if d.weekday() == 5:
|
||||
m = fr.get("sab_manana",0); t = fr.get("sab_tarde",0)
|
||||
if m==0 and t==0: return None
|
||||
return [(9,13)] if m>=t else [(14,18)]
|
||||
t = fr.get("tarde",0); n = fr.get("noche",0)
|
||||
if t==0 and n==0: return None
|
||||
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
|
||||
|
||||
def es_t1(nom, d):
|
||||
fr = act.get((nom,d))
|
||||
if not fr or d.weekday()>=5: return False
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
return (not (n>t)) and (t>0 or n>0)
|
||||
|
||||
def _dt(d, hh): return datetime(d.year,d.month,d.day) + timedelta(hours=hh)
|
||||
|
||||
def mins_laborales(nom, t1, t2):
|
||||
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
|
||||
total=0.0; cur=t1; saltos=0
|
||||
while cur < t2 and saltos < 60:
|
||||
d=cur.date(); bloques=turno_dia(nom,d)
|
||||
if not bloques:
|
||||
cur=_dt(d,24); saltos+=1; continue
|
||||
for (hi,hf) in bloques:
|
||||
ini=_dt(d,hi); fin=_dt(d,hf)
|
||||
if t2<=ini: break
|
||||
if cur>=fin: continue
|
||||
a=max(cur,ini); b=min(t2,fin)
|
||||
if b>a: total += (b-a).total_seconds()/60.0
|
||||
cur=fin
|
||||
if cur>=t2: break
|
||||
if cur.date()==d:
|
||||
cur=_dt(d,24); saltos+=1
|
||||
return round(total)
|
||||
|
||||
filas=[]
|
||||
for r in asig:
|
||||
nom=(r.get("user_name") or "")
|
||||
if NOMBRE not in nom.upper(): continue
|
||||
t1=r.get("created_at")
|
||||
if not isinstance(t1,datetime): continue
|
||||
if not (t1.year==ANO and t1.month==MES and t1.day==DIA): continue
|
||||
filas.append(r)
|
||||
|
||||
d1=date(ANO,MES,DIA)
|
||||
tt=turno_dia((filas[0].get("user_name") or "").upper(), d1) if filas else None
|
||||
turno="Turno 1 (9-18)" if tt==[(9,13),(14,18)] else ("Turno 2 (9-13+18-22)" if tt==[(9,13),(18,22)] else str(tt))
|
||||
|
||||
print(f"\n=== DIANA CHAVEZ — {DIA:02d}/{MES:02d}/{ANO} ===")
|
||||
print(f"Turno detectado ese dia: {turno}")
|
||||
print(f"Total asignados ese dia: {len(filas)}\n")
|
||||
print(f"{'#':3} {'telefono':13} {'asignado':17} {'respondio':17} {'min':>5} nota")
|
||||
print("-"*72)
|
||||
resp=0; sinr=0; suma=0; refri=0
|
||||
for i,r in enumerate(sorted(filas, key=lambda x: x['created_at']),1):
|
||||
t1=r['created_at']; t2=r.get('respuesta_fecha')
|
||||
nom_up=(r.get('user_name') or '').upper()
|
||||
tel=r.get('telefono',''); a_str=t1.strftime('%H:%M:%S')
|
||||
h=t1.hour+t1.minute/60.0
|
||||
en_refri = es_t1(nom_up,d1) and (13<=h<14)
|
||||
nota = " REFRIGERIO (grafica lo excluye)" if en_refri else ""
|
||||
if en_refri: refri+=1
|
||||
if not isinstance(t2,datetime):
|
||||
sinr+=1
|
||||
print(f"{i:3} {tel:13} {a_str:17} {'':17} {'':>5}{nota}")
|
||||
continue
|
||||
m=mins_laborales(nom_up,t1,t2); resp+=1; suma+=m
|
||||
print(f"{i:3} {tel:13} {a_str:17} {t2.strftime('%H:%M:%S'):17} {m:>5}{nota}")
|
||||
print("-"*72)
|
||||
print(f"Respondidos: {resp} | Sin responder: {sinr} | En refrigerio (13-14): {refri}")
|
||||
print(f"Total tabla (todos): {len(filas)} | Total grafica (sin refrigerio): {len(filas)-refri}")
|
||||
print(f"Promedio (solo respondidos): {round(suma/resp) if resp else 0} min")
|
||||
131
backend/diag_tiempo_diana_jul1.py
Normal file
131
backend/diag_tiempo_diana_jul1.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# diag_tiempo_diana_jul1.py
|
||||
# Detalle del 1 de JULIO 2026 para DIANA CHAVEZ: los asignados, hora de asignacion,
|
||||
# si respondieron, hora de respuesta y minutos laborales (misma logica del dashboard).
|
||||
# Ejecutar en backend/: python diag_tiempo_diana_jul1.py
|
||||
from datetime import datetime, timedelta
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
ANO, MES, DIA = 2026, 7, 1
|
||||
NOMBRE = "DIANA" # busca por apellido/nombre que contenga esto en user_name
|
||||
|
||||
dm = DataManager()
|
||||
|
||||
# --- 1) asignacion + respuesta (mismo query del dashboard) ---
|
||||
asig = dm.traer_asignacion_respuesta()
|
||||
# --- 2) actividad de asesores (para detectar turno) ---
|
||||
act_rows = dm.traer_actividad_asesores()
|
||||
|
||||
# mapa user_id -> user_name
|
||||
uid2name = {}
|
||||
for r in asig:
|
||||
if r.get("user_id") is not None:
|
||||
uid2name[r["user_id"]] = r.get("user_name") or ""
|
||||
|
||||
# actividad por (user_name_upper, fecha) -> franja -> conteo
|
||||
from collections import defaultdict
|
||||
act = defaultdict(lambda: defaultdict(int))
|
||||
for a in act_rows:
|
||||
uid = a.get("user_id"); ts = a.get("created_at")
|
||||
if uid not in uid2name or not isinstance(ts, datetime):
|
||||
continue
|
||||
nom = uid2name[uid].upper()
|
||||
d = ts.date(); h = ts.hour + ts.minute/60.0
|
||||
key = (nom, d)
|
||||
if d.weekday() == 6:
|
||||
continue
|
||||
if d.weekday() == 5:
|
||||
if 9 <= h < 13: act[key]["sab_manana"] += 1
|
||||
elif 14 <= h < 18: act[key]["sab_tarde"] += 1
|
||||
else:
|
||||
if 13 <= h < 18: act[key]["tarde"] += 1
|
||||
elif 18 <= h < 22: act[key]["noche"] += 1
|
||||
|
||||
def turno_dia(nom, d):
|
||||
if d.weekday() == 6: return None
|
||||
fr = act.get((nom, d))
|
||||
if not fr: return None
|
||||
if d.weekday() == 5:
|
||||
m = fr.get("sab_manana",0); t = fr.get("sab_tarde",0)
|
||||
if m==0 and t==0: return None
|
||||
return [(9,13)] if m>=t else [(14,18)]
|
||||
t = fr.get("tarde",0); n = fr.get("noche",0)
|
||||
if t==0 and n==0: return None
|
||||
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
|
||||
|
||||
def _dt(d, hh): return datetime(d.year,d.month,d.day) + timedelta(hours=hh)
|
||||
|
||||
def mins_laborales(nom, t1, t2):
|
||||
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
|
||||
total=0.0; cur=t1; saltos=0
|
||||
while cur < t2 and saltos < 60:
|
||||
d=cur.date(); bloques=turno_dia(nom,d)
|
||||
if not bloques:
|
||||
cur=_dt(d,24); saltos+=1; continue
|
||||
for (hi,hf) in bloques:
|
||||
ini=_dt(d,hi); fin=_dt(d,hf)
|
||||
if t2<=ini: break
|
||||
if cur>=fin: continue
|
||||
a=max(cur,ini); b=min(t2,fin)
|
||||
if b>a: total += (b-a).total_seconds()/60.0
|
||||
cur=fin
|
||||
if cur>=t2: break
|
||||
if cur.date()==d:
|
||||
cur=_dt(d,24); saltos+=1
|
||||
return round(total)
|
||||
|
||||
def es_refri_t1(nom, ts):
|
||||
d=ts.date()
|
||||
if d.weekday()>=5: return False
|
||||
fr=act.get((nom,d))
|
||||
if not fr: return False
|
||||
t=fr.get("tarde",0); n=fr.get("noche",0)
|
||||
es_t1 = not (n>t) and (t>0 or n>0)
|
||||
h=ts.hour+ts.minute/60.0
|
||||
return es_t1 and (13<=h<14)
|
||||
|
||||
# --- filtrar Diana + 1 julio (por fecha de asignacion) ---
|
||||
filas=[]
|
||||
for r in asig:
|
||||
nom = (r.get("user_name") or "")
|
||||
if NOMBRE not in nom.upper():
|
||||
continue
|
||||
t1 = r.get("created_at")
|
||||
if not isinstance(t1, datetime): continue
|
||||
if not (t1.year==ANO and t1.month==MES and t1.day==DIA): continue
|
||||
filas.append(r)
|
||||
|
||||
# turno detectado ese dia
|
||||
from datetime import date
|
||||
d1 = date(ANO,MES,DIA)
|
||||
turno = None
|
||||
if filas:
|
||||
nom_up = (filas[0].get("user_name") or "").upper()
|
||||
tt = turno_dia(nom_up, d1)
|
||||
turno = "Turno 1 (9-18)" if tt==[(9,13),(14,18)] else ("Turno 2 (9-13+18-22)" if tt==[(9,13),(18,22)] else str(tt))
|
||||
|
||||
print(f"\n=== DIANA CHAVEZ — {DIA:02d}/{MES:02d}/{ANO} ===")
|
||||
print(f"Turno detectado ese dia: {turno}")
|
||||
print(f"Total asignados ese dia: {len(filas)}\n")
|
||||
print(f"{'#':3} {'telefono':13} {'asignado':17} {'respondio':17} {'min':>5} nota")
|
||||
print("-"*75)
|
||||
resp=0; sinr=0; refri=0; suma=0
|
||||
for i,r in enumerate(sorted(filas, key=lambda x: x['created_at']),1):
|
||||
t1=r['created_at']; t2=r.get('respuesta_fecha')
|
||||
nom_up=(r.get('user_name') or '').upper()
|
||||
tel=r.get('telefono','')
|
||||
a_str=t1.strftime('%H:%M:%S')
|
||||
if es_refri_t1(nom_up, t1):
|
||||
refri+=1
|
||||
r_str = t2.strftime('%H:%M:%S') if isinstance(t2, datetime) else '(sin resp)'
|
||||
print(f"{i:3} {tel:13} {a_str:17} {r_str:17} {'-':>5} EXCLUIDO (refrigerio 13-14)")
|
||||
continue
|
||||
if not isinstance(t2,datetime):
|
||||
sinr+=1
|
||||
print(f"{i:3} {tel:13} {a_str:17} {'(sin resp)':17} {'-':>5} no respondio")
|
||||
continue
|
||||
m=mins_laborales(nom_up,t1,t2)
|
||||
resp+=1; suma+=m
|
||||
print(f"{i:3} {tel:13} {a_str:17} {t2.strftime('%H:%M:%S'):17} {m:>5}")
|
||||
print("-"*75)
|
||||
print(f"Respondidos: {resp} | Sin responder: {sinr} | Excluidos refrigerio: {refri}")
|
||||
print(f"Suma minutos: {suma} | Promedio: {round(suma/resp) if resp else 0} min")
|
||||
51
backend/diag_ultima_etiqueta.py
Normal file
51
backend/diag_ultima_etiqueta.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Diagnostico: para una lista de telefonos, muestra su cached_label_list crudo
|
||||
y la Ultima_Etiqueta calculada (misma logica del DAX / leads_logic).
|
||||
"""
|
||||
import os
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
import leads_logic as L # reutiliza ultima_etiqueta() ya existente
|
||||
|
||||
load_dotenv()
|
||||
|
||||
PG_HOST = os.getenv("PG_HOST")
|
||||
PG_DB = os.getenv("PG_DATABASE") or os.getenv("PG_DB")
|
||||
PG_USER = os.getenv("PG_USER")
|
||||
PG_PASS = os.getenv("PG_PASSWORD") or os.getenv("PG_PASS")
|
||||
PG_PORT = os.getenv("PG_PORT", "5432")
|
||||
|
||||
TELEFONOS = """
|
||||
948400152 953932854 959735396 971615688 972132288 976595677
|
||||
56953794663 56961258851 56962820196 59173326356 59175648709
|
||||
900079313 900153292 900610393 900779778 902490667 902671008
|
||||
902968272 907312317 907818325
|
||||
""".split()
|
||||
|
||||
conn = psycopg2.connect(host=PG_HOST, dbname=PG_DB, user=PG_USER,
|
||||
password=PG_PASS, port=PG_PORT, connect_timeout=30)
|
||||
cur = conn.cursor()
|
||||
|
||||
print(f"{'TELEFONO':14} {'ULTIMA_ETIQUETA':22} | cached_label_list (crudo)")
|
||||
print("-" * 90)
|
||||
|
||||
for tel in TELEFONOS:
|
||||
# busca cualquier variante del numero (con o sin 51 adelante)
|
||||
cur.execute("""
|
||||
SELECT cv.cached_label_list, m.created_at
|
||||
FROM messages m
|
||||
JOIN conversations cv ON m.conversation_id = cv.id
|
||||
JOIN contacts c ON cv.contact_id = c.id
|
||||
WHERE REPLACE(REPLACE(c.phone_number, '+51',''), '+','') LIKE %s
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 1
|
||||
""", (f"%{tel}",))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
print(f"{tel:14} {'(no encontrado)':22} |")
|
||||
continue
|
||||
crudo = row[0]
|
||||
ult = L.ultima_etiqueta(crudo)
|
||||
print(f"{tel:14} {ult:22} | {crudo}")
|
||||
|
||||
conn.close()
|
||||
14
backend/diag_vendedor_sql.py
Normal file
14
backend/diag_vendedor_sql.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# diag_vendedor_sql.py — Verifica el apartado Vendedores.
|
||||
import services as S
|
||||
|
||||
d = S.vendedores_dashboard("2026", "TODOS", "TODOS")
|
||||
print("Vendedores (filtro):", d["vendedores"])
|
||||
print(f"\n{'VENDEDOR':24} {'CartTot':>8} {'Copito':>7} {'Otros':>7} {'MatTot':>7} {'Curso':>6} {'Retir':>6}")
|
||||
print("-"*72)
|
||||
for f in d["filas"]:
|
||||
print(f"{f['vendedor']:24} {f['cartera_total']:>8} {f['cartera_copito']:>7} {f['cartera_otros']:>7} "
|
||||
f"{f['mat_total']:>7} {f['mat_curso']:>6} {f['mat_retiradas']:>6}")
|
||||
t = d["total"]
|
||||
print("-"*72)
|
||||
print(f"{'TOTAL':24} {t['cartera_total']:>8} {t['cartera_copito']:>7} {t['cartera_otros']:>7} "
|
||||
f"{t['mat_total']:>7} {t['mat_curso']:>6} {t['mat_retiradas']:>6}")
|
||||
58
backend/diag_webform.py
Normal file
58
backend/diag_webform.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# diag_webform.py — Compara WEB_FORMULARIO enero: datos_unificados vs cartera_junta (local xlsx).
|
||||
# Usa el export local para cartera (rapido) y baja solo enero de datos_unificados.
|
||||
import os, requests
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
SUP_URL = os.getenv("SUPABASE_URL"); SUP_KEY = os.getenv("SUPABASE_KEY")
|
||||
|
||||
def _norm_tel(v):
|
||||
s = str(v or "")
|
||||
for x in ("+51","+"," ","-","(",")"): s=s.replace(x,"")
|
||||
s=s.strip()
|
||||
if not s or set(s)=={"0"}: return ""
|
||||
if s.isdigit() and len(s)<=6: return ""
|
||||
return s
|
||||
|
||||
def _ene(v):
|
||||
for fmt in ("%d/%m/%Y","%Y-%m-%d"):
|
||||
try:
|
||||
d=datetime.strptime(str(v)[:10],fmt).date()
|
||||
return d.year==2026 and d.month==1
|
||||
except: pass
|
||||
return False
|
||||
|
||||
# datos_unificados WEB_FORMULARIO enero
|
||||
h={"apikey":SUP_KEY,"Authorization":f"Bearer {SUP_KEY}"}
|
||||
out=[]; desde=0
|
||||
while True:
|
||||
r=requests.get(f"{SUP_URL}/rest/v1/datos_unificados",
|
||||
params={"select":"Telefono,Canal,Fechacreada","offset":str(desde),"limit":"1000"},headers=h,timeout=60)
|
||||
r.raise_for_status(); d=r.json()
|
||||
if not d: break
|
||||
out.extend(d)
|
||||
if len(d)<1000: break
|
||||
desde+=1000
|
||||
du=[_norm_tel(r["Telefono"]) for r in out if str(r.get("Canal","")).upper()=="WEB_FORMULARIO" and _ene(r.get("Fechacreada"))]
|
||||
du=set(t for t in du if t)
|
||||
print("datos_unificados WEB_FORMULARIO enero (tel unicos):", len(du))
|
||||
|
||||
# cartera desde el excel local
|
||||
import openpyxl, glob
|
||||
xf=max(glob.glob("cartera_junta_export.xlsx"), default=None)
|
||||
wb=openpyxl.load_workbook("cartera_junta_export.xlsx", read_only=True); ws=wb.active
|
||||
rows=list(ws.iter_rows(values_only=True)); hdr=rows[0]; ci={h:i for i,h in enumerate(hdr)}
|
||||
tel_all=set(str(r[ci["telefono"]]).strip() for r in rows[1:]) # todos los tel de cartera
|
||||
tel_web_ene=set()
|
||||
for r in rows[1:]:
|
||||
if str(r[ci["canal"]]).strip().upper()=="WEB_FORMULARIO" and str(r[ci["fecha_creada"]])[:7]=="2026-01":
|
||||
tel_web_ene.add(str(r[ci["telefono"]]).strip())
|
||||
print("cartera WEB_FORMULARIO enero (tel):", len(tel_web_ene))
|
||||
|
||||
falt_total=[t for t in du if t not in tel_all]
|
||||
falt_web=[t for t in du if t not in tel_web_ene]
|
||||
print(f"\nDe {len(du)} tel de datos_unificados enero:")
|
||||
print(f" NO estan en cartera por NINGUN canal: {len(falt_total)}")
|
||||
print(f" estan en cartera pero NO como WEB_FORMULARIO enero: {len(falt_web)-len(falt_total)}")
|
||||
print(" ejemplos NO en cartera:", falt_total[:10])
|
||||
150
backend/export_base_junta.py
Normal file
150
backend/export_base_junta.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# backend/export_base_junta.py
|
||||
"""
|
||||
Une los leads de Postgre (Fact_Leads_Procesados, mapa de campañas) + Supabase,
|
||||
deduplica por teléfono quedándose con el MÁS ANTIGUO (desempate: Pauta_wsp_face),
|
||||
y exporta un Excel con: Telefono, Canal, Fecha Creada.
|
||||
|
||||
Uso: py -3.12 export_base_junta.py
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Canal preferido en caso de empate de fecha
|
||||
CANAL_PREFERIDO = "Pauta_wsp_face"
|
||||
|
||||
|
||||
def _norm_tel(v):
|
||||
s = str(v or "")
|
||||
for x in ("+51", "+", " ", "-", "(", ")"):
|
||||
s = s.replace(x, "")
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _txt(v):
|
||||
"""Texto en MAYÚSCULAS; '-' o vacío → ''."""
|
||||
s = str(v or "").strip()
|
||||
if s == "-":
|
||||
s = ""
|
||||
return s.upper()
|
||||
|
||||
|
||||
def _to_dt(v):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, datetime):
|
||||
return v
|
||||
s = str(v)[:19]
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"):
|
||||
try:
|
||||
return datetime.strptime(s[:len(fmt) + 2] if "%H" in fmt else s[:10], fmt)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def traer_postgre(dm):
|
||||
"""Mismos leads que Fact_Leads_Procesados (mapa de campañas): tel, fecha, canal(origen)."""
|
||||
filas = dm.traer_leads_chatwoot() # ya aplica el mapa de campañas y trae 'origen'
|
||||
out = []
|
||||
for f in filas:
|
||||
tel = _norm_tel(f.get("telefono"))
|
||||
if not tel:
|
||||
continue
|
||||
out.append({
|
||||
"telefono": tel,
|
||||
"fecha": _to_dt(f.get("fecha_creada")),
|
||||
"canal": _txt(f.get("origen")),
|
||||
"sede": _txt(f.get("sede")),
|
||||
"programa": _txt(f.get("programa")),
|
||||
"codigo": _txt(f.get("codigo")),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def traer_supabase(dm):
|
||||
"""Lee la tabla de Supabase con columnas: telefono, fechacreada, canal."""
|
||||
try:
|
||||
from supabase import create_client
|
||||
url = os.getenv("SUPABASE_URL", "")
|
||||
key = os.getenv("SUPABASE_KEY", "")
|
||||
if not url or not key:
|
||||
print("⚠️ Falta SUPABASE_URL / SUPABASE_KEY en .env — se omite Supabase")
|
||||
return []
|
||||
sb = create_client(url, key)
|
||||
tabla = os.getenv("SUPABASE_TABLA_LEADS", "leads") # ajustar nombre real si difiere
|
||||
res = sb.table(tabla).select("Telefono,Fechacreada,Canal,Sede,Programa,Codigo").execute()
|
||||
out = []
|
||||
for r in (res.data or []):
|
||||
tel = _norm_tel(r.get("Telefono"))
|
||||
if not tel:
|
||||
continue
|
||||
out.append({
|
||||
"telefono": tel,
|
||||
"fecha": _to_dt(r.get("Fechacreada")),
|
||||
"canal": _txt(r.get("Canal")),
|
||||
"sede": _txt(r.get("Sede")),
|
||||
"programa": _txt(r.get("Programa")),
|
||||
"codigo": _txt(r.get("Codigo")),
|
||||
})
|
||||
return out
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error leyendo Supabase: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
dm = DataManager()
|
||||
print("Trayendo Postgre (Fact_Leads_Procesados)...")
|
||||
pg = traer_postgre(dm)
|
||||
print(f" Postgre: {len(pg)} filas")
|
||||
print("Trayendo Supabase...")
|
||||
sup = traer_supabase(dm)
|
||||
print(f" Supabase: {len(sup)} filas")
|
||||
|
||||
todos = pg + sup
|
||||
print(f"Total combinado (con repetidos): {len(todos)}")
|
||||
|
||||
# Dedup por teléfono: quedarse con el MÁS ANTIGUO.
|
||||
# Desempate (misma fecha): preferir CANAL_PREFERIDO.
|
||||
FUTURO = datetime(9999, 1, 1)
|
||||
mejor = {}
|
||||
for r in todos:
|
||||
tel = r["telefono"]
|
||||
f = r["fecha"] or FUTURO
|
||||
actual = mejor.get(tel)
|
||||
if actual is None:
|
||||
mejor[tel] = r
|
||||
continue
|
||||
fa = actual["fecha"] or FUTURO
|
||||
if f < fa:
|
||||
mejor[tel] = r
|
||||
elif f == fa:
|
||||
# empate de fecha → preferir el canal preferido
|
||||
if r["canal"] == CANAL_PREFERIDO and actual["canal"] != CANAL_PREFERIDO:
|
||||
mejor[tel] = r
|
||||
|
||||
final = list(mejor.values())
|
||||
print(f"Teléfonos únicos (sin repetir): {len(final)}")
|
||||
|
||||
# Exportar a Excel
|
||||
import openpyxl
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Base_Junta"
|
||||
ws.append(["Telefono", "Canal", "Fecha Creada", "Sede", "Programa", "Codigo"])
|
||||
for r in sorted(final, key=lambda x: (x["fecha"] or FUTURO)):
|
||||
fecha = r["fecha"].strftime("%d/%m/%Y") if r["fecha"] and r["fecha"] != FUTURO else ""
|
||||
ws.append([r["telefono"], r["canal"], fecha,
|
||||
r.get("sede", ""), r.get("programa", ""), r.get("codigo", "")])
|
||||
salida = os.path.join(os.path.dirname(__file__), "base_junta.xlsx")
|
||||
wb.save(salida)
|
||||
print(f"\n✅ Exportado: {salida}")
|
||||
print(f" Filas: {len(final)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
70
backend/export_campanias.py
Normal file
70
backend/export_campanias.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# export_campanias.py — Exporta TODA la tabla campanias de Supabase a Excel/CSV.
|
||||
# Ejecutar dentro de backend/: python export_campanias.py
|
||||
import os, csv, requests
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
TABLA = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
|
||||
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||
|
||||
assert URL and KEY, "Falta CARTERA_URL / CARTERA_KEY en .env"
|
||||
|
||||
def traer_todo():
|
||||
filas, paso, desde = [], 1000, 0
|
||||
while True:
|
||||
hdr = dict(HEAD)
|
||||
hdr["Range-Unit"] = "items"
|
||||
hdr["Range"] = f"{desde}-{desde + paso - 1}"
|
||||
r = requests.get(f"{URL}/rest/v1/{TABLA}",
|
||||
params={"select": "*"},
|
||||
headers=hdr, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
filas.extend(data)
|
||||
print(f" descargadas: {len(filas)}", end="\r")
|
||||
desde += len(data) # avanzar por lo realmente recibido
|
||||
if len(data) < paso:
|
||||
break
|
||||
print()
|
||||
return filas
|
||||
|
||||
print(f"Descargando '{TABLA}' de Supabase...")
|
||||
filas = traer_todo()
|
||||
print(f"Total: {len(filas)} filas")
|
||||
|
||||
if not filas:
|
||||
print("Sin datos. Revisa CARTERA_URL / CARTERA_KEY en .env")
|
||||
raise SystemExit
|
||||
|
||||
# columnas = union de todas las claves, en orden de la primera fila
|
||||
cols = list(filas[0].keys())
|
||||
for f in filas:
|
||||
for k in f.keys():
|
||||
if k not in cols:
|
||||
cols.append(k)
|
||||
|
||||
# 1) CSV siempre
|
||||
csv_path = "campanias_export.csv"
|
||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as fout:
|
||||
w = csv.DictWriter(fout, fieldnames=cols)
|
||||
w.writeheader()
|
||||
for f in filas:
|
||||
w.writerow({c: f.get(c, "") for c in cols})
|
||||
print(f"CSV generado: {csv_path}")
|
||||
|
||||
# 2) Excel si openpyxl esta disponible
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook(); ws = wb.active; ws.title = "campanias"
|
||||
ws.append(cols)
|
||||
for f in filas:
|
||||
ws.append([f.get(c, "") for c in cols])
|
||||
xlsx_path = "campanias_export.xlsx"
|
||||
wb.save(xlsx_path)
|
||||
print(f"Excel generado: {xlsx_path}")
|
||||
except ImportError:
|
||||
print("(openpyxl no instalado -> solo CSV. Para Excel: pip install openpyxl)")
|
||||
64
backend/export_cartera.py
Normal file
64
backend/export_cartera.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# export_cartera.py — Exporta TODA la tabla cartera_junta de Supabase a Excel/CSV.
|
||||
# Ejecutar dentro de backend/: python export_cartera.py
|
||||
import os, csv, requests
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
TABLA = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
|
||||
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||
|
||||
def traer_todo():
|
||||
filas, paso, desde = [], 1000, 0
|
||||
while True:
|
||||
r = requests.get(f"{URL}/rest/v1/{TABLA}",
|
||||
params={"select": "*", "offset": str(desde), "limit": str(paso)},
|
||||
headers=HEAD, timeout=120)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
filas.extend(data)
|
||||
print(f" descargadas {len(filas)} filas...")
|
||||
if len(data) < paso:
|
||||
break
|
||||
desde += paso
|
||||
return filas
|
||||
|
||||
print(f"Descargando '{TABLA}' de Supabase...")
|
||||
filas = traer_todo()
|
||||
print(f"Total: {len(filas)} filas")
|
||||
|
||||
if not filas:
|
||||
print("Sin datos. Revisa CARTERA_URL / CARTERA_KEY en .env")
|
||||
raise SystemExit
|
||||
|
||||
# columnas = union de todas las claves, en orden de la primera fila
|
||||
cols = list(filas[0].keys())
|
||||
for f in filas:
|
||||
for k in f.keys():
|
||||
if k not in cols:
|
||||
cols.append(k)
|
||||
|
||||
# 1) CSV siempre
|
||||
csv_path = "cartera_junta_export.csv"
|
||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as fout:
|
||||
w = csv.DictWriter(fout, fieldnames=cols)
|
||||
w.writeheader()
|
||||
for f in filas:
|
||||
w.writerow({c: f.get(c, "") for c in cols})
|
||||
print(f"CSV generado: {csv_path}")
|
||||
|
||||
# 2) Excel si openpyxl esta disponible
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook(); ws = wb.active; ws.title = "cartera_junta"
|
||||
ws.append(cols)
|
||||
for f in filas:
|
||||
ws.append([f.get(c, "") for c in cols])
|
||||
xlsx_path = "cartera_junta_export.xlsx"
|
||||
wb.save(xlsx_path)
|
||||
print(f"Excel generado: {xlsx_path}")
|
||||
except ImportError:
|
||||
print("(openpyxl no instalado -> solo CSV. Para Excel: pip install openpyxl)")
|
||||
73
backend/export_roas_excel.py
Normal file
73
backend/export_roas_excel.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# export_roas_excel.py
|
||||
# Genera un Excel de la tabla "Inversión por Sede" (ROAS) con el filtro
|
||||
# Año=2026, Mes=Marzo (3), Día=TODOS — igual que el dashboard.
|
||||
# Columnas: Sede | Programa | Conjunto | Importe Gastado | Resultados
|
||||
# Usa la MISMA función roas_dashboard() del dashboard (no reinventa la logica).
|
||||
# Ejecutar en backend/: python export_roas_excel.py
|
||||
import services as S
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
ANO, MES, DIA = "2026", "3", "TODOS" # Marzo 2026
|
||||
|
||||
data = S.roas_dashboard(ANO, MES, DIA)
|
||||
filas = data.get("filas", [])
|
||||
|
||||
# ── Aplanar a filas: Sede / Programa / Conjunto / Importe / Resultados ──
|
||||
rows = []
|
||||
for f in filas:
|
||||
sede = f.get("sede", "")
|
||||
# nivel sede (total de la sede)
|
||||
rows.append([sede, "", "", f.get("importe", 0), f.get("resultados", 0), "sede"])
|
||||
for p in (f.get("programas") or []):
|
||||
prog = p.get("programa", "")
|
||||
# nivel programa (total del programa)
|
||||
rows.append([sede, prog, "", p.get("importe", 0), p.get("resultados", 0), "prog"])
|
||||
for c in (p.get("conjuntos") or []):
|
||||
rows.append([sede, prog, c.get("conjunto", ""),
|
||||
c.get("importe", 0), c.get("resultados", 0), "conj"])
|
||||
|
||||
# ── Excel ──
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ROAS Marzo 2026"
|
||||
|
||||
azul = PatternFill("solid", fgColor="1E3A5F")
|
||||
blanco = Font(bold=True, color="FFFFFF")
|
||||
fill_sede = PatternFill("solid", fgColor="DBEAFE")
|
||||
fill_prog = PatternFill("solid", fgColor="F1F5F9")
|
||||
bord = Border(*(Side(style="thin", color="D0D7E2"),)*4)
|
||||
cen = Alignment(horizontal="center", vertical="center")
|
||||
izq = Alignment(horizontal="left", vertical="center")
|
||||
|
||||
cab = ["Sede", "Programa", "Conjunto", "Importe Gastado", "Resultados"]
|
||||
for j, t in enumerate(cab, 1):
|
||||
c = ws.cell(1, j, t)
|
||||
c.fill = azul; c.font = blanco; c.alignment = cen; c.border = bord
|
||||
|
||||
r = 2
|
||||
for sede, prog, conj, imp, res, nivel in rows:
|
||||
ws.cell(r, 1, sede)
|
||||
ws.cell(r, 2, prog)
|
||||
ws.cell(r, 3, conj)
|
||||
ci = ws.cell(r, 4, round(float(imp or 0), 2)); ci.number_format = '"$"#,##0.00'
|
||||
cr = ws.cell(r, 5, int(res or 0)); cr.number_format = '#,##0'
|
||||
for j in range(1, 6):
|
||||
cell = ws.cell(r, j); cell.border = bord
|
||||
cell.alignment = izq if j <= 3 else cen
|
||||
if nivel == "sede":
|
||||
cell.fill = fill_sede; cell.font = Font(bold=True)
|
||||
elif nivel == "prog":
|
||||
cell.fill = fill_prog; cell.font = Font(bold=True, color="475569")
|
||||
r += 1
|
||||
|
||||
# anchos
|
||||
for col, w in zip("ABCDE", [16, 14, 46, 16, 14]):
|
||||
ws.column_dimensions[col].width = w
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
out = "ROAS_Marzo_2026.xlsx"
|
||||
wb.save(out)
|
||||
print(f"\nOK -> {out}")
|
||||
print(f"Filas totales: {len(rows)} (sedes: {len(filas)})")
|
||||
print("Columnas: Sede | Programa | Conjunto | Importe Gastado | Resultados")
|
||||
894
backend/leads_logic.py
Normal file
894
backend/leads_logic.py
Normal file
@@ -0,0 +1,894 @@
|
||||
# backend/leads_logic.py
|
||||
"""
|
||||
Lógica del módulo LEADS. Traduce las medidas DAX del PBI a Python.
|
||||
Columnas calculadas replicadas:
|
||||
- Cantidad_Veces -> 1ª aparición de un teléfono = lead único
|
||||
- Ultima_Etiqueta / ESTADO/OBJECION -> limpieza de cached_label_list
|
||||
- Tipo_Programa / Sede -> clasificación por dsc_programa
|
||||
Medidas replicadas:
|
||||
- Leads_Totales_Pauta_Unico, Leads_Procesados_Pauta_Unicos,
|
||||
Leads_Procesados_Contactados_Unicos, % Procesados, % Contactados,
|
||||
Total_Matriculas, Cant_inscritos_Mes, Ocupabilidad,
|
||||
Cursos (Inicios / Suspendidos / Ya Iniciados), Matrículas por día,
|
||||
tabla Estado/Objeción.
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
|
||||
# Etiquetas de sistema que se eliminan para hallar el estado/objeción real (igual que el DAX)
|
||||
ETIQUETAS_SISTEMA = {
|
||||
"atención_humana", "negociación", "supervisor", "grupo_arequipa",
|
||||
"grupo_trujillo", "sin_respuesta",
|
||||
}
|
||||
META_POR_TIPO = {
|
||||
"PROGRAMAS TEAC": 22, "PROGRAMAS TERC": 22,
|
||||
"PROVINCIA TEAC": 18, "PROVINCIA TERC": 18,
|
||||
"SEMINARIOS": 15,
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers de fecha ────────────────────────────────────────────
|
||||
def _to_date(v):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
if isinstance(v, date):
|
||||
return v
|
||||
s = str(v)[:10]
|
||||
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).date()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _en_periodo(d, ano, mes, dia):
|
||||
"""True si la fecha cae en el filtro (ano/mes/dia; 'TODOS' = sin filtrar)."""
|
||||
if d is None:
|
||||
return False
|
||||
if ano not in ("TODOS", None) and d.year != int(ano):
|
||||
return False
|
||||
if mes not in ("TODOS", None) and d.month != int(mes):
|
||||
return False
|
||||
if dia not in ("TODOS", None) and d.day != int(dia):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── Clasificadores (igual que columnas calculadas DAX) ──────────
|
||||
def clasificar_tipo_programa(dsc_programa):
|
||||
up = str(dsc_programa or "").upper()
|
||||
otros = ["CERTIFICACIÓN", "CERTIFICACION", "CURSO A MEDIDA", "MASTERCLASS",
|
||||
"TALLER DE REFRIGERACIÓN DOM", "GESTIÓN DE VENTA", "GESTION DE VENTA"]
|
||||
for o in otros:
|
||||
if o.upper() in up:
|
||||
return "OTROS"
|
||||
return None # el tipo PROGRAMAS/PROVINCIA se arma con sede+programa abajo
|
||||
|
||||
|
||||
def clasificar_sede(dsc_programa):
|
||||
up = str(dsc_programa or "").upper()
|
||||
if "AREQUIPA" in up: return "AREQUIPA"
|
||||
if "TRUJILLO" in up: return "TRUJILLO"
|
||||
if "PIURA" in up: return "PIURA"
|
||||
return "LIMA"
|
||||
|
||||
|
||||
def sede_por_codigo_map():
|
||||
"""{codigo: SEDE} tomado del mapa de campañas (la sede que le corresponde a
|
||||
cada código, SIN reasignar por asesor). Usado por el filtro SEDE de leads,
|
||||
para que coincida con 'sede del código' (no con sede_act)."""
|
||||
from data_manager_v2 import CAMPANIAS
|
||||
out = {}
|
||||
for fila in CAMPANIAS:
|
||||
cod = str(fila[2]).strip() # índice 2 = codigo
|
||||
sede = str(fila[3]).strip().upper() # índice 3 = sede
|
||||
if cod:
|
||||
out[cod] = sede
|
||||
return out
|
||||
|
||||
|
||||
# ── Grupo de PROGRAMA para el filtro: TEAC / TERC / SEMINARIOS / OTROS ──
|
||||
def grupo_programa_curso(dsc_programa):
|
||||
"""Reduce el tipo detallado a 4 grupos para el filtro PROGRAMA (cursos/matrículas).
|
||||
TEAC = PROGRAMAS TEAC + PROVINCIA TEAC
|
||||
TERC = PROGRAMAS TERC + PROVINCIA TERC
|
||||
SEMINARIOS = SEMINARIOS
|
||||
OTROS = OTROS + CARRERA"""
|
||||
tp = tipo_programa_curso(dsc_programa)
|
||||
if tp in ("PROGRAMAS TEAC", "PROVINCIA TEAC"):
|
||||
return "TEAC"
|
||||
if tp in ("PROGRAMAS TERC", "PROVINCIA TERC"):
|
||||
return "TERC"
|
||||
if tp == "SEMINARIOS":
|
||||
return "SEMINARIOS"
|
||||
return "OTROS" # OTROS + CARRERA
|
||||
|
||||
|
||||
def grupo_programa_lead(cargo):
|
||||
"""Grupo de programa para un LEAD, según su 'cargo' (del mapa de campañas).
|
||||
TEAC → TEAC, TERC → TERC, resto (VRF/CO2/DIPLOMADO/etc.) → SEMINARIOS."""
|
||||
c = str(cargo or "").strip().upper()
|
||||
if c == "TEAC":
|
||||
return "TEAC"
|
||||
if c == "TERC":
|
||||
return "TERC"
|
||||
return "SEMINARIOS"
|
||||
|
||||
|
||||
def grupo_tipo_cohorte(tp):
|
||||
"""Reduce 'PROGRAMAS TEAC'/'PROVINCIA TEAC'/... a los 3 grupos de la tabla por Sede:
|
||||
TEAC = PROGRAMAS/PROVINCIA TEAC, TERC = PROGRAMAS/PROVINCIA TERC, resto = SEMINARIOS."""
|
||||
t = str(tp or "").upper()
|
||||
if "TEAC" in t:
|
||||
return "TEAC"
|
||||
if "TERC" in t:
|
||||
return "TERC"
|
||||
return "SEMINARIOS"
|
||||
|
||||
|
||||
# Réplica del DAX Sede_Act: la sede del lead se decide por el ASESOR;
|
||||
# si el asesor no está en la lista, usa la sede de la campaña.
|
||||
_SEDE_POR_ASESOR = {
|
||||
"almendra peralta": "Arequipa",
|
||||
"juan carlos aguilar": "Piura",
|
||||
"diego lázaro": "Trujillo",
|
||||
"diego lazaro": "Trujillo",
|
||||
"verónica la rosa": "Lima",
|
||||
"veronica la rosa": "Lima",
|
||||
"dayana balabarca": "Lima",
|
||||
"milagros vargas": "Lima",
|
||||
"carmen montoya": "Lima",
|
||||
"diana chávez": "Lima",
|
||||
"diana chavez": "Lima",
|
||||
"copito rivera": "Lima",
|
||||
}
|
||||
|
||||
|
||||
def sede_act(asesor, sede_campania):
|
||||
return _SEDE_POR_ASESOR.get(str(asesor or "").strip().lower(), sede_campania)
|
||||
|
||||
|
||||
def tipo_programa_cohorte(sede, programa_cat):
|
||||
"""Igual al DAX 'Tipo Programa': combina sede + (TEAC/TERC) -> categoría."""
|
||||
s = str(sede or "").upper()
|
||||
p = str(programa_cat or "").upper()
|
||||
if s == "LIMA" and p == "TEAC": return "PROGRAMAS TEAC"
|
||||
if s == "LIMA" and p == "TERC": return "PROGRAMAS TERC"
|
||||
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TEAC": return "PROVINCIA TEAC"
|
||||
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TERC": return "PROVINCIA TERC"
|
||||
return "SEMINARIOS"
|
||||
|
||||
|
||||
def tipo_programa_curso(dsc_programa):
|
||||
"""Réplica EXACTA del DAX Tipo_Programa (columna calculada de Fact_SQL_Base_Cursos).
|
||||
Usa CONTAINSSTRING en el mismo ORDEN que el PBI (el orden importa)."""
|
||||
p = str(dsc_programa or "")
|
||||
def has(s): # CONTAINSSTRING es sensible a may/min en DAX; comparamos tal cual
|
||||
return s in p
|
||||
|
||||
# 1) OTROS
|
||||
if (has("CERTIFICACIÓN") or has("CURSO A MEDIDA") or has("MASTERCLASS")
|
||||
or has("TALLER DE REFRIGERACIÓN DOM")
|
||||
or has("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN")):
|
||||
return "OTROS"
|
||||
if (has("CERTIFICACION FUNDAMENTOS DE CHILLER MODULAR INVERT- LG")
|
||||
or has("PIURA - CERTIFICACIÓN MIDEA: TECNOLOGIA INVERTER")
|
||||
or has("AREQUIPA - CERTIFICACIÓN MIDEA - TECNOLOGÍA INVERTER")
|
||||
or has("CERTIFICACIÓN: OPERACIÓN Y MANTENIMIENTO DE REFRIGERADORES MIDEA")
|
||||
or has("TRUJILLO - CERTIFICACIÓN MIDEA: TECNOLOGÍA INVERTER")
|
||||
or has("CERTIFICACIÓN MIDEA: AIRE ACOND INVERTER (INTROD., FUNC., INST. Y MANT.)")):
|
||||
return "OTROS"
|
||||
# 2) CARRERA
|
||||
if has("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION"):
|
||||
return "CARRERA"
|
||||
# 3) PROVINCIA TEAC
|
||||
if (has("TRUJILLO - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||
or has("PIURA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||
or has("AREQUIPA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||
or has("TRUJILLO - VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
|
||||
return "PROVINCIA TEAC"
|
||||
# 4) PROVINCIA TERC
|
||||
if (has("AREQUIPA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
|
||||
or has("PIURA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
|
||||
or has("TRUJILLO - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")):
|
||||
return "PROVINCIA TERC"
|
||||
# 5) SEMINARIOS
|
||||
if (has("CO2") or has("CERTIF") or has("SEM:") or has("MASTERCLASS") or has("DISEÑO")
|
||||
or has("DIPLOMADO") or has("SEM.") or has("DUCTOS") or has("SEMINARIO") or has("SEMINARIOS")):
|
||||
return "SEMINARIOS"
|
||||
# 6) PROGRAMAS TEAC (Lima)
|
||||
if (has("MANTENIMIENTO EN AIRE ACONDICIONADO")
|
||||
or has("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||
or has("TALLER - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
|
||||
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN AA (4M)")
|
||||
or has("VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
|
||||
return "PROGRAMAS TEAC"
|
||||
# 7) PROGRAMAS TERC (Lima)
|
||||
if (has("INSTALACION EN REFRIGERACION COMERCIAL")
|
||||
or has("MANTENIMIENTO EN REFRIGERACIÓN COMERCIAL")
|
||||
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN REFRIGERACIÓN COMERCIAL (4M)")
|
||||
or has("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL")):
|
||||
return "PROGRAMAS TERC"
|
||||
return "SEMINARIOS"
|
||||
|
||||
|
||||
def meta_curso(dsc_programa):
|
||||
"""Meta por curso según Tipo_Programa (igual que el DAX Meta_Curso):
|
||||
PROGRAMAS TEAC/TERC=22, PROVINCIA TEAC/TERC=18, resto=15."""
|
||||
tp = tipo_programa_curso(dsc_programa)
|
||||
if tp in ("PROGRAMAS TEAC", "PROGRAMAS TERC"):
|
||||
return 22
|
||||
if tp in ("PROVINCIA TEAC", "PROVINCIA TERC"):
|
||||
return 18
|
||||
return 15
|
||||
|
||||
|
||||
# ── ESTADO/OBJECION (réplica EXACTA del DAX: cadena de SUBSTITUTE) ──
|
||||
# El DAX quita comas y espacios (pega todas las etiquetas), elimina las de
|
||||
# sistema, y luego reduce combinaciones concatenadas a un estado final.
|
||||
_SUST = [
|
||||
("atención_humana", ""), ("negociación", ""), ("supervisor", ""),
|
||||
("grupo_arequipa", ""), ("grupo_trujillo", ""), ("sin_respuesta", ""),
|
||||
("interesadovendido", "vendido"), ("próximo_inicio", "proxima_fecha"),
|
||||
("inicio", ""), ("aprobación", ""), ("negociacion", ""), ("no_acepta", ""),
|
||||
("pendiente", ""),
|
||||
("revisando_informaciónsólo_consulta", "sólo_consulta"),
|
||||
("contacto_iniciadovendido", "vendido"),
|
||||
("revisando_informacióninteresado", "interesado"),
|
||||
("revisando_informaciónvendido", "vendido"),
|
||||
("sólo_consultainteresado", "interesado"),
|
||||
("revisando_informaciónno_califica", "no_califica"),
|
||||
("interesadopor_pagar", "por_pagar"),
|
||||
("revisando_informaciónprecio_elevado", "precio_elevado"),
|
||||
("proxima_fechavendido", "vendido"),
|
||||
("sólo_consultavendido", "vendido"),
|
||||
("vendidointeresado", "vendido"),
|
||||
("revisando_informaciónproxima_fecha", "proxima_fecha"),
|
||||
("contacto_iniciadosólo_consulta", "sólo_consulta"),
|
||||
]
|
||||
|
||||
|
||||
def estado_objecion(etiquetas):
|
||||
"""Réplica del DAX ESTADO/OBJECION (versión de 28 sustituciones del PBI)."""
|
||||
if etiquetas is None:
|
||||
return "no trabajado"
|
||||
s = str(etiquetas).replace(",", "").replace(" ", "")
|
||||
for buscar, reemplazar in _SUST:
|
||||
s = s.replace(buscar, reemplazar)
|
||||
s = s.strip()
|
||||
return s if s else "no trabajado"
|
||||
|
||||
|
||||
# ── Ultima_Etiqueta (réplica EXACTA del DAX, usada para "Total Contactados") ──
|
||||
_ULTIMA_NO_TRABAJADO = {
|
||||
"", "(en blanco)", "aprobación", "atencion humana", "atención humana",
|
||||
"grupo arequipa", "grupo trujillo", "importacion masiva", "inicio",
|
||||
"negociacion", "sin respuesta", "pendiente",
|
||||
}
|
||||
|
||||
|
||||
def ultima_etiqueta(etiquetas):
|
||||
if etiquetas is None or str(etiquetas).strip() == "":
|
||||
return "NO TRABAJADO"
|
||||
partes = str(etiquetas).split(", ")
|
||||
limpia = partes[-1].replace("_", " ").strip()
|
||||
if limpia in _ULTIMA_NO_TRABAJADO:
|
||||
return "NO TRABAJADO"
|
||||
return limpia if limpia else "NO TRABAJADO"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# PROCESAMIENTO DE LEADS (calcula Cantidad_Veces y agrupa)
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
def procesar_leads(filas_chatwoot):
|
||||
"""Marca el lead único (1ª vez de cada teléfono) y arma estructura limpia.
|
||||
Ordenamos por fecha para que la 1ª aparición de cada teléfono sea la 'única'."""
|
||||
# Ordenar por fecha ascendente (el query puede venir ordenado por id)
|
||||
filas_chatwoot = sorted(filas_chatwoot, key=lambda f: (_to_date(f.get("fecha_creada")) or date.min))
|
||||
# Teléfonos de prueba a excluir (igual que el PBI: 924374783)
|
||||
EXCLUIR = {"924374783"}
|
||||
vistos = set()
|
||||
out = []
|
||||
for f in filas_chatwoot:
|
||||
tel = str(f.get("telefono") or "").strip()
|
||||
if not tel or tel in EXCLUIR:
|
||||
continue
|
||||
es_unico = tel not in vistos
|
||||
vistos.add(tel)
|
||||
fecha = _to_date(f.get("fecha_creada"))
|
||||
asesor = (f.get("asesor") or "").strip()
|
||||
sede_campania = (f.get("sede") or "").strip() or "SIN SEDE"
|
||||
sede = sede_act(asesor, sede_campania) # réplica DAX Sede_Act (reasigna por asesor)
|
||||
cargo = (f.get("programa") or "").strip() # TEAC / TERC / VRF / etc. (del mapa campañas)
|
||||
out.append({
|
||||
"telefono": tel,
|
||||
"asesor": asesor,
|
||||
"fecha": fecha,
|
||||
"estado": estado_objecion(f.get("etiquetas")),
|
||||
"ultima": ultima_etiqueta(f.get("etiquetas")),
|
||||
"sede": sede,
|
||||
"cargo": cargo, # TEAC / TERC / VRF / CO2 / etc.
|
||||
"tipo_programa": tipo_programa_cohorte(sede, cargo), # PROGRAMAS/PROVINCIA/SEMINARIOS
|
||||
"codigo": (f.get("codigo") or "").strip() or "-", # código de campaña
|
||||
"es_unico": es_unico,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# KPIs DEL MÓDULO LEADS
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
def kpis_leads(leads, cursos, matriculas, ano, mes, dia):
|
||||
# Leads en el periodo (solo únicos = Cantidad_Veces == 1)
|
||||
leads_periodo = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
|
||||
|
||||
recibidos = len(leads_periodo)
|
||||
procesados = sum(1 for l in leads_periodo if l["asesor"])
|
||||
contactados = sum(1 for l in leads_periodo if l["asesor"] and l["ultima"] != "NO TRABAJADO")
|
||||
|
||||
pct_procesados = (procesados / recibidos) if recibidos > 0 else 0.0
|
||||
pct_contactados = (contactados / procesados) if procesados > 0 else 0.0
|
||||
|
||||
# ── Cursos del periodo (por fecha de inicio) ──
|
||||
cursos_periodo = [c for c in cursos if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)]
|
||||
cursos_programados = len(cursos_periodo)
|
||||
suspendidos = sum(1 for c in cursos_periodo if str(c.get("cod_estado", "")).strip().upper() == "SUS")
|
||||
hoy = date.today()
|
||||
iniciados = sum(1 for c in cursos_periodo
|
||||
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
|
||||
and (_to_date(c.get("fch_inicio")) or hoy) <= hoy)
|
||||
|
||||
# ── Total matriculados (= medida Matriculas_Totales del PBI) ──
|
||||
# Cuenta matrículas ALU/PRE cuya FECHA DE MATRÍCULA cae en el mes,
|
||||
# excluyendo 2 vendedoras. El Calendario del PBI va por fch_matricula.
|
||||
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
|
||||
total_matriculados = sum(
|
||||
1 for m in matriculas
|
||||
if str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
|
||||
and str(m.get("dsc_vendedor", "")).strip() not in VENDEDORES_EXCLUIDOS
|
||||
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)
|
||||
)
|
||||
# Ocupabilidad (PBI): SUM(Inscritos) / SUM(Meta).
|
||||
# Excluye cursos suspendidos (SUS) y num_indice excluidos manualmente.
|
||||
# (Ver FUTUROS_CAMBIOS.md: mover esta lista a GitHub/Supabase)
|
||||
CURSOS_EXCLUIDOS = {"1154", "1121"}
|
||||
cursos_ocup = [c for c in cursos_periodo
|
||||
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
|
||||
and str(c.get("num_indice", "")).strip() not in CURSOS_EXCLUIDOS]
|
||||
sum_meta = sum(meta_curso(c.get("dsc_programa")) for c in cursos_ocup)
|
||||
sum_inscritos_cursos = sum(int(c.get("Inscritos_Totales", 0) or 0) for c in cursos_ocup)
|
||||
|
||||
# ── Matrículas en los cursos del mes ──
|
||||
# Matrículas ALU/PRE que: (1) se hicieron en el mes (fch_matricula) Y
|
||||
# (2) son de un curso que INICIA en el mes (dsc_promocion = dsc_det_programa
|
||||
# de los cursos del periodo). Réplica de Cant_inscritos_Mes del PBI.
|
||||
promos_periodo = {str(c.get("dsc_det_programa", "")).strip()
|
||||
for c in cursos_periodo if str(c.get("dsc_det_programa", "")).strip()}
|
||||
mats_mes = [m for m in matriculas
|
||||
if str(m.get("dsc_promocion", "")).strip() in promos_periodo
|
||||
and str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
|
||||
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)]
|
||||
matriculas_mes = len({str(m.get("num_matricula")) for m in mats_mes})
|
||||
|
||||
ocupabilidad = (sum_inscritos_cursos / sum_meta) if sum_meta > 0 else 0.0
|
||||
|
||||
return {
|
||||
"leads_recibidos": recibidos,
|
||||
"leads_procesados": procesados,
|
||||
"leads_contactados": contactados,
|
||||
"pct_procesados": round(pct_procesados * 100, 2),
|
||||
"pct_contactados": round(pct_contactados * 100, 2),
|
||||
"total_matriculados": total_matriculados,
|
||||
"matriculas_mes": matriculas_mes,
|
||||
"ocupabilidad": round(ocupabilidad * 100, 2),
|
||||
"cursos_programados": cursos_programados,
|
||||
"cursos_reprogramados": 0, # requiere SharePoint (fase 2)
|
||||
"cursos_suspendidos": suspendidos,
|
||||
"cursos_iniciados": iniciados,
|
||||
}
|
||||
|
||||
|
||||
# ── Tabla Estado/Objeción (agrupa por Ultima_Etiqueta + desglose por asesor) ──
|
||||
def tabla_estado_objecion(leads, ano, mes, dia):
|
||||
leads_periodo = [l for l in leads if l["es_unico"]
|
||||
and l["asesor"] and _en_periodo(l["fecha"], ano, mes, dia)]
|
||||
agg = {} # estado -> total
|
||||
por_asesor = {} # estado -> {asesor: cantidad}
|
||||
telefonos = {} # (estado, asesor) -> [telefonos]
|
||||
for l in leads_periodo:
|
||||
est = l["ultima"] or "NO TRABAJADO"
|
||||
ase = l["asesor"] or "SIN ASESOR"
|
||||
agg[est] = agg.get(est, 0) + 1
|
||||
por_asesor.setdefault(est, {})
|
||||
por_asesor[est][ase] = por_asesor[est].get(ase, 0) + 1
|
||||
telefonos.setdefault((est, ase), []).append(l["telefono"])
|
||||
filas = []
|
||||
for estado in sorted(agg.keys(), key=lambda x: -agg[x]): # mayor a menor por cantidad
|
||||
asesores = sorted(por_asesor[estado].items(), key=lambda x: -x[1])
|
||||
filas.append({
|
||||
"estado": estado,
|
||||
"cantidad": agg[estado],
|
||||
"asesores": [
|
||||
{"asesor": a, "cantidad": c,
|
||||
"telefonos": sorted(telefonos.get((estado, a), []))}
|
||||
for a, c in asesores
|
||||
],
|
||||
})
|
||||
total = sum(agg.values())
|
||||
return {"filas": filas, "total": total}
|
||||
|
||||
|
||||
# ── Matrículas por día (gráfico de línea) ──
|
||||
# Cuenta TODAS las matrículas del día (misma base que Total Matriculados):
|
||||
# estado ALU/PRE, excluyendo las 2 vendedoras, por día de fch_matricula.
|
||||
def matriculas_por_dia(matriculas, cursos, ano, mes, dia):
|
||||
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
|
||||
conteo = {} # dia -> total
|
||||
por_tipo = {} # dia -> {tipo_programa: cantidad}
|
||||
for m in matriculas:
|
||||
if str(m.get("estado_matricula", "")).strip().upper() not in ("ALU", "PRE"):
|
||||
continue
|
||||
if str(m.get("dsc_vendedor", "")).strip() in VENDEDORES_EXCLUIDOS:
|
||||
continue
|
||||
d = _to_date(m.get("fch_matricula"))
|
||||
if not _en_periodo(d, ano, mes, dia):
|
||||
continue
|
||||
conteo[d.day] = conteo.get(d.day, 0) + 1
|
||||
tp = tipo_programa_curso(m.get("dsc_programa")) # TEAC/TERC/PROVINCIA.../SEMINARIOS/OTROS
|
||||
por_tipo.setdefault(d.day, {})
|
||||
por_tipo[d.day][tp] = por_tipo[d.day].get(tp, 0) + 1
|
||||
|
||||
def _detalle(day):
|
||||
items = sorted(por_tipo.get(day, {}).items(), key=lambda x: -x[1])
|
||||
return [{"tipo": t, "cantidad": c} for t, c in items]
|
||||
|
||||
import calendar
|
||||
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
|
||||
ndias = calendar.monthrange(int(ano), int(mes))[1]
|
||||
return [{"dia": d, "cantidad": conteo.get(d, 0), "detalle": _detalle(d)}
|
||||
for d in range(1, ndias + 1)]
|
||||
return [{"dia": d, "cantidad": conteo[d], "detalle": _detalle(d)}
|
||||
for d in sorted(conteo.keys())]
|
||||
|
||||
|
||||
# ── Gráfico "Leads por Programa": serie diaria Totales vs Procesados ──
|
||||
def leads_por_dia(leads, ano, mes, dia):
|
||||
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
|
||||
tot = {} # dia -> totales
|
||||
proc = {} # dia -> procesados (con asesor)
|
||||
for l in lp:
|
||||
d = l["fecha"].day
|
||||
tot[d] = tot.get(d, 0) + 1
|
||||
if l["asesor"]:
|
||||
proc[d] = proc.get(d, 0) + 1
|
||||
import calendar
|
||||
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
|
||||
ndias = calendar.monthrange(int(ano), int(mes))[1]
|
||||
dias = range(1, ndias + 1)
|
||||
else:
|
||||
dias = sorted(set(list(tot.keys()) + list(proc.keys())))
|
||||
return [{"dia": d, "totales": tot.get(d, 0), "procesados": proc.get(d, 0)} for d in dias]
|
||||
|
||||
|
||||
# ── Tabla por Sede_Act -> Tipo_Programa con 4 medidas (cohorte de leads) ──
|
||||
def tabla_pauta(leads, matriculas, ano, mes, dia, importe_por_pauta_periodo=None,
|
||||
resultados_por_pauta_periodo=None, campanias_sede_cargo=None):
|
||||
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
|
||||
resultados_por_pauta_periodo = resultados_por_pauta_periodo or {}
|
||||
campanias_sede_cargo = campanias_sede_cargo or {}
|
||||
# 1) Leads únicos del periodo con su teléfono, fecha, sede y tipo
|
||||
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
|
||||
# mapa teléfono -> fecha del lead (para "matrícula posterior al lead")
|
||||
fecha_lead = {}
|
||||
for l in lp:
|
||||
fecha_lead.setdefault(l["telefono"], l["fecha"])
|
||||
|
||||
# 2) Matrículas que cruzan por teléfono y son POSTERIORES a la fecha del lead
|
||||
# (réplica de Matriculas_Cohorte_Lead e Inversion_x_Pauta_Cohorte)
|
||||
mat_por_tel = {} # telefono -> {"mats": set(num_matricula), "inv": float}
|
||||
for m in matriculas:
|
||||
tel = str(m.get("dsc_telefono_1") or "").strip()
|
||||
if tel not in fecha_lead:
|
||||
continue
|
||||
fm = _to_date(m.get("fch_matricula"))
|
||||
fl = fecha_lead[tel]
|
||||
if not fm or not fl or not (fm > fl):
|
||||
continue
|
||||
# INV_NETA_FINAL: si DOL, ×3.34
|
||||
try: inv = float(m.get("INV_NETA", 0) or 0)
|
||||
except: inv = 0.0
|
||||
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
|
||||
inv *= 3.34
|
||||
e = mat_por_tel.setdefault(tel, {"mats": set(), "inv": 0.0})
|
||||
e["mats"].add(str(m.get("num_matricula")))
|
||||
e["inv"] += inv
|
||||
|
||||
# 3) Agrupar por Sede -> Tipo_Programa -> Código
|
||||
def _nuevo():
|
||||
return {"recibidos": 0, "procesados": 0, "mats": set(), "inv": 0.0}
|
||||
grupos = {} # sede -> tipo -> codigo -> métricas
|
||||
for l in lp:
|
||||
sede = l["sede"]; tp = grupo_tipo_cohorte(l["tipo_programa"]); cod = l.get("codigo", "-"); tel = l["telefono"]
|
||||
g = grupos.setdefault(sede, {}).setdefault(tp, {}).setdefault(cod, _nuevo())
|
||||
g["recibidos"] += 1
|
||||
if l["asesor"]:
|
||||
g["procesados"] += 1
|
||||
if tel in mat_por_tel:
|
||||
g["mats"] |= mat_por_tel[tel]["mats"]
|
||||
g["inv"] += mat_por_tel[tel]["inv"]
|
||||
|
||||
def _fila(d):
|
||||
return {"recibidos": d["recibidos"], "procesados": d["procesados"],
|
||||
"matriculas": len(d["mats"]), "inversion": round(d["inv"], 0)}
|
||||
|
||||
# IMPORTE y RESULTADOS por PAUTA -> se asignan a su (sede, tipo) de CAMPANIAS,
|
||||
# una sola vez por pauta (no por sede del lead), para NO duplicar.
|
||||
# imp_st[(sede,tipo)][cod] = importe ; res_st[(sede,tipo)][cod] = resultados
|
||||
imp_st = {}
|
||||
res_st = {}
|
||||
for cod, imp_val in importe_por_pauta_periodo.items():
|
||||
info = campanias_sede_cargo.get(str(cod).strip())
|
||||
if not info:
|
||||
continue
|
||||
se = str(info.get("sede") or "").upper()
|
||||
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
|
||||
imp_st.setdefault((se, tp), {})[str(cod).strip()] = float(imp_val or 0.0)
|
||||
for cod, res_val in resultados_por_pauta_periodo.items():
|
||||
info = campanias_sede_cargo.get(str(cod).strip())
|
||||
if not info:
|
||||
continue
|
||||
se = str(info.get("sede") or "").upper()
|
||||
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
|
||||
res_st.setdefault((se, tp), {})[str(cod).strip()] = int(res_val or 0)
|
||||
|
||||
filas = []
|
||||
for sede in sorted(grupos.keys()):
|
||||
subfilas = []
|
||||
s_rec = s_proc = 0; s_mats = set(); s_inv = 0.0; s_imp = 0.0; s_res = 0
|
||||
for tp in sorted(grupos[sede].keys()):
|
||||
cods = grupos[sede][tp]
|
||||
imp_map = imp_st.get((sede.upper(), tp), {}) # importe por pauta de esta sede/tipo
|
||||
res_map = res_st.get((sede.upper(), tp), {})
|
||||
# métricas del tipo (sumando sus códigos)
|
||||
t_rec = t_proc = 0; t_mats = set(); t_inv = 0.0; t_imp = 0.0; t_res = 0
|
||||
codigos = []
|
||||
for cod in sorted(cods.keys()):
|
||||
d = cods[cod]
|
||||
imp_cod = float(imp_map.get(str(cod).strip(), 0.0))
|
||||
res_cod = int(res_map.get(str(cod).strip(), 0))
|
||||
codigos.append({"codigo": cod, **_fila(d), "importe": round(imp_cod, 2),
|
||||
"resultados": res_cod})
|
||||
t_rec += d["recibidos"]; t_proc += d["procesados"]
|
||||
t_mats |= d["mats"]; t_inv += d["inv"]; t_imp += imp_cod; t_res += res_cod
|
||||
subfilas.append({
|
||||
"tipo": tp, "recibidos": t_rec, "procesados": t_proc,
|
||||
"matriculas": len(t_mats), "inversion": round(t_inv, 0),
|
||||
"importe": round(t_imp, 2), "resultados": t_res, "codigos": codigos,
|
||||
})
|
||||
s_rec += t_rec; s_proc += t_proc; s_mats |= t_mats; s_inv += t_inv; s_imp += t_imp; s_res += t_res
|
||||
filas.append({
|
||||
"sede": sede, "recibidos": s_rec, "procesados": s_proc,
|
||||
"matriculas": len(s_mats), "inversion": round(s_inv, 0),
|
||||
"importe": round(s_imp, 2), "resultados": s_res, "subfilas": subfilas,
|
||||
})
|
||||
# Totales
|
||||
t_rec = sum(f["recibidos"] for f in filas)
|
||||
t_proc = sum(f["procesados"] for f in filas)
|
||||
t_inv = round(sum(f["inversion"] for f in filas), 0)
|
||||
# Total de importe/resultados: suma de TODAS las pautas que estan en campanias
|
||||
# (una vez cada una), aunque no tengan fila con leads. Asi el total no se pierde.
|
||||
t_imp = round(sum(float(v or 0.0) for cod, v in importe_por_pauta_periodo.items()
|
||||
if str(cod).strip() in campanias_sede_cargo), 2)
|
||||
t_res = sum(int(v or 0) for cod, v in resultados_por_pauta_periodo.items()
|
||||
if str(cod).strip() in campanias_sede_cargo)
|
||||
# matrículas total: unión global
|
||||
all_mats = set()
|
||||
for tel, e in mat_por_tel.items():
|
||||
all_mats |= e["mats"]
|
||||
return {"filas": filas, "total": {"recibidos": t_rec, "procesados": t_proc,
|
||||
"matriculas": len(all_mats), "inversion": t_inv,
|
||||
"importe": t_imp, "resultados": t_res}}
|
||||
|
||||
|
||||
# ── Personalizado: replica la columna Fact_SQL_Base_Cursos[Personalizado] (M) ──
|
||||
# Base = dsc_det_programa, limpiando saltos de línea, luego cadena de reemplazos.
|
||||
_PERS_REEMPLAZOS = [
|
||||
("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO", "TEAC"),
|
||||
("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL", "TERC"),
|
||||
("TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL", "TERC"),
|
||||
("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION ", "CARRERA"),
|
||||
("VIRTUAL SEMINARIO SUPERVISION DE OBRAS EN AIRE ACONDICIONADO", "SUPERVISION DE OBRAS"),
|
||||
("SEMINARIO APLICACIÓN DE VARIADORES DE FRECUENCIA EN SISTEMAS HVAC", "VARIADORES DE FRECUENCIA"),
|
||||
("VIRTUAL - SISTEMAS DE REFRIGERACION CON CO2 - FASE SUBCRITICA Y TRANSCRITICA", "REFRIGERACION CON CO2"),
|
||||
("VIRTUAL SEMINARIO DISEÑO DE CAMARAS DE REFRIGERACION CON SISTEMAS CON FREON", "DISEÑO DE CAMARAS"),
|
||||
("VIRTUAL SEM. DETERMINACIÓN DE CAPACIDAD DE EQUIPOS DE AIRE ACONDICIONADO- CARGAS TÉRMICAS -(A/A)", "CARGAS TERMICAS"),
|
||||
("VIRTUAL SEMINARIO DIBUJO TECNICO Y DISEÑO ASISTIDO POR COMPUTADORA (CAD) PARA HVAC -(A/A)", "DIBUJO TECNICO (CAD)"),
|
||||
("VIRTUAL SEMINARIO: SISTEMAS DE REFRIGERACIÓN INDUSTRIAL POR AMONIACO (NH3)", "AMONIACO (NH3)"),
|
||||
("SEMINARIO VIRTUAL METRADO , COSTEO Y PRESUPUESTOS DE HVAC (AA)", "METRADO, COSTEO Y PRESUPUESTOS"),
|
||||
("CARRERA TECNICA LIMA GESTION DE EMPRESAS", "GESTION DE EMPRESAS"),
|
||||
("VIRTUAL SEMINARIO DISEÑO DE CHILLERS PARA PROCESOS INDUSTRIALES DE REFRIGERACIÓN", "DISEÑO DE CHILLERS"),
|
||||
("VIRTUAL SEMINARIO PRACTICO VENTILACION DE SOTANO Y PREZURIZACION DE ESCALERA -(A/A)", "VENTILACION DE SOTANO"),
|
||||
("SEMINARIO USO DE SOFTWARE EN CÁLCULOS DE AIRE ACONDICIONADO", "SOFTWARE EN CALCULOS DE A/A"),
|
||||
("DISEÑO DE SISTEMAS DE AIRE ACONDICIONADO", "DISEÑO DE SISTEMAS DE A/A"),
|
||||
("SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE VRV/VRF - SAB", "VOLUMEN VARIABLE VRF"),
|
||||
# Seminarios VRF por sede y CO2 → nombre corto (la frecuencia se conserva al final)
|
||||
("PIURA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "PIURA - VRF"),
|
||||
("AREQUIPA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "AREQUIPA - VRF"),
|
||||
("TRUJILLO - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "TRUJILLO - VRF"),
|
||||
("VIRTUAL - DISEÑO Y OPERACIÓN DE SISTEMAS DE REFRIGERACIÓN CON CO2 EN FASE SUBCRITICA Y TRANSCRITICA", "CO2"),
|
||||
# Reemplazos sobre Nombre_Programa_Detalle (bloque ductos y otros)
|
||||
("VIRTUAL - DIMENSIONAMIENTO DE", ""),
|
||||
("METÁLICOS EN AIRE ACONDICIONADO Y VENTILACIÓN", ""),
|
||||
("MASTERCLASS: PROGRAMACIÓN DE CONTROLADORES PARA REFRIGERACIÓN", "PROGRAMACION DE CONTROLADORES"),
|
||||
("DIPLOMADO INTERNACIONAL DE AIRE ACONDICIONADO", "DIPLOMADO DE A/A"),
|
||||
# Reemplazos finales (sedes/carreras/masterclass)
|
||||
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "AREQUIPA - CARRERA - TARDE"),
|
||||
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "PIURA - CARRERA - TARDE"),
|
||||
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "TRUJILLO - CARRERA - TARDE"),
|
||||
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "AREQUIPA - CARRERA - MAÑANA"),
|
||||
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "PIURA - CARRERA - MAÑANA"),
|
||||
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "TRUJILLO - CARRERA - MAÑANA"),
|
||||
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - NOC", "GESTIÓN DE VENTA - NOC"),
|
||||
("MASTERCLASS SELECCIÓN E INSTALACIÓN DE TARJETAS ELECTRÓNICAS UNIVERSALES EN EQUIPOS DE A/A .INVERTER - MAN", "MASTERCLASS TARJETAS UNIVERSALES - MAN"),
|
||||
("MASTERCLASS: SISTEMAS DE A/A CON VRF - SAB", "MASTERCLASS: A/A CON VRF - SAB"),
|
||||
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - TAR", "GESTIÓN DE VENTA - TAR"),
|
||||
]
|
||||
|
||||
|
||||
def personalizado_curso(dsc_programa, cod_frecuencia=""):
|
||||
"""Replica Fact_SQL_Base_Cursos[Personalizado] del PBI:
|
||||
base = dsc_programa & ' - ' & cod_frecuencia (NO dsc_det_programa)."""
|
||||
s = f"{str(dsc_programa or '')} - {str(cod_frecuencia or '')}"
|
||||
# Text.Clean + reemplazar saltos de línea por espacio, luego Trim
|
||||
s = s.replace("\r", " ").replace("\n", " ")
|
||||
s = "".join(ch for ch in s if ch >= " " or ch == " ") # Text.Clean (quita control)
|
||||
s = s.strip()
|
||||
for buscar, reemplazo in _PERS_REEMPLAZOS:
|
||||
s = s.replace(buscar, reemplazo)
|
||||
# colapsar espacios múltiples que pudieran quedar de reemplazos vacíos
|
||||
s = " ".join(s.split())
|
||||
return s
|
||||
|
||||
|
||||
# ── Matriz por Curso (num_indice): Personalizado + métricas de leads por pauta ──
|
||||
# NOTA: las métricas de leads cruzan por el CÓDIGO DE PAUTA del curso, que viene de
|
||||
# SharePoint (no disponible aún). Por eso salen en 0 hasta conectar esa fuente.
|
||||
# Ver FUTUROS_CAMBIOS.md punto 5. Lo que sí se calcula: Personalizado y Fecha Inicio.
|
||||
def matriz_cursos(cursos, leads, ano, mes, dia, pauta_map=None,
|
||||
campanias_map=None, cartera_all=None, cartera_asig=None,
|
||||
mat_por_indice=None, cartera_tels=None, cartera_origen=None,
|
||||
importe_por_pauta=None, importe_por_pauta_periodo=None,
|
||||
cartera_canal=None, conjunto_por_pauta=None, pautas_por_indice=None):
|
||||
pauta_map = pauta_map or {}
|
||||
pautas_por_indice = pautas_por_indice or {} # {num_indice: [pautas]} (muchos-a-muchos)
|
||||
campanias_map = campanias_map or {}
|
||||
cartera_all = cartera_all or {}
|
||||
cartera_asig = cartera_asig or {}
|
||||
mat_por_indice = mat_por_indice or {}
|
||||
cartera_tels = cartera_tels or set()
|
||||
cartera_origen = cartera_origen or {}
|
||||
importe_por_pauta = importe_por_pauta or {}
|
||||
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
|
||||
conjunto_por_pauta = conjunto_por_pauta or {} # {pauta: [conjuntos de anuncios]}
|
||||
# cartera_canal = {"PAUTA": {"all","asig","tels"}, "OTROS": {...}} para el desglose por canal
|
||||
cartera_canal = cartera_canal or {}
|
||||
|
||||
CURSOS_EXCLUIDOS = {"1121", "1154"}
|
||||
|
||||
def _excluir(c):
|
||||
ni = str(c.get("num_indice", ""))
|
||||
if ni in CURSOS_EXCLUIDOS:
|
||||
return True
|
||||
info = pauta_map.get(ni)
|
||||
if not info:
|
||||
return False # sin info en Supabase → no se excluye
|
||||
# Excluir SUSPENDIDO o contar = NO
|
||||
return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO"
|
||||
|
||||
cursos_periodo = [c for c in cursos
|
||||
if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)
|
||||
and not _excluir(c)]
|
||||
|
||||
# Leads únicos agrupados por codigo (= Pauta). Históricos y del mes (filtro fecha).
|
||||
leads_unicos = [l for l in leads if l.get("es_unico")]
|
||||
hist_por_cod = {} # codigo → set teléfonos
|
||||
hist_ases_cod = {} # codigo → set teléfonos con asesor
|
||||
mes_por_cod = {} # codigo → set teléfonos (en periodo filtrado)
|
||||
mes_ases_cod = {} # codigo → set teléfonos con asesor (en periodo)
|
||||
for l in leads_unicos:
|
||||
cod = l.get("codigo", "-")
|
||||
tel = l["telefono"]
|
||||
hist_por_cod.setdefault(cod, set()).add(tel)
|
||||
if l.get("asesor"):
|
||||
hist_ases_cod.setdefault(cod, set()).add(tel)
|
||||
if _en_periodo(l["fecha"], ano, mes, dia):
|
||||
mes_por_cod.setdefault(cod, set()).add(tel)
|
||||
if l.get("asesor"):
|
||||
mes_ases_cod.setdefault(cod, set()).add(tel)
|
||||
|
||||
# Construir filas con sus medidas
|
||||
pre = []
|
||||
for c in sorted(cursos_periodo, key=lambda x: (_to_date(x.get("fch_inicio")) or date.min)):
|
||||
nombre = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
|
||||
fch = _to_date(c.get("fch_inicio"))
|
||||
ni = str(c.get("num_indice", "")).strip()
|
||||
# LISTA de pautas del num_indice (muchos-a-muchos). Si no hay en la tabla nueva,
|
||||
# usa la de basebi (compatibilidad).
|
||||
cods = pautas_por_indice.get(ni)
|
||||
if not cods:
|
||||
_info = pauta_map.get(ni)
|
||||
cods = [_info.get("pauta")] if _info and _info.get("pauta") else []
|
||||
cods = [str(x).strip() for x in cods if x]
|
||||
# Leads = union de telefonos de TODAS las pautas (sin duplicar)
|
||||
def _union(mapa):
|
||||
s = set()
|
||||
for cd in cods:
|
||||
s |= mapa.get(cd, set())
|
||||
return len(s)
|
||||
nuevos = _union(hist_por_cod)
|
||||
nuevos_a = _union(hist_ases_cod)
|
||||
nuevos_m = _union(mes_por_cod)
|
||||
nuevos_ma = _union(mes_ases_cod)
|
||||
pre.append({
|
||||
"num_indice": ni,
|
||||
"personalizado": nombre,
|
||||
"fch": fch,
|
||||
"pauta": cods[0] if cods else None, # pauta principal (para mostrar)
|
||||
"pautas": cods, # todas las pautas (para sumar)
|
||||
"fecha_inicio": fch.strftime("%d/%m/%Y") if fch else "",
|
||||
"leads_nuevos": nuevos,
|
||||
"leads_nuevos_asesor": nuevos_a,
|
||||
"leads_nuevos_mes": nuevos_m,
|
||||
"leads_nuevos_mes_asesor": nuevos_ma,
|
||||
})
|
||||
|
||||
# Índice HISTÓRICO completo (TODOS los cursos, sin filtro de fecha) por
|
||||
# personalizado → lista de (fecha, leads_nuevos). Para el acumulado.
|
||||
hist_cursos = {}
|
||||
for c in cursos:
|
||||
if _excluir(c):
|
||||
continue
|
||||
nom = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
|
||||
f = _to_date(c.get("fch_inicio"))
|
||||
info = pauta_map.get(str(c.get("num_indice", "")))
|
||||
cd = info.get("pauta") if info else None
|
||||
nv = len(hist_por_cod.get(cd, ())) if cd else 0
|
||||
hist_cursos.setdefault(nom, []).append((f, nv))
|
||||
|
||||
# Leads_Acumulados_Historico: suma de leads_nuevos de TODOS los cursos del
|
||||
# MISMO personalizado con fecha <= la del curso actual (histórico completo).
|
||||
filas = []
|
||||
for r in pre:
|
||||
acum = sum(nv for (f, nv) in hist_cursos.get(r["personalizado"], [])
|
||||
if f and r["fch"] and f <= r["fch"])
|
||||
# CARTERA TOTAL: teléfonos únicos de cartera_junta con la misma SEDE+PROGRAMA
|
||||
# del curso (según su pauta → campanias) cuya fecha_creada <= fch_inicio del curso.
|
||||
# cartera_total = todos; cartera_total_asig = solo con asesor asignado.
|
||||
cartera_total = 0
|
||||
cartera_total_asig = 0
|
||||
cod = r.get("pauta")
|
||||
cods = r.get("pautas") or ([cod] if cod else [])
|
||||
if cods and r["fch"]:
|
||||
# Cartera = union de telefonos de la sede+programa de TODAS las pautas del num_indice
|
||||
tels_all = {}; tels_asig = {}
|
||||
for cd in cods:
|
||||
sp = campanias_map.get(str(cd).upper())
|
||||
if not sp:
|
||||
continue
|
||||
for tel, fx in cartera_all.get(sp, {}).items():
|
||||
if fx and (tel not in tels_all or fx < tels_all[tel]):
|
||||
tels_all[tel] = fx
|
||||
for tel, fx in cartera_asig.get(sp, {}).items():
|
||||
if fx and (tel not in tels_asig or fx < tels_asig[tel]):
|
||||
tels_asig[tel] = fx
|
||||
cartera_total = sum(1 for fx in tels_all.values() if fx <= r["fch"])
|
||||
cartera_total_asig = sum(1 for fx in tels_asig.values() if fx <= r["fch"])
|
||||
# MATRÍCULAS NO IDENTIFICADAS: matrículas de este curso (num_indice) cuyo teléfono
|
||||
# (tel_1, o tel_2 si el 1 está vacío) NO aparece en cartera_junta. Sin filtro de fecha.
|
||||
_ni = r["num_indice"]
|
||||
_ni = _ni[:-2] if _ni.endswith(".0") else _ni
|
||||
_mats = mat_por_indice.get(_ni, []) # lista de (telefono, fecha_matricula)
|
||||
_tels_no_iden = [ph for (ph, fm) in _mats if ph not in cartera_tels]
|
||||
matriculas_no_iden = len(_tels_no_iden)
|
||||
# MATRICULAS LEADS NUEVOS: matrícula hecha dentro de 45 días DESPUÉS del origen
|
||||
# del lead (registro es_origen=SI de ese teléfono en la cartera).
|
||||
mat_leads_nuevos = 0
|
||||
mat_leads_antiguos = 0
|
||||
_tels_nuevos = []
|
||||
_tels_antiguos = []
|
||||
for (ph, fm) in _mats:
|
||||
if not ph or not fm:
|
||||
continue
|
||||
forig = cartera_origen.get(ph)
|
||||
if forig is None:
|
||||
continue
|
||||
dias = (fm - forig).days
|
||||
if 0 <= dias <= 45:
|
||||
mat_leads_nuevos += 1
|
||||
_tels_nuevos.append(ph)
|
||||
else:
|
||||
# >45 días, o matrícula anterior al origen del lead (dias<0) → Antiguo
|
||||
mat_leads_antiguos += 1
|
||||
_tels_antiguos.append(ph)
|
||||
# IMPORTE PAUTA: gasto de Meta Ads sumando TODAS las pautas del num_indice.
|
||||
importe_pauta = round(sum(float(importe_por_pauta.get(str(cd), 0.0)) for cd in cods), 2)
|
||||
# IMPORTE PAUTA EN EL MES: igual, pero solo del periodo filtrado.
|
||||
importe_pauta_mes = round(sum(float(importe_por_pauta_periodo.get(str(cd), 0.0)) for cd in cods), 2)
|
||||
|
||||
# ── SUBFILAS por CANAL (PAUTA / OTROS) ── mismo criterio que la fila padre
|
||||
# (SEDE+PROGRAMA vía campanias, misma fecha) + filtro de canal desde cartera.
|
||||
def _subfila(clave):
|
||||
cc = cartera_canal.get(clave, {})
|
||||
c_all = cc.get("all", {}); c_asig = cc.get("asig", {}); c_tels = cc.get("tels", set())
|
||||
ct = ct_asig = 0
|
||||
l_rec = l_proc = 0
|
||||
l_rec_mes = l_proc_mes = 0
|
||||
if cod and r["fch"]:
|
||||
sp = campanias_map.get(str(cod).upper())
|
||||
if sp:
|
||||
fa = c_all.get(sp, {})
|
||||
fg = c_asig.get(sp, {})
|
||||
# Cartera Total/Asig del canal: fecha_creada <= inicio del curso (igual que padre)
|
||||
ct = sum(1 for fx in fa.values() if fx and fx <= r["fch"])
|
||||
ct_asig = sum(1 for fx in fg.values() if fx and fx <= r["fch"])
|
||||
# L. Recibidos/Procesados del canal = misma cartera del canal (mismo corte)
|
||||
l_rec = ct
|
||||
l_proc = ct_asig
|
||||
# "del Mes" = fecha_creada en el periodo filtrado
|
||||
l_rec_mes = sum(1 for fx in fa.values() if _en_periodo(fx, ano, mes, dia))
|
||||
l_proc_mes = sum(1 for fx in fg.values() if _en_periodo(fx, ano, mes, dia))
|
||||
# Matrículas del curso, clasificadas por canal:
|
||||
# - NO identificadas (tel NO en cartera) -> todas a OTROS.
|
||||
# - identificadas -> al canal de su telefono (c_tels), regla 45 dias.
|
||||
mt_noiden = mt_nuevos = mt_antiguos = 0
|
||||
for (ph, fm) in _mats:
|
||||
if not ph:
|
||||
continue
|
||||
en_cartera = ph in cartera_tels
|
||||
if not en_cartera:
|
||||
# no identificada: solo cuenta en la subfila OTROS
|
||||
if clave == "OTROS":
|
||||
mt_noiden += 1
|
||||
continue
|
||||
# identificada: solo cuenta si su tel pertenece a ESTE canal
|
||||
if ph not in c_tels:
|
||||
continue
|
||||
forig = cartera_origen.get(ph)
|
||||
if fm and forig:
|
||||
d = (fm - forig).days
|
||||
if 0 <= d <= 45: mt_nuevos += 1
|
||||
else: mt_antiguos += 1
|
||||
return {"canal": clave, "cartera_total": ct, "cartera_total_asig": ct_asig,
|
||||
"leads_nuevos": l_rec, "leads_nuevos_asesor": l_proc,
|
||||
"leads_nuevos_mes": l_rec_mes, "leads_nuevos_mes_asesor": l_proc_mes,
|
||||
"matriculas_no_iden": mt_noiden, "mat_leads_nuevos": mt_nuevos,
|
||||
"mat_leads_antiguos": mt_antiguos}
|
||||
subfilas = [_subfila("PAUTA"), _subfila("WEB"), _subfila("OTROS")] if cartera_canal else []
|
||||
|
||||
filas.append({
|
||||
"num_indice": r["num_indice"],
|
||||
"personalizado": r["personalizado"],
|
||||
"fecha_inicio": r["fecha_inicio"],
|
||||
"pauta": r.get("pauta"),
|
||||
"importe_pauta": importe_pauta,
|
||||
"importe_pauta_mes": importe_pauta_mes,
|
||||
"cartera_total": cartera_total,
|
||||
"cartera_total_asig": cartera_total_asig,
|
||||
"matriculas_no_iden": matriculas_no_iden,
|
||||
"matriculas_no_iden_tels": _tels_no_iden,
|
||||
"mat_leads_nuevos": mat_leads_nuevos,
|
||||
"mat_leads_antiguos": mat_leads_antiguos,
|
||||
"mat_leads_nuevos_tels": _tels_nuevos,
|
||||
"mat_leads_antiguos_tels": _tels_antiguos,
|
||||
"leads_acumulados": acum,
|
||||
"leads_nuevos": r["leads_nuevos"],
|
||||
"leads_nuevos_asesor": r["leads_nuevos_asesor"],
|
||||
"leads_nuevos_mes": r["leads_nuevos_mes"],
|
||||
"leads_nuevos_mes_asesor": r["leads_nuevos_mes_asesor"],
|
||||
"subfilas_canal": subfilas,
|
||||
"conjuntos": [cj for cd in cods for cj in conjunto_por_pauta.get(str(cd).strip(), [])],
|
||||
"contar": (pauta_map.get(str(r["num_indice"]).strip()) or {}).get("contar") or "SI",
|
||||
})
|
||||
return {"filas": filas}
|
||||
275
backend/main.py
Normal file
275
backend/main.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# backend/main.py
|
||||
"""API REST del Dashboard de LEADS (FastAPI)."""
|
||||
from fastapi import FastAPI, Query, HTTPException, Body
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from datetime import datetime
|
||||
import uvicorn
|
||||
|
||||
import services
|
||||
from cache_manager import start_background_refresh, cache_stats
|
||||
|
||||
app = FastAPI(title="Dashboard Leads API", version="1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup():
|
||||
# Precarga en SEGUNDO PLANO (hilo aparte) para no bloquear el arranque del server.
|
||||
import threading
|
||||
def _precarga_bg():
|
||||
print("[startup] Precargando datos de Leads (2do plano)...")
|
||||
try:
|
||||
services.precargar_todo()
|
||||
print("[startup] Precarga completa.")
|
||||
except Exception as e:
|
||||
print(f"[startup] Precarga falló: {e}")
|
||||
threading.Thread(target=_precarga_bg, daemon=True).start()
|
||||
# Otros General en un hilo aparte (sus consultas son mas pesadas)
|
||||
def _precarga_otros_bg():
|
||||
try:
|
||||
services.precargar_otros_general()
|
||||
except Exception as e:
|
||||
print(f"[startup] Precarga Otros falló: {e}")
|
||||
threading.Thread(target=_precarga_otros_bg, daemon=True).start()
|
||||
start_background_refresh(services.refrescar_todo, interval=900)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok", "hora": datetime.now().isoformat()}
|
||||
|
||||
|
||||
@app.get("/api/cache/stats")
|
||||
def stats():
|
||||
return cache_stats()
|
||||
|
||||
|
||||
@app.post("/api/cache/refresh")
|
||||
def refresh():
|
||||
services.refrescar_todo()
|
||||
return {"status": "refrescado"}
|
||||
|
||||
|
||||
@app.get("/api/leads/filtros")
|
||||
def get_filtros():
|
||||
try:
|
||||
return services.opciones_filtros()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/leads")
|
||||
def get_leads(
|
||||
ano: str = Query("TODOS"),
|
||||
mes: str = Query("TODOS"),
|
||||
dia: str = Query("TODOS"),
|
||||
programa: str = Query("TODOS"),
|
||||
sede: str = Query("TODOS"),
|
||||
):
|
||||
try:
|
||||
return services.leads_dashboard(ano, mes, dia, programa, sede)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/vendedores")
|
||||
def get_vendedores(
|
||||
ano: str = Query("TODOS"),
|
||||
mes: str = Query("TODOS"),
|
||||
dia: str = Query("TODOS"),
|
||||
):
|
||||
try:
|
||||
return services.vendedores_dashboard(ano, mes, dia)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/roas")
|
||||
def get_roas(
|
||||
ano: str = Query("TODOS"),
|
||||
mes: str = Query("TODOS"),
|
||||
dia: str = Query("TODOS"),
|
||||
):
|
||||
try:
|
||||
return services.roas_dashboard(ano, mes, dia)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/conjuntos-sin-sede")
|
||||
def get_conjuntos_sin_sede():
|
||||
try:
|
||||
return services.conjuntos_sin_sede()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/conjuntos-sin-sede/guardar")
|
||||
def post_conjunto_sede(body: dict = Body(...)):
|
||||
try:
|
||||
cambios = body.get("cambios") or []
|
||||
return services.guardar_conjunto_sede(cambios)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/conjuntos-sin-sede/borrar")
|
||||
def post_borrar_conjunto_sede(body: dict = Body(...)):
|
||||
try:
|
||||
return services.borrar_conjunto_sede(body.get("conjunto") or "")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/otros-general")
|
||||
def get_otros_general(
|
||||
ano: str = Query("TODOS"),
|
||||
mes: str = Query("TODOS"),
|
||||
dia: str = Query("TODOS"),
|
||||
):
|
||||
try:
|
||||
return services.otros_general(ano, mes, dia)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/conjuntos-sin-pauta")
|
||||
def get_conjuntos_sin_pauta():
|
||||
try:
|
||||
return {"conjuntos": services.conjuntos_sin_pauta()}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/programas-ocultos")
|
||||
def get_programas_ocultos():
|
||||
try:
|
||||
return services.programas_ocultos()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/programas-ocultos/encender")
|
||||
def post_encender_programas(body: dict = Body(...)):
|
||||
try:
|
||||
nis = body.get("num_indices") or []
|
||||
return services.encender_programas(nis)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/programas-disponibles")
|
||||
def get_programas_disponibles():
|
||||
try:
|
||||
return services.programas_disponibles()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/leyenda-anuncios")
|
||||
def get_leyenda():
|
||||
try:
|
||||
return services.leyenda_anuncios()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/leyenda-anuncios/guardar")
|
||||
def post_leyenda(body: dict = Body(...)):
|
||||
try:
|
||||
cambios = body.get("cambios") or []
|
||||
return services.guardar_leyenda(cambios)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ──
|
||||
@app.get("/api/campanias")
|
||||
def get_campanias():
|
||||
try:
|
||||
return {"filas": services.campanias_listar()}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/campanias/agregar")
|
||||
def post_campania(body: dict = Body(...)):
|
||||
try:
|
||||
return services.campania_agregar(body or {})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/campanias/editar")
|
||||
def post_editar_campania(body: dict = Body(...)):
|
||||
try:
|
||||
cid = body.get("id")
|
||||
return services.campania_editar(cid, body or {})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/campanias/borrar")
|
||||
def post_borrar_campania(body: dict = Body(...)):
|
||||
try:
|
||||
return services.campania_borrar(body.get("id"))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/pauta/uso")
|
||||
def get_uso_pauta(pauta: str = Query(...), excluir: str = Query("")):
|
||||
try:
|
||||
return services.uso_de_pauta(pauta, excluir)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/curso/guardar-pauta")
|
||||
def post_guardar_pauta(body: dict = Body(...)):
|
||||
try:
|
||||
ni = str(body.get("num_indice", "")).strip()
|
||||
pa = str(body.get("pauta", "")).strip()
|
||||
cj = body.get("conjunto") or None
|
||||
contar = body.get("contar") # "SI" / "NO" / None
|
||||
if not ni:
|
||||
raise HTTPException(status_code=400, detail="num_indice es obligatorio")
|
||||
return services.guardar_edicion_curso(ni, pa, cj, contar)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/ultima-actualizacion")
|
||||
def get_ultima_actualizacion():
|
||||
try:
|
||||
return services.ultima_actualizacion()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/leads/alertas")
|
||||
def get_alertas():
|
||||
"""Valores de programa/sede en cartera_junta que NO están en el diccionario."""
|
||||
try:
|
||||
return services.alertas()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/debug/cursos")
|
||||
def debug_cursos(ano: str = "2026", mes: str = "1"):
|
||||
"""Diagnóstico: lista cursos del periodo con inscritos, tipo y meta."""
|
||||
try:
|
||||
return services.debug_cursos(ano, mes)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False)
|
||||
70
backend/medir_tiempos.py
Normal file
70
backend/medir_tiempos.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# medir_tiempos.py
|
||||
# Mide cuánto tarda CADA parte que carga el dashboard de Leads al iniciar.
|
||||
# Simula lo mismo que hace el backend en su precarga, pero cronometrando cada paso.
|
||||
# Ejecutar en backend/: python medir_tiempos.py
|
||||
import time
|
||||
import services as S
|
||||
|
||||
def cron(nombre, fn):
|
||||
t0 = time.perf_counter()
|
||||
ok = "OK"
|
||||
extra = ""
|
||||
try:
|
||||
r = fn()
|
||||
try:
|
||||
if isinstance(r, (list, dict)):
|
||||
extra = f"({len(r)} items)"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
ok = "ERROR"
|
||||
extra = str(e)[:60]
|
||||
dt = time.perf_counter() - t0
|
||||
print(f" {nombre:38} {dt:7.2f}s {ok} {extra}")
|
||||
return dt
|
||||
|
||||
periodos = S._periodos_actual_anterior()
|
||||
a0, m0 = periodos[0]
|
||||
|
||||
print("\n" + "="*70)
|
||||
print(" MEDICIÓN DE TIEMPOS DE CARGA — Dashboard LEADS")
|
||||
print("="*70)
|
||||
|
||||
print("\n[1] INSUMOS CRUDOS (consultas base, se cargan 1 vez)")
|
||||
tot1 = 0
|
||||
tot1 += cron("Leads (Chatwoot/PostgreSQL)", S._leads_crudos)
|
||||
tot1 += cron("Cursos (SQL Server)", S._cursos_crudos)
|
||||
tot1 += cron("Matriculas (SQL Server)", S._matriculas_crudas)
|
||||
tot1 += cron("Leads asignados/respuesta", S._leads_asignados_crudos)
|
||||
tot1 += cron("Cartera (Supabase)", S._cartera_rows_cache)
|
||||
tot1 += cron("Plantillas", S._plantillas_crudas)
|
||||
|
||||
print(f"\n --> Subtotal insumos: {tot1:.2f}s")
|
||||
|
||||
print(f"\n[2] APARTADO LEADS (periodo {a0}-{m0}, filtros TODOS)")
|
||||
tot2 = 0
|
||||
tot2 += cron("leads_dashboard", lambda: S.leads_dashboard(a0, m0, "TODOS", "TODOS", "TODOS"))
|
||||
tot2 += cron("leads_filtros", S.opciones_filtros)
|
||||
print(f"\n --> Subtotal Leads: {tot2:.2f}s")
|
||||
|
||||
print(f"\n[3] APARTADO VENDEDORES (periodo {a0}-{m0})")
|
||||
tot3 = 0
|
||||
tot3 += cron("vendedores_dashboard", lambda: S.vendedores_dashboard(a0, m0, "TODOS"))
|
||||
print(f"\n --> Subtotal Vendedores: {tot3:.2f}s")
|
||||
|
||||
print(f"\n[4] APARTADO OTROS GENERAL (periodo {a0}-{m0}) — el mas pesado")
|
||||
tot4 = 0
|
||||
tot4 += cron("otros_general", lambda: S.otros_general(a0, m0, "TODOS"))
|
||||
print(f"\n --> Subtotal Otros General: {tot4:.2f}s")
|
||||
|
||||
print(f"\n[5] APARTADO ROAS (periodo {a0}-{m0})")
|
||||
tot5 = 0
|
||||
tot5 += cron("roas_dashboard", lambda: S.roas_dashboard(a0, m0, "TODOS"))
|
||||
print(f"\n --> Subtotal ROAS: {tot5:.2f}s")
|
||||
|
||||
total = tot1 + tot2 + tot3 + tot4 + tot5
|
||||
print("\n" + "="*70)
|
||||
print(f" TIEMPO TOTAL (todo en frio, 1a vez): {total:.2f}s")
|
||||
print(" Nota: en el arranque real, [1]+[2] van en un hilo y [4] en otro,")
|
||||
print(" asi que corren EN PARALELO. El tiempo real es ~ el mas lento de los dos.")
|
||||
print("="*70 + "\n")
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
python-dotenv
|
||||
pyodbc
|
||||
psycopg2-binary
|
||||
requests
|
||||
1768
backend/services.py
Normal file
1768
backend/services.py
Normal file
File diff suppressed because it is too large
Load Diff
55
backend/ver_campanias.py
Normal file
55
backend/ver_campanias.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# ver_campanias.py — Muestra en pantalla TODA la tabla campanias de Supabase.
|
||||
# Ejecutar dentro de backend/: python ver_campanias.py
|
||||
import os, requests
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
|
||||
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
|
||||
TABLA = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
|
||||
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||
|
||||
assert URL and KEY, "Falta CARTERA_URL / CARTERA_KEY en .env"
|
||||
|
||||
def traer_todo():
|
||||
filas, paso, desde = [], 1000, 0
|
||||
while True:
|
||||
hdr = dict(HEAD)
|
||||
hdr["Range-Unit"] = "items"
|
||||
hdr["Range"] = f"{desde}-{desde + paso - 1}"
|
||||
r = requests.get(f"{URL}/rest/v1/{TABLA}",
|
||||
params={"select": "*", "order": "codigo"},
|
||||
headers=hdr, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data:
|
||||
break
|
||||
filas.extend(data)
|
||||
desde += len(data)
|
||||
if len(data) < paso:
|
||||
break
|
||||
return filas
|
||||
|
||||
filas = traer_todo()
|
||||
print(f"\nTotal campanias: {len(filas)}\n")
|
||||
|
||||
if not filas:
|
||||
print("Sin datos. Revisa CARTERA_URL / CARTERA_KEY en .env")
|
||||
raise SystemExit
|
||||
|
||||
# columnas
|
||||
cols = ["frase_busqueda", "cargo", "codigo", "sede", "dia", "origen", "fecha_inicio", "fecha_fin"]
|
||||
# por si hay columnas extra
|
||||
for f in filas:
|
||||
for k in f.keys():
|
||||
if k not in cols and k != "id":
|
||||
cols.append(k)
|
||||
|
||||
# imprime cada campania en pantalla
|
||||
for i, f in enumerate(filas, 1):
|
||||
partes = []
|
||||
for c in cols:
|
||||
v = f.get(c, "")
|
||||
v = "" if v is None else str(v)
|
||||
partes.append(f"{c}={v}")
|
||||
print(f"{i:3}. " + " | ".join(partes))
|
||||
24
backend/ver_leyenda.py
Normal file
24
backend/ver_leyenda.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# ver_leyenda.py - Muestra en pantalla el mini-apartado LEYENDA (tabla campanias),
|
||||
# igual que se ve en el dashboard. Solo lectura.
|
||||
# Ejecutar en backend/: python ver_leyenda.py
|
||||
from data_manager_v2 import DataManager
|
||||
|
||||
dm = DataManager()
|
||||
filas = dm.listar_campanias()
|
||||
|
||||
print(f"\n=== LEYENDA (tabla campanias) — {len(filas)} filas ===\n")
|
||||
h = f"{'id':>4} {'FRASE DE BUSQUEDA':38} {'CARGO':16} {'COD':6} {'SEDE':10} {'DIA':14} {'ORIGEN':13} {'INICIO':11} {'FIN':11}"
|
||||
print(h)
|
||||
print("-"*len(h))
|
||||
for f in filas:
|
||||
print(f"{str(f.get('id','')):>4} "
|
||||
f"{str(f.get('frase_busqueda',''))[:38]:38} "
|
||||
f"{str(f.get('cargo',''))[:16]:16} "
|
||||
f"{str(f.get('codigo','')):6} "
|
||||
f"{str(f.get('sede',''))[:10]:10} "
|
||||
f"{str(f.get('dia',''))[:14]:14} "
|
||||
f"{str(f.get('origen',''))[:13]:13} "
|
||||
f"{str(f.get('fecha_inicio','')):11} "
|
||||
f"{str(f.get('fecha_fin','')):11}")
|
||||
print("-"*len(h))
|
||||
print(f"Total: {len(filas)} campanias")
|
||||
Reference in New Issue
Block a user