import win32com.client import pythoncom # AJOUT CRITIQUE from datetime import datetime, timedelta, date from typing import Dict, List, Optional import threading import time import logging from contextlib import contextmanager from config import settings, validate_settings 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 # Caches existants 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 existantes 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 self._lock_clients = threading.RLock() self._lock_articles = threading.RLock() self._lock_com = threading.RLock() # 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() # 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(): pythoncom.CoInitialize() try: while not self._stop_refresh.is_set(): time.sleep(60) # Clients if self._cache_clients_last_update: age = datetime.now() - self._cache_clients_last_update if age.total_seconds() > self._cache_ttl_minutes * 60: 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: self._refresh_cache_articles() finally: 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 Charge TOUS les tiers (CT_Type=0 ET CT_Type=1) """ 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 logger.info("🔄 Actualisation cache clients et prospects...") 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) # ✅ INCLURE TOUS LES TYPES (clients, prospects) 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() # 📊 Statistiques détaillées nb_clients = sum(1 for c in clients if c.get("type") == 0 and not c.get("est_prospect")) nb_prospects = sum(1 for c in clients if c.get("type") == 0 and c.get("est_prospect")) logger.info( f"✅ Cache actualisé: {len(clients)} tiers " f"({nb_clients} clients, {nb_prospects} prospects)" ) 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) def lister_tous_fournisseurs(self, filtre=""): """ ✅ CORRECTION FINALE : Liste fournisseurs SANS passer par _extraire_client() BYPASS TOTAL de _extraire_client() car : - Les objets fournisseurs n'ont pas les mêmes champs que les clients - _extraire_client() plante sur "CT_Qualite" (n'existe pas sur fournisseurs) - Le diagnostic fournisseurs-analyse-complete fonctionne SANS _extraire_client() → On fait EXACTEMENT comme le diagnostic qui marche """ if not self.cial: logger.error("❌ self.cial est None") return [] fournisseurs = [] try: with self._com_context(), self._lock_com: logger.info(f"🔍 Lecture fournisseurs via FactoryFournisseur (filtre='{filtre}')") factory = self.cial.CptaApplication.FactoryFournisseur index = 1 max_iterations = 10000 erreurs_consecutives = 0 max_erreurs = 50 filtre_lower = filtre.lower() if filtre else "" while index < max_iterations and erreurs_consecutives < max_erreurs: try: persist = factory.List(index) if persist is None: logger.debug(f"Fin de liste à l'index {index}") break # Cast fourn = self._cast_client(persist) if fourn: # ✅✅✅ EXTRACTION DIRECTE (pas de _extraire_client) ✅✅✅ try: numero = getattr(fourn, "CT_Num", "").strip() intitule = getattr(fourn, "CT_Intitule", "").strip() if not numero: logger.debug(f"Index {index}: CT_Num vide, skip") erreurs_consecutives += 1 index += 1 continue # Construction objet minimal data = { "numero": numero, "intitule": intitule, "type": 1, # Fournisseur "est_fournisseur": True } # Champs optionnels (avec gestion d'erreur) try: adresse_obj = getattr(fourn, "Adresse", None) if adresse_obj: data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip() data["ville"] = getattr(adresse_obj, "Ville", "").strip() except: data["adresse"] = "" data["code_postal"] = "" data["ville"] = "" try: telecom_obj = getattr(fourn, "Telecom", None) if telecom_obj: data["telephone"] = getattr(telecom_obj, "Telephone", "").strip() data["email"] = getattr(telecom_obj, "EMail", "").strip() except: data["telephone"] = "" data["email"] = "" # Filtrer si nécessaire if not filtre_lower or \ filtre_lower in numero.lower() or \ filtre_lower in intitule.lower(): fournisseurs.append(data) logger.debug(f"✅ Fournisseur ajouté: {numero} - {intitule}") erreurs_consecutives = 0 except Exception as e: logger.debug(f"⚠️ Erreur extraction index {index}: {e}") erreurs_consecutives += 1 else: erreurs_consecutives += 1 index += 1 except Exception as e: logger.debug(f"⚠️ Erreur index {index}: {e}") erreurs_consecutives += 1 index += 1 if erreurs_consecutives >= max_erreurs: logger.warning(f"⚠️ Arrêt après {max_erreurs} erreurs consécutives") break logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés") return fournisseurs except Exception as e: logger.error(f"❌ Erreur liste fournisseurs: {e}", exc_info=True) return [] def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: """ ✅ Crée un nouveau fournisseur dans Sage 100c via FactoryFournisseur IMPORTANT: Utilise FactoryFournisseur.Create() et NON FactoryClient.Create() car les fournisseurs sont gérés séparément dans Sage. Args: fournisseur_data: Dictionnaire contenant: - intitule (obligatoire): Raison sociale - compte_collectif (défaut: "401000"): Compte général - num (optionnel): Code fournisseur personnalisé - adresse, code_postal, ville, pays - email, telephone - siret, tva_intra Returns: Dict contenant le fournisseur créé avec son numéro définitif Raises: ValueError: Si le fournisseur existe déjà ou données invalides RuntimeError: Si erreur technique Sage """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 0 : VALIDATION & NETTOYAGE # ======================================== logger.info("🔍 === VALIDATION DONNÉES FOURNISSEUR ===") if not fournisseur_data.get("intitule"): raise ValueError("Le champ 'intitule' est obligatoire") # Nettoyage et troncature (longueurs max Sage) intitule = str(fournisseur_data["intitule"])[:69].strip() num_prop = str(fournisseur_data.get("num", "")).upper()[:17].strip() if fournisseur_data.get("num") else "" compte = str(fournisseur_data.get("compte_collectif", "401000"))[:13].strip() adresse = str(fournisseur_data.get("adresse", ""))[:35].strip() code_postal = str(fournisseur_data.get("code_postal", ""))[:9].strip() ville = str(fournisseur_data.get("ville", ""))[:35].strip() pays = str(fournisseur_data.get("pays", ""))[:35].strip() telephone = str(fournisseur_data.get("telephone", ""))[:21].strip() email = str(fournisseur_data.get("email", ""))[:69].strip() siret = str(fournisseur_data.get("siret", ""))[:14].strip() tva_intra = str(fournisseur_data.get("tva_intra", ""))[:25].strip() logger.info(f" intitule: '{intitule}' (len={len(intitule)})") logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})") logger.info(f" compte: '{compte}' (len={len(compte)})") # ======================================== # ÉTAPE 1 : CRÉATION OBJET FOURNISSEUR # ======================================== # 🔑 CRITIQUE: Utiliser FactoryFournisseur, PAS FactoryClient ! factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur persist = factory_fournisseur.Create() fournisseur = win32com.client.CastTo(persist, "IBOFournisseur3") # 🔑 CRITIQUE : Initialiser l'objet fournisseur.SetDefault() logger.info("✅ Objet fournisseur créé et initialisé") # ======================================== # ÉTAPE 2 : CHAMPS OBLIGATOIRES # ======================================== logger.info("📝 Définition des champs obligatoires...") # 1. Intitulé (OBLIGATOIRE) fournisseur.CT_Intitule = intitule logger.debug(f" ✅ CT_Intitule: '{intitule}'") # 2. Type = Fournisseur (1) # ⚠️ NOTE: Sur certaines versions Sage, CT_Type n'existe pas # et le type est automatiquement défini par la factory utilisée try: fournisseur.CT_Type = 1 # 1 = Fournisseur logger.debug(" ✅ CT_Type: 1 (Fournisseur)") except: logger.debug(" ⚠️ CT_Type non défini (géré par FactoryFournisseur)") # 3. Qualité (pour versions récentes Sage) try: fournisseur.CT_Qualite = "FOU" logger.debug(" ✅ CT_Qualite: 'FOU'") except: logger.debug(" ⚠️ CT_Qualite non défini (pas critique)") # 4. Compte général principal (OBLIGATOIRE) try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte) if persist_compte: compte_obj = win32com.client.CastTo(persist_compte, "IBOCompteG3") compte_obj.Read() # Assigner l'objet CompteG fournisseur.CompteGPrinc = compte_obj logger.debug(f" ✅ CompteGPrinc: objet '{compte}' assigné") else: logger.warning(f" ⚠️ Compte {compte} introuvable - utilisation défaut") except Exception as e: logger.warning(f" ⚠️ Erreur CompteGPrinc: {e}") # 5. Numéro fournisseur (OBLIGATOIRE - générer si vide) if num_prop: fournisseur.CT_Num = num_prop logger.debug(f" ✅ CT_Num fourni: '{num_prop}'") else: # 🔑 CRITIQUE : Générer le numéro automatiquement try: # Méthode 1 : SetDefaultNumPiece (si disponible) if hasattr(fournisseur, 'SetDefaultNumPiece'): fournisseur.SetDefaultNumPiece() num_genere = getattr(fournisseur, "CT_Num", "") logger.debug(f" ✅ CT_Num auto-généré: '{num_genere}'") else: # Méthode 2 : GetNextNumero depuis la factory num_genere = factory_fournisseur.GetNextNumero() if num_genere: fournisseur.CT_Num = num_genere logger.debug(f" ✅ CT_Num auto (GetNextNumero): '{num_genere}'") else: # Méthode 3 : Fallback - timestamp import time num_genere = f"FOUR{int(time.time()) % 1000000}" fournisseur.CT_Num = num_genere logger.warning(f" ⚠️ CT_Num fallback: '{num_genere}'") except Exception as e: logger.error(f" ❌ Impossible de générer CT_Num: {e}") raise ValueError("Impossible de générer le numéro fournisseur automatiquement") # 6. Catégories (valeurs par défaut) try: if hasattr(fournisseur, 'N_CatTarif'): fournisseur.N_CatTarif = 1 if hasattr(fournisseur, 'N_CatCompta'): fournisseur.N_CatCompta = 1 if hasattr(fournisseur, 'N_Period'): fournisseur.N_Period = 1 logger.debug(" ✅ Catégories (N_*) initialisées") except Exception as e: logger.warning(f" ⚠️ Catégories: {e}") # ======================================== # ÉTAPE 3 : CHAMPS OPTIONNELS # ======================================== logger.info("📝 Définition champs optionnels...") # Adresse (objet IAdresse) if any([adresse, code_postal, ville, pays]): try: adresse_obj = fournisseur.Adresse if adresse: adresse_obj.Adresse = adresse if code_postal: adresse_obj.CodePostal = code_postal if ville: adresse_obj.Ville = ville if pays: adresse_obj.Pays = pays logger.debug(" ✅ Adresse définie") except Exception as e: logger.warning(f" ⚠️ Adresse: {e}") # Télécom (objet ITelecom) if telephone or email: try: telecom_obj = fournisseur.Telecom if telephone: telecom_obj.Telephone = telephone if email: telecom_obj.EMail = email logger.debug(" ✅ Télécom défini") except Exception as e: logger.warning(f" ⚠️ Télécom: {e}") # Identifiants fiscaux if siret: try: fournisseur.CT_Siret = siret logger.debug(f" ✅ SIRET: '{siret}'") except Exception as e: logger.warning(f" ⚠️ SIRET: {e}") if tva_intra: try: fournisseur.CT_Identifiant = tva_intra logger.debug(f" ✅ TVA intra: '{tva_intra}'") except Exception as e: logger.warning(f" ⚠️ TVA: {e}") # Options par défaut try: if hasattr(fournisseur, 'CT_Lettrage'): fournisseur.CT_Lettrage = True if hasattr(fournisseur, 'CT_Sommeil'): fournisseur.CT_Sommeil = False logger.debug(" ✅ Options par défaut définies") except Exception as e: logger.debug(f" ⚠️ Options: {e}") # ======================================== # ÉTAPE 4 : VÉRIFICATION PRÉ-WRITE # ======================================== logger.info("🔍 === DIAGNOSTIC PRÉ-WRITE ===") num_avant_write = getattr(fournisseur, "CT_Num", "") if not num_avant_write: logger.error("❌ CRITIQUE: CT_Num toujours vide !") raise ValueError("Le numéro fournisseur (CT_Num) est obligatoire") logger.info(f"✅ CT_Num confirmé: '{num_avant_write}'") # ======================================== # ÉTAPE 5 : ÉCRITURE EN BASE # ======================================== logger.info("💾 Écriture du fournisseur dans Sage...") try: fournisseur.Write() logger.info("✅ Write() réussi !") except Exception as e: error_detail = str(e) # Récupérer l'erreur Sage détaillée try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = f"{sage_error.Description} (Code: {sage_error.Number})" logger.error(f"❌ Erreur Sage: {error_detail}") except: pass # Analyser l'erreur if "doublon" in error_detail.lower() or "existe" in error_detail.lower(): raise ValueError(f"Ce fournisseur existe déjà : {error_detail}") raise RuntimeError(f"Échec Write(): {error_detail}") # ======================================== # ÉTAPE 6 : RELECTURE & FINALISATION # ======================================== try: fournisseur.Read() except Exception as e: logger.warning(f"⚠️ Impossible de relire: {e}") num_final = getattr(fournisseur, "CT_Num", "") if not num_final: raise RuntimeError("CT_Num vide après Write()") logger.info(f"✅✅✅ FOURNISSEUR CRÉÉ: {num_final} - {intitule} ✅✅✅") # ======================================== # ÉTAPE 7 : CONSTRUCTION RÉPONSE # ======================================== resultat = { "numero": num_final, "intitule": intitule, "compte_collectif": compte, "type": 1, # Fournisseur "est_fournisseur": True, "adresse": adresse or None, "code_postal": code_postal or None, "ville": ville or None, "pays": pays or None, "email": email or None, "telephone": telephone or None, "siret": siret or None, "tva_intra": tva_intra or None } # ⚠️ PAS DE REFRESH CACHE ICI # Car lister_tous_fournisseurs() utilise FactoryFournisseur.List() # qui lit directement depuis Sage (pas de cache) return resultat except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur création fournisseur: {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage: {error_message}") def lire_fournisseur(self, code): """ ✅ NOUVEAU : Lecture d'un fournisseur par code Utilise FactoryFournisseur.ReadNumero() directement """ if not self.cial: return None try: with self._com_context(), self._lock_com: factory = self.cial.CptaApplication.FactoryFournisseur persist = factory.ReadNumero(code) if not persist: logger.warning(f"Fournisseur {code} introuvable") return None fourn = self._cast_client(persist) if not fourn: return None # Extraction directe (même logique que lister_tous_fournisseurs) numero = getattr(fourn, "CT_Num", "").strip() intitule = getattr(fourn, "CT_Intitule", "").strip() data = { "numero": numero, "intitule": intitule, "type": 1, "est_fournisseur": True } # Adresse try: adresse_obj = getattr(fourn, "Adresse", None) if adresse_obj: data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip() data["ville"] = getattr(adresse_obj, "Ville", "").strip() except: data["adresse"] = "" data["code_postal"] = "" data["ville"] = "" # Télécom try: telecom_obj = getattr(fourn, "Telecom", None) if telecom_obj: data["telephone"] = getattr(telecom_obj, "Telephone", "").strip() data["email"] = getattr(telecom_obj, "EMail", "").strip() except: data["telephone"] = "" data["email"] = "" logger.info(f"✅ Fournisseur {code} lu: {intitule}") return data except Exception as e: logger.error(f"❌ Erreur lecture fournisseur {code}: {e}") return None # ========================================================================= # 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é") logger.info("Cache actualisé") def get_cache_info(self): """Retourne les infos du cache (endpoint monitoring)""" with self._lock_clients: info = { "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 ), } } info["ttl_minutes"] = self._cache_ttl_minutes return info # ========================================================================= # CAST HELPERS # ========================================================================= def _cast_client(self, persist_obj): try: obj = win32com.client.CastTo(persist_obj, "IBOClient3") obj.Read() return obj except Exception as e: logger.debug(f"❌ _cast_client échoue: {e}") # ✅ AJOUTER CE LOG 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): """ ✅ CORRECTION : Extraction ULTRA-ROBUSTE pour clients ET fournisseurs Gère tous les cas où des champs peuvent être manquants """ try: # === 1. CHAMPS OBLIGATOIRES === try: numero = getattr(client_obj, "CT_Num", "").strip() if not numero: logger.debug("⚠️ Objet sans CT_Num, skip") return None except Exception as e: logger.debug(f"❌ Erreur lecture CT_Num: {e}") return None try: intitule = getattr(client_obj, "CT_Intitule", "").strip() if not intitule: logger.debug(f"⚠️ {numero} sans CT_Intitule") except Exception as e: logger.debug(f"⚠️ Erreur CT_Intitule sur {numero}: {e}") intitule = "" # === 2. CONSTRUCTION OBJET MINIMAL === data = { "numero": numero, "intitule": intitule, } # === 3. CHAMPS OPTIONNELS (avec try-except individuels) === # Type try: data["type"] = getattr(client_obj, "CT_Type", 0) except: data["type"] = 0 # Qualité try: qualite = getattr(client_obj, "CT_Qualite", None) data["qualite"] = qualite data["est_fournisseur"] = qualite in [2, 3] if qualite is not None else False except: data["qualite"] = None data["est_fournisseur"] = False # Prospect try: data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1 except: data["est_prospect"] = False # === 4. ADRESSE (non critique) === try: adresse = getattr(client_obj, "Adresse", None) if adresse: try: data["adresse"] = getattr(adresse, "Adresse", "").strip() except: data["adresse"] = "" try: data["code_postal"] = getattr(adresse, "CodePostal", "").strip() except: data["code_postal"] = "" try: data["ville"] = getattr(adresse, "Ville", "").strip() except: data["ville"] = "" except Exception as e: logger.debug(f"⚠️ Erreur adresse sur {numero}: {e}") data["adresse"] = "" data["code_postal"] = "" data["ville"] = "" # === 5. TELECOM (non critique) === try: telecom = getattr(client_obj, "Telecom", None) if telecom: try: data["telephone"] = getattr(telecom, "Telephone", "").strip() except: data["telephone"] = "" try: data["email"] = getattr(telecom, "EMail", "").strip() except: data["email"] = "" except Exception as e: logger.debug(f"⚠️ Erreur telecom sur {numero}: {e}") data["telephone"] = "" data["email"] = "" return data except Exception as e: logger.error(f"❌ ERREUR GLOBALE _extraire_client: {e}", exc_info=True) return None # ========================================================================= # 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 et lecture commandes/factures)""" 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() # ✅ Charger 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() except Exception as e: logger.debug(f"Erreur chargement client: {e}") # 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() # ✅ Charger article via .Article article_ref = "" try: article_ref = getattr(ligne, "AR_Ref", "").strip() if not article_ref: article_obj = getattr(ligne, "Article", None) if article_obj: article_obj.Read() article_ref = getattr(article_obj, "AR_Ref", "").strip() except: pass lignes.append( { "article": article_ref, # ✅ Ajout référence article "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", ""), "reference": getattr(doc, "DO_Ref", ""), # ✅ Ajout référence "date": str(getattr(doc, "DO_Date", "")), "client_code": client_code, "client_intitule": client_intitule, "total_ht": getattr(doc, "DO_TotalHT", 0.0), "total_ttc": getattr(doc, "DO_TotalTTC", 0.0), "statut": getattr(doc, "DO_Statut", 0), # ✅ Ajout statut "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 de document - MÉTHODE MANUELLE ⚠️ TransformInto() n'est pas disponible sur cette installation Sage → On crée manuellement le document cible en copiant les données """ if not self.cial: raise RuntimeError("Connexion Sage non etablie") type_source = int(type_source) type_cible = int(type_cible) logger.info( f"[TRANSFORM] Demande MANUELLE: {numero_source} " f"(type {type_source}) -> type {type_cible}" ) # ✅ Matrice de transformations transformations_autorisees = { (0, 10): "Devis -> Commande", (10, 30): "Commande -> Bon de livraison", (10, 60): "Commande -> Facture", (30, 60): "Bon de livraison -> Facture", (0, 60): "Devis -> Facture", } if (type_source, type_cible) not in transformations_autorisees: raise ValueError( f"Transformation non autorisee: {type_source} -> {type_cible}" ) try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : LIRE LE DOCUMENT SOURCE # ======================================== factory = self.cial.FactoryDocumentVente persist_source = factory.ReadPiece(type_source, numero_source) if not persist_source: persist_source = self._find_document_in_list( numero_source, type_source ) if not persist_source: raise ValueError( f"Document {numero_source} (type {type_source}) introuvable" ) doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3") doc_source.Read() # Vérifications statut statut_actuel = getattr(doc_source, "DO_Statut", 0) type_reel = getattr(doc_source, "DO_Type", -1) logger.info( f"[TRANSFORM] Source: type={type_reel}, statut={statut_actuel}" ) if type_reel != type_source: raise ValueError( f"Incoherence: document est de type {type_reel}, pas {type_source}" ) if statut_actuel == 5: raise ValueError("Document deja transforme (statut=5)") if statut_actuel == 6: raise ValueError("Document annule (statut=6)") if statut_actuel in [3, 4]: raise ValueError(f"Document deja realise (statut={statut_actuel})") # Forcer statut "Accepté" si devis brouillon if type_source == 0 and statut_actuel == 0: logger.info("[TRANSFORM] Passage devis a statut Accepte (2)") doc_source.DO_Statut = 2 doc_source.Write() doc_source.Read() # ======================================== # ÉTAPE 2 : EXTRAIRE LES DONNÉES SOURCE # ======================================== logger.info("[TRANSFORM] Extraction donnees source...") # Client client_code = "" client_obj = None try: client_obj = getattr(doc_source, "Client", None) if client_obj: client_obj.Read() client_code = getattr(client_obj, "CT_Num", "").strip() except Exception as e: logger.error(f"Erreur lecture client: {e}") raise ValueError(f"Impossible de lire le client du document source") if not client_code: raise ValueError("Client introuvable dans document source") logger.info(f"[TRANSFORM] Client: {client_code}") # Date date_source = getattr(doc_source, "DO_Date", None) # Lignes lignes_source = [] try: factory_lignes_source = getattr( doc_source, "FactoryDocumentLigne", None ) if not factory_lignes_source: factory_lignes_source = getattr( doc_source, "FactoryDocumentVenteLigne", None ) if factory_lignes_source: index = 1 while index <= 1000: try: ligne_p = factory_lignes_source.List(index) if ligne_p is None: break ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() # Récupérer référence article article_ref = "" try: article_ref = getattr(ligne, "AR_Ref", "").strip() if not article_ref: article_obj = getattr(ligne, "Article", None) if article_obj: article_obj.Read() article_ref = getattr( article_obj, "AR_Ref", "" ).strip() except: pass lignes_source.append( { "article_ref": article_ref, "designation": getattr(ligne, "DL_Design", ""), "quantite": float( getattr(ligne, "DL_Qte", 0.0) ), "prix_unitaire": float( getattr(ligne, "DL_PrixUnitaire", 0.0) ), "remise": float( getattr(ligne, "DL_Remise01REM_Valeur", 0.0) ), "type_remise": int( getattr(ligne, "DL_Remise01REM_Type", 0) ), } ) index += 1 except Exception as e: logger.debug(f"Erreur ligne {index}: {e}") break except Exception as e: logger.error(f"Erreur extraction lignes: {e}") raise ValueError( "Impossible d'extraire les lignes du document source" ) nb_lignes = len(lignes_source) logger.info(f"[TRANSFORM] {nb_lignes} lignes extraites") if nb_lignes == 0: raise ValueError("Document source vide (aucune ligne)") # ======================================== # ÉTAPE 3 : TRANSACTION # ======================================== transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("[TRANSFORM] Transaction demarree") except: logger.debug("[TRANSFORM] BeginTrans non disponible") try: # ======================================== # ÉTAPE 4 : CRÉER LE DOCUMENT CIBLE # ======================================== logger.info(f"[TRANSFORM] Creation document type {type_cible}...") process = self.cial.CreateProcess_Document(type_cible) if not process: raise RuntimeError( f"CreateProcess_Document({type_cible}) a retourne None" ) doc_cible = process.Document try: doc_cible = win32com.client.CastTo( doc_cible, "IBODocumentVente3" ) except: pass logger.info("[TRANSFORM] Document cible cree") # ======================================== # ÉTAPE 5 : DÉFINIR LA DATE # ======================================== import pywintypes if date_source: try: doc_cible.DO_Date = date_source logger.info(f"[TRANSFORM] Date copiee: {date_source}") except Exception as e: logger.warning(f"Impossible de copier date: {e}") doc_cible.DO_Date = pywintypes.Time(datetime.now()) else: doc_cible.DO_Date = pywintypes.Time(datetime.now()) # ======================================== # ÉTAPE 6 : ASSOCIER LE CLIENT # ======================================== logger.info(f"[TRANSFORM] Association client {client_code}...") factory_client = self.cial.CptaApplication.FactoryClient persist_client = factory_client.ReadNumero(client_code) if not persist_client: raise ValueError(f"Client {client_code} introuvable") client_obj_cible = self._cast_client(persist_client) if not client_obj_cible: raise ValueError(f"Impossible de charger client {client_code}") # ✅ Associer le client try: doc_cible.SetClient(client_obj_cible) logger.info( f"[TRANSFORM] SetClient() appele pour {client_code}" ) except Exception as e: logger.warning( f"[TRANSFORM] SetClient() echoue: {e}, tentative SetDefaultClient()" ) doc_cible.SetDefaultClient(client_obj_cible) doc_cible.Write() # Vérifier que le client est bien attaché doc_cible.Read() client_verifie = getattr(doc_cible, "CT_Num", None) if not client_verifie: # Dernière tentative : récupérer via la propriété Client try: client_test = getattr(doc_cible, "Client", None) if client_test: client_test.Read() client_verifie = getattr(client_test, "CT_Num", None) except: pass if not client_verifie: raise ValueError( f"Echec association client {client_code} - CT_Num reste vide apres Write()" ) logger.info( f"[TRANSFORM] Client {client_code} associe et verifie (CT_Num={client_verifie})" ) # 🔒 GARDER UNE RÉFÉRENCE À L'OBJET CLIENT POUR RÉASSOCIATION client_obj_sauvegarde = client_obj_cible # ======================================== # ÉTAPE 7 : COPIER LES LIGNES # ======================================== logger.info(f"[TRANSFORM] Copie de {nb_lignes} lignes...") try: factory_lignes_cible = doc_cible.FactoryDocumentLigne except: factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle for idx, ligne_data in enumerate(lignes_source, 1): logger.debug(f"[TRANSFORM] Ligne {idx}/{nb_lignes}") # Charger article article_ref = ligne_data["article_ref"] if not article_ref: logger.warning( f"Ligne {idx}: pas de reference article, skip" ) continue persist_article = factory_article.ReadReference(article_ref) if not persist_article: logger.warning( f"Ligne {idx}: article {article_ref} introuvable, skip" ) continue article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() # Créer ligne ligne_persist = factory_lignes_cible.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) # Associer article avec quantité quantite = ligne_data["quantite"] try: ligne_obj.SetDefaultArticleReference(article_ref, quantite) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: # Fallback manuel ligne_obj.DL_Design = ligne_data["designation"] ligne_obj.DL_Qte = quantite # Définir prix prix = ligne_data["prix_unitaire"] if prix > 0: ligne_obj.DL_PrixUnitaire = float(prix) # Copier remise remise = ligne_data["remise"] if remise > 0: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = ligne_data[ "type_remise" ] except: pass # Écrire ligne ligne_obj.Write() logger.info(f"[TRANSFORM] {nb_lignes} lignes copiees") # ======================================== # ÉTAPE 8 : COMPLÉTER LES CHAMPS OBLIGATOIRES POUR FACTURE # ======================================== if type_cible == 60: # Facture logger.info( "[TRANSFORM] Completion champs obligatoires facture..." ) # 1. Code journal try: journal = None try: journal = getattr(doc_source, "DO_CodeJournal", None) if journal: logger.info( f"[TRANSFORM] Journal source: {journal}" ) except: pass if not journal: journal = "VTE" logger.info("[TRANSFORM] Journal par defaut: VTE") if hasattr(doc_cible, "DO_CodeJournal"): doc_cible.DO_CodeJournal = journal logger.info( f"[TRANSFORM] Code journal defini: {journal}" ) else: logger.warning( "[TRANSFORM] DO_CodeJournal inexistant sur ce document" ) except Exception as e: logger.warning( f"[TRANSFORM] Impossible de definir code journal: {e}" ) # 2. Souche try: souche = getattr(doc_source, "DO_Souche", 0) if hasattr(doc_cible, "DO_Souche"): doc_cible.DO_Souche = souche logger.info(f"[TRANSFORM] Souche: {souche}") except Exception as e: logger.debug(f"[TRANSFORM] Souche non definie: {e}") # 3. Régime de TVA try: regime = getattr(doc_source, "DO_Regime", None) if regime is not None and hasattr(doc_cible, "DO_Regime"): doc_cible.DO_Regime = regime logger.info(f"[TRANSFORM] Regime TVA: {regime}") except Exception as e: logger.debug(f"[TRANSFORM] Regime TVA non defini: {e}") # 4. Type de transaction try: transaction = getattr(doc_source, "DO_Transaction", None) if transaction is not None and hasattr( doc_cible, "DO_Transaction" ): doc_cible.DO_Transaction = transaction logger.info(f"[TRANSFORM] Transaction: {transaction}") except Exception as e: logger.debug(f"[TRANSFORM] Transaction non definie: {e}") # 5. Domaine (Vente = 0) try: if hasattr(doc_cible, "DO_Domaine"): doc_cible.DO_Domaine = 0 logger.info("[TRANSFORM] Domaine: 0 (Vente)") except Exception as e: logger.debug(f"[TRANSFORM] Domaine non defini: {e}") # ======================================== # 🔒 RÉASSOCIER LE CLIENT AVANT VALIDATION # ======================================== logger.info("[TRANSFORM] Reassociation client avant validation...") try: doc_cible.SetClient(client_obj_sauvegarde) except: doc_cible.SetDefaultClient(client_obj_sauvegarde) # Écriture finale avec tous les champs complétés logger.info("[TRANSFORM] Ecriture document finale...") doc_cible.Write() # ======================================== # ÉTAPE 9 : VALIDER LE DOCUMENT # ======================================== logger.info("[TRANSFORM] Validation document cible...") # Relire pour vérifier doc_cible.Read() # Diagnostic pré-validation logger.info("[TRANSFORM] === PRE-VALIDATION CHECK ===") champs_a_verifier = [ "DO_Type", "CT_Num", "DO_Date", "DO_Souche", "DO_Statut", "DO_Regime", "DO_Transaction", ] for champ in champs_a_verifier: try: if hasattr(doc_cible, champ): valeur = getattr(doc_cible, champ, "?") logger.info(f" {champ}: {valeur}") except: pass # ✅ VÉRIFICATION CLIENT AMÉLIORÉE client_final = getattr(doc_cible, "CT_Num", None) if not client_final: try: client_obj_test = getattr(doc_cible, "Client", None) if client_obj_test: client_obj_test.Read() client_final = getattr(client_obj_test, "CT_Num", None) logger.info( f"[TRANSFORM] Client recupere via .Client: {client_final}" ) except: pass # Si toujours pas de client, dernière réassociation forcée if not client_final: logger.warning( "[TRANSFORM] Client perdu ! Tentative reassociation d'urgence..." ) try: doc_cible.SetClient(client_obj_sauvegarde) except: doc_cible.SetDefaultClient(client_obj_sauvegarde) doc_cible.Write() doc_cible.Read() client_final = getattr(doc_cible, "CT_Num", None) if not client_final: try: client_obj_test = getattr(doc_cible, "Client", None) if client_obj_test: client_obj_test.Read() client_final = getattr( client_obj_test, "CT_Num", None ) except: pass if not client_final: logger.error( "[TRANSFORM] IMPOSSIBLE d'associer le client malgre toutes les tentatives" ) raise ValueError( f"Client {client_code} impossible a associer au document" ) logger.info( f"[TRANSFORM] ✅ Client confirme avant validation: {client_final}" ) # Lancer le processus try: logger.info("[TRANSFORM] Appel Process()...") process.Process() logger.info("[TRANSFORM] Document cible valide avec succes") except Exception as e: logger.error(f"[TRANSFORM] ERREUR Process(): {e}") logger.error("[TRANSFORM] === DIAGNOSTIC COMPLET ===") try: attributs_doc = [ attr for attr in dir(doc_cible) if (attr.startswith("DO_") or attr.startswith("CT_")) and not callable(getattr(doc_cible, attr, None)) ] for attr in sorted(attributs_doc): try: valeur = getattr(doc_cible, attr, "N/A") logger.error(f" {attr}: {valeur}") except: pass except: pass raise # ======================================== # ÉTAPE 10 : RÉCUPÉRER LE NUMÉRO # ======================================== numero_cible = None try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_cible = getattr(doc_result, "DO_Piece", "") except: pass if not numero_cible: numero_cible = getattr(doc_cible, "DO_Piece", "") if not numero_cible: raise RuntimeError("Numero document cible vide") logger.info(f"[TRANSFORM] Document cible cree: {numero_cible}") # ======================================== # ÉTAPE 11 : COMMIT & MAJ STATUT SOURCE # ======================================== if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info("[TRANSFORM] Transaction committee") except: pass # Attente indexation time.sleep(1) # Marquer source comme "Transformé" try: doc_source.Read() doc_source.DO_Statut = 5 doc_source.Write() logger.info("[TRANSFORM] Statut source -> 5 (TRANSFORME)") except Exception as e: logger.warning(f"Impossible MAJ statut source: {e}") logger.info( f"[TRANSFORM] ✅ SUCCES: {numero_source} ({type_source}) -> " f"{numero_cible} ({type_cible}) - {nb_lignes} lignes" ) 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("[TRANSFORM] Transaction annulee (rollback)") except: pass raise except Exception as e: logger.error(f"[TRANSFORM] ❌ ERREUR: {e}", exc_info=True) raise RuntimeError(f"Echec transformation: {str(e)}") def _find_document_in_list(self, numero, type_doc): """Cherche un document dans List() si ReadPiece échoue""" try: factory = self.cial.FactoryDocumentVente index = 1 while index < 10000: try: persist = factory.List(index) if persist is None: break doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() if ( getattr(doc, "DO_Type", -1) == type_doc and getattr(doc, "DO_Piece", "") == numero ): logger.info(f"[TRANSFORM] Document trouve a l'index {index}") return persist index += 1 except: index += 1 continue return None except Exception as e: logger.error(f"[TRANSFORM] Erreur recherche document: {e}") return None # ========================================================================= # 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 ) # ========================================================================= # PROSPECTS (CT_Type = 0 AND CT_Prospect = 1) # ========================================================================= def lister_tous_prospects(self, filtre=""): """Liste tous les prospects depuis le cache""" with self._lock_clients: if not filtre: return [ c for c in self._cache_clients if c.get("type") == 0 and c.get("est_prospect") ] filtre_lower = filtre.lower() return [ c for c in self._cache_clients if c.get("type") == 0 and c.get("est_prospect") and ( filtre_lower in c["numero"].lower() or filtre_lower in c["intitule"].lower() ) ] def lire_prospect(self, code_prospect): """Retourne un prospect depuis le cache""" with self._lock_clients: prospect = self._cache_clients_dict.get(code_prospect) if prospect and prospect.get("type") == 0 and prospect.get("est_prospect"): return prospect return None # ========================================================================= # EXTRACTION CLIENTS (Mise à jour pour inclure prospects) # ========================================================================= def _extraire_client(self, client_obj): """MISE À JOUR : Extraction avec détection prospect""" data = { "numero": getattr(client_obj, "CT_Num", ""), "intitule": getattr(client_obj, "CT_Intitule", ""), "type": getattr( client_obj, "CT_Type", 0 ), # 0=Client/Prospect, 1=Fournisseur "est_prospect": getattr(client_obj, "CT_Prospect", 0) == 1, # ✅ NOUVEAU } 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 # ========================================================================= # AVOIRS (DO_Domaine = 0 AND DO_Type = 5) # ========================================================================= def lister_avoirs(self, limit=100, statut=None): """Liste tous les avoirs de vente""" if not self.cial: return [] avoirs = [] try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentVente index = 1 max_iterations = limit * 3 erreurs_consecutives = 0 max_erreurs = 50 while ( len(avoirs) < limit and index < max_iterations and erreurs_consecutives < max_erreurs ): try: persist = factory.List(index) if persist is None: break doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() # ✅ Filtrer : DO_Domaine = 0 (Vente) AND DO_Type = 5 (Avoir) doc_type = getattr(doc, "DO_Type", -1) doc_domaine = getattr(doc, "DO_Domaine", -1) if doc_domaine != 0 or doc_type != settings.SAGE_TYPE_BON_AVOIR: index += 1 continue doc_statut = getattr(doc, "DO_Statut", 0) # Filtre statut optionnel if statut is not None and doc_statut != statut: index += 1 continue # Charger 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() except: pass avoirs.append( { "numero": getattr(doc, "DO_Piece", ""), "reference": getattr(doc, "DO_Ref", ""), "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": doc_statut, } ) erreurs_consecutives = 0 index += 1 except: erreurs_consecutives += 1 index += 1 if erreurs_consecutives >= max_erreurs: break logger.info(f"✅ {len(avoirs)} avoirs retournés") return avoirs except Exception as e: logger.error(f"❌ Erreur liste avoirs: {e}", exc_info=True) return [] def lire_avoir(self, numero): """Lecture d'un avoir avec ses lignes""" if not self.cial: return None try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentVente # Essayer ReadPiece persist = factory.ReadPiece(settings.SAGE_TYPE_BON_AVOIR, numero) if not persist: # Chercher dans List() 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) == settings.SAGE_TYPE_BON_AVOIR and getattr(doc_test, "DO_Piece", "") == numero ): persist = persist_test break index += 1 except: index += 1 if not persist: return None doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() # Charger 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() except: pass avoir = { "numero": getattr(doc, "DO_Piece", ""), "reference": getattr(doc, "DO_Ref", ""), "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": [], } # Charger lignes try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) if factory_lignes: index = 1 while index <= 100: try: ligne_persist = factory_lignes.List(index) if ligne_persist is None: break ligne = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) ligne.Read() article_ref = "" try: article_ref = getattr(ligne, "AR_Ref", "").strip() if not article_ref: article_obj = getattr(ligne, "Article", None) if article_obj: article_obj.Read() article_ref = getattr( article_obj, "AR_Ref", "" ).strip() except: pass avoir["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: break except: pass logger.info(f"✅ Avoir {numero} lu: {len(avoir['lignes'])} lignes") return avoir except Exception as e: logger.error(f"❌ Erreur lecture avoir {numero}: {e}") return None # ========================================================================= # LIVRAISONS (DO_Domaine = 0 AND DO_Type = 3) # ========================================================================= def lister_livraisons(self, limit=100, statut=None): """Liste tous les bons de livraison""" if not self.cial: return [] livraisons = [] try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentVente index = 1 max_iterations = limit * 3 erreurs_consecutives = 0 max_erreurs = 50 while ( len(livraisons) < limit and index < max_iterations and erreurs_consecutives < max_erreurs ): try: persist = factory.List(index) if persist is None: break doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() # ✅ Filtrer : DO_Domaine = 0 (Vente) AND DO_Type = 30 (Livraison) doc_type = getattr(doc, "DO_Type", -1) doc_domaine = getattr(doc, "DO_Domaine", -1) if ( doc_domaine != 0 or doc_type != settings.SAGE_TYPE_BON_LIVRAISON ): index += 1 continue doc_statut = getattr(doc, "DO_Statut", 0) if statut is not None and doc_statut != statut: index += 1 continue # Charger 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() except: pass livraisons.append( { "numero": getattr(doc, "DO_Piece", ""), "reference": getattr(doc, "DO_Ref", ""), "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": doc_statut, } ) erreurs_consecutives = 0 index += 1 except: erreurs_consecutives += 1 index += 1 if erreurs_consecutives >= max_erreurs: break logger.info(f"✅ {len(livraisons)} livraisons retournées") return livraisons except Exception as e: logger.error(f"❌ Erreur liste livraisons: {e}", exc_info=True) return [] def lire_livraison(self, numero): """Lecture d'une livraison avec ses lignes""" if not self.cial: return None try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentVente # Essayer ReadPiece persist = factory.ReadPiece(settings.SAGE_TYPE_BON_LIVRAISON, numero) if not persist: # Chercher dans List() 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) == settings.SAGE_TYPE_BON_LIVRAISON and getattr(doc_test, "DO_Piece", "") == numero ): persist = persist_test break index += 1 except: index += 1 if not persist: return None doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() # Charger 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() except: pass livraison = { "numero": getattr(doc, "DO_Piece", ""), "reference": getattr(doc, "DO_Ref", ""), "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": [], } # Charger lignes try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) if factory_lignes: index = 1 while index <= 100: try: ligne_persist = factory_lignes.List(index) if ligne_persist is None: break ligne = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) ligne.Read() article_ref = "" try: article_ref = getattr(ligne, "AR_Ref", "").strip() if not article_ref: article_obj = getattr(ligne, "Article", None) if article_obj: article_obj.Read() article_ref = getattr( article_obj, "AR_Ref", "" ).strip() except: pass livraison["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: break except: pass logger.info( f"✅ Livraison {numero} lue: {len(livraison['lignes'])} lignes" ) return livraison except Exception as e: logger.error(f"❌ Erreur lecture livraison {numero}: {e}") return None # ========================================================================= # CREATION CLIENT (US-A8 ?) # ========================================================================= def creer_client(self, client_data: Dict) -> Dict: """ Crée un nouveau client dans Sage 100c via l'API COM. ✅ VERSION CORRIGÉE : CT_Type supprimé (n'existe pas dans cette version) """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 0 : VALIDATION & NETTOYAGE # ======================================== logger.info("🔍 === VALIDATION DES DONNÉES ===") if not client_data.get("intitule"): raise ValueError("Le champ 'intitule' est obligatoire") # Nettoyage et troncature intitule = str(client_data["intitule"])[:69].strip() num_prop = str(client_data.get("num", "")).upper()[:17].strip() if client_data.get("num") else "" compte = str(client_data.get("compte_collectif", "411000"))[:13].strip() adresse = str(client_data.get("adresse", ""))[:35].strip() code_postal = str(client_data.get("code_postal", ""))[:9].strip() ville = str(client_data.get("ville", ""))[:35].strip() pays = str(client_data.get("pays", ""))[:35].strip() telephone = str(client_data.get("telephone", ""))[:21].strip() email = str(client_data.get("email", ""))[:69].strip() siret = str(client_data.get("siret", ""))[:14].strip() tva_intra = str(client_data.get("tva_intra", ""))[:25].strip() logger.info(f" intitule: '{intitule}' (len={len(intitule)})") logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})") logger.info(f" compte: '{compte}' (len={len(compte)})") # ======================================== # ÉTAPE 1 : CRÉATION OBJET CLIENT # ======================================== factory_client = self.cial.CptaApplication.FactoryClient persist = factory_client.Create() client = win32com.client.CastTo(persist, "IBOClient3") # 🔑 CRITIQUE : Initialiser l'objet client.SetDefault() logger.info("✅ Objet client créé et initialisé") # ======================================== # ÉTAPE 2 : CHAMPS OBLIGATOIRES (dans l'ordre !) # ======================================== logger.info("📝 Définition des champs obligatoires...") # 1. Intitulé (OBLIGATOIRE) client.CT_Intitule = intitule logger.debug(f" ✅ CT_Intitule: '{intitule}'") # ❌ CT_Type SUPPRIMÉ (n'existe pas dans cette version) # client.CT_Type = 0 # <-- LIGNE SUPPRIMÉE # 2. Qualité (important pour filtrage Client/Fournisseur) try: client.CT_Qualite = "CLI" logger.debug(" ✅ CT_Qualite: 'CLI'") except: logger.debug(" ⚠️ CT_Qualite non défini (pas critique)") # 3. Compte général principal (OBLIGATOIRE) try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte) if persist_compte: compte_obj = win32com.client.CastTo(persist_compte, "IBOCompteG3") compte_obj.Read() # Assigner l'objet CompteG client.CompteGPrinc = compte_obj logger.debug(f" ✅ CompteGPrinc: objet '{compte}' assigné") else: logger.warning(f" ⚠️ Compte {compte} introuvable - utilisation du compte par défaut") except Exception as e: logger.warning(f" ⚠️ Erreur CompteGPrinc: {e}") # 4. Numéro client (OBLIGATOIRE - générer si vide) if num_prop: client.CT_Num = num_prop logger.debug(f" ✅ CT_Num fourni: '{num_prop}'") else: # 🔑 CRITIQUE : Générer le numéro automatiquement try: # Méthode 1 : Utiliser SetDefaultNumPiece (si disponible) if hasattr(client, 'SetDefaultNumPiece'): client.SetDefaultNumPiece() num_genere = getattr(client, "CT_Num", "") logger.debug(f" ✅ CT_Num auto-généré (SetDefaultNumPiece): '{num_genere}'") else: # Méthode 2 : Lire le prochain numéro depuis la souche factory_client = self.cial.CptaApplication.FactoryClient num_genere = factory_client.GetNextNumero() if num_genere: client.CT_Num = num_genere logger.debug(f" ✅ CT_Num auto-généré (GetNextNumero): '{num_genere}'") else: # Méthode 3 : Fallback - utiliser un timestamp import time num_genere = f"CLI{int(time.time()) % 1000000}" client.CT_Num = num_genere logger.warning(f" ⚠️ CT_Num fallback temporaire: '{num_genere}'") except Exception as e: logger.error(f" ❌ Impossible de générer CT_Num: {e}") raise ValueError("Impossible de générer le numéro client automatiquement. Veuillez fournir un numéro manuellement.") # 5. Catégories tarifaires (valeurs par défaut) try: # Catégorie tarifaire (obligatoire) if hasattr(client, 'N_CatTarif'): client.N_CatTarif = 1 # Catégorie comptable (obligatoire) if hasattr(client, 'N_CatCompta'): client.N_CatCompta = 1 # Autres catégories if hasattr(client, 'N_Period'): client.N_Period = 1 if hasattr(client, 'N_Expedition'): client.N_Expedition = 1 if hasattr(client, 'N_Condition'): client.N_Condition = 1 if hasattr(client, 'N_Risque'): client.N_Risque = 1 logger.debug(" ✅ Catégories (N_*) initialisées") except Exception as e: logger.warning(f" ⚠️ Catégories: {e}") # ======================================== # ÉTAPE 3 : CHAMPS OPTIONNELS MAIS RECOMMANDÉS # ======================================== logger.info("📝 Définition champs optionnels...") # Adresse (objet IAdresse) if any([adresse, code_postal, ville, pays]): try: adresse_obj = client.Adresse if adresse: adresse_obj.Adresse = adresse if code_postal: adresse_obj.CodePostal = code_postal if ville: adresse_obj.Ville = ville if pays: adresse_obj.Pays = pays logger.debug(" ✅ Adresse définie") except Exception as e: logger.warning(f" ⚠️ Adresse: {e}") # Télécom (objet ITelecom) if telephone or email: try: telecom_obj = client.Telecom if telephone: telecom_obj.Telephone = telephone if email: telecom_obj.EMail = email logger.debug(" ✅ Télécom défini") except Exception as e: logger.warning(f" ⚠️ Télécom: {e}") # Identifiants fiscaux if siret: try: client.CT_Siret = siret logger.debug(f" ✅ SIRET: '{siret}'") except Exception as e: logger.warning(f" ⚠️ SIRET: {e}") if tva_intra: try: client.CT_Identifiant = tva_intra logger.debug(f" ✅ TVA intracommunautaire: '{tva_intra}'") except Exception as e: logger.warning(f" ⚠️ TVA: {e}") # Autres champs utiles (valeurs par défaut intelligentes) try: # Type de facturation (1 = facture normale) if hasattr(client, 'CT_Facture'): client.CT_Facture = 1 # Lettrage automatique activé if hasattr(client, 'CT_Lettrage'): client.CT_Lettrage = True # Pas de prospect if hasattr(client, 'CT_Prospect'): client.CT_Prospect = False # Client actif (pas en sommeil) if hasattr(client, 'CT_Sommeil'): client.CT_Sommeil = False logger.debug(" ✅ Options par défaut définies") except Exception as e: logger.debug(f" ⚠️ Options: {e}") # ======================================== # ÉTAPE 4 : DIAGNOSTIC PRÉ-WRITE (pour debug) # ======================================== logger.info("🔍 === DIAGNOSTIC PRÉ-WRITE ===") champs_critiques = [ ("CT_Intitule", "str"), ("CT_Num", "str"), ("CompteGPrinc", "object"), ("N_CatTarif", "int"), ("N_CatCompta", "int"), ] for champ, type_attendu in champs_critiques: try: val = getattr(client, champ, None) if type_attendu == "object": status = "✅ Objet défini" if val else "❌ NULL" else: if type_attendu == "str": status = f"✅ '{val}' (len={len(val)})" if val else "❌ Vide" else: status = f"✅ {val}" logger.info(f" {champ}: {status}") except Exception as e: logger.error(f" {champ}: ❌ Erreur - {e}") # ======================================== # ÉTAPE 5 : VÉRIFICATION FINALE CT_Num # ======================================== num_avant_write = getattr(client, "CT_Num", "") if not num_avant_write: logger.error("❌ CRITIQUE: CT_Num toujours vide après toutes les tentatives !") raise ValueError( "Le numéro client (CT_Num) est obligatoire mais n'a pas pu être généré. " "Veuillez fournir un numéro manuellement via le paramètre 'num'." ) logger.info(f"✅ CT_Num confirmé avant Write(): '{num_avant_write}'") # ======================================== # ÉTAPE 6 : ÉCRITURE EN BASE # ======================================== logger.info("💾 Écriture du client dans Sage...") try: client.Write() logger.info("✅ Write() réussi !") except Exception as e: error_detail = str(e) # Récupérer l'erreur Sage détaillée try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = f"{sage_error.Description} (Code: {sage_error.Number})" logger.error(f"❌ Erreur Sage: {error_detail}") except: pass # Analyser l'erreur spécifique if "longueur invalide" in error_detail.lower(): logger.error("❌ ERREUR 'longueur invalide' - Dump des champs:") for attr in dir(client): if attr.startswith("CT_") or attr.startswith("N_"): try: val = getattr(client, attr, None) if isinstance(val, str): logger.error(f" {attr}: '{val}' (len={len(val)})") elif val is not None and not callable(val): logger.error(f" {attr}: {val} (type={type(val).__name__})") except: pass if "doublon" in error_detail.lower() or "existe" in error_detail.lower(): raise ValueError(f"Ce client existe déjà : {error_detail}") raise RuntimeError(f"Échec Write(): {error_detail}") # ======================================== # ÉTAPE 7 : RELECTURE & FINALISATION # ======================================== try: client.Read() except Exception as e: logger.warning(f"⚠️ Impossible de relire: {e}") num_final = getattr(client, "CT_Num", "") if not num_final: raise RuntimeError("CT_Num vide après Write()") logger.info(f"✅✅✅ CLIENT CRÉÉ: {num_final} - {intitule} ✅✅✅") # ======================================== # ÉTAPE 8 : REFRESH CACHE # ======================================== self._refresh_cache_clients() return { "numero": num_final, "intitule": intitule, "compte_collectif": compte, "type": 0, # Par défaut client "adresse": adresse or None, "code_postal": code_postal or None, "ville": ville or None, "pays": pays or None, "email": email or None, "telephone": telephone or None, "siret": siret or None, "tva_intra": tva_intra or None } except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur création client: {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage: {error_message}") def modifier_client(self, code: str, client_data: Dict) -> Dict: """ ✏️ Modification d'un client existant dans Sage 100c Args: code: Code du client à modifier client_data: Dictionnaire avec les champs à mettre à jour Returns: Client modifié """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : CHARGER LE CLIENT EXISTANT # ======================================== logger.info(f"🔍 Recherche client {code}...") factory_client = self.cial.CptaApplication.FactoryClient persist = factory_client.ReadNumero(code) if not persist: raise ValueError(f"Client {code} introuvable") client = win32com.client.CastTo(persist, "IBOClient3") client.Read() logger.info(f"✅ Client {code} trouvé: {getattr(client, 'CT_Intitule', '')}") # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS # ======================================== logger.info("📝 Mise à jour des champs...") champs_modifies = [] # Intitulé if "intitule" in client_data: intitule = str(client_data["intitule"])[:69].strip() client.CT_Intitule = intitule champs_modifies.append(f"intitule='{intitule}'") # Adresse if any(k in client_data for k in ["adresse", "code_postal", "ville", "pays"]): try: adresse_obj = client.Adresse if "adresse" in client_data: adresse = str(client_data["adresse"])[:35].strip() adresse_obj.Adresse = adresse champs_modifies.append("adresse") if "code_postal" in client_data: cp = str(client_data["code_postal"])[:9].strip() adresse_obj.CodePostal = cp champs_modifies.append("code_postal") if "ville" in client_data: ville = str(client_data["ville"])[:35].strip() adresse_obj.Ville = ville champs_modifies.append("ville") if "pays" in client_data: pays = str(client_data["pays"])[:35].strip() adresse_obj.Pays = pays champs_modifies.append("pays") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour adresse: {e}") # Télécom if "email" in client_data or "telephone" in client_data: try: telecom_obj = client.Telecom if "email" in client_data: email = str(client_data["email"])[:69].strip() telecom_obj.EMail = email champs_modifies.append("email") if "telephone" in client_data: tel = str(client_data["telephone"])[:21].strip() telecom_obj.Telephone = tel champs_modifies.append("telephone") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour télécom: {e}") # SIRET if "siret" in client_data: try: siret = str(client_data["siret"])[:14].strip() client.CT_Siret = siret champs_modifies.append("siret") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour SIRET: {e}") # TVA Intracommunautaire if "tva_intra" in client_data: try: tva = str(client_data["tva_intra"])[:25].strip() client.CT_Identifiant = tva champs_modifies.append("tva_intra") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour TVA: {e}") if not champs_modifies: logger.warning("⚠️ Aucun champ à modifier") return self._extraire_client(client) logger.info(f"📝 Champs à modifier: {', '.join(champs_modifies)}") # ======================================== # ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS # ======================================== logger.info("💾 Écriture des modifications...") try: client.Write() logger.info("✅ Write() réussi !") except Exception as e: error_detail = str(e) try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = f"{sage_error.Description} (Code: {sage_error.Number})" except: pass logger.error(f"❌ Erreur Write(): {error_detail}") raise RuntimeError(f"Échec modification: {error_detail}") # ======================================== # ÉTAPE 4 : RELIRE ET RETOURNER # ======================================== client.Read() logger.info(f"✅✅✅ CLIENT MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅") # Refresh cache self._refresh_cache_clients() return self._extraire_client(client) except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur modification client: {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage: {error_message}")