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 modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: """ ✏️ Modification d'un fournisseur existant dans Sage 100c IMPORTANT: Utilise FactoryFournisseur.ReadNumero() pour charger le fournisseur Args: code: Code du fournisseur à modifier fournisseur_data: Dictionnaire avec les champs à mettre à jour Returns: Fournisseur modifié """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : CHARGER LE FOURNISSEUR EXISTANT # ======================================== logger.info(f"🔍 Recherche fournisseur {code}...") factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur persist = factory_fournisseur.ReadNumero(code) if not persist: raise ValueError(f"Fournisseur {code} introuvable") fournisseur = self._cast_client(persist) # ✅ Réutiliser _cast_client if not fournisseur: raise ValueError(f"Impossible de charger le fournisseur {code}") logger.info(f"✅ Fournisseur {code} trouvé: {getattr(fournisseur, 'CT_Intitule', '')}") # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS # ======================================== logger.info("📝 Mise à jour des champs...") champs_modifies = [] # Intitulé if "intitule" in fournisseur_data: intitule = str(fournisseur_data["intitule"])[:69].strip() fournisseur.CT_Intitule = intitule champs_modifies.append(f"intitule='{intitule}'") # Adresse if any(k in fournisseur_data for k in ["adresse", "code_postal", "ville", "pays"]): try: adresse_obj = fournisseur.Adresse if "adresse" in fournisseur_data: adresse = str(fournisseur_data["adresse"])[:35].strip() adresse_obj.Adresse = adresse champs_modifies.append("adresse") if "code_postal" in fournisseur_data: cp = str(fournisseur_data["code_postal"])[:9].strip() adresse_obj.CodePostal = cp champs_modifies.append("code_postal") if "ville" in fournisseur_data: ville = str(fournisseur_data["ville"])[:35].strip() adresse_obj.Ville = ville champs_modifies.append("ville") if "pays" in fournisseur_data: pays = str(fournisseur_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 fournisseur_data or "telephone" in fournisseur_data: try: telecom_obj = fournisseur.Telecom if "email" in fournisseur_data: email = str(fournisseur_data["email"])[:69].strip() telecom_obj.EMail = email champs_modifies.append("email") if "telephone" in fournisseur_data: tel = str(fournisseur_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 fournisseur_data: try: siret = str(fournisseur_data["siret"])[:14].strip() fournisseur.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 fournisseur_data: try: tva = str(fournisseur_data["tva_intra"])[:25].strip() fournisseur.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") # Retourner les données actuelles via extraction directe return { "numero": getattr(fournisseur, "CT_Num", "").strip(), "intitule": getattr(fournisseur, "CT_Intitule", "").strip(), "type": 1, "est_fournisseur": True } logger.info(f"📝 Champs à modifier: {', '.join(champs_modifies)}") # ======================================== # ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS # ======================================== logger.info("💾 Écriture des modifications...") try: fournisseur.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 # ======================================== fournisseur.Read() logger.info(f"✅✅✅ FOURNISSEUR MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅") # Extraction directe (comme lire_fournisseur) numero = getattr(fournisseur, "CT_Num", "").strip() intitule = getattr(fournisseur, "CT_Intitule", "").strip() data = { "numero": numero, "intitule": intitule, "type": 1, "est_fournisseur": True } # Adresse try: adresse_obj = getattr(fournisseur, "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(fournisseur, "Telecom", None) if telecom_obj: data["telephone"] = getattr(telecom_obj, "Telephone", "").strip() data["email"] = getattr(telecom_obj, "EMail", "").strip() except: data["telephone"] = "" data["email"] = "" return data except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur modification 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 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 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}") def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: """ ✏️ Modification d'un devis existant dans Sage Permet de modifier la date, les lignes et le statut. Si les lignes sont modifiées, elles remplacent TOUTES les lignes existantes. """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : CHARGER LE DEVIS EXISTANT # ======================================== logger.info(f"🔍 Recherche devis {numero}...") factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(0, numero) # Si ReadPiece échoue, chercher dans List() 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): persist = persist_test break index += 1 except: index += 1 if not persist: raise ValueError(f"Devis {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() logger.info(f"✅ Devis {numero} trouvé") # Vérifier le statut (ne pas modifier si déjà transformé) statut_actuel = getattr(doc, "DO_Statut", 0) if statut_actuel == 5: raise ValueError(f"Le devis {numero} a déjà été transformé") # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS # ======================================== champs_modifies = [] # Mise à jour de la date if "date_devis" in devis_data: import pywintypes date_str = devis_data["date_devis"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f"📅 Date modifiée: {date_obj.date()}") # Mise à jour du statut if "statut" in devis_data: nouveau_statut = devis_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f"📊 Statut modifié: {statut_actuel} → {nouveau_statut}") # Écriture des modifications de base if champs_modifies: doc.Write() # ======================================== # ÉTAPE 3 : REMPLACEMENT DES LIGNES (si demandé) # ======================================== if "lignes" in devis_data and devis_data["lignes"] is not None: logger.info(f"🔄 Remplacement des lignes...") # Supprimer TOUTES les lignes existantes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne # Compter et supprimer les lignes existantes index_ligne = 1 while index_ligne <= 100: try: ligne_p = factory_lignes.List(index_ligne) if ligne_p is None: break ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") ligne.Read() ligne.Delete() index_ligne += 1 except: break logger.info(f"🗑️ {index_ligne - 1} ligne(s) supprimée(s)") # Ajouter les nouvelles lignes factory_article = self.cial.FactoryArticle for idx, ligne_data in enumerate(devis_data["lignes"], 1): logger.info(f"➕ Ajout ligne {idx}: {ligne_data['article_code']}") # Charger l'article persist_article = factory_article.ReadReference(ligne_data["article_code"]) if not persist_article: raise ValueError(f"Article {ligne_data['article_code']} introuvable") article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") article_obj.Read() # Créer la nouvelle ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") except: ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") # Associer article quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite # Définir le prix (si fourni) if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) # Définir la remise (si fournie) if ligne_data.get("remise_pourcentage", 0) > 0: try: ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) ligne_obj.DL_Remise01REM_Type = 0 except: pass ligne_obj.Write() logger.info(f"✅ {len(devis_data['lignes'])} nouvelle(s) ligne(s) ajoutée(s)") champs_modifies.append("lignes") # ======================================== # ÉTAPE 4 : VALIDATION FINALE # ======================================== doc.Write() # Attente indexation time.sleep(1) # Relecture pour récupérer les totaux doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅✅✅ DEVIS MODIFIÉ: {numero} ({', '.join(champs_modifies)}) ✅✅✅") logger.info(f"💰 Nouveaux totaux: {total_ht}€ HT / {total_ttc}€ TTC") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, "statut": getattr(doc, "DO_Statut", 0) } except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur modification devis: {e}", exc_info=True) raise RuntimeError(f"Erreur technique Sage: {str(e)}") def creer_commande_enrichi(self, commande_data: dict) -> Dict: """ ➕ Création d'une commande (type 10 = Bon de commande) Similaire à creer_devis_enrichi mais pour les commandes. Utilise CreateProcess_Document(10) au lieu de (0). """ if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info(f"🚀 Début création commande pour client {commande_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: pass try: # Création document COMMANDE (type 10) process = self.cial.CreateProcess_Document(settings.SAGE_TYPE_BON_COMMANDE) doc = process.Document try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass logger.info("📄 Document commande créé") # Date import pywintypes if isinstance(commande_data["date_commande"], str): date_obj = datetime.fromisoformat(commande_data["date_commande"]) elif isinstance(commande_data["date_commande"], date): date_obj = datetime.combine(commande_data["date_commande"], 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) factory_client = self.cial.CptaApplication.FactoryClient persist_client = factory_client.ReadNumero(commande_data["client"]["code"]) if not persist_client: raise ValueError(f"Client {commande_data['client']['code']} introuvable") client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError(f"Impossible de charger le client") doc.SetDefaultClient(client_obj) doc.Write() logger.info(f"👤 Client {commande_data['client']['code']} associé") # Référence externe (optionnelle) if commande_data.get("reference"): try: doc.DO_Ref = commande_data["reference"] logger.info(f"🔖 Référence: {commande_data['reference']}") except: pass # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle logger.info(f"📦 Ajout de {len(commande_data['lignes'])} lignes...") for idx, ligne_data in enumerate(commande_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 doc.Write() process.Process() if transaction_active: self.cial.CptaApplication.CommitTrans() # Récupération numéro time.sleep(2) numero_commande = None try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3") doc_result.Read() numero_commande = getattr(doc_result, "DO_Piece", "") except: pass if not numero_commande: numero_commande = getattr(doc, "DO_Piece", "") # Relecture factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece(settings.SAGE_TYPE_BON_COMMANDE, numero_commande) if persist_reread: doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3") doc_final.Read() total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) else: total_ht = 0.0 total_ttc = 0.0 logger.info(f"✅✅✅ COMMANDE CRÉÉE: {numero_commande} - {total_ttc}€ TTC ✅✅✅") return { "numero_commande": numero_commande, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": len(commande_data["lignes"]), "client_code": commande_data["client"]["code"], "date_commande": str(date_obj.date()), } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except: pass raise except Exception as e: logger.error(f"❌ Erreur création commande: {e}", exc_info=True) raise RuntimeError(f"Échec création commande: {str(e)}") def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: """ ✏️ Modification d'une commande existante Code similaire à modifier_devis mais pour type 10 (Bon de commande) """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : CHARGER LE DEVIS EXISTANT # ======================================== logger.info(f"🔍 Recherche devis {numero}...") factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(10, numero) # Si ReadPiece échoue, chercher dans List() 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): persist = persist_test break index += 1 except: index += 1 if not persist: raise ValueError(f"Devis {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() logger.info(f"✅ Devis {numero} trouvé") # Vérifier le statut (ne pas modifier si déjà transformé) statut_actuel = getattr(doc, "DO_Statut", 0) if statut_actuel == 5: raise ValueError(f"Le devis {numero} a déjà été transformé") # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS # ======================================== champs_modifies = [] # Mise à jour de la date if "date_devis" in commande_data: import pywintypes date_str = commande_data["date_devis"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f"📅 Date modifiée: {date_obj.date()}") # Mise à jour du statut if "statut" in commande_data: nouveau_statut = commande_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f"📊 Statut modifié: {statut_actuel} → {nouveau_statut}") # Écriture des modifications de base if champs_modifies: doc.Write() # ======================================== # ÉTAPE 3 : REMPLACEMENT DES LIGNES (si demandé) # ======================================== if "lignes" in commande_data and commande_data["lignes"] is not None: logger.info(f"🔄 Remplacement des lignes...") # Supprimer TOUTES les lignes existantes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne # Compter et supprimer les lignes existantes index_ligne = 1 while index_ligne <= 100: try: ligne_p = factory_lignes.List(index_ligne) if ligne_p is None: break ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") ligne.Read() ligne.Delete() index_ligne += 1 except: break logger.info(f"🗑️ {index_ligne - 1} ligne(s) supprimée(s)") # Ajouter les nouvelles lignes factory_article = self.cial.FactoryArticle for idx, ligne_data in enumerate(commande_data["lignes"], 1): logger.info(f"➕ Ajout ligne {idx}: {ligne_data['article_code']}") # Charger l'article persist_article = factory_article.ReadReference(ligne_data["article_code"]) if not persist_article: raise ValueError(f"Article {ligne_data['article_code']} introuvable") article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") article_obj.Read() # Créer la nouvelle ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") except: ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") # Associer article quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite # Définir le prix (si fourni) if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) # Définir la remise (si fournie) if ligne_data.get("remise_pourcentage", 0) > 0: try: ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) ligne_obj.DL_Remise01REM_Type = 0 except: pass ligne_obj.Write() logger.info(f"✅ {len(commande_data['lignes'])} nouvelle(s) ligne(s) ajoutée(s)") champs_modifies.append("lignes") # ======================================== # ÉTAPE 4 : VALIDATION FINALE # ======================================== doc.Write() # Attente indexation time.sleep(1) # Relecture pour récupérer les totaux doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅✅✅ DEVIS MODIFIÉ: {numero} ({', '.join(champs_modifies)}) ✅✅✅") logger.info(f"💰 Nouveaux totaux: {total_ht}€ HT / {total_ttc}€ TTC") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, "statut": getattr(doc, "DO_Statut", 0) } except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur modification devis: {e}", exc_info=True) raise RuntimeError(f"Erreur technique Sage: {str(e)}")