import streamlit as st import pandas as pd import threading from datetime import datetime, timedelta from apscheduler.schedulers.background import BackgroundScheduler from core import audit_log from Analytics.vortex_params import set_parametre, get_parametre from core.email_client import envoyer_email, get_credential SEUIL_JOURS_DEMANDE = 3 # age (en jours) au-dela duquel une demande EN_ATTENTE remonte dans le digest DIGEST_PARAM_NAME = "Dernier_Digest_Notifications" DIGEST_INTERVALLE_HEURES = 24 # doit rester coherent avec digest_intervalle_heures de demarrer_scheduler # ============================================================================== # VARIABLE GLOBALE — accessible depuis le thread background ET depuis Streamlit # On ne peut PAS écrire dans st.session_state depuis un thread séparé. # ============================================================================== _statut_notif = { "derniere_digest" : None, # str — horodatage lisible du dernier digest admin "derniere_digest_kyc" : 0, # int — dossiers KYC en attente signalés "derniere_digest_prets" : 0, # int — demandes de prêt en attente signalées "scheduler_started" : False, # bool — anti-double-démarrage } _scheduler_lock = threading.Lock() # ────────────────────────────────────────────── # DIGEST ADMIN QUOTIDIEN (dossiers KYC + demandes en attente) # ────────────────────────────────────────────── def _template_digest_admin(kyc_pending: list, demandes_en_attente: list) -> str: def _lignes_kyc(): if not kyc_pending: return '

Aucun dossier en attente de revue.

' items = "".join( f'{d["type"]}' f'{d["nom"]}' f'{d["id"]}' f'{d["statut"]}' for d in kyc_pending ) return f'{items}
' def _lignes_demandes(): if not demandes_en_attente: return '

Aucune demande en attente au-delà du seuil.

' items = "".join( f'{d["id_pret"]}' f'{d["nom"]}' f'{d["age_jours"]} j' for d in demandes_en_attente ) return f'{items}
' return f"""

[REGISTRE] Digest Logistique — Dossiers à traiter

Vortex-Flux – Synthèse quotidienne automatique

KYC en attente de revue ({len(kyc_pending)})

{_lignes_kyc()}

Demandes de prêt en attente depuis plus de {SEUIL_JOURS_DEMANDE} jours ({len(demandes_en_attente)})

{_lignes_demandes()}

[DATE] Digest généré le {datetime.now().strftime('%d/%m/%Y à %H:%M')}

Système de rappels automatiques Vortex-Flux
""" @audit_log.tracer("JOB", "job_digest_admin", resume=lambda a, k: {"force": k.get("force", False)}, acteur="JOB:job_digest_admin") def _job_digest_admin(gspread_client, sheet_name: str, force: bool = False): """Tâche planifiée (thread séparé) : synthèse quotidienne des dossiers KYC en attente de revue et des demandes de prêt en attente depuis trop longtemps. N'écrit jamais dans st.session_state ni n'appelle st.* (hors contexte Streamlit). `next_run_time=datetime.now()` dans demarrer_scheduler() fait tourner ce job immediatement a chaque demarrage du scheduler — donc a chaque redemarrage du process (redeploiement, reveil du Space apres une periode d'inactivite...), pas seulement toutes les digest_intervalle_heures. Sans ce garde-fou, un redemarrage frequent (ex: un deploiement juste avant que quelqu'un se connecte) renvoyait le digest a chaque fois, independamment du dernier envoi reel. On verifie donc l'horodatage persiste dans Parametres_Vortex (survit aux redemarrages) avant d'envoyer, sauf si force=True (bouton "ENVOYER LE DIGEST MAINTENANT", qui doit toujours envoyer immediatement).""" now = datetime.now() now_str = now.strftime('%d/%m/%Y %H:%M') print(f"[{now_str}] ▶ Digest admin logistique…") if not force: dernier_envoi = get_parametre(gspread_client, sheet_name, DIGEST_PARAM_NAME) if dernier_envoi: try: dernier_envoi_dt = datetime.fromisoformat(str(dernier_envoi)) if now - dernier_envoi_dt < timedelta(hours=DIGEST_INTERVALLE_HEURES): print(f"[{now_str}] [SAUT] Digest deja envoye le {dernier_envoi} (< {DIGEST_INTERVALLE_HEURES}h) — ignore.") return except ValueError: pass # valeur illisible : on ne bloque pas l'envoi try: sh = gspread_client.open(sheet_name) df_clients = pd.DataFrame(sh.worksheet("Clients_KYC").get_all_records()) except Exception as e: print(f"[ECHEC] Digest : impossible de lire Clients_KYC : {e}") return try: df_garants = pd.DataFrame(sh.worksheet("Garants_KYC").get_all_records()) except Exception: df_garants = pd.DataFrame() try: df_req = pd.DataFrame(sh.worksheet("Prets_Requests").get_all_records()) except Exception: df_req = pd.DataFrame() statuts_a_signaler = {"ORANGE_TOLERANCE", "ROUGE_REJET"} kyc_pending = [] for df, label in ((df_clients, "Client"), (df_garants, "Garant")): if df.empty or "Statut_KYC_Global" not in df.columns: continue subset = df[df["Statut_KYC_Global"].astype(str).str.upper().isin(statuts_a_signaler)] for _, row in subset.iterrows(): kyc_pending.append({ "type": label, "nom": row.get("Nom_Complet", "N/A"), "id": row.get("ID_Client", row.get("ID_Garant", "N/A")), "statut": row.get("Statut_KYC_Global", ""), }) demandes_en_attente = [] if not df_req.empty and "Statut" in df_req.columns and "Date_Creation" in df_req.columns: en_attente = df_req[df_req["Statut"].astype(str).str.strip().str.upper() == "EN_ATTENTE"] for _, row in en_attente.iterrows(): try: d_creation = datetime.strptime(str(row["Date_Creation"]).strip(), "%d-%m-%Y %H:%M:%S") except ValueError: continue age_jours = (now - d_creation).days if age_jours > SEUIL_JOURS_DEMANDE: demandes_en_attente.append({ "id_pret": row.get("ID_Pret", "N/A"), "nom": row.get("Nom_Complet", "N/A"), "age_jours": age_jours, }) if kyc_pending or demandes_en_attente: sender_email = get_credential("SENDER_EMAIL") destinataire_digest = get_credential("ADMIN_EMAIL") or sender_email ok, detail = envoyer_email( destinataire_digest, f"Digest Logistique — {len(kyc_pending)} KYC / {len(demandes_en_attente)} demande(s)", html_content=_template_digest_admin(kyc_pending, demandes_en_attente), ) if not ok: print(f"Digest : erreur d'envoi : {detail}") _statut_notif["derniere_digest"] = now_str _statut_notif["derniere_digest_kyc"] = len(kyc_pending) _statut_notif["derniere_digest_prets"] = len(demandes_en_attente) try: set_parametre(gspread_client, sheet_name, DIGEST_PARAM_NAME, now.isoformat(), raison=f"{len(kyc_pending)} KYC / {len(demandes_en_attente)} demandes") except Exception as e: print(f"[ATTENTION] Digest : échec horodatage Parametres_Vortex : {e}") print(f"[Fin] [REGISTRE] Digest : {len(kyc_pending)} KYC en attente, {len(demandes_en_attente)} demande(s) en attente") # ────────────────────────────────────────────── # DÉMARRAGE DU SCHEDULER # ────────────────────────────────────────────── SIGNATURES_INTERVALLE_HEURES = 6 @audit_log.tracer("JOB", "job_relances_signatures", resume=lambda a, k: {}, acteur="JOB:job_relances_signatures") def _job_relances_signatures(gspread_client, sheet_name: str): """Relance a 24 h, re-emission automatique apres expiration (max 3), alerte admin ensuite. Voir core/signature_service.traiter_relances. N'echoue jamais bruyamment.""" try: from core import signature_service bilan = signature_service.traiter_relances(gspread_client.open(sheet_name)) print(f"[Signatures] relances={bilan['relances']} reemissions={bilan['reemissions']} alertes={bilan['alertes']}") except Exception as exc: from core.errors import log_swallowed log_swallowed("Job de relances de signatures echoue", exc) def demarrer_scheduler(gspread_client, sheet_name: str, digest_intervalle_heures: int = 24): """ Lance le scheduler en arrière-plan au démarrage de l'app. Utilise _statut_notif (global) pour éviter le double démarrage, car st.session_state n'est pas fiable ici (contexte multi-thread). """ with _scheduler_lock: if _statut_notif["scheduler_started"]: return # Déjà en cours, on ne relance pas scheduler = BackgroundScheduler(daemon=True) scheduler.add_job( func = _job_digest_admin, args = [gspread_client, sheet_name], trigger = "interval", hours = digest_intervalle_heures, next_run_time = datetime.now(), # ← exécution immédiate au démarrage id = "digest_admin", replace_existing = True, ) from core.suivi_emails import job_synchronisation scheduler.add_job( func = job_synchronisation, args = [gspread_client, sheet_name], trigger = "interval", hours = 1, next_run_time = datetime.now() + timedelta(minutes=10), id = "suivi_emails", replace_existing = True, ) scheduler.add_job( func = _job_relances_signatures, args = [gspread_client, sheet_name], trigger = "interval", hours = SIGNATURES_INTERVALLE_HEURES, next_run_time = datetime.now() + timedelta(minutes=5), id = "relances_signatures", replace_existing = True, ) scheduler.start() _statut_notif["scheduler_started"] = True print(f"[OK] Scheduler démarré — digest toutes les {digest_intervalle_heures}h") def declencher_digest_maintenant(gspread_client, sheet_name: str): """Declenchement manuel et synchrone du digest (bouton admin, hors thread planifie). force=True : ignore le garde-fou des 24h, le clic du gerant doit toujours envoyer.""" _job_digest_admin(gspread_client, sheet_name, force=True) return { "derniere_digest": _statut_notif["derniere_digest"], "kyc": _statut_notif["derniere_digest_kyc"], "demandes": _statut_notif["derniere_digest_prets"], } # ────────────────────────────────────────────── # WIDGET D'AFFICHAGE (Dashboard) # ────────────────────────────────────────────── def afficher_statut_notifications(): """ Lit _statut_notif (global) et affiche le statut dans l'interface. Appelé depuis le thread principal Streamlit — lecture seule, thread-safe. """ st.markdown("### Notifications automatiques") from modules.rappels_auto import HEURE_ENVOI st.caption( f"Rappels des échéances en retard et critiques : envoi automatique chaque jour à " f"{HEURE_ENVOI}h00. Historique dans Repayments > Journal d'audit des rappels." ) derniere_digest = _statut_notif["derniere_digest"] if derniere_digest: st.info( f"Dernier digest logistique : **{derniere_digest}** — " f"{_statut_notif['derniere_digest_kyc']} dossier(s) KYC, " f"{_statut_notif['derniere_digest_prets']} demande(s) en attente signalé(s)" )