Actualizar backend/services.py

This commit is contained in:
2026-09-22 10:45:20 -05:00
parent 611ea35508
commit 981a606cce

View File

@@ -91,14 +91,16 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
dm._fecha_canc_overrides = {} dm._fecha_canc_overrides = {}
dm._inv_neta_overrides = {} dm._inv_neta_overrides = {}
logic = VentasLogic(dm) logic = VentasLogic(dm)
datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa) \ # En VENTAS (con_overrides=False) excluimos los RET aunque hayan pagado. En Comisiones NO.
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes)) _excl_ret = not con_overrides
datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa, _excl_ret) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes), _excl_ret)
datos = [d for d in datos if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR'] datos = [d for d in datos if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR']
# PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que # PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que
# la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla). # la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla).
try: try:
det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), str(mes)) det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), str(mes), _excl_ret)
pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else [] pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else []
pend_por_vend = {} pend_por_vend = {}
for f in pend_filas: for f in pend_filas:
@@ -345,9 +347,22 @@ def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"):
logic = RentabilidadLogic(dm) logic = RentabilidadLogic(dm)
datos = logic.obtener_datos_procesados(str(ano), str(mes), sede, filtro_prog) datos = logic.obtener_datos_procesados(str(ano), str(mes), sede, filtro_prog)
filas = logic.formatear_datos_para_tabla(datos) filas = logic.formatear_datos_para_tabla(datos)
# Desglose de costos por programa (para exportar). Va aparte para NO alterar
# la tabla visible (que solo usa las 13 primeras columnas).
extras = {}
for f in (filas or []):
if len(f) >= 29 and str(f[0]).upper().find("TOTAL") < 0:
extras[str(f[0])] = {
"costo_epp_cert": f[14], "costo_doc": f[15],
"costo_cons": f[16], "costo_mkt": f[17], "mb_ini": f[18],
"sede": f[19], "venta_ini": f[20], "costo_ini": f[21],
"venta_act": f[22], "costo_act": f[23], "mb_act": f[24],
"costo_cert": f[25], "total_ins": f[26], "retirados": f[27],
"en_curso": f[28],
}
filas_s = _serializar_filas(filas, 13) filas_s = _serializar_filas(filas, 13)
nombres = [str(f[0]) for f in filas_s if "TOTAL" not in str(f[0]).upper()] nombres = [str(f[0]) for f in filas_s if "TOTAL" not in str(f[0]).upper()]
return {"filas": filas_s, "programas": nombres} return {"filas": filas_s, "programas": nombres, "extras": extras}
return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load) return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load)
@@ -719,6 +734,7 @@ def _sb():
return dm.supabase_client return dm.supabase_client
# ── Estado/observaciones por alumno (tabla cobranza_estado_alumno) ── # ── Estado/observaciones por alumno (tabla cobranza_estado_alumno) ──
# Ahora es POR (matricula, ano, mes): el estado/observaciones aplican a ese mes.
def alumno_estado_obtener(matricula, ano, mes): def alumno_estado_obtener(matricula, ano, mes):
"""Devuelve {estado, observaciones} de un alumno en (ano, mes). Default si no existe.""" """Devuelve {estado, observaciones} de un alumno en (ano, mes). Default si no existe."""
sb = _sb() sb = _sb()
@@ -745,7 +761,7 @@ def alumno_estado_guardar(matricula, ano, mes, estado, observaciones, usuario=No
"actualizado_por": usuario or None, "actualizado_por": usuario or None,
} }
sb.table("cobranza_estado_alumno").upsert(payload, on_conflict="matricula,ano,mes").execute() sb.table("cobranza_estado_alumno").upsert(payload, on_conflict="matricula,ano,mes").execute()
# Invalidar cobranza (columna SALDO POR RETIRAR) y el apartado Retiros # Invalidar cobranza (columna SALDO POR RETIRAR) y el apartado Retiros.
from cache_manager import cache_invalidate from cache_manager import cache_invalidate
cache_invalidate("cobranza") cache_invalidate("cobranza")
cache_invalidate("retiros") cache_invalidate("retiros")
@@ -760,12 +776,11 @@ def alumnos_por_retirar(ano, mes):
return {"matriculas": [str(r["matricula"]) for r in (res.data or [])]} return {"matriculas": [str(r["matricula"]) for r in (res.data or [])]}
# ── RETIROS (apartado) ─────────────────────────────────────────────────────
def retiros(ano="2026", mes=8): def retiros(ano="2026", mes=8):
"""Apartado RETIROS: alumnos marcados POR RETIRAR en (ano, mes), con sus datos """Apartado RETIROS: alumnos marcados POR RETIRAR en (ano, mes), con sus datos
del cronograma de cobranza. El estado POR RETIRAR y la info manual son de ese mes.""" del cronograma de cobranza. El estado POR RETIRAR y la info manual son de ese mes."""
def _load(): def _load():
# ¿Hay SNAPSHOT guardado (GUARDAR TOTAL) de este mes? Si hay al menos una # 1) ¿Hay SNAPSHOT guardado (GUARDAR TOTAL) de este mes? Si hay al menos una
# fila guardada, se muestra ese snapshot congelado (todas las columnas). # fila guardada, se muestra ese snapshot congelado (todas las columnas).
snap = retiros_snapshot_obtener(ano, mes) snap = retiros_snapshot_obtener(ano, mes)
if snap.get("filas"): if snap.get("filas"):
@@ -791,6 +806,8 @@ def retiros(ano="2026", mes=8):
extra = dm.traer_vendedor_cod_detalle(list(mats_ret)) extra = dm.traer_vendedor_cod_detalle(list(mats_ret))
except Exception: except Exception:
extra = {} extra = {}
# Saldos MES ANTERIOR y MES EN CURSO por matrícula (mismos datos de Cobranza).
# cta_ant-cob_ant = saldo mes anterior; cta_cur-cob_cur = saldo mes en curso.
from datetime import datetime as _dtnow from datetime import datetime as _dtnow
_hoy = _dtnow.now() _hoy = _dtnow.now()
cob_idx = {} cob_idx = {}
@@ -836,6 +853,7 @@ def retiros(ano="2026", mes=8):
"monto_venc_cur": monto_cur, "monto_venc_cur": monto_cur,
"imp_total_pagado": ex.get("imp_total_pagado", 0), "imp_total_pagado": ex.get("imp_total_pagado", 0),
"monto_dejo_pagar": round(float(ex.get("inv_neta", 0) or 0) - float(ex.get("imp_total_pagado", 0) or 0), 2), "monto_dejo_pagar": round(float(ex.get("inv_neta", 0) or 0) - float(ex.get("imp_total_pagado", 0) or 0), 2),
# columnas manuales guardadas de este mes
"n_asistencia": inf.get("n_asistencia"), "n_asistencia": inf.get("n_asistencia"),
"n_faltas": inf.get("n_faltas"), "n_faltas": inf.get("n_faltas"),
"ultimo_dia_asist": inf.get("ultimo_dia_asist") or "", "ultimo_dia_asist": inf.get("ultimo_dia_asist") or "",
@@ -846,11 +864,13 @@ def retiros(ano="2026", mes=8):
"motivo_retiro": inf.get("motivo_retiro") or "", "motivo_retiro": inf.get("motivo_retiro") or "",
"detalle_retiro": inf.get("detalle_retiro") or "", "detalle_retiro": inf.get("detalle_retiro") or "",
}) })
# ordenar por fecha de matrícula (más reciente primero)
filas.sort(key=lambda x: x["fch_matricula"], reverse=True) filas.sort(key=lambda x: x["fch_matricula"], reverse=True)
return {"filas": filas, "guardado": False} return {"filas": filas, "guardado": False}
return cache_get_or_set("retiros", (str(ano), str(mes)), _load) return cache_get_or_set("retiros", (str(ano), str(mes)), _load)
# ── Info manual de retiros (tabla retiros_info en Supabase) ──
_RETIROS_INFO_CAMPOS = [ _RETIROS_INFO_CAMPOS = [
"n_asistencia", "n_faltas", "ultimo_dia_asist", "hasta_sesion", "n_asistencia", "n_faltas", "ultimo_dia_asist", "hasta_sesion",
"fch_ult_resp", "fch_envio_correo", "retiro_wsp_class", "fch_ult_resp", "fch_envio_correo", "retiro_wsp_class",
@@ -905,6 +925,7 @@ def retiros_snapshot_obtener(ano, mes):
filas = [] filas = []
guardado_en = None guardado_en = None
for r in (res.data or []): for r in (res.data or []):
# Fila de snapshot = tiene alumno o programa (las de solo-INFO no los tienen).
if not (r.get("alumno") or r.get("programa")): if not (r.get("alumno") or r.get("programa")):
continue continue
if not guardado_en and r.get("actualizado_en"): if not guardado_en and r.get("actualizado_en"):
@@ -952,6 +973,7 @@ def retiros_info_guardar(matricula, ano, mes, campos, usuario=None):
"actualizado_en": datetime.now(timezone.utc).isoformat(), "actualizado_en": datetime.now(timezone.utc).isoformat(),
"actualizado_por": usuario or None, "actualizado_por": usuario or None,
} }
# upsert por la llave (matricula, ano, mes)
sb.table("retiros_info").upsert(payload, on_conflict="matricula,ano,mes").execute() sb.table("retiros_info").upsert(payload, on_conflict="matricula,ano,mes").execute()
from cache_manager import cache_invalidate from cache_manager import cache_invalidate
cache_invalidate("retiros") cache_invalidate("retiros")
@@ -989,6 +1011,7 @@ def retiros_snapshot_guardar(ano, mes, filas, usuario=None):
"actualizado_en": now, "actualizado_en": now,
"actualizado_por": usuario or None, "actualizado_por": usuario or None,
}) })
# 1) borrar todo el mes 2) insertar lo nuevo (2 operaciones, egress mínimo)
sb.table("retiros_info").delete().eq("ano", ano).eq("mes", mes).execute() sb.table("retiros_info").delete().eq("ano", ano).eq("mes", mes).execute()
if registros: if registros:
sb.table("retiros_info").insert(registros).execute() sb.table("retiros_info").insert(registros).execute()
@@ -1051,16 +1074,121 @@ def usuarios_eliminar(user_id):
return {"status": "ok"} return {"status": "ok"}
# ── HISTORIAL DE ESTADO DE ASESORES (Chatwoot -> tabla historial_estado_asesor) ──
def registrar_estado_asesores():
"""Lee el estado actual de cada agente en Chatwoot (account_users) y, si cambió
respecto a lo último guardado, inserta una fila en historial_estado_asesor.
availability: 0 = ON (conectado), 1 = OFF (desconectado)."""
dm = get_dm()
conn = dm.get_pg_connection()
if conn is None:
return {"status": "error", "msg": "sin conexion postgre"}
try:
cur = conn.cursor()
# Estado actual de cada asesor (solo correos de la escuela, ignora tests)
cur.execute("""
SELECT u.name, u.uid, au.availability, au.updated_at
FROM account_users au
JOIN users u ON au.user_id = u.id
WHERE u.uid LIKE '%@escueladerefrigeracion.edu.pe'
""")
actuales = cur.fetchall() # [(name, email, availability, updated_at), ...]
# Ultimo estado guardado por asesor (para comparar)
cur.execute("""
SELECT DISTINCT ON (email) email, accion
FROM historial_estado_asesor
ORDER BY email, fecha_hora DESC
""")
ultimo = {r[0]: r[1] for r in cur.fetchall()} # {email: 'ON'/'OFF'}
nuevos = 0
for (name, email, avail, updated_at) in actuales:
accion = "ON" if int(avail) == 0 else "OFF" # 0=on, 1=off
if ultimo.get(email) == accion:
continue # no cambió -> no se guarda
cur.execute("""
INSERT INTO historial_estado_asesor (asesor, email, accion, origen, quien, fecha_hora)
VALUES (%s, %s, %s, %s, %s, %s)
""", (name, email, accion, "CHATWOOT", name, updated_at))
nuevos += 1
conn.commit()
return {"status": "ok", "nuevos": nuevos}
except Exception as e:
print(f"[historial asesores] {e}")
return {"status": "error", "msg": str(e)}
finally:
conn.close()
def desactivar_auto_offline_todos():
"""Revisa TODOS los agentes de Chatwoot y, si alguno tiene auto_offline activado,
lo desactiva. Pensado para correr 1 vez al día (5-6am)."""
import requests
CHATWOOT_URL = "https://gestor.escueladerefrigeracion.edu.pe"
ACCESS_TOKEN = "4anazHvZnvKLtup8biu5Zuoh"
ACCOUNT_ID = "1"
headers = {"api_access_token": ACCESS_TOKEN, "Content-Type": "application/json"}
try:
r = requests.get(f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents",
headers=headers, timeout=15)
r.raise_for_status()
agentes = r.json()
except Exception as e:
print(f"[auto_offline] no se pudo leer agentes: {e}")
return {"status": "error", "msg": str(e)}
desactivados = 0
for ag in agentes:
ao = ag.get("auto_offline")
if ao is True or str(ao).lower() == "true":
try:
requests.patch(f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents/{ag.get('id')}",
headers=headers, json={"auto_offline": False}, timeout=15)
desactivados += 1
except Exception as e:
print(f"[auto_offline] fallo al desactivar {ag.get('id')}: {e}")
print(f"[auto_offline] desactivados: {desactivados}")
return {"status": "ok", "desactivados": desactivados}
def historial_asesor(email, limite=100):
"""Devuelve el historial de estados de un asesor (por email), más reciente primero."""
dm = get_dm()
conn = dm.get_pg_connection()
if conn is None:
return {"historial": []}
try:
cur = conn.cursor()
cur.execute("""
SELECT accion, origen, quien, fecha_hora
FROM historial_estado_asesor
WHERE email = %s
ORDER BY fecha_hora DESC
LIMIT %s
""", (email, int(limite)))
filas = [{"accion": r[0], "origen": r[1], "quien": r[2],
"fecha_hora": r[3].isoformat() if r[3] else None} for r in cur.fetchall()]
return {"historial": filas}
except Exception as e:
print(f"[historial_asesor] {e}")
return {"historial": []}
finally:
conn.close()
# ── ROLES Y PERMISOS (tabla 'permisos': rol, modulo, puede_ver) ──────────── # ── ROLES Y PERMISOS (tabla 'permisos': rol, modulo, puede_ver) ────────────
def roles_modulos_disponibles(): def roles_modulos_disponibles():
"""Lista de módulos existentes (todos los que aparecen en la tabla permisos).""" """Lista de módulos existentes (todos los que aparecen en la tabla permisos),
para armar las casillas al crear un rol. Ordenados alfabéticamente."""
sb = _sb() sb = _sb()
res = sb.table("permisos").select("modulo").execute() res = sb.table("permisos").select("modulo").execute()
mods = sorted({str(r.get("modulo", "")).strip() for r in (res.data or []) if r.get("modulo")}) mods = sorted({str(r.get("modulo", "")).strip() for r in (res.data or []) if r.get("modulo")})
return {"modulos": mods} return {"modulos": mods}
def roles_listar(): def roles_listar():
"""Devuelve todos los roles con la lista de módulos que puede ver cada uno.""" """Devuelve todos los roles con la lista de módulos que puede ver cada uno.
[{rol, modulos: [..]}]"""
sb = _sb() sb = _sb()
res = sb.table("permisos").select("rol,modulo,puede_ver").execute() res = sb.table("permisos").select("rol,modulo,puede_ver").execute()
por_rol = {} por_rol = {}
@@ -1074,7 +1202,8 @@ def roles_listar():
return {"roles": [{"rol": k, "modulos": sorted(v)} for k, v in sorted(por_rol.items())]} return {"roles": [{"rol": k, "modulos": sorted(v)} for k, v in sorted(por_rol.items())]}
def rol_guardar(rol, modulos): def rol_guardar(rol, modulos):
"""Crea o actualiza un rol: borra sus filas y reinserta una por módulo marcado.""" """Crea o actualiza un rol: borra sus filas y reinserta una por módulo marcado.
modulos = lista de módulos que el rol PUEDE ver."""
sb = _sb() sb = _sb()
rol = str(rol or "").strip().upper() rol = str(rol or "").strip().upper()
if not rol: if not rol:
@@ -1212,73 +1341,3 @@ def comisiones_override_restaurar(num_matricula):
cache_invalidate("ventas") cache_invalidate("ventas")
cache_invalidate("comisiones_det_todos") cache_invalidate("comisiones_det_todos")
return {"status": "ok"} return {"status": "ok"}
# ── HISTORIAL DE ESTADO DE ASESORES (Chatwoot -> historial_estado_asesor) ──
def registrar_estado_asesores():
"""Lee el estado actual de cada agente en Chatwoot (account_users) y, si cambió
respecto a lo último guardado, inserta una fila en historial_estado_asesor.
availability: 0 = ON (conectado), 1 = OFF (desconectado)."""
dm = get_dm()
conn = dm.get_pg_connection()
if conn is None:
return {"status": "error", "msg": "sin conexion postgre"}
try:
cur = conn.cursor()
cur.execute("""
SELECT u.name, u.uid, au.availability, au.updated_at
FROM account_users au
JOIN users u ON au.user_id = u.id
WHERE u.uid LIKE '%@escueladerefrigeracion.edu.pe'
""")
actuales = cur.fetchall()
cur.execute("""
SELECT DISTINCT ON (email) email, accion
FROM historial_estado_asesor
ORDER BY email, fecha_hora DESC
""")
ultimo = {r[0]: r[1] for r in cur.fetchall()}
nuevos = 0
for (name, email, avail, updated_at) in actuales:
accion = "ON" if int(avail) == 0 else "OFF"
if ultimo.get(email) == accion:
continue
cur.execute("""
INSERT INTO historial_estado_asesor (asesor, email, accion, origen, quien, fecha_hora)
VALUES (%s, %s, %s, %s, %s, %s)
""", (name, email, accion, "CHATWOOT", name, updated_at))
nuevos += 1
conn.commit()
return {"status": "ok", "nuevos": nuevos}
except Exception as e:
print(f"[historial asesores] {e}")
return {"status": "error", "msg": str(e)}
finally:
conn.close()
def historial_asesor(email, limite=100):
"""Devuelve el historial de estados de un asesor (por email), más reciente primero."""
dm = get_dm()
conn = dm.get_pg_connection()
if conn is None:
return {"historial": []}
try:
cur = conn.cursor()
cur.execute("""
SELECT accion, origen, quien, fecha_hora
FROM historial_estado_asesor
WHERE email = %s
ORDER BY fecha_hora DESC
LIMIT %s
""", (email, int(limite)))
filas = [{"accion": r[0], "origen": r[1], "quien": r[2],
"fecha_hora": r[3].isoformat() if r[3] else None} for r in cur.fetchall()]
return {"historial": filas}
except Exception as e:
print(f"[historial_asesor] {e}")
return {"historial": []}
finally:
conn.close()