diff --git a/backend/services.py b/backend/services.py index 6c82868..6bf4e9d 100644 --- a/backend/services.py +++ b/backend/services.py @@ -91,14 +91,16 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False): dm._fecha_canc_overrides = {} dm._inv_neta_overrides = {} logic = VentasLogic(dm) - datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa) \ - if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes)) + # En VENTAS (con_overrides=False) excluimos los RET aunque hayan pagado. En Comisiones NO. + _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'] # PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que # la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla). try: - det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), 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_por_vend = {} for f in pend_filas: @@ -345,9 +347,22 @@ def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"): logic = RentabilidadLogic(dm) datos = logic.obtener_datos_procesados(str(ano), str(mes), sede, filtro_prog) 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) 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) @@ -719,6 +734,7 @@ def _sb(): return dm.supabase_client # ── 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): """Devuelve {estado, observaciones} de un alumno en (ano, mes). Default si no existe.""" sb = _sb() @@ -745,7 +761,7 @@ def alumno_estado_guardar(matricula, ano, mes, estado, observaciones, usuario=No "actualizado_por": usuario or None, } 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 cache_invalidate("cobranza") cache_invalidate("retiros") @@ -760,13 +776,12 @@ def alumnos_por_retirar(ano, mes): return {"matriculas": [str(r["matricula"]) for r in (res.data or [])]} -# ── RETIROS (apartado) ───────────────────────────────────────────────────── def retiros(ano="2026", mes=8): """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.""" def _load(): - # ¿Hay SNAPSHOT guardado (GUARDAR TOTAL) de este mes? Si hay al menos una - # fila guardada, se muestra ese snapshot congelado (todas las columnas). + # 1) ¿Hay SNAPSHOT guardado (GUARDAR TOTAL) de este mes? Si hay al menos una + # fila guardada, se muestra ese snapshot congelado (todas las columnas). snap = retiros_snapshot_obtener(ano, mes) if snap.get("filas"): return {"filas": snap["filas"], "guardado": True, @@ -791,6 +806,8 @@ def retiros(ano="2026", mes=8): extra = dm.traer_vendedor_cod_detalle(list(mats_ret)) except Exception: 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 _hoy = _dtnow.now() cob_idx = {} @@ -836,6 +853,7 @@ def retiros(ano="2026", mes=8): "monto_venc_cur": monto_cur, "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), + # columnas manuales guardadas de este mes "n_asistencia": inf.get("n_asistencia"), "n_faltas": inf.get("n_faltas"), "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 "", "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) return {"filas": filas, "guardado": False} return cache_get_or_set("retiros", (str(ano), str(mes)), _load) +# ── Info manual de retiros (tabla retiros_info en Supabase) ── _RETIROS_INFO_CAMPOS = [ "n_asistencia", "n_faltas", "ultimo_dia_asist", "hasta_sesion", "fch_ult_resp", "fch_envio_correo", "retiro_wsp_class", @@ -905,6 +925,7 @@ def retiros_snapshot_obtener(ano, mes): filas = [] guardado_en = None 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")): continue 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_por": usuario or None, } + # upsert por la llave (matricula, ano, mes) sb.table("retiros_info").upsert(payload, on_conflict="matricula,ano,mes").execute() from cache_manager import cache_invalidate cache_invalidate("retiros") @@ -989,6 +1011,7 @@ def retiros_snapshot_guardar(ano, mes, filas, usuario=None): "actualizado_en": now, "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() if registros: sb.table("retiros_info").insert(registros).execute() @@ -1051,16 +1074,121 @@ def usuarios_eliminar(user_id): 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) ──────────── 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() res = sb.table("permisos").select("modulo").execute() mods = sorted({str(r.get("modulo", "")).strip() for r in (res.data or []) if r.get("modulo")}) return {"modulos": mods} 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() res = sb.table("permisos").select("rol,modulo,puede_ver").execute() por_rol = {} @@ -1074,7 +1202,8 @@ def roles_listar(): return {"roles": [{"rol": k, "modulos": sorted(v)} for k, v in sorted(por_rol.items())]} 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() rol = str(rol or "").strip().upper() if not rol: @@ -1212,73 +1341,3 @@ def comisiones_override_restaurar(num_matricula): cache_invalidate("ventas") cache_invalidate("comisiones_det_todos") 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()