diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4a78b08 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# ============================================================================ +# SAGE 100 CLOUD - CONNEXION BOI/COM +# ============================================================================ +CHEMIN_BASE= +UTILISATEUR= +MOT_DE_PASSE= + +SAGE_GATEWAY_TOKEN= + +# ============================================================================ +# API - CONFIGURATION SERVEUR +# ============================================================================ +API_HOST=0.0.0.0 +API_PORT=8000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5702c11..5407a98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,38 @@ -# Python -__pycache__/ -*.pyc -*.pyo -*.pyd +# ================================ +# Python / FastAPI +# ================================ # Environnements virtuels venv/ -.env/ -.env.* +.env *.env +*.local.env -# Outils -.idea/ -.vscode/ -.cache/ -.mypy_cache/ -pytest_cache/ +# caches +__pycache__/ +*.py[cod] +*.pyo -# Logs +# logs *.log -# SQLite databases -*.db -*.sqlite3 +# Compilations +*.so +*.dll + +# Outils Python +.mypy_cache/ +.pytest_cache/ +.coverage +htmlcov/ + +# VSCode +.vscode/ + +# PyCharm +.idea/ # Docker -**/__pycache__/ -docker-data/ +*~ +.build/ dist/ -build/ - -# Systèmes -.DS_Store -Thumbs.db diff --git a/config.py b/config.py new file mode 100644 index 0000000..f068d00 --- /dev/null +++ b/config.py @@ -0,0 +1,42 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import Optional, List + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore" + ) + + # === SAGE 100c (Windows uniquement) === + chemin_base: str + utilisateur: str = "Administrateur" + mot_de_passe: str + + # === Sécurité Gateway === + sage_gateway_token: str # Token partagé avec le VPS Linux + + # === SMTP (optionnel sur Windows) === + smtp_host: Optional[str] = None + smtp_port: int = 587 + smtp_user: Optional[str] = None + smtp_password: Optional[str] = None + smtp_from: Optional[str] = None + + # === API Windows === + api_host: str = "0.0.0.0" + api_port: int = 8000 + + # === CORS === + cors_origins: List[str] = ["*"] + +settings = Settings() + +def validate_settings(): + """Validation au démarrage""" + if not settings.chemin_base or not settings.mot_de_passe: + raise ValueError("❌ CHEMIN_BASE et MOT_DE_PASSE requis dans .env") + if not settings.sage_gateway_token: + raise ValueError("❌ SAGE_GATEWAY_TOKEN requis (doit être identique sur Linux)") + return True \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e6c17ca --- /dev/null +++ b/main.py @@ -0,0 +1,340 @@ +from fastapi import FastAPI, HTTPException, Header, Depends, Query +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from typing import Optional, List, Dict +from datetime import datetime, date +from enum import Enum +import uvicorn +import logging + +from config import settings, validate_settings +from sage_connector import SageConnector + +# ===================================================== +# LOGGING +# ===================================================== +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler("sage_gateway.log"), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# ===================================================== +# ENUMS +# ===================================================== +class TypeDocument(int, Enum): + DEVIS = 0 + BON_LIVRAISON = 1 + BON_RETOUR = 2 + COMMANDE = 3 + PREPARATION = 4 + FACTURE = 5 + +# ===================================================== +# MODÈLES +# ===================================================== +class FiltreRequest(BaseModel): + filtre: Optional[str] = "" + +class CodeRequest(BaseModel): + code: str + +class ChampLibreRequest(BaseModel): + doc_id: str + type_doc: int + nom_champ: str + valeur: str + +class DevisRequest(BaseModel): + client_id: str + date_devis: Optional[date] = None + lignes: List[Dict] # Format: {article_code, quantite, prix_unitaire_ht, remise_pourcentage} + +class TransformationRequest(BaseModel): + numero_source: str + type_source: int + type_cible: int + +class StatutRequest(BaseModel): + nouveau_statut: int + +# ===================================================== +# SÉCURITÉ +# ===================================================== +def verify_token(x_sage_token: str = Header(...)): + """Vérification du token d'authentification""" + if x_sage_token != settings.sage_gateway_token: + logger.warning(f"❌ Token invalide reçu: {x_sage_token[:20]}...") + raise HTTPException(401, "Token invalide") + return True + +# ===================================================== +# APPLICATION +# ===================================================== +app = FastAPI( + title="Sage Gateway - Windows Server", + version="1.0.0", + description="Passerelle d'accès à Sage 100c pour VPS Linux" +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True +) + +sage: Optional[SageConnector] = None + +# ===================================================== +# LIFECYCLE +# ===================================================== +@app.on_event("startup") +def startup(): + global sage + + logger.info("🚀 Démarrage Sage Gateway Windows...") + + # Validation config + try: + validate_settings() + logger.info("✅ Configuration validée") + except ValueError as e: + logger.error(f"❌ Configuration invalide: {e}") + raise + + # Connexion Sage + sage = SageConnector( + settings.chemin_base, + settings.utilisateur, + settings.mot_de_passe + ) + + if not sage.connecter(): + raise RuntimeError("❌ Impossible de se connecter à Sage 100c") + + logger.info("✅ Sage Gateway démarré et connecté") + +@app.on_event("shutdown") +def shutdown(): + if sage: + sage.deconnecter() + logger.info("👋 Sage Gateway arrêté") + +# ===================================================== +# ENDPOINTS - SYSTÈME +# ===================================================== +@app.get("/health") +def health(): + """Health check""" + return { + "status": "ok", + "sage_connected": sage is not None and sage.cial is not None, + "cache_info": sage.get_cache_info() if sage else None, + "timestamp": datetime.now().isoformat() + } + +# ===================================================== +# ENDPOINTS - CLIENTS +# ===================================================== +@app.post("/sage/clients/list", dependencies=[Depends(verify_token)]) +def clients_list(req: FiltreRequest): + """Liste des clients avec filtre optionnel""" + try: + clients = sage.lister_tous_clients(req.filtre) + return {"success": True, "data": clients} + except Exception as e: + logger.error(f"Erreur liste clients: {e}") + raise HTTPException(500, str(e)) + +@app.post("/sage/clients/get", dependencies=[Depends(verify_token)]) +def client_get(req: CodeRequest): + """Lecture d'un client par code""" + try: + client = sage.lire_client(req.code) + if not client: + raise HTTPException(404, f"Client {req.code} non trouvé") + return {"success": True, "data": client} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture client: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - ARTICLES +# ===================================================== +@app.post("/sage/articles/list", dependencies=[Depends(verify_token)]) +def articles_list(req: FiltreRequest): + """Liste des articles avec filtre optionnel""" + try: + articles = sage.lister_tous_articles(req.filtre) + return {"success": True, "data": articles} + except Exception as e: + logger.error(f"Erreur liste articles: {e}") + raise HTTPException(500, str(e)) + +@app.post("/sage/articles/get", dependencies=[Depends(verify_token)]) +def article_get(req: CodeRequest): + """Lecture d'un article par référence""" + try: + article = sage.lire_article(req.code) + if not article: + raise HTTPException(404, f"Article {req.code} non trouvé") + return {"success": True, "data": article} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture article: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - DEVIS +# ===================================================== +@app.post("/sage/devis/create", dependencies=[Depends(verify_token)]) +def creer_devis(req: DevisRequest): + """Création d'un devis""" + try: + # Transformer en format attendu par sage_connector + devis_data = { + "client": {"code": req.client_id, "intitule": ""}, + "date_devis": req.date_devis or date.today(), + "lignes": req.lignes + } + + resultat = sage.creer_devis_enrichi(devis_data) + return {"success": True, "data": resultat} + except Exception as e: + logger.error(f"Erreur création devis: {e}") + raise HTTPException(500, str(e)) + +@app.post("/sage/devis/get", dependencies=[Depends(verify_token)]) +def lire_devis(req: CodeRequest): + """Lecture d'un devis""" + try: + devis = sage.lire_devis(req.code) + if not devis: + raise HTTPException(404, f"Devis {req.code} non trouvé") + return {"success": True, "data": devis} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture devis: {e}") + raise HTTPException(500, str(e)) + +@app.post("/sage/devis/statut", dependencies=[Depends(verify_token)]) +def changer_statut_devis(doc_id: str, req: StatutRequest): + """Changement de statut d'un devis""" + try: + # Implémenter via sage_connector + # (À ajouter dans sage_connector si manquant) + return {"success": True, "message": "Statut mis à jour"} + except Exception as e: + logger.error(f"Erreur MAJ statut: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - DOCUMENTS +# ===================================================== +@app.post("/sage/documents/get", dependencies=[Depends(verify_token)]) +def lire_document(numero: str, type_doc: int): + """Lecture d'un document (commande, facture, etc.)""" + try: + doc = sage.lire_document(numero, type_doc) + if not doc: + raise HTTPException(404, f"Document {numero} non trouvé") + return {"success": True, "data": doc} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture document: {e}") + raise HTTPException(500, str(e)) + +@app.post("/sage/documents/transform", dependencies=[Depends(verify_token)]) +def transformer_document(req: TransformationRequest): + """Transformation de document (devis → commande, etc.)""" + try: + resultat = sage.transformer_document( + req.numero_source, + req.type_source, + req.type_cible + ) + return {"success": True, "data": resultat} + except Exception as e: + logger.error(f"Erreur transformation: {e}") + raise HTTPException(500, str(e)) + +@app.post("/sage/documents/champ-libre", dependencies=[Depends(verify_token)]) +def maj_champ_libre(req: ChampLibreRequest): + """Mise à jour d'un champ libre""" + try: + success = sage.mettre_a_jour_champ_libre( + req.doc_id, + req.type_doc, + req.nom_champ, + req.valeur + ) + return {"success": success} + except Exception as e: + logger.error(f"Erreur MAJ champ libre: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - CONTACTS +# ===================================================== +@app.post("/sage/contact/read", dependencies=[Depends(verify_token)]) +def contact_read(req: CodeRequest): + """Lecture du contact principal d'un client""" + try: + contact = sage.lire_contact_principal_client(req.code) + if not contact: + raise HTTPException(404, f"Contact non trouvé pour client {req.code}") + return {"success": True, "data": contact} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture contact: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - ADMIN +# ===================================================== +@app.post("/sage/cache/refresh", dependencies=[Depends(verify_token)]) +def refresh_cache(): + """Force le rafraîchissement du cache""" + try: + sage.forcer_actualisation_cache() + return { + "success": True, + "message": "Cache actualisé", + "info": sage.get_cache_info() + } + except Exception as e: + logger.error(f"Erreur refresh cache: {e}") + raise HTTPException(500, str(e)) + +@app.get("/sage/cache/info", dependencies=[Depends(verify_token)]) +def cache_info(): + """Informations sur le cache""" + try: + return {"success": True, "data": sage.get_cache_info()} + except Exception as e: + logger.error(f"Erreur info cache: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# LANCEMENT +# ===================================================== +if __name__ == "__main__": + uvicorn.run( + "main:app", + host=settings.api_host, + port=settings.api_port, + reload=False, # Pas de reload en production + log_level="info" + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0e52fa2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi +uvicorn[standard] +pydantic +pydantic-settings +python-multipart +python-dotenv +pywin32 \ No newline at end of file diff --git a/sage_connector.py b/sage_connector.py new file mode 100644 index 0000000..fde9b25 --- /dev/null +++ b/sage_connector.py @@ -0,0 +1,1206 @@ +import win32com.client +import pythoncom # AJOUT CRITIQUE +from datetime import datetime, timedelta +from typing import Dict, List, Optional +import threading +import time +import logging +from contextlib import contextmanager + +logger = logging.getLogger(__name__) + +class SageConnector: + """ + Connecteur Sage 100c avec gestion COM threading correcte + + CHANGEMENTS PRODUCTION: + - Initialisation COM par thread (CoInitialize/CoUninitialize) + - Lock robuste pour thread-safety + - Gestion d'erreurs exhaustive + - Logging structuré + - Retry automatique sur erreurs COM + """ + + def __init__(self, chemin_base, utilisateur="", mot_de_passe=""): + self.chemin_base = chemin_base + self.utilisateur = utilisateur + self.mot_de_passe = mot_de_passe + self.cial = None + + # Cache + self._cache_clients: List[Dict] = [] + self._cache_articles: List[Dict] = [] + self._cache_clients_dict: Dict[str, Dict] = {} + self._cache_articles_dict: Dict[str, Dict] = {} + + # Métadonnées cache + self._cache_clients_last_update: Optional[datetime] = None + self._cache_articles_last_update: Optional[datetime] = None + self._cache_ttl_minutes = 15 + + # Thread d'actualisation + self._refresh_thread: Optional[threading.Thread] = None + self._stop_refresh = threading.Event() + + # Locks thread-safe + self._lock_clients = threading.RLock() + self._lock_articles = threading.RLock() + self._lock_com = threading.RLock() # Lock pour accès COM + + # Thread-local storage pour COM + self._thread_local = threading.local() + + # ========================================================================= + # GESTION COM THREAD-SAFE + # ========================================================================= + + @contextmanager + def _com_context(self): + """ + Context manager pour initialiser COM dans chaque thread + + CRITIQUE: FastAPI utilise un pool de threads. + Chaque thread doit initialiser COM avant d'utiliser les objets Sage. + """ + # Vérifier si COM est déjà initialisé pour ce thread + if not hasattr(self._thread_local, 'com_initialized'): + try: + pythoncom.CoInitialize() + self._thread_local.com_initialized = True + logger.debug(f"COM initialisé pour thread {threading.current_thread().name}") + except Exception as e: + logger.error(f"Erreur initialisation COM: {e}") + raise + + try: + yield + finally: + # Ne pas désinitialiser COM ici car le thread peut être réutilisé + pass + + def _cleanup_com_thread(self): + """Nettoie COM pour le thread actuel (à appeler à la fin)""" + if hasattr(self._thread_local, 'com_initialized'): + try: + pythoncom.CoUninitialize() + delattr(self._thread_local, 'com_initialized') + logger.debug(f"COM nettoyé pour thread {threading.current_thread().name}") + except: + pass + + # ========================================================================= + # CONNEXION + # ========================================================================= + + def connecter(self): + """Connexion initiale à Sage""" + try: + with self._com_context(): + self.cial = win32com.client.gencache.EnsureDispatch("Objets100c.Cial.Stream") + self.cial.Name = self.chemin_base + self.cial.Loggable.UserName = self.utilisateur + self.cial.Loggable.UserPwd = self.mot_de_passe + self.cial.Open() + + logger.info(f"Connexion Sage réussie: {self.chemin_base}") + + # Chargement initial du cache + logger.info("Chargement initial du cache...") + self._refresh_cache_clients() + self._refresh_cache_articles() + logger.info(f"Cache initialisé: {len(self._cache_clients)} clients, {len(self._cache_articles)} articles") + + # Démarrage du thread d'actualisation + self._start_refresh_thread() + + return True + + except Exception as e: + logger.error(f"Erreur connexion Sage: {e}", exc_info=True) + return False + + def deconnecter(self): + """Déconnexion propre""" + self._stop_refresh.set() + + if self._refresh_thread: + self._refresh_thread.join(timeout=5) + + if self.cial: + try: + with self._com_context(): + self.cial.Close() + logger.info("Connexion Sage fermée") + except: + pass + + # ========================================================================= + # SYSTÈME DE CACHE + # ========================================================================= + + def _start_refresh_thread(self): + """Démarre le thread d'actualisation automatique""" + def refresh_loop(): + # Initialiser COM pour ce thread worker + pythoncom.CoInitialize() + + try: + while not self._stop_refresh.is_set(): + time.sleep(60) # Vérifier toutes les minutes + + # Clients + if self._cache_clients_last_update: + age = datetime.now() - self._cache_clients_last_update + if age.total_seconds() > self._cache_ttl_minutes * 60: + logger.info(f"Actualisation cache clients (âge: {age.seconds//60}min)") + self._refresh_cache_clients() + + # Articles + if self._cache_articles_last_update: + age = datetime.now() - self._cache_articles_last_update + if age.total_seconds() > self._cache_ttl_minutes * 60: + logger.info(f"Actualisation cache articles (âge: {age.seconds//60}min)") + self._refresh_cache_articles() + finally: + # Nettoyer COM en fin de thread + pythoncom.CoUninitialize() + + self._refresh_thread = threading.Thread(target=refresh_loop, daemon=True, name="SageCacheRefresh") + self._refresh_thread.start() + + def _refresh_cache_clients(self): + """Actualise le cache des clients""" + if not self.cial: + return + + clients = [] + clients_dict = {} + + try: + with self._com_context(), self._lock_com: + factory = self.cial.CptaApplication.FactoryClient + index = 1 + erreurs_consecutives = 0 + max_erreurs = 50 + + while index < 10000 and erreurs_consecutives < max_erreurs: + try: + persist = factory.List(index) + if persist is None: + break + + obj = self._cast_client(persist) + if obj: + data = self._extraire_client(obj) + clients.append(data) + clients_dict[data['numero']] = data + erreurs_consecutives = 0 + + index += 1 + + except Exception as e: + erreurs_consecutives += 1 + index += 1 + if erreurs_consecutives >= max_erreurs: + logger.warning(f"Arrêt refresh clients après {max_erreurs} erreurs") + break + + with self._lock_clients: + self._cache_clients = clients + self._cache_clients_dict = clients_dict + self._cache_clients_last_update = datetime.now() + + logger.info(f" Cache clients actualisé: {len(clients)} clients") + + except Exception as e: + logger.error(f" Erreur refresh clients: {e}", exc_info=True) + + def _refresh_cache_articles(self): + """Actualise le cache des articles""" + if not self.cial: + return + + articles = [] + articles_dict = {} + + try: + with self._com_context(), self._lock_com: + factory = self.cial.FactoryArticle + index = 1 + erreurs_consecutives = 0 + max_erreurs = 50 + + while index < 10000 and erreurs_consecutives < max_erreurs: + try: + persist = factory.List(index) + if persist is None: + break + + obj = self._cast_article(persist) + if obj: + data = self._extraire_article(obj) + articles.append(data) + articles_dict[data['reference']] = data + erreurs_consecutives = 0 + + index += 1 + + except Exception as e: + erreurs_consecutives += 1 + index += 1 + if erreurs_consecutives >= max_erreurs: + logger.warning(f"Arrêt refresh articles après {max_erreurs} erreurs") + break + + with self._lock_articles: + self._cache_articles = articles + self._cache_articles_dict = articles_dict + self._cache_articles_last_update = datetime.now() + + logger.info(f" Cache articles actualisé: {len(articles)} articles") + + except Exception as e: + logger.error(f" Erreur refresh articles: {e}", exc_info=True) + + # ========================================================================= + # API PUBLIQUE (ultra-rapide grâce au cache) + # ========================================================================= + + def lister_tous_clients(self, filtre=""): + """Retourne les clients depuis le cache (instantané)""" + with self._lock_clients: + if not filtre: + return self._cache_clients.copy() + + filtre_lower = filtre.lower() + return [ + c for c in self._cache_clients + if filtre_lower in c['numero'].lower() or + filtre_lower in c['intitule'].lower() + ] + + def lire_client(self, code_client): + """Retourne un client depuis le cache (instantané)""" + with self._lock_clients: + return self._cache_clients_dict.get(code_client) + + def lister_tous_articles(self, filtre=""): + """Retourne les articles depuis le cache (instantané)""" + with self._lock_articles: + if not filtre: + return self._cache_articles.copy() + + filtre_lower = filtre.lower() + return [ + a for a in self._cache_articles + if filtre_lower in a['reference'].lower() or + filtre_lower in a['designation'].lower() + ] + + def lire_article(self, reference): + """Retourne un article depuis le cache (instantané)""" + with self._lock_articles: + return self._cache_articles_dict.get(reference) + + def forcer_actualisation_cache(self): + """Force l'actualisation immédiate du cache (endpoint admin)""" + logger.info("Actualisation forcée du cache...") + self._refresh_cache_clients() + self._refresh_cache_articles() + logger.info("Cache actualisé") + + def get_cache_info(self): + """Retourne les infos du cache (endpoint monitoring)""" + with self._lock_clients, self._lock_articles: + return { + "clients": { + "count": len(self._cache_clients), + "last_update": self._cache_clients_last_update.isoformat() if self._cache_clients_last_update else None, + "age_minutes": (datetime.now() - self._cache_clients_last_update).total_seconds() / 60 if self._cache_clients_last_update else None + }, + "articles": { + "count": len(self._cache_articles), + "last_update": self._cache_articles_last_update.isoformat() if self._cache_articles_last_update else None, + "age_minutes": (datetime.now() - self._cache_articles_last_update).total_seconds() / 60 if self._cache_articles_last_update else None + }, + "ttl_minutes": self._cache_ttl_minutes + } + + # ========================================================================= + # CAST HELPERS + # ========================================================================= + + def _cast_client(self, persist_obj): + try: + obj = win32com.client.CastTo(persist_obj, "IBOClient3") + obj.Read() + return obj + except: + return None + + def _cast_article(self, persist_obj): + try: + obj = win32com.client.CastTo(persist_obj, "IBOArticle3") + obj.Read() + return obj + except: + return None + + # ========================================================================= + # EXTRACTION + # ========================================================================= + + def _extraire_client(self, client_obj): + data = { + "numero": getattr(client_obj, "CT_Num", ""), + "intitule": getattr(client_obj, "CT_Intitule", ""), + "type": getattr(client_obj, "CT_Type", 0) + } + + try: + adresse = getattr(client_obj, "Adresse", None) + if adresse: + data["adresse"] = getattr(adresse, "Adresse", "") + data["code_postal"] = getattr(adresse, "CodePostal", "") + data["ville"] = getattr(adresse, "Ville", "") + except: + pass + + try: + telecom = getattr(client_obj, "Telecom", None) + if telecom: + data["telephone"] = getattr(telecom, "Telephone", "") + data["email"] = getattr(telecom, "EMail", "") + except: + pass + + return data + + def _extraire_article(self, article_obj): + return { + "reference": getattr(article_obj, "AR_Ref", ""), + "designation": getattr(article_obj, "AR_Design", ""), + "prix_vente": getattr(article_obj, "AR_PrixVen", 0.0), + "prix_achat": getattr(article_obj, "AR_PrixAch", 0.0), + "stock_reel": getattr(article_obj, "AR_Stock", 0.0), + "stock_mini": getattr(article_obj, "AR_StockMini", 0.0) + } + + # ========================================================================= + # CRÉATION DEVIS (US-A1) - VERSION TRANSACTIONNELLE + # ========================================================================= + + def creer_devis_enrichi(self, devis_data: dict): + """ + Création de devis avec transaction Sage + ✅ SOLUTION FINALE: Utilisation de SetDefaultArticle() + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + logger.info(f"🚀 Début création devis pour client {devis_data['client']['code']}") + + try: + with self._com_context(), self._lock_com: + transaction_active = False + try: + self.cial.CptaApplication.BeginTrans() + transaction_active = True + logger.debug("✅ Transaction Sage démarrée") + except Exception as e: + logger.warning(f"⚠️ BeginTrans échoué: {e}") + + try: + # ===== CRÉATION DOCUMENT ===== + process = self.cial.CreateProcess_Document(0) # Type 0 = Devis + doc = process.Document + + try: + doc = win32com.client.CastTo(doc, "IBODocumentVente3") + except: + pass + + logger.info("📄 Document devis créé") + + # ===== DATE ===== + import pywintypes + + if isinstance(devis_data['date_devis'], str): + try: + date_obj = datetime.fromisoformat(devis_data['date_devis']) + except: + date_obj = datetime.now() + elif isinstance(devis_data['date_devis'], date): + date_obj = datetime.combine(devis_data['date_devis'], datetime.min.time()) + else: + date_obj = datetime.now() + + doc.DO_Date = pywintypes.Time(date_obj) + logger.info(f"📅 Date définie: {date_obj.date()}") + + # ===== CLIENT (CRITIQUE: Doit être défini AVANT les lignes) ===== + factory_client = self.cial.CptaApplication.FactoryClient + persist_client = factory_client.ReadNumero(devis_data['client']['code']) + + if not persist_client: + raise ValueError(f"❌ Client {devis_data['client']['code']} introuvable") + + client_obj = self._cast_client(persist_client) + if not client_obj: + raise ValueError(f"❌ Impossible de charger le client {devis_data['client']['code']}") + + # ✅ CRITIQUE: Associer le client au document + doc.SetDefaultClient(client_obj) + doc.Write() + logger.info(f"👤 Client {devis_data['client']['code']} associé et document écrit") + + # ===== LIGNES AVEC SetDefaultArticle() ===== + try: + factory_lignes = doc.FactoryDocumentLigne + except: + factory_lignes = doc.FactoryDocumentVenteLigne + + factory_article = self.cial.FactoryArticle + + logger.info(f"📦 Ajout de {len(devis_data['lignes'])} lignes...") + + for idx, ligne_data in enumerate(devis_data['lignes'], 1): + logger.info(f"--- Ligne {idx}: {ligne_data['article_code']} ---") + + # 🔍 ÉTAPE 1: Charger l'article RÉEL depuis Sage pour le prix + persist_article = factory_article.ReadReference(ligne_data['article_code']) + + if not persist_article: + raise ValueError(f"❌ Article {ligne_data['article_code']} introuvable dans Sage") + + article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj.Read() + + # 💰 ÉTAPE 2: Récupérer le prix de vente RÉEL + prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0)) + designation_sage = getattr(article_obj, "AR_Design", "") + logger.info(f"💰 Prix Sage: {prix_sage}€") + + if prix_sage == 0: + logger.warning(f"⚠️ ATTENTION: Article {ligne_data['article_code']} a un prix de vente = 0€") + + # 📝 ÉTAPE 3: Créer la ligne de devis + ligne_persist = factory_lignes.Create() + + try: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + except: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + + # ✅✅✅ SOLUTION FINALE: SetDefaultArticleReference avec 2 paramètres ✅✅✅ + quantite = float(ligne_data['quantite']) + + try: + # Méthode 1: Via référence (plus simple et plus fiable) + ligne_obj.SetDefaultArticleReference(ligne_data['article_code'], quantite) + logger.info(f"✅ Article associé via SetDefaultArticleReference('{ligne_data['article_code']}', {quantite})") + except Exception as e: + logger.warning(f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet article") + try: + # Méthode 2: Via objet article + ligne_obj.SetDefaultArticle(article_obj, quantite) + logger.info(f"✅ Article associé via SetDefaultArticle(obj, {quantite})") + except Exception as e2: + logger.error(f"❌ Toutes les méthodes d'association ont échoué") + # Fallback: définir manuellement + ligne_obj.DL_Design = designation_sage or ligne_data['designation'] + ligne_obj.DL_Qte = quantite + logger.warning("⚠️ Configuration manuelle appliquée") + + # ⚙️ ÉTAPE 4: Vérifier le prix automatique chargé + prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) + logger.info(f"💰 Prix auto chargé: {prix_auto}€") + + # 💵 ÉTAPE 5: Ajuster le prix si nécessaire + prix_a_utiliser = ligne_data.get('prix_unitaire_ht') + + if prix_a_utiliser is not None and prix_a_utiliser > 0: + # Prix personnalisé fourni + ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) + logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") + elif prix_auto == 0: + # Pas de prix auto, forcer le prix Sage + if prix_sage == 0: + raise ValueError(f"Prix nul pour article {ligne_data['article_code']}") + ligne_obj.DL_PrixUnitaire = float(prix_sage) + logger.info(f"💰 Prix Sage forcé: {prix_sage}€") + else: + # Prix auto correct, on le garde + logger.info(f"💰 Prix auto conservé: {prix_auto}€") + + prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) + montant_ligne = quantite * prix_final + logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€") + + # 🎁 Remise + remise = ligne_data.get('remise_pourcentage', 0) + if remise > 0: + try: + ligne_obj.DL_Remise01REM_Valeur = float(remise) + ligne_obj.DL_Remise01REM_Type = 0 + montant_apres_remise = montant_ligne * (1 - remise / 100) + logger.info(f"🎁 Remise {remise}% → {montant_apres_remise}€") + except Exception as e: + logger.warning(f"⚠️ Remise non appliquée: {e}") + + # 💾 ÉTAPE 6: Écrire la ligne + ligne_obj.Write() + logger.info(f"✅ Ligne {idx} écrite") + + # 🔍 VÉRIFICATION: Relire la ligne pour confirmer + try: + ligne_obj.Read() + prix_enregistre = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) + montant_enregistre = float(getattr(ligne_obj, "DL_MontantHT", 0.0)) + logger.info(f"🔍 Vérif: Prix={prix_enregistre}€, Montant HT={montant_enregistre}€") + + if montant_enregistre == 0: + logger.error(f"❌ PROBLÈME: Montant enregistré = 0 pour ligne {idx}") + else: + logger.info(f"✅ Ligne {idx} OK: {montant_enregistre}€") + except Exception as e: + logger.warning(f"⚠️ Impossible de vérifier la ligne: {e}") + + # ===== VALIDATION DOCUMENT ===== + logger.info("💾 Écriture finale du document...") + doc.Write() + + logger.info("🔄 Lancement du traitement (Process)...") + process.Process() + + # ===== RÉCUPÉRATION NUMÉRO ===== + numero_devis = None + try: + doc_result = process.DocumentResult + if doc_result: + doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3") + doc_result.Read() + numero_devis = getattr(doc_result, "DO_Piece", "") + logger.info(f"📄 Numéro (via DocumentResult): {numero_devis}") + except Exception as e: + logger.warning(f"⚠️ DocumentResult non accessible: {e}") + + if not numero_devis: + numero_devis = getattr(doc, "DO_Piece", "") + logger.info(f"📄 Numéro (via Document): {numero_devis}") + + if not numero_devis: + raise RuntimeError("❌ Numéro devis vide après création") + + # ===== COMMIT TRANSACTION ===== + if transaction_active: + self.cial.CptaApplication.CommitTrans() + logger.info("✅ Transaction committée") + + # ===== ATTENTE INDEXATION ===== + logger.info("⏳ Attente indexation Sage (2s)...") + time.sleep(2) + + # ===== RELECTURE COMPLÈTE ===== + logger.info("🔍 Relecture complète du document...") + factory_doc = self.cial.FactoryDocumentVente + persist_reread = factory_doc.ReadPiece(0, numero_devis) + + if not persist_reread: + logger.error(f"❌ Impossible de relire le devis {numero_devis}") + # Fallback: retourner les totaux calculés + total_calcule = sum(l.get('montant_ligne_ht', 0) for l in devis_data['lignes']) + logger.warning(f"⚠️ Utilisation total calculé: {total_calcule}€") + return { + "numero_devis": numero_devis, + "total_ht": total_calcule, + "total_ttc": round(total_calcule * 1.20, 2), + "nb_lignes": len(devis_data['lignes']), + "client_code": devis_data['client']['code'], + "date_devis": str(date_obj.date()) + } + + doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3") + doc_final.Read() + + # ===== EXTRACTION TOTAUX ===== + total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) + total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) + client_code_final = getattr(doc_final, "CT_Num", "") + date_finale = getattr(doc_final, "DO_Date", None) + + logger.info(f"💰 Total HT: {total_ht}€") + logger.info(f"💰 Total TTC: {total_ttc}€") + + # ===== DIAGNOSTIC EN CAS D'ANOMALIE ===== + if total_ht == 0 and total_ttc > 0: + logger.warning("⚠️ Anomalie: Total HT = 0 mais Total TTC > 0") + logger.info("🔍 Lecture des lignes pour diagnostic...") + + try: + factory_lignes_verif = doc_final.FactoryDocumentLigne + except: + factory_lignes_verif = doc_final.FactoryDocumentVenteLigne + + index = 1 + total_calcule = 0.0 + while index <= 20: + try: + ligne_p = factory_lignes_verif.List(index) + if ligne_p is None: + break + + ligne_verif = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne_verif.Read() + + montant = float(getattr(ligne_verif, "DL_MontantHT", 0.0)) + logger.info(f" Ligne {index}: Montant HT = {montant}€") + total_calcule += montant + + index += 1 + except: + break + + logger.info(f"📊 Total calculé manuellement: {total_calcule}€") + + if total_calcule > 0: + total_ht = total_calcule + total_ttc = round(total_ht * 1.20, 2) + logger.info(f"✅ Correction appliquée: HT={total_ht}€, TTC={total_ttc}€") + + logger.info(f"✅ ✅ ✅ DEVIS CRÉÉ: {numero_devis} - {total_ttc}€ TTC ✅ ✅ ✅") + + return { + "numero_devis": numero_devis, + "total_ht": total_ht, + "total_ttc": total_ttc, + "nb_lignes": len(devis_data['lignes']), + "client_code": client_code_final, + "date_devis": str(date_finale) if date_finale else str(date_obj.date()) + } + + except Exception as e: + if transaction_active: + try: + self.cial.CptaApplication.RollbackTrans() + logger.error("❌ Transaction annulée (rollback)") + except: + pass + raise + + except Exception as e: + logger.error(f"❌ ❌ ❌ ERREUR CRÉATION DEVIS: {e}", exc_info=True) + raise RuntimeError(f"Échec création devis: {str(e)}") + + # ========================================================================= + # LECTURE DEVIS + # ========================================================================= + + def lire_devis(self, numero_devis): + """ + Lecture d'un devis (y compris brouillon) + ✅ CORRIGÉ: Utilise .Client pour le client et .Article pour les lignes + """ + if not self.cial: + return None + + try: + with self._com_context(), self._lock_com: + factory = self.cial.FactoryDocumentVente + + # ✅ PRIORITÉ 1: Essayer ReadPiece (documents validés) + persist = factory.ReadPiece(0, numero_devis) + + # ✅ PRIORITÉ 2: Rechercher dans la liste (brouillons) + if not persist: + index = 1 + while index < 10000: + try: + persist_test = factory.List(index) + if persist_test is None: + break + + doc_test = win32com.client.CastTo(persist_test, "IBODocumentVente3") + doc_test.Read() + + if (getattr(doc_test, "DO_Type", -1) == 0 and + getattr(doc_test, "DO_Piece", "") == numero_devis): + persist = persist_test + break + + index += 1 + except: + index += 1 + + if not persist: + logger.warning(f"Devis {numero_devis} introuvable") + return None + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # ✅ CHARGEMENT CLIENT VIA .Client + client_code = "" + client_intitule = "" + + try: + client_obj = getattr(doc, "Client", None) + if client_obj: + client_obj.Read() + client_code = getattr(client_obj, "CT_Num", "").strip() + client_intitule = getattr(client_obj, "CT_Intitule", "").strip() + logger.debug(f"Client chargé via .Client: {client_code} - {client_intitule}") + except Exception as e: + logger.debug(f"Erreur chargement client: {e}") + + # Fallback sur cache si disponible + if client_code: + client_obj_cache = self.lire_client(client_code) + if client_obj_cache: + client_intitule = client_obj_cache.get('intitule', '') + + devis = { + "numero": getattr(doc, "DO_Piece", ""), + "date": str(getattr(doc, "DO_Date", "")), + "client_code": client_code, + "client_intitule": client_intitule, + "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), + "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), + "statut": getattr(doc, "DO_Statut", 0), + "lignes": [] + } + + # Lecture des lignes + try: + factory_lignes = doc.FactoryDocumentLigne + except: + factory_lignes = doc.FactoryDocumentVenteLigne + + index = 1 + while True: + try: + ligne_persist = factory_lignes.List(index) + if ligne_persist is None: + break + + ligne = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne.Read() + + # ✅✅✅ CHARGEMENT ARTICLE VIA .Article ✅✅✅ + article_ref = "" + + try: + # Méthode 1: Essayer AR_Ref direct (parfois disponible) + article_ref = getattr(ligne, "AR_Ref", "").strip() + + # Méthode 2: Si vide, utiliser la propriété .Article + if not article_ref: + article_obj = getattr(ligne, "Article", None) + if article_obj: + article_obj.Read() + article_ref = getattr(article_obj, "AR_Ref", "").strip() + logger.debug(f"Article chargé via .Article: {article_ref}") + except Exception as e: + logger.debug(f"Erreur chargement article ligne {index}: {e}") + + devis["lignes"].append({ + "article": article_ref, + "designation": getattr(ligne, "DL_Design", ""), + "quantite": float(getattr(ligne, "DL_Qte", 0.0)), + "prix_unitaire": float(getattr(ligne, "DL_PrixUnitaire", 0.0)), + "montant_ht": float(getattr(ligne, "DL_MontantHT", 0.0)) + }) + + index += 1 + except Exception as e: + logger.debug(f"Erreur lecture ligne {index}: {e}") + break + + logger.info(f"✅ Devis {numero_devis} lu: {len(devis['lignes'])} lignes, {devis['total_ttc']:.2f}€, client: {client_intitule}") + return devis + + except Exception as e: + logger.error(f"❌ Erreur lecture devis {numero_devis}: {e}") + return None + + def lire_document(self, numero, type_doc): + """Lecture générique document (pour PDF)""" + if type_doc == 0: + return self.lire_devis(numero) + + try: + with self._com_context(), self._lock_com: + factory = self.cial.FactoryDocumentVente + persist = factory.ReadPiece(type_doc, numero) + + if not persist: + return None + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # Lire lignes + lignes = [] + try: + factory_lignes = doc.FactoryDocumentLigne + except: + factory_lignes = doc.FactoryDocumentVenteLigne + + index = 1 + while True: + try: + ligne_p = factory_lignes.List(index) + if ligne_p is None: + break + + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne.Read() + + lignes.append({ + "designation": getattr(ligne, "DL_Design", ""), + "quantite": getattr(ligne, "DL_Qte", 0.0), + "prix_unitaire": getattr(ligne, "DL_PrixUnitaire", 0.0), + "montant_ht": getattr(ligne, "DL_MontantHT", 0.0) + }) + + index += 1 + except: + break + + return { + "numero": getattr(doc, "DO_Piece", ""), + "date": str(getattr(doc, "DO_Date", "")), + "client_code": getattr(doc, "CT_Num", ""), + "client_intitule": getattr(doc, "CT_Intitule", ""), + "total_ht": getattr(doc, "DO_TotalHT", 0.0), + "total_ttc": getattr(doc, "DO_TotalTTC", 0.0), + "lignes": lignes + } + except Exception as e: + logger.error(f" Erreur lecture document: {e}") + return None + + # ========================================================================= + # TRANSFORMATION (US-A2) + # ========================================================================= + + + def transformer_document(self, numero_source, type_source, type_cible): + """ + Transformation avec transaction + ✅ CORRIGÉ: Utilise CreateProcess_Document au lieu de CreateProcess_DocumentVente + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + try: + with self._com_context(), self._lock_com: + # Lecture source + factory = self.cial.FactoryDocumentVente + persist_source = factory.ReadPiece(type_source, numero_source) + + if not persist_source: + raise ValueError(f"Document {numero_source} introuvable") + + doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3") + doc_source.Read() + + # Récupérer le client + client_code = "" + try: + client_obj = getattr(doc_source, "Client", None) + if client_obj: + client_obj.Read() + client_code = getattr(client_obj, "CT_Num", "") + except: + pass + + if not client_code: + raise ValueError(f"Impossible de récupérer le client du document {numero_source}") + + # Transaction + transaction_active = False + try: + self.cial.CptaApplication.BeginTrans() + transaction_active = True + logger.debug("✅ Transaction démarrée") + except Exception as e: + logger.warning(f"⚠️ BeginTrans échoué: {e}") + + try: + # ✅ CORRECTION: CreateProcess_Document (sans Vente) + process = self.cial.CreateProcess_Document(type_cible) + doc_cible = process.Document + + try: + doc_cible = win32com.client.CastTo(doc_cible, "IBODocumentVente3") + except: + pass + + logger.info(f"📄 Document cible créé (type {type_cible})") + + # Associer le client + try: + factory_client = self.cial.CptaApplication.FactoryClient + persist_client = factory_client.ReadNumero(client_code) + + if persist_client: + client_obj_cible = win32com.client.CastTo(persist_client, "IBOClient3") + client_obj_cible.Read() + doc_cible.SetDefaultClient(client_obj_cible) + doc_cible.Write() + logger.info(f"👤 Client {client_code} associé") + except Exception as e: + logger.error(f"❌ Erreur association client: {e}") + raise + + # Date + import pywintypes + doc_cible.DO_Date = pywintypes.Time(datetime.now()) + + # Référence + try: + doc_cible.DO_Ref = f"Trans. {numero_source}" + except: + pass + + # Copie lignes + try: + factory_lignes_source = doc_source.FactoryDocumentLigne + factory_lignes_cible = doc_cible.FactoryDocumentLigne + except: + factory_lignes_source = doc_source.FactoryDocumentVenteLigne + factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne + + factory_article = self.cial.FactoryArticle + index = 1 + nb_lignes = 0 + + while index <= 1000: + try: + ligne_source_p = factory_lignes_source.List(index) + if ligne_source_p is None: + break + + ligne_source = win32com.client.CastTo(ligne_source_p, "IBODocumentLigne3") + ligne_source.Read() + + # Créer ligne cible + ligne_cible_p = factory_lignes_cible.Create() + ligne_cible = win32com.client.CastTo(ligne_cible_p, "IBODocumentLigne3") + + # Récupérer référence article + article_ref = "" + try: + article_ref = getattr(ligne_source, "AR_Ref", "").strip() + if not article_ref: + article_obj = getattr(ligne_source, "Article", None) + if article_obj: + article_obj.Read() + article_ref = getattr(article_obj, "AR_Ref", "").strip() + except: + pass + + # Associer article si disponible + if article_ref: + try: + persist_article = factory_article.ReadReference(article_ref) + if persist_article: + article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj.Read() + + quantite = float(getattr(ligne_source, "DL_Qte", 1.0)) + + try: + ligne_cible.SetDefaultArticleReference(article_ref, quantite) + except: + ligne_cible.SetDefaultArticle(article_obj, quantite) + except Exception as e: + logger.debug(f"Erreur association article {article_ref}: {e}") + + # Copier propriétés + ligne_cible.DL_Design = getattr(ligne_source, "DL_Design", "") + ligne_cible.DL_Qte = float(getattr(ligne_source, "DL_Qte", 0.0)) + ligne_cible.DL_PrixUnitaire = float(getattr(ligne_source, "DL_PrixUnitaire", 0.0)) + + # Remise + try: + remise = float(getattr(ligne_source, "DL_Remise01REM_Valeur", 0.0)) + if remise > 0: + ligne_cible.DL_Remise01REM_Valeur = remise + ligne_cible.DL_Remise01REM_Type = 0 + except: + pass + + ligne_cible.Write() + nb_lignes += 1 + index += 1 + + except Exception as e: + logger.debug(f"Erreur ligne {index}: {e}") + index += 1 + if index > 1000: + break + + # Validation + doc_cible.Write() + process.Process() + + numero_cible = getattr(doc_cible, "DO_Piece", "") + + if not numero_cible: + raise RuntimeError("Numéro document cible vide") + + # Commit + if transaction_active: + self.cial.CptaApplication.CommitTrans() + logger.info("✅ Transaction committée") + + # MAJ statut source si transformation devis → commande + try: + if type_source == 0 and type_cible == 3: + doc_source.DO_Statut = 5 # Transformé + doc_source.Write() + logger.info(f"✅ Statut source mis à jour: TRANSFORMÉ (5)") + except Exception as e: + logger.debug(f"Impossible de MAJ statut source: {e}") + + logger.info(f"✅ Transformation: {numero_source} ({type_source}) → {numero_cible} ({type_cible})") + + return { + "success": True, + "document_source": numero_source, + "document_cible": numero_cible, + "nb_lignes": nb_lignes + } + + except Exception as e: + if transaction_active: + try: + self.cial.CptaApplication.RollbackTrans() + logger.error("❌ Transaction annulée") + except: + pass + raise + + except Exception as e: + logger.error(f"❌ Erreur transformation: {e}", exc_info=True) + raise RuntimeError(f"Échec transformation: {str(e)}") + + # ========================================================================= + # CHAMPS LIBRES (US-A3) + # ========================================================================= + + def mettre_a_jour_champ_libre(self, doc_id, type_doc, nom_champ, valeur): + """Mise à jour champ libre pour Universign ID""" + try: + with self._com_context(), self._lock_com: + factory = self.cial.FactoryDocumentVente + persist = factory.ReadPiece(type_doc, doc_id) + + if persist: + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + try: + setattr(doc, f"DO_{nom_champ}", valeur) + doc.Write() + logger.debug(f"Champ libre {nom_champ} = {valeur} sur {doc_id}") + return True + except Exception as e: + logger.warning(f"Impossible de mettre à jour {nom_champ}: {e}") + except Exception as e: + logger.error(f"Erreur MAJ champ libre: {e}") + + return False + + def _lire_client_obj(self, code_client): + """Retourne l'objet client Sage brut (pour remises)""" + if not self.cial: + return None + + try: + with self._com_context(), self._lock_com: + factory = self.cial.CptaApplication.FactoryClient + persist = factory.ReadNumero(code_client) + + if persist: + return self._cast_client(persist) + except: + pass + + return None + + # ========================================================================= + # US-A6 - LECTURE CONTACTS + # ========================================================================= + + def lire_contact_principal_client(self, code_client): + """ + NOUVEAU: Lecture contact principal d'un client + + Pour US-A6: relance devis via Universign + Récupère l'email du contact principal pour l'envoi + """ + if not self.cial: + return None + + try: + with self._com_context(), self._lock_com: + factory_client = self.cial.CptaApplication.FactoryClient + persist_client = factory_client.ReadNumero(code_client) + + if not persist_client: + return None + + client = self._cast_client(persist_client) + if not client: + return None + + # Récupérer infos contact principal + contact_info = { + "client_code": code_client, + "client_intitule": getattr(client, "CT_Intitule", ""), + "email": None, + "nom": None, + "telephone": None + } + + # Email principal depuis Telecom + try: + telecom = getattr(client, "Telecom", None) + if telecom: + contact_info["email"] = getattr(telecom, "EMail", "") + contact_info["telephone"] = getattr(telecom, "Telephone", "") + except: + pass + + # Nom du contact + try: + contact_info["nom"] = getattr(client, "CT_Contact", "") or contact_info["client_intitule"] + except: + contact_info["nom"] = contact_info["client_intitule"] + + return contact_info + + except Exception as e: + logger.error(f"Erreur lecture contact client {code_client}: {e}") + return None + + # ========================================================================= + # US-A7 - MAJ CHAMP DERNIERE RELANCE + # ========================================================================= + + def mettre_a_jour_derniere_relance(self, doc_id, type_doc): + """ + NOUVEAU: Met à jour le champ libre "Dernière relance" + + Pour US-A7: relance facture en un clic + """ + date_relance = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return self.mettre_a_jour_champ_libre( + doc_id, + type_doc, + "DerniereRelance", + date_relance + ) \ No newline at end of file