first commit

This commit is contained in:
mrtoine 2025-09-20 13:18:04 +02:00
commit e6c52820cd
227 changed files with 16156 additions and 0 deletions

View file

@ -0,0 +1,5 @@
from .client import Client
from .handler import ClientHandler
from .prospect import Prospect
from .prospect_handler import ProspectHandler
from .cli import main

849
modules/crm/cli.py Normal file
View file

@ -0,0 +1,849 @@
import os
from .client import Client
from .handler import ClientHandler
from .prospect import Prospect
from .prospect_handler import ProspectHandler
from datetime import date
def clear_screen():
"""Efface l'écran du terminal"""
os.system('cls' if os.name == 'nt' else 'clear')
def main():
"""Point d'entrée pour l'application CRM"""
client_handler = ClientHandler()
prospect_handler = ProspectHandler()
while True:
clear_screen()
print("=== CRM - Gestion des Relations Clients ===\n")
print("1. Gestion des Clients")
print("2. Gestion des Prospects")
print("3. Tableau de bord")
print("4. Retour au menu principal")
choice = input("\nSélectionnez une option (1-4): ")
if choice == "1":
# Gestion des clients
manage_clients(client_handler, prospect_handler)
elif choice == "2":
# Gestion des prospects
manage_prospects(client_handler, prospect_handler)
elif choice == "3":
# Tableau de bord
show_dashboard(client_handler, prospect_handler)
elif choice == "4":
# Retour au menu principal
print("\nRetour au menu principal...")
break
else:
print("\nOption invalide. Veuillez réessayer.")
input("\nAppuyez sur Entrée pour continuer...")
def display_all_clients(client_handler):
"""Affiche tous les clients"""
clear_screen()
print("=== Liste des Clients ===\n")
clients = client_handler.get_all_clients()
if not clients:
print("Aucun client trouvé.")
return
for i, client in enumerate(clients, 1):
print(f"{i}. {client.name} ({client.company})")
print(f" Email: {client.email} | Téléphone: {client.phone}")
print(f" Dernière interaction: {client.last_contact}")
print(f" Documents: Devis ({len(client.linked_docs['devis'])}) | "
f"Propositions ({len(client.linked_docs['propositions'])}) | "
f"Factures ({len(client.linked_docs['factures'])})")
print("")
def search_clients(client_handler):
"""Recherche des clients par nom"""
clear_screen()
print("=== Recherche de Clients ===\n")
search_term = input("Entrez un terme de recherche (nom, email, entreprise): ")
if not search_term:
print("Recherche annulée.")
return
results = []
clients = client_handler.get_all_clients()
for client in clients:
if (search_term.lower() in client.name.lower() or
search_term.lower() in client.email.lower() or
search_term.lower() in client.company.lower()):
results.append(client)
if not results:
print(f"Aucun client trouvé pour '{search_term}'.")
return
print(f"\n{len(results)} client(s) trouvé(s):\n")
for i, client in enumerate(results, 1):
print(f"{i}. {client.name} ({client.company})")
print(f" Email: {client.email} | Téléphone: {client.phone}")
print("")
# Offrir la possibilité de voir les détails d'un client
if results:
choice = input("\nEntrez le numéro du client pour voir les détails (ou Entrée pour annuler): ")
if choice.isdigit() and 1 <= int(choice) <= len(results):
client = results[int(choice) - 1]
display_client_details(client)
def add_new_client(client_handler):
"""Ajoute un nouveau client"""
clear_screen()
print("=== Ajout d'un Nouveau Client ===\n")
# Recueillir les informations du client
name = input("Nom du client: ")
if not name:
print("Le nom est obligatoire. Opération annulée.")
return
company = input("Entreprise: ")
email = input("Email: ")
phone = input("Téléphone: ")
notes = input("Notes: ")
# Créer le nouveau client
new_client = Client(
name=name,
company=company,
email=email,
phone=phone,
notes=notes
)
# Ajouter le client
client_handler.add_client(new_client)
print(f"\nClient '{name}' ajouté avec succès!")
def edit_client(client_handler):
"""Modifie un client existant"""
clear_screen()
print("=== Modification d'un Client ===\n")
# Afficher tous les clients pour sélection
clients = client_handler.get_all_clients()
if not clients:
print("Aucun client trouvé.")
return
for i, client in enumerate(clients, 1):
print(f"{i}. {client.name} ({client.company})")
# Sélectionner un client à modifier
choice = input("\nEntrez le numéro du client à modifier (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(clients):
print("Opération annulée ou sélection invalide.")
return
client = clients[int(choice) - 1]
# Afficher les détails actuels
print(f"\nModification de '{client.name}':")
print(f"1. Nom: {client.name}")
print(f"2. Entreprise: {client.company}")
print(f"3. Email: {client.email}")
print(f"4. Téléphone: {client.phone}")
print(f"5. Notes: {client.notes}")
print("6. Tout modifier")
print("7. Annuler")
# Choisir ce qu'il faut modifier
edit_choice = input("\nQue souhaitez-vous modifier (1-7): ")
if edit_choice == "7":
print("Modification annulée.")
return
if edit_choice == "1" or edit_choice == "6":
client.name = input(f"Nouveau nom [{client.name}]: ") or client.name
if edit_choice == "2" or edit_choice == "6":
client.company = input(f"Nouvelle entreprise [{client.company}]: ") or client.company
if edit_choice == "3" or edit_choice == "6":
client.email = input(f"Nouvel email [{client.email}]: ") or client.email
if edit_choice == "4" or edit_choice == "6":
client.phone = input(f"Nouveau téléphone [{client.phone}]: ") or client.phone
if edit_choice == "5" or edit_choice == "6":
client.notes = input(f"Nouvelles notes [{client.notes}]: ") or client.notes
# Mettre à jour le client
client_handler.update_client(client)
print(f"\nClient '{client.name}' mis à jour avec succès!")
def delete_client(client_handler):
"""Supprime un client"""
clear_screen()
print("=== Suppression d'un Client ===\n")
# Afficher tous les clients pour sélection
clients = client_handler.get_all_clients()
if not clients:
print("Aucun client trouvé.")
return
for i, client in enumerate(clients, 1):
print(f"{i}. {client.name} ({client.company})")
# Sélectionner un client à supprimer
choice = input("\nEntrez le numéro du client à supprimer (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(clients):
print("Opération annulée ou sélection invalide.")
return
client = clients[int(choice) - 1]
# Confirmer la suppression
confirm = input(f"Êtes-vous sûr de vouloir supprimer '{client.name}'? (o/n): ")
if confirm.lower() != 'o':
print("Suppression annulée.")
return
# Supprimer le client
client_handler.delete_client(client.id)
print(f"\nClient '{client.name}' supprimé avec succès!")
def view_client_documents(client_handler):
"""Affiche les documents associés à un client"""
clear_screen()
print("=== Documents d'un Client ===\n")
# Afficher tous les clients pour sélection
clients = client_handler.get_all_clients()
if not clients:
print("Aucun client trouvé.")
return
for i, client in enumerate(clients, 1):
print(f"{i}. {client.name} ({client.company})")
# Sélectionner un client
choice = input("\nEntrez le numéro du client pour voir ses documents (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(clients):
print("Opération annulée ou sélection invalide.")
return
client = clients[int(choice) - 1]
display_client_documents(client)
def display_client_details(client):
"""Affiche les détails d'un client"""
clear_screen()
print(f"=== Détails du Client: {client.name} ===\n")
print(f"ID: {client.id}")
print(f"Nom: {client.name}")
print(f"Entreprise: {client.company}")
print(f"Email: {client.email}")
print(f"Téléphone: {client.phone}")
print(f"Notes: {client.notes}")
print(f"Dernière interaction: {client.last_contact}")
print(f"Prochaine action: {client.next_action}")
if client.tags:
print(f"Tags: {', '.join(client.tags)}")
# Afficher les documents
display_client_documents(client)
def display_client_documents(client):
"""Affiche les documents associés à un client"""
print(f"\n=== Documents de {client.name} ===\n")
devis = client.linked_docs.get('devis', [])
propositions = client.linked_docs.get('propositions', [])
factures = client.linked_docs.get('factures', [])
if not devis and not propositions and not factures:
print("Aucun document trouvé pour ce client.")
return
if devis:
print("Devis:")
for i, doc in enumerate(devis, 1):
print(f" {i}. {os.path.basename(doc)}")
if propositions:
print("\nPropositions commerciales:")
for i, doc in enumerate(propositions, 1):
print(f" {i}. {os.path.basename(doc)}")
if factures:
print("\nFactures:")
for i, doc in enumerate(factures, 1):
print(f" {i}. {os.path.basename(doc)}")
# Option pour ouvrir un document
print("\n1. Ouvrir un document")
print("2. Retour")
choice = input("\nQue souhaitez-vous faire (1-2): ")
if choice == "1":
# Choisir le type de document
print("\nType de document:")
print("1. Devis")
print("2. Proposition commerciale")
print("3. Facture")
doc_type_choice = input("\nSélectionnez le type de document (1-3): ")
doc_list = []
if doc_type_choice == "1" and devis:
doc_list = devis
doc_type_name = "devis"
elif doc_type_choice == "2" and propositions:
doc_list = propositions
doc_type_name = "proposition commerciale"
elif doc_type_choice == "3" and factures:
doc_list = factures
doc_type_name = "facture"
else:
print("Choix invalide ou aucun document de ce type.")
return
# Afficher les documents du type sélectionné
print(f"\n{doc_type_name.capitalize()}s disponibles:")
for i, doc in enumerate(doc_list, 1):
print(f" {i}. {os.path.basename(doc)}")
# Sélectionner un document à ouvrir
doc_choice = input(f"\nSélectionnez un {doc_type_name} à ouvrir (1-{len(doc_list)}): ")
if doc_choice.isdigit() and 1 <= int(doc_choice) <= len(doc_list):
selected_doc = doc_list[int(doc_choice) - 1]
open_document(selected_doc)
else:
print("Sélection invalide.")
def open_document(document_path):
"""Ouvre un document PDF"""
try:
if os.path.exists(document_path):
if os.name == 'nt': # Windows
os.startfile(document_path)
elif os.name == 'posix': # macOS et Linux
if 'darwin' in os.uname().sysname.lower(): # macOS
os.system(f'open "{document_path}"')
else: # Linux
os.system(f'xdg-open "{document_path}"')
print(f"Document ouvert: {os.path.basename(document_path)}")
else:
print(f"Erreur: Le document {document_path} n'existe pas.")
except Exception as e:
print(f"Erreur lors de l'ouverture du document: {e}")
def manage_clients(client_handler, prospect_handler):
"""Gestion des clients"""
while True:
clear_screen()
print("=== Gestion des Clients ===\n")
print("1. Afficher tous les clients")
print("2. Rechercher un client")
print("3. Ajouter un nouveau client")
print("4. Modifier un client")
print("5. Supprimer un client")
print("6. Voir les documents d'un client")
print("7. Retour")
choice = input("\nSélectionnez une option (1-7): ")
if choice == "1":
# Afficher tous les clients
display_all_clients(client_handler)
elif choice == "2":
# Rechercher un client
search_clients(client_handler)
elif choice == "3":
# Ajouter un nouveau client
add_new_client(client_handler)
elif choice == "4":
# Modifier un client
edit_client(client_handler)
elif choice == "5":
# Supprimer un client
delete_client(client_handler)
elif choice == "6":
# Voir les documents d'un client
view_client_documents(client_handler)
elif choice == "7":
# Retour
break
else:
print("\nOption invalide. Veuillez réessayer.")
input("\nAppuyez sur Entrée pour continuer...")
def manage_prospects(client_handler, prospect_handler):
"""Gestion des prospects"""
while True:
clear_screen()
print("=== Gestion des Prospects ===\n")
print("1. Afficher tous les prospects")
print("2. Rechercher un prospect")
print("3. Ajouter un nouveau prospect")
print("4. Modifier un prospect")
print("5. Supprimer un prospect")
print("6. Convertir un prospect en client")
print("7. Afficher les prospects par statut")
print("8. Retour")
choice = input("\nSélectionnez une option (1-8): ")
if choice == "1":
# Afficher tous les prospects
display_all_prospects(prospect_handler)
elif choice == "2":
# Rechercher un prospect
search_prospects(prospect_handler)
elif choice == "3":
# Ajouter un nouveau prospect
add_new_prospect(prospect_handler)
elif choice == "4":
# Modifier un prospect
edit_prospect(prospect_handler)
elif choice == "5":
# Supprimer un prospect
delete_prospect(prospect_handler)
elif choice == "6":
# Convertir un prospect en client
convert_prospect_to_client(prospect_handler, client_handler)
elif choice == "7":
# Afficher les prospects par statut
display_prospects_by_status(prospect_handler)
elif choice == "8":
# Retour
break
else:
print("\nOption invalide. Veuillez réessayer.")
input("\nAppuyez sur Entrée pour continuer...")
def show_dashboard(client_handler, prospect_handler):
"""Affiche un tableau de bord avec des statistiques"""
clear_screen()
print("=== Tableau de Bord CRM ===\n")
# Récupérer les données
clients = client_handler.get_all_clients()
prospects = prospect_handler.get_all_prospects()
# Statistiques de base
print(f"Nombre total de clients: {len(clients)}")
print(f"Nombre total de prospects: {len(prospects)}")
# Statistiques des prospects par statut
statuses = {}
for prospect in prospects:
status = prospect.status
if status in statuses:
statuses[status] += 1
else:
statuses[status] = 1
if statuses:
print("\nProspects par statut:")
for status, count in statuses.items():
print(f" {status}: {count}")
# Derniers prospects ajoutés
recent_prospects = prospect_handler.get_recent_prospects(days=30)
if recent_prospects:
print(f"\nNouveaux prospects (30 derniers jours): {len(recent_prospects)}")
# Activité récente
print("\nRécapitulatif des activités récentes:")
print(" Propositions commerciales en attente...")
print(" Devis en cours...")
# Suggestions d'actions
print("\nSuggestions d'actions:")
if len(prospects) > 0:
print(" • Suivez vos prospects pour augmenter votre taux de conversion")
if len(clients) > 0:
print(" • Contactez vos clients existants pour des opportunités de vente additionnelle")
if len(prospects) == 0:
print(" • Ajoutez des prospects pour développer votre portefeuille client")
def display_all_prospects(prospect_handler):
"""Affiche tous les prospects"""
clear_screen()
print("=== Liste des Prospects ===\n")
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
for i, prospect in enumerate(prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company})")
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
print(f" Statut: {prospect.status} | Source: {prospect.source}")
print(f" Dernière interaction: {prospect.last_contact}")
print("")
def search_prospects(prospect_handler):
"""Recherche des prospects par nom"""
clear_screen()
print("=== Recherche de Prospects ===\n")
search_term = input("Entrez un terme de recherche (nom, email, entreprise): ")
if not search_term:
print("Recherche annulée.")
return
results = []
prospects = prospect_handler.get_all_prospects()
for prospect in prospects:
if (search_term.lower() in prospect.name.lower() or
search_term.lower() in prospect.email.lower() or
search_term.lower() in prospect.company.lower()):
results.append(prospect)
if not results:
print(f"Aucun prospect trouvé pour '{search_term}'.")
return
print(f"\n{len(results)} prospect(s) trouvé(s):\n")
for i, prospect in enumerate(results, 1):
print(f"{i}. {prospect.name} ({prospect.company})")
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
print(f" Statut: {prospect.status}")
print("")
# Offrir la possibilité de voir les détails d'un prospect
if results:
choice = input("\nEntrez le numéro du prospect pour voir les détails (ou Entrée pour annuler): ")
if choice.isdigit() and 1 <= int(choice) <= len(results):
prospect = results[int(choice) - 1]
display_prospect_details(prospect)
def add_new_prospect(prospect_handler):
"""Ajoute un nouveau prospect"""
clear_screen()
print("=== Ajout d'un Nouveau Prospect ===\n")
# Recueillir les informations du prospect
name = input("Nom du prospect: ")
if not name:
print("Le nom est obligatoire. Opération annulée.")
return
company = input("Entreprise: ")
email = input("Email: ")
phone = input("Téléphone: ")
source = input("Source (site web, référence, salon, etc.): ")
notes = input("Notes: ")
# Statut du prospect
print("\nStatut du prospect:")
print("1. Nouveau")
print("2. Contacté")
print("3. Qualifié")
print("4. Proposition envoyée")
print("5. Non intéressé")
status_choice = input("\nSélectionnez le statut (1-5): ")
status = "Nouveau" # Défaut
if status_choice == "1":
status = "Nouveau"
elif status_choice == "2":
status = "Contacté"
elif status_choice == "3":
status = "Qualifié"
elif status_choice == "4":
status = "Proposition envoyée"
elif status_choice == "5":
status = "Non intéressé"
# Créer le nouveau prospect
new_prospect = Prospect(
name=name,
company=company,
email=email,
phone=phone,
source=source,
notes=notes,
status=status
)
# Ajouter le prospect
prospect_handler.add_prospect(new_prospect)
print(f"\nProspect '{name}' ajouté avec succès!")
def edit_prospect(prospect_handler):
"""Modifie un prospect existant"""
clear_screen()
print("=== Modification d'un Prospect ===\n")
# Afficher tous les prospects pour sélection
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
for i, prospect in enumerate(prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company}) - {prospect.status}")
# Sélectionner un prospect à modifier
choice = input("\nEntrez le numéro du prospect à modifier (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(prospects):
print("Opération annulée ou sélection invalide.")
return
prospect = prospects[int(choice) - 1]
# Afficher les détails actuels
print(f"\nModification de '{prospect.name}':")
print(f"1. Nom: {prospect.name}")
print(f"2. Entreprise: {prospect.company}")
print(f"3. Email: {prospect.email}")
print(f"4. Téléphone: {prospect.phone}")
print(f"5. Source: {prospect.source}")
print(f"6. Statut: {prospect.status}")
print(f"7. Notes: {prospect.notes}")
print("8. Tout modifier")
print("9. Annuler")
# Choisir ce qu'il faut modifier
edit_choice = input("\nQue souhaitez-vous modifier (1-9): ")
if edit_choice == "9":
print("Modification annulée.")
return
if edit_choice == "1" or edit_choice == "8":
prospect.name = input(f"Nouveau nom [{prospect.name}]: ") or prospect.name
if edit_choice == "2" or edit_choice == "8":
prospect.company = input(f"Nouvelle entreprise [{prospect.company}]: ") or prospect.company
if edit_choice == "3" or edit_choice == "8":
prospect.email = input(f"Nouvel email [{prospect.email}]: ") or prospect.email
if edit_choice == "4" or edit_choice == "8":
prospect.phone = input(f"Nouveau téléphone [{prospect.phone}]: ") or prospect.phone
if edit_choice == "5" or edit_choice == "8":
prospect.source = input(f"Nouvelle source [{prospect.source}]: ") or prospect.source
if edit_choice == "6" or edit_choice == "8":
print("\nStatut du prospect:")
print("1. Nouveau")
print("2. Contacté")
print("3. Qualifié")
print("4. Proposition envoyée")
print("5. Non intéressé")
status_choice = input(f"\nSélectionnez le statut (1-5) [Actuel: {prospect.status}]: ")
if status_choice == "1":
prospect.status = "Nouveau"
elif status_choice == "2":
prospect.status = "Contacté"
elif status_choice == "3":
prospect.status = "Qualifié"
elif status_choice == "4":
prospect.status = "Proposition envoyée"
elif status_choice == "5":
prospect.status = "Non intéressé"
if edit_choice == "7" or edit_choice == "8":
prospect.notes = input(f"Nouvelles notes [{prospect.notes}]: ") or prospect.notes
# Mettre à jour le prospect
prospect_handler.update_prospect(prospect)
print(f"\nProspect '{prospect.name}' mis à jour avec succès!")
def delete_prospect(prospect_handler):
"""Supprime un prospect"""
clear_screen()
print("=== Suppression d'un Prospect ===\n")
# Afficher tous les prospects pour sélection
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
for i, prospect in enumerate(prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company}) - {prospect.status}")
# Sélectionner un prospect à supprimer
choice = input("\nEntrez le numéro du prospect à supprimer (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(prospects):
print("Opération annulée ou sélection invalide.")
return
prospect = prospects[int(choice) - 1]
# Confirmer la suppression
confirm = input(f"Êtes-vous sûr de vouloir supprimer '{prospect.name}'? (o/n): ")
if confirm.lower() != 'o':
print("Suppression annulée.")
return
# Supprimer le prospect
prospect_handler.delete_prospect(prospect.id)
print(f"\nProspect '{prospect.name}' supprimé avec succès!")
def convert_prospect_to_client(prospect_handler, client_handler):
"""Convertit un prospect en client"""
clear_screen()
print("=== Conversion d'un Prospect en Client ===\n")
# Afficher tous les prospects pour sélection
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
for i, prospect in enumerate(prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company}) - {prospect.status}")
# Sélectionner un prospect à convertir
choice = input("\nEntrez le numéro du prospect à convertir en client (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(prospects):
print("Opération annulée ou sélection invalide.")
return
prospect = prospects[int(choice) - 1]
# Confirmer la conversion
confirm = input(f"Êtes-vous sûr de vouloir convertir '{prospect.name}' en client? (o/n): ")
if confirm.lower() != 'o':
print("Conversion annulée.")
return
# Convertir le prospect en client
client = prospect_handler.convert_to_client(prospect.id, client_handler)
if client:
print(f"\nProspect '{prospect.name}' converti en client avec succès!")
else:
print("\nErreur lors de la conversion du prospect en client.")
def display_prospect_details(prospect):
"""Affiche les détails d'un prospect"""
clear_screen()
print(f"=== Détails du Prospect: {prospect.name} ===\n")
print(f"ID: {prospect.id}")
print(f"Nom: {prospect.name}")
print(f"Entreprise: {prospect.company}")
print(f"Email: {prospect.email}")
print(f"Téléphone: {prospect.phone}")
print(f"Source: {prospect.source}")
print(f"Statut: {prospect.status}")
print(f"Notes: {prospect.notes}")
print(f"Dernière interaction: {prospect.last_contact}")
print(f"Prochaine action: {prospect.next_action}")
if prospect.tags:
print(f"Tags: {', '.join(prospect.tags)}")
def display_prospects_by_status(prospect_handler):
"""Affiche les prospects regroupés par statut"""
clear_screen()
print("=== Prospects par Statut ===\n")
# Récupérer tous les prospects
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
# Regrouper les prospects par statut
status_groups = {}
for prospect in prospects:
status = prospect.status
if status not in status_groups:
status_groups[status] = []
status_groups[status].append(prospect)
# Afficher les prospects par groupe de statut
for status, prospects_list in status_groups.items():
print(f"\n=== {status} ({len(prospects_list)}) ===")
for i, prospect in enumerate(prospects_list, 1):
print(f"{i}. {prospect.name} ({prospect.company})")
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
print("\n")
# Option pour filtrer par un statut spécifique
print("Voulez-vous voir les détails d'un statut spécifique?")
print("1. Nouveau")
print("2. Contacté")
print("3. Qualifié")
print("4. Proposition envoyée")
print("5. Non intéressé")
print("6. Retour")
choice = input("\nSélectionnez une option (1-6): ")
status_mapping = {
"1": "Nouveau",
"2": "Contacté",
"3": "Qualifié",
"4": "Proposition envoyée",
"5": "Non intéressé"
}
if choice in status_mapping:
selected_status = status_mapping[choice]
filtered_prospects = prospect_handler.get_prospects_by_status(selected_status)
if filtered_prospects:
clear_screen()
print(f"=== Prospects avec statut: {selected_status} ===\n")
for i, prospect in enumerate(filtered_prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company})")
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
print(f" Dernière interaction: {prospect.last_contact}")
print(f" Notes: {prospect.notes[:50]}..." if len(prospect.notes) > 50 else f" Notes: {prospect.notes}")
print("")
# Option pour voir les détails d'un prospect spécifique
detail_choice = input("\nEntrez le numéro du prospect pour voir les détails (ou Entrée pour annuler): ")
if detail_choice.isdigit() and 1 <= int(detail_choice) <= len(filtered_prospects):
prospect = filtered_prospects[int(detail_choice) - 1]
display_prospect_details(prospect)
else:
print(f"\nAucun prospect avec le statut '{selected_status}' trouvé.")
if __name__ == "__main__":
main()

54
modules/crm/client.py Normal file
View file

@ -0,0 +1,54 @@
from datetime import date
import uuid
from datetime import date
import uuid
class Client:
def __init__(
self,
name: str,
company: str = "",
email: str = "",
phone: str = "",
notes: str = "",
tags: list = None,
last_contact: str = None,
next_action: str = "",
linked_docs: dict = None,
id: str = None,
created_at: str = None
):
self.id = id or f"cli_{uuid.uuid4().hex[:8]}"
self.name = name
self.company = company
self.email = email
self.phone = phone
self.notes = notes
self.tags = tags or []
self.last_contact = last_contact or str(date.today())
self.next_action = next_action
self.linked_docs = linked_docs or {"devis": [], "propositions": [], "factures": []}
self.created_at = created_at or str(date.today())
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"company": self.company,
"email": self.email,
"phone": self.phone,
"notes": self.notes,
"tags": self.tags,
"last_contact": self.last_contact,
"next_action": self.next_action,
"linked_docs": self.linked_docs,
"created_at": self.created_at
}
@staticmethod
def from_dict(data: dict):
return Client(**data)
def __str__(self):
return f"Client({self.name}, {self.company}, {self.email}, {self.phone})"

172
modules/crm/handler.py Normal file
View file

@ -0,0 +1,172 @@
from datetime import date
import os
import json
from .client import Client
from core.data import Data
class ClientHandler:
def __init__(self, clients_folder="Data/clients"):
self.clients_folder = clients_folder
self.clients = []
self.load_clients()
def load_clients(self):
"""Charge tous les clients depuis les fichiers existants"""
self.clients = []
# Charger les clients depuis les fichiers JSON existants
if os.path.exists(self.clients_folder):
for filename in os.listdir(self.clients_folder):
if filename.endswith('.json'):
client_path = os.path.join(self.clients_folder, filename)
try:
# Utiliser le gestionnaire de données pour charger le fichier JSON
data_manager = Data(client_path)
client_data = data_manager.load_data()
# Si le fichier a la structure d'un client, créer un objet Client
if "client_name" in client_data:
# Conversion du format existant vers le format Client
client_obj = {
"name": client_data.get("client_name", ""),
"email": client_data.get("client_email", ""),
"phone": client_data.get("client_phone", ""),
"notes": client_data.get("additional_info", ""),
"company": client_data.get("project_name", ""),
"tags": [], # Pas de tags dans les données existantes
"next_action": "", # Pas d'action suivante dans les données existantes
"linked_docs": self._find_linked_docs(client_data.get("client_name", ""))
}
client = Client(**client_obj)
self.clients.append(client)
except Exception as e:
print(f"Erreur lors du chargement du client {filename}: {e}")
return self.clients
def _find_linked_docs(self, client_name):
"""Trouve les documents associés à un client"""
linked_docs = {"devis": [], "propositions": [], "factures": []}
client_name_normalized = client_name.replace(" ", "_").lower()
# Rechercher les devis
devis_folder = "output/devis"
if os.path.exists(devis_folder):
for filename in os.listdir(devis_folder):
if filename.startswith(client_name_normalized) and filename.endswith(".pdf"):
linked_docs["devis"].append(os.path.join(devis_folder, filename))
# Rechercher les propositions
propositions_folder = "output/propositions"
if os.path.exists(propositions_folder):
for filename in os.listdir(propositions_folder):
if filename.startswith(client_name_normalized) and filename.endswith(".pdf"):
linked_docs["propositions"].append(os.path.join(propositions_folder, filename))
# Rechercher les factures (si elles existent)
factures_folder = "output/factures"
if os.path.exists(factures_folder):
for filename in os.listdir(factures_folder):
if filename.startswith(client_name_normalized) and filename.endswith(".pdf"):
linked_docs["factures"].append(os.path.join(factures_folder, filename))
return linked_docs
def get_all_clients(self):
"""Retourne tous les clients"""
return self.clients
def get_client_by_id(self, client_id):
"""Retourne un client par son ID"""
for client in self.clients:
if client.id == client_id:
return client
return None
def get_client_by_name(self, client_name):
"""Retourne un client par son nom"""
for client in self.clients:
if client.name.lower() == client_name.lower():
return client
return None
def add_client(self, client):
"""Ajoute un nouveau client"""
self.clients.append(client)
# Sauvegarder le client dans un fichier JSON
self._save_client(client)
return client
def update_client(self, client):
"""Met à jour un client existant"""
for i, existing_client in enumerate(self.clients):
if existing_client.id == client.id:
self.clients[i] = client
# Sauvegarder le client mis à jour
self._save_client(client)
return client
return None
def delete_client(self, client_id):
"""Supprime un client"""
for i, client in enumerate(self.clients):
if client.id == client_id:
deleted_client = self.clients.pop(i)
# Supprimer le fichier JSON du client
client_file = os.path.join(self.clients_folder, f"{deleted_client.name.replace(' ', '_').lower()}.json")
if os.path.exists(client_file):
os.remove(client_file)
return deleted_client
return None
def _save_client(self, client):
"""Sauvegarde un client dans un fichier JSON"""
client_data = {
"client_name": client.name,
"client_email": client.email,
"client_phone": client.phone,
"additional_info": client.notes,
"project_name": client.company,
# Autres champs pour maintenir la compatibilité avec le format existant
"project_type": "",
"project_description": "",
"features": [],
"budget": "",
"payment_terms": "",
"contact_info": f"{client.name}, {client.phone}",
"deadline": ""
}
client_file = os.path.join(self.clients_folder, f"{client.name.replace(' ', '_').lower()}.json")
data_manager = Data(client_file)
data_manager.save_data(client_data)
return client_file
def add_task_for_client(self, client_id: str, title: str, due_date: str, description: str = "", priority: str = "normale", metadata: dict = None):
"""
Crée une tâche liée à un client.
:param client_id: ID du client
:param title: Titre de la tâche
:param due_date: Date d'échéance au format ISO (YYYY-MM-DD)
:param description: Description optionnelle
:param priority: 'basse' | 'normale' | 'haute'
:param metadata: métadonnées optionnelles
:return: ID de la tâche créée ou None si client introuvable
"""
client = self.get_client_by_id(client_id)
if not client:
return None
# Import local pour éviter dépendances circulaires au chargement
from modules.tasks.task import Task
from modules.tasks.task_handler import TaskHandler
task = Task(
title=title,
due_date=due_date,
description=description,
entity_type="client",
entity_id=client_id,
priority=priority,
metadata=metadata or {},
)
return TaskHandler().add_task(task)

95
modules/crm/prospect.py Normal file
View file

@ -0,0 +1,95 @@
from datetime import date
import uuid
from typing import Union
from .status import ProspectStatus
class Prospect:
def __init__(
self,
name: str,
company: str = "",
email: str = "",
phone: str = "",
source: str = "", # D'où vient ce prospect (site web, référence, etc.)
notes: str = "",
status: Union[ProspectStatus, str] = ProspectStatus.NOUVEAU, # Nouveau, Contacté, Qualifié, Proposition, Non intéressé
tags: list = None,
last_contact: str = None,
next_action: str = "",
linked_docs: dict = None,
id: str = None,
created_at: str = None,
score: int = 0,
last_interaction: str = None
):
self.id = id or f"pros_{uuid.uuid4().hex[:8]}"
self.name = name
self.company = company
self.email = email
self.phone = phone
self.source = source
self.notes = notes
# Stockage interne en Enum
self._status: ProspectStatus = ProspectStatus.from_value(status)
self.tags = tags or []
self.last_contact = last_contact or str(date.today())
self.next_action = next_action
self.linked_docs = linked_docs or {"propositions": []}
self.created_at = created_at or str(date.today())
self.score = score or 0
self.last_interaction = last_interaction
@property
def status(self) -> str:
"""Exposition publique du statut en chaîne pour compatibilité (templates, JSON)."""
return self._status.value
@status.setter
def status(self, value: Union[ProspectStatus, str]) -> None:
"""Permet d'assigner soit une chaîne, soit l'enum directement."""
self._status = ProspectStatus.from_value(value)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"company": self.company,
"email": self.email,
"phone": self.phone,
"source": self.source,
"notes": self.notes,
"status": self.status, # sérialise en chaîne
"tags": self.tags,
"last_contact": self.last_contact,
"next_action": self.next_action,
"linked_docs": self.linked_docs,
"created_at": self.created_at,
"score": self.score,
"last_interaction": self.last_interaction
}
@staticmethod
def from_dict(data: dict):
# __init__ gère la conversion status -> Enum
return Prospect(**data)
def convert_to_client(self):
"""Convertit ce prospect en client"""
from .client import Client
return Client(
name=self.name,
company=self.company,
email=self.email,
phone=self.phone,
notes=self.notes,
tags=self.tags,
last_contact=self.last_contact,
next_action=self.next_action,
linked_docs=self.linked_docs
)
def __str__(self):
return f"Prospect({self.name}, {self.company}, {self.status}, {self.email})"

View file

@ -0,0 +1,157 @@
from datetime import date
import os
import json
from .prospect import Prospect
from core.data import Data
class ProspectHandler:
def __init__(self, prospects_folder="Data/prospects"):
self.prospects_folder = prospects_folder
self.prospects = []
# Créer le dossier des prospects s'il n'existe pas
if not os.path.exists(self.prospects_folder):
os.makedirs(self.prospects_folder)
self.load_prospects()
def load_prospects(self):
"""Charge tous les prospects depuis les fichiers existants"""
self.prospects = []
# Charger les prospects depuis les fichiers JSON existants
if os.path.exists(self.prospects_folder):
for filename in os.listdir(self.prospects_folder):
if filename.endswith('.json'):
prospect_path = os.path.join(self.prospects_folder, filename)
try:
# Utiliser le gestionnaire de données pour charger le fichier JSON
data_manager = Data(prospect_path)
prospect_data = data_manager.load_data()
# Si le fichier a la structure d'un prospect, créer un objet Prospect
if "name" in prospect_data:
prospect = Prospect.from_dict(prospect_data)
self.prospects.append(prospect)
except Exception as e:
print(f"Erreur lors du chargement du prospect {filename}: {e}")
return self.prospects
def get_all_prospects(self):
"""Retourne tous les prospects"""
return self.prospects
def get_prospect_by_id(self, prospect_id):
"""Retourne un prospect par son ID"""
for prospect in self.prospects:
if prospect.id == prospect_id:
return prospect
return None
def get_prospect_by_name(self, prospect_name):
"""Retourne un prospect par son nom"""
for prospect in self.prospects:
if prospect.name.lower() == prospect_name.lower():
return prospect
return None
def add_prospect(self, prospect):
"""Ajoute un nouveau prospect"""
self.prospects.append(prospect)
# Sauvegarder le prospect dans un fichier JSON
self._save_prospect(prospect)
return prospect
def update_prospect(self, prospect):
"""Met à jour un prospect existant"""
for i, existing_prospect in enumerate(self.prospects):
if existing_prospect.id == prospect.id:
self.prospects[i] = prospect
# Sauvegarder le prospect mis à jour
self._save_prospect(prospect)
return prospect
return None
def delete_prospect(self, prospect_id):
"""Supprime un prospect"""
for i, prospect in enumerate(self.prospects):
if prospect.id == prospect_id:
deleted_prospect = self.prospects.pop(i)
# Supprimer le fichier JSON du prospect
prospect_file = os.path.join(self.prospects_folder, f"{deleted_prospect.id}.json")
if os.path.exists(prospect_file):
os.remove(prospect_file)
return deleted_prospect
return None
def convert_to_client(self, prospect_id, client_handler):
"""Convertit un prospect en client et le supprime de la liste des prospects"""
prospect = self.get_prospect_by_id(prospect_id)
if prospect:
# Créer un client à partir du prospect
client = prospect.convert_to_client()
# Ajouter le client
client_handler.add_client(client)
# Supprimer le prospect
self.delete_prospect(prospect_id)
return client
return None
def _save_prospect(self, prospect):
"""Sauvegarde un prospect dans un fichier JSON"""
prospect_data = prospect.to_dict()
prospect_file = os.path.join(self.prospects_folder, f"{prospect.id}.json")
data_manager = Data(prospect_file)
data_manager.save_data(prospect_data)
return prospect_file
def get_prospects_by_status(self, status):
"""Retourne les prospects par statut"""
return [p for p in self.prospects if p.status.lower() == status.lower()]
def get_recent_prospects(self, days=30):
"""Retourne les prospects créés dans les X derniers jours"""
today = date.today()
recent_prospects = []
for prospect in self.prospects:
try:
created_date = date.fromisoformat(prospect.created_at)
delta = today - created_date
if delta.days <= days:
recent_prospects.append(prospect)
except ValueError:
# Si la date n'est pas au format ISO, ignorer ce prospect
pass
return recent_prospects
def add_task_for_prospect(self, prospect_id: str, title: str, due_date: str, description: str = "", priority: str = "normale", metadata: dict = None):
"""
Crée une tâche liée à un prospect.
:param prospect_id: ID du prospect
:param title: Titre de la tâche
:param due_date: Date d'échéance au format ISO (YYYY-MM-DD)
:param description: Description optionnelle
:param priority: 'basse' | 'normale' | 'haute'
:param metadata: métadonnées optionnelles
:return: ID de la tâche créée ou None si prospect introuvable
"""
prospect = self.get_prospect_by_id(prospect_id)
if not prospect:
return None
from modules.tasks.task import Task
from modules.tasks.task_handler import TaskHandler
task = Task(
title=title,
due_date=due_date,
description=description,
entity_type="prospect",
entity_id=prospect_id,
priority=priority,
metadata=metadata or {},
)
return TaskHandler().add_task(task)

52
modules/crm/scoring.py Normal file
View file

@ -0,0 +1,52 @@
from datetime import date
from typing import Literal
from modules.crm.prospect_handler import ProspectHandler
def categorize(score: int) -> Literal["Froid", "Tiède", "Chaud"]:
if score is None:
score = 0
if score >= 50:
return "Chaud"
if score >= 20:
return "Tiède"
return "Froid"
def adjust_score(prospect_id: str, delta: int) -> int:
"""
Ajuste le score d'un prospect et met à jour la dernière interaction.
Retourne le score à jour.
"""
handler = ProspectHandler()
p = handler.get_prospect_by_id(prospect_id)
if not p:
return 0
try:
current = int(getattr(p, "score", 0) or 0)
except Exception:
current = 0
new_score = max(0, current + int(delta))
setattr(p, "score", new_score)
setattr(p, "last_interaction", date.today().isoformat())
# On ne change pas le statut ici, sauf règle spécifique (reply)
handler.update_prospect(p)
return new_score
def adjust_score_for_event(prospect_id: str, event: Literal["open", "click", "reply"]) -> int:
if event == "open":
return adjust_score(prospect_id, 10)
if event == "click":
return adjust_score(prospect_id, 20)
if event == "reply":
score = adjust_score(prospect_id, 30)
# Règle: un reply passe le prospect en statut "Chaud"
handler = ProspectHandler()
p = handler.get_prospect_by_id(prospect_id)
if p:
p.status = "Chaud"
handler.update_prospect(p)
return score
return adjust_score(prospect_id, 0)

181
modules/crm/search.py Normal file
View file

@ -0,0 +1,181 @@
import json
import os
from datetime import datetime
from typing import List, Dict, Any, Optional
def _project_root() -> str:
# modules/crm/search.py -> remonter à la racine projet
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _prospects_dir() -> str:
return os.path.join(_project_root(), "Data", "prospects")
def _read_json(path: str) -> Optional[Dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _norm_date(date_str: Optional[str]) -> Optional[str]:
if not date_str:
return None
# Tente de normaliser vers YYYY-MM-DD
s = str(date_str)
# Si déjà au format ISO ou contient "T"
if len(s) >= 10:
return s[:10]
return None
def _extract_fields(data: Dict[str, Any], fallback_id: str) -> Dict[str, Any]:
# Essaye plusieurs variantes de clés
name = data.get("name") or data.get("nom") or data.get("full_name") or data.get("contact_name") or ""
email = data.get("email") or data.get("mail") or ""
company = data.get("company") or data.get("societe") or data.get("entreprise") or data.get("organization") or ""
status = data.get("status") or data.get("statut") or ""
city = data.get("city") or data.get("ville") or ""
tags = data.get("tags") or []
if isinstance(tags, str):
# transformer "a, b, c" -> ["a","b","c"]
tags = [t.strip() for t in tags.split(",") if t.strip()]
created_at = data.get("created_at") or data.get("created") or data.get("date_created") or ""
created_at = _norm_date(created_at) or ""
pid = data.get("id") or fallback_id
# Liaisons potentielles
client_id = data.get("client_id") or data.get("clientId") or data.get("client")
project_ids = data.get("project_ids") or data.get("projects") or data.get("projectId") or []
if isinstance(project_ids, (str, int)):
project_ids = [str(project_ids)]
elif isinstance(project_ids, list):
project_ids = [str(x) for x in project_ids]
return {
"id": pid,
"entity_type": "prospect",
"name": name,
"email": email,
"company": company,
"status": status,
"city": city,
"tags": tags,
"created_at": created_at,
"client_id": client_id,
"project_ids": project_ids,
}
def load_all_prospects() -> List[Dict[str, Any]]:
base_dir = _prospects_dir()
os.makedirs(base_dir, exist_ok=True)
results: List[Dict[str, Any]] = []
for fn in os.listdir(base_dir):
if not fn.endswith(".json"):
continue
path = os.path.join(base_dir, fn)
data = _read_json(path)
if not data:
continue
pid = os.path.splitext(fn)[0]
results.append(_extract_fields(data, pid))
return results
def _contains(hay: str, needle: str) -> bool:
return needle in hay
def _safe_lower(s: Optional[str]) -> str:
return (s or "").lower()
def filter_prospects(
*,
q: Optional[str] = None,
name: Optional[str] = None,
email: Optional[str] = None,
company: Optional[str] = None,
status: Optional[str] = None,
city: Optional[str] = None,
tags: Optional[List[str]] = None,
date_from: Optional[str] = None, # YYYY-MM-DD
date_to: Optional[str] = None, # YYYY-MM-DD
) -> List[Dict[str, Any]]:
items = load_all_prospects()
q = _safe_lower(q)
name = _safe_lower(name)
email = _safe_lower(email)
company = _safe_lower(company)
status = _safe_lower(status)
city = _safe_lower(city)
tags = [t.strip().lower() for t in (tags or []) if t.strip()]
df = None
dt = None
try:
if date_from:
df = datetime.strptime(date_from, "%Y-%m-%d").date()
except Exception:
df = None
try:
if date_to:
dt = datetime.strptime(date_to, "%Y-%m-%d").date()
except Exception:
dt = None
out: List[Dict[str, Any]] = []
for it in items:
iname = _safe_lower(it.get("name"))
iemail = _safe_lower(it.get("email"))
icompany = _safe_lower(it.get("company"))
istatus = _safe_lower(it.get("status"))
icity = _safe_lower(it.get("city"))
itags = [str(t).lower() for t in (it.get("tags") or [])]
icreated = it.get("created_at") or ""
icreated_d = None
try:
if icreated:
icreated_d = datetime.strptime(icreated[:10], "%Y-%m-%d").date()
except Exception:
icreated_d = None
# Filtre global q
if q and not (
_contains(iname, q) or _contains(iemail, q) or _contains(icompany, q) or _contains(icity, q)
):
continue
# Filtres spécifiques
if name and not _contains(iname, name):
continue
if email and not _contains(iemail, email):
continue
if company and not _contains(icompany, company):
continue
if status and istatus != status:
continue
if city and not _contains(icity, city):
continue
if tags:
# exiger que tous les tags demandés soient présents
itags_set = set(itags)
if not all(tag in itags_set for tag in tags):
continue
if df and (not icreated_d or icreated_d < df):
continue
if dt and (not icreated_d or icreated_d > dt):
continue
out.append(it)
# On peut trier par date de création desc, puis nom
out.sort(key=lambda d: (d.get("created_at") or "", d.get("name") or ""), reverse=True)
return out

51
modules/crm/status.py Normal file
View file

@ -0,0 +1,51 @@
import unicodedata
from enum import Enum
from typing import Union, Optional
def _normalize_no_accents(s: str) -> str:
"""Normalise une chaîne en supprimant les accents et en la passant en minuscule."""
return "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c)).lower()
class ProspectStatus(str, Enum):
NOUVEAU = "Nouveau"
CONTACTE = "Contacté"
RELANCE = "Relancé"
QUALIFIE = "Qualifié"
PROPOSITION = "Proposition"
NON_INTERESSE = "Non intéressé"
@classmethod
def from_value(cls, value: Optional[Union["ProspectStatus", str]]) -> "ProspectStatus":
"""Convertit une valeur (enum ou chaîne) en ProspectStatus, avec tolérance sur la casse/accents."""
if isinstance(value, cls):
return value
if value is None:
return cls.NOUVEAU
s = str(value).strip()
if not s:
return cls.NOUVEAU
n = _normalize_no_accents(s)
if n in {"nouveau", "new"}:
return cls.NOUVEAU
if n in {"contacte", "contacted"}:
return cls.CONTACTE
if n in {"relance", "relaunched"}:
return cls.RELANCE
if n in {"qualifie", "qualified"}:
return cls.QUALIFIE
if n in {"proposition", "proposal"}:
return cls.PROPOSITION
if n in {"non interesse", "noninteresse", "not interested", "refuse", "refus", "abandon"}:
return cls.NON_INTERESSE
# Tentative de correspondance exacte insensible aux accents/casse avec les valeurs de l'enum
for member in cls:
if _normalize_no_accents(member.value) == n:
return member
# Valeur inconnue -> par défaut
return cls.NOUVEAU

78
modules/crm/web.py Normal file
View file

@ -0,0 +1,78 @@
from flask import Blueprint, request, jsonify, url_for
from typing import List
from modules.crm.search import filter_prospects
crm_api_bp = Blueprint("crm_api", __name__, url_prefix="/api/crm")
@crm_api_bp.get("/prospects/search")
def prospects_search():
"""
API de recherche/filtre prospects.
Paramètres (query string):
- q: recherche globale (nom, email, société, ville)
- name, email, company, status, city: filtres spécifiques (contient, sauf status: égal insensible à la casse)
- tags: liste séparée par des virgules (tous doivent être présents)
- date_from, date_to: période sur created_at (YYYY-MM-DD)
"""
q = request.args.get("q")
name = request.args.get("name")
email = request.args.get("email")
company = request.args.get("company")
status = request.args.get("status")
city = request.args.get("city")
tags_raw = request.args.get("tags") or ""
tags: List[str] = [t.strip() for t in tags_raw.split(",")] if tags_raw else []
date_from = request.args.get("date_from") or None
date_to = request.args.get("date_to") or None
results = filter_prospects(
q=q,
name=name,
email=email,
company=company,
status=status,
city=city,
tags=tags,
date_from=date_from,
date_to=date_to,
)
# Enrichit avec des URLs navigables
enriched = []
for it in results:
new_it = dict(it)
# URL principale (prospect)
try:
new_it["url"] = url_for("prospect_details", prospect_id=it.get("id"))
except Exception:
pass
related = []
cid = it.get("client_id")
if cid:
try:
related.append({"type": "client", "url": url_for("client_details", client_id=cid)})
except Exception:
pass
pids = it.get("project_ids") or []
for pid in pids:
try:
related.append({"type": "project", "url": url_for("project_details", project_id=pid)})
except Exception:
pass
if related:
new_it["related"] = related
# S'assure que le type est présent
new_it["entity_type"] = new_it.get("entity_type") or "prospect"
enriched.append(new_it)
return jsonify(
{
"count": len(enriched),
"results": enriched,
}
)