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, forcer_brouillon: bool = False): """ Création de devis OPTIMISÉE - Version hybride Args: devis_data: Données du devis forcer_brouillon: Si True, crée en statut 0 (Brouillon) Si False, laisse Sage décider (généralement statut 2) ✅ AVANTAGES: - Rapide comme l'ancienne version - Possibilité de forcer en brouillon si nécessaire - Pas d'attentes inutiles - Relecture simplifiée """ if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info( f"🚀 Début création devis pour client {devis_data['client']['code']} " f"(brouillon={forcer_brouillon})" ) 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) 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 ===== 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']}" ) doc.SetDefaultClient(client_obj) # ✅ STATUT: Définir SEULEMENT si brouillon demandé doc.DO_Statut = 2 logger.info("📊 Statut forcé: 0 (Brouillon)") # Sinon, laisser Sage décider (généralement 2 = Accepté) doc.Write() logger.info(f"👤 Client {devis_data['client']['code']} associé") # ===== LIGNES ===== 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.debug( f"--- 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() prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0)) designation_sage = getattr(article_obj, "AR_Design", "") if prix_sage == 0: logger.warning( f"⚠️ Article {ligne_data['article_code']} a un prix = 0€" ) # Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) 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 = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite # Prix prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) prix_a_utiliser = ligne_data.get("prix_unitaire_ht") if prix_a_utiliser is not None and prix_a_utiliser > 0: ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) elif prix_auto == 0: if prix_sage == 0: raise ValueError( f"Prix nul pour article {ligne_data['article_code']}" ) ligne_obj.DL_PrixUnitaire = float(prix_sage) # 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 except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") ligne_obj.Write() logger.info(f"✅ {len(devis_data['lignes'])} lignes écrites") # ===== VALIDATION ===== doc.Write() # ✅ PROCESS() uniquement si pas en brouillon if not forcer_brouillon: logger.info("🔄 Lancement Process()...") process.Process() else: # En brouillon, Process() peut échouer, on essaie mais on ignore l'erreur try: process.Process() logger.info("✅ Process() appelé (brouillon)") except: logger.debug("⚠️ Process() ignoré pour brouillon") # ===== RÉCUPÉRATION NUMÉRO ===== numero_devis = None # Méthode 1: DocumentResult 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", "") except: pass # Méthode 2: Document direct if not numero_devis: numero_devis = getattr(doc, "DO_Piece", "") # Méthode 3: SetDefaultNumPiece if not numero_devis: try: doc.SetDefaultNumPiece() doc.Write() doc.Read() numero_devis = getattr(doc, "DO_Piece", "") except: pass if not numero_devis: raise RuntimeError("❌ Numéro devis vide après création") logger.info(f"📄 Numéro: {numero_devis}") # ===== COMMIT ===== if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info("✅ Transaction committée") except: pass # ===== RELECTURE SIMPLIFIÉE (pas d'attente excessive) ===== # Attendre juste 500ms pour l'indexation time.sleep(0.5) factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece(0, numero_devis) if not persist_reread: # Si ReadPiece échoue, chercher dans les 100 premiers logger.debug("ReadPiece échoué, recherche dans List()...") index = 1 while index < 100: try: persist_test = factory_doc.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_reread = persist_test logger.info(f"✅ Document trouvé à l'index {index}") break index += 1 except: index += 1 # Extraction des totaux 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)) statut_final = getattr(doc_final, "DO_Statut", 0) else: # Fallback: calculer manuellement total_calcule = sum( l.get("montant_ligne_ht", 0) for l in devis_data["lignes"] ) total_ht = total_calcule total_ttc = round(total_calcule * 1.20, 2) statut_final = 0 if forcer_brouillon else 2 logger.info(f"💰 Total HT: {total_ht}€") logger.info(f"💰 Total TTC: {total_ttc}€") logger.info(f"📊 Statut final: {statut_final}") 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": devis_data["client"]["code"], "date_devis": str(date_obj.date()), "statut": statut_final, } 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) ✅ ENRICHI: Inclut maintenant a_deja_ete_transforme ❌ N'utilise JAMAIS List() - uniquement ReadPiece """ if not self.cial: return None try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentVente # ✅ UNIQUEMENT ReadPiece try: persist = factory.ReadPiece(0, numero_devis) if persist: logger.info(f"✅ Devis {numero_devis} trouvé via ReadPiece") else: logger.warning( f"❌ Devis {numero_devis} introuvable via ReadPiece" ) return None except Exception as e: logger.error(f"❌ ReadPiece échoué pour {numero_devis}: {e}") 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 Exception as e: logger.debug(f"Erreur chargement client: {e}") 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": [], } # ✅✅ NOUVEAU: Vérifier si déjà transformé try: verif = self.verifier_si_deja_transforme(numero_devis, 0) devis["a_deja_ete_transforme"] = verif.get("deja_transforme", False) devis["documents_cibles"] = verif.get("documents_cibles", []) logger.info( f"📊 Devis {numero_devis}: " f"transformé={devis['a_deja_ete_transforme']}, " f"nb_docs_cibles={len(devis['documents_cibles'])}" ) except Exception as e: logger.warning(f"⚠️ Erreur vérification transformation: {e}") devis["a_deja_ete_transforme"] = False devis["documents_cibles"] = [] # 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() # Charger 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 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, " f"{devis['total_ttc']:.2f}€, statut={devis['statut']}, " f"transformé={devis['a_deja_ete_transforme']}" ) return devis except Exception as e: logger.error(f"❌ Erreur lecture devis {numero_devis}: {e}", exc_info=True) return None def lire_document(self, numero, type_doc): """ Lecture générique document ✅ AJOUT: Retourne maintenant DO_Ref """ 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, "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 "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), "lignes": lignes, } except Exception as e: logger.error(f"❌ Erreur lecture document: {e}") return None def verifier_si_deja_transforme(self, numero_source: str, type_source: int) -> Dict: """ 🔍 Vérifie si un document a déjà été transformé ✅ ULTRA-OPTIMISÉ: Utilise ReadPiece avec DO_Ref au lieu de scanner List() Performance: - Ancienne méthode: 30+ secondes (scan de 10000+ documents) - Nouvelle méthode: < 1 seconde (lectures directes ciblées) Stratégie: 1. Construire les numéros potentiels basés sur les conventions Sage 2. Tester directement avec ReadPiece 3. Limite stricte de 50 documents à scanner en dernier recours Returns: { "deja_transforme": bool, "documents_cibles": [ {"numero": "BC00001", "type": 10, "date": "..."} ] } """ if not self.cial: return {"deja_transforme": False, "documents_cibles": []} try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentVente documents_cibles = [] logger.info(f"🔍 Vérification transformations pour {numero_source}...") # ======================================== # MÉTHODE 1: DEVINER LES NUMÉROS CIBLES PAR CONVENTION # ======================================== # Extraire le numéro de base (ex: "00001" depuis "DE00001") import re match = re.search(r"(\d+)$", numero_source) if match: numero_base = match.group(1) # Mapper les préfixes selon les types prefixes_par_type = { 10: ["BC", "CMD"], # Bon de commande 30: ["BL", "LIV"], # Bon de livraison 60: ["FA", "FACT"], # Facture } # Types cibles possibles selon le type source types_cibles_possibles = { 0: [10, 60], # Devis → Commande ou Facture 10: [30, 60], # Commande → BL ou Facture 30: [60], # BL → Facture } types_a_tester = types_cibles_possibles.get(type_source, []) # Tester chaque combinaison type/préfixe for type_cible in types_a_tester: for prefix in prefixes_par_type.get(type_cible, []): numero_potentiel = f"{prefix}{numero_base}" try: persist = factory.ReadPiece( type_cible, numero_potentiel ) if persist: doc = win32com.client.CastTo( persist, "IBODocumentVente3" ) doc.Read() # Vérifier que DO_Ref correspond bien ref_origine = getattr(doc, "DO_Ref", "").strip() if ( numero_source in ref_origine or ref_origine == numero_source ): documents_cibles.append( { "numero": getattr(doc, "DO_Piece", ""), "type": type_cible, "type_libelle": self._get_type_libelle( type_cible ), "date": str( getattr(doc, "DO_Date", "") ), "reference": ref_origine, "total_ttc": float( getattr(doc, "DO_TotalTTC", 0.0) ), "statut": getattr(doc, "DO_Statut", -1), "methode_detection": "convention_nommage", } ) logger.info( f"✅ Trouvé via convention: {numero_potentiel} " f"(DO_Ref={ref_origine})" ) except: # Ce numéro n'existe pas, continuer continue # ======================================== # MÉTHODE 2: SCAN ULTRA-LIMITÉ (max 50 documents) # ======================================== # Seulement si rien trouvé ET que c'est critique if not documents_cibles: logger.info(f"🔍 Scan limité (max 50 documents)...") index = 1 max_scan = 100 # ⚡ LIMITE STRICTE à 50 au lieu de 500 while index < max_scan: try: persist = factory.List(index) if persist is None: break doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() # Vérifier DO_Ref ref_origine = getattr(doc, "DO_Ref", "").strip() if ( numero_source in ref_origine or ref_origine == numero_source ): doc_type = getattr(doc, "DO_Type", -1) documents_cibles.append( { "numero": getattr(doc, "DO_Piece", ""), "type": doc_type, "type_libelle": self._get_type_libelle( doc_type ), "date": str(getattr(doc, "DO_Date", "")), "reference": ref_origine, "total_ttc": float( getattr(doc, "DO_TotalTTC", 0.0) ), "statut": getattr(doc, "DO_Statut", -1), "methode_detection": "scan_limite", } ) logger.info( f"✅ Trouvé via scan: {getattr(doc, 'DO_Piece', '')} " f"à l'index {index}" ) index += 1 except Exception as e: index += 1 continue # ======================================== # RÉSULTAT # ======================================== logger.info( f"📊 Résultat vérification {numero_source}: " f"{len(documents_cibles)} transformation(s) trouvée(s)" ) return { "deja_transforme": len(documents_cibles) > 0, "nb_transformations": len(documents_cibles), "documents_cibles": documents_cibles, } except Exception as e: logger.error(f"❌ Erreur vérification transformation: {e}") return {"deja_transforme": False, "documents_cibles": []} def _get_type_libelle(self, type_doc: int) -> str: """Retourne le libellé d'un type de document""" types = { 0: "Devis", 10: "Bon de commande", 20: "Préparation", 30: "Bon de livraison", 40: "Bon de retour", 50: "Bon d'avoir", 60: "Facture", } return types.get(type_doc, f"Type {type_doc}") def transformer_document(self, numero_source, type_source, type_cible): """ 🔧 Transformation de document - VERSION FUSIONNÉE FINALE ✅ Copie DO_Ref du source vers la cible (du nouveau) ✅ Ne modifie JAMAIS le statut du document source ✅ Préserve toutes les lignes correctement (de l'ancien) """ if not self.cial: raise RuntimeError("Connexion Sage non établie") type_source = int(type_source) type_cible = int(type_cible) logger.info( f"[TRANSFORM] Demande : {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 autorisée: {type_source} -> {type_cible}" ) # ======================================== # VÉRIFICATION AUTOMATIQUE DES DOUBLONS # ======================================== logger.info("[TRANSFORM] Vérification des doublons via DO_Ref...") verification = self.verifier_si_deja_transforme(numero_source, type_source) if verification["deja_transforme"]: docs_existants = verification["documents_cibles"] docs_meme_type = [d for d in docs_existants if d["type"] == type_cible] if docs_meme_type: nums = [d["numero"] for d in docs_meme_type] error_msg = ( f"❌ Le document {numero_source} a déjà été transformé " f"en {self._get_type_libelle(type_cible)}. " f"Document(s) existant(s) : {', '.join(nums)}" ) logger.error(f"[TRANSFORM] {error_msg}") raise ValueError(error_msg) else: logger.warning( f"[TRANSFORM] ⚠️ Le document {numero_source} a déjà été transformé " f"{len(docs_existants)} fois vers d'autres types" ) 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() 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}" ) # ======================================== # ÉTAPE 2 : EXTRAIRE LES DONNÉES SOURCE # ======================================== logger.info("[TRANSFORM] Extraction données 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) # ✅ NOUVEAU: Référence externe (DO_Ref) - UTILISER LE NUMÉRO SOURCE reference_pour_cible = numero_source logger.info(f"[TRANSFORM] Référence à copier: {reference_pour_cible}") # 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 démarrée") except: logger.debug("[TRANSFORM] BeginTrans non disponible") try: # ======================================== # ÉTAPE 4 : CRÉER LE DOCUMENT CIBLE # ======================================== logger.info(f"[TRANSFORM] Création document type {type_cible}...") process = self.cial.CreateProcess_Document(type_cible) if not process: raise RuntimeError( f"CreateProcess_Document({type_cible}) a retourné None" ) doc_cible = process.Document try: doc_cible = win32com.client.CastTo( doc_cible, "IBODocumentVente3" ) except: pass logger.info("[TRANSFORM] Document cible créé") # ======================================== # ÉTAPE 5 : DÉFINIR LA DATE # ======================================== import pywintypes if date_source: try: doc_cible.DO_Date = date_source logger.info(f"[TRANSFORM] Date copiée: {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}") try: doc_cible.SetClient(client_obj_cible) logger.info( f"[TRANSFORM] SetClient() appelé pour {client_code}" ) except Exception as e: logger.warning( f"[TRANSFORM] SetClient() échoue: {e}, tentative SetDefaultClient()" ) doc_cible.SetDefaultClient(client_obj_cible) # ✅ FUSION: Définir DO_Ref AVANT le premier Write() try: doc_cible.DO_Ref = reference_pour_cible logger.info( f"[TRANSFORM] DO_Ref défini: {reference_pour_cible}" ) except Exception as e: logger.warning(f"Impossible de définir DO_Ref: {e}") 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: 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}") logger.info( f"[TRANSFORM] Client {client_code} associé (CT_Num={client_verifie})" ) 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}") article_ref = ligne_data["article_ref"] if not article_ref: logger.warning( f"Ligne {idx}: pas de référence 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() ligne_persist = factory_lignes_cible.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = ligne_data["quantite"] try: ligne_obj.SetDefaultArticleReference(article_ref, quantite) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data["designation"] ligne_obj.DL_Qte = quantite prix = ligne_data["prix_unitaire"] if prix > 0: ligne_obj.DL_PrixUnitaire = float(prix) 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 ligne_obj.Write() # ✅ FUSION: Log détaillé de la ligne écrite logger.debug( f"[TRANSFORM] Ligne {idx} écrite: {article_ref} x{quantite} @ {prix}€" ) logger.info(f"[TRANSFORM] {nb_lignes} lignes copiées") # ======================================== # ÉTAPE 8 : COMPLÉTER LES CHAMPS OBLIGATOIRES # ======================================== if type_cible == 60: # Facture logger.info( "[TRANSFORM] Complétion champs obligatoires facture..." ) try: journal = ( getattr(doc_source, "DO_CodeJournal", None) or "VTE" ) if hasattr(doc_cible, "DO_CodeJournal"): doc_cible.DO_CodeJournal = journal except Exception as e: logger.warning(f"Code journal: {e}") try: souche = getattr(doc_source, "DO_Souche", 0) if hasattr(doc_cible, "DO_Souche"): doc_cible.DO_Souche = souche except: pass try: regime = getattr(doc_source, "DO_Regime", None) if regime is not None and hasattr(doc_cible, "DO_Regime"): doc_cible.DO_Regime = regime except: pass # ======================================== # ÉTAPE 9 : RÉASSOCIER LE CLIENT # ======================================== logger.info("[TRANSFORM] Réassociation client avant validation...") try: doc_cible.SetClient(client_obj_sauvegarde) except: doc_cible.SetDefaultClient(client_obj_sauvegarde) logger.info("[TRANSFORM] Écriture document finale...") doc_cible.Write() # ======================================== # ÉTAPE 10 : VALIDER LE DOCUMENT # ======================================== logger.info("[TRANSFORM] Validation document cible...") 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.warning( "Client perdu ! Tentative réassociation 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: raise ValueError(f"Client {client_code} impossible à associer") logger.info(f"[TRANSFORM] ✅ Client confirmé: {client_final}") try: logger.info("[TRANSFORM] Appel Process()...") process.Process() logger.info("[TRANSFORM] Document cible validé avec succès") except Exception as e: logger.error(f"[TRANSFORM] ERREUR Process(): {e}") raise # ======================================== # ÉTAPE 11 : 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("Numéro document cible vide") logger.info(f"[TRANSFORM] Document cible créé: {numero_cible}") # ======================================== # ÉTAPE 12 : COMMIT (STATUT SOURCE INCHANGÉ) # ======================================== if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info("[TRANSFORM] Transaction committée") except: pass # Attente indexation time.sleep(1) # ✅ LE DOCUMENT SOURCE GARDE SON STATUT ACTUEL # ✅ FUSION: Message final clair logger.info( f"[TRANSFORM] ✅ SUCCÈS: {numero_source} ({type_source}) -> " f"{numero_cible} ({type_cible}) - {nb_lignes} lignes - " f"Référence: {reference_pour_cible} - Statut source inchangé" ) 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 annulée (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 - VERSION FINALE OPTIMISÉE ✅ Même stratégie intelligente que modifier_commande """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ÉTAPE 1 : CHARGER LE DEVIS logger.info(f"🔍 Recherche devis {numero}...") factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(0, numero) 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 transformation verification = self.verifier_si_deja_transforme(numero, 0) if verification["deja_transforme"]: docs_cibles = verification["documents_cibles"] nums = [d["numero"] for d in docs_cibles] raise ValueError( f"❌ Devis {numero} déjà transformé en {len(docs_cibles)} document(s): {', '.join(nums)}" ) statut_actuel = getattr(doc, "DO_Statut", 0) if statut_actuel == 5: raise ValueError(f"Devis {numero} déjà transformé (statut=5)") # ÉTAPE 2 : CHAMPS SIMPLES champs_modifies = [] if "date_devis" in devis_data: import pywintypes date_str = devis_data["date_devis"] date_obj = ( datetime.fromisoformat(date_str) if isinstance(date_str, str) else date_str ) doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f"📅 Date: {date_obj.date()}") if "statut" in devis_data: nouveau_statut = devis_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f"📊 Statut: {statut_actuel} → {nouveau_statut}") if champs_modifies: doc.Write() # ÉTAPE 3 : MODIFICATION INTELLIGENTE DES LIGNES if "lignes" in devis_data and devis_data["lignes"] is not None: logger.info(f"🔄 Modification intelligente des lignes...") nouvelles_lignes = devis_data["lignes"] nb_nouvelles = len(nouvelles_lignes) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # Compter existantes nb_existantes = 0 index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_existantes += 1 index += 1 except: break logger.info( f"📊 {nb_existantes} existantes → {nb_nouvelles} nouvelles" ) # MODIFIER EXISTANTES nb_a_modifier = min(nb_existantes, nb_nouvelles) for idx in range(1, nb_a_modifier + 1): ligne_data = nouvelles_lignes[idx - 1] ligne_p = factory_lignes.List(idx) ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") ligne.Read() 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() try: ligne.WriteDefault() except: pass quantite = float(ligne_data["quantite"]) try: ligne.SetDefaultArticleReference( ligne_data["article_code"], quantite ) except: try: ligne.SetDefaultArticle(article_obj, quantite) except: ligne.DL_Design = ligne_data.get("designation", "") ligne.DL_Qte = quantite if ligne_data.get("prix_unitaire_ht"): ligne.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) if ligne_data.get("remise_pourcentage", 0) > 0: try: ligne.DL_Remise01REM_Valeur = float( ligne_data["remise_pourcentage"] ) ligne.DL_Remise01REM_Type = 0 except: pass ligne.Write() logger.debug(f" ✅ Ligne {idx} modifiée") # AJOUTER MANQUANTES if nb_nouvelles > nb_existantes: for idx in range(nb_existantes, nb_nouvelles): ligne_data = nouvelles_lignes[idx] 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() ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) 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 if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) 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.debug(f" ✅ Ligne {idx + 1} ajoutée") # SUPPRIMER EN TROP elif nb_nouvelles < nb_existantes: for idx in range(nb_existantes, nb_nouvelles, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() try: ligne.Remove() except AttributeError: ligne.WriteDefault() except: pass except: pass champs_modifies.append("lignes") # VALIDATION logger.info("💾 Validation finale...") doc.Write() import time time.sleep(1) 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} ✅✅✅") logger.info(f"💰 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 technique: {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) ✅ CORRECTION: Gestion identique aux devis - Prix automatique depuis Sage si non fourni - Prix = 0 toléré (articles de service, etc.) - Remise optionnelle """ 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 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}€") # ✅ TOLÉRER prix = 0 (articles de service, etc.) if prix_sage == 0: logger.warning( f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" ) # 📍 ÉTAPE 3: Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) # ✅ SetDefaultArticleReference quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) logger.info( f"✅ Article associé via SetDefaultArticleReference" ) except Exception as e: logger.warning( f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") ligne_obj.DL_Design = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") # ⚙️ ÉTAPE 4: Vérifier le prix automatique 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 and prix_sage > 0: # Pas de prix auto mais prix Sage existe ligne_obj.DL_PrixUnitaire = float(prix_sage) logger.info(f"💰 Prix Sage forcé: {prix_sage}€") elif prix_auto > 0: # Prix auto OK logger.info(f"💰 Prix auto conservé: {prix_auto}€") # ✅ SINON: Prix reste à 0 (toléré pour services, etc.) 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 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}€" ) except Exception as e: logger.warning(f"⚠️ Impossible de vérifier: {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)}") # ============================================================================ # CORRECTIF CRITIQUE : Modification devis/commandes # ============================================================================ def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: """ ✏️ Modification commande - VERSION SIMPLIFIÉE 🔧 STRATÉGIE REMPLACEMENT LIGNES: - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - Utilise .Remove() pour la suppression - Simple, robuste, prévisible """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION COMMANDE {numero} ===") # ======================================== # ÉTAPE 1 : CHARGER LE DOCUMENT # ======================================== logger.info("📂 Chargement document...") factory = self.cial.FactoryDocumentVente persist = None # Chercher le document for type_test in [10, 3, settings.SAGE_TYPE_BON_COMMANDE]: try: persist_test = factory.ReadPiece(type_test, numero) if persist_test: persist = persist_test logger.info(f" ✅ Document trouvé (type={type_test})") break except: continue if not persist: raise ValueError(f"❌ Commande {numero} INTROUVABLE") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() statut_actuel = getattr(doc, "DO_Statut", 0) type_reel = getattr(doc, "DO_Type", -1) logger.info(f" 📊 Type={type_reel}, Statut={statut_actuel}") # ======================================== # ÉTAPE 2 : VÉRIFIER CLIENT INITIAL # ======================================== client_code_initial = "" try: client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_code_initial = getattr(client_obj, "CT_Num", "").strip() logger.info(f" 👤 Client initial: {client_code_initial}") else: logger.error(" ❌ Objet Client NULL à l'état initial !") except Exception as e: logger.error(f" ❌ Erreur lecture client initial: {e}") if not client_code_initial: raise ValueError("❌ Client introuvable dans le document") # Compter les lignes initiales nb_lignes_initial = 0 try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_lignes_initial += 1 index += 1 except: break logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") # ======================================== # ÉTAPE 3 : DÉTERMINER LES MODIFICATIONS # ======================================== champs_modifies = [] modif_date = "date_commande" in commande_data modif_statut = "statut" in commande_data modif_ref = "reference" in commande_data modif_lignes = ( "lignes" in commande_data and commande_data["lignes"] is not None ) logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ======================================== # ÉTAPE 4 : TEST WRITE() BASIQUE # ======================================== logger.info("🧪 Test Write() basique (sans modification)...") try: doc.Write() logger.info(" ✅ Write() basique OK") doc.Read() # Vérifier que le client est toujours là client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_apres = getattr(client_obj, "CT_Num", "") if client_apres == client_code_initial: logger.info(f" ✅ Client préservé: {client_apres}") else: logger.error( f" ❌ Client a changé: {client_code_initial} → {client_apres}" ) else: logger.error(" ❌ Client devenu NULL après Write() basique") except Exception as e: logger.error(f" ❌ Write() basique ÉCHOUE: {e}") logger.error(f" ❌ ABANDON: Le document est VERROUILLÉ") raise ValueError( f"Document verrouillé, impossible de modifier: {e}" ) # ======================================== # ÉTAPE 5 : MODIFICATIONS SIMPLES (pas de lignes) # ======================================== if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: logger.info(" 📅 Modification date...") import pywintypes date_str = commande_data["date_commande"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") if modif_statut: logger.info(" 📊 Modification statut...") nouveau_statut = commande_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") if modif_ref: logger.info(" 📖 Modification référence...") try: doc.DO_Ref = commande_data["reference"] champs_modifies.append("reference") logger.info( f" ✅ Référence définie: {commande_data['reference']}" ) except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") # Écrire sans réassocier le client logger.info(" 💾 Write() sans réassociation client...") try: doc.Write() logger.info(" ✅ Write() réussi") doc.Read() # Vérifier client client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_apres = getattr(client_obj, "CT_Num", "") if client_apres == client_code_initial: logger.info(f" ✅ Client préservé: {client_apres}") else: logger.error( f" ❌ Client perdu: {client_code_initial} → {client_apres}" ) except Exception as e: error_msg = str(e) try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_msg = f"{sage_error.Description} (Code: {sage_error.Number})" except: pass logger.error(f" ❌ Write() échoue: {error_msg}") raise ValueError(f"Sage refuse: {error_msg}") # ======================================== # ÉTAPE 6 : REMPLACEMENT COMPLET DES LIGNES # ======================================== elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") nouvelles_lignes = commande_data["lignes"] nb_nouvelles = len(nouvelles_lignes) logger.info( f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles lignes" ) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # ============================================ # SOUS-ÉTAPE 1 : SUPPRIMER TOUTES LES LIGNES EXISTANTES # ============================================ if nb_lignes_initial > 0: logger.info( f" 🗑️ Suppression de {nb_lignes_initial} lignes existantes..." ) # Supprimer depuis la fin pour éviter les problèmes d'index for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() # ✅ Utiliser .Remove() comme indiqué ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: logger.warning( f" ⚠️ Impossible de supprimer ligne {idx}: {e}" ) # Continuer même si une suppression échoue logger.info(" ✅ Toutes les lignes existantes supprimées") # ============================================ # SOUS-ÉTAPE 2 : AJOUTER LES NOUVELLES LIGNES # ============================================ logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") for idx, ligne_data in enumerate(nouvelles_lignes, 1): logger.info( f" --- Ligne {idx}/{nb_nouvelles}: {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 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" ) quantite = float(ligne_data["quantite"]) # Associer article 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 # Prix if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) # Remise 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 # Écrire la ligne ligne_obj.Write() logger.info(f" ✅ Ligne {idx} ajoutée") logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") # Écrire le document logger.info(" 💾 Write() document après remplacement lignes...") doc.Write() logger.info(" ✅ Document écrit") doc.Read() # Vérifier client client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_apres = getattr(client_obj, "CT_Num", "") logger.info(f" 👤 Client après remplacement: {client_apres}") else: logger.error(" ❌ Client NULL après remplacement") champs_modifies.append("lignes") # ======================================== # ÉTAPE 7 : RELECTURE ET RETOUR # ======================================== logger.info("📊 Relecture finale...") import time time.sleep(1) doc.Read() # Vérifier client final client_obj_final = getattr(doc, "Client", None) if client_obj_final: client_obj_final.Read() client_final = getattr(client_obj_final, "CT_Num", "") else: client_final = "" total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅ ✅ ✅ SUCCÈS: {numero} modifiée ✅ ✅ ✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") logger.info(f" 👤 Client final: {client_final}") logger.info(f" 📝 Champs modifiés: {champs_modifies}") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, "statut": getattr(doc, "DO_Statut", 0), "client_code": client_final, } except ValueError as e: logger.error(f"❌ ERREUR MÉTIER: {e}") raise except Exception as e: logger.error(f"❌ ERREUR TECHNIQUE: {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} (Code: {err.Number})" ) except: pass raise RuntimeError(f"Erreur Sage: {error_message}") def creer_livraison_enrichi(self, livraison_data: dict) -> Dict: """ ➕ Création d'une livraison (type 30 = Bon de livraison) ✅ Gestion identique aux commandes/devis """ if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info( f"🚀 Début création livraison pour client {livraison_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 LIVRAISON (type 30) process = self.cial.CreateProcess_Document( settings.SAGE_TYPE_BON_LIVRAISON ) doc = process.Document try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass logger.info("📄 Document livraison créé") # Date import pywintypes if isinstance(livraison_data["date_livraison"], str): date_obj = datetime.fromisoformat( livraison_data["date_livraison"] ) elif isinstance(livraison_data["date_livraison"], date): date_obj = datetime.combine( livraison_data["date_livraison"], 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( livraison_data["client"]["code"] ) if not persist_client: raise ValueError( f"Client {livraison_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 {livraison_data['client']['code']} associé") # Référence externe (optionnelle) if livraison_data.get("reference"): try: doc.DO_Ref = livraison_data["reference"] logger.info(f"📖 Référence: {livraison_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(livraison_data['lignes'])} lignes..." ) for idx, ligne_data in enumerate(livraison_data["lignes"], 1): logger.info( f"--- Ligne {idx}: {ligne_data['article_code']} ---" ) # Charger l'article RÉEL depuis Sage 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() # 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"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" ) # Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) logger.info( f"✅ Article associé via SetDefaultArticleReference" ) except Exception as e: logger.warning( f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") ligne_obj.DL_Design = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") # Vérifier le prix automatique prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) logger.info(f"💰 Prix auto chargé: {prix_auto}€") # 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: ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") elif prix_auto == 0 and prix_sage > 0: ligne_obj.DL_PrixUnitaire = float(prix_sage) logger.info(f"💰 Prix Sage forcé: {prix_sage}€") elif prix_auto > 0: 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}") # Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") # Validation doc.Write() process.Process() if transaction_active: self.cial.CptaApplication.CommitTrans() # Récupération numéro time.sleep(2) numero_livraison = None try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_livraison = getattr(doc_result, "DO_Piece", "") except: pass if not numero_livraison: numero_livraison = getattr(doc, "DO_Piece", "") # Relecture factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece( settings.SAGE_TYPE_BON_LIVRAISON, numero_livraison ) 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"✅✅✅ LIVRAISON CRÉÉE: {numero_livraison} - {total_ttc}€ TTC ✅✅✅" ) return { "numero_livraison": numero_livraison, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": len(livraison_data["lignes"]), "client_code": livraison_data["client"]["code"], "date_livraison": 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 livraison: {e}", exc_info=True) raise RuntimeError(f"Échec création livraison: {str(e)}") def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: """ ✏️ Modification d'une livraison existante 🔧 STRATÉGIE REMPLACEMENT LIGNES: - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - Utilise .Remove() pour la suppression """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION LIVRAISON {numero} ===") # ======================================== # ÉTAPE 1 : CHARGER LE DOCUMENT # ======================================== logger.info("📂 Chargement document...") factory = self.cial.FactoryDocumentVente persist = None # Chercher le document for type_test in [30, settings.SAGE_TYPE_BON_LIVRAISON]: try: persist_test = factory.ReadPiece(type_test, numero) if persist_test: persist = persist_test logger.info(f" ✅ Document trouvé (type={type_test})") break except: continue if not persist: raise ValueError(f"❌ Livraison {numero} INTROUVABLE") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() statut_actuel = getattr(doc, "DO_Statut", 0) logger.info(f" 📊 Statut={statut_actuel}") # Vérifier qu'elle n'est pas transformée if statut_actuel == 5: raise ValueError(f"La livraison {numero} a déjà été transformée") if statut_actuel == 6: raise ValueError(f"La livraison {numero} est annulée") # Compter les lignes initiales nb_lignes_initial = 0 try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_lignes_initial += 1 index += 1 except: break logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") # ======================================== # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS # ======================================== champs_modifies = [] modif_date = "date_livraison" in livraison_data modif_statut = "statut" in livraison_data modif_ref = "reference" in livraison_data modif_lignes = ( "lignes" in livraison_data and livraison_data["lignes"] is not None ) logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ======================================== # ÉTAPE 3 : MODIFICATIONS SIMPLES # ======================================== if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: import pywintypes date_str = livraison_data["date_livraison"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") if modif_statut: nouveau_statut = livraison_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") if modif_ref: try: doc.DO_Ref = livraison_data["reference"] champs_modifies.append("reference") logger.info(f" ✅ Référence définie") except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") doc.Write() logger.info(" ✅ Write() réussi") # ======================================== # ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES # ======================================== elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") nouvelles_lignes = livraison_data["lignes"] nb_nouvelles = len(nouvelles_lignes) logger.info( f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles" ) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # SUPPRESSION TOUTES LES LIGNES if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: logger.warning( f" ⚠️ Erreur suppression ligne {idx}: {e}" ) logger.info(" ✅ Toutes les lignes supprimées") # AJOUT NOUVELLES LIGNES logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") for idx, ligne_data in enumerate(nouvelles_lignes, 1): 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() ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) 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 if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) 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.debug(f" ✅ Ligne {idx} ajoutée") logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") doc.Write() champs_modifies.append("lignes") # ======================================== # ÉTAPE 5 : RELECTURE ET RETOUR # ======================================== import time time.sleep(1) doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅✅✅ LIVRAISON MODIFIÉE: {numero} ✅✅✅") logger.info(f" 💰 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 technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") def creer_avoir_enrichi(self, avoir_data: dict) -> Dict: """ ➕ Création d'un avoir (type 50 = Bon d'avoir) ✅ Gestion identique aux commandes/devis/livraisons """ if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info( f"🚀 Début création avoir pour client {avoir_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 AVOIR (type 50) process = self.cial.CreateProcess_Document( settings.SAGE_TYPE_BON_AVOIR ) doc = process.Document try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass logger.info("📄 Document avoir créé") # Date import pywintypes if isinstance(avoir_data["date_avoir"], str): date_obj = datetime.fromisoformat(avoir_data["date_avoir"]) elif isinstance(avoir_data["date_avoir"], date): date_obj = datetime.combine( avoir_data["date_avoir"], 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( avoir_data["client"]["code"] ) if not persist_client: raise ValueError( f"Client {avoir_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 {avoir_data['client']['code']} associé") # Référence externe (optionnelle) if avoir_data.get("reference"): try: doc.DO_Ref = avoir_data["reference"] logger.info(f"📖 Référence: {avoir_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(avoir_data['lignes'])} lignes...") for idx, ligne_data in enumerate(avoir_data["lignes"], 1): logger.info( f"--- Ligne {idx}: {ligne_data['article_code']} ---" ) # Charger l'article RÉEL depuis Sage 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() # 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"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" ) # Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) logger.info( f"✅ Article associé via SetDefaultArticleReference" ) except Exception as e: logger.warning( f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") ligne_obj.DL_Design = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") # Vérifier le prix automatique prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) logger.info(f"💰 Prix auto chargé: {prix_auto}€") # 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: ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") elif prix_auto == 0 and prix_sage > 0: ligne_obj.DL_PrixUnitaire = float(prix_sage) logger.info(f"💰 Prix Sage forcé: {prix_sage}€") elif prix_auto > 0: 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}") # Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") # Validation doc.Write() process.Process() if transaction_active: self.cial.CptaApplication.CommitTrans() # Récupération numéro time.sleep(2) numero_avoir = None try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_avoir = getattr(doc_result, "DO_Piece", "") except: pass if not numero_avoir: numero_avoir = getattr(doc, "DO_Piece", "") # Relecture factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece( settings.SAGE_TYPE_BON_AVOIR, numero_avoir ) 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"✅✅✅ AVOIR CRÉÉ: {numero_avoir} - {total_ttc}€ TTC ✅✅✅" ) return { "numero_avoir": numero_avoir, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": len(avoir_data["lignes"]), "client_code": avoir_data["client"]["code"], "date_avoir": 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 avoir: {e}", exc_info=True) raise RuntimeError(f"Échec création avoir: {str(e)}") def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: """ ✏️ Modification d'un avoir existant 🔧 STRATÉGIE REMPLACEMENT LIGNES: - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - Utilise .Remove() pour la suppression """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION AVOIR {numero} ===") # ÉTAPE 1 : CHARGER LE DOCUMENT logger.info("📂 Chargement document...") factory = self.cial.FactoryDocumentVente persist = None # Chercher le document for type_test in [50, settings.SAGE_TYPE_BON_AVOIR]: try: persist_test = factory.ReadPiece(type_test, numero) if persist_test: persist = persist_test logger.info(f" ✅ Document trouvé (type={type_test})") break except: continue if not persist: raise ValueError(f"❌ Avoir {numero} INTROUVABLE") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() statut_actuel = getattr(doc, "DO_Statut", 0) logger.info(f" 📊 Statut={statut_actuel}") # Vérifier qu'il n'est pas transformé if statut_actuel == 5: raise ValueError(f"L'avoir {numero} a déjà été transformé") if statut_actuel == 6: raise ValueError(f"L'avoir {numero} est annulé") # Compter les lignes initiales nb_lignes_initial = 0 try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_lignes_initial += 1 index += 1 except: break logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS champs_modifies = [] modif_date = "date_avoir" in avoir_data modif_statut = "statut" in avoir_data modif_ref = "reference" in avoir_data modif_lignes = ( "lignes" in avoir_data and avoir_data["lignes"] is not None ) logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ÉTAPE 3 : MODIFICATIONS SIMPLES if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: import pywintypes date_str = avoir_data["date_avoir"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") if modif_statut: nouveau_statut = avoir_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") if modif_ref: try: doc.DO_Ref = avoir_data["reference"] champs_modifies.append("reference") logger.info(f" ✅ Référence définie") except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") doc.Write() logger.info(" ✅ Write() réussi") # ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") nouvelles_lignes = avoir_data["lignes"] nb_nouvelles = len(nouvelles_lignes) logger.info( f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles" ) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # SUPPRESSION TOUTES LES LIGNES if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: logger.warning( f" ⚠️ Erreur suppression ligne {idx}: {e}" ) logger.info(" ✅ Toutes les lignes supprimées") # AJOUT NOUVELLES LIGNES logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") for idx, ligne_data in enumerate(nouvelles_lignes, 1): 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() ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) 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 if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) 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.debug(f" ✅ Ligne {idx} ajoutée") logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") doc.Write() champs_modifies.append("lignes") # ÉTAPE 5 : RELECTURE ET RETOUR import time time.sleep(1) doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅✅✅ AVOIR MODIFIÉ: {numero} ✅✅✅") logger.info(f" 💰 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 technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") def creer_facture_enrichi(self, facture_data: dict) -> Dict: """ ➕ Création d'une facture (type 60 = Facture) ⚠️ ATTENTION: Les factures ont souvent des champs obligatoires supplémentaires selon la configuration Sage (DO_CodeJournal, DO_Souche, DO_Regime, etc.) ✅ Gestion identique aux autres documents + champs spécifiques factures """ if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info( f"🚀 Début création facture pour client {facture_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 FACTURE (type 60) process = self.cial.CreateProcess_Document( settings.SAGE_TYPE_FACTURE ) doc = process.Document try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass logger.info("📄 Document facture créé") # Date import pywintypes if isinstance(facture_data["date_facture"], str): date_obj = datetime.fromisoformat(facture_data["date_facture"]) elif isinstance(facture_data["date_facture"], date): date_obj = datetime.combine( facture_data["date_facture"], 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( facture_data["client"]["code"] ) if not persist_client: raise ValueError( f"Client {facture_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 {facture_data['client']['code']} associé") # Référence externe (optionnelle) if facture_data.get("reference"): try: doc.DO_Ref = facture_data["reference"] logger.info(f"📖 Référence: {facture_data['reference']}") except: pass # ============================================ # CHAMPS SPÉCIFIQUES FACTURES # ============================================ logger.info("⚙️ Configuration champs spécifiques factures...") # Code journal (si disponible) try: if hasattr(doc, "DO_CodeJournal"): # Essayer de récupérer le code journal par défaut try: param_societe = ( self.cial.CptaApplication.ParametreSociete ) journal_defaut = getattr( param_societe, "P_CodeJournalVte", "VTE" ) doc.DO_CodeJournal = journal_defaut logger.info(f" ✅ Code journal: {journal_defaut}") except: doc.DO_CodeJournal = "VTE" logger.info(" ✅ Code journal: VTE (défaut)") except Exception as e: logger.debug(f" ⚠️ Code journal: {e}") # Souche (si disponible) try: if hasattr(doc, "DO_Souche"): doc.DO_Souche = 0 logger.debug(" ✅ Souche: 0 (défaut)") except: pass # Régime (si disponible) try: if hasattr(doc, "DO_Regime"): doc.DO_Regime = 0 logger.debug(" ✅ Régime: 0 (défaut)") except: pass # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle logger.info(f"📦 Ajout de {len(facture_data['lignes'])} lignes...") for idx, ligne_data in enumerate(facture_data["lignes"], 1): logger.info( f"--- Ligne {idx}: {ligne_data['article_code']} ---" ) # Charger l'article RÉEL depuis Sage 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() # 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"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" ) # Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) logger.info( f"✅ Article associé via SetDefaultArticleReference" ) except Exception as e: logger.warning( f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") ligne_obj.DL_Design = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") # Vérifier le prix automatique prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) logger.info(f"💰 Prix auto chargé: {prix_auto}€") # 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: ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") elif prix_auto == 0 and prix_sage > 0: ligne_obj.DL_PrixUnitaire = float(prix_sage) logger.info(f"💰 Prix Sage forcé: {prix_sage}€") elif prix_auto > 0: 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}") # Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") # ============================================ # VALIDATION FINALE # ============================================ logger.info("💾 Validation facture...") # Réassocier le client avant validation (critique pour factures) try: doc.SetClient(client_obj) logger.debug(" ✅ Client réassocié avant validation") except: try: doc.SetDefaultClient(client_obj) except: pass doc.Write() logger.info("🔄 Process()...") process.Process() if transaction_active: self.cial.CptaApplication.CommitTrans() logger.info("✅ Transaction committée") # Récupération numéro time.sleep(2) numero_facture = None try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_facture = getattr(doc_result, "DO_Piece", "") except: pass if not numero_facture: numero_facture = getattr(doc, "DO_Piece", "") if not numero_facture: raise RuntimeError("Numéro facture vide après création") logger.info(f"📄 Numéro facture: {numero_facture}") # Relecture factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece( settings.SAGE_TYPE_FACTURE, numero_facture ) 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"✅✅✅ FACTURE CRÉÉE: {numero_facture} - {total_ttc}€ TTC ✅✅✅" ) return { "numero_facture": numero_facture, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": len(facture_data["lignes"]), "client_code": facture_data["client"]["code"], "date_facture": 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 facture: {e}", exc_info=True) raise RuntimeError(f"Échec création facture: {str(e)}") def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: """ ✏️ Modification d'une facture existante ⚠️ ATTENTION: Les factures comptabilisées peuvent être verrouillées par Sage 🔧 STRATÉGIE REMPLACEMENT LIGNES: - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - Utilise .Remove() pour la suppression """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION FACTURE {numero} ===") # ÉTAPE 1 : CHARGER LE DOCUMENT logger.info("📂 Chargement document...") factory = self.cial.FactoryDocumentVente persist = None # Chercher le document for type_test in [60, 5, settings.SAGE_TYPE_FACTURE]: try: persist_test = factory.ReadPiece(type_test, numero) if persist_test: persist = persist_test logger.info(f" ✅ Document trouvé (type={type_test})") break except: continue if not persist: raise ValueError(f"❌ Facture {numero} INTROUVABLE") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() statut_actuel = getattr(doc, "DO_Statut", 0) logger.info(f" 📊 Statut={statut_actuel}") # Vérifier qu'elle n'est pas transformée ou annulée if statut_actuel == 5: raise ValueError(f"La facture {numero} a déjà été transformée") if statut_actuel == 6: raise ValueError(f"La facture {numero} est annulée") # Vérifier client initial client_code_initial = "" try: client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_code_initial = getattr(client_obj, "CT_Num", "").strip() logger.info(f" 👤 Client initial: {client_code_initial}") except Exception as e: logger.error(f" ❌ Erreur lecture client initial: {e}") if not client_code_initial: raise ValueError("❌ Client introuvable dans le document") # Compter les lignes initiales nb_lignes_initial = 0 try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_lignes_initial += 1 index += 1 except: break logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS champs_modifies = [] modif_date = "date_facture" in facture_data modif_statut = "statut" in facture_data modif_ref = "reference" in facture_data modif_lignes = ( "lignes" in facture_data and facture_data["lignes"] is not None ) logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ÉTAPE 3 : TEST WRITE() BASIQUE logger.info("🧪 Test Write() basique (sans modification)...") try: doc.Write() logger.info(" ✅ Write() basique OK") doc.Read() except Exception as e: logger.error(f" ❌ Write() basique ÉCHOUE: {e}") logger.error(f" ❌ ABANDON: Le document est VERROUILLÉ") raise ValueError( f"Document verrouillé, impossible de modifier: {e}" ) # ÉTAPE 4 : MODIFICATIONS SIMPLES if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: import pywintypes date_str = facture_data["date_facture"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") if modif_statut: nouveau_statut = facture_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") if modif_ref: try: doc.DO_Ref = facture_data["reference"] champs_modifies.append("reference") logger.info(f" ✅ Référence définie") except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") doc.Write() logger.info(" ✅ Write() réussi") # ÉTAPE 5 : REMPLACEMENT COMPLET LIGNES elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") nouvelles_lignes = facture_data["lignes"] nb_nouvelles = len(nouvelles_lignes) logger.info( f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles" ) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # SUPPRESSION TOUTES LES LIGNES if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: logger.warning( f" ⚠️ Erreur suppression ligne {idx}: {e}" ) logger.info(" ✅ Toutes les lignes supprimées") # AJOUT NOUVELLES LIGNES logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") for idx, ligne_data in enumerate(nouvelles_lignes, 1): 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() ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) 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 if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) 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.debug(f" ✅ Ligne {idx} ajoutée") logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") doc.Write() champs_modifies.append("lignes") # ÉTAPE 6 : RELECTURE ET RETOUR import time time.sleep(1) doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅✅✅ FACTURE MODIFIÉE: {numero} ✅✅✅") logger.info(f" 💰 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 technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}")